Initial commit

This commit is contained in:
2026-01-12 08:17:17 -07:00
commit de5b6feef4
104 changed files with 12925 additions and 0 deletions

171
backend/README.md Normal file
View File

@@ -0,0 +1,171 @@
# ServiceManagerWeb Backend
FastAPI backend para el sistema de Mesa de Ayuda B2B multi-tenant.
## Estructura
```
backend/
├── app/
│ ├── main.py # FastAPI app principal
│ ├── core/ # Configuración y utilidades core
│ │ ├── config.py # Configuración con Pydantic Settings
│ │ ├── database.py # SQLAlchemy async setup
│ │ ├── security.py # JWT, hashing, 2FA
│ │ └── logging.py # Structured logging
│ ├── models/ # Modelos SQLAlchemy
│ │ ├── tenant.py # Modelo de tenant (multi-tenancy)
│ │ ├── user.py # Modelo de usuario
│ │ └── ... # Otros modelos
│ ├── api/ # API routes
│ │ └── v1/ # API version 1
│ │ ├── router.py # Router principal
│ │ └── endpoints/ # Endpoints por dominio
│ ├── middleware/ # Custom middleware
│ ├── services/ # Business logic
│ ├── repositories/ # Data access layer
│ ├── schemas/ # Pydantic schemas
│ └── utils/ # Utilidades compartidas
├── tests/ # Tests unitarios e integración
├── migrations/ # Migraciones Alembic
├── requirements.txt # Dependencias Python
└── pyproject.toml # Configuración del proyecto
```
## Características Implementadas
### Core
- [x] FastAPI app con configuración async
- [x] Pydantic Settings para configuración
- [x] SQLAlchemy 2.0 async
- [x] Structured logging con structlog
- [x] JWT authentication con refresh tokens
- [x] 2FA con TOTP
- [x] Multi-tenancy middleware
### API
- [x] Health checks (/health, /health/detailed)
- [x] Authentication endpoints básicos
- [x] Middleware de correlation ID y tenant
- [x] Error handling centralizado
- [x] CORS configurado
### Seguridad
- [x] Argon2 password hashing
- [x] JWT con algoritmos seguros
- [x] TOTP 2FA implementation
- [x] Validation con Pydantic v2
## Quick Start
```bash
# Instalar dependencias
pip install -r requirements.txt
# Variables de entorno (copiar desde raíz del proyecto)
cp ../.env.example .env
# Ejecutar en desarrollo
uvicorn app.main:app --reload --port 8000
# O usar Docker
docker-compose up backend
```
## Testing
```bash
# Ejecutar tests
pytest
# Con coverage
pytest --cov=app tests/
# Solo tests unitarios
pytest -m "unit"
# Solo tests de integración
pytest -m "integration"
```
## Code Quality
```bash
# Linting
ruff check .
# Formateo
black .
# Type checking
mypy .
# Fix automático
ruff check . --fix
black .
```
## Desarrollo
### Agregar nuevos endpoints
1. Crear schema en `app/schemas/`
2. Crear endpoint en `app/api/v1/endpoints/`
3. Registrar router en `app/api/v1/router.py`
4. Agregar tests en `tests/`
### Modelos de base de datos
1. Crear modelo en `app/models/`
2. Importar en `app/models/__init__.py`
3. Crear migración: `alembic revision --autogenerate -m "descripción"`
4. Aplicar migración: `alembic upgrade head`
### Variables de entorno
Todas las configuraciones están en `app/core/config.py` usando Pydantic Settings.
Ver `.env.example` para todas las variables disponibles.
## Arquitectura
### Clean Architecture
- **Presentation**: FastAPI endpoints y schemas
- **Application**: Services y casos de uso
- **Domain**: Entidades y reglas de negocio
- **Infrastructure**: Repositorios, DB, external APIs
### Patrones implementados
- Repository pattern para acceso a datos
- Dependency injection con FastAPI Depends
- Unit of Work para transacciones
- Command/Query separation
## Monitoring
- Structured logging con correlation IDs
- Health checks para load balancer
- Métricas con Prometheus (TODO)
- Error tracking (TODO)
## Security Checklist
- [x] Password hashing con Argon2
- [x] JWT con secret keys seguras
- [x] CORS restrictivo
- [x] Input validation con Pydantic
- [x] SQL injection protection (SQLAlchemy)
- [x] Rate limiting (TODO: implementar)
- [x] File upload validation (TODO: implementar)
- [x] XSS protection (headers en nginx)
## Próximos pasos
1. Implementar repositorios y services
2. Completar autenticación con base de datos
3. Agregar endpoints de users y tickets
4. Implementar rate limiting
5. Agregar métricas y monitoring
6. Tests de integración completos

22
backend/add_columns.py Normal file
View File

@@ -0,0 +1,22 @@
import asyncio
from sqlalchemy import text
from app.core.database import engine
async def add_columns():
print("Starting schema update...")
async with engine.begin() as conn:
try:
await conn.execute(text("ALTER TABLE tickets ADD COLUMN system_id UUID REFERENCES systems(id)"))
print("Added system_id column")
except Exception as e:
print(f"Error adding system_id (might exist): {e}")
try:
await conn.execute(text("ALTER TABLE tickets ADD COLUMN category_id UUID REFERENCES categories(id)"))
print("Added category_id column")
except Exception as e:
print(f"Error adding category_id (might exist): {e}")
print("Schema update finished.")
if __name__ == "__main__":
asyncio.run(add_columns())

