Done setup template

This commit is contained in:
2024-06-01 12:34:20 +00:00
parent 71d4afcc5e
commit 449a5f644f
104 changed files with 313 additions and 256 deletions

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = "develop"

82
backend/app.py Normal file
View File

@ -0,0 +1,82 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from backend.core.config import get_app_settings
from backend.core.root_logger import get_logger
from backend.routes import router
from backend import __version__
import uvicorn
settings = get_app_settings()
logger = get_logger()
description = f"""
fuware is a web application for managing your hours items and tracking them.
"""
@asynccontextmanager
async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("start: database initialization")
import backend.db.init_db as init_db
init_db.main()
logger.info("end: database initialization")
logger.info("-----SYSTEM STARTUP-----")
# logger.info("------APP SETTINGS------")
# logger.info(
# settings.model_dump_json(
# indent=4,
# exclude={
# "SECRET",
# "DB_URL", # replace by DB_URL_PUBLIC for logs
# "DB_PROVIDER",
# },
# )
# )
yield
logger.info("-----SYSTEM SHUTDOWN-----")
app = FastAPI(
title="Fuware",
description=description,
version=__version__,
docs_url=settings.DOCS_URL,
redoc_url=settings.REDOC_URL,
lifespan=lifespan_fn,
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
if not settings.PRODUCTION:
allowed_origins = ["http://localhost:3000"]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(HTTPException)
async def unicorn_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={"status": exc.status_code, "data": exc.detail},
)
def api_routers():
app.include_router(router)
api_routers()
def main():
uvicorn.run("app:app", host="0.0.0.0", port=settings.API_PORT, reload=True, workers=1, forwarded_allow_ips="*")
if __name__ == "__main__":
main()

1
backend/core/__init__.py Normal file
View File

@ -0,0 +1 @@
from .message_code import *

31
backend/core/config.py Normal file
View File

@ -0,0 +1,31 @@
import os
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
from backend.core.settings import AppSettings, app_settings_constructor
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent
ENV = BASE_DIR.joinpath(".env")
load_dotenv()
PRODUCTION = os.getenv("PRODUCTION", "True").lower() in ["true", "1"]
TESTING = os.getenv("TESTING", "False").lower() in ["true", "1"]
DATA_DIR = os.getenv("DATA_DIR")
def determine_data_dir() -> Path:
global PRODUCTION, TESTING, BASE_DIR, DATA_DIR
if TESTING:
return BASE_DIR.joinpath(DATA_DIR if DATA_DIR else "tests/.temp")
if PRODUCTION:
return Path(DATA_DIR if DATA_DIR else "/app/data")
return BASE_DIR.joinpath("dev", "data")
@lru_cache
def get_app_settings() -> AppSettings:
return app_settings_constructor(env_file=ENV, production=PRODUCTION, data_dir=determine_data_dir())

View File

@ -0,0 +1 @@
from .dependencies import *

View File

@ -0,0 +1,76 @@
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from backend.core.config import get_app_settings
from backend.core import MessageCode
import jwt
from backend.services.user.user_service import UserService
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
ALGORITHM = "HS256"
settings = get_app_settings()
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail)) -> bool:
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
exp: int = payload.get("exp")
if exp is not None:
try:
user_service = UserService()
user = user_service.get_by_id(user_id)
if not user:
raise credentials_exception
if user.is_lock is True:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
except Exception:
return credentials_exception
return user
except Exception:
raise credentials_exception
async def get_current_user(request: Request, token: str | None = Depends(oauth2_scheme_soft_fail)):
"""verify that user has a valid session"""
if token is None and settings.COOKIE_KEY in request.cookies:
# Try extract from cookie
token = request.cookies.get(settings.COOKIE_KEY, "")
else:
token = token or ""
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
exp: int = payload.get("exp")
if user_id is None or exp is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="credentials have expired",
)
user_service = UserService()
user = user_service.get_by_id(user_id)
if not user:
raise credentials_exception
if user.is_lock is True:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
return user
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="credentials have expired",
)
except Exception:
raise credentials_exception

View File

