diff --git a/add_participant.py b/add_participant.py new file mode 100644 index 0000000..fa4006f --- /dev/null +++ b/add_participant.py @@ -0,0 +1,48 @@ +import asyncio +from app.core.database import AsyncSessionLocal, Base, engine +from app.models.ticket import Ticket +from sqlalchemy import String, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import GUID +import uuid + +# Agregar modelo al archivo ticket.py +new_model = ''' + +class TicketParticipant(Base): + """Participantes asignados a un ticket""" + __tablename__ = "ticket_participants" + + ticket_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("tickets.id", ondelete="CASCADE"), + nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False + ) + role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant") + + ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants") + user: Mapped["User"] = relationship("User") + + __table_args__ = ( + UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"), + ) + + def __repr__(self): + return f"" +''' + +with open("/app/app/models/ticket.py", "r") as f: + content = f.read() + +if "TicketParticipant" in content: + print("Ya existe TicketParticipant") +else: + content += new_model + with open("/app/app/models/ticket.py", "w") as f: + f.write(content) + print("OK: TicketParticipant agregado") diff --git a/add_relation.py b/add_relation.py new file mode 100644 index 0000000..aa184b3 --- /dev/null +++ b/add_relation.py @@ -0,0 +1,21 @@ +with open("/app/app/models/ticket.py", "r") as f: + content = f.read() + +old = ' def __repr__(self) -> str:\n return f""' +new = ''' participants: Mapped[list["TicketParticipant"]] = relationship( + "TicketParticipant", + back_populates="ticket", + cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f""''' + +if old in content: + content = content.replace(old, new) + print("OK: relacion participants agregada") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/models/ticket.py", "w") as f: + f.write(content) diff --git a/backend/add_participant.py b/backend/add_participant.py new file mode 100644 index 0000000..fa4006f --- /dev/null +++ b/backend/add_participant.py @@ -0,0 +1,48 @@ +import asyncio +from app.core.database import AsyncSessionLocal, Base, engine +from app.models.ticket import Ticket +from sqlalchemy import String, ForeignKey, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.database import GUID +import uuid + +# Agregar modelo al archivo ticket.py +new_model = ''' + +class TicketParticipant(Base): + """Participantes asignados a un ticket""" + __tablename__ = "ticket_participants" + + ticket_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("tickets.id", ondelete="CASCADE"), + nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False + ) + role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant") + + ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants") + user: Mapped["User"] = relationship("User") + + __table_args__ = ( + UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"), + ) + + def __repr__(self): + return f"" +''' + +with open("/app/app/models/ticket.py", "r") as f: + content = f.read() + +if "TicketParticipant" in content: + print("Ya existe TicketParticipant") +else: + content += new_model + with open("/app/app/models/ticket.py", "w") as f: + f.write(content) + print("OK: TicketParticipant agregado") diff --git a/backend/add_relation.py b/backend/add_relation.py new file mode 100644 index 0000000..aa184b3 --- /dev/null +++ b/backend/add_relation.py @@ -0,0 +1,21 @@ +with open("/app/app/models/ticket.py", "r") as f: + content = f.read() + +old = ' def __repr__(self) -> str:\n return f""' +new = ''' participants: Mapped[list["TicketParticipant"]] = relationship( + "TicketParticipant", + back_populates="ticket", + cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f""''' + +if old in content: + content = content.replace(old, new) + print("OK: relacion participants agregada") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/models/ticket.py", "w") as f: + f.write(content) diff --git a/backend/app/api/schemas/category.py b/backend/app/api/schemas/category.py index 91cda6d..3d33381 100644 --- a/backend/app/api/schemas/category.py +++ b/backend/app/api/schemas/category.py @@ -11,13 +11,14 @@ import uuid class CategoryCreate(BaseModel): - """Schema para crear categoría. No incluye tenant_id (se asigna automáticamente).""" + """Schema para crear categoría. tenant_id opcional para ADMIN global.""" name: str description: Optional[str] = None color: Optional[str] = None sla_response_hours: int = 24 sla_resolution_hours: int = 72 auto_assign_to: Optional[uuid.UUID] = None + tenant_id: Optional[uuid.UUID] = None class CategoryUpdate(BaseModel): diff --git a/backend/app/api/v1/endpoints/categories.py b/backend/app/api/v1/endpoints/categories.py index 4376e03..6690ced 100644 --- a/backend/app/api/v1/endpoints/categories.py +++ b/backend/app/api/v1/endpoints/categories.py @@ -79,11 +79,16 @@ async def create_category( ✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario. """ - # ✅ CORREGIDO: Asignar tenant_id del usuario actual - db_category = Category( - **category.model_dump(), - tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático - ) + # ADMIN global puede especificar tenant_id; otros usan el suyo + from app.models.user import UserRole as _R + data = category.model_dump() + if current_user.role == _R.ADMIN and data.get("tenant_id"): + target_tenant_id = data["tenant_id"] + else: + target_tenant_id = current_user.tenant_id + data["tenant_id"] = target_tenant_id + + db_category = Category(**data) db.add(db_category) await db.commit() diff --git a/backend/app/api/v1/endpoints/participants.py b/backend/app/api/v1/endpoints/participants.py new file mode 100644 index 0000000..15376e9 --- /dev/null +++ b/backend/app/api/v1/endpoints/participants.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +import uuid + +from app.api import deps +from app.core.database import get_db +from app.models.ticket import Ticket, TicketParticipant +from app.models.user import User +from pydantic import BaseModel + +router = APIRouter() + +class ParticipantAdd(BaseModel): + user_id: uuid.UUID + +class ParticipantResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + ticket_id: uuid.UUID + role: str + user_email: str + user_name: str + + class Config: + from_attributes = True + +@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse]) +async def list_participants( + ticket_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user) +): + result = await db.execute( + select(TicketParticipant) + .where(TicketParticipant.ticket_id == ticket_id) + .options(selectinload(TicketParticipant.user)) + ) + participants = result.scalars().all() + return [ + ParticipantResponse( + id=p.id, + user_id=p.user_id, + ticket_id=p.ticket_id, + role=p.role, + user_email=p.user.email, + user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip() + ) + for p in participants + ] + +@router.post("/{ticket_id}/participants", response_model=ParticipantResponse) +async def add_participant( + ticket_id: uuid.UUID, + data: ParticipantAdd, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user) +): + # Verificar que el ticket existe + ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = ticket_result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Solo el creador puede agregar participantes + if ticket.created_by != current_user.id and not current_user.role.is_global: + raise HTTPException(status_code=403, detail="Only the ticket creator can add participants") + + # Verificar que el usuario existe + user_result = await db.execute(select(User).where(User.id == data.user_id)) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Verificar que no sea duplicado + existing = await db.execute( + select(TicketParticipant).where( + TicketParticipant.ticket_id == ticket_id, + TicketParticipant.user_id == data.user_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="User is already a participant") + + participant = TicketParticipant( + ticket_id=ticket_id, + user_id=data.user_id, + role="participant" + ) + db.add(participant) + await db.commit() + await db.refresh(participant) + + return ParticipantResponse( + id=participant.id, + user_id=participant.user_id, + ticket_id=participant.ticket_id, + role=participant.role, + user_email=user.email, + user_name=f"{user.first_name or ''} {user.last_name or ''}".strip() + ) + +@router.delete("/{ticket_id}/participants/{user_id}", status_code=204) +async def remove_participant( + ticket_id: uuid.UUID, + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user) +): + ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = ticket_result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + if ticket.created_by != current_user.id and not current_user.role.is_global: + raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants") + + result = await db.execute( + select(TicketParticipant).where( + TicketParticipant.ticket_id == ticket_id, + TicketParticipant.user_id == user_id + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=404, detail="Participant not found") + + await db.delete(participant) + await db.commit() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 1f491bf..56fd3e2 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -6,7 +6,7 @@ Router principal para la API v1 from fastapi import APIRouter -from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports +from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants api_router = APIRouter() @@ -53,6 +53,12 @@ api_router.include_router( prefix="/tickets", tags=["tickets"] ) +# Participants routes (nested under tickets) +api_router.include_router( + participants.router, + prefix="/tickets", + tags=["participants"] +) # Client Profile routes api_router.include_router( diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py index ab9b9ea..0ae54c2 100644 --- a/backend/app/models/ticket.py +++ b/backend/app/models/ticket.py @@ -157,5 +157,37 @@ class Ticket(Base): CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'), ) + participants: Mapped[list["TicketParticipant"]] = relationship( + "TicketParticipant", + back_populates="ticket", + cascade="all, delete-orphan" + ) + def __repr__(self) -> str: - return f"" \ No newline at end of file + return f"" + +class TicketParticipant(Base): + """Participantes asignados a un ticket""" + __tablename__ = "ticket_participants" + + ticket_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("tickets.id", ondelete="CASCADE"), + nullable=False + ) + user_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False + ) + role: Mapped[str] = mapped_column(String(50), nullable=False, default="participant") + + ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="participants") + user: Mapped["User"] = relationship("User") + + __table_args__ = ( + UniqueConstraint("ticket_id", "user_id", name="uq_ticket_participant"), + ) + + def __repr__(self): + return f"" diff --git a/backend/check_schema.py b/backend/check_schema.py new file mode 100644 index 0000000..c638b34 --- /dev/null +++ b/backend/check_schema.py @@ -0,0 +1,3 @@ +with open("/app/app/api/schemas/category.py", "r") as f: + content = f.read() +print(content) diff --git a/backend/fix_categories.py b/backend/fix_categories.py new file mode 100644 index 0000000..c45ad8a --- /dev/null +++ b/backend/fix_categories.py @@ -0,0 +1,28 @@ +with open("/app/app/api/v1/endpoints/categories.py", "r") as f: + content = f.read() + +old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual + db_category = Category( + **category.model_dump(), + tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático + )''' + +new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo + from app.models.user import UserRole as _R + data = category.model_dump() + if current_user.role == _R.ADMIN and data.get("tenant_id"): + target_tenant_id = data["tenant_id"] + else: + target_tenant_id = current_user.tenant_id + data["tenant_id"] = target_tenant_id + + db_category = Category(**data)''' + +if old in content: + content = content.replace(old, new) + print("OK: fix aplicado") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/categories.py", "w") as f: + f.write(content) diff --git a/backend/fix_router.py b/backend/fix_router.py new file mode 100644 index 0000000..8e247fa --- /dev/null +++ b/backend/fix_router.py @@ -0,0 +1,34 @@ +with open("/app/app/api/v1/router.py", "r") as f: + content = f.read() + +old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports" +new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants" + +old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)" +new2 = """# Tickets routes +api_router.include_router( + tickets.router, + prefix="/tickets", + tags=["tickets"] +) +# Participants routes (nested under tickets) +api_router.include_router( + participants.router, + prefix="/tickets", + tags=["participants"] +)""" + +if old in content: + content = content.replace(old, new) + print("OK: import agregado") +else: + print("ERROR: import no encontrado") + +if old2 in content: + content = content.replace(old2, new2) + print("OK: router agregado") +else: + print("ERROR: router no encontrado") + +with open("/app/app/api/v1/router.py", "w") as f: + f.write(content) diff --git a/backend/fix_schema.py b/backend/fix_schema.py new file mode 100644 index 0000000..711afe6 --- /dev/null +++ b/backend/fix_schema.py @@ -0,0 +1,30 @@ +with open("/app/app/api/schemas/category.py", "r") as f: + content = f.read() + +old = '''class CategoryCreate(BaseModel): + """Schema para crear categoría. No incluye tenant_id (se asigna automáticamente).""" + name: str + description: Optional[str] = None + color: Optional[str] = None + sla_response_hours: int = 24 + sla_resolution_hours: int = 72 + auto_assign_to: Optional[uuid.UUID] = None''' + +new = '''class CategoryCreate(BaseModel): + """Schema para crear categoría. tenant_id opcional para ADMIN global.""" + name: str + description: Optional[str] = None + color: Optional[str] = None + sla_response_hours: int = 24 + sla_resolution_hours: int = 72 + auto_assign_to: Optional[uuid.UUID] = None + tenant_id: Optional[uuid.UUID] = None''' + +if old in content: + content = content.replace(old, new) + print("OK") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/schemas/category.py", "w") as f: + f.write(content) diff --git a/backend/migrations/versions/46bccd948688_add_ticket_participants.py b/backend/migrations/versions/46bccd948688_add_ticket_participants.py new file mode 100644 index 0000000..f01e7eb --- /dev/null +++ b/backend/migrations/versions/46bccd948688_add_ticket_participants.py @@ -0,0 +1,28 @@ +"""add_ticket_participants + +Revision ID: 46bccd948688 +Revises: d2e3f4a5b6c7 +Create Date: 2026-03-11 17:59:55.409878 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '46bccd948688' +down_revision = 'd2e3f4a5b6c7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### \ No newline at end of file diff --git a/check_schema.py b/check_schema.py new file mode 100644 index 0000000..c638b34 --- /dev/null +++ b/check_schema.py @@ -0,0 +1,3 @@ +with open("/app/app/api/schemas/category.py", "r") as f: + content = f.read() +print(content) diff --git a/fix_categories.py b/fix_categories.py new file mode 100644 index 0000000..c45ad8a --- /dev/null +++ b/fix_categories.py @@ -0,0 +1,28 @@ +with open("/app/app/api/v1/endpoints/categories.py", "r") as f: + content = f.read() + +old = ''' # ✅ CORREGIDO: Asignar tenant_id del usuario actual + db_category = Category( + **category.model_dump(), + tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático + )''' + +new = ''' # ADMIN global puede especificar tenant_id; otros usan el suyo + from app.models.user import UserRole as _R + data = category.model_dump() + if current_user.role == _R.ADMIN and data.get("tenant_id"): + target_tenant_id = data["tenant_id"] + else: + target_tenant_id = current_user.tenant_id + data["tenant_id"] = target_tenant_id + + db_category = Category(**data)''' + +if old in content: + content = content.replace(old, new) + print("OK: fix aplicado") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/v1/endpoints/categories.py", "w") as f: + f.write(content) diff --git a/fix_router.py b/fix_router.py new file mode 100644 index 0000000..8e247fa --- /dev/null +++ b/fix_router.py @@ -0,0 +1,34 @@ +with open("/app/app/api/v1/router.py", "r") as f: + content = f.read() + +old = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports" +new = "from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets, client_profile, audit, sla, reports, participants" + +old2 = "# Tickets routes\napi_router.include_router(\n tickets.router,\n prefix=\"/tickets\",\n tags=[\"tickets\"]\n)" +new2 = """# Tickets routes +api_router.include_router( + tickets.router, + prefix="/tickets", + tags=["tickets"] +) +# Participants routes (nested under tickets) +api_router.include_router( + participants.router, + prefix="/tickets", + tags=["participants"] +)""" + +if old in content: + content = content.replace(old, new) + print("OK: import agregado") +else: + print("ERROR: import no encontrado") + +if old2 in content: + content = content.replace(old2, new2) + print("OK: router agregado") +else: + print("ERROR: router no encontrado") + +with open("/app/app/api/v1/router.py", "w") as f: + f.write(content) diff --git a/fix_schema.py b/fix_schema.py new file mode 100644 index 0000000..711afe6 --- /dev/null +++ b/fix_schema.py @@ -0,0 +1,30 @@ +with open("/app/app/api/schemas/category.py", "r") as f: + content = f.read() + +old = '''class CategoryCreate(BaseModel): + """Schema para crear categoría. No incluye tenant_id (se asigna automáticamente).""" + name: str + description: Optional[str] = None + color: Optional[str] = None + sla_response_hours: int = 24 + sla_resolution_hours: int = 72 + auto_assign_to: Optional[uuid.UUID] = None''' + +new = '''class CategoryCreate(BaseModel): + """Schema para crear categoría. tenant_id opcional para ADMIN global.""" + name: str + description: Optional[str] = None + color: Optional[str] = None + sla_response_hours: int = 24 + sla_resolution_hours: int = 72 + auto_assign_to: Optional[uuid.UUID] = None + tenant_id: Optional[uuid.UUID] = None''' + +if old in content: + content = content.replace(old, new) + print("OK") +else: + print("ERROR: bloque no encontrado") + +with open("/app/app/api/schemas/category.py", "w") as f: + f.write(content) diff --git a/frontend-client/src/lib/components/ParticipantsModal.svelte b/frontend-client/src/lib/components/ParticipantsModal.svelte new file mode 100644 index 0000000..aa1def8 --- /dev/null +++ b/frontend-client/src/lib/components/ParticipantsModal.svelte @@ -0,0 +1,242 @@ + + + +
+
dispatch('close')}>
+ +
+ + +
+
+

