Add vue-router, login/setup flow and backend logging
Refactor frontend to route-based navigation with vue-router, add system setup and login pages with API integration. Add structured logging, access-log middleware and startup lifecycle to FastAPI backend.
This commit is contained in:
@@ -44,7 +44,13 @@ class Settings(BaseSettings):
|
||||
|
||||
redis_url: str | None = Field(default=None, alias="REDIS_URL")
|
||||
cors_origins: list[str] = Field(default_factory=list, alias="CORS_ORIGINS")
|
||||
vite_api_base_url: str = Field(default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL")
|
||||
vite_api_base_url: str = Field(
|
||||
default="http://127.0.0.1:8000/api/v1", alias="VITE_API_BASE_URL"
|
||||
)
|
||||
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
log_dir: str = Field(default="logs", alias="LOG_DIR")
|
||||
log_file_enabled: bool = Field(default=True, alias="LOG_FILE_ENABLED")
|
||||
|
||||
@property
|
||||
def resolved_database_url(self) -> str:
|
||||
|
||||
72
server/src/app/core/logging.py
Normal file
72
server/src/app/core/logging.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
_LEVEL_COLORS: dict[int, str] = {
|
||||
logging.DEBUG: "\033[36m",
|
||||
logging.INFO: "\033[32m",
|
||||
logging.WARNING: "\033[33m",
|
||||
logging.ERROR: "\033[31m",
|
||||
logging.CRITICAL: "\033[1;31m",
|
||||
}
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
class _ColorFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
color = _LEVEL_COLORS.get(record.levelno, "")
|
||||
record.levelname = f"{color}{record.levelname:<8}{_RESET}"
|
||||
return super().format(record)
|
||||
|
||||
|
||||
def _build_console_handler(level: int) -> logging.StreamHandler:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(_ColorFormatter(LOG_FORMAT, datefmt=DATE_FORMAT))
|
||||
return handler
|
||||
|
||||
|
||||
def _build_file_handler(log_dir: Path, level: int) -> RotatingFileHandler:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
handler = RotatingFileHandler(
|
||||
log_dir / "app.log",
|
||||
maxBytes=10 * 1024 * 1024,
|
||||
backupCount=10,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT))
|
||||
return handler
|
||||
|
||||
|
||||
def setup_logging(
|
||||
*,
|
||||
level: str = "INFO",
|
||||
log_dir: str = "logs",
|
||||
enable_file: bool = True,
|
||||
) -> None:
|
||||
numeric_level = getattr(logging, level.upper(), logging.INFO)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(numeric_level)
|
||||
root.handlers.clear()
|
||||
|
||||
root.addHandler(_build_console_handler(numeric_level))
|
||||
|
||||
if enable_file:
|
||||
from app.core.config import SERVER_DIR
|
||||
|
||||
file_path = SERVER_DIR / log_dir
|
||||
root.addHandler(_build_file_handler(file_path, numeric_level))
|
||||
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
@@ -5,17 +5,32 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.middleware.logging import AccessLogMiddleware
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = get_settings()
|
||||
|
||||
setup_logging(
|
||||
level=settings.log_level,
|
||||
log_dir=settings.log_dir,
|
||||
enable_file=settings.log_file_enabled,
|
||||
)
|
||||
|
||||
logger = get_logger("app.main")
|
||||
logger.info(
|
||||
"Starting %s (env=%s, debug=%s)", settings.app_name, settings.app_env, settings.app_debug
|
||||
)
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
debug=settings.app_debug,
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(AccessLogMiddleware)
|
||||
|
||||
if settings.cors_origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -31,6 +46,15 @@ def create_app() -> FastAPI:
|
||||
def root() -> dict[str, str]:
|
||||
return {"message": f"{settings.app_name} is running"}
|
||||
|
||||
@app.on_event("startup")
|
||||
def _on_startup() -> None:
|
||||
logger.info(
|
||||
"Server ready — host=%s port=%s prefix=%s",
|
||||
settings.app_host,
|
||||
settings.app_port,
|
||||
settings.api_v1_prefix,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
0
server/src/app/middleware/__init__.py
Normal file
0
server/src/app/middleware/__init__.py
Normal file
42
server/src/app/middleware/logging.py
Normal file
42
server/src/app/middleware/logging.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger("app.middleware.access")
|
||||
|
||||
_SKIP_PATHS: frozenset[str] = frozenset({"/", "/docs", "/openapi.json", "/redoc"})
|
||||
|
||||
|
||||
class AccessLogMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if request.url.path in _SKIP_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
request_id = request.headers.get("X-Request-ID", uuid.uuid4().hex[:12])
|
||||
start = time.perf_counter()
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
duration_ms = (time.perf_counter() - start) * 1000
|
||||
level = logging.WARNING if response.status_code >= 500 else logging.INFO
|
||||
|
||||
logger.log(
|
||||
level,
|
||||
"%s %s %s %.1fms request_id=%s",
|
||||
request.method,
|
||||
request.url.path,
|
||||
response.status_code,
|
||||
duration_ms,
|
||||
request_id,
|
||||
)
|
||||
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
@@ -1,20 +1,34 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.employee import Employee
|
||||
from app.repositories.employee import EmployeeRepository
|
||||
from app.schemas.employee import EmployeeCreate
|
||||
|
||||
logger = get_logger("app.services.employee")
|
||||
|
||||
|
||||
class EmployeeService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.repository = EmployeeRepository(db)
|
||||
|
||||
def list_employees(self) -> list[Employee]:
|
||||
return self.repository.list()
|
||||
employees = self.repository.list()
|
||||
logger.info("Listed employees (count=%d)", len(employees))
|
||||
return employees
|
||||
|
||||
def get_employee(self, employee_id: str) -> Employee | None:
|
||||
return self.repository.get(employee_id)
|
||||
employee = self.repository.get(employee_id)
|
||||
if employee:
|
||||
logger.info("Fetched employee id=%s name=%s", employee_id, employee.name)
|
||||
else:
|
||||
logger.warning("Employee not found id=%s", employee_id)
|
||||
return employee
|
||||
|
||||
def create_employee(self, payload: EmployeeCreate) -> Employee:
|
||||
employee = Employee(**payload.model_dump())
|
||||
return self.repository.create(employee)
|
||||
created = self.repository.create(employee)
|
||||
logger.info(
|
||||
"Created employee id=%s no=%s name=%s", created.id, created.employee_no, created.name
|
||||
)
|
||||
return created
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.models.reimbursement import ReimbursementRequest
|
||||
from app.repositories.reimbursement import ReimbursementRepository
|
||||
from app.schemas.reimbursement import ReimbursementCreate
|
||||
|
||||
logger = get_logger("app.services.reimbursement")
|
||||
|
||||
|
||||
class ReimbursementService:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.repository = ReimbursementRepository(db)
|
||||
|
||||
def list_reimbursements(self) -> list[ReimbursementRequest]:
|
||||
return self.repository.list()
|
||||
items = self.repository.list()
|
||||
logger.info("Listed reimbursements (count=%d)", len(items))
|
||||
return items
|
||||
|
||||
def get_reimbursement(self, request_id: str) -> ReimbursementRequest | None:
|
||||
return self.repository.get(request_id)
|
||||
request = self.repository.get(request_id)
|
||||
if request:
|
||||
logger.info("Fetched reimbursement id=%s no=%s", request_id, request.request_no)
|
||||
else:
|
||||
logger.warning("Reimbursement not found id=%s", request_id)
|
||||
return request
|
||||
|
||||
def create_reimbursement(self, payload: ReimbursementCreate) -> ReimbursementRequest:
|
||||
request = ReimbursementRequest(**payload.model_dump(), status="draft")
|
||||
return self.repository.create(request)
|
||||
created = self.repository.create(request)
|
||||
logger.info(
|
||||
"Created reimbursement id=%s no=%s amount=%s",
|
||||
created.id,
|
||||
created.request_no,
|
||||
created.amount,
|
||||
)
|
||||
return created
|
||||
|
||||
Reference in New Issue
Block a user