Compare commits

..

1 Commits
v1.2 ... v1.3.0

Author SHA1 Message Date
0d7cdf51ca Versión 1.3.0: Conexion completa del proyecto 2026-02-03 10:38:54 -07:00
34 changed files with 1012 additions and 4691 deletions

View File

@@ -1,37 +0,0 @@
[alembic]
script_location = backend/migrations
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -1,38 +0,0 @@
[alembic]
script_location = backend/migrations
sqlalchemy.url = postgresql://servicemanager:servicemanager123@172.19.0.3:5432/servicemanager
target_metadata = app.core.database.Base.metadata
default_environment = development
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -3,53 +3,191 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import List, Optional from typing import List, Optional
from datetime import datetime
import uuid import uuid
from app.core.database import get_db from app.core.database import get_db
from app.models.category import Category from app.models.category import Category
from app.models.user import User
from app.api import deps from app.api import deps
router = APIRouter() router = APIRouter()
class CategoryBase(BaseModel): # ===================================
# PYDANTIC SCHEMAS
# ===================================
class CategoryCreate(BaseModel):
"""Schema para crear categoría - NO incluye tenant_id (se asigna automáticamente)"""
name: str name: str
description: Optional[str] = None description: Optional[str] = None
is_active: bool = True color: Optional[str] = None
tenant_id: Optional[uuid.UUID] = None sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
class CategoryCreate(CategoryBase): class CategoryUpdate(BaseModel):
pass """Schema para actualizar categoría"""
class CategoryUpdate(CategoryBase):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: Optional[int] = None
sla_resolution_hours: Optional[int] = None
auto_assign_to: Optional[uuid.UUID] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
tenant_id: Optional[uuid.UUID] = None
class CategoryResponse(CategoryBase): class CategoryResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos"""
id: uuid.UUID id: uuid.UUID
tenant_id: uuid.UUID # ✅ AÑADIDO
name: str
description: Optional[str] = None
color: Optional[str] = None
sla_response_hours: int
sla_resolution_hours: int
auto_assign_to: Optional[uuid.UUID] = None
is_active: bool
created_at: datetime # ✅ AÑADIDO
updated_at: datetime # ✅ AÑADIDO
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[CategoryResponse]) @router.get("/", response_model=List[CategoryResponse])
async def read_categories( async def read_categories(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint + no solo superuser
): ):
query = select(Category).offset(skip).limit(limit) """
Listar categorías del tenant del usuario actual.
✅ Implementa multi-tenancy: solo muestra categorías del tenant del usuario.
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(Category).where(
Category.tenant_id == current_user.tenant_id
).offset(skip).limit(limit)
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@router.post("/", response_model=CategoryResponse)
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category( async def create_category(
category: CategoryCreate, category: CategoryCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
): ):
db_category = Category(**category.model_dump()) """
Crear nueva categoría en el tenant del usuario actual.
✅ 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
)
db.add(db_category) db.add(db_category)
await db.commit() await db.commit()
await db.refresh(db_category) await db.refresh(db_category)
return db_category return db_category
@router.get("/{category_id}", response_model=CategoryResponse)
async def read_category(
category_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Obtener una categoría específica del tenant.
✅ Implementa multi-tenancy: solo permite acceso a categorías del propio tenant.
"""
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
)
result = await db.execute(query)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
return category
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: uuid.UUID,
category_update: CategoryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Actualizar categoría del tenant.
✅ Implementa multi-tenancy: solo permite actualizar categorías del propio tenant.
"""
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_category = result.scalar_one_or_none()
if not db_category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Actualizar campos
update_data = category_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_category, field, value)
await db.commit()
await db.refresh(db_category)
return db_category
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Desactivar categoría del tenant (soft delete).
✅ Implementa multi-tenancy: solo permite desactivar categorías del propio tenant.
"""
query = select(Category).where(
Category.id == category_id,
Category.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_category = result.scalar_one_or_none()
if not db_category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Soft delete
db_category.is_active = False
await db.commit()
return None

View File

@@ -3,51 +3,179 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import List, Optional from typing import List, Optional
from datetime import datetime
import uuid import uuid
from app.core.database import get_db from app.core.database import get_db
from app.models.system import System from app.models.system import System
from app.models.user import User
from app.api import deps from app.api import deps
router = APIRouter() router = APIRouter()
class SystemBase(BaseModel): # ===================================
# PYDANTIC SCHEMAS
# ===================================
class SystemCreate(BaseModel):
"""Schema para crear sistema - NO incluye tenant_id (se asigna automáticamente)"""
name: str name: str
description: Optional[str] = None description: Optional[str] = None
is_active: bool = True
class SystemCreate(SystemBase): class SystemUpdate(BaseModel):
pass """Schema para actualizar sistema"""
class SystemUpdate(SystemBase):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
class SystemResponse(SystemBase): class SystemResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos"""
id: uuid.UUID id: uuid.UUID
tenant_id: uuid.UUID # ✅ AÑADIDO
name: str
description: Optional[str] = None
is_active: bool
created_at: datetime # ✅ AÑADIDO
updated_at: datetime # ✅ AÑADIDO
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[SystemResponse]) @router.get("/", response_model=List[SystemResponse])
async def read_systems( async def read_systems(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint + no solo superuser
): ):
query = select(System).offset(skip).limit(limit) """
Listar sistemas del tenant del usuario actual.
✅ Implementa multi-tenancy: solo muestra sistemas del tenant del usuario.
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(System).where(
System.tenant_id == current_user.tenant_id
).offset(skip).limit(limit)
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@router.post("/", response_model=SystemResponse)
@router.post("/", response_model=SystemResponse, status_code=status.HTTP_201_CREATED)
async def create_system( async def create_system(
system: SystemCreate, system: SystemCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
): ):
db_system = System(**system.model_dump()) """
Crear nuevo sistema en el tenant del usuario actual.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
"""
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_system = System(
**system.model_dump(),
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
db.add(db_system) db.add(db_system)
await db.commit() await db.commit()
await db.refresh(db_system) await db.refresh(db_system)
return db_system return db_system
@router.get("/{system_id}", response_model=SystemResponse)
async def read_system(
system_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Obtener un sistema específico del tenant.
✅ Implementa multi-tenancy: solo permite acceso a sistemas del propio tenant.
"""
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
)
result = await db.execute(query)
system = result.scalar_one_or_none()
if not system:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System not found"
)
return system
@router.put("/{system_id}", response_model=SystemResponse)
async def update_system(
system_id: uuid.UUID,
system_update: SystemUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Actualizar sistema del tenant.
✅ Implementa multi-tenancy: solo permite actualizar sistemas del propio tenant.
"""
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_system = result.scalar_one_or_none()
if not db_system:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System not found"
)
# Actualizar campos
update_data = system_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_system, field, value)
await db.commit()
await db.refresh(db_system)
return db_system
@router.delete("/{system_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_system(
system_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Desactivar sistema del tenant (soft delete).
✅ Implementa multi-tenancy: solo permite desactivar sistemas del propio tenant.
"""
query = select(System).where(
System.id == system_id,
System.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_system = result.scalar_one_or_none()
if not db_system:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System not found"
)
# Soft delete
db_system.is_active = False
await db.commit()
return None

View File

@@ -1,5 +1,5 @@
""" """
Tickets endpoints - ServiceManagerWeb (VERSION FINAL CORREGIDA) Tickets endpoints - ServiceManagerWeb
""" """
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
@@ -12,13 +12,11 @@ from app.core.database import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.models.ticket import Ticket, TicketStatus, TicketPriority from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User from app.models.user import User
from app.models.category import Category from app.models.category import Category # ✅ CORREGIDO: Era TicketCategory
from app.models.system import System from app.models.system import System
import uuid import uuid
import logging
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
# =================================== # ===================================
# SCHEMAS # SCHEMAS
@@ -28,7 +26,7 @@ class TicketCreate(BaseModel):
subject: str subject: str
description: str description: str
category_id: Optional[str] = None category_id: Optional[str] = None
affected_system_id: Optional[str] = None # Updated field name affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
priority: str = "MEDIUM" priority: str = "MEDIUM"
class TicketUpdate(BaseModel): class TicketUpdate(BaseModel):
@@ -46,7 +44,7 @@ class TicketResponse(BaseModel):
status: str status: str
priority: str priority: str
category_id: Optional[str] = None category_id: Optional[str] = None
affected_system_id: Optional[str] = None # Updated field name affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
created_by: str created_by: str
assigned_to: Optional[str] = None assigned_to: Optional[str] = None
created_at: datetime created_at: datetime
@@ -82,18 +80,18 @@ async def create_ticket(
# Convertir IDs de string a UUID si son proporcionados # Convertir IDs de string a UUID si son proporcionados
category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None category_uuid = uuid.UUID(ticket.category_id) if ticket.category_id else None
system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None system_uuid = uuid.UUID(ticket.affected_system_id) if ticket.affected_system_id else None # ✅ CORREGIDO
# ✅ CORRECCIÓN: Validar en la tabla correcta 'categories' # ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
if category_uuid: if category_uuid:
category = await db.get(Category, category_uuid) category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
if not category: if not category:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe." detail=f"La categoría con ID {ticket.category_id} no existe."
) )
# Validar si el system_id existe en la tabla systems # Validar si el system_id existe en la tabla affected_systems
if system_uuid: if system_uuid:
system = await db.get(System, system_uuid) system = await db.get(System, system_uuid)
if not system: if not system:
@@ -102,7 +100,6 @@ async def create_ticket(
detail=f"El sistema con ID {ticket.affected_system_id} no existe." detail=f"El sistema con ID {ticket.affected_system_id} no existe."
) )
# ✅ CORRECCIÓN: Usar 'affected_system_id' (nombre real en la BD)
db_ticket = Ticket( db_ticket = Ticket(
id=uuid.uuid4(), id=uuid.uuid4(),
tenant_id=current_user.tenant_id, tenant_id=current_user.tenant_id,
@@ -110,7 +107,7 @@ async def create_ticket(
subject=ticket.subject, subject=ticket.subject,
description=ticket.description, description=ticket.description,
category_id=category_uuid, category_id=category_uuid,
affected_system_id=system_uuid, # Updated field name affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
priority=TicketPriority[ticket.priority.upper()], priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id, created_by=current_user.id,
status=TicketStatus.NEW, status=TicketStatus.NEW,
@@ -122,9 +119,7 @@ async def create_ticket(
await db.commit() await db.commit()
await db.refresh(db_ticket) await db.refresh(db_ticket)
logger.info(f"Ticket {db_ticket.ticket_number} created by {current_user.email}") # ✅ CORREGIDO: Usar affected_system_id en respuesta
# ✅ CORRECCIÓN: Usar affected_system_id
return { return {
"id": str(db_ticket.id), "id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number, "ticket_number": db_ticket.ticket_number,
@@ -133,7 +128,7 @@ async def create_ticket(
"status": db_ticket.status.value, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORRECTO "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(db_ticket.created_by), "created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "created_at": db_ticket.created_at,
@@ -142,17 +137,12 @@ async def create_ticket(
except ValueError as e: except ValueError as e:
await db.rollback() await db.rollback()
logger.error(f"Invalid UUID format: {str(e)}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}" detail=f"Invalid UUID format: {str(e)}"
) )
except HTTPException:
await db.rollback()
raise
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error(f"Error creating ticket: {str(e)}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}" detail=f"Error creating ticket: {str(e)}"
@@ -170,55 +160,44 @@ async def get_tickets(
""" """
Obtener tickets del usuario actual Obtener tickets del usuario actual
""" """
try: query = select(Ticket).where(
query = select(Ticket).where( Ticket.tenant_id == current_user.tenant_id,
Ticket.tenant_id == current_user.tenant_id, Ticket.created_by == current_user.id
Ticket.created_by == current_user.id )
)
if status_filter:
if status_filter: try:
try: status_enum = TicketStatus[status_filter.upper()]
status_enum = TicketStatus[status_filter.upper()] query = query.where(Ticket.status == status_enum)
query = query.where(Ticket.status == status_enum) except KeyError:
except KeyError: raise HTTPException(
raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST,
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid status: {status_filter}"
detail=f"Invalid status: {status_filter}" )
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
result = await db.execute(query) tickets = result.scalars().all()
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
logger.info(f"Listed {len(tickets)} tickets for user {current_user.email}") return [
{
# ✅ CORRECCIÓN: Usar affected_system_id "id": str(t.id),
return [ "ticket_number": t.ticket_number,
{ "subject": t.subject,
"id": str(t.id), "description": t.description,
"ticket_number": t.ticket_number, "status": t.status.value,
"subject": t.subject, "priority": t.priority.value,
"description": t.description, "category_id": str(t.category_id) if t.category_id else None,
"status": t.status.value, "affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"priority": t.priority.value, "created_by": str(t.created_by),
"category_id": str(t.category_id) if t.category_id else None, "assigned_to": str(t.assigned_to) if t.assigned_to else None,
"system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORRECTO "created_at": t.created_at,
"created_by": str(t.created_by), "updated_at": t.updated_at
"assigned_to": str(t.assigned_to) if t.assigned_to else None, }
"created_at": t.created_at, for t in tickets
"updated_at": t.updated_at ]
}
for t in tickets
]
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing tickets: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error listing tickets: {str(e)}"
)
@router.get("/{ticket_id}", response_model=TicketResponse) @router.get("/{ticket_id}", response_model=TicketResponse)
@@ -238,45 +217,36 @@ async def get_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
try: query = select(Ticket).where(
query = select(Ticket).where( Ticket.id == ticket_uuid,
Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id,
Ticket.tenant_id == current_user.tenant_id, Ticket.created_by == current_user.id
Ticket.created_by == current_user.id )
)
result = await db.execute(query)
result = await db.execute(query) ticket = result.scalars().first()
ticket = result.scalars().first()
if not ticket:
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
# ✅ CORRECCIÓN: Usar affected_system_id
return {
"id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"status": ticket.status.value,
"priority": ticket.priority.value,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, # ✅ CORRECTO
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting ticket: {str(e)}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Error getting ticket: {str(e)}" detail=f"Ticket {ticket_id} not found"
) )
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(ticket.id),
"ticket_number": ticket.ticket_number,
"subject": ticket.subject,
"description": ticket.description,
"status": ticket.status.value,
"priority": ticket.priority.value,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at,
"updated_at": ticket.updated_at
}
@router.patch("/{ticket_id}", response_model=TicketResponse) @router.patch("/{ticket_id}", response_model=TicketResponse)
@@ -297,22 +267,22 @@ async def update_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
try: query = select(Ticket).where(
query = select(Ticket).where( Ticket.id == ticket_uuid,
Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id,
Ticket.tenant_id == current_user.tenant_id, Ticket.created_by == current_user.id
Ticket.created_by == current_user.id )
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
) )
result = await db.execute(query) try:
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
update_data = ticket_update.dict(exclude_unset=True) update_data = ticket_update.dict(exclude_unset=True)
for field, value in update_data.items(): for field, value in update_data.items():
@@ -330,9 +300,7 @@ async def update_ticket(
await db.commit() await db.commit()
await db.refresh(db_ticket) await db.refresh(db_ticket)
logger.info(f"Ticket {db_ticket.ticket_number} updated by {current_user.email}") # ✅ CORREGIDO: Usar affected_system_id
# ✅ CORRECCIÓN: Usar affected_system_id
return { return {
"id": str(db_ticket.id), "id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number, "ticket_number": db_ticket.ticket_number,
@@ -341,19 +309,15 @@ async def update_ticket(
"status": db_ticket.status.value, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORRECTO "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(db_ticket.created_by), "created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at "updated_at": db_ticket.updated_at
} }
except HTTPException:
await db.rollback()
raise
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error(f"Error updating ticket: {str(e)}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error updating ticket: {str(e)}" detail=f"Error updating ticket: {str(e)}"
@@ -378,31 +342,29 @@ async def close_ticket(
detail="Invalid ticket ID format" detail="Invalid ticket ID format"
) )
try: query = select(Ticket).where(
query = select(Ticket).where( Ticket.id == ticket_uuid,
Ticket.id == ticket_uuid, Ticket.tenant_id == current_user.tenant_id,
Ticket.tenant_id == current_user.tenant_id, Ticket.created_by == current_user.id
Ticket.created_by == current_user.id )
result = await db.execute(query)
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
) )
result = await db.execute(query) try:
db_ticket = result.scalars().first()
if not db_ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
db_ticket.status = TicketStatus.CLOSED db_ticket.status = TicketStatus.CLOSED
db_ticket.updated_at = datetime.utcnow() db_ticket.updated_at = datetime.utcnow()
await db.commit() await db.commit()
await db.refresh(db_ticket) await db.refresh(db_ticket)
logger.info(f"Ticket {db_ticket.ticket_number} closed by {current_user.email}") # ✅ CORREGIDO: Usar affected_system_id
# ✅ CORRECCIÓN: Usar affected_system_id
return { return {
"id": str(db_ticket.id), "id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number, "ticket_number": db_ticket.ticket_number,
@@ -411,19 +373,15 @@ async def close_ticket(
"status": db_ticket.status.value, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORRECTO "affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(db_ticket.created_by), "created_by": str(db_ticket.created_by),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None, "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at "updated_at": db_ticket.updated_at
} }
except HTTPException:
await db.rollback()
raise
except Exception as e: except Exception as e:
await db.rollback() await db.rollback()
logger.error(f"Error closing ticket: {str(e)}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error closing ticket: {str(e)}" detail=f"Error closing ticket: {str(e)}"

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional from typing import List, Optional
from datetime import datetime
import uuid import uuid
from app.core.database import get_db from app.core.database import get_db
@@ -12,57 +13,335 @@ from app.api import deps
router = APIRouter() router = APIRouter()
class UserBase(BaseModel): # ===================================
# PYDANTIC SCHEMAS
# ===================================
class UserCreate(BaseModel):
"""Schema para crear usuario - NO incluye tenant_id (se asigna automáticamente)"""
email: EmailStr email: EmailStr
first_name: str first_name: str
last_name: str last_name: str
role: UserRole role: UserRole
is_active: bool = True
tenant_id: Optional[uuid.UUID] = None
class UserCreate(UserBase):
password: str password: str
language: str = "es"
timezone: str = "UTC"
notifications_email: bool = True
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
"""Schema para actualizar usuario"""
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
role: Optional[UserRole] = None role: Optional[UserRole] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
password: Optional[str] = None # Optional password update password: Optional[str] = None
language: Optional[str] = None
timezone: Optional[str] = None
notifications_email: Optional[bool] = None
class UserResponse(UserBase): class UserResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos públicos"""
id: uuid.UUID id: uuid.UUID
tenant_id: uuid.UUID
email: EmailStr
first_name: str
last_name: str
avatar_url: Optional[str] = None
role: UserRole
is_active: bool
email_verified: bool
last_login: Optional[datetime] = None
language: str
timezone: str
notifications_email: bool
totp_enabled: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[UserResponse]) @router.get("/", response_model=List[UserResponse])
async def read_users( async def read_users(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
role: Optional[UserRole] = None,
is_active: Optional[bool] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user: User = Depends(deps.get_current_user)
): ):
query = select(User).offset(skip).limit(limit) """
Listar usuarios del tenant del usuario actual.
✅ Implementa multi-tenancy: solo muestra usuarios del tenant del usuario.
Filtros opcionales:
- role: filtrar por rol
- is_active: filtrar por estado activo
"""
# ✅ CORREGIDO: Filtrar por tenant_id
query = select(User).where(User.tenant_id == current_user.tenant_id)
# Aplicar filtros opcionales
if role:
query = query.where(User.role == role)
if is_active is not None:
query = query.where(User.is_active == is_active)
query = query.offset(skip).limit(limit).order_by(User.created_at.desc())
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() return result.scalars().all()
@router.post("/", response_model=UserResponse)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user( async def create_user(
user: UserCreate, user: UserCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser) current_user: User = Depends(deps.get_current_user)
): ):
query = select(User).where(User.email == user.email) """
Crear nuevo usuario en el tenant del usuario actual.
✅ Implementa multi-tenancy: asigna automáticamente tenant_id del usuario.
Restricciones:
- Solo ADMIN, SUPPORT_MANAGER y CLIENT_ADMIN pueden crear usuarios
- El email debe ser único dentro del tenant
"""
# Verificar permisos
if not current_user.can_manage_users:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to create users"
)
# Verificar si el email ya existe en el tenant
query = select(User).where(
User.email == user.email,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query) result = await db.execute(query)
if result.scalar_one_or_none(): if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
user_data = user.model_dump(exclude={"password"}) detail="Email already registered in this tenant"
password_hash = security.get_password_hash(user.password) )
# Preparar datos del usuario
user_data = user.model_dump(exclude={"password"})
password_hash = security.hash_password(user.password)
# ✅ CORREGIDO: Asignar tenant_id del usuario actual
db_user = User(
**user_data,
password_hash=password_hash,
tenant_id=current_user.tenant_id # ✅ Multi-tenancy automático
)
db_user = User(**user_data, password_hash=password_hash)
db.add(db_user) db.add(db_user)
await db.commit() await db.commit()
await db.refresh(db_user) await db.refresh(db_user)
return db_user return db_user
@router.get("/{user_id}", response_model=UserResponse)
async def read_user(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Obtener un usuario específico del tenant.
✅ Implementa multi-tenancy: solo permite acceso a usuarios del propio tenant.
"""
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id # ✅ Seguridad multi-tenant
)
result = await db.execute(query)
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: uuid.UUID,
user_update: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Actualizar usuario del tenant.
✅ Implementa multi-tenancy: solo permite actualizar usuarios del propio tenant.
Restricciones:
- Solo ADMIN, SUPPORT_MANAGER y CLIENT_ADMIN pueden actualizar usuarios
- No se puede cambiar el tenant_id
"""
# Verificar permisos
if not current_user.can_manage_users:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to update users"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Verificar email único si se está cambiando
update_data = user_update.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] != db_user.email:
email_query = select(User).where(
User.email == update_data["email"],
User.tenant_id == current_user.tenant_id,
User.id != user_id
)
email_result = await db.execute(email_query)
if email_result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already in use by another user"
)
# Actualizar campos
for field, value in update_data.items():
if field == "password":
# Hash the new password
db_user.password_hash = security.hash_password(value)
else:
setattr(db_user, field, value)
await db.commit()
await db.refresh(db_user)
return db_user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Desactivar usuario del tenant (soft delete).
✅ Implementa multi-tenancy: solo permite desactivar usuarios del propio tenant.
Restricciones:
- Solo ADMIN puede eliminar usuarios
- No se puede eliminar a sí mismo
- No se puede eliminar el último ADMIN del tenant
"""
# Verificar permisos - solo ADMIN puede eliminar
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can delete users"
)
# No se puede eliminar a sí mismo
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot delete yourself"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Verificar que no sea el último admin del tenant
if db_user.role == UserRole.ADMIN:
admin_query = select(User).where(
User.tenant_id == current_user.tenant_id,
User.role == UserRole.ADMIN,
User.is_active == True,
User.id != user_id
)
admin_result = await db.execute(admin_query)
active_admins = admin_result.scalars().all()
if len(active_admins) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete the last active admin of the tenant"
)
# Soft delete
db_user.is_active = False
await db.commit()
return None
@router.patch("/{user_id}/activate", response_model=UserResponse)
async def activate_user(
user_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user)
):
"""
Reactivar usuario desactivado.
✅ Implementa multi-tenancy: solo permite reactivar usuarios del propio tenant.
"""
# Verificar permisos
if not current_user.can_manage_users:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to activate users"
)
# Buscar usuario
query = select(User).where(
User.id == user_id,
User.tenant_id == current_user.tenant_id
)
result = await db.execute(query)
db_user = result.scalar_one_or_none()
if not db_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
db_user.is_active = True
await db.commit()
await db.refresh(db_user)
return db_user

View File

@@ -54,3 +54,6 @@ api_router.include_router(
tags=["tickets"] tags=["tickets"]
) )
# Ensure FastAPI is installed in the environment
# If not, install it using:
# pip install fastapi

View File

@@ -139,8 +139,4 @@ def get_settings() -> Settings:
Using lru_cache to create a singleton pattern for settings. Using lru_cache to create a singleton pattern for settings.
""" """
return Settings() return Settings()
# Crear una instancia global de Settings
settings = Settings()

View File

@@ -1,33 +1,50 @@
""" """
Category Model - ServiceManagerWeb Category Model - ServiceManagerWeb
Categorías de tickets por tenant
""" """
from sqlalchemy import String, Text, Boolean, ForeignKey, Column, Integer from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from app.core.database import Base from app.core.database import Base
from app.models.tenant import Tenant
from app.models.ticket import Ticket
class Category(Base): class Category(Base):
__tablename__ = "ticket_categories" # Updated table name """Modelo de categorías de tickets (ticket_categories en BD)"""
__tablename__ = "ticket_categories" # ✅ CORREGIDO: nombre correcto de tabla
# Campos básicos
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Updated tenant_id to be required # ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column( tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"), ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False nullable=False # ✅ Obligatorio
)
# ✅ AÑADIDOS: Campos de SLA según schema.sql
color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True)
sla_response_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False)
sla_resolution_hours: Mapped[int] = mapped_column(Integer, default=72, nullable=False)
auto_assign_to: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
) )
# Relationships # Relationships
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category") tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="category")
tenant: Mapped["Tenant"] = relationship("Tenant") # Assuming Tenant model is imported tenant: Mapped["Tenant"] = relationship("Tenant")
auto_assign_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[auto_assign_to])
# ✅ AÑADIDO: Constraint único por tenant (no puede haber categorías duplicadas en el mismo tenant)
__table_args__ = (
UniqueConstraint('tenant_id', 'name', name='uq_ticket_categories_tenant_name'),
)
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Category(id={self.id}, name='{self.name}')>" return f"<Category(id={self.id}, name='{self.name}', tenant_id={self.tenant_id})>"

View File

@@ -1,28 +1,43 @@
""" """
System Model - ServiceManagerWeb System Model - ServiceManagerWeb
Sistemas afectados por tenant
""" """
from sqlalchemy import String, Text, Boolean from sqlalchemy import String, Text, Boolean, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from typing import List, Optional from typing import List, Optional
import uuid import uuid
from app.core.database import Base from app.core.database import Base
class System(Base): class System(Base):
__tablename__ = "systems" """Modelo de sistemas afectados (affected_systems en BD)"""
__tablename__ = "affected_systems" # ✅ CORREGIDO: nombre correcto de tabla
# Campos básicos
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# ✅ AÑADIDO: tenant_id obligatorio para multi-tenancy (faltaba completamente)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
# Relationships # Relationships
# If we want tickets to link to systems, we will add relationship in Ticket later or now. # ✅ ACTUALIZADO: nombre de relación a affected_system
# We will assume Ticket links to System.
tickets: Mapped[List["Ticket"]] = relationship( tickets: Mapped[List["Ticket"]] = relationship(
"Ticket", "Ticket",
back_populates="affected_system", back_populates="affected_system" # ✅ Nombre actualizado
foreign_keys="Ticket.affected_system_id" )
tenant: Mapped["Tenant"] = relationship("Tenant")
# ✅ AÑADIDO: Constraint único por tenant (no puede haber sistemas duplicados en el mismo tenant)
__table_args__ = (
UniqueConstraint('tenant_id', 'name', name='uq_affected_systems_tenant_name'),
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<System(id={self.id}, name='{self.name}')>" return f"<System(id={self.id}, name='{self.name}', tenant_id={self.tenant_id})>"

View File

@@ -1,9 +1,7 @@
""" """
Tenant Model - ServiceManagerWeb Tenant Model - ServiceManagerWeb
Modelo para organizaciones cliente (multi-tenancy) Modelo para organizaciones cliente (multi-tenancy)
""" """
from sqlalchemy import String, Integer, Text, Boolean, ARRAY from sqlalchemy import String, Integer, Text, Boolean, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM from sqlalchemy.dialects.postgresql import UUID, ENUM
@@ -13,17 +11,14 @@ import uuid
from app.core.database import Base from app.core.database import Base
class TenantStatus(str, enum.Enum): class TenantStatus(str, enum.Enum):
"""Estados de un tenant.""" """Estados de un tenant."""
ACTIVE = "active" ACTIVE = "active"
SUSPENDED = "suspended" SUSPENDED = "suspended"
INACTIVE = "inactive" INACTIVE = "inactive"
class Tenant(Base): class Tenant(Base):
"""Modelo de Tenant (Organización cliente).""" """Modelo de Tenant (Organización cliente)."""
__tablename__ = "tenants" __tablename__ = "tenants"
# Información básica # Información básica
@@ -51,15 +46,14 @@ class Tenant(Base):
# Estado # Estado
status: Mapped[TenantStatus] = mapped_column( status: Mapped[TenantStatus] = mapped_column(
String(20), String(20),
default=TenantStatus.ACTIVE default=TenantStatus.ACTIVE
) )
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Relaciones # Relaciones
users: Mapped[List["User"]] = relationship("User", back_populates="tenant") users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant") tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant") categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant") # ✅ CORREGIDO: Era "TicketCategory"
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>" return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"

View File

@@ -1,62 +1,112 @@
""" """
Ticket Model - ServiceManagerWeb Ticket Model - ServiceManagerWeb
Tickets de soporte - Core del negocio
""" """
from sqlalchemy import String, ForeignKey, Text from sqlalchemy import String, ForeignKey, Text, Integer, CheckConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM from sqlalchemy.dialects.postgresql import UUID, ENUM
from typing import Optional, TYPE_CHECKING from typing import Optional
from datetime import datetime
import enum import enum
import uuid import uuid
from app.core.database import Base from app.core.database import Base
from .tenant import Tenant
from .system import System
from .user import User
if TYPE_CHECKING:
from app.models.category import Category
class TicketStatus(str, enum.Enum): class TicketStatus(str, enum.Enum):
"""Estados posibles de un ticket"""
NEW = "NEW" NEW = "NEW"
TRIAGE = "TRIAGE" TRIAGE = "TRIAGE"
IN_PROGRESS = "IN_PROGRESS" IN_PROGRESS = "IN_PROGRESS"
WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT" WAITING_CUSTOMER = "WAITING_CUSTOMER" # ✅ CORREGIDO: nombre según schema.sql
RESOLVED = "RESOLVED" RESOLVED = "RESOLVED"
CLOSED = "CLOSED" CLOSED = "CLOSED"
REOPENED = "REOPENED" REOPENED = "REOPENED"
class TicketPriority(str, enum.Enum): class TicketPriority(str, enum.Enum):
"""Prioridades posibles de un ticket"""
LOW = "LOW" LOW = "LOW"
MEDIUM = "MEDIUM" MEDIUM = "MEDIUM"
HIGH = "HIGH" HIGH = "HIGH"
URGENT = "URGENT" URGENT = "URGENT"
class Ticket(Base): class Ticket(Base):
"""Modelo de tickets de soporte"""
__tablename__ = "tickets" __tablename__ = "tickets"
# Note: id, created_at, updated_at are inherited from Base # Multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False) UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
# Campos básicos
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False) ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
subject: Mapped[str] = mapped_column(String(255), nullable=False) subject: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[TicketStatus] = mapped_column(ENUM(TicketStatus, name="ticket_status_enum", create_type=False), default=TicketStatus.NEW) # Estado y Prioridad
priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM) status: Mapped[TicketStatus] = mapped_column(
ENUM(TicketStatus, name="ticket_status_enum", create_type=False),
default=TicketStatus.NEW,
nullable=False
)
priority: Mapped[TicketPriority] = mapped_column(
ENUM(TicketPriority, name="ticket_priority_enum", create_type=False),
default=TicketPriority.MEDIUM,
nullable=False
)
# Foreign Keys # ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) created_by: Mapped[uuid.UUID] = mapped_column(
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False
)
assigned_to: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True
)
affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("systems.id"), nullable=True) # ✅ CORREGIDO: Renombrado de system_id a affected_system_id
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("categories.id"), nullable=True) affected_system_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("affected_systems.id"), # ✅ Tabla correcta
nullable=True
)
# ✅ CORREGIDO: Foreign key a tabla correcta
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ticket_categories.id"), # ✅ Tabla correcta
nullable=True
)
# ✅ AÑADIDOS: Campos de SLA según schema.sql
sla_response_due: Mapped[Optional[datetime]] = mapped_column(nullable=True)
sla_resolution_due: Mapped[Optional[datetime]] = mapped_column(nullable=True)
first_response_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
# ✅ AÑADIDOS: Campos de CSAT (Customer Satisfaction) según schema.sql
rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
rating_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
rated_at: Mapped[Optional[datetime]] = mapped_column(nullable=True)
# Relationships # Relationships
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets") tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
system: Mapped["System"] = relationship("System", back_populates="tickets") # ✅ ACTUALIZADO: Nombre de relación y optional
category: Mapped["Category"] = relationship("Category", back_populates="tickets") affected_system: Mapped[Optional["System"]] = relationship(
"System",
back_populates="tickets"
)
category: Mapped[Optional["Category"]] = relationship(
"Category",
back_populates="tickets"
)
created_by_user: Mapped["User"] = relationship( created_by_user: Mapped["User"] = relationship(
"User", "User",
@@ -69,3 +119,12 @@ class Ticket(Base):
foreign_keys=[assigned_to], foreign_keys=[assigned_to],
back_populates="assigned_tickets" back_populates="assigned_tickets"
) )
# ✅ AÑADIDOS: Constraints según schema.sql
__table_args__ = (
UniqueConstraint('tenant_id', 'ticket_number', name='uq_tickets_tenant_number'),
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
)
def __repr__(self) -> str:
return f"<Ticket(id={self.id}, number='{self.ticket_number}', status={self.status})>"

View File

View File

@@ -1,38 +0,0 @@
[alembic]
script_location = backend/migrations
sqlalchemy.url = postgresql://servicemanager:servicemanager123@172.19.0.3:5432/servicemanager
target_metadata = app.core.database.Base.metadata
default_environment = development
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers = console
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers = console
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
formatter = generic
[formatter_generic]
format = %(asctime)s %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -1,23 +1,43 @@
# backend/migrations/env.py
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from alembic import context from alembic import context
import asyncio
# Importa tus settings
from app.core.config import settings
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
# metadata de tus modelos # add your model's MetaData object here
from app.core.database import Base # for 'autogenerate' support
target_metadata = Base.metadata # from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
def run_migrations_offline(): # other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
context.configure( context.configure(
url=url, url=url,
@@ -25,26 +45,34 @@ def run_migrations_offline():
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
def do_run_migrations(connection): def run_migrations_online() -> None:
context.configure(connection=connection, target_metadata=target_metadata) """Run migrations in 'online' mode.
with context.begin_transaction():
context.run_migrations()
In this scenario we need to create an Engine
and associate a connection with the context.
async def run_migrations_online(): """
connectable = create_async_engine( connectable = engine_from_config(
settings.DATABASE_URL, poolclass=pool.NullPool config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
) )
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations) with connectable.connect() as connection:
await connectable.dispose() context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode(): if context.is_offline_mode():
run_migrations_offline() run_migrations_offline()
else: else:
asyncio.run(run_migrations_online()) run_migrations_online()

View File

@@ -1,30 +0,0 @@
"""Fix ambiguous foreign key in System.tickets relationship
Revision ID: 1b894c060a94
Revises: d77aa93a2cf2
Create Date: 2026-01-27 19:20:19.670006
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '1b894c060a94'
down_revision: Union[str, None] = 'd77aa93a2cf2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass # No changes needed for the database schema
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass # No changes needed for the database schema
# ### end Alembic commands ###

