Bckn funcion

This commit is contained in:
2026-03-17 11:09:30 -06:00
parent 5e58d53416
commit d691b71c44
17 changed files with 406 additions and 224 deletions

View File

@@ -0,0 +1,53 @@
"""Schemas para TicketIssue"""
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
import uuid
class TaggedUserBasic(BaseModel):
id: uuid.UUID
full_name: str
email: str
class Config:
from_attributes = True
class IssueCreate(BaseModel):
content: str
priority: str = "MEDIUM"
tagged_user_ids: List[uuid.UUID] = []
@field_validator("priority")
@classmethod
def validate_priority(cls, v: str) -> str:
valid = {"LOW", "MEDIUM", "HIGH", "URGENT"}
if v.upper() not in valid:
raise ValueError(f"priority debe ser uno de: {valid}")
return v.upper()
@field_validator("content")
@classmethod
def validate_content(cls, v: str) -> str:
if not v.strip():
raise ValueError("content no puede estar vacío")
return v.strip()
class IssueResponse(BaseModel):
id: uuid.UUID
ticket_id: uuid.UUID
tenant_id: uuid.UUID
content: str
priority: str
created_by: uuid.UUID
created_by_name: str
tagged_users: List[TaggedUserBasic] = []
attachment_filename: Optional[str] = None
attachment_mime_type: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -7,6 +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.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
@@ -411,3 +413,179 @@ async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSessi
logger.info(f"Returning file: {attachment.original_filename}")
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
async def get_ticket_issues(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Obtener asuntos de un ticket."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
# Verificar que el ticket existe y pertenece al tenant
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
)
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
issues_query = (
select(TicketIssue)
.where(TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
.order_by(TicketIssue.created_at.desc())
)
result = await db.execute(issues_query)
issues = result.scalars().all()
return [
IssueResponse(
id=issue.id,
ticket_id=issue.ticket_id,
tenant_id=issue.tenant_id,
content=issue.content,
priority=issue.priority.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,
)
for issue in issues
]
@router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket_issue(
ticket_id: str,
issue: IssueCreate,
file: Optional[UploadFile] = File(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
Crear un asunto de escalación en un ticket.
Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant.
"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
# 1. Verificar que el ticket existe y pertenece al tenant
result = await db.execute(
select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
)
)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
# 2. Verificar permisos: solo el creador del ticket o CLIENT_ADMIN
is_ticket_owner = ticket_obj.created_by == current_user.id
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN
if not is_ticket_owner and not is_client_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo el creador del ticket o el administrador del tenant pueden crear asuntos",
)
# 3. Validar usuarios etiquetados — deben pertenecer al mismo tenant
tagged_users = []
if issue.tagged_user_ids:
result = await db.execute(
select(User).where(
User.id.in_(issue.tagged_user_ids),
User.tenant_id == current_user.tenant_id,
User.is_active == True,
)
)
tagged_users = result.scalars().all()
found_ids = {u.id for u in tagged_users}
missing = [str(uid) for uid in issue.tagged_user_ids if uid not in found_ids]
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Usuarios no encontrados en el tenant: {missing}",
)
# 4. Manejar adjunto opcional
attachment_path = None
attachment_filename = None
attachment_mime_type = None
if file and file.filename:
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
attachment_path = file_metadata["file_path"]
attachment_filename = file_metadata["original_filename"]
attachment_mime_type = file_metadata["mime_type"]
# 5. Crear el asunto
new_issue = TicketIssue(
id=uuid.uuid4(),
ticket_id=ticket_uuid,
tenant_id=current_user.tenant_id,
created_by=current_user.id,
content=issue.content,
priority=TicketPriority[issue.priority],
attachment_path=attachment_path,
attachment_filename=attachment_filename,
attachment_mime_type=attachment_mime_type,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
new_issue.tagged_users = tagged_users
db.add(new_issue)
await db.commit()
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
# 6. Audit log
await safe_audit_log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.issue.create",
resource_type="ticket_issue",
resource_id=new_issue.id,
new_values={
"ticket_id": str(ticket_uuid),
"priority": issue.priority,
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
},
)
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,
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=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in new_issue.tagged_users
],
attachment_filename=new_issue.attachment_filename,
attachment_mime_type=new_issue.attachment_mime_type,
created_at=new_issue.created_at,
updated_at=new_issue.updated_at,
)

View File

@@ -35,6 +35,7 @@ from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache
from app.core.limiter import limiter
from app.models.issue import TicketIssue, ticket_issue_tagged_users
settings = get_settings()
setup_logging()

View File

@@ -3,6 +3,7 @@
from .user import User
from .tenant import Tenant
from .ticket import Ticket
from .issue import TicketIssue, ticket_issue_tagged_users
from .comment import TicketComment
from .system import System
from .category import Category
@@ -13,8 +14,9 @@ from .refresh_token import RefreshToken
__all__ = [
"User",
"Tenant",
"Tenant",
"Ticket",
"TicketIssue",
"TicketComment",
"System",
"Category",

102
backend/app/models/issue.py Normal file
View File

@@ -0,0 +1,102 @@
"""
TicketIssue Model - ServiceManagerWeb
Asuntos de escalación asociados a tickets.
Creados por el dueño del ticket o el CLIENT_ADMIN del tenant.
"""
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 uuid
from app.core.database import Base, GUID
from app.models.ticket import TicketPriority
from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
ticket_issue_tagged_users = Table(
"ticket_issue_tagged_users",
Base.metadata,
Column(
"issue_id",
GUID(),
ForeignKey("ticket_issues.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
Column(
"user_id",
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
)
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(
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
"postgresql",
),
default=TicketPriority.MEDIUM,
nullable=False,
)
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment)
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],
)
tagged_users: Mapped[List["User"]] = relationship(
"User",
secondary=ticket_issue_tagged_users,
)
def __repr__(self) -> str:
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>"

View File

@@ -163,6 +163,12 @@ class Ticket(Base):
cascade="all, delete-orphan"
)
issues: Mapped[list["TicketIssue"]] = relationship(
"TicketIssue",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"