diff --git a/.gitignore b/.gitignore index cfb3007..74939d3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ __pycache__ /venv /test /test/* +logs certs .vscode diff --git a/core/singleton.py b/core/singleton.py new file mode 100644 index 0000000..3776cb9 --- /dev/null +++ b/core/singleton.py @@ -0,0 +1,7 @@ +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + return cls._instances[cls] diff --git a/logger/__init__.py b/logger/__init__.py new file mode 100644 index 0000000..f30fff0 --- /dev/null +++ b/logger/__init__.py @@ -0,0 +1 @@ +from logger.builder import logger_builder as logger_builder diff --git a/logger/builder.py b/logger/builder.py new file mode 100644 index 0000000..70b3df1 --- /dev/null +++ b/logger/builder.py @@ -0,0 +1,60 @@ +import logging +import logging.config + +from logger.constants import ( + LEVEL_NAME, + BACKUP_COUNT, + LOGS_FOLDER, + MAX_LOG_FILE_SIZE_BYTES, +) +from logger.formatter import JsonFormatter +from logger.gunzip_rotating_file_handler import GunZipRotatingFileHandler +from logger.filters import LevelFilter, RequestIdFilter +from core.singleton import Singleton + + +class LoggerBuilder(metaclass=Singleton): + def get_logger(self) -> logging.Logger: + logger = logging.getLogger("crm") + logger.setLevel(logging.DEBUG) + logger.handlers.clear() + self.set_handlers(logger) + return logger + + def set_handlers(self, logger: logging.Logger): + LOGGER_LEVEL_STEP = 10 + + for level in range(logging.DEBUG, logging.CRITICAL + 1, LOGGER_LEVEL_STEP): + logger.addHandler(self.create_rotating_file_handler(level)) + + logger.addHandler(self.create_console_handler()) + + @classmethod + def create_rotating_file_handler(cls, level: int) -> GunZipRotatingFileHandler: + folder = LOGS_FOLDER / LEVEL_NAME[level] + folder.mkdir(parents=True, exist_ok=True) + filename = LEVEL_NAME[level] + ".log" + + file_handler = GunZipRotatingFileHandler( + folder / filename, + maxBytes=MAX_LOG_FILE_SIZE_BYTES, + encoding="UTF-8", + backupCount=BACKUP_COUNT[level], + ) + + file_handler.addFilter(LevelFilter(level)) + file_handler.addFilter(RequestIdFilter()) + file_handler.setFormatter(JsonFormatter()) + + return file_handler + + @classmethod + def create_console_handler(cls) -> logging.StreamHandler: + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + console_handler.setFormatter(JsonFormatter()) + return console_handler + + +logger_builder = LoggerBuilder() diff --git a/logger/constants.py b/logger/constants.py new file mode 100644 index 0000000..b2fca21 --- /dev/null +++ b/logger/constants.py @@ -0,0 +1,25 @@ +import logging +from pathlib import Path + +from constants import APP_PATH + +LEVEL_NAME = { + logging.FATAL: "fatal", + logging.CRITICAL: "critical", + logging.ERROR: "error", + logging.WARNING: "warning", + logging.INFO: "info", + logging.DEBUG: "debug", +} + +BACKUP_COUNT = { + logging.FATAL: 5, + logging.CRITICAL: 5, + logging.ERROR: 4, + logging.WARNING: 3, + logging.INFO: 2, + logging.DEBUG: 1, +} + +MAX_LOG_FILE_SIZE_BYTES = 30 * 1024 * 1024 # 30 Mb +LOGS_FOLDER = Path(APP_PATH) / Path("logs") diff --git a/logger/filters.py b/logger/filters.py new file mode 100644 index 0000000..d548173 --- /dev/null +++ b/logger/filters.py @@ -0,0 +1,17 @@ +import logging +import uuid + + +class LevelFilter(logging.Filter): + def __init__(self, level): + super().__init__() + self.level = level + + def filter(self, record): + return record.levelno == self.level + + +class RequestIdFilter(logging.Filter): + def filter(self, record): + record.request_id = str(uuid.uuid4()) + return True diff --git a/logger/formatter.py b/logger/formatter.py new file mode 100644 index 0000000..e08339f --- /dev/null +++ b/logger/formatter.py @@ -0,0 +1,20 @@ +import json +import logging +from datetime import datetime, UTC + + +class JsonFormatter(logging.Formatter): + def format(self, record: any): + log_record = { + "timestamp": datetime.now(UTC).isoformat(), + "level": record.levelname, + "module": record.module, + "line": record.lineno, + "message": record.getMessage(), + "request_id": record.request_id, + } + + if record.exc_info: + log_record["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_record) diff --git a/logger/gunzip_rotating_file_handler.py b/logger/gunzip_rotating_file_handler.py new file mode 100644 index 0000000..35bdf98 --- /dev/null +++ b/logger/gunzip_rotating_file_handler.py @@ -0,0 +1,35 @@ +import gzip +from logging.handlers import RotatingFileHandler +import shutil +import os + + +class GunZipRotatingFileHandler(RotatingFileHandler): + def doRollover(self): + if self.stream: + self.stream.close() + self.stream = None + + if self.backupCount > 0: + # Rotate existing backup files + for i in range(self.backupCount - 1, 0, -1): + sfn = self.rotation_filename("%s.%d.gz" % (self.baseFilename, i)) + dfn = self.rotation_filename("%s.%d.gz" % (self.baseFilename, i + 1)) + if os.path.exists(sfn): + if os.path.exists(dfn): + os.remove(dfn) + os.rename(sfn, dfn) + + # Compress current log file to .1.gz + dfn = self.rotation_filename(self.baseFilename + ".1.gz") + if os.path.exists(dfn): + os.remove(dfn) + + if os.path.exists(self.baseFilename): + with open(self.baseFilename, "rb") as f_in: + with gzip.open(dfn, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) + os.remove(self.baseFilename) + + if not self.delay: + self.stream = self._open() diff --git a/main.py b/main.py index a0a6466..4c22a7f 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from starlette.staticfiles import StaticFiles -origins = [ - 'http://localhost:5173' -] +origins = ["http://localhost:5173"] app = FastAPI(separate_input_output_schemas=False) @@ -18,5 +16,7 @@ app.add_middleware( ) routers_list = [] +for router in routers_list: + app.include_router(router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/models/base.py b/models/base.py index 89f690c..9a7117e 100644 --- a/models/base.py +++ b/models/base.py @@ -4,8 +4,8 @@ from sqlalchemy.orm import DeclarativeBase class BaseModel(DeclarativeBase, AsyncAttrs): def __repr__(self): - if hasattr(self, 'id'): - return f'<{self.__class__.__name__} id={self.id}>' + if hasattr(self, "id"): + return f"<{self.__class__.__name__} id={self.id}>" return super().__repr__()