View File

@@ -1,37 +0,0 @@
"""Fix affected_system relationship in Ticket model
Revision ID: d77aa93a2cf2
Revises:
Create Date: 2026-01-27 19:18:04.005182
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'd77aa93a2cf2'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_foreign_key(
'fk_tickets_affected_system_id',
'tickets',
'systems',
['affected_system_id'],
['id'],
ondelete='SET NULL'
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('fk_tickets_affected_system_id', 'tickets', type_='foreignkey')
# ### end Alembic commands ###

View File

@@ -1,23 +0,0 @@
from sqlalchemy import create_engine
from alembic import command
from alembic.config import Config
# Cargar la URL de la base de datos desde el entorno
from os import getenv
DATABASE_URL = getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@postgres:5432/servicemanager")
# Cambiar el dialecto a uno síncrono
DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
# Crear un motor síncrono
engine = create_engine(DATABASE_URL)
# Configurar Alembic
alembic_cfg = Config("backend/migrations/alembic.ini")
alembic_cfg.attributes['connection'] = engine.connect()
# Ejecutar las migraciones
if __name__ == "__main__":
print("Ejecutando migraciones...")
command.upgrade(alembic_cfg, "head")
print("Migraciones aplicadas correctamente.")

View File

@@ -1,5 +1,4 @@
import asyncio
import asyncio
import sys import sys
import os import os
@@ -8,8 +7,10 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import select from sqlalchemy import select
from app.core.database import AsyncSessionLocal from app.core.database import AsyncSessionLocal
from app.models.tenant import Tenant # Import Tenant to register it from app.models.tenant import Tenant
from app.models.ticket import Ticket # Import Ticket to register it from app.models.ticket import Ticket
from app.models.category import Category # ✅ AÑADIR ESTO
from app.models.system import System # ✅ AÑADIR ESTO
from app.models.user import User from app.models.user import User
from app.core.security import SecurityUtils from app.core.security import SecurityUtils

View File

@@ -1,10 +1,8 @@
from logging.config import fileConfig from logging.config import fileConfig
import os import os
from sqlalchemy import create_engine, pool from sqlalchemy import create_engine, pool
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy.engine import engine_from_config from sqlalchemy.engine import engine_from_config
from alembic import context from alembic import context
import asyncio
# Import Base and all models # Import Base and all models
from app.core.database import Base from app.core.database import Base
@@ -43,29 +41,25 @@ def run_migrations_offline():
context.run_migrations() context.run_migrations()
async def run_migrations_online(): def run_migrations_online():
""" """
Run migrations in 'online' mode. Run migrations in 'online' mode.
""" """
# Fetch the URL from Alembic configuration # Fetch the URL from Alembic configuration
alembic_config = config.get_section(config.config_ini_section) alembic_config = config.get_section(config.config_ini_section)
alembic_config["sqlalchemy.url"] = DATABASE_URL # Use async URL alembic_config["sqlalchemy.url"] = SYNC_DATABASE_URL
connectable: AsyncEngine = create_async_engine( connectable = engine_from_config(
DATABASE_URL, alembic_config,
prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
async with connectable.connect() as connection: with connectable.connect() as connection:
await connection.run_sync( context.configure(connection=connection, target_metadata=target_metadata)
lambda sync_connection: context.configure(
connection=sync_connection, target_metadata=target_metadata,
compare_type=True # Ensure column types are compared
)
)
await connection.run_sync(context.run_migrations())
asyncio.run(do_run_migrations()) with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode(): if context.is_offline_mode():

View File

@@ -1,28 +0,0 @@
import asyncio
from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
from sqlalchemy.ext.asyncio import create_async_engine
async def run_async_migrations():
alembic_cfg = Config("alembic.ini")
script = ScriptDirectory.from_config(alembic_cfg)
engine = create_async_engine("postgresql+asyncpg://servicemanager:servicemanager123@172.19.0.3:5432/servicemanager")
async with engine.connect() as connection:
def do_run_migrations(connection):
with EnvironmentContext(
alembic_cfg,
script,
connection=connection,
fn=lambda rev, context: script.run_env()
) as context:
context.configure(connection=connection, target_metadata=None)
with context.begin_transaction():
context.run_migrations()
await connection.run_sync(do_run_migrations)
if __name__ == "__main__":
asyncio.run(run_async_migrations())

View File

@@ -1,23 +0,0 @@
from sqlalchemy import create_engine
from alembic import command
from alembic.config import Config
# Cargar la URL de la base de datos desde el entorno
from os import getenv
DATABASE_URL = getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@postgres:5432/servicemanager")
# Cambiar el dialecto a uno síncrono
DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
# Crear un motor síncrono
engine = create_engine(DATABASE_URL)
# Configurar Alembic
alembic_cfg = Config("backend/migrations/alembic.ini")
alembic_cfg.attributes['connection'] = engine.connect()
# Ejecutar las migraciones
if __name__ == "__main__":
print("Ejecutando migraciones...")
command.upgrade(alembic_cfg, "head")
print("Migraciones aplicadas correctamente.")

View File

@@ -1,19 +0,0 @@
import asyncio
import asyncpg
async def test_connection():
try:
conn = await asyncpg.connect(
user="servicemanager",
password="servicemanager123",
database="servicemanager",
host="postgres",
port=5432
)
print("Connection successful!")
await conn.close()
except Exception as e:
print(f"Connection failed: {e}")
if __name__ == "__main__":
asyncio.run(test_connection())

BIN
backups Normal file

Binary file not shown.

View File

@@ -1,37 +0,0 @@
-- Migration Script: Fix Schema Inconsistencies
-- Step 1: Migrate data from `categories` to `ticket_categories`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'categories') THEN
INSERT INTO ticket_categories (id, tenant_id, name, description)
SELECT id, COALESCE(tenant_id, (SELECT id FROM tenants LIMIT 1)), name, description
FROM categories;
END IF;
END $$;
-- Step 2: Update foreign key for `tickets.category_id`
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_category_id_fkey;
ALTER TABLE tickets ADD CONSTRAINT tickets_category_id_fkey FOREIGN KEY (category_id) REFERENCES ticket_categories (id) ON DELETE CASCADE;
-- Step 3: Drop `categories` table
DROP TABLE IF EXISTS categories CASCADE;
-- Step 4: Rename `tickets.system_id` to `affected_system_id`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tickets' AND column_name = 'system_id'
) THEN
ALTER TABLE tickets RENAME COLUMN system_id TO affected_system_id;
END IF;
END $$;
-- Step 5: Update foreign key for `tickets.affected_system_id`
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_system_id_fkey;
ALTER TABLE tickets ADD CONSTRAINT tickets_affected_system_id_fkey FOREIGN KEY (affected_system_id) REFERENCES affected_systems (id) ON DELETE CASCADE;
-- Step 6: Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_tickets_category_id ON tickets (category_id);
CREATE INDEX IF NOT EXISTS idx_tickets_affected_system_id ON tickets (affected_system_id);

View File

@@ -51,8 +51,8 @@ services:
# =================================== # ===================================
backend: backend:
build: build:
context: . context: ./backend
dockerfile: docker/Dockerfile.backend dockerfile: ../docker/Dockerfile.backend
container_name: servicemanager-backend container_name: servicemanager-backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
@@ -74,7 +74,6 @@ services:
- ./backend:/app - ./backend:/app
- uploads_data:/app/uploads - uploads_data:/app/uploads
- logs_data:/app/logs - logs_data:/app/logs
- ./alembic.ini:/app/alembic.ini
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@@ -162,7 +161,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${ENVIRONMENT:-development} - NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://servicemanager-backend:8000 - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_APP_NAME=ServiceManager Cliente - PUBLIC_APP_NAME=ServiceManager Cliente
volumes: volumes:
- ./frontend-client:/app - ./frontend-client:/app
@@ -187,7 +186,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- NODE_ENV=${ENVIRONMENT:-development} - NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://servicemanager-backend:8000 - PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_APP_NAME=ServiceManager Admin - PUBLIC_APP_NAME=ServiceManager Admin
volumes: volumes:
- ./frontend-internal:/app - ./frontend-internal:/app

View File

@@ -1,3 +1,4 @@
# FastAPI Backend Dockerfile
FROM python:3.11-slim FROM python:3.11-slim
# Instalar dependencias del sistema # Instalar dependencias del sistema
@@ -7,20 +8,13 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Directorio de trabajo y PYTHONPATH # Configurar directorio de trabajo
WORKDIR /app WORKDIR /app
ENV PYTHONPATH=/app
# Instalar dependencias Python # Copiar requirements y instalar dependencias Python
COPY backend/requirements.txt requirements.txt COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copiar todo el contenido del directorio backend
COPY backend /app/backend
# Copiar el script de migraciones
COPY backend/run_sync_migrations.py /app/backend/run_sync_migrations.py
# Crear usuario no root # Crear usuario no root
RUN useradd --create-home --shell /bin/bash app \ RUN useradd --create-home --shell /bin/bash app \
&& chown -R app:app /app && chown -R app:app /app
@@ -29,6 +23,9 @@ RUN useradd --create-home --shell /bin/bash app \
RUN mkdir -p /app/uploads /app/logs \ RUN mkdir -p /app/uploads /app/logs \
&& chown -R app:app /app/uploads /app/logs && chown -R app:app /app/uploads /app/logs
# Copiar código de la aplicación
COPY . .
# Cambiar permisos # Cambiar permisos
RUN chown -R app:app /app RUN chown -R app:app /app
@@ -38,9 +35,9 @@ USER app
# Exponer puerto # Exponer puerto
EXPOSE 8000 EXPOSE 8000
# Healthcheck # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1 CMD curl -f http://localhost:8000/health || exit 1
# CMD por defecto # Comando por defecto
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

67
fix_admin_password.py Normal file
View File

@@ -0,0 +1,67 @@
"""
Script para actualizar el password del usuario admin
Ejecutar: python fix_admin_password.py
"""
import asyncio
import sys
from sqlalchemy import select, update
from passlib.context import CryptContext
# Importar desde el proyecto
sys.path.insert(0, '/app')
from app.core.database import AsyncSessionLocal
from app.models.user import User
# Configurar passlib igual que en security.py
pwd_context = CryptContext(
schemes=["argon2", "bcrypt"],
deprecated="auto",
argon2__memory_cost=65536,
argon2__time_cost=3,
argon2__parallelism=4,
)
async def fix_admin_password():
"""Actualizar password del admin a 'admin123'"""
# Generar hash del password
new_password = "admin123"
password_hash = pwd_context.hash(new_password)
print(f"Nuevo hash generado para password: {new_password}")
print(f"Hash: {password_hash[:50]}...")
async with AsyncSessionLocal() as session:
try:
# Buscar usuario admin
result = await session.execute(
select(User).where(User.email == "admin@aduanasoft.com")
)
user = result.scalar_one_or_none()
if not user:
print("❌ Usuario admin no encontrado")
return
print(f"✅ Usuario encontrado: {user.email} (ID: {user.id})")
# Actualizar password
user.password_hash = password_hash
await session.commit()
print("✅ Password actualizado exitosamente")
print(f" Email: admin@aduanasoft.com")
print(f" Password: admin123")
except Exception as e:
await session.rollback()
print(f"❌ Error: {e}")
raise
if __name__ == "__main__":
print("=" * 60)
print("ACTUALIZAR PASSWORD DEL ADMIN")
print("=" * 60)
asyncio.run(fix_admin_password())
print("=" * 60)

View File

@@ -1,15 +0,0 @@
import { writable } from 'svelte/store';
import { api } from '$utils/api';
export const tickets = writable({
tickets: [],
async loadTickets() {
try {
const data = await api.get('/tickets');
this.update((state) => ({ ...state, tickets: data }));
} catch (error) {
console.error('Error al cargar los tickets:', error);
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@
"postcss": "^8.4.24", "postcss": "^8.4.24",
"prettier": "^2.8.0", "prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1", "prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.2.20", "svelte": "^4.0.5",
"svelte-check": "^3.4.3", "svelte-check": "^3.4.3",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
@@ -37,13 +37,13 @@
"vitest": "^0.34.0" "vitest": "^0.34.0"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.0.18",
"@tailwindcss/forms": "^0.5.4", "@tailwindcss/forms": "^0.5.4",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"chart.js": "^4.3.0", "@heroicons/react": "^2.0.18",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^2.30.0",
"heroicons": "^2.0.18", "heroicons": "^2.0.18",
"zod": "^3.22.2" "zod": "^3.22.2",
"date-fns": "^2.30.0",
"chart.js": "^4.3.0",
"chartjs-adapter-date-fns": "^3.0.0"
} }
} }

View File

@@ -1,53 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api'; // ajusta el path si es distinto
let tickets: any[] = [];
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
loading = true;
tickets = await api.get('/tickets');
} catch (err: any) {
console.error(err);
error = err.message ?? 'Error cargando tickets';
} finally {
loading = false;
}
});
</script>
<h1>Tickets</h1>
{#if loading}
<p>Cargando tickets...</p>
{:else if error}
<p style="color: red;">{error}</p>
{:else if tickets.length === 0}
<p>No hay tickets</p>
{:else}
<table border="1" cellpadding="8">
<thead>
<tr>
<th>Número</th>
<th>Asunto</th>
<th>Estado</th>
<th>Prioridad</th>
<th>Creado</th>
</tr>
</thead>
<tbody>
{#each tickets as ticket}
<tr>
<td>{ticket.ticket_number}</td>
<td>{ticket.subject}</td>
<td>{ticket.status}</td>
<td>{ticket.priority}</td>
<td>{new Date(ticket.created_at).toLocaleString()}</td>
</tr>
{/each}
</tbody>
</table>
{/if}

View File

@@ -1,24 +1,24 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
server: { server: {
port: 3000, port: 3000,
host: '0.0.0.0', host: '0.0.0.0',
proxy: { proxy: {
'/api/v1': { '/api': {
target: 'http://servicemanager-backend:8000', target: 'http://servicemanager-backend:8000',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '') rewrite: (path) => path.replace(/^\/api/, '')
} }
} }
}, },
preview: { preview: {
port: 3000, port: 3000,
host: '0.0.0.0' host: '0.0.0.0'
}, },
build: { build: {
target: 'esnext' target: 'esnext'
} }
}); });