Release v1.7.1 - Mejoras en SLA, Auditoria y Multi-tenant

 Características Nuevas:
- Cálculo automático de SLA en tickets basado en categoría
- Auto-asignación de tickets según configuración de categoría
- Auditoría completa en operaciones de categorías (create/update/delete)
- Visualización de estado SLA en listado y detalle de tickets

🐛 Correcciones:
- Fix actualización de status en tenants (manejo correcto de enum TenantStatus)
- Corrección de campos contact_phone y contact_email en tenants
- Corrección de modelo TicketResponse (agregar campos SLA y usar ConfigDict)
- Eliminación de archivo changelog duplicado

🔧 Mejoras de Infraestructura:
- Agregar montaje de backend en workers y beat para imports correctos
- Mejorar path handling en sla_tasks.py para Docker
- Scripts de testing integrados (test_frontend_integration, test_manual, test_tenant_update)
- Agregar database.py en workers/app/core para sesiones async

📝 Frontend:
- Actualizar UI de tenants con nuevos campos (email, teléfono, status enum)
- Agregar columna de SLA en listado de tickets
- Mostrar información detallada de SLA en vista de ticket individual
- Indicadores visuales de estado de SLA (vencido, cumplido, en plazo)
This commit is contained in:
2026-02-17 10:26:56 -07:00
parent 75726d915f
commit cc1e964c3a
13 changed files with 730 additions and 152 deletions

View File

@@ -9,7 +9,8 @@ import uuid
from app.core.database import get_db
from app.models.category import Category
from app.models.user import User
from app.api import deps
from app.api import deps
from app.services.audit_service import AuditService
router = APIRouter()
@@ -98,6 +99,27 @@ async def create_category(
db.add(db_category)
await db.commit()
await db.refresh(db_category)
# Registrar creación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.create",
resource_type="category",
resource_id=db_category.id,
new_values={
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return db_category
@@ -153,6 +175,14 @@ async def update_category(
detail="Category not found"
)
# Guardar valores anteriores para auditoría
old_values = {
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
# Actualizar campos
update_data = category_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
@@ -160,6 +190,29 @@ async def update_category(
await db.commit()
await db.refresh(db_category)
# Registrar actualización en auditoría
try:
new_values = {
"name": db_category.name,
"sla_response_hours": db_category.sla_response_hours,
"sla_resolution_hours": db_category.sla_resolution_hours,
"is_active": db_category.is_active
}
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.update",
resource_type="category",
resource_id=db_category.id,
old_values=old_values,
new_values=new_values
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return db_category
@@ -187,7 +240,30 @@ async def delete_category(
detail="Category not found"
)
# Guardar valores para auditoría
old_values = {
"name": db_category.name,
"is_active": db_category.is_active
}
# Soft delete
db_category.is_active = False
await db.commit()
# Registrar eliminación en auditoría
try:
await AuditService.log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="category.delete",
resource_type="category",
resource_id=db_category.id,
old_values=old_values,
new_values={"is_active": False}
)
await db.commit()
except Exception:
pass # No fallar si falla el audit log
return None

View File

@@ -16,6 +16,7 @@ class TenantBase(BaseModel):
slug: str
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
class TenantCreate(TenantBase):
pass
@@ -25,6 +26,7 @@ class TenantUpdate(BaseModel):
slug: Optional[str] = None
domain: Optional[str] = None
contact_email: Optional[EmailStr] = None
contact_phone: Optional[str] = None
status: Optional[TenantStatus] = None
class TenantResponse(TenantBase):
@@ -86,12 +88,16 @@ async def update_tenant(
update_data = tenant_in.model_dump(exclude_unset=True)
if "status" in update_data:
tenant.is_active = update_data.pop("status") == TenantStatus.active
# Convertir string a enum TenantStatus
status_value = update_data.pop("status")
if isinstance(status_value, str):
tenant.status = TenantStatus(status_value)
else:
tenant.status = status_value
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

@@ -4,7 +4,7 @@ Tickets endpoints - ServiceManagerWeb
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
@@ -45,6 +45,8 @@ class TicketUpdate(BaseModel):
assigned_to: Optional[str] = None
class TicketResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
ticket_number: str
subject: str
@@ -58,9 +60,10 @@ class TicketResponse(BaseModel):
assigned_to: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
sla_response_due: Optional[datetime] = None
sla_resolution_due: Optional[datetime] = None
first_response_at: Optional[datetime] = None
resolved_at: Optional[datetime] = None
class TicketCloseRequest(BaseModel):
resolution: Optional[str] = None
@@ -108,6 +111,7 @@ async def create_ticket(
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None
# Validar categoría
category = None
if category_uuid:
category = await db.get(Category, category_uuid)
if not category:
@@ -125,6 +129,21 @@ async def create_ticket(
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
# Calcular SLA deadlines basados en la categoría
from datetime import timedelta
sla_response_due = None
sla_resolution_due = None
assigned_to_user = None
if category:
now = datetime.utcnow()
sla_response_due = now + timedelta(hours=category.sla_response_hours)
sla_resolution_due = now + timedelta(hours=category.sla_resolution_hours)
# Auto-asignar si la categoría tiene configurado auto_assign_to
if category.auto_assign_to:
assigned_to_user = category.auto_assign_to
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
@@ -135,7 +154,10 @@ async def create_ticket(
affected_system_id=system_uuid,
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
assigned_to=assigned_to_user,
status=TicketStatus.NEW,
sla_response_due=sla_response_due,
sla_resolution_due=sla_resolution_due,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
@@ -251,7 +273,7 @@ async def get_tickets(
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
# ✅ CORREGIDO: Usar affected_system_id y agregar campos SLA
return [
{
"id": str(t.id),
@@ -266,7 +288,11 @@ async def get_tickets(
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
"updated_at": t.updated_at,
"sla_response_due": t.sla_response_due,
"sla_resolution_due": t.sla_resolution_due,
"first_response_at": t.first_response_at,
"resolved_at": t.resolved_at
}
for t in tickets
]