commit ebb12e6ab7c4026b673a5ff5aa97de9e6b8cbc19 Author: SamratGhale Date: Tue Apr 21 19:00:53 2026 +0545 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..263b950 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fe1f45 --- /dev/null +++ b/README.md @@ -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 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/alembic.ini @@ -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 /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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..960198a --- /dev/null +++ b/alembic/env.py @@ -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() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/34ec41e7908b_init.py b/alembic/versions/34ec41e7908b_init.py new file mode 100644 index 0000000..9d2095a --- /dev/null +++ b/alembic/versions/34ec41e7908b_init.py @@ -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 ### diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/schemas.py b/app/auth/schemas.py new file mode 100644 index 0000000..ac91124 --- /dev/null +++ b/app/auth/schemas.py @@ -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] = [] diff --git a/app/auth/views.py b/app/auth/views.py new file mode 100644 index 0000000..b57a63c --- /dev/null +++ b/app/auth/views.py @@ -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)] + + + + + diff --git a/app/background.py b/app/background.py new file mode 100644 index 0000000..3b4aa73 --- /dev/null +++ b/app/background.py @@ -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() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/config.py b/app/db/config.py new file mode 100644 index 0000000..434a024 --- /dev/null +++ b/app/db/config.py @@ -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() diff --git a/app/db/db.py b/app/db/db.py new file mode 100644 index 0000000..d9237a2 --- /dev/null +++ b/app/db/db.py @@ -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)] diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..cefc944 --- /dev/null +++ b/app/exceptions.py @@ -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 + + + + + + + + + + + + + + + + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..69e006f --- /dev/null +++ b/app/main.py @@ -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 +) + diff --git a/app/middlewares.py b/app/middlewares.py new file mode 100644 index 0000000..7b471f6 --- /dev/null +++ b/app/middlewares.py @@ -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 diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e1c577a --- /dev/null +++ b/app/models.py @@ -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"), + ) + + + + + + + + + diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..e4a6361 --- /dev/null +++ b/app/routes.py @@ -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}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f968a34 --- /dev/null +++ b/requirements.txt @@ -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