Bckn funcion

This commit is contained in:
2026-03-17 11:09:30 -06:00
parent 5e58d53416
commit d691b71c44
17 changed files with 406 additions and 224 deletions

View File

@@ -1,48 +0,0 @@
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")

View File

@@ -1,21 +0,0 @@
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)

View File

@@ -0,0 +1,53 @@
"""Schemas para TicketIssue"""
from pydantic import BaseModel, field_validator
from typing import Optional, List
from datetime import datetime
import uuid
class TaggedUserBasic(BaseModel):
id: uuid.UUID
full_name: str
email: str
class Config:
from_attributes = True
class IssueCreate(BaseModel):
content: str
priority: str = "MEDIUM"
tagged_user_ids: List[uuid.UUID] = []
@field_validator("priority")
@classmethod
def validate_priority(cls, v: str) -> str:
valid = {"LOW", "MEDIUM", "HIGH", "URGENT"}
if v.upper() not in valid:
raise ValueError(f"priority debe ser uno de: {valid}")
return v.upper()
@field_validator("content")
@classmethod
def validate_content(cls, v: str) -> str:
if not v.strip():
raise ValueError("content no puede estar vacío")
return v.strip()
class IssueResponse(BaseModel):
id: uuid.UUID
ticket_id: uuid.UUID
tenant_id: uuid.UUID
content: str
priority: str
created_by: uuid.UUID
created_by_name: str
tagged_users: List[TaggedUserBasic] = []
attachment_filename: Optional[str] = None
attachment_mime_type: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@@ -7,6 +7,8 @@ from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
import uuid
from app.models.issue import TicketIssue, ticket_issue_tagged_users
from app.api.schemas.issue import IssueCreate, IssueResponse
from app.core.database import get_db
from app.api.deps import get_current_user, get_current_tenant
@@ -411,3 +413,179 @@ async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSessi
logger.info(f"Returning file: {attachment.original_filename}")
return FileResponse(path=file_path, filename=attachment.original_filename, media_type=attachment.mime_type)
@router.get("/{ticket_id}/issues", response_model=List[IssueResponse])
async def get_ticket_issues(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Obtener asuntos de un ticket."""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
# Verificar que el ticket existe y pertenece al tenant
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
)
if current_user.role.is_client:
query = query.where(Ticket.created_by == current_user.id)
result = await db.execute(query)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
issues_query = (
select(TicketIssue)
.where(TicketIssue.ticket_id == ticket_uuid)
.options(
selectinload(TicketIssue.created_by_user),
selectinload(TicketIssue.tagged_users),
)
.order_by(TicketIssue.created_at.desc())
)
result = await db.execute(issues_query)
issues = result.scalars().all()
return [
IssueResponse(
id=issue.id,
ticket_id=issue.ticket_id,
tenant_id=issue.tenant_id,
content=issue.content,
priority=issue.priority.value,
created_by=issue.created_by,
created_by_name=f"{issue.created_by_user.first_name} {issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in issue.tagged_users
],
attachment_filename=issue.attachment_filename,
attachment_mime_type=issue.attachment_mime_type,
created_at=issue.created_at,
updated_at=issue.updated_at,
)
for issue in issues
]
@router.post("/{ticket_id}/issues", response_model=IssueResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket_issue(
ticket_id: str,
issue: IssueCreate,
file: Optional[UploadFile] = File(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
current_tenant: Tenant = Depends(get_current_tenant),
):
"""
Crear un asunto de escalación en un ticket.
Solo puede crearlo el dueño del ticket o el CLIENT_ADMIN del tenant.
"""
ticket_uuid = validate_uuid_param(ticket_id, "ticket ID")
# 1. Verificar que el ticket existe y pertenece al tenant
result = await db.execute(
select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
)
)
ticket_obj = result.scalars().first()
if not ticket_obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ticket no encontrado")
# 2. Verificar permisos: solo el creador del ticket o CLIENT_ADMIN
is_ticket_owner = ticket_obj.created_by == current_user.id
is_client_admin = current_user.role == UserRole.CLIENT_ADMIN
if not is_ticket_owner and not is_client_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo el creador del ticket o el administrador del tenant pueden crear asuntos",
)
# 3. Validar usuarios etiquetados — deben pertenecer al mismo tenant
tagged_users = []
if issue.tagged_user_ids:
result = await db.execute(
select(User).where(
User.id.in_(issue.tagged_user_ids),
User.tenant_id == current_user.tenant_id,
User.is_active == True,
)
)
tagged_users = result.scalars().all()
found_ids = {u.id for u in tagged_users}
missing = [str(uid) for uid in issue.tagged_user_ids if uid not in found_ids]
if missing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Usuarios no encontrados en el tenant: {missing}",
)
# 4. Manejar adjunto opcional
attachment_path = None
attachment_filename = None
attachment_mime_type = None
if file and file.filename:
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
attachment_path = file_metadata["file_path"]
attachment_filename = file_metadata["original_filename"]
attachment_mime_type = file_metadata["mime_type"]
# 5. Crear el asunto
new_issue = TicketIssue(
id=uuid.uuid4(),
ticket_id=ticket_uuid,
tenant_id=current_user.tenant_id,
created_by=current_user.id,
content=issue.content,
priority=TicketPriority[issue.priority],
attachment_path=attachment_path,
attachment_filename=attachment_filename,
attachment_mime_type=attachment_mime_type,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
new_issue.tagged_users = tagged_users
db.add(new_issue)
await db.commit()
await db.refresh(new_issue, ["created_by_user", "tagged_users"])
# 6. Audit log
await safe_audit_log(
db=db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="ticket.issue.create",
resource_type="ticket_issue",
resource_id=new_issue.id,
new_values={
"ticket_id": str(ticket_uuid),
"priority": issue.priority,
"tagged_users": [str(uid) for uid in issue.tagged_user_ids],
},
)
return IssueResponse(
id=new_issue.id,
ticket_id=new_issue.ticket_id,
tenant_id=new_issue.tenant_id,
content=new_issue.content,
priority=new_issue.priority.value,
created_by=new_issue.created_by,
created_by_name=f"{new_issue.created_by_user.first_name} {new_issue.created_by_user.last_name}",
tagged_users=[
{"id": u.id, "full_name": f"{u.first_name} {u.last_name}", "email": u.email}
for u in new_issue.tagged_users
],
attachment_filename=new_issue.attachment_filename,
attachment_mime_type=new_issue.attachment_mime_type,
created_at=new_issue.created_at,
updated_at=new_issue.updated_at,
)

View File

@@ -35,6 +35,7 @@ from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache
from app.core.limiter import limiter
from app.models.issue import TicketIssue, ticket_issue_tagged_users
settings = get_settings()
setup_logging()

View File

@@ -3,6 +3,7 @@
from .user import User
from .tenant import Tenant
from .ticket import Ticket
from .issue import TicketIssue, ticket_issue_tagged_users
from .comment import TicketComment
from .system import System
from .category import Category
@@ -15,6 +16,7 @@ __all__ = [
"User",
"Tenant",
"Ticket",
"TicketIssue",
"TicketComment",
"System",
"Category",

102
backend/app/models/issue.py Normal file
View File

@@ -0,0 +1,102 @@
"""
TicketIssue Model - ServiceManagerWeb
Asuntos de escalación asociados a tickets.
Creados por el dueño del ticket o el CLIENT_ADMIN del tenant.
"""
from sqlalchemy import String, ForeignKey, Text, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional, List
from datetime import datetime
import uuid
from app.core.database import Base, GUID
from app.models.ticket import TicketPriority
from sqlalchemy import Enum as SAEnum
from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM
# Tabla de relación N:M entre TicketIssue y User (usuarios etiquetados)
ticket_issue_tagged_users = Table(
"ticket_issue_tagged_users",
Base.metadata,
Column(
"issue_id",
GUID(),
ForeignKey("ticket_issues.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
Column(
"user_id",
GUID(),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
)
class TicketIssue(Base):
"""
Asunto de escalación de un ticket.
Solo puede crearlo:
- El usuario que creó el ticket (created_by del Ticket)
- El CLIENT_ADMIN del mismo tenant
"""
__tablename__ = "ticket_issues"
# Multi-tenancy — siempre filtrar por esto
tenant_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
)
# Relación con el ticket padre
ticket_id: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False,
)
# Quién lo creó
created_by: Mapped[uuid.UUID] = mapped_column(
GUID(),
ForeignKey("users.id"),
nullable=False,
)
# Contenido
content: Mapped[str] = mapped_column(Text, nullable=False)
priority: Mapped[TicketPriority] = mapped_column(
SAEnum(TicketPriority, name="ticket_priority_enum", native_enum=False).with_variant(
PG_ENUM(TicketPriority, name="ticket_priority_enum", create_type=True),
"postgresql",
),
default=TicketPriority.MEDIUM,
nullable=False,
)
# Adjunto opcional (reutiliza file_handler igual que TicketAttachment)
attachment_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
attachment_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
attachment_mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Relationships
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="issues")
created_by_user: Mapped["User"] = relationship(
"User",
foreign_keys=[created_by],
)
tagged_users: Mapped[List["User"]] = relationship(
"User",
secondary=ticket_issue_tagged_users,
)
def __repr__(self) -> str:
return f"<TicketIssue(id={self.id}, ticket={self.ticket_id}, priority={self.priority})>"

View File

@@ -163,6 +163,12 @@ class Ticket(Base):
cascade="all, delete-orphan"
)
issues: Mapped[list["TicketIssue"]] = relationship(
"TicketIssue",
back_populates="ticket",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"

View File

@@ -0,0 +1,63 @@
"""add_ticket_issues
Revision ID: b2dcb926e091
Revises: 46bccd948688
Create Date: 2026-03-17 16:25:34.452826
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = 'b2dcb926e091'
down_revision = '46bccd948688'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = inspector.get_table_names()
if 'ticket_issues' not in existing_tables:
op.create_table(
'ticket_issues',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('ticket_id', sa.UUID(), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column(
'priority',
postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT',
name='ticket_priority_enum', create_type=False),
nullable=False,
),
sa.Column('attachment_path', sa.String(500), nullable=True),
sa.Column('attachment_filename', sa.String(255), nullable=True),
sa.Column('attachment_mime_type', sa.String(100), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True),
server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True),
server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
)
if 'ticket_issue_tagged_users' not in existing_tables:
op.create_table(
'ticket_issue_tagged_users',
sa.Column('issue_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['issue_id'], ['ticket_issues.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('issue_id', 'user_id'),
)
def downgrade() -> None:
op.drop_table('ticket_issue_tagged_users')
op.drop_table('ticket_issues')

View File

@@ -1,3 +0,0 @@
with open("/app/app/api/schemas/category.py", "r") as f:
content = f.read()
print(content)

View File

@@ -1,19 +0,0 @@
import asyncio
from app.core.database import AsyncSessionLocal
from app.models.user import User
from app.models.tenant import Tenant
from sqlalchemy import select
async def check():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User, Tenant).join(Tenant).where(User.email == 'javier@ventas.com')
)
user, tenant = result.one()
print(f"user.tenant_id: {user.tenant_id}")
print(f"tenant.id: {tenant.id}")
print(f"tenant.slug: {tenant.slug}")
print(f"user.role: {user.role}")
print(f"role.is_client: {user.role.is_client}")
asyncio.run(check())