@ -0,0 +1,66 @@
import json
import logging
import pathlib
import typing
from logging import config as logging_config
__dir = pathlib.Path(__file__).parent
__conf: dict[str, str] | None = None
def _load_config(path: pathlib.Path, substitutions: dict[str, str] | None = None) -> dict[str, typing.Any]:
with open(path) as file:
if substitutions:
contents = file.read()
for key, value in substitutions.items():
# Replaces the key matches
#
# Example:
# {"key": "value"}
# "/path/to/${key}/file" -> "/path/to/value/file"
contents = contents.replace(f"${{{key}}}", value)
json_data = json.loads(contents)
else:
json_data = json.load(file)
return json_data
def log_config() -> dict[str, str]:
if __conf is None:
raise ValueError("logger not configured, must call configured_logger first")
return __conf
def configured_logger(
*,
mode: str,
config_override: pathlib.Path | None = None,
substitutions: dict[str, str] | None = None,
) -> logging.Logger:
"""
Configure the logger based on the mode and return the root logger
Args:
mode (str): The mode to configure the logger for (production, development, testing)
config_override (pathlib.Path, optional): A path to a custom logging config. Defaults to None.
substitutions (dict[str, str], optional): A dictionary of substitutions to apply to the logging config.
"""
global __conf
if config_override:
__conf = _load_config(config_override, substitutions)
else:
if mode == "production":
__conf = _load_config(__dir / "logconf.prod.json", substitutions)
elif mode == "development":
__conf = _load_config(__dir / "logconf.dev.json", substitutions)
elif mode == "testing":
__conf = _load_config(__dir / "logconf.test.json", substitutions)
else:
raise ValueError(f"Invalid mode: {mode}")
logging_config.dictConfig(config=__conf)
return logging.getLogger()

View File

@ -0,0 +1,15 @@
{
"version": 1,
"disable_existing_loggers": false,
"handlers": {
"rich": {
"class": "rich.logging.RichHandler"
}
},
"loggers": {
"root": {
"level": "DEBUG",
"handlers": ["rich"]
}
}
}

View File

@ -0,0 +1,63 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"simple": {
"format": "%(levelname)-8s %(asctime)s - %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S"
},
"detailed": {
"format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s: %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S"
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": "%(levelname)-8s %(asctime)s - [%(client_addr)s] %(status_code)s \"%(request_line)s\"",
"datefmt": "%Y-%m-%dT%H:%M:%S"
}
},
"handlers": {
"stderr": {
"class": "logging.StreamHandler",
"level": "WARNING",
"formatter": "simple",
"stream": "ext://sys.stderr"
},
"stdout": {
"class": "logging.StreamHandler",
"level": "${LOG_LEVEL}",
"formatter": "simple",
"stream": "ext://sys.stdout"
},
"access": {
"class": "logging.StreamHandler",
"level": "${LOG_LEVEL}",
"formatter": "access",
"stream": "ext://sys.stdout"
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"level": "DEBUG",
"formatter": "detailed",
"filename": "${DATA_DIR}/mealie.log",
"maxBytes": 10000,
"backupCount": 3
}
},
"loggers": {
"root": {
"level": "${LOG_LEVEL}",
"handlers": ["stderr", "file", "stdout"]
},
"uvicorn.error": {
"handlers": ["stderr", "file", "stdout"],
"level": "${LOG_LEVEL}",
"propagate": false
},
"uvicorn.access": {
"handlers": ["access", "file"],
"level": "${LOG_LEVEL}",
"propagate": false
}
}
}

View File

@ -0,0 +1,24 @@
{
"version": 1,
"disable_existing_loggers": false,
"formatters": {
"detailed": {
"format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s: %(message)s",
"datefmt": "%Y-%m-%dT%H:%M:%S"
}
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "detailed",
"stream": "ext://sys.stdout"
}
},
"loggers": {
"root": {
"level": "${LOG_LEVEL}",
"handlers": ["stdout"]
}
}
}

View File

@ -0,0 +1,6 @@
class MessageCode():
CREATED_USER: str = 'CREATED_USER'
WRONG_INPUT: str = 'LOGIN_WRONG'
ACCOUNT_LOCK: str = 'USER_LOCK'
REFRESH_TOKEN_EXPIRED: str = 'REFRESH_TOKEN_EXPIRED'

