This commit is contained in:
2026-04-21 19:00:53 +05:45
commit ebb12e6ab7
20 changed files with 1391 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.idea/
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# 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/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# dotenv
.env
.env.development
.env.production
.env.local
.env.staging
# virtualenv
.venv
venv/
ENV/
.vscode
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.DS_Store
*.sqlite3
media/
*.pyc
*.db
*.pid
# Ignore Django Migrations in Development if you are working on team
# Only for Development only
# **/migrations/**
# !**/migrations
# !**/migrations/__init__.py
# Ignore Vim swap files
*.sw[op]
*~
static/
+17
View File
@@ -0,0 +1,17 @@
# Template for fastapi backend for aayutech projects
## Libraries used
1. Fastapi
2. Alembic
3. Postgresql with sqlalchemy
4. Pydantic
## Features
1. Base user and /login using Oauth2
2. Api logging in audit table table
3. Model base class including soft delete, created_at, updated_at
4. Model auditing by default
5. Static files routing
## TODO:
5. Optional multitenant
+149
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_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(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 = driver://user:pass@localhost/dbname
[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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+91
View File
@@ -0,0 +1,91 @@
from dotenv import load_dotenv
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy_declarative_extensions import register_alembic_events
register_alembic_events(schemas = True, roles = True, grants = True, rows = True)
import os
load_dotenv()
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
DATABASE_URL = os.getenv('DATABASE_URL', '')
# 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)
config.set_main_option('sqlalchemy.url', DATABASE_URL)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from app.models import Base
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 = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
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
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"}
+106
View File
@@ -0,0 +1,106 @@
"""Init
Revision ID: 34ec41e7908b
Revises:
Create Date: 2026-04-21 18:48:24.829721
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '34ec41e7908b'
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."""
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('type', sa.String(), nullable=False),
sa.Column('is_archived', sa.Boolean(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
)
# ### commands auto generated by Alembic - please adjust! ###
op.execute("""CREATE FUNCTION "public_users_audit_insert"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "users_audit" ("audit_operation", "audit_timestamp", "audit_current_user", "id", "username", "full_name", "email", "type", "is_archived", "hashed_password")
SELECT 'I', now(), current_user, NEW."id", NEW."username", NEW."full_name", NEW."email", NEW."type", NEW."is_archived", NEW."hashed_password";
RETURN NULL;
END
$$;""")
op.execute("""CREATE FUNCTION "public_users_audit_update"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "users_audit" ("audit_operation", "audit_timestamp", "audit_current_user", "id", "username", "full_name", "email", "type", "is_archived", "hashed_password")
SELECT 'U', now(), current_user, NEW."id", NEW."username", NEW."full_name", NEW."email", NEW."type", NEW."is_archived", NEW."hashed_password";
RETURN NULL;
END
$$;""")
op.execute("""CREATE FUNCTION "public_users_audit_delete"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO "users_audit" ("audit_operation", "audit_timestamp", "audit_current_user", "id", "username", "full_name", "email", "type", "is_archived", "hashed_password")
SELECT 'D', now(), current_user, OLD."id", OLD."username", OLD."full_name", OLD."email", OLD."type", OLD."is_archived", OLD."hashed_password";
RETURN NULL;
END
$$;""")
op.execute("""CREATE TRIGGER "public_users_audit_insert" AFTER INSERT ON "users" FOR EACH ROW EXECUTE PROCEDURE "public_users_audit_insert"();""")
op.execute("""CREATE TRIGGER "public_users_audit_update" AFTER UPDATE ON "users" FOR EACH ROW EXECUTE PROCEDURE "public_users_audit_update"();""")
op.execute("""CREATE TRIGGER "public_users_audit_delete" AFTER DELETE ON "users" FOR EACH ROW EXECUTE PROCEDURE "public_users_audit_delete"();""")
op.create_table('api_log',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('api_key', sa.Uuid(), nullable=True),
sa.Column('ip_address', postgresql.INET(), nullable=False),
sa.Column('path', sa.String(), nullable=False),
sa.Column('method', sa.String(), nullable=False),
sa.Column('status_code', sa.Integer(), nullable=False),
sa.Column('request_body', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('response_body', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('query_params', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('path_params', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('process_time', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_api_log'))
)
op.create_index('ix_unique_active_email', 'users', ['email'], unique=True, postgresql_where='is_archived = false')
op.create_index('ix_unique_active_username', 'users', ['username'], unique=True, postgresql_where='is_archived = false')
op.create_table('users_audit',
sa.Column('audit_pk', sa.Integer(), nullable=False),
sa.Column('audit_operation', sa.Unicode(length=1), nullable=False),
sa.Column('audit_timestamp', sa.DateTime(timezone=True), nullable=False),
sa.Column('audit_current_user', sa.Unicode(length=64), nullable=False),
sa.Column('id', sa.Uuid(), nullable=True),
sa.Column('username', sa.String(), nullable=True),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('type', sa.String(), nullable=True),
sa.Column('is_archived', sa.Boolean(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('audit_pk', name=op.f('pk_users_audit'))
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users_audit')
op.drop_index('ix_unique_active_username', table_name='users', postgresql_where='is_archived = false')
op.drop_index('ix_unique_active_email', table_name='users', postgresql_where='is_archived = false')
op.drop_table('users')
op.drop_table('api_log')
op.execute("""DROP TRIGGER "public_users_audit_delete" ON "users";""")
op.execute("""DROP TRIGGER "public_users_audit_update" ON "users";""")
op.execute("""DROP TRIGGER "public_users_audit_insert" ON "users";""")
op.execute("""DROP FUNCTION public_users_audit_delete();""")
op.execute("""DROP FUNCTION public_users_audit_update();""")
op.execute("""DROP FUNCTION public_users_audit_insert();""")
# ### end Alembic commands ###
View File
+26
View File
@@ -0,0 +1,26 @@
from pydantic import BaseModel, ConfigDict, ValidationError, EmailStr
import uuid
class UserResponse(BaseModel):
username : str
id : uuid.UUID
email : EmailStr
type : str
model_config = ConfigDict(from_attributes=True)
class CreateUserRequest(BaseModel):
username : str
email : EmailStr
type : str
password : str
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
access_token : str
refresh_token : str
token_type : str
class TokenData(BaseModel):
username : str
scopes : list[str] = []
+185
View File
@@ -0,0 +1,185 @@
"""
Authentication according to the OAuth2 specification.
When user is created it's password is stored in form of hashed_password returned from function hash_password
Authentication flow
Login using username and password, -> returns jwt token if successful
When using other apis that needs authentication, get_current_user is called to get to validate and get the logged in user data
get_current_user -> takes in bearer token and retrives user using oauth2_scheme
"""
import jwt
from datetime import timedelta, datetime
from typing import Annotated
from fastapi import Depends, FastAPI, APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm, SecurityScopes
from pydantic import BaseModel, ConfigDict, ValidationError
from sqlalchemy import select
from pwdlib import PasswordHash
from ..models import Users
from .schemas import *
from ..db.db import db_dependency
from ..exceptions import ExceptionHandlerRoute
#TODO: env file
SECRET_KEY = "17267606688d2ef63beeb906b8dab8448cb8b1e3d147678ed6d69eb9eb147b26"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 120
router = APIRouter(prefix = '/auth', tags = ['Auth'], route_class = ExceptionHandlerRoute)
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='/auth/login',
scopes= {
"me": "Read information about the current user",
"Items": "Read items"
}
)
password_has = PasswordHash.recommended()
"""
Helper functions to encode and decode and retriver user
"""
def verify_password(plain_password, hashed_password):
return password_has.verify(plain_password, hashed_password)
def get_password_hash(password):
return password_has.hash(password)
def get_user(db, username: str):
query = select(Users).where((Users.username == username) | (Users.email == username))
user = db.execute(query).first()
if user:
return user[0]
return None
"""
Takes in the jwt token and returns the user value from database
Throws exceptions if username doesn't exist or password is incorrect
"""
def get_current_user(db: db_dependency, token : Annotated[str, Depends(oauth2_scheme)]) -> Users:
credentials_exceptions = HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Could not validate credientals",
headers = {"WWW-Authenticate": "Bearer"}
)
try:
#decode payload
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise credentials_exceptions
token_data = TokenData(username = username)
except jwt.InvalidTokenError:
raise credentials_exceptions
#get user from userid
user = get_user(db, username = token_data.username)
if user is None:
raise credentials_exceptions
return user
@router.get('/users/me')
def get_curr_user(current_user : Annotated[Users, Depends(get_current_user)]):
return current_user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now() + expires_delta
else:
expire = datetime.now() + timedelta(minutes = 15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(payload = to_encode, key = SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict):
to_encode = data.copy()
expire = datetime.now() + timedelta(days = 7)
data.update({"exp": expire})
return jwt.encode(data, SECRET_KEY, algorithm=ALGORITHM)
@router.post("/refresh")
def refresh_token(refresh_token : str):
try:
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
except Exception as e:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invlid or expired refresh token"
)
if not payload:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invlid or expired refresh token"
)
username = payload.get("sub")
new_access_token = create_access_token({"sub": username})
new_refresh_token = create_refresh_token({"sub": username})
return {
"access_token" : new_access_token,
"refresh_token": new_refresh_token,
}
def authenticate_user(db, username: str, password: str):
#get user, hash password, verify password return true or false
user = get_user(db, username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
#authenticate user using autneticate_user and return token created from create_access_token
@router.post("/login")
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: db_dependency):
#get user from database
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Username or password is incorrect",
headers = {"WWW-Authenticate": "Bearer"}
)
#create access tokens
access_token_expires = timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(data = { "sub": user.username }, expires_delta= access_token_expires)
refresh_token = create_access_token(data = { "sub": user.username })
return Token(
access_token = access_token,
refresh_token = refresh_token,
token_type = "bearer"
)
user_dependency = Annotated[Users, Depends(get_current_user)]
+39
View File
@@ -0,0 +1,39 @@
import json
import uuid
from datetime import datetime, UTC
from starlette.responses import StreamingResponse
from .db.db import get_db
from .models import ApiLog
def write_log(req: Request, res: StreamingResponse, req_body : dict, res_body: str, process_time : float):
db = next(get_db())
try:
res_body = json.loads(res_body)
except Exception:
res_body = None
client_ip = req.client.host
if client_ip == 'testclient':
client_ip = '127.0.0.1'
log = ApiLog(
api_key = uuid.UUID(req.headers.get("x-api-key")) if req.headers.get("x-api-key") else None,
ip_address = client_ip,
path = req.url.path,
method = req.method,
status_code = res.status_code,
request_body = req_body,
response_body = res_body,
query_params = dict(req.query_params),
path_params = req.path_params,
process_time = process_time,
created_at = datetime.now(UTC)
)
db.add(log)
db.commit()
db.close()
View File
+27
View File
@@ -0,0 +1,27 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel, Field, ConfigDict
class FilterParams(BaseModel):
limit : int = Field(100, gt=0, le=100)
offset : int = Field(0, ge=0)
order_by : str = "created_at"
search : str | None = None
tags : list[str] = []
class ListResponseBase(BaseModel):
total : int
offset : int
limit : int
model_config = ConfigDict(from_attributes=True)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
DATABASE_URL : str
TEST_DATABASE_URL : str
FIRST_SUPERUSER : str
FIRST_SUPERUSER_PASSWORD : str
FIRST_SUPERUSER_EMAIL : str
settings = Settings()
+49
View File
@@ -0,0 +1,49 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, with_loader_criteria
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.exc import SQLAlchemyError
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from dotenv import load_dotenv
import os
load_dotenv()
engine = create_engine(os.getenv("DATABASE_URL", ""))
SessionLocal = sessionmaker(autoflush=False, bind=engine)
def safe_commit(db : Session):
try:
db.commit()
except SQLAlchemyError:
db.rollback()
raise
def get_db():
db = SessionLocal()
try:
yield db
except Exception:
db.rollback()
raise
finally:
db.close()
@event.listens_for(SessionLocal, "do_orm_execute")
def _add_filtering_criterial(execute_state):
skip_filter = execute_state.execution_options.get("skip_filter", False)
if execute_state.is_select and not skip_filter:
from ..models.models import AuditMixin
execute_state.statement = execute_state.statement.options(
with_loader_criteria(
AuditMixin,
lambda cls: cls.is_archived.is_(False),
include_aliases = True,
)
)
db_dependency = Annotated[Session, Depends(get_db)]
+122
View File
@@ -0,0 +1,122 @@
"""
This file defines and handles all the errors
The ExceptionHandlerRoute class is used by all the routes
"""
from enum import Enum
from typing import Any, Coroutine, Type
from fastapi import Request, Response
from fastapi.routing import APIRoute
from pydantic import BaseModel, ConfigDict
from collections.abc import Callable
from sqlalchemy.exc import NoResultFound, SQLAlchemyError
class ProblemType(str, Enum):
ITEM_NOT_FOUND = 'ITEM_NOT_FOUND'
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS'
UNIQUE_CONSTRAINT = 'UNIQUE_CONSTRAINT'
INVALID_REQUEST = 'INVALID_REQUEST'
BLANK = 'BLANK'
class ProblemDetail(BaseModel):
""" RFC 7807 Problem Details for HTTP APIs """
type : ProblemType = ProblemType.BLANK
status : int | None = None
title : str | None = None
detail : str | None = None
model_config = ConfigDict(from_attributes=True)
class ItemNotFoundException(Exception):
def __init__(self, model: Type, key : Any = ""):
self.model = model.__name__
self.key = str(key)
class ExceptionHandlerRoute(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
try:
return await original_route_handler(request)
except SQLAlchemyError as exc:
problem = ProblemDetail(status = 422)
if type(exc) == NoResultFound:
problem.type = ProblemType.ITEM_NOT_FOUND
problem.title = f'{exc.model} Not Found'
problem.detail = { 'key' : exc.key}
problem.status = 404
elif type(exc.orig) == UniqueViolation:
problem.detail = str(exc.orig).split('\n')[1].split(': ')[1]
problem.type = ProblemType.UNIQUE_VALIDATION
problem.title = 'Unique violation'
elif type(exc.orig) == ForeignKeyViolation:
problem.type = ProblemType.FOREIGN_KEY_VIOLATION
problem.title = 'Foreign Key Violation'
problem.detail = str(exc.orig).split('\n')[1].split(': ')[1]
return JSONResponse(
status_code = problem.status,
content = problem.model_dump(exclude_none = True),
headers = {'Content-Type': 'application/problem+json'}
)
raise HTTPException(status_code = 422, detail = detail)
except Exception as exc:
problem = ProblemDetail(status = 500)
problem.title = 'Internal Server Error please report the bug'
problem.type = ProblemType.INTERNAL_SERVER_ERROR
problem.detail = str(exc)
if type(exc) == ItemNotFoundException:
problem.type = ProblemType.ITEM_NOT_FOUND
problem.title = f'{exc.model} Not Found'
problem.detail = { 'key' : exc.key}
problem.status = 404
if type(exc) == RequestValidationError:
problem.type = ProblemType.INVALID_REQUEST
problem.title = "Input validation error"
problem.detail = exc.errors()
problem.status = 422
if type(exc) == HTTPException:
print(exc.status_code)
problem.title = "Credientials error"
problem.detail = exc.detail
problem.status = exc.status_code
if problem.status == 401:
problem.type = ProblemType.INVALID_CREDENTIALS
return JSONResponse(
status_code = problem.status,
content = problem.model_dump(exclude_none = True),
headers = {'Content-Type': 'application/problem+json'}
)
return custom_route_handler
+174
View File
@@ -0,0 +1,174 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .exceptions import ExceptionHandlerRoute
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from fastapi.staticfiles import StaticFiles
from .models import Users
from .db.db import engine
from .db.config import settings
from .middlewares import app_middleware
from .auth.views import get_password_hash
from .routes import routers, docs
@asynccontextmanager
async def init_db(app: FastAPI):
with Session(engine) as session:
from sqlalchemy import select, insert
user = session.execute(select(Users).where(Users.username == settings.FIRST_SUPERUSER)).first()
if not user:
session.execute(insert(Users).values(
username = settings.FIRST_SUPERUSER,
hashed_password = get_password_hash(settings.FIRST_SUPERUSER_PASSWORD),
full_name = "admin",
type = 'ADMIN',
email = settings.FIRST_SUPERUSER_EMAIL
))
session.commit()
yield
description = """
# Description
This part of the documentation includes overview of the backend, backend internals and common attributes shared by all the apis.
The backend is written using [FastAPI](https://fastapi.tiangolo.com/), [SQLAlchemy](https://www.sqlalchemy.org/) and [Postgresql](https://www.postgresql.org/).
## Documentation
Redoc with openapi will serve as primary documentation, which will be available publicly in development and staging environment and not for production evnrionment for security reasons.
## Authorization
Authorization is implemended using OAuth2 using password and username, logging with external providers like google, github etc. is not supported.
Each type of user i.e. doctor, patient, staff is required to create their own username and password while registering, so that will be able to login to the system.
The auth token expiry duration is 120 minutes currently
## Common columns
All tables in the backend shares some common columns, these columns will be described here and not in the respective API as it has the same meaning everywhere.
1. __created_at, updated_at__ : represents the datetime when the row was added, updated respectively
2. __create_by_id, updated_by_id__ : foreign key to user's id, it's extracted from the logged in user who created or updated the row.
3. __is_archived__ : It represents if the row is deleted, allows us to implement soft delete.
## Audit
Each table on the backend has it's respective audit table with suffix ___audit__. i.e __appointment__ table has __appointment_audit__.
It stores all the changes done in each row including which operation it was, which columns were changed and what's it's old and new values.
APIs will be created to access the audit for each table.
It's implemented using sqlalchemy_declarative_extention library
## Internals
### Primary, Foreign Key
All primary keys are [UUID](https://en.wikipedia.org/wiki/Universally_unique_identifier), which makes it possible to avoid alot of security risks and implement dynamic features.
Due to this all the foreign keys are also UUIDs
UUIDs can be generated in postgres or in python easy and fast
## Errors
Errors are implemented using [RFC9457](https://www.rfc-editor.org/rfc/rfc9457.html) with some slight modification. Here is a sample error response.
```json
{
"type": "UNIQUE_VALIDATION",
"status": 422,
"detail": "Key (username)=(banana) already exists.",
"title": "Unique violation"
}
```
The type will always be a standard enum value. the status will always be the http status code.
The type, status, title and detail will __always__ exist,
For now in case of pydantic validation error, the raw pydantic message will be set in details
In case of internal server error the type will be 'INTERNAL_SERVER_ERROR' and it's detail will contain the error message.
In this way all the errors will be well documented and handled in a central module, It will also make writing automated tests easier.
# Testing
This section covers the steps for testing and guidelines for reporting and verifying bugs for backend api.
In the future the manual for QA for frontend will also be included here.
## Automated testing
Automated testing is done using fastapi's testclient, pytest and faker
This test creates new database on startup, runs the tests and deletes everything, so the errors i can be reproduceable and deterministic.
The test interacts to the backend only thru API
### Prerequisites
1. Python
2. Postgresql server with a clean database (You can avoid it by using test database on the server)
### Steps
1. Clone the [repository](https://git.aayutech.dev/fourleaf/backend)
2. Create .env file using .env.example as reference
3. Create virtual environment and install packages from requirements.txt
4. Run tests using pytest (lookup pytest's documentation for more information)
5. Create a test inside the tests folder
## Manual testing
Manual testing is done using postman or similar api client.
Url for testing in dev environment is `https://api-dev.aayutech.dev`
### Steps
1. Download postmand collection from the git server (optional)
2. Create and run requests from postman
## Reporting bug
All the bug reporting will be done using kanban `https://project.aayutech.dev`
### Bug criteria
1. All __INTERNAL_SERVER_ERROR__
2. All about:none errors
3. All errors that consists of raw sql messages
4. Errors that doesn't match the description of the api
5. Bug should be able to be reproduced
### Bug reporting steps
1. Create new task in the `Bug Report` column in kanboard
2. Write short description about the bug and how it can be reproduced
3. Optionally take screenshot of postman or terminal where the bug can be seen
4. Add related user as assigne
### Bug resolve steps
1. After a bug is reported a QA or Developer will approve the bug and put it in the `BUG` column.
2. After the bug is verified, the respective developer will fix the bug and put it in the `Read for QA` column.
3. A QA will reverify the bug and put it in the `DONE` column.
"""
app = FastAPI(
version = "0.0.1",
lifespan = init_db,
title = "Fastapi template",
openapi_tags = docs,
description = description
)
for router in routers: app.include_router(router)
app.middleware('http')(app_middleware)
app.add_middleware(
CORSMiddleware
,allow_origins=["*"]
,allow_credentials=True # Set to True if you need to support cookies/authorization headers
,allow_methods=["*"] # Allows all methods (GET, POST, PUT, DELETE, OPTIONS, etc.)
,allow_headers=["*"] # Allows all headers
)
+36
View File
@@ -0,0 +1,36 @@
from datetime import datetime
import time
from collections.abc import Callable
from typing import Annotated
from fastapi import Body, FastAPI, HTTPException, Response, Request
from fastapi.exceptions import RequestValidationError as exc
from fastapi.routing import APIRoute
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
from psycopg.errors import UniqueViolation
from starlette.concurrency import iterate_in_threadpool
from starlette.background import BackgroundTask
from .background import write_log
async def app_middleware(request: Request, call_next):
try:
req_body = await request.json()
except Exception:
req_body = None
start_time = time.perf_counter()
response = await call_next(request)
process_time = time.perf_counter() - start_time
if request.url.path.split('/')[1] != 'static':
res_body = [section async for section in response.body_iterator]
response.body_iterator = iterate_in_threadpool(iter(res_body))
if res_body != None and len(res_body) > 0:
res_body = res_body[0].decode()
else:
res_body = True
#add the background task to the background response object to queue the job
response.background = BackgroundTask(write_log, request, response, req_body, res_body, process_time)
return response
+93
View File
@@ -0,0 +1,93 @@
from datetime import datetime
from typing import NewType
from sqlalchemy import MetaData, Uuid, Index
from sqlalchemy_declarative_extensions import declarative_database
from sqlalchemy_declarative_extensions.audit import audit
from sqlalchemy.orm import declarative_base, declared_attr, Mapped, mapped_column
from sqlalchemy.dialects.postgresql import INET, JSONB, ARRAY
import uuid
nameing_metadata = MetaData(naming_convention= {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
})
nstr30 = NewType("nstr30", str)
nstr50 = NewType("nstr50", str)
nstr100 = NewType("nstr100", str)
nstr255 = NewType("nstr255", str)
tz_date = NewType("tz_date", datetime)
Base = declarative_database(declarative_base(metadata=nameing_metadata))
class ApiLog(Base):
__tablename__ = 'api_log'
id : Mapped[uuid.UUID] = mapped_column(primary_key=True, default = uuid.uuid7)
api_key : Mapped[uuid.UUID | None]
ip_address : Mapped[str] = mapped_column(INET)
path : Mapped[str]
method : Mapped[str]
status_code : Mapped[int]
request_body : Mapped[dict | None] = mapped_column(JSONB)
response_body : Mapped[dict | None] = mapped_column(JSONB)
query_params : Mapped[dict | None] = mapped_column(JSONB)
path_params : Mapped[dict | None] = mapped_column(JSONB)
process_time : Mapped[float]
created_at : Mapped[datetime]
class AuditMixin:
@declared_attr
def created_at(cls) -> Mapped[tz_date]:
return mapped_column()
@declared_attr
def updated_at(cls) -> Mapped[tz_date]:
return mapped_column()
@declared_attr
def created_by(cls) -> Mapped[Users | None]:
return mapped_column(foreign_key=[cls.created_by_id], remote_side=[Users.id])
@declared_attr
def updated_by(cls) -> Mapped[Users | None]:
return mapped_column(foreign_key=[cls.updated_by_id], remote_side=[Users.id])
@declared_attr
def updated_by_id(cls) -> Mapped[Uuid]:
return mapped_column()
@declared_attr
def created_by_id(cls) -> Mapped[Uuid]:
return mapped_column()
@audit()
class Users(Base):
__tablename__ = "users"
id : Mapped[Uuid] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
username : Mapped[nstr50] = mapped_column()
full_name : Mapped[nstr100]
email : Mapped[nstr255] = mapped_column()
type : Mapped[nstr50] = mapped_column(default="staff")
is_archived : Mapped[bool] = mapped_column(default=False)
hashed_password: Mapped[nstr255]
__table_args__ = (
Index("ix_unique_active_email", email, unique=True, postgresql_where="is_archived = false"),
Index("ix_unique_active_username", username, unique=True, postgresql_where="is_archived = false"),
)
+21
View File
@@ -0,0 +1,21 @@
import importlib
from .exceptions import ExceptionHandlerRoute
module_path = [
'.auth.views'
]
routers = []
docs = []
for path in module_path:
module = importlib.import_module(path, package = 'app')
if hasattr(module, 'router'):
assert(type(module.router.route_class == ExceptionHandlerRoute))
routers.append(module.router)
if hasattr(module, 'docs'):
tag = module.router.tags[0]
docs.append({"name" : tag, "description" : module.docs})
+102
View File
@@ -0,0 +1,102 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
certifi==2026.2.25
click==8.3.2
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.136.0
fastapi-cli==0.0.24
fastapi-cloud-cli==0.17.0
fastar==0.11.0
greenlet==3.4.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
Jinja2==3.1.6
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
psycopg2==2.9.12
pydantic==2.13.2
pydantic-extra-types==2.11.1
pydantic-settings==2.13.1
pydantic_core==2.46.2
Pygments==2.20.0
python-dotenv==1.2.2
python-multipart==0.0.26
PyYAML==6.0.3
rich==15.0.0
rich-toolkit==0.19.7
rignore==0.7.6
sentry-sdk==2.58.0
shellingham==1.5.4
SQLAlchemy==2.0.49
sqlalchemy-declarative-extensions==0.16.8
starlette==1.0.0
typer==0.24.1
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.44.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==16.0
alembic==1.18.4
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
certifi==2026.2.25
cffi==2.0.0
click==8.3.2
cryptography==46.0.7
dnspython==2.8.0
email-validator==2.3.0
fastapi==0.136.0
fastapi-cli==0.0.24
fastapi-cloud-cli==0.17.0
fastar==0.11.0
greenlet==3.4.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
Jinja2==3.1.6
jwt==1.4.0
Mako==1.3.11
markdown-it-py==4.0.0
MarkupSafe==3.0.3
mdurl==0.1.2
psycopg==3.3.3
psycopg2==2.9.12
pwdlib==0.3.0
pycparser==3.0
pydantic==2.13.2
pydantic-extra-types==2.11.1
pydantic-settings==2.13.1
pydantic_core==2.46.2
Pygments==2.20.0
python-dotenv==1.2.2
python-multipart==0.0.26
PyYAML==6.0.3
rich==15.0.0
rich-toolkit==0.19.7
rignore==0.7.6
sentry-sdk==2.58.0
shellingham==1.5.4
SQLAlchemy==2.0.49
sqlalchemy-declarative-extensions==0.16.8
starlette==1.0.0
typer==0.24.1
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.6.3
uvicorn==0.44.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==16.0