Initial commit
This commit is contained in:
57
backend/app/api/deps.py
Normal file
57
backend/app/api/deps.py
Normal 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
|
||||
297
backend/app/api/v1/endpoints/auth.py
Normal file
297
backend/app/api/v1/endpoints/auth.py
Normal 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
|
||||
55
backend/app/api/v1/endpoints/categories.py
Normal file
55
backend/app/api/v1/endpoints/categories.py
Normal 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
|
||||
88
backend/app/api/v1/endpoints/health.py
Normal file
88
backend/app/api/v1/endpoints/health.py
Normal 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"}
|
||||
53
backend/app/api/v1/endpoints/systems.py
Normal file
53
backend/app/api/v1/endpoints/systems.py
Normal 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
|
||||
94
backend/app/api/v1/endpoints/tenants.py
Normal file
94
backend/app/api/v1/endpoints/tenants.py
Normal 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
|
||||
68
backend/app/api/v1/endpoints/users.py
Normal file
68
backend/app/api/v1/endpoints/users.py
Normal 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
|
||||
47
backend/app/api/v1/router.py
Normal file
47
backend/app/api/v1/router.py
Normal 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
142
backend/app/core/config.py
Normal 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()
|
||||
94
backend/app/core/database.py
Normal file
94
backend/app/core/database.py
Normal 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
145
backend/app/core/logging.py
Normal 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)
|
||||
271
backend/app/core/security.py
Normal file
271
backend/app/core/security.py
Normal 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
201
backend/app/main.py
Normal 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"
|
||||
)
|
||||
46
backend/app/middleware/correlation_id.py
Normal file
46
backend/app/middleware/correlation_id.py
Normal 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
|
||||
72
backend/app/middleware/tenant.py
Normal file
72
backend/app/middleware/tenant.py
Normal 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)
|
||||
28
backend/app/models/category.py
Normal file
28
backend/app/models/category.py
Normal 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}')>"
|
||||
25
backend/app/models/system.py
Normal file
25
backend/app/models/system.py
Normal 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}')>"
|
||||
68
backend/app/models/tenant.py
Normal file
68
backend/app/models/tenant.py
Normal 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
|
||||
65
backend/app/models/ticket.py
Normal file
65
backend/app/models/ticket.py
Normal 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
135
backend/app/models/user.py
Normal 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
|
||||
Reference in New Issue
Block a user