View File

@ -0,0 +1,43 @@
import logging
from .config import get_app_settings
from .logger.config import configured_logger
__root_logger: None | logging.Logger = None
def get_logger(module=None) -> logging.Logger:
"""
Get a logger instance for a module, in most cases module should not be
provided. Simply using the root logger is sufficient.
Cases where you would want to use a module specific logger might be a background
task or a long running process where you want to easily identify the source of
those messages
"""
global __root_logger
if __root_logger is None:
app_settings = get_app_settings()
mode = "development"
if app_settings.TESTING:
mode = "testing"
elif app_settings.PRODUCTION:
mode = "production"
substitutions = {
"LOG_LEVEL": app_settings.LOG_LEVEL.upper(),
}
__root_logger = configured_logger(
mode=mode,
config_override=app_settings.LOG_CONFIG_OVERRIDE,
substitutions=substitutions,
)
if module is None:
return __root_logger
return __root_logger.getChild(module)

View File

@ -0,0 +1 @@
from .security import *

View File

@ -0,0 +1,34 @@
from functools import lru_cache
from typing import Protocol
import bcrypt
from backend.core.config import get_app_settings
class Hasher(Protocol):
def hash(self, password: str) -> str: ...
def verify(self, password: str, hashed: str) -> bool: ...
class FakeHasher:
def hash(self, password: str) -> str:
return password
def verify(self, password: str, hashed: str) -> bool:
return password == hashed
class BcryptHasher:
def hash(self, password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verify(self, password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
@lru_cache(maxsize=1)
def get_hasher() -> Hasher:
settings = get_app_settings()
if settings.TESTING:
return FakeHasher()
return BcryptHasher()

View File

@ -0,0 +1,46 @@
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
import jwt
from backend.core.config import get_app_settings
from backend.core import root_logger
from backend.core.security.hasher import get_hasher
ALGORITHM = "HS256"
logger = root_logger.get_logger("security")
settings = get_app_settings()
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
settings = get_app_settings()
to_encode = data.copy()
expires_delta = expires_delta or timedelta(minutes=settings.EXP_TOKEN)
expire = datetime.now(timezone.utc) + expires_delta
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
return create_access_token(data, expires_delta=timedelta(days=settings.EXP_REFRESH))
def create_file_token(file_path: Path) -> str:
token_data = {"file": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def hash_password(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
return get_hasher().hash(password)
def url_safe_token() -> str:
"""Generates a cryptographic token without embedded data. Used for password reset tokens and invitation tokens"""
return secrets.token_urlsafe(24)
def verify_token(exp: int):
expried = datetime.fromtimestamp(exp / 1e3)
return expried < datetime.now(timezone.utc)

View File

@ -0,0 +1 @@
from .settings import *

View File

@ -0,0 +1,28 @@
from abc import ABC, abstractmethod
from pathlib import Path
from pydantic import BaseModel
class AbstractDBProvider(ABC):
@property
@abstractmethod
def db_url(self) -> str: ...
@property
@abstractmethod
def db_url_public(self) -> str: ...
class SQLiteProvider(AbstractDBProvider, BaseModel):
data_dir: Path
prefix: str = ""
@property
def db_path(self):
return self.data_dir / f"{self.prefix}fuware.db"
@property
def db_url(self) -> str:
return f"sqlite:///{str(self.db_path.absolute())}"
@property
def db_url_public(self) -> str:
return self.db_url

View File

@ -0,0 +1,80 @@
from pathlib import Path
from backend.core.settings.db_providers import AbstractDBProvider, SQLiteProvider
from pydantic_settings import BaseSettings # type: ignore
def determine_secrets(production: bool) -> str:
if not production:
return "shh-secret-test-key"
return "1d00e664fb3b07aff5a191755ea72f9c4bc85a3f36868308d0b2c417aed3419e"
def determine_cookie(production: bool) -> str:
if not production:
return "logcook"
return "7fo24CMyIc"
class AppSettings(BaseSettings):
PRODUCTION: bool
TESTING: bool
BASE_URL: str = "http://localhost:8080"
"""trailing slashes are trimmed (ex. `http://localhost:8080/` becomes ``http://localhost:8080`)"""
HOST_IP: str = "*"
API_HOST: str = "0.0.0.0"
API_PORT: int = 9000
API_DOCS: bool = True
ALLOW_SIGNUP: bool = False
SECRET: str
COOKIE_KEY: str
EXP_TOKEN: int = 30
"""in minutes, default is 30 minutes"""
EXP_REFRESH: int = 7
"""in days, default is 7 days"""
LOG_CONFIG_OVERRIDE: Path | None = None
"""path to custom logging configuration file"""
LOG_LEVEL: str = "info"
"""corresponds to standard Python log levels"""
@property
def DOCS_URL(self) -> str | None:
return "/docs" if self.API_DOCS else None
@property
def REDOC_URL(self) -> str | None:
return "/redoc" if self.API_DOCS else None
# ===============================================
# Database Configuration
DB_PROVIDER: AbstractDBProvider | None = None
@property
def DB_URL(self) -> str | None:
return self.DB_PROVIDER.db_url if self.DB_PROVIDER else None
@property
def DB_URL_PUBLIC(self) -> str | None:
return self.DB_PROVIDER.db_url_public if self.DB_PROVIDER else None
def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, env_encoding="utf-8") -> AppSettings:
"""
app_settings_constructor is a factory function that returns an AppSettings object. It is used to inject the
required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated
directly, but rather through this factory function.
"""
app_settings = AppSettings(
_env_file=env_file, # type: ignore
_env_file_encoding=env_encoding, # type: ignore
**{"SECRET": determine_secrets(production), 'COOKIE_KEY': determine_cookie(production)},
)
app_settings.DB_PROVIDER = SQLiteProvider(data_dir=data_dir)
return app_settings

1
backend/db/__init__.py Normal file
View File

@ -0,0 +1 @@
from .db_setup import *

48
backend/db/db_setup.py Normal file
View File

@ -0,0 +1,48 @@
from collections.abc import Generator
from contextlib import contextmanager
from sqlalchemy.orm.session import Session
from sqlalchemy import create_engine, event, Engine
from sqlalchemy.orm import scoped_session, sessionmaker
from backend.core.config import get_app_settings
settings = get_app_settings()
def sql_global_init(db_url: str):
connect_args = {"check_same_thread": False}
engine = create_engine(db_url, echo=False, connect_args=connect_args, pool_pre_ping=True, future=True)
SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True))
return SessionLocal, engine
SessionLocal, engine = sql_global_init(settings.DB_URL)
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
@contextmanager
def session_context() -> Session: # type: ignore
"""
session_context() provides a managed session to the database that is automatically
closed when the context is exited. This is the preferred method of accessing the
database.
Note: use `generate_session` when using the `Depends` function from FastAPI
"""
global SessionLocal
sess = SessionLocal()
try:
yield sess
finally:
sess.close()
def generate_session() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

86
backend/db/init_db.py Normal file
View File

@ -0,0 +1,86 @@
import os
from pathlib import Path
from time import sleep
from sqlalchemy import engine, orm, text
from alembic import command, config, script
from alembic.config import Config
from alembic.runtime import migration
from backend.core import root_logger
from backend.core.config import get_app_settings
from backend.db.db_setup import session_context
from backend.repos.repository_users import RepositoryUsers
from backend.repos.seeder import default_users_init
# from fuware.db.models import User
PROJECT_DIR = Path(__file__).parent.parent.parent
logger = root_logger.get_logger()
def init_db(db) -> None:
logger.info("Initializing user data...")
default_users_init(db)
def db_is_at_head(alembic_cfg: config.Config) -> bool:
settings = get_app_settings()
url = settings.DB_URL
if not url:
raise ValueError("No database url found")
connectable = engine.create_engine(url)
directory = script.ScriptDirectory.from_config(alembic_cfg)
with connectable.begin() as connection:
context = migration.MigrationContext.configure(connection)
return set(context.get_current_heads()) == set(directory.get_heads())
def connect(session: orm.Session) -> bool:
try:
session.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Error connecting to database: {e}")
return False
def main():
max_retry = 10
wait_seconds = 1
with session_context() as session:
while True:
if connect(session):
logger.info("Database connection established.")
break
logger.error(f"Database connection failed. Retrying in {wait_seconds} seconds...")
max_retry -= 1
sleep(wait_seconds)
if max_retry == 0:
raise ConnectionError("Database connection failed - exiting application.")
alembic_cfg_path = os.getenv("ALEMBIC_CONFIG_FILE", default=str(PROJECT_DIR / "alembic.ini"))
if not os.path.isfile(alembic_cfg_path):
raise Exception("Provided alembic config path doesn't exist")
alembic_cfg = Config(alembic_cfg_path)
if db_is_at_head(alembic_cfg):
logger.debug("Migration not needed.")
else:
logger.info("Migration needed. Performing migration...")
command.upgrade(alembic_cfg, "head")
if session.get_bind().name == "postgresql": # needed for fuzzy search and fast GIN text indices
session.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;"))
users = RepositoryUsers()
if users.get_all():
logger.info("Database already seeded.")
else:
logger.info("Seeding database...")
init_db(session)
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
from .users import *

View File

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import DateTime
from sqlalchemy.orm import declarative_base, Mapped, mapped_column
from text_unidecode import unidecode
from backend.db.db_setup import SessionLocal
Model = declarative_base()
Model.query = SessionLocal.query_property()
class SqlAlchemyBase(Model):
__abstract__ = True
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), index=True)
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow(), onupdate=datetime.utcnow())
@classmethod
def normalize(cls, val: str) -> str:
return unidecode(val).lower().strip()