Participantes del asunto

+

{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}

+
+ +
+ +
+ + + {#if canManage} +
+ +
+ + + + +
+ + + {#if searchQuery && filteredUsers.length > 0} +
+ {#each filteredUsers.slice(0, 6) as user (user.id)} + + {/each} +
+ {:else if searchQuery && filteredUsers.length === 0} +

No se encontraron usuarios disponibles

+ {/if} +
+ {/if} + + +
+

+ {canManage ? 'Participantes actuales' : 'Personas involucradas'} +

+ + {#if isLoading} +
+
+
+ + {:else if participants.length === 0} +
+ + + +

Aún no hay participantes

+ {#if canManage} +

Usa el buscador para agregar personas

+ {/if} +
+ + {:else} +
+ {#each participants as p (p.id)} +
+
+ {avatarInitials(p.user_name, p.user_email)} +
+
+

{p.user_name || p.user_email}

+

{p.user_email}

+
+ + Participante + + {#if canManage} + + {/if} +
+ {/each} +
+ {/if} +
+
+ + +
+ +
+ +
+
diff --git a/frontend-internal/src/lib/components/ParticipantsModal.svelte b/frontend-internal/src/lib/components/ParticipantsModal.svelte new file mode 100644 index 0000000..aa1def8 --- /dev/null +++ b/frontend-internal/src/lib/components/ParticipantsModal.svelte @@ -0,0 +1,242 @@ + + + +
+
dispatch('close')}>
+ +
+ + +
+
+

Participantes del asunto

+

{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}

+
+ +
+ +
+ + + {#if canManage} +
+ +
+ + + + +
+ + + {#if searchQuery && filteredUsers.length > 0} +
+ {#each filteredUsers.slice(0, 6) as user (user.id)} + + {/each} +
+ {:else if searchQuery && filteredUsers.length === 0} +

No se encontraron usuarios disponibles

+ {/if} +
+ {/if} + + +
+

+ {canManage ? 'Participantes actuales' : 'Personas involucradas'} +

+ + {#if isLoading} +
+
+
+ + {:else if participants.length === 0} +
+ + + +

Aún no hay participantes

+ {#if canManage} +

Usa el buscador para agregar personas

+ {/if} +
+ + {:else} +
+ {#each participants as p (p.id)} +
+
+ {avatarInitials(p.user_name, p.user_email)} +
+
+

{p.user_name || p.user_email}

+

{p.user_email}

+
+ + Participante + + {#if canManage} + + {/if} +
+ {/each} +
+ {/if} +
+
+ + +
+ +
+ +
+
diff --git a/frontend-internal/src/routes/tickets/[id]/+page.svelte b/frontend-internal/src/routes/tickets/[id]/+page.svelte index 02d685d..7c3bf9d 100644 --- a/frontend-internal/src/routes/tickets/[id]/+page.svelte +++ b/frontend-internal/src/routes/tickets/[id]/+page.svelte @@ -4,7 +4,9 @@ import { toast } from '$lib/stores/toast'; import { api } from '$lib/utils/api'; import { onMount } from 'svelte'; + import ParticipantsModal from '$lib/components/ParticipantsModal.svelte'; + let showParticipants = false; let ticketId: string; let ticket = null; let comments = []; @@ -374,6 +376,17 @@
{formatDate(ticket.updated_at)}
+ +
+
+

Asunto

+ +
+
+ {#if ticket.sla_response_due || ticket.sla_resolution_due}
@@ -429,4 +442,11 @@
{/if} + {#if showParticipants && ticket} + showParticipants = false} + /> +{/if} diff --git a/participants_router.py b/participants_router.py new file mode 100644 index 0000000..15376e9 --- /dev/null +++ b/participants_router.py @@ -0,0 +1,130 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +import uuid + +from app.api import deps +from app.core.database import get_db +from app.models.ticket import Ticket, TicketParticipant +from app.models.user import User +from pydantic import BaseModel + +router = APIRouter() + +class ParticipantAdd(BaseModel): + user_id: uuid.UUID + +class ParticipantResponse(BaseModel): + id: uuid.UUID + user_id: uuid.UUID + ticket_id: uuid.UUID + role: str + user_email: str + user_name: str + + class Config: + from_attributes = True + +@router.get("/{ticket_id}/participants", response_model=list[ParticipantResponse]) +async def list_participants( + ticket_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user) +): + result = await db.execute( + select(TicketParticipant) + .where(TicketParticipant.ticket_id == ticket_id) + .options(selectinload(TicketParticipant.user)) + ) + participants = result.scalars().all() + return [ + ParticipantResponse( + id=p.id, + user_id=p.user_id, + ticket_id=p.ticket_id, + role=p.role, + user_email=p.user.email, + user_name=f"{p.user.first_name or ''} {p.user.last_name or ''}".strip() + ) + for p in participants + ] + +@router.post("/{ticket_id}/participants", response_model=ParticipantResponse) +async def add_participant( + ticket_id: uuid.UUID, + data: ParticipantAdd, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user) +): + # Verificar que el ticket existe + ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = ticket_result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + # Solo el creador puede agregar participantes + if ticket.created_by != current_user.id and not current_user.role.is_global: + raise HTTPException(status_code=403, detail="Only the ticket creator can add participants") + + # Verificar que el usuario existe + user_result = await db.execute(select(User).where(User.id == data.user_id)) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Verificar que no sea duplicado + existing = await db.execute( + select(TicketParticipant).where( + TicketParticipant.ticket_id == ticket_id, + TicketParticipant.user_id == data.user_id + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="User is already a participant") + + participant = TicketParticipant( + ticket_id=ticket_id, + user_id=data.user_id, + role="participant" + ) + db.add(participant) + await db.commit() + await db.refresh(participant) + + return ParticipantResponse( + id=participant.id, + user_id=participant.user_id, + ticket_id=participant.ticket_id, + role=participant.role, + user_email=user.email, + user_name=f"{user.first_name or ''} {user.last_name or ''}".strip() + ) + +@router.delete("/{ticket_id}/participants/{user_id}", status_code=204) +async def remove_participant( + ticket_id: uuid.UUID, + user_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(deps.get_current_user) +): + ticket_result = await db.execute(select(Ticket).where(Ticket.id == ticket_id)) + ticket = ticket_result.scalar_one_or_none() + if not ticket: + raise HTTPException(status_code=404, detail="Ticket not found") + + if ticket.created_by != current_user.id and not current_user.role.is_global: + raise HTTPException(status_code=403, detail="Only the ticket creator can remove participants") + + result = await db.execute( + select(TicketParticipant).where( + TicketParticipant.ticket_id == ticket_id, + TicketParticipant.user_id == user_id + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=404, detail="Participant not found") + + await db.delete(participant) + await db.commit()