Implementacion modal ticket 1
This commit is contained in:
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 ###
|
||||
Reference in New Issue
Block a user