View File

@ -0,0 +1 @@
from .users import *

View File

@ -0,0 +1,20 @@
from uuid import uuid4
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from .._model_base import SqlAlchemyBase
class User(SqlAlchemyBase):
__tablename__ = 'users'
id: Mapped[UUID] = mapped_column(UUID, primary_key=True, default=uuid4, index=True)
username: Mapped[str | None] = mapped_column(String, unique=True, index=True, nullable=False)
password: Mapped[str | None] = mapped_column(String, index=True, nullable=False)
name: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
is_admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
is_lock: Mapped[bool | None] = mapped_column(Boolean, default=False)
def __repr__(self):
return f"{self.__class__.__name__}, name: {self.name}, username: {self.username}"

10
backend/main.py Normal file
View File

@ -0,0 +1,10 @@
import uvicorn
from backend.app import settings
def main():
uvicorn.run("app:app", host=settings.API_HOST, port=settings.API_PORT, reload=True, workers=1, forwarded_allow_ips=settings.HOST_IP)
if __name__ == "__main__":
main()

View File

@ -0,0 +1 @@
from .repository_users import *

View File

@ -0,0 +1,36 @@
from backend.core.config import get_app_settings
from backend.core.security.security import hash_password
from backend.db.models import User
from backend.schemas import UserCreate
from sqlalchemy.orm import Session
from uuid import UUID
from backend.schemas.user.user import UserSeeds
settings = get_app_settings()
class RepositoryUsers:
def __init__(self):
self.user = User()
def get_all(self, skip: int = 0, limit: int = 100):
return self.user.query.offset(skip).limit(limit).all()
def get_by_username(self, username: str):
return self.user.query.filter_by(username=username).first()
def get_by_id(self, user_id: str):
return self.user.query.filter_by(id=UUID(user_id)).first()
def create(self, db: Session, user: UserCreate | UserSeeds):
try:
password = getattr(user, "password")
db_user = User(**user.dict(exclude={"password"}), password=hash_password(password))
db.add(db_user)
db.commit()
except Exception:
db.rollback()
raise
db.refresh(db_user)
return db_user

