diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index 5b0b94b..964a22c 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -5,6 +5,7 @@ from ..config import get_settings + # Configuraci贸n del esquema de seguridad security = HTTPBearer(auto_error=False) diff --git a/app/backoffice/router.py b/app/backoffice/router.py index 4707af8..da44431 100644 --- a/app/backoffice/router.py +++ b/app/backoffice/router.py @@ -1,22 +1,41 @@ +""" +Backoffice dashboard router. + +Provides administrative HTML endpoints for NeuroBank. +This module must only expose an APIRouter instance named `router`. +""" + from fastapi import APIRouter from fastapi.responses import HTMLResponse + router = APIRouter( prefix="/backoffice", - tags=["Backoffice Dashboard"], + tags=["Backoffice"], ) -@router.get("/", response_class=HTMLResponse) -async def backoffice_home(): - return """ - - - NeuroBank Backoffice - - -

NeuroBank Admin Dashboard

-

Backoffice is up and running.

- - - """ +@router.get( + "/", + response_class=HTMLResponse, + name="backoffice_home", + summary="Backoffice dashboard home", +) +async def backoffice_home() -> HTMLResponse: + """Render the main backoffice dashboard page.""" + return HTMLResponse( + content=""" + + + + + NeuroBank Backoffice + + +

NeuroBank Admin Dashboard

+

Backoffice is up and running.

