Asunto en internos funcionando

This commit is contained in:
2026-03-24 08:01:57 -06:00
parent daf3a524c7
commit 08816ab73a
7 changed files with 660 additions and 240 deletions

View File

@@ -35,12 +35,25 @@ class IssueCreate(BaseModel):
return v.strip()
class IssueStatusUpdate(BaseModel):
status: str
@field_validator("status")
@classmethod
def validate_status(cls, v: str) -> str:
valid = {"OPEN", "IN_PROGRESS", "RESOLVED", "CLOSED"}
if v.upper() not in valid:
raise ValueError(f"status debe ser uno de: {valid}")
return v.upper()
class IssueResponse(BaseModel):
id: uuid.UUID
ticket_id: uuid.UUID
tenant_id: uuid.UUID
content: str
priority: str
status: str
created_by: uuid.UUID
created_by_name: str
tagged_users: List[TaggedUserBasic] = []

View File

@@ -7,8 +7,8 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
import uuid
from app.models.issue import TicketIssue, ticket_issue_tagged_users
from app.api.schemas.issue import IssueCreate, IssueResponse
from app.models.issue import TicketIssue, ticket_issue_tagged_users, IssueStatus
from app.api.schemas.issue import IssueCreate, IssueResponse, IssueStatusUpdate
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
@@ -571,6 +571,7 @@ async def get_ticket_issues(
IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value,
status=issue.status.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
@@ -657,6 +658,7 @@ async def create_ticket_issue(
return IssueResponse(
id=new_issue.id, ticket_id=new_issue.ticket_id, tenant_id=new_issue.tenant_id,
content=new_issue.content, priority=new_issue.priority.value,
status=new_issue.status.value,
created_by=new_issue.created_by,
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
tagged_users=[
@@ -709,4 +711,142 @@ async def upload_issue_attachment(
"message": "Adjunto subido correctamente",
"attachment_filename": db_issue.attachment_filename,
"attachment_mime_type": db_issue.attachment_mime_type,
}
}
@router.get("/{ticket_id}/issues/{issue_id}", response_model=IssueResponse)
async def get_issue_detail(
ticket_id: str,
issue_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Obtener detalle de un asunto. Accesible para todos los roles."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
query = select(Ticket).where(Ticket.id == ticket_uuid)
if current_user.role != UserRole.ADMIN:
query = query.where(Ticket.tenant_id == current_user.tenant_id)
result = await db.execute(query)
if not result.scalars().first():
raise HTTPException(status_code=404, detail="Ticket no encontrado")
result = await db.execute(
select(TicketIssue)
.where(TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="Asunto no encontrado")
return IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value,
status=issue.status.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in issue.tagged_users
],
attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at, updated_at=issue.updated_at,
)
@router.patch("/{ticket_id}/issues/{issue_id}/status", response_model=IssueResponse)
async def update_issue_status(
ticket_id: str,
issue_id: str,
data: IssueStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Actualizar status de un asunto."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
if current_user.role != UserRole.ADMIN and current_user.role != UserRole.CLIENT_ADMIN:
raise HTTPException(status_code=403, detail="Solo administradores pueden cambiar el status")
result = await db.execute(
select(TicketIssue)
.where(TicketIssue.id == issue_uuid, TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="Asunto no encontrado")
issue.status = IssueStatus[data.status]
issue.updated_at = datetime.utcnow()
await db.commit()
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.issue.status_update", resource_type="ticket_issue",
resource_id=issue.id,
new_values={"status": data.status},
)
return IssueResponse(
id=issue.id, ticket_id=issue.ticket_id, tenant_id=issue.tenant_id,
content=issue.content, priority=issue.priority.value,
status=issue.status.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in issue.tagged_users
],
attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at, updated_at=issue.updated_at,
)
@router.delete("/{ticket_id}/issues/{issue_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_issue(
ticket_id: str,
issue_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Eliminar un asunto. Solo ADMIN global o CLIENT_ADMIN."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
issue_uuid = validate_uuid_param(issue_id, "issue ID")
if current_user.role != UserRole.ADMIN and current_user.role != UserRole.CLIENT_ADMIN:
raise HTTPException(status_code=403, detail="Solo administradores pueden eliminar asuntos")
result = await db.execute(
select(TicketIssue).where(
TicketIssue.id == issue_uuid,
TicketIssue.ticket_id == ticket_uuid,
)
)
issue = result.scalars().first()
if not issue:
raise HTTPException(status_code=404, detail="Asunto no encontrado")
old_content = issue.content
old_status = issue.status.value
await db.delete(issue)
await db.commit()
await safe_audit_log(
db=db, tenant_id=current_user.tenant_id, user_id=current_user.id,
action="ticket.issue.delete", resource_type="ticket_issue",
resource_id=issue_uuid,
old_values={"content": old_content, "status": old_status},
)

View File

@@ -63,6 +63,29 @@ async def read_users(
result = await db.execute(query)
return result.scalars().all()
@router.get("/taggable", response_model=List[UserResponse])
async def get_taggable_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Obtener usuarios que se pueden etiquetar en asuntos.
- Staff interno + CLIENT_ADMIN → todos los usuarios activos
- CLIENT_USER → solo CLIENT_ADMIN de su tenant
"""
if current_user.role.is_global or current_user.role == UserRole.CLIENT_ADMIN:
query = select(User).where(User.is_active == True)
else:
query = select(User).where(
User.tenant_id == current_user.tenant_id,
User.role == UserRole.CLIENT_ADMIN,
User.is_active == True
)
query = query.order_by(User.first_name)
result = await db.execute(query)
return result.scalars().all()
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
@@ -402,3 +425,4 @@ async def activate_user(
await db.commit()
await db.refresh(db_user)
return db_user

View File

@@ -8,6 +8,7 @@ from sqlalchemy import String, ForeignKey, Text, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
from datetime import datetime
import enum
import uuid
from app.core.database import Base, GUID
@@ -16,6 +17,13 @@ from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
class IssueStatus(str, enum.Enum):
OPEN = "OPEN"
IN_PROGRESS = "IN_PROGRESS"
RESOLVED = "RESOLVED"
CLOSED = "CLOSED"
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
ticket_issue_tagged_users = Table(
"ticket_issue_tagged_users",
@@ -38,37 +46,23 @@ ticket_issue_tagged_users = Table(
class TicketIssue(Base):
"""
Asunto de escalación de un ticket.
Solo puede crearlo:
- El usuario que creó el ticket (created_by del Ticket)
- El CLIENT_ADMIN del mismo tenant
"""
__tablename__ = "ticket_issues"
# Multi-tenancy — siempre filtrar por esto
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
# Relación con el ticket padre
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False,
)
# Quién lo creó
created_by: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id"),
nullable=False,
)
# Contenido
content: Mapped[str] = mapped_column(Text, nullable=False)
priority: Mapped[TicketPriority] = mapped_column(
@@ -80,23 +74,24 @@ class TicketIssue(Base):
nullable=False,
)
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment)
status: Mapped[IssueStatus] = mapped_column(
SAEnum(IssueStatus, name="issue_status_enum", native_enum=False).with_variant(
PG_ENUM(IssueStatus, name="issue_status_enum", create_type=True),
"postgresql",
),
default=IssueStatus.OPEN,
nullable=False,
)
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Relationships
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues")
created_by_user: Mapped["User"] = relationship(
"User",
foreign_keys=[created_by],
)
created_by_user: Mapped["User"] = relationship("User", foreign_keys=[created_by])
tagged_users: Mapped[List["User"]] = relationship(
"User",
secondary=ticket_issue_tagged_users,
"User", secondary=ticket_issue_tagged_users,
)
def __repr__(self) -> str:
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>"
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, status={self.status})>"