View File

@ -0,0 +1 @@
from .init_users import default_users_init

View File

@ -0,0 +1,33 @@
from backend.core.config import get_app_settings
from backend.core.root_logger import get_logger
from backend.repos.repository_users import RepositoryUsers
from sqlalchemy.orm import Session
from backend.schemas.user import UserSeeds
logger = get_logger("init_users")
settings = get_app_settings()
def dev_users() -> list[dict]:
return [
{
"username": "sam",
"password": "admin",
"name": "Sam",
"is_admin": True,
"is_lock": False,
},
{
"username": "sam1",
"password": "admin",
"name": "Sam1",
"is_admin": False,
"is_lock": False,
},
]
def default_users_init(session: Session):
users = RepositoryUsers()
for user in dev_users():
users.create(session, UserSeeds(**user))

View File

@ -0,0 +1,9 @@
from fastapi import APIRouter
from . import (auth, user)
router = APIRouter(prefix='/api')
router.include_router(auth.router)
router.include_router(user.router)

View File

@ -0,0 +1,9 @@
from enum import Enum
from fastapi import APIRouter, Depends
from backend.core.dependencies import get_auth_user
class PrivateAPIRouter(APIRouter):
def __init__(self, tags: list[str | Enum] | None = None, prefix: str = "", **kwargs):
super().__init__(tags=tags, prefix=prefix, dependencies=[Depends(get_auth_user)], **kwargs)

