From 08816ab73ac98d98993176769f4c4e9d44e53daa Mon Sep 17 00:00:00 2001 From: icamarillo Date: Tue, 24 Mar 2026 08:01:57 -0600 Subject: [PATCH] Asunto en internos funcionando --- backend/app/api/schemas/issue.py | 13 + backend/app/api/v1/endpoints/tickets.py | 146 ++++++- backend/app/api/v1/endpoints/users.py | 24 ++ backend/app/models/issue.py | 45 +- .../versions/9158111a7e00_add_issue_status.py | 41 ++ .../lib/components/IssueDetailModal.svelte | 227 ++++++++++ .../src/routes/tickets/[id]/+page.svelte | 404 +++++++++--------- 7 files changed, 660 insertions(+), 240 deletions(-) create mode 100644 backend/migrations/versions/9158111a7e00_add_issue_status.py create mode 100644 frontend-internal/src/lib/components/IssueDetailModal.svelte diff --git a/backend/app/api/schemas/issue.py b/backend/app/api/schemas/issue.py index e478710..ecde532 100644 --- a/backend/app/api/schemas/issue.py +++ b/backend/app/api/schemas/issue.py @@ -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] = [] diff --git a/backend/app/api/v1/endpoints/tickets.py b/backend/app/api/v1/endpoints/tickets.py index adedbef..6b7c8c3 100644 --- a/backend/app/api/v1/endpoints/tickets.py +++ b/backend/app/api/v1/endpoints/tickets.py @@ -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, - } \ No newline at end of file + } + + +@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}, + ) \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py index 0328ddc..ce6656c 100644 --- a/backend/app/api/v1/endpoints/users.py +++ b/backend/app/api/v1/endpoints/users.py @@ -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 + diff --git a/backend/app/models/issue.py b/backend/app/models/issue.py index 226cb77..7311201 100644 --- a/backend/app/models/issue.py +++ b/backend/app/models/issue.py @@ -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"" \ No newline at end of file + return f"" \ No newline at end of file diff --git a/backend/migrations/versions/9158111a7e00_add_issue_status.py b/backend/migrations/versions/9158111a7e00_add_issue_status.py new file mode 100644 index 0000000..388adcf --- /dev/null +++ b/backend/migrations/versions/9158111a7e00_add_issue_status.py @@ -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") \ No newline at end of file diff --git a/frontend-internal/src/lib/components/IssueDetailModal.svelte b/frontend-internal/src/lib/components/IssueDetailModal.svelte new file mode 100644 index 0000000..628f712 --- /dev/null +++ b/frontend-internal/src/lib/components/IssueDetailModal.svelte @@ -0,0 +1,227 @@ + + +
+
+
e.key === 'Escape' && handleClose()} + /> + +
+ +
+

Detalle del Asunto

+ +
+ + +
+ +
+ + {priorityObj.label} + + + {statusObj.label} + +
+ + +
+

Descripción

+

+ {issue.content} +

+
+ + +
+ Creado por {issue.created_by_name} + {formatDate(issue.created_at)} +
+ + + {#if issue.tagged_users?.length > 0} +
+

Usuarios etiquetados

+
+ {#each issue.tagged_users as user} +
+ {user.full_name} + · + {user.email} +
+ {/each} +
+
+ {/if} + + + {#if issue.attachment_filename} +
+

Adjunto

+
+ + + + {issue.attachment_filename} +
+
+ {/if} + + + {#if canManage} +
+ + +
+ {/if} +
+ + +
+ {#if canManage} + + {:else} +
+ {/if} + +
+
+
+
diff --git a/frontend-internal/src/routes/tickets/[id]/+page.svelte b/frontend-internal/src/routes/tickets/[id]/+page.svelte index 40b3031..e18da0b 100644 --- a/frontend-internal/src/routes/tickets/[id]/+page.svelte +++ b/frontend-internal/src/routes/tickets/[id]/+page.svelte @@ -1,6 +1,7 @@ @@ -194,7 +186,6 @@
- -
-
-

- {ticket.description} -

-
+

{ticket.description}

@@ -221,43 +207,35 @@ Archivos Adjuntos ({attachments.length}) -
-
- {#each attachments as attachment} -
-
-
- ADJ -
-
-

- {attachment.original_filename} -

-

- {Math.round(attachment.size_bytes / 1024)} KB - {#if attachment.uploaded_by_name} - • Subido por {attachment.uploaded_by_name} - {/if} - {#if attachment.uploaded_at} - • {formatDate(attachment.uploaded_at)} - {/if} -

-
-
- + ADJ +
+
+

+ {attachment.original_filename} +

+

+ {Math.round(attachment.size_bytes / 1024)} KB + {#if attachment.uploaded_by_name}• Subido por {attachment.uploaded_by_name}{/if} +

+
- {/each} -
+ + + {/each} {/if} @@ -269,9 +247,7 @@
{#if comments.length === 0} -

- No hay comentarios aún. ¡Sé el primero en comentar! -

+

No hay comentarios aún.

{:else}
{#each comments as comment} @@ -281,39 +257,31 @@ > {comment.author_name - ? comment.author_name - .split(' ') - .map(n => n[0]) - .join('') - : 'U'} + ?.split(' ') + .map(n => n[0]) + .join('') ?? 'U'}
- - {comment.author_name || 'Usuario Desconocido'} - - - {formatDate(comment.created_at)} - + {comment.author_name || 'Desconocido'} + {formatDate(comment.created_at)} {#if comment.is_internal} Interno - Interno - {/if}
-

- {comment.content} -

+

{comment.content}

{/each} {/if} -