+ + + """, + status_code=200, + ) diff --git a/app/backoffice/router_clean.py b/app/backoffice/router_clean.py index 12e5a1b..56d1c02 100644 --- a/app/backoffice/router_clean.py +++ b/app/backoffice/router_clean.py @@ -3,18 +3,19 @@ Enterprise-grade admin panel para impresionar reclutadores bancarios """ -import random -import uuid from datetime import datetime, timedelta from decimal import Decimal from enum import Enum +import random from typing import Any, Dict, List +import uuid from fastapi import APIRouter, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from pydantic import BaseModel, Field + # Router configuration router = APIRouter(prefix="/backoffice", tags=["Backoffice Dashboard"]) templates = Jinja2Templates(directory="app/backoffice/templates") diff --git a/app/config.py b/app/config.py index 420ce45..ff8c9dd 100644 --- a/app/config.py +++ b/app/config.py @@ -1,52 +1,71 @@ """ Application configuration and settings. -This module provides a centralized configuration management system using Pydantic. -It MUST NOT import FastAPI, routers, or app.main to avoid circular dependencies. +Centralized configuration management using Pydantic Settings. +This module MUST NOT import FastAPI, routers, or app.main +to avoid circular dependencies. """ from functools import lru_cache from typing import List -from pydantic_settings import BaseSettings +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Application settings loaded from environment variables.""" + # ------------------------------------------------------------------ # Application + # ------------------------------------------------------------------ app_name: str = "NeuroBank FastAPI Toolkit" app_version: str = "1.0.0" environment: str = "development" debug: bool = False + # ------------------------------------------------------------------ # Security - api_key: str = "" - secret_key: str = "" + # ------------------------------------------------------------------ + api_key: str = Field(default="", repr=False) + secret_key: str = Field(default="", repr=False) + # ------------------------------------------------------------------ # CORS - cors_origins: List[str] = ["*"] + # ------------------------------------------------------------------ + cors_origins: List[str] = Field(default_factory=lambda: ["*"]) + # ------------------------------------------------------------------ # Logging + # ------------------------------------------------------------------ log_level: str = "INFO" - class Config: - env_file = ".env" - case_sensitive = False - extra = "ignore" # Ignore extra environment variables + # ------------------------------------------------------------------ + # Pydantic Settings config (v2 style) + # ------------------------------------------------------------------ + model_config = SettingsConfigDict( + env_file=".env", + case_sensitive=False, + extra="ignore", + ) @property def is_production(self) -> bool: - """Check if running in production environment.""" + """Return True if running in production environment.""" return self.environment.lower() == "production" + @property + def security_enabled(self) -> bool: + """Check whether security credentials are configured.""" + return bool(self.api_key and self.secret_key) + -@lru_cache() +@lru_cache def get_settings() -> Settings: """ - Get cached settings instance. + Return cached Settings instance. - Uses lru_cache to ensure settings are loaded once and reused. - This avoids repeated environment variable reads. + Ensures environment variables are read once + and prevents configuration drift. """ return Settings() diff --git a/app/main.py b/app/main.py index 8aae500..75752e7 100644 --- a/app/main.py +++ b/app/main.py @@ -10,8 +10,8 @@ Dependencies flow: main.py -> config.py, routers (NEVER the reverse) """ -import logging from contextlib import asynccontextmanager +import logging from typing import Dict from fastapi import FastAPI @@ -19,6 +19,7 @@ from fastapi.responses import JSONResponse from app.backoffice.router import router as backoffice_router +from app.config import get_settings from app.routers import operator from app.utils.logging import setup_logging @@ -29,35 +30,29 @@ async def lifespan(app: FastAPI): Application lifespan manager. Configures logging on startup and ensures clean shutdown. - Settings are imported lazily inside the function to avoid circular imports. """ - # Lazy import to avoid circular dependency - from app.config import get_settings - settings = get_settings() - # Startup try: - setup_logging() # NO arguments - CodeQL requirement + setup_logging() # MUST be zero-arg (CodeQL) logging.info("Logging configured successfully") - logging.info(f"Starting {settings.app_name} v{settings.app_version}") - logging.info(f"Environment: {settings.environment}") - except Exception as exc: + logging.info("Starting %s v%s", settings.app_name, settings.app_version) + logging.info("Environment: %s", settings.environment) + except Exception as exc: # pragma: no cover - defensive fallback logging.basicConfig(level=logging.INFO) logging.error("Failed to configure logging, using basic config", exc_info=exc) yield - # Shutdown logging.info("Application shutdown completed") -# Lazy settings access -from app.config import get_settings +# ------------------------------------------------------------------- +# App initialization +# ------------------------------------------------------------------- settings = get_settings() -# Create FastAPI app app = FastAPI( title=settings.app_name, version=settings.app_version, @@ -67,7 +62,10 @@ async def lifespan(app: FastAPI): redoc_url="/redoc", ) -# Configure CORS middleware +# ------------------------------------------------------------------- +# Middleware +# ------------------------------------------------------------------- + app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, @@ -76,10 +74,17 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -# Include routers -app.include_router(operator.router, prefix="/api", tags=["API"]) +# ------------------------------------------------------------------- +# Routers +# ------------------------------------------------------------------- + +app.include_router(operator.router) app.include_router(backoffice_router) +# ------------------------------------------------------------------- +# Endpoints +# ------------------------------------------------------------------- + @app.get( "/", @@ -98,14 +103,14 @@ async def root() -> Dict[str, object]: }, "endpoints": { "health_check": "/health", - "operator_operations": "/api", + "api": "/api", "backoffice": "/backoffice", }, "features": [ - "馃彟 Banking Operations", - "馃攼 API Key Authentication", - "馃搳 Admin Backoffice Dashboard", - "鈽侊笍 Cloud & Serverless Ready", + "Banking Operations API", + "API Key Authentication", + "Admin Backoffice Dashboard", + "Cloud & Serverless Ready", ], } diff --git a/app/routers/operator.py b/app/routers/operator.py index 2074004..51a5b76 100644 --- a/app/routers/operator.py +++ b/app/routers/operator.py @@ -1,14 +1,20 @@ -from fastapi import APIRouter, Depends, HTTPException, Path, status +""" +Banking operations API router. + +Exposes secured endpoints for order status queries and invoice generation. +""" + +from fastapi import APIRouter, Depends, Path, status from pydantic import BaseModel, Field -from ..auth.dependencies import verify_api_key -from ..services.invoice_service import generate_invoice -from ..services.order_service import get_order_status +from app.auth.dependencies import verify_api_key +from app.services.invoice_service import generate_invoice +from app.services.order_service import get_order_status + -# Router con documentaci贸n mejorada router = APIRouter( - prefix="", - tags=["馃彟 Banking Operations"], + prefix="/api", + tags=["Banking Operations"], responses={ 401: {"description": "API Key missing or invalid"}, 404: {"description": "Resource not found"}, @@ -16,32 +22,18 @@ }, ) -# ----- Modelos Pydantic con documentaci贸n mejorada ----- +# ------------------------------------------------------------------- +# Pydantic models +# ------------------------------------------------------------------- class OrderStatusResponse(BaseModel): - """ - **Respuesta del estado de una orden bancaria** + """Response containing the current status of a banking order.""" - Contiene informaci贸n completa sobre el estado actual de una transacci贸n. - """ - - order_id: str = Field( - ..., description="Identificador 煤nico de la orden", examples=["ORD-2025-001234"] - ) - status: str = Field( - ..., description="Estado actual de la orden", examples=["processing"] - ) - carrier: str = Field( - ..., - description="Entidad procesadora de la transacci贸n", - examples=["VISA_NETWORK"], - ) - eta: str = Field( - ..., - description="Tiempo estimado de finalizaci贸n (ISO 8601)", - examples=["2025-07-20T16:30:00Z"], - ) + order_id: str = Field(..., examples=["ORD-2025-001234"]) + status: str = Field(..., examples=["processing"]) + carrier: str = Field(..., examples=["VISA_NETWORK"]) + eta: str = Field(..., examples=["2025-07-20T16:30:00Z"]) model_config = { "json_schema_extra": { @@ -56,49 +48,24 @@ class OrderStatusResponse(BaseModel): class InvoiceRequest(BaseModel): - """ - **Solicitud para generar factura** - - Datos necesarios para generar una factura de transacci贸n bancaria. - """ + """Request payload for invoice generation.""" order_id: str = Field( ..., - description="ID de la orden para la cual generar la factura", - examples=["ORD-2025-001234"], min_length=5, max_length=50, + examples=["ORD-2025-001234"], ) - model_config = {"json_schema_extra": {"example": {"order_id": "ORD-2025-001234"}}} - class InvoiceResponse(BaseModel): - """ - **Respuesta de factura generada** + """Response containing generated invoice details.""" - Contiene los detalles completos de la factura generada. - """ - - invoice_id: str = Field( - ..., - description="Identificador 煤nico de la factura generada", - examples=["INV-2025-789012"], - ) - order_id: str = Field( - ..., description="ID de la orden asociada", examples=["ORD-2025-001234"] - ) - amount: float = Field( - ..., description="Monto total de la factura", examples=[1250.75], ge=0 - ) - currency: str = Field( - ..., description="C贸digo de moneda ISO 4217", examples=["EUR"], max_length=3 - ) - issued_at: str = Field( - ..., - description="Fecha y hora de emisi贸n (ISO 8601)", - examples=["2025-07-20T15:45:30Z"], - ) + invoice_id: str = Field(..., examples=["INV-2025-789012"]) + order_id: str = Field(..., examples=["ORD-2025-001234"]) + amount: float = Field(..., ge=0, examples=[1250.75]) + currency: str = Field(..., max_length=3, examples=["EUR"]) + issued_at: str = Field(..., examples=["2025-07-20T15:45:30Z"]) model_config = { "json_schema_extra": { @@ -113,174 +80,37 @@ class InvoiceResponse(BaseModel): } -# ----- Endpoints con documentaci贸n completa ----- +# ------------------------------------------------------------------- +# Endpoints +# ------------------------------------------------------------------- @router.get( "/order/{order_id}", response_model=OrderStatusResponse, - summary="馃搳 Consultar Estado de Orden", - description=""" - **Consulta el estado actual de una orden bancaria** - - Este endpoint permite verificar el estado de procesamiento de cualquier - transacci贸n bancaria utilizando su identificador 煤nico. - - ### 馃攳 Casos de uso: - - Seguimiento de transferencias en tiempo real - - Verificaci贸n de estado de pagos - - Monitoreo de transacciones pendientes - - Auditor铆a de operaciones bancarias - - ### 馃搵 Estados posibles: - - `pending`: Orden recibida, esperando procesamiento - - `processing`: Transacci贸n en curso - - `completed`: Operaci贸n finalizada exitosamente - - `failed`: Error en el procesamiento - - `cancelled`: Orden cancelada por el usuario - - ### 馃攼 Autenticaci贸n: - Requiere API Key v谩lida en el header `X-API-Key`. - """, + summary="Get order status", dependencies=[Depends(verify_api_key)], - responses={ - 200: { - "description": "Estado de orden obtenido exitosamente", - "content": { - "application/json": { - "examples": { - "processing_order": { - "summary": "Orden en procesamiento", - "value": { - "order_id": "ORD-2025-001234", - "status": "processing", - "carrier": "VISA_NETWORK", - "eta": "2025-07-20T16:30:00Z", - }, - }, - "completed_order": { - "summary": "Orden completada", - "value": { - "order_id": "ORD-2025-005678", - "status": "completed", - "carrier": "MASTERCARD_NETWORK", - "eta": "2025-07-20T15:45:00Z", - }, - }, - } - } - }, - }, - 404: { - "description": "Orden no encontrada", - "content": { - "application/json": { - "example": {"detail": "Order ORD-2025-999999 not found"} - } - }, - }, - }, ) async def order_status( order_id: str = Path( ..., - description="Identificador 煤nico de la orden a consultar", - examples=["ORD-2025-001234"], pattern="^[A-Z]{3}-[0-9]{4}-[0-9]{6}$", - ) -): - """ - **Endpoint para consultar el estado de una orden bancaria** - - Procesa la consulta de estado y retorna informaci贸n detallada - sobre el procesamiento actual de la transacci贸n. - """ + examples=["ORD-2025-001234"], + ), +) -> OrderStatusResponse: + """Return the current processing status of an order.""" return get_order_status(order_id) @router.post( - "/invoice/{invoice_id}", + "/invoice", response_model=InvoiceResponse, - summary="馃Ь Generar Factura", - description=""" - **Genera una factura oficial para una orden completada** - - Este endpoint crea una factura detallada para una transacci贸n bancaria - espec铆fica, incluyendo todos los datos fiscales requeridos. - - ### 馃搵 Caracter铆sticas: - - Generaci贸n autom谩tica de ID de factura - - C谩lculo de montos con precisi贸n decimal - - Timestamp de emisi贸n en formato ISO 8601 - - Cumplimiento con normativas fiscales europeas - - ### 馃捈 Casos de uso: - - Facturaci贸n autom谩tica post-transacci贸n - - Generaci贸n de comprobantes para auditor铆as - - Documentaci贸n fiscal de operaciones - - Integraci贸n con sistemas contables - - ### 鈿狅笍 Restricciones: - - Solo se pueden facturar 贸rdenes con estado `completed` - - Una orden puede tener m煤ltiples facturas (refacturaci贸n) - - Los montos se calculan incluyendo comisiones aplicables - - ### 馃攼 Autenticaci贸n: - Requiere API Key v谩lida en el header `X-API-Key`. - """, + summary="Generate invoice", + status_code=status.HTTP_201_CREATED, dependencies=[Depends(verify_api_key)], - responses={ - 200: { - "description": "Factura generada exitosamente", - "content": { - "application/json": { - "examples": { - "standard_invoice": { - "summary": "Factura est谩ndar", - "value": { - "invoice_id": "INV-2025-789012", - "order_id": "ORD-2025-001234", - "amount": 1250.75, - "currency": "EUR", - "issued_at": "2025-07-20T15:45:30Z", - }, - }, - "high_amount_invoice": { - "summary": "Factura de alto importe", - "value": { - "invoice_id": "INV-2025-789013", - "order_id": "ORD-2025-005678", - "amount": 50000.00, - "currency": "USD", - "issued_at": "2025-07-20T15:50:15Z", - }, - }, - } - } - }, - }, - 404: { - "description": "Orden no encontrada", - "content": { - "application/json": { - "example": { - "detail": "Order ORD-2025-999999 not found or not eligible for invoicing" - } - } - }, - }, - }, ) -async def invoice( - invoice_id: str = Path( - ..., description="ID de la factura a generar", examples=["INV-2025-789012"] - ), - data: InvoiceRequest = None, -): - """ - **Endpoint para generar facturas de 贸rdenes bancarias** - - Procesa la solicitud de facturaci贸n y genera un documento oficial - con todos los detalles fiscales requeridos. - """ +async def create_invoice( + data: InvoiceRequest, +) -> InvoiceResponse: + """Generate an invoice for a completed order.""" return generate_invoice(data.order_id) diff --git a/app/telemetry.py b/app/telemetry.py index ad7884e..386362d 100644 --- a/app/telemetry.py +++ b/app/telemetry.py @@ -10,6 +10,7 @@ from fastapi import FastAPI + logger = logging.getLogger(__name__) diff --git a/app/tests/test_logging.py b/app/tests/test_logging.py index eb44417..588da72 100644 --- a/app/tests/test_logging.py +++ b/app/tests/test_logging.py @@ -12,48 +12,51 @@ class TestSetupLogging: """Tests for setup_logging function.""" - def test_setup_logging_returns_logger(self): - """Test that setup_logging returns a logger instance.""" - logger = setup_logging() - assert isinstance(logger, logging.Logger) + def test_setup_logging_configures_root_logger(self): + """Test that setup_logging configures the root logger.""" + setup_logging() + root = logging.getLogger() + assert root.level in ( + logging.DEBUG, + logging.INFO, + logging.WARNING, + logging.ERROR, + ) def test_setup_logging_default_level(self): """Test setup_logging with default INFO level.""" with patch.dict(os.environ, {}, clear=False): os.environ.pop("LOG_LEVEL", None) - logger = setup_logging() - assert logger.level == logging.INFO + setup_logging() + root = logging.getLogger() + assert root.level == logging.INFO def test_setup_logging_debug_level(self): """Test setup_logging with DEBUG level.""" with patch.dict(os.environ, {"LOG_LEVEL": "DEBUG"}, clear=False): - logger = setup_logging() - assert logger.level == logging.DEBUG + setup_logging() + root = logging.getLogger() + assert root.level == logging.DEBUG def test_setup_logging_warning_level(self): """Test setup_logging with WARNING level.""" with patch.dict(os.environ, {"LOG_LEVEL": "WARNING"}, clear=False): - logger = setup_logging() - assert logger.level == logging.WARNING + setup_logging() + root = logging.getLogger() + assert root.level == logging.WARNING def test_setup_logging_invalid_level_defaults_to_info(self): """Test setup_logging with invalid level defaults to INFO.""" with patch.dict(os.environ, {"LOG_LEVEL": "INVALID"}, clear=False): - logger = setup_logging() - assert logger.level == logging.INFO + setup_logging() + root = logging.getLogger() + assert root.level == logging.INFO - def test_setup_logging_clears_existing_handlers(self): - """Test that setup_logging clears existing handlers.""" - # Add a dummy handler - root = logging.getLogger() - dummy_handler = logging.StreamHandler() - root.addHandler(dummy_handler) - - # Setup logging should clear it + def test_setup_logging_adds_handler(self): + """Test that setup_logging adds at least one handler.""" setup_logging() - - # Verify only one handler exists - assert len(root.handlers) == 1 + root = logging.getLogger() + assert len(root.handlers) >= 1 class TestGetLogger: diff --git a/app/tests/test_main.py b/app/tests/test_main.py index bbfc948..431ee7c 100644 --- a/app/tests/test_main.py +++ b/app/tests/test_main.py @@ -1,5 +1,5 @@ -import pytest from httpx import ASGITransport, AsyncClient +import pytest from app.main import app diff --git a/app/tests/test_operator.py b/app/tests/test_operator.py index d7bb2e4..738fece 100644 --- a/app/tests/test_operator.py +++ b/app/tests/test_operator.py @@ -1,11 +1,12 @@ import os -import pytest from httpx import ASGITransport, AsyncClient +import pytest from app.config import get_settings from app.main import app + # Obtener API key del sistema de configuraci贸n settings = get_settings() TEST_API_KEY = settings.api_key @@ -36,12 +37,12 @@ async def test_generate_invoice(): transport=ASGITransport(app=app), base_url="http://test" ) as ac: resp = await ac.post( - "/api/invoice/INV-2025-001", + "/api/invoice", json={"order_id": "ORD-2025-001234"}, headers={"X-API-Key": TEST_API_KEY}, ) - assert resp.status_code == 200 + assert resp.status_code == 201 data = resp.json() assert data["order_id"] == "ORD-2025-001234" assert "invoice_id" in data diff --git a/app/tests/test_telemetry.py b/app/tests/test_telemetry.py index f6d6e0d..831413a 100644 --- a/app/tests/test_telemetry.py +++ b/app/tests/test_telemetry.py @@ -3,8 +3,8 @@ import logging from unittest.mock import MagicMock, patch -import pytest from fastapi import FastAPI +import pytest from app.telemetry import log_request_metrics, setup_telemetry diff --git a/app/utils/logging.py b/app/utils/logging.py index 086e915..9959255 100644 --- a/app/utils/logging.py +++ b/app/utils/logging.py @@ -1,8 +1,9 @@ """ Logging configuration module. -This module configures structured JSON logging for the application. -It reads configuration internally to avoid circular dependencies. +Configures structured JSON logging for the application. +Reads configuration from environment variables internally to avoid +circular dependencies with app.config. """ import logging @@ -12,46 +13,41 @@ from pythonjsonlogger import jsonlogger -def setup_logging(): +def setup_logging() -> None: """ - Configure application logging system with NO parameters. + Configure application logging system. - Reads configuration from environment variables internally to avoid - circular import dependencies with app.config module. - - This function MUST be called with zero arguments to satisfy CodeQL - and maintain architectural boundaries. + IMPORTANT: + - This function MUST be called with ZERO arguments. + - Configuration is read exclusively from environment variables. + - Designed to satisfy CodeQL and avoid circular imports. """ - # Read log level from environment, default to INFO + # Read log level from environment (default: INFO) log_level = os.getenv("LOG_LEVEL", "INFO").upper() - - # Validate log level numeric_level = getattr(logging, log_level, logging.INFO) - # Create JSON formatter formatter = jsonlogger.JsonFormatter( - fmt="%(asctime)s %(name)s %(levelname)s %(message)s" + "%(asctime)s %(name)s %(levelname)s %(message)s" ) - # Configure handler for stdout handler = logging.StreamHandler(sys.stdout) handler.setFormatter(formatter) - # Configure root logger root_logger = logging.getLogger() - root_logger.setLevel(numeric_level) - # Clear existing handlers to avoid duplicates - root_logger.handlers.clear() - root_logger.addHandler(handler) + # Prevent duplicate handlers if setup_logging is called twice + if not root_logger.handlers: + root_logger.addHandler(handler) - # Configure uvicorn logger - uvicorn_logger = logging.getLogger("uvicorn") - uvicorn_logger.setLevel(numeric_level) + root_logger.setLevel(numeric_level) - return root_logger + # Uvicorn loggers (important in prod) + for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + logging.getLogger(logger_name).setLevel(numeric_level) def get_logger(name: str) -> logging.Logger: - """Get a configured logger for the specified module.""" + """ + Return a logger configured by setup_logging. + """ return logging.getLogger(name) diff --git a/pyproject.toml b/pyproject.toml index 7c1569c..a633bce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,10 @@ [tool.isort] profile = "black" line_length = 88 + known_first_party = ["app"] +known_third_party = ["fastapi", "pydantic", "starlette"] + combine_as_imports = true +force_sort_within_sections = true +lines_after_imports = 2 diff --git a/requirements.txt b/requirements.txt index 78a9bcb..38226e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ fastapi==0.124.4 starlette==0.49.1 + uvicorn[standard]==0.38.0 uvloop==0.21.0 @@ -23,4 +24,3 @@ pytest-cov==5.0.0 watchtower==3.0.0 aws-xray-sdk==2.13.0 mangum==0.17.0 -