57
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,57 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import ValidationError
from app.core.database import get_db
from app.core.security import security
from app.core.config import get_settings
from app.models.user import User, UserRole
settings = get_settings()
# Define OAuth2 scheme here or import from auth if needed.
# Defining here creates a separate instance which is fine as they share config.
# Ideally auth.py should import from here, but modifying auth.py is risky now.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
payload = security.verify_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id: str = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalars().first()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
async def get_current_active_superuser(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user

View File

@@ -0,0 +1,297 @@
"""
Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, EmailStr
from typing import Optional
import structlog
from app.core.database import get_db
from app.core.security import security
from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
router = APIRouter()
logger = structlog.get_logger(__name__)
settings = get_settings()
# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"/{settings.API_VERSION}/auth/login")
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class LoginRequest(BaseModel):
"""Schema for login request."""
email: EmailStr
password: str
tenant_slug: str
totp_code: Optional[str] = None
class LoginResponse(BaseModel):
"""Schema for login response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class RefreshTokenRequest(BaseModel):
"""Schema for refresh token request."""
refresh_token: str
class TokenResponse(BaseModel):
"""Schema for token response."""
access_token: str
token_type: str = "bearer"
expires_in: int
# ===================================
# ENDPOINTS
# ===================================
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
db: AsyncSession = Depends(get_db)
):
"""
Authenticate user and return access/refresh tokens.
Args:
login_data: Login credentials
db: Database session
Returns:
LoginResponse with tokens and user info
Raises:
HTTPException: If authentication fails
"""
logger.info(
"Login attempt",
email=login_data.email,
tenant_slug=login_data.tenant_slug
)
# 1. Buscar usuario en base de datos
query = select(User).where(User.email == login_data.email)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 2. Verificar usuario y contraseña
if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning(
"Login failed - invalid credentials",
email=login_data.email
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
)
# 3. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
email=login_data.email
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
)
# Create tokens
token_data = {
"sub": str(user.id),
"email": user.email,
"role": user.role.value if hasattr(user.role, "value") else user.role,
"tenant_id": str(user.tenant_id)
}
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
logger.info(
"Login successful",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
user_id=str(user.id)
)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
user={
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"role": user.role,
"tenant_id": str(user.tenant_id),
"is_active": user.is_active,
"is_two_factor_enabled": user.totp_enabled or False,
"created_at": user.created_at.isoformat() if user.created_at else None
}
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
refresh_data: RefreshTokenRequest,
db: AsyncSession = Depends(get_db)
):
"""
Refresh access token using refresh token.
Args:
refresh_data: Refresh token data
db: Database session
Returns:
New access token
Raises:
HTTPException: If refresh token is invalid
"""
logger.info("Token refresh attempt")
# Verify refresh token
payload = security.verify_token(refresh_data.refresh_token)
if not payload or payload.get("type") != "refresh":
logger.warning("Token refresh failed - invalid token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# TODO: Check if refresh token exists in database and is not revoked
# Create new access token
token_data = {
"sub": payload["sub"],
"email": payload["email"],
"role": payload["role"],
"tenant_id": payload["tenant_id"]
}
access_token = security.create_access_token(token_data)
logger.info("Token refresh successful", user_id=payload["sub"])
return TokenResponse(
access_token=access_token,
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
@router.post("/logout")
async def logout(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
"""
Logout user and revoke refresh token.
Args:
token: Access token
db: Database session
Returns:
Success message
"""
logger.info("Logout attempt")
# Verify token
payload = security.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# TODO: Revoke refresh token in database
logger.info("Logout successful", user_id=payload["sub"])
return {"message": "Successfully logged out"}
@router.get("/me")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
):
"""
Get current user information.
Args:
token: Access token
db: Database session
Returns:
Current user data
Raises:
HTTPException: If token is invalid
"""
# Verify token
payload = security.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token"
)
# TODO: Fetch actual user from database
return {
"id": payload["sub"],
"email": payload["email"],
"role": payload["role"],
"tenant_id": payload["tenant_id"]
}
# ===================================
# DEPENDENCIES
# ===================================
async def get_current_active_user(token: str = Depends(oauth2_scheme)):
"""
Dependency to get current active user from token.
Args:
token: Access token
Returns:
Current user data
Raises:
HTTPException: If token is invalid or user is inactive
"""
payload = security.verify_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
# TODO: Verify user exists and is active
return payload

View File

@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
import uuid
from app.core.database import get_db
from app.models.category import Category
from app.api import deps
router = APIRouter()
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
is_active: bool = True
tenant_id: Optional[uuid.UUID] = None
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(CategoryBase):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
tenant_id: Optional[uuid.UUID] = None
class CategoryResponse(CategoryBase):
id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@router.get("/", response_model=List[CategoryResponse])
async def read_categories(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
query = select(Category).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=CategoryResponse)
async def create_category(
category: CategoryCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
db_category = Category(**category.model_dump())
db.add(db_category)
await db.commit()
await db.refresh(db_category)
return db_category

View File

@@ -0,0 +1,88 @@
"""
Health Check Endpoints - ServiceManagerWeb
Endpoints para health checks y monitoring
"""
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.core.database import get_db, check_database_health
from app.core.config import get_settings
router = APIRouter()
logger = structlog.get_logger(__name__)
settings = get_settings()
@router.get("/health")
async def health_check():
"""
Basic health check endpoint.
Returns basic service information and status.
"""
return {
"status": "healthy",
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"environment": settings.ENVIRONMENT
}
@router.get("/health/detailed")
async def detailed_health_check(db: AsyncSession = Depends(get_db)):
"""
Detailed health check with database connectivity.
Checks database connection and returns detailed status.
"""
# Check database
db_healthy = await check_database_health()
# TODO: Add Redis health check
# TODO: Add Celery health check
overall_status = "healthy" if db_healthy else "unhealthy"
status_code = status.HTTP_200_OK if db_healthy else status.HTTP_503_SERVICE_UNAVAILABLE
health_data = {
"status": overall_status,
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"environment": settings.ENVIRONMENT,
"checks": {
"database": "healthy" if db_healthy else "unhealthy",
"redis": "not_implemented",
"celery": "not_implemented"
}
}
if not db_healthy:
logger.error("Health check failed - database unhealthy")
return health_data
@router.get("/readiness")
async def readiness_check():
"""
Kubernetes readiness probe endpoint.
Returns 200 if service is ready to accept traffic.
"""
# For now, just return ready
# In production, this might check for startup completion,
# database migrations, etc.
return {"status": "ready"}
@router.get("/liveness")
async def liveness_check():
"""
Kubernetes liveness probe endpoint.
Returns 200 if service is alive and should not be restarted.
"""
return {"status": "alive"}

View File

@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
import uuid
from app.core.database import get_db
from app.models.system import System
from app.api import deps
router = APIRouter()
class SystemBase(BaseModel):
name: str
description: Optional[str] = None
is_active: bool = True
class SystemCreate(SystemBase):
pass
class SystemUpdate(SystemBase):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class SystemResponse(SystemBase):
id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@router.get("/", response_model=List[SystemResponse])
async def read_systems(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
query = select(System).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=SystemResponse)
async def create_system(
system: SystemCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
db_system = System(**system.model_dump())
db.add(db_system)
await db.commit()
await db.refresh(db_system)
return db_system

View File

@@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
import uuid
from app.core.database import get_db
from app.models.tenant import Tenant, TenantStatus
from app.api import deps
router = APIRouter()
class TenantBase(BaseModel):
name: str
slug: str
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
class TenantCreate(TenantBase):
pass
class TenantUpdate(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
status: Optional[TenantStatus] = None
class TenantResponse(TenantBase):
id: uuid.UUID
status: TenantStatus
model_config = ConfigDict(from_attributes=True)
@router.get("/", response_model=List[TenantResponse])
async def read_tenants(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
query = select(Tenant).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=TenantResponse)
async def create_tenant(
tenant: TenantCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
# Check existing slug
query = select(Tenant).where(Tenant.slug == tenant.slug)
result = await db.execute(query)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Tenant slug already exists")
db_tenant = Tenant(**tenant.model_dump())
db.add(db_tenant)
await db.commit()
await db.refresh(db_tenant)
return db_tenant
@router.get("/{tenant_id}", response_model=TenantResponse)
async def read_tenant(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return tenant
@router.put("/{tenant_id}", response_model=TenantResponse)
async def update_tenant(
tenant_id: uuid.UUID,
tenant_in: TenantUpdate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
update_data = tenant_in.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tenant, field, value)
db.add(tenant)
await db.commit()
await db.refresh(tenant)
return tenant

View File

@@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
import uuid
from app.core.database import get_db
from app.core.security import security
from app.models.user import User, UserRole
from app.api import deps
router = APIRouter()
class UserBase(BaseModel):
email: EmailStr
first_name: str
last_name: str
role: UserRole
is_active: bool = True
tenant_id: Optional[uuid.UUID] = None
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
password: Optional[str] = None # Optional password update
class UserResponse(UserBase):
id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@router.get("/", response_model=List[UserResponse])
async def read_users(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
query = select(User).offset(skip).limit(limit)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=UserResponse)
async def create_user(
user: UserCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
query = select(User).where(User.email == user.email)
result = await db.execute(query)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user_data = user.model_dump(exclude={"password"})
password_hash = security.get_password_hash(user.password)
db_user = User(**user_data, password_hash=password_hash)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user

View File

@@ -0,0 +1,47 @@
"""
API v1 Router - ServiceManagerWeb
Router principal para la API v1
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories
api_router = APIRouter()
# Health check routes
api_router.include_router(
health.router,
tags=["health"]
)
# Authentication routes
api_router.include_router(
auth.router,
prefix="/auth",
tags=["authentication"]
)
api_router.include_router(
tenants.router,
prefix="/tenants",
tags=["tenants"]
)
api_router.include_router(
users.router,
prefix="/users",
tags=["users"]
)
api_router.include_router(
systems.router,
prefix="/systems",
tags=["systems"]
)
api_router.include_router(
categories.router,
prefix="/categories",
tags=["categories"]
)

142
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Core Configuration - ServiceManagerWeb
Configuración centralizada usando Pydantic Settings v2
"""
from functools import lru_cache
from typing import List, Optional
from pydantic_settings import BaseSettings
from pydantic import field_validator, Field
import os
class Settings(BaseSettings):
"""Configuración de la aplicación."""
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8",
"case_sensitive": False
}
# ===================================
# GENERAL
# ===================================
ENVIRONMENT: str = Field(default="development", env="ENVIRONMENT")
DEBUG: bool = Field(default=False, env="DEBUG")
SECRET_KEY: str = Field(..., env="SECRET_KEY")
API_VERSION: str = Field(default="v1", env="API_VERSION")
# ===================================
# DATABASE
# ===================================
DATABASE_URL: str = Field(..., env="DATABASE_URL")
# ===================================
# REDIS
# ===================================
REDIS_URL: str = Field(..., env="REDIS_URL")
# ===================================
# JWT AUTHENTICATION
# ===================================
JWT_SECRET_KEY: str = Field(..., env="JWT_SECRET_KEY")
JWT_ALGORITHM: str = Field(default="HS256", env="JWT_ALGORITHM")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60, env="ACCESS_TOKEN_EXPIRE_MINUTES")
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, env="REFRESH_TOKEN_EXPIRE_DAYS")
# ===================================
# CORS
# ===================================
CORS_ORIGINS: str = Field(
default="http://localhost:3000,http://localhost:3001",
env="CORS_ORIGINS"
)
# ===================================
# EMAIL
# ===================================
SMTP_HOST: str = Field(default="localhost", env="SMTP_HOST")
SMTP_PORT: int = Field(default=587, env="SMTP_PORT")
SMTP_USER: Optional[str] = Field(default=None, env="SMTP_USER")
SMTP_PASSWORD: Optional[str] = Field(default=None, env="SMTP_PASSWORD")
SMTP_USE_TLS: bool = Field(default=True, env="SMTP_USE_TLS")
SMTP_USE_SSL: bool = Field(default=False, env="SMTP_USE_SSL")
DEFAULT_FROM_EMAIL: str = Field(default="noreply@servicemanager.local", env="DEFAULT_FROM_EMAIL")
DEFAULT_FROM_NAME: str = Field(default="ServiceManager", env="DEFAULT_FROM_NAME")
# ===================================
# FILE UPLOADS
# ===================================
MAX_UPLOAD_SIZE_MB: int = Field(default=10, env="MAX_UPLOAD_SIZE_MB")
ALLOWED_FILE_EXTENSIONS: List[str] = Field(
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"],
env="ALLOWED_FILE_EXTENSIONS"
)
UPLOAD_PATH: str = Field(default="/app/uploads", env="UPLOAD_PATH")
@field_validator("ALLOWED_FILE_EXTENSIONS", mode='before')
@classmethod
def validate_file_extensions(cls, v):
if isinstance(v, str):
return [ext.strip().lower() for ext in v.split(",")]
return [ext.lower() for ext in v]
# ===================================
# SECURITY
# ===================================
RATE_LIMIT_ENABLED: bool = Field(default=True, env="RATE_LIMIT_ENABLED")
PASSWORD_MIN_LENGTH: int = Field(default=8, env="PASSWORD_MIN_LENGTH")
# Argon2 settings
ARGON2_TIME_COST: int = Field(default=3, env="ARGON2_TIME_COST")
ARGON2_MEMORY_COST: int = Field(default=65536, env="ARGON2_MEMORY_COST")
ARGON2_PARALLELISM: int = Field(default=4, env="ARGON2_PARALLELISM")
# ===================================
# LOGGING
# ===================================
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")
LOG_FORMAT: str = Field(default="json", env="LOG_FORMAT")
LOG_FILE: Optional[str] = Field(default=None, env="LOG_FILE")
# ===================================
# FRONTEND URLS
# ===================================
CLIENT_FRONTEND_URL: str = Field(default="http://localhost:3000", env="CLIENT_FRONTEND_URL")
INTERNAL_FRONTEND_URL: str = Field(default="http://localhost:3001", env="INTERNAL_FRONTEND_URL")
# ===================================
# HEALTH CHECKS
# ===================================
HEALTH_CHECK_TIMEOUT: int = Field(default=30, env="HEALTH_CHECK_TIMEOUT")
# ===================================
# CELERY
# ===================================
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
def is_production(self) -> bool:
"""Check if environment is production."""
return self.ENVIRONMENT.lower() == "production"
def is_development(self) -> bool:
"""Check if environment is development."""
return self.ENVIRONMENT.lower() == "development"
def is_testing(self) -> bool:
"""Check if environment is testing."""
return self.ENVIRONMENT.lower() == "testing"
@lru_cache()
def get_settings() -> Settings:
"""
Get cached settings instance.
Using lru_cache to create a singleton pattern for settings.
"""
return Settings()

View File

@@ -0,0 +1,94 @@
"""
Database Configuration - ServiceManagerWeb
SQLAlchemy 2.0 async setup con PostgreSQL
"""
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, DateTime, func
from typing import AsyncGenerator
import uuid
from datetime import datetime
from app.core.config import get_settings
settings = get_settings()
# Create async engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=5,
max_overflow=10,
pool_pre_ping=True, # Verify connections before use
pool_recycle=3600, # Recycle connections after 1 hour
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=True,
autocommit=False
)
class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy."""
# Columnas comunes para auditoría
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now()
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency para obtener sesión de base de datos.
Yields:
AsyncSession: Sesión de base de datos
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def create_tables():
"""Crear todas las tablas en desarrollo."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_tables():
"""Eliminar todas las tablas (solo para testing)."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# Health check function
async def check_database_health() -> bool:
"""
Verificar conectividad con la base de datos.
Returns:
bool: True si la conexión es exitosa
"""
try:
async with AsyncSessionLocal() as session:
await session.execute("SELECT 1")
return True
except Exception:
return False

145
backend/app/core/logging.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Structured Logging Configuration - ServiceManagerWeb
Configuración de logging estructurado con structlog
"""
import logging
import logging.config
import sys
from typing import Any, Dict
import structlog
from app.core.config import get_settings
settings = get_settings()
def add_correlation_id(logger: Any, method_name: str, event_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Agregar correlation ID a los logs si está disponible."""
# En un contexto de request real, esto vendría del middleware
# Por ahora es un placeholder
return event_dict
def configure_structlog():
"""Configurar structlog para logging estructurado."""
processors = [
# Add the log level and a timestamp to the event_dict
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
add_correlation_id,
]
if settings.LOG_FORMAT == "json":
processors.append(structlog.processors.JSONRenderer())
else:
processors.append(structlog.dev.ConsoleRenderer())
structlog.configure(
processors=processors,
wrapper_class=structlog.stdlib.BoundLogger,
logger_factory=structlog.stdlib.LoggerFactory(),
context_class=dict,
cache_logger_on_first_use=True,
)
def setup_logging():
"""Configurar el sistema de logging completo."""
# Configure structlog
configure_structlog()
# Configure standard library logging
logging_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.processors.JSONRenderer(),
},
"console": {
"()": structlog.stdlib.ProcessorFormatter,
"processor": structlog.dev.ConsoleRenderer(colors=True),
},
},
"handlers": {
"console": {
"level": settings.LOG_LEVEL,
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "json" if settings.LOG_FORMAT == "json" else "console",
},
},
"loggers": {
"": { # root logger
"handlers": ["console"],
"level": settings.LOG_LEVEL,
"propagate": False,
},
"uvicorn": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"sqlalchemy": {
"handlers": ["console"],
"level": "WARNING",
"propagate": False,
},
"celery": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
},
}
# Add file handler if specified
if settings.LOG_FILE:
logging_config["handlers"]["file"] = {
"level": settings.LOG_LEVEL,
"class": "logging.handlers.RotatingFileHandler",
"filename": settings.LOG_FILE,
"maxBytes": 10 * 1024 * 1024, # 10MB
"backupCount": 5,
"formatter": "json",
}
# Add file handler to all loggers
for logger_config in logging_config["loggers"].values():
logger_config["handlers"].append("file")
logging.config.dictConfig(logging_config)
# Convenience function to get logger
def get_logger(name: str = None) -> structlog.BoundLogger:
"""
Get a configured structlog logger.
Args:
name: Logger name (optional)
Returns:
Configured structlog logger
"""
return structlog.get_logger(name)

View File

@@ -0,0 +1,271 @@
"""
Security Utilities - ServiceManagerWeb
Funciones de seguridad para autenticación y autorización
"""
from datetime import datetime, timedelta
from typing import Optional, Union, Dict, Any
from passlib.context import CryptContext
from passlib.handlers.argon2 import argon2
from jose import JWTError, jwt
import pyotp
import secrets
import base64
import struct
from app.core.config import get_settings
settings = get_settings()
# Password hashing context
pwd_context = CryptContext(
schemes=["argon2"],
deprecated="auto",
argon2__time_cost=settings.ARGON2_TIME_COST,
argon2__memory_cost=settings.ARGON2_MEMORY_COST,
argon2__parallelism=settings.ARGON2_PARALLELISM,
)
class SecurityUtils:
"""Utilidades de seguridad centralizadas."""
@staticmethod
def hash_password(password: str) -> str:
"""
Hash a password using Argon2.
Args:
password: Plain text password
Returns:
Hashed password
"""
return pwd_context.hash(password)
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
plain_password: Plain text password
hashed_password: Hashed password
Returns:
True if password matches
"""
return pwd_context.verify(plain_password, hashed_password)
@staticmethod
def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: Token payload
expires_delta: Token expiration time
Returns:
JWT token string
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
@staticmethod
def create_refresh_token(data: Dict[str, Any]) -> str:
"""
Create a JWT refresh token.
Args:
data: Token payload
Returns:
JWT refresh token string
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(
to_encode,
settings.JWT_SECRET_KEY,
algorithm=settings.JWT_ALGORITHM
)
return encoded_jwt
@staticmethod
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify and decode a JWT token.
Args:
token: JWT token string
Returns:
Token payload if valid, None otherwise
"""
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM]
)
return payload
except JWTError:
return None
@staticmethod
def generate_totp_secret() -> str:
"""
Generate a base32-encoded secret for TOTP.
Returns:
Base32 encoded secret
"""
return pyotp.random_base32()
@staticmethod
def generate_totp_uri(secret: str, email: str, issuer_name: str = "ServiceManager") -> str:
"""
Generate TOTP URI for QR code.
Args:
secret: Base32 encoded secret
email: User email
issuer_name: Application name
Returns:
TOTP URI
"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(
name=email,
issuer_name=issuer_name
)
@staticmethod
def verify_totp(secret: str, token: str, window: int = 1) -> bool:
"""
Verify a TOTP token.
Args:
secret: Base32 encoded secret
token: TOTP token
window: Time window tolerance
Returns:
True if token is valid
"""
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=window)
@staticmethod
def generate_backup_codes(count: int = 8) -> list[str]:
"""
Generate backup codes for 2FA.
Args:
count: Number of codes to generate
Returns:
List of backup codes
"""
codes = []
for _ in range(count):
code = secrets.token_hex(4).upper()
# Format as XXXX-XXXX
formatted_code = f"{code[:4]}-{code[4:]}"
codes.append(formatted_code)
return codes
@staticmethod
def hash_token(token: str) -> str:
"""
Hash a token for secure storage.
Args:
token: Token to hash
Returns:
Hashed token
"""
return pwd_context.hash(token)
@staticmethod
def verify_hashed_token(token: str, hashed_token: str) -> bool:
"""
Verify a token against its hash.
Args:
token: Plain token
hashed_token: Hashed token
Returns:
True if token matches
"""
return pwd_context.verify(token, hashed_token)
@staticmethod
def generate_secure_token(length: int = 32) -> str:
"""
Generate a cryptographically secure random token.
Args:
length: Token length in bytes
Returns:
URL-safe base64 encoded token
"""
token = secrets.token_bytes(length)
return base64.urlsafe_b64encode(token).decode('utf-8').rstrip('=')
@staticmethod
def is_strong_password(password: str) -> tuple[bool, list[str]]:
"""
Check if password meets security requirements.
Args:
password: Password to check
Returns:
Tuple of (is_valid, list_of_issues)
"""
issues = []
if len(password) < settings.PASSWORD_MIN_LENGTH:
issues.append(f"Password must be at least {settings.PASSWORD_MIN_LENGTH} characters long")
if not any(c.islower() for c in password):
issues.append("Password must contain at least one lowercase letter")
if not any(c.isupper() for c in password):
issues.append("Password must contain at least one uppercase letter")
if not any(c.isdigit() for c in password):
issues.append("Password must contain at least one digit")
if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
issues.append("Password must contain at least one special character")
return len(issues) == 0, issues
# Create singleton instance
security = SecurityUtils()

201
backend/app/main.py Normal file
View File

@@ -0,0 +1,201 @@
"""
ServiceManagerWeb Backend - FastAPI Application
Mesa de Ayuda B2B multi-tenant con Clean Architecture
"""
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
import structlog
import time
import uuid
from app.core.config import get_settings
from app.core.database import engine, create_tables
# Import models to register them with SQLAlchemy
from app.models.tenant import Tenant
from app.models.system import System
from app.models.category import Category
from app.models.user import User
from app.models.ticket import Ticket
from app.core.logging import setup_logging
from app.api.v1.router import api_router
from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware
settings = get_settings()
setup_logging()
logger = structlog.get_logger()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifecycle manager para la aplicación."""
# Startup
logger.info("Iniciando ServiceManagerWeb Backend", version=settings.API_VERSION)
if settings.ENVIRONMENT == "development":
await create_tables()
logger.info("Tablas de base de datos verificadas")
yield
# Shutdown
logger.info("Cerrando ServiceManagerWeb Backend")
# Crear aplicación FastAPI
app = FastAPI(
title="ServiceManagerWeb API",
description="Mesa de Ayuda B2B multi-tenant para Aduanasoft",
version=settings.API_VERSION,
lifespan=lifespan,
docs_url=f"/{settings.API_VERSION}/docs" if settings.ENVIRONMENT == "development" else None,
redoc_url=f"/{settings.API_VERSION}/redoc" if settings.ENVIRONMENT == "development" else None,
openapi_url=f"/{settings.API_VERSION}/openapi.json"
)
# ===================================
# MIDDLEWARE
# ===================================
# CORS
cors_origins = settings.CORS_ORIGINS.split(",") if isinstance(settings.CORS_ORIGINS, str) else settings.CORS_ORIGINS
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Compression
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Custom middleware
app.add_middleware(CorrelationIDMiddleware)
app.add_middleware(TenantMiddleware)
# Request logging middleware
@app.middleware("http")
async def request_logging_middleware(request: Request, call_next):
"""Log todas las requests con métricas de performance."""
start_time = time.time()
correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4()))
# Log request
logger.info(
"Request iniciada",
method=request.method,
url=str(request.url),
correlation_id=correlation_id,
user_agent=request.headers.get("user-agent"),
remote_addr=request.client.host if request.client else None
)
# Process request
response = await call_next(request)
# Log response
duration = time.time() - start_time
logger.info(
"Request completada",
method=request.method,
url=str(request.url),
status_code=response.status_code,
duration=f"{duration:.3f}s",
correlation_id=correlation_id
)
# Add correlation ID to response headers
response.headers["X-Correlation-ID"] = correlation_id
return response
# ===================================
# EXCEPTION HANDLERS
# ===================================
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handler global para excepciones no capturadas."""
correlation_id = getattr(request.state, "correlation_id", str(uuid.uuid4()))
logger.error(
"Excepción no manejada",
error=str(exc),
correlation_id=correlation_id,
url=str(request.url),
method=request.method,
exc_info=True
)
return JSONResponse(
status_code=500,
content={
"success": False,
"error": {
"code": "INTERNAL_ERROR",
"message": "Error interno del servidor"
},
"correlation_id": correlation_id
}
)
# ===================================
# ROUTES
# ===================================
# Health check endpoint
@app.get("/health")
async def health_check():
"""Health check para load balancer y monitoring."""
return {
"status": "healthy",
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"environment": settings.ENVIRONMENT
}
# Root endpoint
@app.get("/")
async def root():
"""Endpoint raíz con información básica."""
return {
"service": "ServiceManagerWeb API",
"version": settings.API_VERSION,
"docs": f"/{settings.API_VERSION}/docs",
"environment": settings.ENVIRONMENT
}
# API routes
app.include_router(
api_router,
prefix=f"/{settings.API_VERSION}",
responses={
400: {"description": "Bad Request"},
401: {"description": "Unauthorized"},
403: {"description": "Forbidden"},
404: {"description": "Not Found"},
422: {"description": "Validation Error"},
500: {"description": "Internal Server Error"}
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=settings.ENVIRONMENT == "development"
)

View File

@@ -0,0 +1,46 @@
"""
Correlation ID Middleware - ServiceManagerWeb
Middleware para rastrear requests con correlation ID
"""
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import uuid
import structlog
logger = structlog.get_logger(__name__)
class CorrelationIDMiddleware(BaseHTTPMiddleware):
"""
Middleware para manejar correlation IDs.
Extrae el correlation ID del header X-Correlation-ID o genera uno nuevo.
Lo almacena en el estado de la request para uso en logs y responses.
"""
async def dispatch(self, request: Request, call_next) -> Response:
"""Process request and add correlation ID."""
# Extract or generate correlation ID
correlation_id = request.headers.get("X-Correlation-ID")
if not correlation_id:
correlation_id = str(uuid.uuid4())
# Store in request state
request.state.correlation_id = correlation_id
# Add to structlog context
with structlog.contextvars.bound_contextvars(
correlation_id=correlation_id,
path=request.url.path,
method=request.method
):
response = await call_next(request)
# Add correlation ID to response headers
response.headers["X-Correlation-ID"] = correlation_id
return response

View File

@@ -0,0 +1,72 @@
"""
Tenant Middleware - ServiceManagerWeb
Middleware para manejo de multi-tenancy
"""
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import structlog
logger = structlog.get_logger(__name__)
class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware para extraer y validar información del tenant.
Extrae el tenant_id del header X-Tenant-ID y lo almacena
en el estado de la request para uso posterior.
"""
# Rutas que no requieren tenant
EXCLUDED_PATHS = {
"/health",
"/",
"/v1/auth/login",
"/docs",
"/openapi.json",
"/redoc"
}
async def dispatch(self, request: Request, call_next) -> Response:
"""Process request and add tenant information."""
# Skip tenant validation for excluded paths
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
return await call_next(request)
# Extract tenant from header
tenant_id = request.headers.get("X-Tenant-ID")
tenant_slug = request.headers.get("X-Tenant-Slug")
# For now, we'll be more permissive in development
# In production, tenant should be strictly required
if not tenant_id and not tenant_slug:
logger.warning(
"Request without tenant information",
path=request.url.path,
method=request.method
)
# For now, continue without tenant for development
# raise HTTPException(
# status_code=status.HTTP_400_BAD_REQUEST,
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
# )
# Store tenant info in request state
request.state.tenant_id = tenant_id
request.state.tenant_slug = tenant_slug
# TODO: Validate tenant exists and is active
# This would involve a database query which we'll implement later
logger.debug(
"Tenant middleware processed",
tenant_id=tenant_id,
tenant_slug=tenant_slug,
path=request.url.path
)
return await call_next(request)

