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):
|
||||
"""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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
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 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(
|
||||
|
||||
@@ -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"<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 { 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 @@
|
||||
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
|
||||
</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 -->
|
||||
{#if ticket.sla_response_due || ticket.sla_resolution_due}
|
||||
<div class="pt-4 border-t border-gray-200">
|
||||
@@ -429,4 +442,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if showParticipants && ticket}
|
||||
<ParticipantsModal
|
||||
ticketId={ticket.id}
|
||||
createdBy={ticket.created_by}
|
||||
on:close={() => showParticipants = false}
|
||||
/>
|
||||
{/if}
|
||||
</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