Implementacion modal ticket 1
This commit is contained in:
48
add_participant.py
Normal file
48
add_participant.py
Normal file
@@ -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"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||||
|
'''
|
||||||
|
|
||||||
|
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")
|
||||||
21
add_relation.py
Normal file
21
add_relation.py
Normal file
@@ -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"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
|
||||||
|
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
|
||||||
|
"TicketParticipant",
|
||||||
|
back_populates="ticket",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
|
||||||
|
|
||||||
|
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)
|
||||||
48
backend/add_participant.py
Normal file
48
backend/add_participant.py
Normal file
@@ -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"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||||
|
'''
|
||||||
|
|
||||||
|
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")
|
||||||
21
backend/add_relation.py
Normal file
21
backend/add_relation.py
Normal file
@@ -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"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'
|
||||||
|
new = ''' participants: Mapped[list["TicketParticipant"]] = relationship(
|
||||||
|
"TicketParticipant",
|
||||||
|
back_populates="ticket",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Ticket(id={self.id}, number=\'{self.ticket_number}\', status={self.status})>"'''
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -11,13 +11,14 @@ import uuid
|
|||||||
|
|
||||||
|
|
||||||
class CategoryCreate(BaseModel):
|
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
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
sla_response_hours: int = 24
|
sla_response_hours: int = 24
|
||||||
sla_resolution_hours: int = 72
|
sla_resolution_hours: int = 72
|
||||||
auto_assign_to: Optional[uuid.UUID] = None
|
auto_assign_to: Optional[uuid.UUID] = None
|
||||||
|
tenant_id: Optional[uuid.UUID] = None
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
|
|||||||
@@ -79,11 +79,16 @@ async def create_category(
|
|||||||
|
|
||||||
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
|
||||||
"""
|
"""
|
||||||
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
|
# ADMIN global puede especificar tenant_id; otros usan el suyo
|
||||||
db_category = Category(
|
from app.models.user import UserRole as _R
|
||||||
**category.model_dump(),
|
data = category.model_dump()
|
||||||
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
|
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)
|
db.add(db_category)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
130
backend/app/api/v1/endpoints/participants.py
Normal file
130
backend/app/api/v1/endpoints/participants.py
Normal file
@@ -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()
|
||||||
@@ -6,7 +6,7 @@ Router principal para la API v1
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -53,6 +53,12 @@ api_router.include_router(
|
|||||||
prefix="/tickets",
|
prefix="/tickets",
|
||||||
tags=["tickets"]
|
tags=["tickets"]
|
||||||
)
|
)
|
||||||
|
# Participants routes (nested under tickets)
|
||||||
|
api_router.include_router(
|
||||||
|
participants.router,
|
||||||
|
prefix="/tickets",
|
||||||
|
tags=["participants"]
|
||||||
|
)
|
||||||
|
|
||||||
# Client Profile routes
|
# Client Profile routes
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
|
|||||||
@@ -157,5 +157,37 @@ class Ticket(Base):
|
|||||||
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
|
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:
|
def __repr__(self) -> str:
|
||||||
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"
|
||||||
|
|
||||||
|
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"<TicketParticipant(ticket={self.ticket_id}, user={self.user_id})>"
|
||||||
|
|||||||
3
backend/check_schema.py
Normal file
3
backend/check_schema.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
with open("/app/app/api/schemas/category.py", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
print(content)
|
||||||
28
backend/fix_categories.py
Normal file
28
backend/fix_categories.py
Normal file
@@ -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)
|
||||||
34
backend/fix_router.py
Normal file
34
backend/fix_router.py
Normal file
@@ -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)
|
||||||
30
backend/fix_schema.py
Normal file
30
backend/fix_schema.py
Normal file
@@ -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)
|
||||||
@@ -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 ###
|
||||||
3
check_schema.py
Normal file
3
check_schema.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
with open("/app/app/api/schemas/category.py", "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
print(content)
|
||||||
28
fix_categories.py
Normal file
28
fix_categories.py
Normal file
@@ -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)
|
||||||
34
fix_router.py
Normal file
34
fix_router.py
Normal file
@@ -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)
|
||||||
30
fix_schema.py
Normal file
30
fix_schema.py
Normal file
@@ -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)
|
||||||
242
frontend-client/src/lib/components/ParticipantsModal.svelte
Normal file
242
frontend-client/src/lib/components/ParticipantsModal.svelte
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/utils/api';
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
export let ticketId: string;
|
||||||
|
export let createdBy: string;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
ticket_id: string;
|
||||||
|
role: string;
|
||||||
|
user_email: string;
|
||||||
|
user_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let participants: Participant[] = [];
|
||||||
|
let allUsers: User[] = [];
|
||||||
|
let isLoading = true;
|
||||||
|
let isAdding = false;
|
||||||
|
let searchQuery = '';
|
||||||
|
let selectedUserId = '';
|
||||||
|
|
||||||
|
$: isCreator = $auth.user?.id === createdBy;
|
||||||
|
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
|
||||||
|
$: canManage = isCreator || isGlobalAdmin;
|
||||||
|
|
||||||
|
$: participantIds = new Set(participants.map(p => p.user_id));
|
||||||
|
|
||||||
|
$: filteredUsers = allUsers.filter(u => {
|
||||||
|
if (participantIds.has(u.id)) return false;
|
||||||
|
if (u.id === createdBy) return false;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return !q ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([loadParticipants(), loadUsers()]);
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadParticipants() {
|
||||||
|
try {
|
||||||
|
participants = await api.get(`/tickets/${ticketId}/participants`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error cargando participantes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
allUsers = await api.get('/users/');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error cargando usuarios');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addParticipant(userId: string) {
|
||||||
|
if (!userId) return;
|
||||||
|
isAdding = true;
|
||||||
|
try {
|
||||||
|
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
|
||||||
|
participants = [...participants, p];
|
||||||
|
searchQuery = '';
|
||||||
|
toast.success('Participante agregado');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Error al agregar participante');
|
||||||
|
} finally {
|
||||||
|
isAdding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeParticipant(userId: string) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
|
||||||
|
participants = participants.filter(p => p.user_id !== userId);
|
||||||
|
toast.success('Participante eliminado');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Error al eliminar participante');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarInitials(name: string, email: string): string {
|
||||||
|
if (name.trim()) {
|
||||||
|
const parts = name.trim().split(' ');
|
||||||
|
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
|
||||||
|
}
|
||||||
|
return email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarColor(email: string): string {
|
||||||
|
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
|
||||||
|
let hash = 0;
|
||||||
|
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
|
||||||
|
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" on:click={() => dispatch('close')}
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
|
||||||
|
|
||||||
|
<!-- Buscador para agregar -->
|
||||||
|
{#if canManage}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o email..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultados de búsqueda -->
|
||||||
|
{#if searchQuery && filteredUsers.length > 0}
|
||||||
|
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
{#each filteredUsers.slice(0, 6) as user (user.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => addParticipant(user.id)}
|
||||||
|
disabled={isAdding}
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
|
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if searchQuery && filteredUsers.length === 0}
|
||||||
|
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lista de participantes -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center py-6">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if participants.length === 0}
|
||||||
|
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-400">Aún no hay participantes</p>
|
||||||
|
{#if canManage}
|
||||||
|
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each participants as p (p.id)}
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
|
||||||
|
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
|
{avatarInitials(p.user_name, p.user_email)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
|
||||||
|
Participante
|
||||||
|
</span>
|
||||||
|
{#if canManage}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => removeParticipant(p.user_id)}
|
||||||
|
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Quitar participante"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
|
||||||
|
<button type="button" on:click={() => dispatch('close')}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
242
frontend-internal/src/lib/components/ParticipantsModal.svelte
Normal file
242
frontend-internal/src/lib/components/ParticipantsModal.svelte
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/utils/api';
|
||||||
|
import { toast } from '$lib/stores/toast';
|
||||||
|
import { auth } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
export let ticketId: string;
|
||||||
|
export let createdBy: string;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
interface Participant {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
ticket_id: string;
|
||||||
|
role: string;
|
||||||
|
user_email: string;
|
||||||
|
user_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let participants: Participant[] = [];
|
||||||
|
let allUsers: User[] = [];
|
||||||
|
let isLoading = true;
|
||||||
|
let isAdding = false;
|
||||||
|
let searchQuery = '';
|
||||||
|
let selectedUserId = '';
|
||||||
|
|
||||||
|
$: isCreator = $auth.user?.id === createdBy;
|
||||||
|
$: isGlobalAdmin = $auth.user?.role === 'ADMIN';
|
||||||
|
$: canManage = isCreator || isGlobalAdmin;
|
||||||
|
|
||||||
|
$: participantIds = new Set(participants.map(p => p.user_id));
|
||||||
|
|
||||||
|
$: filteredUsers = allUsers.filter(u => {
|
||||||
|
if (participantIds.has(u.id)) return false;
|
||||||
|
if (u.id === createdBy) return false;
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return !q ||
|
||||||
|
u.email.toLowerCase().includes(q) ||
|
||||||
|
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([loadParticipants(), loadUsers()]);
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadParticipants() {
|
||||||
|
try {
|
||||||
|
participants = await api.get(`/tickets/${ticketId}/participants`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error cargando participantes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
allUsers = await api.get('/users/');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error('Error cargando usuarios');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addParticipant(userId: string) {
|
||||||
|
if (!userId) return;
|
||||||
|
isAdding = true;
|
||||||
|
try {
|
||||||
|
const p = await api.post(`/tickets/${ticketId}/participants`, { user_id: userId });
|
||||||
|
participants = [...participants, p];
|
||||||
|
searchQuery = '';
|
||||||
|
toast.success('Participante agregado');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Error al agregar participante');
|
||||||
|
} finally {
|
||||||
|
isAdding = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeParticipant(userId: string) {
|
||||||
|
try {
|
||||||
|
await api.delete(`/tickets/${ticketId}/participants/${userId}`);
|
||||||
|
participants = participants.filter(p => p.user_id !== userId);
|
||||||
|
toast.success('Participante eliminado');
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || 'Error al eliminar participante');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarInitials(name: string, email: string): string {
|
||||||
|
if (name.trim()) {
|
||||||
|
const parts = name.trim().split(' ');
|
||||||
|
return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase();
|
||||||
|
}
|
||||||
|
return email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarColor(email: string): string {
|
||||||
|
const colors = ['bg-blue-400', 'bg-violet-400', 'bg-emerald-400', 'bg-rose-400', 'bg-amber-400', 'bg-cyan-400'];
|
||||||
|
let hash = 0;
|
||||||
|
for (const c of email) hash = (hash << 5) - hash + c.charCodeAt(0);
|
||||||
|
return colors[Math.abs(hash) % colors.length];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Overlay -->
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" on:click={() => dispatch('close')}></div>
|
||||||
|
|
||||||
|
<div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Participantes del asunto</h2>
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">{participants.length} persona{participants.length !== 1 ? 's' : ''} involucrada{participants.length !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" on:click={() => dispatch('close')}
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-5 max-h-[70vh] overflow-y-auto">
|
||||||
|
|
||||||
|
<!-- Buscador para agregar -->
|
||||||
|
{#if canManage}
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Agregar persona</label>
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nombre o email..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resultados de búsqueda -->
|
||||||
|
{#if searchQuery && filteredUsers.length > 0}
|
||||||
|
<div class="mt-2 border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
|
{#each filteredUsers.slice(0, 6) as user (user.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => addParticipant(user.id)}
|
||||||
|
disabled={isAdding}
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-blue-50 transition-colors text-left border-b border-gray-100 last:border-0"
|
||||||
|
>
|
||||||
|
<div class="w-8 h-8 rounded-full {avatarColor(user.email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
|
{avatarInitials(`${user.first_name} ${user.last_name}`, user.email)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{user.first_name} {user.last_name}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<span class="ml-auto text-xs text-blue-600 font-medium flex-shrink-0">+ Agregar</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if searchQuery && filteredUsers.length === 0}
|
||||||
|
<p class="mt-2 text-sm text-gray-400 text-center py-2">No se encontraron usuarios disponibles</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Lista de participantes -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-700 mb-3">
|
||||||
|
{canManage ? 'Participantes actuales' : 'Personas involucradas'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center py-6">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if participants.length === 0}
|
||||||
|
<div class="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
<svg class="w-10 h-10 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-gray-400">Aún no hay participantes</p>
|
||||||
|
{#if canManage}
|
||||||
|
<p class="text-xs text-gray-300 mt-1">Usa el buscador para agregar personas</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each participants as p (p.id)}
|
||||||
|
<div class="flex items-center gap-3 p-3 bg-gray-50 rounded-lg group">
|
||||||
|
<div class="w-9 h-9 rounded-full {avatarColor(p.user_email)} flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||||
|
{avatarInitials(p.user_name, p.user_email)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{p.user_name || p.user_email}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{p.user_email}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 bg-white px-2 py-0.5 rounded-full border border-gray-200">
|
||||||
|
Participante
|
||||||
|
</span>
|
||||||
|
{#if canManage}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={() => removeParticipant(p.user_id)}
|
||||||
|
class="p-1 rounded text-gray-300 hover:text-rose-500 hover:bg-rose-50 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Quitar participante"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border-t border-gray-100 flex justify-end">
|
||||||
|
<button type="button" on:click={() => dispatch('close')}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
import { toast } from '$lib/stores/toast';
|
import { toast } from '$lib/stores/toast';
|
||||||
import { api } from '$lib/utils/api';
|
import { api } from '$lib/utils/api';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import ParticipantsModal from '$lib/components/ParticipantsModal.svelte';
|
||||||
|
|
||||||
|
let showParticipants = false;
|
||||||
let ticketId: string;
|
let ticketId: string;
|
||||||
let ticket = null;
|
let ticket = null;
|
||||||
let comments = [];
|
let comments = [];
|
||||||
@@ -374,6 +376,17 @@
|
|||||||
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
|
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Participantes -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-5 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Asunto</h3>
|
||||||
|
<button type="button" on:click={() => showParticipants = true}
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||||
|
Gestionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- SLA Information -->
|
<!-- SLA Information -->
|
||||||
{#if ticket.sla_response_due || ticket.sla_resolution_due}
|
{#if ticket.sla_response_due || ticket.sla_resolution_due}
|
||||||
<div class="pt-4 border-t border-gray-200">
|
<div class="pt-4 border-t border-gray-200">
|
||||||
@@ -429,4 +442,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if showParticipants && ticket}
|
||||||
|
<ParticipantsModal
|
||||||
|
ticketId={ticket.id}
|
||||||
|
createdBy={ticket.created_by}
|
||||||
|
on:close={() => showParticipants = false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
130
participants_router.py
Normal file
130
participants_router.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user