View File

@@ -1,28 +0,0 @@
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)

View File

@@ -1,15 +0,0 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old = ' # Verificar permisos - solo ADMIN puede eliminar\n if current_user.role != UserRole.ADMIN:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
new = ' # Verificar permisos - ADMIN global o CLIENT_ADMIN pueden eliminar\n allowed = [UserRole.ADMIN, UserRole.CLIENT_ADMIN]\n if current_user.role not in allowed:\n raise HTTPException(\n status_code=status.HTTP_403_FORBIDDEN,\n detail="Only admins can delete users"\n )'
if old in content:
content = content.replace(old, new)
print("OK: permisos delete actualizados")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)

View File

@@ -1,14 +0,0 @@
with open("/app/app/api/v1/endpoints/users.py", "r") as f:
content = f.read()
old = " # Soft delete\n db_user.is_active = False\n await db.commit()"
new = " # Hard delete\n await db.delete(db_user)\n await db.commit()"
if old in content:
content = content.replace(old, new)
print("OK: hard delete aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/users.py", "w") as f:
f.write(content)

View File

@@ -1,34 +0,0 @@
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)

View File

@@ -1,30 +0,0 @@
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)

View File

@@ -1,11 +0,0 @@
with open("/app/src/routes/usuarios/+page.svelte", "r") as f:
content = f.read()
content = content.replace(
"{#if (user as any).can_manage_users && user.role !== 'CLIENT_ADMIN'}",
"{#if user.can_manage_users && user.role !== 'CLIENT_ADMIN'}"
)
with open("/app/src/routes/usuarios/+page.svelte", "w") as f:
f.write(content)
print("Listo")