View File

@@ -0,0 +1,28 @@
"""
Category Model - ServiceManagerWeb
"""
from sqlalchemy import String, Text, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import List, Optional
import uuid
from app.core.database import Base
class Category(Base):
__tablename__ = "categories"
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Optional: Tenant specific categories?
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
# Relationships
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported
def __repr__(self) -> str:
return f"<Category(id={self.id}, name='{self.name}')>"

View File

@@ -0,0 +1,25 @@
"""
System Model - ServiceManagerWeb
"""
from sqlalchemy import String, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import List, Optional
import uuid
from app.core.database import Base
class System(Base):
__tablename__ = "systems"
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships
# If we want tickets to link to systems, we will add relationship in Ticket later or now.
# We will assume Ticket links to System.
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system")
def __repr__(self) -> str:
return f"<System(id={self.id}, name='{self.name}')>"

View File

@@ -0,0 +1,68 @@
"""
Tenant Model - ServiceManagerWeb
Modelo para organizaciones cliente (multi-tenancy)
"""
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from typing import List, Optional
import enum
import uuid
from app.core.database import Base
class TenantStatus(str, enum.Enum):
"""Estados de un tenant."""
ACTIVE = "active"
SUSPENDED = "suspended"
INACTIVE = "inactive"
class Tenant(Base):
"""Modelo de Tenant (Organización cliente)."""
__tablename__ = "tenants"
# Información básica
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
domain: Mapped[Optional[str]] = mapped_column(String(255))
logo_url: Mapped[Optional[str]] = mapped_column(String(500))
# Contacto
contact_email: Mapped[Optional[str]] = mapped_column(String(320))
contact_phone: Mapped[Optional[str]] = mapped_column(String(20))
address: Mapped[Optional[str]] = mapped_column(Text)
# Configuración regional
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
locale: Mapped[str] = mapped_column(String(10), default="es-ES")
# Límites y configuración
max_users: Mapped[int] = mapped_column(Integer, default=50)
max_storage_mb: Mapped[int] = mapped_column(Integer, default=1024)
allowed_file_types: Mapped[List[str]] = mapped_column(
ARRAY(String),
default=["pdf", "jpg", "jpeg", "png", "doc", "docx", "xls", "xlsx", "txt"]
)
# Estado
status: Mapped[TenantStatus] = mapped_column(
String(20),
default=TenantStatus.ACTIVE
)
# Relaciones
users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
def __repr__(self) -> str:
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
@property
def is_active(self) -> bool:
"""Check if tenant is active."""
return self.status == TenantStatus.ACTIVE

