Add DB version with alimbic and add log system

This commit is contained in:
2024-05-11 15:16:16 +00:00
parent 392dee8640
commit a7e31b8ca9
24 changed files with 1131 additions and 137 deletions

View File

@ -1,27 +1,54 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from mimetypes import init
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fuware.core.config import get_app_settings
from fuware import __version__
from fuware.core.root_logger import get_logger
from fuware.routes import router
from fuware 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.
"""
# event.listen(models.User.__table__, 'after_create', initialize_table)
@asynccontextmanager
async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
logger.info("start: database initialization")
import fuware.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
redoc_url=settings.REDOC_URL,
lifespan=lifespan_fn,
)
app.add_middleware(GZipMiddleware, minimum_size=1000)

View File

@ -7,7 +7,6 @@ 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:

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

@ -32,6 +32,12 @@ class AppSettings(BaseSettings):
SECRET: str
COOKIE_KEY: str
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

View File

@ -1,8 +1,8 @@
from collections.abc import Generator
from contextlib import contextmanager
from sqlalchemy.orm.session import Session
from sqlalchemy import create_engine, event, Engine, text
from sqlalchemy import create_engine, event, Engine
from sqlalchemy.orm import scoped_session, sessionmaker
from fuware.core.config import get_app_settings
settings = get_app_settings()
@ -10,13 +10,13 @@ settings = get_app_settings()
def sql_global_init(db_url: str):
connect_args = {"check_same_thread": False}
engine = create_engine(db_url, echo=True, connect_args=connect_args, pool_pre_ping=True, future=True)
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) # type: ignore
SessionLocal, engine = sql_global_init(settings.DB_URL)
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
@ -24,10 +24,21 @@ def set_sqlite_pragma(dbapi_connection, connection_record):
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# with engine.connect() as connection:
# result = connection.execute(text('select "Hello"'))
@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.
# print(result.all())
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()

View File

@ -1,9 +1,87 @@
from db_setup import engine
from fuware.db.seeder import initialize_table
from models._model_base import Model
from sqlalchemy import event
from models.users import User
import os
from pathlib import Path
from time import sleep
event.listen(User.__table__, 'after_create', initialize_table)
from sqlalchemy import engine, orm, text
Model.metadata.create_all(bind=engine)
from alembic import command, config, script
from alembic.config import Config
from alembic.runtime import migration
from fuware.core import root_logger
from fuware.core.config import get_app_settings
from fuware.db.db_setup import session_context
from fuware.repos.repository_users import RepositoryUsers
from fuware.repos.seeder import default_users_init
from fuware.db.models._model_base import Model
# 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

@ -1,6 +1,6 @@
import uuid
from sqlalchemy import Boolean, Column, String
from sqlalchemy.orm import Mapped, mapped_column
from uuid import uuid4
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
@ -9,12 +9,26 @@ from .._model_base import SqlAlchemyBase
class User(SqlAlchemyBase):
__tablename__ = 'users'
id: Mapped[UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4, index=True)
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)
session_login = relationship("SessionLogin", back_populates="user", uselist=False)
def __repr__(self):
return f"{self.__class__.__name__}, name: {self.name}, username: {self.username}"
class SessionLogin(SqlAlchemyBase):
__tablename__ = 'session_login'
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
session: Mapped[str] = mapped_column(UUID, default=uuid4, index=True, nullable=False)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), unique=True, index=True, nullable=False)
user = relationship("User", back_populates="session_login")
def __repr__(self):
return f"{self.__class__.__name__}, session: {self.session}, user_id: {self.user_id}"

View File

@ -1,5 +1,5 @@
from fuware.core.security.hasher import get_hasher
from fuware.db.models.users.users import User
from fuware.db.models import SessionLogin, User
from fuware.schemas import UserCreate
from sqlalchemy.orm import Session
@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
class RepositoryUsers:
def __init__(self):
self.user = User()
self.sessionLogin = SessionLogin()
def get_all(self, skip: int = 0, limit: int = 100):
return self.user.query.offset(skip).limit(limit).all()
@ -26,3 +27,34 @@ class RepositoryUsers:
db.refresh(db_user)
return db_user
def get_session_by_user_id(self, user_id: str):
return self.sessionLogin.query.filter_by(user_id=user_id).first()
def create_session(self, db: Session, user_id: str):
try:
db_ss = SessionLogin(user_id=user_id)
db.add(db_ss)
db.commit()
except Exception:
db.rollback()
raise
db.refresh(db_ss)
return db_ss
def login(self, db: Session, user_id: str):
db_ss = self.get_session_by_user_id(user_id)
if not db_ss:
db_ss = self.create_session(db=db, user_id=user_id)
return db_ss
def logout(self, db: Session, user_ss: str):
db_ss = self.sessionLogin.query.filter_by(session=user_ss).first()
try:
db.delete(db_ss)
db.commit()
except Exception as e:
db.rollback()
raise e
pass

View File

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

View File

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

View File

@ -3,9 +3,10 @@ from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from fuware.core.config import get_app_settings
from fuware.core.security.hasher import get_hasher
from fuware.db.db_setup import generate_session
from fuware.schemas import ReturnValue, PrivateUser, UserRequest
from fuware.schemas import ReturnValue, UserRequest
from fuware.schemas.user.user import UserCreate
from fuware.services import UserService
@ -13,6 +14,7 @@ from fuware.services import UserService
public_router = APIRouter(tags=["Users: Authentication"])
user_service = UserService()
hasher = get_hasher()
settings = get_app_settings()
@public_router.put('/register')
def register_user(user: UserCreate, db: Session = Depends(generate_session)) -> ReturnValue[Any]:
@ -22,16 +24,15 @@ def register_user(user: UserCreate, db: Session = Depends(generate_session)) ->
user_return = user_service.create(db=db, user=user)
return ReturnValue(status=200, data=jsonable_encoder(user_return))
# @public_router.post('/login', response_model=ReturnValue[PrivateUser])
@public_router.post('/login', response_model=ReturnValue[Any])
def user_login(user: UserRequest, response: Response) -> ReturnValue[Any]:
def user_login(user: UserRequest, response: Response, db: Session = Depends(generate_session)) -> ReturnValue[Any]:
db_user = user_service.get_by_username(username=user.username)
if not db_user:
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
if not hasher.verify(password=user.password, hashed=db_user.password):
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
if db_user.is_lock is True:
raise HTTPException(status_code=401, detail="Your Account is banned")
# cookieEncode = encryptString(user.username + ',' + user.password)
# response.set_cookie(key=COOKIE_KEY, value=cookieEncode.decode('utf-8'))
raise HTTPException(status_code=401, detail="Your Account was locked")
cookieEncode = user_service.check_login(db=db, user_id=db_user.id)
response.set_cookie(key=settings.COOKIE_KEY, value=cookieEncode.session)
return ReturnValue(status=200, data=jsonable_encoder(db_user))

View File

@ -1,9 +1,12 @@
from fastapi import HTTPException
from sqlalchemy.orm import Session
from fuware.core.security.hasher import get_hasher
from fuware.repos import RepositoryUsers
from fuware.schemas import UserRequest, UserCreate
from fuware.services._base_service import BaseService
from fuware.schemas import UserCreate
hasher = get_hasher()
class UserService(BaseService):
def __init__(self):
@ -17,3 +20,17 @@ class UserService(BaseService):
def create(self, db: Session, user: UserCreate):
return self.repos.create(db=db, user=user)
def check_exist(self, db: Session, user: UserRequest):
db_user = self.get_by_username(username=user.username)
if not db_user:
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
if not hasher.verify(password=user.password, hashed=db_user.password):
raise HTTPException(status_code=401, detail="Your username or password input is wrong!")
if db_user.is_lock is True:
raise HTTPException(status_code=401, detail="Your Account is banned")
return db_user
def check_login(self, db: Session, user_id: str):
db_session = self.repos.login(db=db, user_id=user_id)
return db_session