View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import auth
router = APIRouter(prefix='/auth')
router.include_router(auth.auth_router)

View File

@ -0,0 +1,70 @@
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Response, status
# from fastapi.encoders import jsonable_encoder
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from backend.core.config import get_app_settings
from backend.core.dependencies.dependencies import get_current_user
from backend.core import MessageCode
from backend.db.db_setup import generate_session
from backend.schemas import ReturnValue, UserRequest, LoginResponse, UserCreate, PrivateUser
from backend.services.user import UserService
auth_router = APIRouter(tags=["Users: Authentication"])
user_service = UserService()
settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)]
current_user_token = Annotated[PrivateUser, Depends(get_current_user)]
@auth_router.post('/token')
async def get_token(form_data: Annotated[OAuth2PasswordRequestForm, Depends()], db: db_dependency):
user = user_service.check_exist(user=UserRequest(username=form_data.username, password=form_data.password))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=MessageCode.WRONG_INPUT)
token = user_service.get_access_token(user_id=user.id)
return {'access_token': token, 'token_type': 'bearer'}
@auth_router.put('/register')
def register_user(user: UserCreate, db: db_dependency) -> ReturnValue[Any]:
db_user = user_service.get_by_username(username=user.username)
if db_user:
raise HTTPException(status_code=400, detail=MessageCode.CREATED_USER)
user_service.create(db=db, user=user)
return ReturnValue(status=200, data="created")
@auth_router.post('/login', response_model=ReturnValue[LoginResponse])
def user_login(user: UserRequest, response: Response) -> ReturnValue[Any]:
db_user = user_service.check_exist(user=user)
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=MessageCode.WRONG_INPUT)
if db_user.is_lock is True:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
access_token, refresh_token = user_service.generate_token(user_id=db_user.id)
duration_access = datetime.now(timezone.utc) + timedelta(minutes=settings.EXP_TOKEN)
duration_refresh = int(timedelta(days=settings.EXP_REFRESH).total_seconds())
response.set_cookie(
key=settings.COOKIE_KEY,
value=refresh_token,
max_age=duration_refresh,
expires=duration_refresh,
httponly=True,
samesite="strict",
)
return ReturnValue(status=200, data=dict(access_token=access_token, exp=int(duration_access.timestamp()), name=db_user.name))
@auth_router.get('/refresh')
def user_check(current_user: current_user_token) -> ReturnValue[Any]:
access_token = user_service.get_access_token(user_id=current_user.id)
duration_access = datetime.now(timezone.utc) + timedelta(minutes=settings.EXP_TOKEN)
return ReturnValue(status=200, data=dict(accessToken=access_token, exp=int(duration_access.timestamp())))
@auth_router.get('/logout')
def user_logout(response: Response, current_user: current_user_token) -> ReturnValue[Any]:
if current_user:
response.delete_cookie(key=settings.COOKIE_KEY)
return ReturnValue(status=200, data='Logged out')

View File