View File

@@ -0,0 +1,65 @@
"""
Ticket Model - ServiceManagerWeb
"""
from sqlalchemy import String, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from typing import Optional
import enum
import uuid
from app.core.database import Base
class TicketStatus(str, enum.Enum):
NEW = "NEW"
TRIAGE = "TRIAGE"
IN_PROGRESS = "IN_PROGRESS"
WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT"
RESOLVED = "RESOLVED"
CLOSED = "CLOSED"
REOPENED = "REOPENED"
class TicketPriority(str, enum.Enum):
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
URGENT = "URGENT"
class Ticket(Base):
__tablename__ = "tickets"
# Note: id, created_at, updated_at are inherited from Base
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW)
priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM)
# Foreign Keys
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True)
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True)
# Relationships
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
system: Mapped["System"] = relationship("System", back_populates="tickets")
category: Mapped["Category"] = relationship("Category", back_populates="tickets")
created_by_user: Mapped["User"] = relationship(
"User",
foreign_keys=[created_by],
back_populates="created_tickets"
)
assigned_to_user: Mapped[Optional["User"]] = relationship(
"User",
foreign_keys=[assigned_to],
back_populates="assigned_tickets"
)

135
backend/app/models/user.py Normal file
View File

@@ -0,0 +1,135 @@
"""
User Model - ServiceManagerWeb
Modelo para usuarios del sistema (internos y clientes)
"""
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
from typing import Optional, List
import enum
import uuid
from datetime import datetime
from app.core.database import Base
class UserRole(str, enum.Enum):
"""Roles de usuario en el sistema."""
# Staff interno
ADMIN = "ADMIN" # Control total
SUPPORT_MANAGER = "SUPPORT_MANAGER" # Gestión de equipos y SLAs
AGENT = "AGENT" # Atención de tickets
AUDITOR = "AUDITOR" # Solo lectura para auditoría
# Clientes
CLIENT_ADMIN = "CLIENT_ADMIN" # Admin de organización cliente
CLIENT_USER = "CLIENT_USER" # Usuario final cliente
class User(Base):
"""Modelo de Usuario."""
__tablename__ = "users"
# Relación con tenant
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
# Información básica
email: Mapped[str] = mapped_column(String(320), nullable=False)
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
avatar_url: Mapped[Optional[str]] = mapped_column(String(500))
# Autenticación
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False)
# 2FA (opcional para staff interno)
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
backup_codes: Mapped[Optional[List[str]]] = mapped_column(ARRAY(String))
# Estado
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
last_activity: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Preferencias
language: Mapped[str] = mapped_column(String(10), default="es")
timezone: Mapped[str] = mapped_column(String(50), default="UTC")
notifications_email: Mapped[bool] = mapped_column(Boolean, default=True)
# Relaciones
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="users")
created_tickets: Mapped[List["Ticket"]] = relationship(
"Ticket",
back_populates="created_by_user",
foreign_keys="Ticket.created_by"
)
assigned_tickets: Mapped[List["Ticket"]] = relationship(
"Ticket",
back_populates="assigned_to_user",
foreign_keys="Ticket.assigned_to"
)
# Unique constraint por tenant
__table_args__ = (
{"postgresql_tablespace": "users"},
)
def __repr__(self) -> str:
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
@property
def full_name(self) -> str:
"""Get user's full name."""
return f"{self.first_name} {self.last_name}"
@property
def is_staff(self) -> bool:
"""Check if user is internal staff."""
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT,
UserRole.AUDITOR
]
@property
def is_client(self) -> bool:
"""Check if user is a client."""
return self.role in [
UserRole.CLIENT_ADMIN,
UserRole.CLIENT_USER
]
@property
def can_manage_users(self) -> bool:
"""Check if user can manage other users."""
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.CLIENT_ADMIN
]
@property
def can_manage_tickets(self) -> bool:
"""Check if user can manage tickets."""
return self.role in [
UserRole.ADMIN,
UserRole.SUPPORT_MANAGER,
UserRole.AGENT
]
@property
def requires_2fa(self) -> bool:
"""Check if 2FA is required for this user."""
# 2FA opcional para staff interno, no requerido para clientes
return self.is_staff

