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

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"]
)