@ -0,0 +1,7 @@
from fastapi import APIRouter
from . import user
router = APIRouter(prefix='/user')
router.include_router(user.public_router)

View File

@ -0,0 +1,21 @@
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.core.config import get_app_settings
from backend.core.dependencies import is_logged_in
from backend.db.db_setup import generate_session
from backend.schemas.common import ReturnValue
from backend.schemas.user import ProfileResponse
from backend.services.user import UserService
public_router = APIRouter(tags=["Users: Info"])
user_service = UserService()
settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)]
current_user_token = Annotated[ProfileResponse, Depends(is_logged_in)]
@public_router.get("/me", response_model=ReturnValue[ProfileResponse])
def get_user(current_user: current_user_token) -> ReturnValue[Any]:
return ReturnValue(status=200, data=current_user)

View File

@ -0,0 +1,2 @@
from .common import *
from .user import *

View File

@ -0,0 +1,8 @@
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar('T')
class ReturnValue(BaseModel, Generic[T]):
status: int
data: T

View File

@ -0,0 +1,27 @@
from typing import ClassVar, TypeVar
from humps import camelize
from enum import Enum
from pydantic import BaseModel, ConfigDict
T = TypeVar("T", bound=BaseModel)
class SearchType(Enum):
fuzzy = "fuzzy"
tokenized = "tokenized"
class FuwareModel(BaseModel):
_searchable_properties: ClassVar[list[str]] = []
"""
Searchable properties for the search API.
The first property will be used for sorting (order_by)
"""
model_config = ConfigDict(alias_generator=camelize, populate_by_name=True)
def cast(self, cls: type[T], **kwargs) -> T:
"""
Cast the current model to another with additional arguments. Useful for
transforming DTOs into models that are saved to a database
"""
create_data = {field: getattr(self, field) for field in self.__fields__ if field in cls.__fields__}
create_data.update(kwargs or {})
return cls(**create_data)

View File

@ -0,0 +1 @@
from .user import *

View File

@ -0,0 +1,40 @@
from datetime import datetime
from uuid import UUID
from pydantic import ConfigDict
from fastapi import Form
from backend.schemas.fuware_model import FuwareModel
class UserBase(FuwareModel):
username: str = Form(...)
class UserRequest(UserBase):
password: str = Form(...)
class UserCreate(UserRequest):
name: str
class UserSeeds(UserCreate):
is_admin: bool
is_lock: bool
class PrivateUser(UserBase):
id: UUID
name: str
is_admin: bool
is_lock: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProfileResponse(UserBase):
name: str
is_admin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class LoginResponse(FuwareModel):
access_token: str
exp: int
name: str

View File

View File

@ -0,0 +1,6 @@
from backend.core.config import get_app_settings
class BaseService:
def __init__(self) -> None:
self.setting = get_app_settings()

View File

@ -0,0 +1 @@
from .user_service import *

View File

@ -0,0 +1,39 @@
from sqlalchemy.orm import Session
from backend.core.security.hasher import get_hasher
from backend.core.security import create_access_token
from backend.core.security.security import create_refresh_token
from backend.repos import RepositoryUsers
from backend.schemas import UserRequest, UserCreate
from backend.services._base_service import BaseService
class UserService(BaseService):
def __init__(self):
self.repos = RepositoryUsers()
def get_all(self, skip: int = 0, limit: int = 100):
return self.repos.get_all(skip=skip, limit=limit)
def get_by_username(self, username: str):
return self.repos.get_by_username(username)
def get_by_id(self, user_id: str):
return self.repos.get_by_id(user_id)
def create(self, db: Session, user: UserCreate):
return self.repos.create(db=db, user=user)
def check_exist(self, user: UserRequest):
db_user = self.get_by_username(username=user.username)
if not db_user:
return False
if not get_hasher().verify(password=user.password, hashed=db_user.password):
return False
return db_user
def generate_token(self, user_id: str):
access_token = create_access_token(data={"sub": str(user_id)})
refresh_token = create_refresh_token(data={"sub": str(user_id)})
return access_token, refresh_token
def get_access_token(self, user_id: str):
return create_access_token(data={"sub": str(user_id)})