View File

@@ -0,0 +1,41 @@
import asyncio
import sys
import os
# Add parent directory to path so we can import 'app'
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.tenant import Tenant # Import Tenant to register it
from app.models.ticket import Ticket # Import Ticket to register it
from app.models.user import User
from app.core.security import SecurityUtils
async def fix_password():
async with AsyncSessionLocal() as session:
# Find the admin user
email = "admin@aduanasoft.com"
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
print(f"User {email} found.")
# Reset password to 'admin123'
new_password = "admin123"
hashed = SecurityUtils.hash_password(new_password)
user.password_hash = hashed
try:
await session.commit()
print(f"Password for {email} updated successfully!")
print(f"New password is: {new_password}")
except Exception as e:
await session.rollback()
print(f"Error updating password: {e}")
else:
print(f"User {email} not found!")
if __name__ == "__main__":
asyncio.run(fix_password())

181
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,181 @@
# ServiceManagerWeb Backend - PyProject Configuration
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "servicemanager-backend"
version = "0.1.0"
description = "ServiceManagerWeb Backend - Mesa de Ayuda B2B"
authors = [
{name = "Aduanasoft", email = "dev@aduanasoft.com"}
]
readme = "README.md"
requires-python = ">=3.11"
license = {text = "Proprietary"}
dependencies = [
"fastapi==0.104.1",
"uvicorn[standard]==0.24.0",
"pydantic==2.5.0",
"pydantic-settings==2.1.0",
"sqlalchemy==2.0.23",
"alembic==1.13.0",
"asyncpg==0.29.0",
"psycopg2-binary==2.9.9",
"python-jose[cryptography]==3.3.0",
"python-multipart==0.0.6",
"passlib[argon2]==1.7.4",
"argon2-cffi==23.1.0",
"pyotp==2.9.0",
"celery==5.3.4",
"redis==5.0.1",
"email-validator==2.1.0",
"jinja2==3.1.2",
"python-magic==0.4.27",
"pillow==10.1.0",
"httpx==0.25.2",
"aiofiles==23.2.1",
"python-dateutil==2.8.2",
"pytz==2023.3",
"slugify==0.0.1",
"structlog==23.2.0",
"prometheus-client==0.19.0"
]
[project.optional-dependencies]
dev = [
"pytest==7.4.3",
"pytest-asyncio==0.21.1",
"pytest-cov==4.1.0",
"faker==20.1.0",
"ruff==0.1.7",
"black==23.11.0",
"mypy==1.7.1",
"pre-commit==3.6.0"
]
production = [
"gunicorn==21.2.0"
]
[tool.setuptools.packages.find]
where = ["."]
include = ["app*"]
# ===================================
# RUFF CONFIGURATION
# ===================================
[tool.ruff]
line-length = 100
target-version = "py311"
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"N", # pep8-naming
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"C901", # too complex
]
[tool.ruff.per-file-ignores]
"__init__.py" = ["F401"]
"tests/**/*" = ["N802", "N803", "N806"]
[tool.ruff.isort]
known-first-party = ["app"]
# ===================================
# BLACK CONFIGURATION
# ===================================
[tool.black]
line-length = 100
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| build
| dist
)/
'''
# ===================================
# MYPY CONFIGURATION
# ===================================
[tool.mypy]
python_version = "3.11"
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true
strict_equality = true
show_error_codes = true
[[tool.mypy.overrides]]
module = [
"passlib.*",
"pyotp.*",
"magic.*",
]
ignore_missing_imports = true
# ===================================
# PYTEST CONFIGURATION
# ===================================
[tool.pytest.ini_options]
minversion = "7.0"
addopts = "-ra -q --strict-markers --strict-config"
testpaths = ["tests"]
asyncio_mode = "auto"
python_files = ["test_*.py", "*_test.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
]
# ===================================
# COVERAGE CONFIGURATION
# ===================================
[tool.coverage.run]
source = ["app"]
omit = [
"*/tests/*",
"*/venv/*",
"*/__pycache__/*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]

86
backend/requirements.txt Normal file
View File

@@ -0,0 +1,86 @@
# Backend Requirements - ServiceManagerWeb
# FastAPI Backend Dependencies
# ===================================
# CORE FRAMEWORK
# ===================================
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
# ===================================
# DATABASE & ORM
# ===================================
sqlalchemy==2.0.23
alembic==1.13.0
asyncpg==0.29.0 # PostgreSQL async driver
psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations)
# ===================================
# AUTHENTICATION & SECURITY
# ===================================
python-jose[cryptography]==3.3.0
python-multipart==0.0.6
passlib[argon2]==1.7.4
argon2-cffi==23.1.0
pyotp==2.9.0 # TOTP/2FA support
# ===================================
# ASYNC TASKS
# ===================================
celery==5.3.4
redis==5.0.1
# ===================================
# EMAIL
# ===================================
email-validator==2.1.0
jinja2==3.1.2 # Email templates
# ===================================
# FILE HANDLING
# ===================================
python-magic==0.4.27 # MIME type detection
pillow==10.1.0 # Image processing
# ===================================
# HTTP & REQUESTS
# ===================================
httpx==0.25.2 # Async HTTP client
aiofiles==23.2.1 # Async file operations
# ===================================
# UTILITIES
# ===================================
python-dateutil==2.8.2
pytz==2023.3
slugify==0.0.1
# ===================================
# MONITORING & LOGGING
# ===================================
structlog==23.2.0
prometheus-client==0.19.0
# ===================================
# DEVELOPMENT & TESTING
# ===================================
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
httpx==0.25.2 # For testing
faker==20.1.0 # Test data generation
# ===================================
# CODE QUALITY
# ===================================
ruff==0.1.7 # Linter & formatter
black==23.11.0 # Code formatter
mypy==1.7.1 # Type checker
pre-commit==3.6.0 # Git hooks
# ===================================
# PRODUCTION
# ===================================
gunicorn==21.2.0