Asunto en internos funcionando
This commit is contained in:
@@ -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] = []
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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})>"
|
||||
41
backend/migrations/versions/9158111a7e00_add_issue_status.py
Normal file
41
backend/migrations/versions/9158111a7e00_add_issue_status.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""add_issue_status
|
||||
|
||||
Revision ID: 9158111a7e00
|
||||
Revises: 0aec0a6e294a
|
||||
Create Date: 2026-03-19 19:00:25.334583
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '9158111a7e00'
|
||||
down_revision = '0aec0a6e294a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Verificar si el tipo ya existe antes de crearlo
|
||||
op.execute("""
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE issue_status_enum AS ENUM ('OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
op.add_column('ticket_issues', sa.Column(
|
||||
'status',
|
||||
postgresql.ENUM('OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED',
|
||||
name='issue_status_enum', create_type=False),
|
||||
nullable=True
|
||||
))
|
||||
|
||||
op.execute("UPDATE ticket_issues SET status = 'OPEN' WHERE status IS NULL")
|
||||
|
||||
op.alter_column('ticket_issues', 'status', nullable=False)
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('ticket_issues', 'status')
|
||||
op.execute("DROP TYPE IF EXISTS issue_status_enum")
|
||||
Reference in New Issue
Block a user