Compare commits

..

4 Commits
v1.1 ... v1.3.2

Author SHA1 Message Date
896c99d586 🚀 Release v1.3.2: Sistema completamente configurado y optimizado
 Nuevas funcionalidades:
-  Sistema de migraciones Alembic implementado
-  Dependencias frontend resueltas (SvelteKit + TypeScript)
-  Configuraciones VS Code optimizadas
-  GitHub Copilot configuración enterprise
-  Testing completo 100% exitoso

🔧 Cambios técnicos:
- Alembic: Configuración completa con templates
- Frontend: 686 paquetes npm instalados
- VS Code: Debugger modernizado (python -> debugpy)
- Database: 16 tablas sincronizadas
- Docker: 8 servicios funcionando correctamente

🏗️ Arquitectura:
- Multi-tenant B2B system ready
- Production-ready configuration
- Enterprise-grade development environment
2026-02-05 13:00:56 -07:00
2125504831 Release version 1.3.1: Updated backend models and endpoints 2026-02-05 11:19:36 -07:00
0d7cdf51ca Versión 1.3.0: Conexion completa del proyecto 2026-02-03 10:38:54 -07:00
arielit3
6215fc40a7 Bump version to 1.2.0 2026-01-19 14:04:55 -07:00
71 changed files with 13035 additions and 15450 deletions

187
.github/copilot-context.md vendored Normal file
View File

@@ -0,0 +1,187 @@
# Configuración Avanzada de GitHub Copilot para ServiceManagerWeb
## Variables de Contexto Importantes
### Configuración del Sistema
```env
# Variables críticas a considerar
DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/servicemanager
REDIS_URL=redis://localhost:6379/0
JWT_SECRET_KEY=your-secret-key
TENANT_ISOLATION=strict
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]
```
### Modelos de Datos Clave
#### User Model Completo
```python
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))
email: Mapped[str] = mapped_column(unique=True, index=True)
role: Mapped[UserRole] = mapped_column(default=UserRole.CLIENT_USER)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
```
#### Ticket Workflow States
```python
class TicketStatus(str, Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
PENDING_CLIENT = "pending_client"
RESOLVED = "resolved"
CLOSED = "closed"
CANCELLED = "cancelled"
```
## Reglas de Implementación Específicas
### 1. Multi-Tenancy Estricto
- NUNCA hacer queries sin filtrar por `tenant_id`
- Middleware de tenant debe estar en toda request
- Validar permisos a nivel de tenant antes de operaciones
### 2. Audit Trail Obligatorio
```python
async def log_audit_event(
action: str,
resource_type: str,
resource_id: int,
user_id: int,
tenant_id: int,
details: dict = None
):
# Implementar en todas las operaciones CRUD críticas
```
### 3. Error Handling Consistente
```python
# Backend
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions for tenant resource"
)
# Frontend
import { toast } from '$lib/stores/toast';
toast.error("Error al procesar la solicitud");
```
### 4. Performance Patterns
```python
# Queries con paginación siempre
async def get_tickets_paginated(
db: AsyncSession,
tenant_id: int,
skip: int = 0,
limit: int = 20
) -> Tuple[List[Ticket], int]:
# Select con join optimizado + count total
```
## Componentes Frontend Reutilizables
### Layout Structure
```
+layout.svelte (global)
├── Header.svelte (navigation)
├── Sidebar.svelte (menu)
└── Toast.svelte (notifications)
```
### Form Patterns
```typescript
// Validation con Zod
const createTicketSchema = z.object({
title: z.string().min(5).max(200),
description: z.string().min(10),
priority: z.nativeEnum(TicketPriority),
category_id: z.number().positive()
});
```
## Debugging y Logging
### Backend Logging
```python
import structlog
logger = structlog.get_logger(__name__)
# En cada endpoint
logger.info(
"ticket_created",
ticket_id=ticket.id,
user_id=current_user.id,
tenant_id=current_user.tenant_id,
correlation_id=request.correlation_id
)
```
### Frontend Error Boundary
```svelte
<!-- En +layout.svelte -->
{#if $page.error}
<ErrorComponent error={$page.error} />
{/if}
```
## Comandos de Desarrollo Específicos
```bash
# Backend development
cd backend && uvicorn app.main:app --reload --port 8000
# Frontend internal (admin panel)
cd frontend-internal && npm run dev -- --port 3001
# Frontend client (customer portal)
cd frontend-client && npm run dev -- --port 3000
# Workers
cd workers && celery -A app.celery worker --loglevel=info
# Full stack con Docker
docker-compose -f docker-compose.dev.yml up
# Database operations
docker-compose exec backend alembic revision --autogenerate -m "Description"
docker-compose exec backend alembic upgrade head
# Testing complete
docker-compose exec backend pytest -v --cov=app
```
## Code Review Checklist
- [ ] ✅ Multi-tenant isolation verificado
- [ ] 🔒 Autenticación/autorización implementada
- [ ] 📊 Audit logging en operaciones críticas
- [ ] 🚀 Performance considerado (índices, paginación)
- [ ] 🧪 Tests unitarios/integración agregados
- [ ] 📝 OpenAPI documentation actualizada
- [ ] 🎨 UI/UX consistente con design system
- [ ] 🐛 Error handling comprehensivo
- [ ] 📱 Responsive design verificado
- [ ] 🔍 Type safety con TypeScript/mypy
## Herramientas de Calidad
```bash
# Python quality
ruff check . --fix
black .
mypy .
bandit -r app/
safety check
# JavaScript/TypeScript quality
npm run lint
npm run type-check
npm run format
```
Esta configuración te ayudará a mantener la calidad enterprise del sistema ServiceManagerWeb.

View File

@@ -98,4 +98,98 @@ black .
mypy .
```
## Configuración de IA Especializada
### Prioridades de Asistencia
1. **Seguridad primero**: Siempre implementar autenticación/autorización en nuevos endpoints
2. **Multi-tenancy**: Verificar aislamiento de datos entre tenants en toda nueva funcionalidad
3. **Performance**: Considerar impacto en bases de datos grandes (índices, paginación, caching)
4. **Auditabilidad**: Registrar acciones sensibles en el sistema de audit
5. **Escalabilidad**: Código preparado para crecimiento empresarial
### Patrones Preferidos
#### Backend (FastAPI)
```python
# Estructura de endpoint típica
@router.post("/", response_model=schemas.TicketResponse)
async def create_ticket(
ticket: schemas.TicketCreate,
current_user: models.User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
# 1. Validar permisos multi-tenant
# 2. Procesar lógica de negocio
# 3. Audit log
# 4. Return response
```
#### Frontend (SvelteKit)
```typescript
// Store pattern con Zod validation
import { z } from 'zod';
import { writable } from 'svelte/store';
const TicketSchema = z.object({
title: z.string().min(5),
priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT'])
});
```
### Contexto de Archivos Clave
#### Backend Core
- `app/core/security.py`: JWT, permissions, rate limiting
- `app/middleware/tenant.py`: Multi-tenant context
- `app/models/`: SQLAlchemy models con relationships
- `app/api/v1/endpoints/`: Endpoints REST por dominio
#### Frontend Routing
- `frontend-internal/`: Panel administrativo interno
- `frontend-client/`: Portal de clientes
- Ambos usan SvelteKit con layout compartido
#### Workers/Tasks
- `workers/app/tasks/`: Tareas Celery asíncronas
- `email_tasks.py`: Notificaciones y plantillas
- `sla_tasks.py`: Monitoreo de SLAs automático
### Troubleshooting Común
#### Database Issues
```bash
# Reset migrations
docker-compose exec backend alembic downgrade base
docker-compose exec backend alembic upgrade head
```
#### Multi-tenant Debug
- Verificar `tenant_context` middleware
- Headers: `X-Tenant-ID` en requests
- Queries siempre filtrar por tenant_id
#### Frontend Build Errors
```bash
cd frontend-internal && npm run build
cd frontend-client && npm run build
```
### Convenciones de Desarrollo
#### Naming
- **Models**: PascalCase (User, Ticket, TenantOrganization)
- **Endpoints**: kebab-case (/api/v1/user-management/)
- **Components**: PascalCase.svelte (TicketCard.svelte)
- **Stores**: camelCase (ticketStore.ts)
#### Error Handling
- Backend: HTTPException con status codes apropiados
- Frontend: Toast notifications para UX
- Logs: Structured logging con correlation IDs
#### Testing Strategy
- Unit: Lógica de negocio y validaciones
- Integration: Endpoints completos con DB
- E2E: Flujos críticos multi-tenant
Cuando trabajes en este proyecto, siempre considera la naturaleza multi-tenant y empresarial del sistema.

View File

@@ -1,7 +1,54 @@
[alembic]
script_location = backend/alembic
sqlalchemy.url = postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
# A generic, single database configuration for ServiceManagerWeb
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
# behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
@@ -33,4 +80,5 @@ level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,65 +0,0 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
from dotenv import load_dotenv
import sys
import os
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
import asyncio
# Cargar variables de entorno desde .env
load_dotenv()
# Agregar el directorio 'backend' al PYTHONPATH
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Configuración de Alembic
config = context.config
# Configurar logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Agregar la URL de la base de datos desde las variables de entorno
config.set_main_option(
"sqlalchemy.url",
os.getenv("DATABASE_URL", "postgresql+asyncpg://user:password@localhost/dbname")
)
# Importar modelos para las migraciones
from app.models import * # Importa todos los modelos aquí
from app.core.database import Base
target_metadata = Base.metadata # Reemplaza con tu metadata
# Cambiar el motor a asíncrono para Alembic
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
# Crear el motor asíncrono
connectable = create_async_engine(
os.getenv("DATABASE_URL", "postgresql+asyncpg://user:password@localhost/dbname"),
echo=True, # Habilitar logs para depuración
)
async def run_migrations():
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
# Configurar migraciones en modo offline
def run_migrations_offline():
context.configure(url=os.getenv("DATABASE_URL"), target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations())

View File

@@ -1,28 +0,0 @@
"""
Add clients table
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "add_clients_table"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = inspect(bind)
if 'clients' not in inspector.get_table_names():
op.create_table(
'clients',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('name', sa.String, nullable=False),
sa.Column('email', sa.String, nullable=False, unique=True),
sa.Column('phone', sa.String, nullable=False),
)
def downgrade():
op.drop_table('clients')

View File

@@ -1,20 +0,0 @@
"""
Agregar columna system_id a la tabla tickets
"""
from alembic import op
import sqlalchemy as sa
# Revisión ID y dependencias
revision = 'add_system_id_to_tickets'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
"""Agregar columna system_id a la tabla tickets."""
op.add_column('tickets', sa.Column('system_id', sa.UUID, nullable=True))
def downgrade():
"""Eliminar columna system_id de la tabla tickets."""
op.drop_column('tickets', 'system_id')

View File

@@ -1,18 +0,0 @@
"""
Add updated_at column to ticket_categories
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "add_updated_at_to_category"
down_revision = "add_clients_table"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("ticket_categories", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True))
def downgrade():
op.drop_column("ticket_categories", "updated_at")

View File

@@ -55,13 +55,3 @@ async def get_current_active_superuser(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
return current_user

View File

@@ -3,94 +3,191 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
import uuid
import logging
from app.core.database import get_db
from app.models.category import Category
from app.models.user import User
from app.api import deps
router = APIRouter()
logger = logging.getLogger("categories")
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class CategoryBase(BaseModel):
class CategoryCreate(BaseModel):
"""Schema para crear categoría - NO incluye tenant_id (se asigna automáticamente)"""
name: str
description: Optional[str] = None
is_active: bool = True
tenant_id: Optional[uuid.UUID] = None
color: Optional[str] = None
sla_response_hours: int = 24
sla_resolution_hours: int = 72
auto_assign_to: Optional[uuid.UUID] = None
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(CategoryBase):
class CategoryUpdate(BaseModel):
"""Schema para actualizar categoría"""
name: 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
tenant_id: Optional[uuid.UUID] = None
class CategoryResponse(CategoryBase):
class CategoryResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos"""
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)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[CategoryResponse])
async def read_categories(
skip: int = 0,
limit: int = 100,
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
):
try:
logger.debug("Fetching categories with skip=%d, limit=%d", skip, limit)
query = select(Category).offset(skip).limit(limit)
result = await db.execute(query)
categories = result.scalars().all()
logger.debug("Fetched %d categories", len(categories))
return categories
except Exception as e:
logger.exception("Unhandled exception occurred while fetching categories.")
raise HTTPException(status_code=500, detail="Internal Server Error")
"""
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)
return result.scalars().all()
@router.post("/", response_model=CategoryResponse)
@router.post("/", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
category: CategoryCreate,
category: CategoryCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
current_user: User = Depends(deps.get_current_user) # ✅ CORREGIDO: Type hint
):
new_category = Category(**category.model_dump())
db.add(new_category)
await db.commit()
await db.refresh(new_category)
return new_category
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: uuid.UUID,
category: CategoryUpdate,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
db_category = await db.get(Category, category_id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
for key, value in category.model_dump(exclude_unset=True).items():
setattr(db_category, key, value)
"""
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)
await db.commit()
await db.refresh(db_category)
return db_category
@router.delete("/{category_id}", status_code=204)
@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 = Depends(deps.get_current_active_superuser)
current_user: User = Depends(deps.get_current_user)
):
db_category = await db.get(Category, category_id)
"""
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=404, detail="Category not found")
await db.delete(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

@@ -1,61 +0,0 @@
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text # <--- IMPORTANTE: Necesario para consultas SQL
from app.core.database import get_db
# IMPORTANTE: Renombramos para evitar conflictos
from app.models.client import Client as ClientModel
from app.schemas.client import ClientCreate, ClientUpdate, Client as ClientSchema
router = APIRouter()
# GET: Obtener todos los clientes
@router.get("/clients", response_model=list[ClientSchema])
async def get_clients(db: AsyncSession = Depends(get_db)):
# Corrección: Usamos text() y mappings().all()
query = text("SELECT * FROM clients")
result = await db.execute(query)
return result.mappings().all()
# POST: Crear cliente
@router.post("/clients", response_model=ClientSchema)
async def create_client(client: ClientCreate, db: AsyncSession = Depends(get_db)):
# Usamos ClientModel para guardar en BD
new_client = ClientModel(**client.dict())
db.add(new_client)
await db.commit()
await db.refresh(new_client)
return new_client
# GET ONE: Obtener un cliente por ID
@router.get("/clients/{client_id}", response_model=ClientSchema)
async def read_client(client_id: int, db: AsyncSession = Depends(get_db)):
db_client = await db.get(ClientModel, client_id)
if not db_client:
raise HTTPException(status_code=404, detail="Client not found")
return db_client
# PUT: Actualizar cliente
@router.put("/clients/{client_id}", response_model=ClientSchema)
async def update_client(client_id: int, client: ClientUpdate, db: AsyncSession = Depends(get_db)):
db_client = await db.get(ClientModel, client_id)
if not db_client:
raise HTTPException(status_code=404, detail="Client not found")
for key, value in client.dict(exclude_unset=True).items():
setattr(db_client, key, value)
await db.commit()
await db.refresh(db_client)
return db_client
# DELETE: Borrar cliente
@router.delete("/clients/{client_id}")
async def delete_client(client_id: int, db: AsyncSession = Depends(get_db)):
db_client = await db.get(ClientModel, client_id)
if not db_client:
raise HTTPException(status_code=404, detail="Client not found")
await db.delete(db_client)
await db.commit()
return {"message": "Client deleted successfully"}

View File

@@ -3,51 +3,179 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from datetime import datetime
import uuid
from app.core.database import get_db
from app.models.system import System
from app.models.user import User
from app.api import deps
router = APIRouter()
class SystemBase(BaseModel):
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class SystemCreate(BaseModel):
"""Schema para crear sistema - NO incluye tenant_id (se asigna automáticamente)"""
name: str
description: Optional[str] = None
is_active: bool = True
class SystemCreate(SystemBase):
pass
class SystemUpdate(SystemBase):
class SystemUpdate(BaseModel):
"""Schema para actualizar sistema"""
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class SystemResponse(SystemBase):
class SystemResponse(BaseModel):
"""Schema de respuesta - incluye todos los campos"""
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)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[SystemResponse])
async def read_systems(
skip: int = 0,
limit: int = 100,
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)
return result.scalars().all()
@router.post("/", response_model=SystemResponse)
@router.post("/", response_model=SystemResponse, status_code=status.HTTP_201_CREATED)
async def create_system(
system: SystemCreate,
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)
await db.commit()
await db.refresh(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

@@ -85,6 +85,9 @@ async def update_tenant(
raise HTTPException(status_code=404, detail="Tenant not found")
update_data = tenant_in.model_dump(exclude_unset=True)
if "status" in update_data:
tenant.is_active = update_data.pop("status") == TenantStatus.active
for field, value in update_data.items():
setattr(tenant, field, value)
@@ -92,3 +95,18 @@ async def update_tenant(
await db.commit()
await db.refresh(tenant)
return tenant
@router.delete("/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tenant(
tenant_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user = Depends(deps.get_current_active_superuser)
):
"""Eliminar un cliente (tenant) por ID."""
tenant = await db.get(Tenant, tenant_id)
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
await db.delete(tenant)
await db.commit()
return {"message": "Tenant deleted successfully"}

View File

@@ -1,75 +1,604 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import List
import uuid
from sqlalchemy.exc import NoResultFound
"""
Tickets endpoints - ServiceManagerWeb
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.models.ticket import Ticket # Asegúrate de tener el modelo Ticket
from app.models.category import Category # Ensure Category model is imported
from app.api import deps # Para autenticación y permisos
from app.models.user import User # Asegúrate de importar el modelo User
from app.api.deps import get_current_user
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User
from app.models.category import Category # ✅ CORREGIDO: Era TicketCategory
from app.models.system import System
from app.models.comment import TicketComment
import uuid
router = APIRouter()
# Esquema para crear un ticket
# ===================================
# SCHEMAS
# ===================================
class TicketCreate(BaseModel):
title: str
subject: str
description: str
category_id: uuid.UUID
priority: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
priority: str = "MEDIUM"
class TicketUpdate(BaseModel):
subject: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
assigned_to: Optional[str] = None
# Esquema para devolver un ticket
class TicketResponse(BaseModel):
id: uuid.UUID
id: str
ticket_number: str
subject: str
title: str
description: str
category_id: uuid.UUID
status: str
priority: str
created_at: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
created_by: str
assigned_to: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@router.post("/", response_model=TicketResponse)
class TicketCloseRequest(BaseModel):
resolution: Optional[str] = None
# ===================================
# TICKET ENDPOINTS
# ===================================
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket(
ticket: TicketCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user) # Verifica que el usuario esté autenticado
current_user: User = Depends(get_current_user)
):
# Validate if the category exists
"""
Crear un nuevo ticket
"""
try:
category = await db.execute(select(Category).where(Category.id == ticket.category_id))
category = category.scalar_one()
except NoResultFound:
raise HTTPException(status_code=404, detail="Category not found")
# Generar número de ticket único
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
)
count = result.scalar() or 0
ticket_number = f"TK-{count + 1:06d}"
# Convertir IDs de string a UUID si son proporcionados
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 # ✅ CORREGIDO
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
# Validar si el system_id existe en la tabla affected_systems
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id en respuesta
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
)
# Crear un nuevo ticket
db_ticket = Ticket(
ticket_number=str(uuid.uuid4())[:8], # Generate a unique ticket number
subject=ticket.title,
description=ticket.description,
category_id=ticket.category_id,
priority=ticket.priority,
created_by=current_user.id,
tenant_id=current_user.tenant_id
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
return db_ticket
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_active_user) # Verifica que el usuario esté autenticado
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets del usuario actual
"""
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
return [
{
"id": str(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"title": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
for t in tickets
]
@router.get("/{ticket_id}", response_model=TicketResponse)
async def get_ticket(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener un ticket específico
"""
try:
result = await db.execute(select(Ticket).where(Ticket.tenant_id == current_user.tenant_id))
tickets = result.scalars().all()
return tickets
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
ticket = result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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,
"title": 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)
async def update_ticket(
ticket_id: str,
ticket_update: TicketUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Actualizar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_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"
)
try:
update_data = ticket_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "status" and value:
setattr(db_ticket, field, TicketStatus[value.upper()])
elif field == "priority" and value:
setattr(db_ticket, field, TicketPriority[value.upper()])
elif field == "assigned_to" and value:
setattr(db_ticket, field, uuid.UUID(value))
else:
setattr(db_ticket, field, value)
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except Exception as e:
# Log the error for debugging purposes
import logging
logger = logging.getLogger("tickets")
logger.exception("Error fetching tickets for tenant_id=%s", current_user.tenant_id)
raise HTTPException(status_code=500, detail="An error occurred while fetching tickets.")
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error updating ticket: {str(e)}"
)
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
async def close_ticket(
ticket_id: str,
close_request: TicketCloseRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Cerrar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_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"
)
try:
db_ticket.status = TicketStatus.CLOSED
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"title": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error closing ticket: {str(e)}"
)
## ===================================
# COMMENT ENDPOINTS
# ===================================
class CommentCreate(BaseModel):
content: str
is_internal: bool = False
class CommentResponse(BaseModel):
id: str
ticket_id: str
author_id: str
author_name: str
content: str
is_internal: bool
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
@router.get("/{ticket_id}/comments", response_model=List[CommentResponse])
async def get_ticket_comments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener comentarios de un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
# Verificar que el ticket existe y el usuario tiene acceso
ticket_query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id
)
ticket_result = await db.execute(ticket_query)
ticket = ticket_result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
# Obtener comentarios
comments_query = select(TicketComment, User).join(
User, TicketComment.author_id == User.id
).where(
TicketComment.ticket_id == ticket_uuid
).order_by(TicketComment.created_at.asc())
result = await db.execute(comments_query)
comments_with_users = result.all()
# Formatear respuesta
return [
{
"id": str(comment.id),
"ticket_id": str(comment.ticket_id),
"author_id": str(comment.author_id),
"author_name": f"{user.first_name} {user.last_name}",
"content": comment.content,
"is_internal": comment.is_internal,
"created_at": comment.created_at,
"updated_at": comment.updated_at
}
for comment, user in comments_with_users
]
@router.post("/{ticket_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def create_comment(
ticket_id: str,
comment: CommentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Agregar un comentario a un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
# Verificar que el ticket existe y el usuario tiene acceso
ticket_query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id
)
ticket_result = await db.execute(ticket_query)
ticket_obj = ticket_result.scalars().first()
if not ticket_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Ticket {ticket_id} not found"
)
# Crear comentario
new_comment = TicketComment(
id=uuid.uuid4(),
ticket_id=ticket_uuid,
author_id=current_user.id,
content=comment.content,
is_internal=comment.is_internal,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(new_comment)
# Actualizar el ticket updated_at
ticket_obj.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(new_comment)
# Retornar con el nombre del autor
return {
"id": str(new_comment.id),
"ticket_id": str(new_comment.ticket_id),
"author_id": str(new_comment.author_id),
"author_name": f"{current_user.first_name} {current_user.last_name}",
"content": new_comment.content,
"is_internal": new_comment.is_internal,
"created_at": new_comment.created_at,
"updated_at": new_comment.updated_at
}
@router.delete("/{ticket_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ticket(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Eliminar un ticket (solo admin/manager)
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_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"
)
await db.delete(db_ticket)
await db.commit()
return {"message": "Ticket deleted successfully"}
# ===================================
# ATTACHMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/attachments")
async def get_ticket_attachments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener adjuntos de un ticket
"""
return []
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
async def upload_attachment(
ticket_id: str,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Subir un archivo adjunto a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="File uploads not yet implemented"
)

View File

@@ -0,0 +1,451 @@
"""
Tickets endpoints - ServiceManagerWeb
"""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Optional
from datetime import datetime
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.user import User
from app.models.category import Category # ✅ CORREGIDO: Era TicketCategory
from app.models.system import System
import uuid
router = APIRouter()
# ===================================
# SCHEMAS
# ===================================
class TicketCreate(BaseModel):
subject: str
description: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
priority: str = "MEDIUM"
class TicketUpdate(BaseModel):
subject: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
assigned_to: Optional[str] = None
class TicketResponse(BaseModel):
id: str
ticket_number: str
subject: str
description: str
status: str
priority: str
category_id: Optional[str] = None
affected_system_id: Optional[str] = None # ✅ CORREGIDO: Era system_id
created_by: str
assigned_to: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TicketCloseRequest(BaseModel):
resolution: Optional[str] = None
# ===================================
# TICKET ENDPOINTS
# ===================================
@router.post("/", response_model=TicketResponse, status_code=status.HTTP_201_CREATED)
async def create_ticket(
ticket: TicketCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Crear un nuevo ticket
"""
try:
# Generar número de ticket único
result = await db.execute(
select(func.count(Ticket.id)).where(Ticket.tenant_id == current_user.tenant_id)
)
count = result.scalar() or 0
ticket_number = f"TK-{count + 1:06d}"
# Convertir IDs de string a UUID si son proporcionados
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 # ✅ CORREGIDO
# ✅ CORREGIDO: Validar en la tabla correcta con el nombre correcto del modelo
if category_uuid:
category = await db.get(Category, category_uuid) # ✅ Category, no TicketCategory
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"La categoría con ID {ticket.category_id} no existe."
)
# Validar si el system_id existe en la tabla affected_systems
if system_uuid:
system = await db.get(System, system_uuid)
if not system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"El sistema con ID {ticket.affected_system_id} no existe."
)
db_ticket = Ticket(
id=uuid.uuid4(),
tenant_id=current_user.tenant_id,
ticket_number=ticket_number,
subject=ticket.subject,
description=ticket.description,
category_id=category_uuid,
affected_system_id=system_uuid, # ✅ CORREGIDO: Nombre correcto del campo
priority=TicketPriority[ticket.priority.upper()],
created_by=current_user.id,
status=TicketStatus.NEW,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_ticket)
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id en respuesta
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except ValueError as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid UUID format: {str(e)}"
)
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error creating ticket: {str(e)}"
)
@router.get("/", response_model=List[TicketResponse])
async def get_tickets(
skip: int = 0,
limit: int = 100,
status_filter: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener tickets del usuario actual
"""
query = select(Ticket).where(
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
if status_filter:
try:
status_enum = TicketStatus[status_filter.upper()]
query = query.where(Ticket.status == status_enum)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {status_filter}"
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
# ✅ CORREGIDO: Usar affected_system_id
return [
{
"id": str(t.id),
"ticket_number": t.ticket_number,
"subject": t.subject,
"description": t.description,
"status": t.status.value,
"priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None, # ✅ CORREGIDO
"created_by": str(t.created_by),
"assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at,
"updated_at": t.updated_at
}
for t in tickets
]
@router.get("/{ticket_id}", response_model=TicketResponse)
async def get_ticket(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener un ticket específico
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_id,
Ticket.created_by == current_user.id
)
result = await db.execute(query)
ticket = result.scalars().first()
if not ticket:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
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)
async def update_ticket(
ticket_id: str,
ticket_update: TicketUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Actualizar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_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"
)
try:
update_data = ticket_update.dict(exclude_unset=True)
for field, value in update_data.items():
if field == "status" and value:
setattr(db_ticket, field, TicketStatus[value.upper()])
elif field == "priority" and value:
setattr(db_ticket, field, TicketPriority[value.upper()])
elif field == "assigned_to" and value:
setattr(db_ticket, field, uuid.UUID(value))
else:
setattr(db_ticket, field, value)
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error updating ticket: {str(e)}"
)
@router.patch("/{ticket_id}/close", response_model=TicketResponse)
async def close_ticket(
ticket_id: str,
close_request: TicketCloseRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Cerrar un ticket
"""
try:
ticket_uuid = uuid.UUID(ticket_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid ticket ID format"
)
query = select(Ticket).where(
Ticket.id == ticket_uuid,
Ticket.tenant_id == current_user.tenant_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"
)
try:
db_ticket.status = TicketStatus.CLOSED
db_ticket.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(db_ticket)
# ✅ CORREGIDO: Usar affected_system_id
return {
"id": str(db_ticket.id),
"ticket_number": db_ticket.ticket_number,
"subject": db_ticket.subject,
"description": db_ticket.description,
"status": db_ticket.status.value,
"priority": db_ticket.priority.value,
"category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"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),
"assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at,
"updated_at": db_ticket.updated_at
}
except Exception as e:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error closing ticket: {str(e)}"
)
# ===================================
# COMMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/comments")
async def get_ticket_comments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener comentarios de un ticket
"""
return []
@router.post("/{ticket_id}/comments", status_code=status.HTTP_201_CREATED)
async def create_comment(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Agregar un comentario a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Comments not yet implemented"
)
# ===================================
# ATTACHMENT ENDPOINTS (placeholder)
# ===================================
@router.get("/{ticket_id}/attachments")
async def get_ticket_attachments(
ticket_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Obtener adjuntos de un ticket
"""
return []
@router.post("/{ticket_id}/attachments", status_code=status.HTTP_201_CREATED)
async def upload_attachment(
ticket_id: str,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Subir un archivo adjunto a un ticket
"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="File uploads not yet implemented"
)

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel, ConfigDict, EmailStr
from typing import List, Optional
from datetime import datetime
import uuid
from app.core.database import get_db
@@ -12,57 +13,335 @@ from app.api import deps
router = APIRouter()
class UserBase(BaseModel):
# ===================================
# PYDANTIC SCHEMAS
# ===================================
class UserCreate(BaseModel):
"""Schema para crear usuario - NO incluye tenant_id (se asigna automáticamente)"""
email: EmailStr
first_name: str
last_name: str
role: UserRole
is_active: bool = True
tenant_id: Optional[uuid.UUID] = None
class UserCreate(UserBase):
password: str
language: str = "es"
timezone: str = "UTC"
notifications_email: bool = True
class UserUpdate(BaseModel):
"""Schema para actualizar usuario"""
email: Optional[EmailStr] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
role: Optional[UserRole] = 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
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)
# ===================================
# ENDPOINTS
# ===================================
@router.get("/", response_model=List[UserResponse])
async def read_users(
skip: int = 0,
limit: int = 100,
limit: int = 100,
role: Optional[UserRole] = None,
is_active: Optional[bool] = None,
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)
return result.scalars().all()
@router.post("/", response_model=UserResponse)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user: UserCreate,
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)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
user_data = user.model_dump(exclude={"password"})
password_hash = security.get_password_hash(user.password)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered in this tenant"
)
# 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)
await db.commit()
await db.refresh(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

@@ -5,7 +5,8 @@ Router principal para la API v1
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, clients, tickets
from app.api.v1.endpoints import auth, health, tenants, users, systems, categories, tickets
api_router = APIRouter()
@@ -46,14 +47,13 @@ api_router.include_router(
tags=["categories"]
)
api_router.include_router(
clients.router,
prefix="/clients",
tags=["clients"]
)
# Tickets routes
api_router.include_router(
tickets.router,
prefix="/tickets",
tags=["tickets"]
)
)
# Ensure FastAPI is installed in the environment
# If not, install it using:
# pip install fastapi

View File

@@ -119,18 +119,6 @@ class Settings(BaseSettings):
CELERY_BROKER_URL: str = Field(..., env="CELERY_BROKER_URL")
CELERY_RESULT_BACKEND: str = Field(..., env="CELERY_RESULT_BACKEND")
# ===================================
# CAMPOS ADICIONALES
# ===================================
POSTGRES_DB: str = Field(..., env="POSTGRES_DB")
POSTGRES_USER: str = Field(..., env="POSTGRES_USER")
POSTGRES_PASSWORD: str = Field(..., env="POSTGRES_PASSWORD")
SMTP_USERNAME: Optional[str] = Field(default=None, env="SMTP_USERNAME")
MAX_UPLOAD_SIZE: int = Field(default=10485760, env="MAX_UPLOAD_SIZE")
UPLOAD_DIR: str = Field(default="./uploads", env="UPLOAD_DIR")
RATE_LIMIT_REQUESTS: int = Field(default=100, env="RATE_LIMIT_REQUESTS")
RATE_LIMIT_WINDOW: int = Field(default=60, env="RATE_LIMIT_WINDOW")
def is_production(self) -> bool:
"""Check if environment is production."""
return self.ENVIRONMENT.lower() == "production"
@@ -151,7 +139,4 @@ def get_settings() -> Settings:
Using lru_cache to create a singleton pattern for settings.
"""
return Settings()
# Crear una instancia global de Settings
settings = Settings()
return Settings()

View File

@@ -10,7 +10,6 @@ from sqlalchemy import String, DateTime, func
from typing import AsyncGenerator
import uuid
from datetime import datetime
import logging
from app.core.config import get_settings
@@ -35,8 +34,6 @@ AsyncSessionLocal = async_sessionmaker(
autocommit=False
)
logger = logging.getLogger("database")
class Base(DeclarativeBase):
"""Base class para todos los modelos SQLAlchemy."""
@@ -60,16 +57,13 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
async with AsyncSessionLocal() as session:
try:
logger.debug("Intentando crear una nueva sesión de base de datos.")
yield session
await session.commit()
except Exception as e:
logger.error("Error al crear la sesión de base de datos: %s", str(e))
except Exception:
await session.rollback()
raise
finally:
await session.close()
logger.debug("Sesión de base de datos cerrada.")
async def create_tables():

View File

@@ -21,6 +21,7 @@ from app.models.system import System
from app.models.category import Category
from app.models.user import User
from app.models.ticket import Ticket
from app.models.comment import TicketComment
from app.core.logging import setup_logging
from app.api.v1.router import api_router
@@ -198,4 +199,4 @@ if __name__ == "__main__":
host="0.0.0.0",
port=8000,
reload=settings.ENVIRONMENT == "development"
)
)

View File

@@ -8,20 +8,10 @@ from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
import structlog
from contextlib import asynccontextmanager
from sqlalchemy.sql import text
logger = structlog.get_logger(__name__)
# Correct the usage of async for by wrapping it in an async function
@asynccontextmanager
async def get_db_context():
from app.core.database import get_db
async for db in get_db():
yield db
class TenantMiddleware(BaseHTTPMiddleware):
"""
Middleware para extraer y validar información del tenant.
@@ -44,56 +34,33 @@ class TenantMiddleware(BaseHTTPMiddleware):
"""Process request and add tenant information."""
# Skip tenant validation for excluded paths
if request.url.path in self.EXCLUDED_PATHS or any(request.url.path.startswith(path) for path in self.EXCLUDED_PATHS):
if request.url.path in self.EXCLUDED_PATHS or request.url.path.startswith("/docs"):
return await call_next(request)
# Extract tenant from header
tenant_id = request.headers.get("X-Tenant-ID")
tenant_slug = request.headers.get("X-Tenant-Slug")
# Revertir cambios para requerir encabezados de tenant
# For now, we'll be more permissive in development
# In production, tenant should be strictly required
if not tenant_id and not tenant_slug:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
logger.warning(
"Request without tenant information",
path=request.url.path,
method=request.method
)
# For now, continue without tenant for development
# raise HTTPException(
# status_code=status.HTTP_400_BAD_REQUEST,
# detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
# )
# Store tenant info in request state
request.state.tenant_id = tenant_id
request.state.tenant_slug = tenant_slug
# Validate tenant exists and is active
if tenant_id or tenant_slug:
from app.core.database import get_db
from app.models.tenant import Tenant
# Update the middleware to use the new context manager
async with get_db_context() as db:
tenant = await db.execute(
text(
"""
SELECT * FROM tenants
WHERE id = :tenant_id OR slug = :tenant_slug AND status = 'active'
"""
),
{"tenant_id": tenant_id, "tenant_slug": tenant_slug}
)
tenant = tenant.fetchone()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found or inactive"
)
# Store validated tenant info
request.state.tenant = tenant
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
)
# TODO: Validate tenant exists and is active
# This would involve a database query which we'll implement later
logger.debug(
"Tenant middleware processed",

View File

@@ -1,32 +1,50 @@
"""
Category Model - ServiceManagerWeb
Categorías de tickets por tenant
"""
from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime
from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
from typing import List, Optional
import uuid
from app.core.database import Base
class Category(Base):
__tablename__ = "ticket_categories"
"""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)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Optional: Tenant specific categories?
tenant_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
# ✅ CORREGIDO: tenant_id es obligatorio para multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
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
)
# Timestamp columns
created_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[DateTime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
# Relationships
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:
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,29 +0,0 @@
from sqlalchemy import Column, Integer, String
from app.core.database import Base
class Client(Base):
__tablename__ = "clients"
id = Column(Integer, primary_key=True, index=True)
clave = Column(String, nullable=False)
tipo_cliente = Column(String, nullable=False)
nombre = Column(String, nullable=False)
pais = Column(String, nullable=False)
estado = Column(String, nullable=False)
ciudad = Column(String, nullable=False)
direccion = Column(String, nullable=False)
numero_ext = Column(String, nullable=True)
cp = Column(String, nullable=True)
colonia = Column(String, nullable=True)
lada = Column(String, nullable=True)
telefono1 = Column(String, nullable=True)
telefono2 = Column(String, nullable=True)
tel_directo = Column(String, nullable=True)
ext = Column(String, nullable=True)
fax = Column(String, nullable=True)
horario = Column(String, nullable=True)
pagina = Column(String, nullable=True)
correo = Column(String, nullable=True)
medio_publicidad = Column(String, nullable=True)
nacionalidad = Column(String, nullable=True)
logo = Column(String, nullable=True)

View File

@@ -0,0 +1,69 @@
"""
Comment Model - ServiceManagerWeb
Modelo para comentarios en tickets
"""
from sqlalchemy import Column, String, Text, Boolean, ForeignKey, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
import uuid
from app.core.database import Base
class TicketComment(Base):
"""Comentarios en tickets."""
__tablename__ = "ticket_comments"
# Columnas
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4
)
ticket_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tickets.id", ondelete="CASCADE"),
nullable=False,
index=True
)
author_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=False,
index=True
)
content: Mapped[str] = mapped_column(Text, nullable=False)
is_internal: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
index=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
# Relationships
ticket: Mapped["Ticket"] = relationship("Ticket", back_populates="comments")
author: Mapped["User"] = relationship("User")
def __repr__(self) -> str:
return f"<TicketComment {self.id} by {self.author_id}>"

View File

View File

@@ -1,25 +1,43 @@
"""
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.dialects.postgresql import UUID
from typing import List, Optional
import uuid
from app.core.database import 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)
description: Mapped[Optional[str]] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=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
# If we want tickets to link to systems, we will add relationship in Ticket later or now.
# We will assume Ticket links to System.
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="system")
# ✅ ACTUALIZADO: nombre de relación a affected_system
tickets: Mapped[List["Ticket"]] = relationship(
"Ticket",
back_populates="affected_system" # ✅ Nombre actualizado
)
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:
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
Modelo para organizaciones cliente (multi-tenancy)
"""
from sqlalchemy import String, Integer, Text, Boolean, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, ENUM
@@ -13,17 +11,14 @@ import uuid
from app.core.database import Base
class TenantStatus(str, enum.Enum):
"""Estados de un tenant."""
ACTIVE = "active"
SUSPENDED = "suspended"
INACTIVE = "inactive"
class Tenant(Base):
"""Modelo de Tenant (Organización cliente)."""
__tablename__ = "tenants"
# Información básica
@@ -51,18 +46,14 @@ class Tenant(Base):
# Estado
status: Mapped[TenantStatus] = mapped_column(
String(20),
String(20),
default=TenantStatus.ACTIVE
)
# Relaciones
users: Mapped[List["User"]] = relationship("User", back_populates="tenant")
tickets: Mapped[List["Ticket"]] = relationship("Ticket", back_populates="tenant")
categories: Mapped[List["Category"]] = relationship("Category", back_populates="tenant") # ✅ CORREGIDO: Era "TicketCategory"
def __repr__(self) -> str:
return f"<Tenant(id={self.id}, name='{self.name}', slug='{self.slug}')>"
@property
def is_active(self) -> bool:
"""Check if tenant is active."""
return self.status == TenantStatus.ACTIVE

View File

@@ -1,58 +1,112 @@
"""
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.dialects.postgresql import UUID, ENUM
from typing import Optional
from datetime import datetime
import enum
import uuid
from app.core.database import Base
from app.models.system import System
from app.models.category import Category
class TicketStatus(str, enum.Enum):
"""Estados posibles de un ticket"""
NEW = "NEW"
TRIAGE = "TRIAGE"
IN_PROGRESS = "IN_PROGRESS"
WAITING_FOR_CLIENT = "WAITING_FOR_CLIENT"
WAITING_CUSTOMER = "WAITING_CUSTOMER" # ✅ CORREGIDO: nombre según schema.sql
RESOLVED = "RESOLVED"
CLOSED = "CLOSED"
REOPENED = "REOPENED"
class TicketPriority(str, enum.Enum):
"""Prioridades posibles de un ticket"""
LOW = "LOW"
MEDIUM = "MEDIUM"
HIGH = "HIGH"
URGENT = "URGENT"
class Ticket(Base):
"""Modelo de tickets de soporte"""
__tablename__ = "tickets"
# Note: id, created_at, updated_at are inherited from Base
tenant_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
# Multi-tenancy
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False
)
# Campos básicos
ticket_number: Mapped[str] = mapped_column(String(20), nullable=False)
subject: Mapped[str] = mapped_column(String(255), 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)
priority: Mapped[TicketPriority] = mapped_column(ENUM(TicketPriority, name="ticket_priority_enum", create_type=False), default=TicketPriority.MEDIUM)
# Estado y Prioridad
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
created_by: Mapped[uuid.UUID] = mapped_column(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)
# ✅ CORREGIDO: Foreign Keys apuntan a tablas correctas
created_by: Mapped[uuid.UUID] = mapped_column(
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)
category_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("ticket_categories.id"), nullable=True)
# ✅ CORREGIDO: Renombrado de system_id a affected_system_id
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
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="tickets")
system: Mapped["System"] = relationship("System", back_populates="tickets")
category: Mapped["Category"] = relationship("Category", back_populates="tickets")
# ✅ ACTUALIZADO: Nombre de relación y optional
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(
"User",
@@ -65,3 +119,18 @@ class Ticket(Base):
foreign_keys=[assigned_to],
back_populates="assigned_tickets"
)
comments: Mapped[list["TicketComment"]] = relationship(
"TicketComment",
back_populates="ticket",
cascade="all, delete-orphan"
)
# ✅ 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

@@ -13,7 +13,6 @@ import uuid
from datetime import datetime
from app.core.database import Base
from app.models.ticket import Ticket
class UserRole(str, enum.Enum):
@@ -49,7 +48,7 @@ class User(Base):
# Autenticación
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(ENUM(UserRole), nullable=False)
role: Mapped[UserRole] = mapped_column(ENUM(UserRole, name="user_role_enum"), nullable=False)
# 2FA (opcional para staff interno)
totp_secret: Mapped[Optional[str]] = mapped_column(String(32))
@@ -133,4 +132,4 @@ class User(Base):
def requires_2fa(self) -> bool:
"""Check if 2FA is required for this user."""
# 2FA opcional para staff interno, no requerido para clientes
return self.is_staff
return self.is_staff

View File

@@ -1,37 +0,0 @@
from pydantic import BaseModel
class ClientBase(BaseModel):
clave: str
tipo_cliente: str
nombre: str
pais: str
estado: str
ciudad: str
direccion: str
numero_ext: str | None = None
cp: str | None = None
colonia: str | None = None
lada: str | None = None
telefono1: str | None = None
telefono2: str | None = None
tel_directo: str | None = None
ext: str | None = None
fax: str | None = None
horario: str | None = None
pagina: str | None = None
correo: str | None = None
medio_publicidad: str | None = None
nacionalidad: str | None = None
logo: str | None = None
class ClientCreate(ClientBase):
pass
class ClientUpdate(ClientBase):
pass
class Client(ClientBase):
id: int
class Config:
from_attributes = True

View File

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = None
# 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")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,40 +1,42 @@
import asyncio
import sys
import os
import json
# Asegurar que el directorio raíz esté en PYTHONPATH
# Add parent directory to path so we can import 'app'
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from app.core.security import SecurityUtils
from app.core.config import settings
from app.models.user import User
from app.models.tenant import Tenant
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from sqlalchemy.future import select
import asyncio
from app.models.tenant import Tenant
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.core.security import SecurityUtils
# Generar un token predeterminado para el usuario admin
def generate_default_token():
async def async_task():
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).filter(User.email == "admin@aduanasoft.com"))
user = result.scalar_one_or_none()
if user:
print(f"User found: {user.email}")
token = SecurityUtils.create_access_token({"sub": str(user.id), "email": user.email})
print(f"Generated token: {token}")
async def fix_password():
async with AsyncSessionLocal() as session:
# Find the admin user
email = "admin@aduanasoft.com"
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user:
print(f"User {email} found.")
# Reset password to 'admin123'
new_password = "admin123"
hashed = SecurityUtils.hash_password(new_password)
user.password_hash = hashed
try:
await session.commit()
print(f"Password for {email} updated successfully!")
print(f"New password is: {new_password}")
except Exception as e:
await session.rollback()
print(f"Error updating password: {e}")
else:
print(f"User {email} not found!")
# Validate the token
payload = SecurityUtils.verify_token(token)
if payload:
print("Token is valid. Payload:")
print(json.dumps(payload, indent=4))
else:
print("Token validation failed.")
else:
print("User not found. Token not generated.")
asyncio.run(async_task())
generate_default_token()
print("Token generation process completed.")
if __name__ == "__main__":
asyncio.run(fix_password())

68
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,68 @@
from logging.config import fileConfig
import os
from sqlalchemy import create_engine, pool
from sqlalchemy.engine import engine_from_config
from alembic import context
# Import Base and all models
from app.core.database import Base
from app.models import tenant # Import all models explicitly
# Alembic Config object
config = context.config
# Logging configuration
if config.config_file_name:
fileConfig(config.config_file_name)
# Get DATABASE_URL and convert to synchronous
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise RuntimeError("DATABASE_URL environment variable is not set")
SYNC_DATABASE_URL = DATABASE_URL.replace("+asyncpg", "")
# Metadata for autogenerate
target_metadata = Base.metadata
def run_migrations_offline():
"""
Run migrations in 'offline' mode.
"""
context.configure(
url=SYNC_DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""
Run migrations in 'online' mode.
"""
# Fetch the URL from Alembic configuration
alembic_config = config.get_section(config.config_ini_section)
alembic_config["sqlalchemy.url"] = SYNC_DATABASE_URL
connectable = engine_from_config(
alembic_config,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,464 @@
"""Create all tables
Revision ID: 35742cfbb850
Revises:
Create Date: 2026-02-05 19:37:58.771067
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '35742cfbb850'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_audit_logs_action', table_name='audit_logs')
op.drop_index('idx_audit_logs_correlation_id', table_name='audit_logs')
op.drop_index('idx_audit_logs_created_at', table_name='audit_logs')
op.drop_index('idx_audit_logs_resource', table_name='audit_logs')
op.drop_index('idx_audit_logs_tenant_id', table_name='audit_logs')
op.drop_index('idx_audit_logs_user_id', table_name='audit_logs')
op.drop_table('audit_logs')
op.drop_index('idx_tickets_assigned_to', table_name='tickets')
op.drop_index('idx_tickets_category', table_name='tickets')
op.drop_index('idx_tickets_created_at', table_name='tickets')
op.drop_index('idx_tickets_created_by', table_name='tickets')
op.drop_index('idx_tickets_number', table_name='tickets')
op.drop_index('idx_tickets_priority', table_name='tickets')
op.drop_index('idx_tickets_sla_resolution', table_name='tickets')
op.drop_index('idx_tickets_sla_response', table_name='tickets')
op.drop_index('idx_tickets_status', table_name='tickets')
op.drop_index('idx_tickets_tenant_id', table_name='tickets')
op.drop_table('tickets')
op.drop_index('idx_refresh_tokens_expires', table_name='refresh_tokens')
op.drop_index('idx_refresh_tokens_hash', table_name='refresh_tokens')
op.drop_index('idx_refresh_tokens_user_id', table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_index('idx_notification_logs_created_at', table_name='notification_logs')
op.drop_index('idx_notification_logs_recipient', table_name='notification_logs')
op.drop_index('idx_notification_logs_status', table_name='notification_logs')
op.drop_index('idx_notification_logs_tenant_id', table_name='notification_logs')
op.drop_index('idx_notification_logs_ticket_id', table_name='notification_logs')
op.drop_table('notification_logs')
op.drop_table('affected_systems')
op.drop_index('idx_ticket_comments_author_id', table_name='ticket_comments')
op.drop_index('idx_ticket_comments_created_at', table_name='ticket_comments')
op.drop_index('idx_ticket_comments_ticket_id', table_name='ticket_comments')
op.drop_table('ticket_comments')
op.drop_index('idx_clients_name', table_name='clients')
op.drop_index('idx_clients_properties', table_name='clients', postgresql_using='gin')
op.drop_index('idx_clients_tax_id', table_name='clients')
op.drop_index('idx_clients_tenant_id', table_name='clients')
op.drop_table('clients')
op.drop_table('categories')
op.drop_index('idx_users_active', table_name='users')
op.drop_index('idx_users_email', table_name='users')
op.drop_index('idx_users_role', table_name='users')
op.drop_index('idx_users_tenant_email', table_name='users')
op.drop_index('idx_users_tenant_id', table_name='users')
op.drop_table('users')
op.drop_table('systems')
op.drop_table('ticket_categories')
op.drop_index('idx_ticket_status_history_changed_by', table_name='ticket_status_history')
op.drop_index('idx_ticket_status_history_created_at', table_name='ticket_status_history')
op.drop_index('idx_ticket_status_history_ticket_id', table_name='ticket_status_history')
op.drop_table('ticket_status_history')
op.drop_index('idx_ticket_attachments_comment_id', table_name='ticket_attachments')
op.drop_index('idx_ticket_attachments_ticket_id', table_name='ticket_attachments')
op.drop_index('idx_ticket_attachments_uploaded_by', table_name='ticket_attachments')
op.drop_table('ticket_attachments')
op.drop_index('idx_email_templates_tenant_id', table_name='email_templates')
op.drop_index('idx_email_templates_type', table_name='email_templates')
op.drop_table('email_templates')
op.alter_column('tenants', 'timezone',
existing_type=sa.VARCHAR(length=50),
nullable=False,
existing_server_default=sa.text("'UTC'::character varying"))
op.alter_column('tenants', 'locale',
existing_type=sa.VARCHAR(length=10),
nullable=False,
existing_server_default=sa.text("'es-ES'::character varying"))
op.alter_column('tenants', 'max_users',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('50'))
op.alter_column('tenants', 'max_storage_mb',
existing_type=sa.INTEGER(),
nullable=False,
existing_server_default=sa.text('1024'))
op.alter_column('tenants', 'allowed_file_types',
existing_type=postgresql.ARRAY(sa.TEXT()),
type_=sa.ARRAY(sa.String()),
nullable=False,
existing_server_default=sa.text("ARRAY['pdf'::text, 'jpg'::text, 'jpeg'::text, 'png'::text, 'doc'::text, 'docx'::text, 'xls'::text, 'xlsx'::text, 'txt'::text]"))
op.alter_column('tenants', 'status',
existing_type=sa.VARCHAR(length=20),
nullable=False,
existing_server_default=sa.text("'active'::character varying"))
op.alter_column('tenants', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.alter_column('tenants', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=False,
existing_server_default=sa.text('now()'))
op.drop_index('idx_tenants_domain', table_name='tenants')
op.drop_index('idx_tenants_slug', table_name='tenants')
op.drop_index('idx_tenants_status', table_name='tenants')
op.drop_table_comment(
'tenants',
existing_comment='Organizaciones cliente en el sistema multi-tenant',
schema=None
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table_comment(
'tenants',
'Organizaciones cliente en el sistema multi-tenant',
existing_comment=None,
schema=None
)
op.create_index('idx_tenants_status', 'tenants', ['status'], unique=False)
op.create_index('idx_tenants_slug', 'tenants', ['slug'], unique=False)
op.create_index('idx_tenants_domain', 'tenants', ['domain'], unique=False)
op.alter_column('tenants', 'updated_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('tenants', 'created_at',
existing_type=postgresql.TIMESTAMP(timezone=True),
nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('tenants', 'status',
existing_type=sa.VARCHAR(length=20),
nullable=True,
existing_server_default=sa.text("'active'::character varying"))
op.alter_column('tenants', 'allowed_file_types',
existing_type=sa.ARRAY(sa.String()),
type_=postgresql.ARRAY(sa.TEXT()),
nullable=True,
existing_server_default=sa.text("ARRAY['pdf'::text, 'jpg'::text, 'jpeg'::text, 'png'::text, 'doc'::text, 'docx'::text, 'xls'::text, 'xlsx'::text, 'txt'::text]"))
op.alter_column('tenants', 'max_storage_mb',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('1024'))
op.alter_column('tenants', 'max_users',
existing_type=sa.INTEGER(),
nullable=True,
existing_server_default=sa.text('50'))
op.alter_column('tenants', 'locale',
existing_type=sa.VARCHAR(length=10),
nullable=True,
existing_server_default=sa.text("'es-ES'::character varying"))
op.alter_column('tenants', 'timezone',
existing_type=sa.VARCHAR(length=50),
nullable=True,
existing_server_default=sa.text("'UTC'::character varying"))
op.create_table('email_templates',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('subject_template', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('body_template', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('available_variables', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='email_templates_tenant_id_fkey'),
sa.PrimaryKeyConstraint('id', name='email_templates_pkey')
)
op.create_index('idx_email_templates_type', 'email_templates', ['template_type'], unique=False)
op.create_index('idx_email_templates_tenant_id', 'email_templates', ['tenant_id'], unique=False)
op.create_table('ticket_attachments',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('comment_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('uploaded_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('filename', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('original_filename', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('mime_type', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('file_size', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('file_path', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
sa.Column('md5_hash', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
sa.Column('sha256_hash', sa.VARCHAR(length=64), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['comment_id'], ['ticket_comments.id'], name='ticket_attachments_comment_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_attachments_ticket_id_fkey', ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], name='ticket_attachments_uploaded_by_fkey'),
sa.PrimaryKeyConstraint('id', name='ticket_attachments_pkey'),
comment='Archivos adjuntos en tickets'
)
op.create_index('idx_ticket_attachments_uploaded_by', 'ticket_attachments', ['uploaded_by'], unique=False)
op.create_index('idx_ticket_attachments_ticket_id', 'ticket_attachments', ['ticket_id'], unique=False)
op.create_index('idx_ticket_attachments_comment_id', 'ticket_attachments', ['comment_id'], unique=False)
op.create_table('ticket_status_history',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('changed_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('old_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=True),
sa.Column('new_status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), autoincrement=False, nullable=False),
sa.Column('old_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('new_assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('comment', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['changed_by'], ['users.id'], name='ticket_status_history_changed_by_fkey'),
sa.ForeignKeyConstraint(['new_assigned_to'], ['users.id'], name='ticket_status_history_new_assigned_to_fkey'),
sa.ForeignKeyConstraint(['old_assigned_to'], ['users.id'], name='ticket_status_history_old_assigned_to_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_status_history_ticket_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='ticket_status_history_pkey')
)
op.create_index('idx_ticket_status_history_ticket_id', 'ticket_status_history', ['ticket_id'], unique=False)
op.create_index('idx_ticket_status_history_created_at', 'ticket_status_history', ['created_at'], unique=False)
op.create_index('idx_ticket_status_history_changed_by', 'ticket_status_history', ['changed_by'], unique=False)
op.create_table('ticket_categories',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('color', sa.VARCHAR(length=7), autoincrement=False, nullable=True),
sa.Column('sla_response_hours', sa.INTEGER(), server_default=sa.text('24'), autoincrement=False, nullable=True),
sa.Column('sla_resolution_hours', sa.INTEGER(), server_default=sa.text('72'), autoincrement=False, nullable=True),
sa.Column('auto_assign_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['auto_assign_to'], ['users.id'], name='ticket_categories_auto_assign_to_fkey'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='ticket_categories_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='ticket_categories_pkey'),
sa.UniqueConstraint('tenant_id', 'name', name='ticket_categories_tenant_id_name_key'),
postgresql_ignore_search_path=False
)
op.create_table('systems',
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name='systems_pkey')
)
op.create_table('users',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
sa.Column('first_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('last_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('avatar_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('password_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('role', postgresql.ENUM('ADMIN', 'SUPPORT_MANAGER', 'AGENT', 'AUDITOR', 'CLIENT_ADMIN', 'CLIENT_USER', name='user_role_enum'), autoincrement=False, nullable=False),
sa.Column('totp_secret', sa.VARCHAR(length=32), autoincrement=False, nullable=True),
sa.Column('totp_enabled', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.Column('backup_codes', postgresql.ARRAY(sa.TEXT()), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('email_verified', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.Column('last_login', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('last_activity', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('language', sa.VARCHAR(length=10), server_default=sa.text("'es'::character varying"), autoincrement=False, nullable=True),
sa.Column('timezone', sa.VARCHAR(length=50), server_default=sa.text("'UTC'::character varying"), autoincrement=False, nullable=True),
sa.Column('notifications_email', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='users_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='users_pkey'),
sa.UniqueConstraint('tenant_id', 'email', name='users_tenant_id_email_key'),
comment='Usuarios del sistema (internos y clientes)',
postgresql_ignore_search_path=False
)
op.create_index('idx_users_tenant_id', 'users', ['tenant_id'], unique=False)
op.create_index('idx_users_tenant_email', 'users', ['tenant_id', 'email'], unique=False)
op.create_index('idx_users_role', 'users', ['role'], unique=False)
op.create_index('idx_users_email', 'users', ['email'], unique=False)
op.create_index('idx_users_active', 'users', ['is_active'], unique=False)
op.create_table('categories',
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='categories_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='categories_pkey')
)
op.create_table('clients',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('code', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('name', sa.VARCHAR(length=200), autoincrement=False, nullable=False),
sa.Column('tax_id', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('client_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('account_manager', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('address_street', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('address_ext_num', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('neighborhood', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('zip_code', sa.VARCHAR(length=10), autoincrement=False, nullable=True),
sa.Column('city', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('state', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('country', sa.VARCHAR(length=100), server_default=sa.text("'Mexico'::character varying"), autoincrement=False, nullable=True),
sa.Column('phone_primary', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('phone_secondary', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('fax', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('email', sa.VARCHAR(length=320), autoincrement=False, nullable=True),
sa.Column('website', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('status', postgresql.ENUM('prospect', 'active', 'suspended', 'cancelled', name='client_status_enum'), server_default=sa.text("'prospect'::client_status_enum"), autoincrement=False, nullable=True),
sa.Column('logo_url', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('properties', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='clients_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='clients_pkey'),
sa.UniqueConstraint('tenant_id', 'code', name='clients_tenant_id_code_key'),
sa.UniqueConstraint('tenant_id', 'tax_id', name='clients_tenant_id_tax_id_key')
)
op.create_index('idx_clients_tenant_id', 'clients', ['tenant_id'], unique=False)
op.create_index('idx_clients_tax_id', 'clients', ['tax_id'], unique=False)
op.create_index('idx_clients_properties', 'clients', ['properties'], unique=False, postgresql_using='gin')
op.create_index('idx_clients_name', 'clients', ['name'], unique=False)
op.create_table('ticket_comments',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('author_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('is_internal', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], name='ticket_comments_author_id_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='ticket_comments_ticket_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='ticket_comments_pkey'),
comment='Comentarios en tickets'
)
op.create_index('idx_ticket_comments_ticket_id', 'ticket_comments', ['ticket_id'], unique=False)
op.create_index('idx_ticket_comments_created_at', 'ticket_comments', ['created_at'], unique=False)
op.create_index('idx_ticket_comments_author_id', 'ticket_comments', ['author_id'], unique=False)
op.create_table('affected_systems',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='affected_systems_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='affected_systems_pkey'),
sa.UniqueConstraint('tenant_id', 'name', name='affected_systems_tenant_id_name_key'),
postgresql_ignore_search_path=False
)
op.create_table('notification_logs',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('recipient_email', sa.VARCHAR(length=320), autoincrement=False, nullable=False),
sa.Column('subject', sa.VARCHAR(length=500), autoincrement=False, nullable=False),
sa.Column('template_type', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('ticket_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('status', sa.VARCHAR(length=20), server_default=sa.text("'pending'::character varying"), autoincrement=False, nullable=True),
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('provider', sa.VARCHAR(length=50), autoincrement=False, nullable=True),
sa.Column('external_id', sa.VARCHAR(length=255), autoincrement=False, nullable=True),
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.CheckConstraint("status::text = ANY (ARRAY['pending'::character varying, 'sent'::character varying, 'failed'::character varying, 'bounced'::character varying]::text[])", name='notification_logs_status_check'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='notification_logs_tenant_id_fkey'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], name='notification_logs_ticket_id_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='notification_logs_user_id_fkey'),
sa.PrimaryKeyConstraint('id', name='notification_logs_pkey')
)
op.create_index('idx_notification_logs_ticket_id', 'notification_logs', ['ticket_id'], unique=False)
op.create_index('idx_notification_logs_tenant_id', 'notification_logs', ['tenant_id'], unique=False)
op.create_index('idx_notification_logs_status', 'notification_logs', ['status'], unique=False)
op.create_index('idx_notification_logs_recipient', 'notification_logs', ['recipient_email'], unique=False)
op.create_index('idx_notification_logs_created_at', 'notification_logs', ['created_at'], unique=False)
op.create_table('refresh_tokens',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('token_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('device_info', sa.VARCHAR(length=500), autoincrement=False, nullable=True),
sa.Column('ip_address', postgresql.INET(), autoincrement=False, nullable=True),
sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
sa.Column('revoked', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='refresh_tokens_user_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='refresh_tokens_pkey')
)
op.create_index('idx_refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
op.create_index('idx_refresh_tokens_hash', 'refresh_tokens', ['token_hash'], unique=False)
op.create_index('idx_refresh_tokens_expires', 'refresh_tokens', ['expires_at'], unique=False)
op.create_table('tickets',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('ticket_number', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('subject', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('description', sa.TEXT(), autoincrement=False, nullable=False),
sa.Column('category_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('priority', postgresql.ENUM('LOW', 'MEDIUM', 'HIGH', 'URGENT', name='ticket_priority_enum'), server_default=sa.text("'MEDIUM'::ticket_priority_enum"), autoincrement=False, nullable=True),
sa.Column('affected_system_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('created_by', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('assigned_to', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('status', postgresql.ENUM('NEW', 'TRIAGE', 'IN_PROGRESS', 'WAITING_CUSTOMER', 'RESOLVED', 'CLOSED', 'REOPENED', name='ticket_status_enum'), server_default=sa.text("'NEW'::ticket_status_enum"), autoincrement=False, nullable=True),
sa.Column('sla_response_due', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('sla_resolution_due', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('first_response_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('resolved_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('rating', sa.INTEGER(), autoincrement=False, nullable=True),
sa.Column('rating_comment', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('rated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.CheckConstraint('rating >= 1 AND rating <= 5', name='tickets_rating_check'),
sa.ForeignKeyConstraint(['affected_system_id'], ['affected_systems.id'], name='tickets_affected_system_id_fkey'),
sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], name='tickets_assigned_to_fkey'),
sa.ForeignKeyConstraint(['category_id'], ['ticket_categories.id'], name='tickets_category_id_fkey'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], name='tickets_created_by_fkey'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='tickets_tenant_id_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name='tickets_pkey'),
sa.UniqueConstraint('tenant_id', 'ticket_number', name='tickets_tenant_id_ticket_number_key'),
comment='Tickets de soporte - core del negocio'
)
op.create_index('idx_tickets_tenant_id', 'tickets', ['tenant_id'], unique=False)
op.create_index('idx_tickets_status', 'tickets', ['status'], unique=False)
op.create_index('idx_tickets_sla_response', 'tickets', ['sla_response_due'], unique=False)
op.create_index('idx_tickets_sla_resolution', 'tickets', ['sla_resolution_due'], unique=False)
op.create_index('idx_tickets_priority', 'tickets', ['priority'], unique=False)
op.create_index('idx_tickets_number', 'tickets', ['ticket_number'], unique=False)
op.create_index('idx_tickets_created_by', 'tickets', ['created_by'], unique=False)
op.create_index('idx_tickets_created_at', 'tickets', ['created_at'], unique=False)
op.create_index('idx_tickets_category', 'tickets', ['category_id'], unique=False)
op.create_index('idx_tickets_assigned_to', 'tickets', ['assigned_to'], unique=False)
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), autoincrement=False, nullable=False),
sa.Column('tenant_id', sa.UUID(), autoincrement=False, nullable=False),
sa.Column('user_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('action', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('resource_type', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('resource_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('ip_address', postgresql.INET(), autoincrement=False, nullable=True),
sa.Column('user_agent', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('correlation_id', sa.UUID(), autoincrement=False, nullable=True),
sa.Column('old_values', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('new_values', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name='audit_logs_tenant_id_fkey'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='audit_logs_user_id_fkey'),
sa.PrimaryKeyConstraint('id', name='audit_logs_pkey'),
comment='Bitácora de acciones para auditoría y compliance'
)
op.create_index('idx_audit_logs_user_id', 'audit_logs', ['user_id'], unique=False)
op.create_index('idx_audit_logs_tenant_id', 'audit_logs', ['tenant_id'], unique=False)
op.create_index('idx_audit_logs_resource', 'audit_logs', ['resource_type', 'resource_id'], unique=False)
op.create_index('idx_audit_logs_created_at', 'audit_logs', ['created_at'], unique=False)
op.create_index('idx_audit_logs_correlation_id', 'audit_logs', ['correlation_id'], unique=False)
op.create_index('idx_audit_logs_action', 'audit_logs', ['action'], unique=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,24 @@
"""Test migration setup
Revision ID: 48c43e9204c3
Revises: 35742cfbb850
Create Date: 2026-02-05 19:39:07.431453
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '48c43e9204c3'
down_revision = '35742cfbb850'
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -15,6 +15,7 @@ pydantic-settings==2.1.0
sqlalchemy==2.0.23
alembic==1.13.0
asyncpg==0.29.0 # PostgreSQL async driver
psycopg2-binary==2.9.9 # PostgreSQL sync driver (for migrations)
# ===================================
# AUTHENTICATION & SECURITY
@@ -41,7 +42,7 @@ jinja2==3.1.2 # Email templates
# FILE HANDLING
# ===================================
python-magic==0.4.27 # MIME type detection
# pillow==10.1.0 # Image processing
pillow==10.1.0 # Image processing
# ===================================
# HTTP & REQUESTS

View File

@@ -1,18 +0,0 @@
import os
import asyncio
import asyncpg
def get_database_url():
# Replace 'postgresql+asyncpg' with 'postgresql' for asyncpg compatibility
return os.getenv("DATABASE_URL", "postgresql://servicemanager:servicemanager123@servicemanager-db:5432/servicemanager").replace("postgresql+asyncpg", "postgresql")
async def test_connection():
try:
conn = await asyncpg.connect(get_database_url())
print("Connection to the database was successful!")
await conn.close()
except Exception as e:
print(f"Failed to connect to the database: {e}")
if __name__ == "__main__":
asyncio.run(test_connection())

View File

@@ -1,71 +0,0 @@
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@pytest.fixture
def sample_client_data():
return {
"clave": "12345",
"tipo_cliente": "Regular",
"nombre": "Cliente Prueba",
"pais": "México",
"estado": "Chihuahua",
"ciudad": "Cd. Juárez",
"direccion": "Calle Falsa 123",
"numero_ext": "12",
"cp": "32000",
"colonia": "Centro",
"lada": "656",
"telefono1": "1234567890",
"telefono2": "0987654321",
"tel_directo": "1231231234",
"ext": "101",
"fax": "1231231235",
"horario": "9:00 - 18:00",
"pagina": "www.clienteprueba.com",
"correo": "cliente@prueba.com",
"medio_publicidad": "Internet",
"nacionalidad": "Mexicana",
"logo": "logo.png"
}
def test_create_client(sample_client_data):
response = client.post("/api/v1/clients", json=sample_client_data)
assert response.status_code == 200
assert response.json()["clave"] == sample_client_data["clave"]
def test_read_client(sample_client_data):
# Create a client first
create_response = client.post("/api/v1/clients", json=sample_client_data)
client_id = create_response.json()["id"]
# Read the client
response = client.get(f"/api/v1/clients/{client_id}")
assert response.status_code == 200
assert response.json()["id"] == client_id
def test_update_client(sample_client_data):
# Create a client first
create_response = client.post("/api/v1/clients", json=sample_client_data)
client_id = create_response.json()["id"]
# Update the client
updated_data = {"nombre": "Cliente Actualizado"}
response = client.put(f"/api/v1/clients/{client_id}", json=updated_data)
assert response.status_code == 200
assert response.json()["nombre"] == "Cliente Actualizado"
def test_delete_client(sample_client_data):
# Create a client first
create_response = client.post("/api/v1/clients", json=sample_client_data)
client_id = create_response.json()["id"]
# Delete the client
response = client.delete(f"/api/v1/clients/{client_id}")
assert response.status_code == 200
# Verify deletion
response = client.get(f"/api/v1/clients/{client_id}")
assert response.status_code == 404

View File

@@ -1,27 +0,0 @@
import pytest
from app.core.security import SecurityUtils
from datetime import timedelta
# Test password hashing and verification
def test_password_hashing():
plain_password = "securepassword123"
hashed_password = SecurityUtils.hash_password(plain_password)
assert SecurityUtils.verify_password(plain_password, hashed_password) == True
assert SecurityUtils.verify_password("wrongpassword", hashed_password) == False
# Test JWT token generation and validation
def test_jwt_token_generation():
payload = {"sub": "12345", "email": "test@example.com"}
token = SecurityUtils.create_access_token(payload, expires_delta=timedelta(minutes=15))
decoded_payload = SecurityUtils.verify_token(token)
assert decoded_payload is not None
assert decoded_payload["sub"] == "12345"
assert decoded_payload["email"] == "test@example.com"
# Test TOTP secret generation
def test_totp_secret_generation():
secret = SecurityUtils.generate_totp_secret()
assert len(secret) > 0
assert isinstance(secret, str)

BIN
backups Normal file

Binary file not shown.

View File

@@ -38,74 +38,6 @@ CREATE INDEX idx_tenants_slug ON tenants(slug);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_tenants_domain ON tenants(domain);
-- ===================================
-- DOMINIO: CLIENTS (Cartera de Clientes)
-- ===================================
-- Enum para los radio buttons de estatus
CREATE TYPE client_status_enum AS ENUM (
'prospect',
'active',
'suspended',
'cancelled'
);
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Identificación (Sección General)
code VARCHAR(50), -- Clave del cliente
name VARCHAR(200) NOT NULL, -- Nombre / Razón Social
tax_id VARCHAR(20), -- RFC
client_type VARCHAR(50), -- Tipo de Cliente
account_manager VARCHAR(100), -- Encargado Cliente (Texto simple por ahora)
-- Dirección (Desglosada)
address_street TEXT, -- Dirección / Calle
address_ext_num VARCHAR(20), -- Núm. Ext
neighborhood VARCHAR(100), -- Colonia
zip_code VARCHAR(10), -- CP
city VARCHAR(100), -- Ciudad
state VARCHAR(100), -- Estado
country VARCHAR(100) DEFAULT 'Mexico',
-- Contacto
phone_primary VARCHAR(20), -- Teléfono 1
phone_secondary VARCHAR(20),-- Teléfono 2
fax VARCHAR(20),
email VARCHAR(320), -- Correo
website VARCHAR(255), -- Página Web
-- Configuración y Flexibilidad
status client_status_enum DEFAULT 'prospect',
logo_url VARCHAR(500), -- Ruta al archivo PDF/Imagen del logo
-- Campo JSONB para lo que sobre (Horario, Nacionalidad, Medio Publicidad, Lada, etc.)
properties JSONB DEFAULT '{}'::jsonb,
-- Auditoría estándar
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Restricciones: No repetir RFC ni Clave dentro del mismo Tenant
UNIQUE(tenant_id, code),
UNIQUE(tenant_id, tax_id)
);
-- Índices para búsqueda rápida
CREATE INDEX idx_clients_tenant_id ON clients(tenant_id);
CREATE INDEX idx_clients_tax_id ON clients(tax_id);
CREATE INDEX idx_clients_name ON clients(name);
CREATE INDEX idx_clients_properties ON clients USING gin (properties);
-- Trigger para mantener actualizado el campo updated_at
-- (Usa la función que ya definiste al final de tu script)
CREATE TRIGGER update_clients_updated_at BEFORE UPDATE ON clients
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ===================================
-- DOMINIO: AUTH (Autenticación)
-- ===================================

View File

@@ -55,7 +55,6 @@ services:
dockerfile: ../docker/Dockerfile.backend
container_name: servicemanager-backend
restart: unless-stopped
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
env_file:
- .env
environment:
@@ -71,10 +70,8 @@ services:
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
- PYTHONPATH=/app
volumes:
- ./backend:/app
- ./tests:/app/tests
- uploads_data:/app/uploads
- logs_data:/app/logs
ports:
@@ -164,7 +161,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_APP_NAME=ServiceManager Cliente
volumes:
- ./frontend-client:/app
@@ -189,7 +186,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${ENVIRONMENT:-development}
- PUBLIC_API_URL=http://backend:8000
- PUBLIC_API_URL=${API_BASE_URL:-http://localhost:8000}
- PUBLIC_APP_NAME=ServiceManager Admin
volumes:
- ./frontend-internal:/app

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)

3946
frontend-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -59,6 +59,9 @@
<button
on:click={toggleMenu}
class="flex items-center space-x-2 text-gray-700 hover:text-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-md p-2"
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && toggleMenu()}
>
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<span class="text-primary-600 text-sm font-medium">
@@ -87,6 +90,9 @@
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
role="button"
tabindex="0"
on:keydown={(e) => e.key === 'Enter' && handleLogout()}
>
Cerrar Sesión
</button>

View File

@@ -29,13 +29,12 @@ const initialState: AppState = {
// API helper function
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authState.token}`,
'X-Tenant-ID': authState.tenantId, // Added tenant header
...options.headers
}
});
@@ -61,10 +60,8 @@ function createAppStore() {
try {
const categories = await apiCall('/categories/');
console.log('Loaded categories:', categories); // Debugging
update(state => ({ ...state, categories, isLoading: false }));
} catch (error) {
console.error('Error loading categories:', error); // Debugging
update(state => ({
...state,
isLoading: false,

View File

@@ -107,7 +107,6 @@ function createAuthStore() {
isLoading: false
});
} catch (error) {
console.error('Login error:', error); // Debugging the error
update(state => ({ ...state, isLoading: false }));
throw error;
}

View File

@@ -1,44 +0,0 @@
import { writable } from 'svelte/store';
export const clients = writable([]);
export async function fetchClients() {
const response = await fetch('/api/v1/clients');
const data = await response.json();
clients.set(data);
}
export async function createClient(client) {
const response = await fetch('/api/v1/clients', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(client),
});
if (response.ok) {
fetchClients();
}
}
export async function updateClient(clientId, client) {
const response = await fetch(`/api/v1/clients/${clientId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(client),
});
if (response.ok) {
fetchClients();
}
}
export async function deleteClient(clientId) {
const response = await fetch(`/api/v1/clients/${clientId}`, {
method: 'DELETE',
});
if (response.ok) {
fetchClients();
}
}

View File

@@ -1,7 +1,6 @@
import { writable } from 'svelte/store';
import { auth } from './auth.js';
import { get } from 'svelte/store';
import { writable, get } from 'svelte/store';
import type { Writable } from 'svelte/store';
import { auth } from './auth';
// Types
export interface Ticket {
@@ -24,12 +23,13 @@ export interface Ticket {
export interface TicketComment {
id: string;
ticket_id: string;
user_id: string;
user_name: string;
user_role: string;
author_id: string;
author_name: string;
author_role: string;
content: string;
is_internal: boolean;
created_at: string;
updated_at: string;
}
export interface TicketAttachment {
@@ -84,8 +84,29 @@ async function apiCall(endpoint: string, options: RequestInit = {}) {
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Request failed');
let errorMessage = 'Request failed';
try {
const error = await response.json();
console.error('❌ API Error Response:', error);
// Manejar diferentes formatos de error de FastAPI
if (error.detail) {
if (Array.isArray(error.detail)) {
// Errores de validación de FastAPI
errorMessage = error.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ');
} else if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else {
errorMessage = JSON.stringify(error.detail);
}
} else {
errorMessage = JSON.stringify(error);
}
} catch (e) {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
@@ -100,13 +121,13 @@ function createTicketsStore() {
// Load user's tickets
loadTickets: async () => {
update(state => ({ ...state, isLoading: true, error: null }));
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try {
const tickets = await apiCall('/tickets/');
update(state => ({ ...state, tickets, isLoading: false }));
update((state: TicketsState) => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
@@ -116,7 +137,7 @@ function createTicketsStore() {
// Load specific ticket with details
loadTicket: async (ticketId: string) => {
update(state => ({ ...state, isLoading: true, error: null }));
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try {
const [ticket, comments, attachments] = await Promise.all([
@@ -125,7 +146,7 @@ function createTicketsStore() {
apiCall(`/tickets/${ticketId}/attachments`)
]);
update(state => ({
update((state: TicketsState) => ({
...state,
currentTicket: ticket,
comments,
@@ -133,25 +154,48 @@ function createTicketsStore() {
isLoading: false
}));
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
}));
}
},
// Reload only comments silently (for polling)
reloadComments: async (ticketId: string) => {
try {
const comments = await apiCall(`/tickets/${ticketId}/comments`);
update((state: TicketsState) => ({ ...state, comments }));
} catch (error) {
// Silent fail - no mostrar error en polling
console.error('Error reloading comments:', error);
}
},
// Create new ticket
createTicket: async (ticket: CreateTicketRequest) => {
update(state => ({ ...state, isLoading: true, error: null }));
update((state: TicketsState) => ({ ...state, isLoading: true, error: null }));
try {
// Mapear campos del frontend al formato del backend
const ticketData = {
subject: ticket.title, // ← Backend espera "subject" no "title"
description: ticket.description,
category_id: ticket.category_id,
priority: ticket.priority,
system_id: null // ← Opcional
};
console.log('Sending ticket data:', ticketData);
const newTicket = await apiCall('/tickets/', {
method: 'POST',
body: JSON.stringify(ticket)
body: JSON.stringify(ticketData)
});
update(state => ({
update((state: TicketsState) => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
@@ -159,7 +203,8 @@ function createTicketsStore() {
return newTicket;
} catch (error) {
update(state => ({
console.error('Create ticket error:', error);
update((state: TicketsState) => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
@@ -176,14 +221,14 @@ function createTicketsStore() {
body: JSON.stringify({ content })
});
update(state => ({
update((state: TicketsState) => ({
...state,
comments: [...state.comments, comment]
}));
return comment;
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
@@ -213,14 +258,14 @@ function createTicketsStore() {
const attachment = await response.json();
update(state => ({
update((state: TicketsState) => ({
...state,
attachments: [...state.attachments, attachment]
}));
return attachment;
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
@@ -236,15 +281,15 @@ function createTicketsStore() {
body: JSON.stringify({ resolution })
});
update(state => ({
update((state: TicketsState) => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
tickets: state.tickets.map((t: Ticket) => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update(state => ({
update((state: TicketsState) => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
@@ -254,12 +299,12 @@ function createTicketsStore() {
// Clear error
clearError: () => {
update(state => ({ ...state, error: null }));
update((state: TicketsState) => ({ ...state, error: null }));
},
// Clear current ticket
clearCurrentTicket: () => {
update(state => ({
update((state: TicketsState) => ({
...state,
currentTicket: null,
comments: [],

View File

@@ -1,53 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clients } from '$lib/stores/clients';
import { toast } from '$lib/stores/toast';
let clientList = [];
let isLoading = true;
onMount(async () => {
try {
clientList = await clients.loadClients();
} catch (error) {
toast.error('Error al cargar los clientes');
} finally {
isLoading = false;
}
});
</script>
<svelte:head>
<title>Clientes - ServiceManager</title>
</svelte:head>
<div class="container mx-auto py-8">
<h1 class="text-2xl font-bold mb-4">Listado de Clientes</h1>
{#if isLoading}
<p>Cargando clientes...</p>
{:else if clientList.length === 0}
<p>No hay clientes registrados.</p>
{:else}
<table class="table-auto w-full border-collapse border border-gray-300">
<thead>
<tr>
<th class="border border-gray-300 px-4 py-2">Nombre</th>
<th class="border border-gray-300 px-4 py-2">Email</th>
<th class="border border-gray-300 px-4 py-2">Acciones</th>
</tr>
</thead>
<tbody>
{#each clientList as client}
<tr>
<td class="border border-gray-300 px-4 py-2">{client.name}</td>
<td class="border border-gray-300 px-4 py-2">{client.email}</td>
<td class="border border-gray-300 px-4 py-2">
<a href={`/clients/${client.id}/edit`} class="text-blue-500 hover:underline">Editar</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>

View File

@@ -1,83 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clients } from '$lib/stores/clients';
import { toast } from '$lib/stores/toast';
import { goto, page } from '$app/navigation';
let clientId = $page.params.id;
let name = '';
let email = '';
let isSubmitting = false;
let errors: Record<string, string> = {};
onMount(async () => {
try {
const client = await clients.loadClient(clientId);
name = client.name;
email = client.email;
} catch (error) {
toast.error('Error al cargar el cliente');
goto('/clients');
}
});
function validateForm() {
errors = {};
if (!name.trim()) {
errors.name = 'El nombre es requerido';
}
if (!email.trim()) {
errors.email = 'El email es requerido';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
isSubmitting = true;
try {
await clients.updateClient(clientId, { name, email });
toast.success('Cliente actualizado exitosamente');
goto('/clients');
} catch (error) {
toast.error('Error al actualizar el cliente');
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Editar Cliente - ServiceManager</title>
</svelte:head>
<div class="container mx-auto py-8">
<h1 class="text-2xl font-bold mb-4">Editar Cliente</h1>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input id="name" type="text" bind:value={name} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.name}
<p class="text-red-500 text-sm">{errors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input id="email" type="email" bind:value={email} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.email}
<p class="text-red-500 text-sm">{errors.email}</p>
{/if}
</div>
<button type="submit" class="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : 'Guardar Cambios'}
</button>
</form>
</div>

View File

@@ -1,71 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { clients } from '$lib/stores/clients';
import { toast } from '$lib/stores/toast';
import { goto } from '$app/navigation';
let name = '';
let email = '';
let isSubmitting = false;
let errors: Record<string, string> = {};
function validateForm() {
errors = {};
if (!name.trim()) {
errors.name = 'El nombre es requerido';
}
if (!email.trim()) {
errors.email = 'El email es requerido';
}
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
isSubmitting = true;
try {
await clients.createClient({ name, email });
toast.success('Cliente creado exitosamente');
goto('/clients');
} catch (error) {
toast.error('Error al crear el cliente');
} finally {
isSubmitting = false;
}
}
</script>
<svelte:head>
<title>Crear Cliente - ServiceManager</title>
</svelte:head>
<div class="container mx-auto py-8">
<h1 class="text-2xl font-bold mb-4">Crear Nuevo Cliente</h1>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Nombre</label>
<input id="name" type="text" bind:value={name} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.name}
<p class="text-red-500 text-sm">{errors.name}</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input id="email" type="email" bind:value={email} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm" />
{#if errors.email}
<p class="text-red-500 text-sm">{errors.email}</p>
{/if}
</div>
<button type="submit" class="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Creando...' : 'Crear Cliente'}
</button>
</form>
</div>

View File

@@ -14,7 +14,17 @@
let closeResolution = '';
let fileInput: HTMLInputElement;
let isUploading = false;
let pollingInterval: any = null;
async function loadComments() {
try {
await tickets.reloadComments(ticketId);
} catch (error) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', error);
}
}
onMount(() => {
// Redirect if not authenticated
if (!$auth.isAuthenticated) {
@@ -25,8 +35,20 @@
ticketId = $page.params.id;
if (ticketId) {
tickets.loadTicket(ticketId);
// Polling cada 3 segundos
pollingInterval = setInterval(() => {
loadComments();
}, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
});
};
});
// Format date
function formatDate(dateString: string): string {
@@ -287,16 +309,17 @@
{:else}
<div class="space-y-4">
{#each $tickets.comments as comment}
{console.log('Comment:', comment)}
<div class="flex space-x-3">
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-primary-600 text-xs font-medium">
{comment.user_name.split(' ').map(n => n[0]).join('')}
{comment.author_name ? comment.author_name.split(' ').map(n => n[0]).join('') : '??'}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900">
{comment.user_name}
{comment.author_name}
</span>
<span class="text-xs text-gray-500">
{formatDate(comment.created_at)}

View File

@@ -0,0 +1,28 @@
// vite.config.js
import { sveltekit } from "file:///app/node_modules/@sveltejs/kit/src/exports/vite/index.js";
import { defineConfig } from "file:///app/node_modules/vite/dist/node/index.js";
var vite_config_default = defineConfig({
plugins: [sveltekit()],
server: {
port: 3e3,
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://servicemanager-backend:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "")
}
}
},
preview: {
port: 3e3,
host: "0.0.0.0"
},
build: {
target: "esnext"
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvYXBwXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvYXBwL3ZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9hcHAvdml0ZS5jb25maWcuanNcIjtpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xyXG5pbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJztcclxuXHJcbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XHJcblx0cGx1Z2luczogW3N2ZWx0ZWtpdCgpXSxcclxuXHRzZXJ2ZXI6IHtcclxuXHRcdHBvcnQ6IDMwMDAsXHJcblx0XHRob3N0OiAnMC4wLjAuMCcsXHJcblx0XHRwcm94eToge1xyXG5cdFx0XHQnL2FwaSc6IHtcclxuXHRcdFx0XHR0YXJnZXQ6ICdodHRwOi8vc2VydmljZW1hbmFnZXItYmFja2VuZDo4MDAwJyxcclxuXHRcdFx0XHRjaGFuZ2VPcmlnaW46IHRydWUsXHJcblx0XHRcdFx0cmV3cml0ZTogKHBhdGgpID0+IHBhdGgucmVwbGFjZSgvXlxcL2FwaS8sICcnKVxyXG5cdFx0XHR9XHJcblx0XHR9XHJcblx0fSxcclxuXHRwcmV2aWV3OiB7XHJcblx0XHRwb3J0OiAzMDAwLFxyXG5cdFx0aG9zdDogJzAuMC4wLjAnXHJcblx0fSxcclxuXHRidWlsZDoge1xyXG5cdFx0dGFyZ2V0OiAnZXNuZXh0J1xyXG5cdH1cclxufSk7Il0sCiAgIm1hcHBpbmdzIjogIjtBQUE4TCxTQUFTLGlCQUFpQjtBQUN4TixTQUFTLG9CQUFvQjtBQUU3QixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMzQixTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ1AsTUFBTTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sT0FBTztBQUFBLE1BQ04sUUFBUTtBQUFBLFFBQ1AsUUFBUTtBQUFBLFFBQ1IsY0FBYztBQUFBLFFBQ2QsU0FBUyxDQUFDLFNBQVMsS0FBSyxRQUFRLFVBQVUsRUFBRTtBQUFBLE1BQzdDO0FBQUEsSUFDRDtBQUFBLEVBQ0Q7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNSLE1BQU07QUFBQSxJQUNOLE1BQU07QUFBQSxFQUNQO0FBQUEsRUFDQSxPQUFPO0FBQUEsSUFDTixRQUFRO0FBQUEsRUFDVDtBQUNELENBQUM7IiwKICAibmFtZXMiOiBbXQp9Cg==

5036
frontend-internal/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,45 +0,0 @@
import { writable } from 'svelte/store';
export const api = {
async getClients() {
const response = await fetch('/api/v1/clients');
if (!response.ok) {
throw new Error('Error fetching clients');
}
return await response.json();
},
async createClient(client) {
const response = await fetch('/api/v1/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client),
});
if (!response.ok) {
throw new Error('Error creating client');
}
return await response.json();
},
async updateClient(clientId, client) {
const response = await fetch(`/api/v1/clients/${clientId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client),
});
if (!response.ok) {
throw new Error('Error updating client');
}
return await response.json();
},
async deleteClient(clientId) {
const response = await fetch(`/api/v1/clients/${clientId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Error deleting client');
}
return await response.json();
},
};

View File

@@ -43,11 +43,15 @@
async function handleSubmit() {
try {
if (editingTenant) {
await api.put(`/tenants/${editingTenant.id}`, formData);
toast.success('Cliente actualizado');
// Asegurarse de enviar el campo "status" correctamente
const updatedData = { ...formData, status: formData.is_active ? 'active' : 'inactive' };
await api.put(`/tenants/${editingTenant.id}`, updatedData);
toast.success(`Cliente ${formData.is_active ? 'activado' : 'desactivado'} correctamente`);
} else {
await api.post('/tenants/', formData);
toast.success('Cliente creado');
// Asegurarse de enviar el campo "status" al crear un cliente
const newData = { ...formData, status: formData.is_active ? 'active' : 'inactive' };
await api.post('/tenants/', newData);
toast.success('Cliente creado correctamente');
}
showModal = false;
loadTenants();
@@ -152,6 +156,11 @@
<button type="button" on:click={() => showModal = false} class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm">
Cancelar
</button>
{#if editingTenant}
<button type="button" on:click={async () => { await api.delete(`/tenants/${editingTenant.id}`); toast.success('Cliente eliminado'); showModal = false; loadTenants(); }} class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:col-start-1 sm:text-sm">
Eliminar
</button>
{/if}
</div>
</form>
</Modal>

View File

@@ -0,0 +1,561 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
import Modal from '$lib/components/Modal.svelte';
let tickets = [];
let categories = [];
let systems = [];
let users = [];
let isLoading = false;
let showModal = false;
let showEditModal = false;
let showDeleteModal = false;
let selectedTicket = null;
// Filtros
let filterStatus = '';
let filterPriority = '';
// Form para editar
let editFormData = {
status: '',
priority: '',
assigned_to: ''
};
let formData = {
subject: '',
description: '',
category_id: '',
affected_system_id: '',
priority: 'MEDIUM'
};
const STATUSES = [
{ value: 'NEW', label: 'Nuevo', color: 'blue' },
{ value: 'IN_PROGRESS', label: 'En Progreso', color: 'indigo' },
{ value: 'WAITING_CUSTOMER', label: 'Esperando Cliente', color: 'orange' },
{ value: 'RESOLVED', label: 'Resuelto', color: 'green' },
{ value: 'CLOSED', label: 'Cerrado', color: 'gray' },
{ value: 'REOPENED', label: 'Reabierto', color: 'red' }
];
const PRIORITIES = [
{ value: 'LOW', label: 'Baja', color: 'gray' },
{ value: 'MEDIUM', label: 'Media', color: 'blue' },
{ value: 'HIGH', label: 'Alta', color: 'orange' },
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
// Ajustar la función loadData para asegurar que los filtros se envíen correctamente
async function loadData() {
isLoading = true;
try {
const [ticketsData, categoriesData, systemsData, usersData] = await Promise.all([
api.get('/tickets/', {
params: {
status: filterStatus || undefined,
priority: filterPriority || undefined
}
}),
api.get('/categories/'),
api.get('/systems/'),
api.get('/users/')
]);
tickets = ticketsData;
categories = categoriesData;
systems = systemsData;
users = usersData;
} catch (e) {
toast.error('Error cargando datos: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
// Asegurar que los filtros se apliquen al cambiar su valor
function applyFilters() {
loadData();
}
function openCreateModal() {
selectedTicket = null;
formData = {
subject: '',
description: '',
category_id: '',
affected_system_id: '',
priority: 'MEDIUM'
};
showModal = true;
}
function viewTicket(ticket) {
// Navegar a la página de detalle del ticket
goto(`/tickets/${ticket.id}`);
}
function openEditModal(ticket) {
selectedTicket = ticket;
editFormData = {
status: ticket.status,
priority: ticket.priority,
assigned_to: ticket.assigned_to || ''
};
showEditModal = true;
}
function openDeleteModal(ticket) {
selectedTicket = ticket;
showDeleteModal = true;
}
async function handleDelete() {
if (!selectedTicket) return;
try {
await api.delete(`/tickets/${selectedTicket.id}`);
toast.success('Ticket eliminado exitosamente');
showDeleteModal = false;
selectedTicket = null;
loadData();
} catch (e) {
toast.error(e.message || 'Error eliminando ticket');
}
}
async function handleUpdate() {
try {
const payload = {};
if (editFormData.status !== selectedTicket.status) {
payload.status = editFormData.status;
}
if (editFormData.priority !== selectedTicket.priority) {
payload.priority = editFormData.priority;
}
if (editFormData.assigned_to !== selectedTicket.assigned_to) {
payload.assigned_to = editFormData.assigned_to || null;
}
if (Object.keys(payload).length === 0) {
toast.info('No hay cambios para guardar');
showEditModal = false;
return;
}
await api.patch(`/tickets/${selectedTicket.id}`, payload);
toast.success('Ticket actualizado exitosamente');
showEditModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error actualizando ticket');
}
}
async function handleSubmit() {
try {
const payload = {
subject: formData.subject,
description: formData.description,
category_id: formData.category_id || null,
affected_system_id: formData.affected_system_id || null,
priority: formData.priority
};
await api.post('/tickets/', payload);
toast.success('Ticket creado exitosamente');
showModal = false;
loadData();
} catch (e) {
toast.error(e.message || 'Error creando ticket');
}
}
function getStatusBadge(status) {
const statusObj = STATUSES.find(s => s.value === status);
return statusObj || { label: status, color: 'gray' };
}
function getPriorityBadge(priority) {
const priorityObj = PRIORITIES.find(p => p.value === priority);
return priorityObj || { label: priority, color: 'gray' };
}
function getCategoryName(id) {
if (!id) return '-';
const cat = categories.find(c => c.id === id);
return cat ? cat.name : '-';
}
function getSystemName(id) {
if (!id) return '-';
const sys = systems.find(s => s.id === id);
return sys ? sys.name : '-';
}
function getUserName(id) {
if (!id) return '-';
const user = users.find(u => u.id === id);
return user ? `${user.first_name} ${user.last_name}` : '-';
}
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
onMount(loadData);
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Tickets de Soporte</h1>
<p class="mt-2 text-sm text-gray-700">Gestión de tickets del sistema de mesa de ayuda.</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<button
type="button"
on:click={openCreateModal}
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 sm:w-auto"
>
Nuevo Ticket
</button>
</div>
</div>
<!-- Filtros -->
<div class="mt-6 bg-white shadow sm:rounded-lg p-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label for="filterStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<select
id="filterStatus"
bind:value={filterStatus}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">Todos</option>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
<div>
<label for="filterPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
id="filterPriority"
bind:value={filterPriority}
on:change={applyFilters}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">Todas</option>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="flex items-end">
<button
on:click={loadData}
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Actualizar
</button>
</div>
</div>
</div>
<!-- Tabla de Tickets -->
<div class="mt-8 flex flex-col">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6">Ticket</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asunto</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Estado</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Prioridad</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Categoría</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Asignado a</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Creado</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Acciones</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#if isLoading}
<tr><td colspan="8" class="text-center py-4">Cargando...</td></tr>
{:else if tickets.length === 0}
<tr><td colspan="8" class="text-center py-4">No hay tickets registrados</td></tr>
{:else}
{#each tickets as ticket}
<tr
class="hover:bg-gray-50 cursor-pointer"
on:click={() => viewTicket(ticket)}
>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6">
{ticket.ticket_number || ticket.id.substring(0, 8)}
</td>
<td class="px-3 py-4 text-sm text-gray-900">
<div class="font-medium">{ticket.subject}</div>
<div class="text-gray-500 truncate max-w-xs">{ticket.description}</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getStatusBadge(ticket.status).color}-100 text-{getStatusBadge(ticket.status).color}-800">
{getStatusBadge(ticket.status).label}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getPriorityBadge(ticket.priority).color}-100 text-{getPriorityBadge(ticket.priority).color}-800">
{getPriorityBadge(ticket.priority).label}
</span>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCategoryName(ticket.category_id)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getUserName(ticket.assigned_to)}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{formatDate(ticket.created_at)}
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 space-x-2">
<button
on:click|stopPropagation={() => openEditModal(ticket)}
class="text-indigo-600 hover:text-indigo-900"
>
Editar
</button>
<button
on:click|stopPropagation={() => openDeleteModal(ticket)}
class="text-red-600 hover:text-red-900"
>
Eliminar
</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Crear Ticket -->
<Modal open={showModal} title="Nuevo Ticket" on:close={() => showModal = false}>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="subject" class="block text-sm font-medium text-gray-700">Asunto *</label>
<input
type="text"
id="subject"
bind:value={formData.subject}
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
placeholder="Breve descripción del problema"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Descripción *</label>
<textarea
id="description"
bind:value={formData.description}
required
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
placeholder="Describe el problema en detalle..."
></textarea>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Categoría</label>
<select
id="category"
bind:value={formData.category_id}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">-- Seleccionar --</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
<div>
<label for="system" class="block text-sm font-medium text-gray-700">Sistema Afectado</label>
<select
id="system"
bind:value={formData.affected_system_id}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">-- Seleccionar --</option>
{#each systems as system}
<option value={system.id}>{system.name}</option>
{/each}
</select>
</div>
</div>
<div>
<label for="priority" class="block text-sm font-medium text-gray-700">Prioridad *</label>
<select
id="priority"
bind:value={formData.priority}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
Crear Ticket
</button>
<button
type="button"
on:click={() => showModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancelar
</button>
</div>
</form>
</Modal>
<!-- Modal Editar Ticket -->
<Modal open={showEditModal} title="Editar Ticket #{selectedTicket?.ticket_number || ''}" on:close={() => showEditModal = false}>
<form on:submit|preventDefault={handleUpdate} class="space-y-4">
<div>
<label for="editStatus" class="block text-sm font-medium text-gray-700">Estado</label>
<select
id="editStatus"
bind:value={editFormData.status}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
{#each STATUSES as status}
<option value={status.value}>{status.label}</option>
{/each}
</select>
</div>
<div>
<label for="editPriority" class="block text-sm font-medium text-gray-700">Prioridad</label>
<select
id="editPriority"
bind:value={editFormData.priority}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
{#each PRIORITIES as priority}
<option value={priority.value}>{priority.label}</option>
{/each}
</select>
</div>
<div>
<label for="editAssigned" class="block text-sm font-medium text-gray-700">Asignar a</label>
<select
id="editAssigned"
bind:value={editFormData.assigned_to}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
>
<option value="">-- Sin asignar --</option>
{#each users.filter(u => u.role === 'AGENT' || u.role === 'SUPPORT_MANAGER' || u.role === 'ADMIN') as user}
<option value={user.id}>{user.first_name} {user.last_name} ({user.role})</option>
{/each}
</select>
</div>
<div class="bg-gray-50 p-3 rounded">
<p class="text-sm text-gray-600"><strong>Asunto:</strong> {selectedTicket?.subject}</p>
<p class="text-sm text-gray-600 mt-1"><strong>Creado por:</strong> {getUserName(selectedTicket?.created_by)}</p>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="submit"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:col-start-2 sm:text-sm"
>
Guardar Cambios
</button>
<button
type="button"
on:click={() => showEditModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancelar
</button>
</div>
</form>
</Modal>
<!-- Modal Eliminar Ticket -->
<Modal open={showDeleteModal} title="Eliminar Ticket" on:close={() => showDeleteModal = false}>
<div class="space-y-4">
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
¿Estás seguro de eliminar este ticket?
</h3>
<div class="mt-2 text-sm text-red-700">
<p>Esta acción no se puede deshacer. Se eliminarán también todos los comentarios y adjuntos asociados.</p>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 p-3 rounded">
<p class="text-sm text-gray-600"><strong>Ticket:</strong> {selectedTicket?.ticket_number}</p>
<p class="text-sm text-gray-600 mt-1"><strong>Asunto:</strong> {selectedTicket?.subject}</p>
</div>
<div class="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="button"
on:click={handleDelete}
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:col-start-2 sm:text-sm"
>
Sí, Eliminar
</button>
<button
type="button"
on:click={() => showDeleteModal = false}
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancelar
</button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api } from '$lib/utils/api';
import { toast } from '$lib/stores/toast';
let ticketId: string;
let ticket = null;
let comments = [];
let users = [];
let isLoading = false;
let newComment = '';
let isSubmittingComment = false;
let pollingInterval: any = null;
const STATUSES = [
{ value: 'NEW', label: 'Nuevo', color: 'blue' },
{ value: 'IN_PROGRESS', label: 'En Progreso', color: 'indigo' },
{ value: 'WAITING_CUSTOMER', label: 'Esperando Cliente', color: 'orange' },
{ value: 'RESOLVED', label: 'Resuelto', color: 'green' },
{ value: 'CLOSED', label: 'Cerrado', color: 'gray' },
{ value: 'REOPENED', label: 'Reabierto', color: 'red' }
];
const PRIORITIES = [
{ value: 'LOW', label: 'Baja', color: 'gray' },
{ value: 'MEDIUM', label: 'Media', color: 'blue' },
{ value: 'HIGH', label: 'Alta', color: 'orange' },
{ value: 'URGENT', label: 'Urgente', color: 'red' }
];
async function loadComments() {
try {
const commentsData = await api.get(`/tickets/${ticketId}/comments`);
comments = commentsData;
} catch (e) {
// No mostrar error en polling silencioso
console.error('Error recargando comentarios:', e);
}
}
async function loadData() {
isLoading = true;
try {
const [ticketData, commentsData, usersData] = await Promise.all([
api.get(`/tickets/${ticketId}`),
api.get(`/tickets/${ticketId}/comments`),
api.get('/users/')
]);
ticket = ticketData;
comments = commentsData;
users = usersData;
} catch (e) {
toast.error('Error cargando ticket: ' + (e.message || 'Error desconocido'));
} finally {
isLoading = false;
}
}
async function handleAddComment() {
if (!newComment.trim()) return;
isSubmittingComment = true;
try {
const comment = await api.post(`/tickets/${ticketId}/comments`, {
content: newComment.trim(),
is_internal: false
});
comments = [...comments, comment];
newComment = '';
toast.success('Comentario agregado');
} catch (e) {
toast.error(e.message || 'Error al agregar comentario');
} finally {
isSubmittingComment = false;
}
}
function getStatusBadge(status) {
const statusObj = STATUSES.find(s => s.value === status);
return statusObj || { label: status, color: 'gray' };
}
function getPriorityBadge(priority) {
const priorityObj = PRIORITIES.find(p => p.value === priority);
return priorityObj || { label: priority, color: 'gray' };
}
function getUserName(id) {
if (!id) return 'Desconocido';
const user = users.find(u => u.id === id);
return user ? `${user.first_name} ${user.last_name}` : 'Desconocido';
}
function formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
onMount(() => {
ticketId = $page.params.id;
if (ticketId) {
loadData();
// Polling cada 3 segundos
pollingInterval = setInterval(() => {
loadComments();
}, 3000);
}
// Cleanup cuando se desmonte el componente
return () => {
if (pollingInterval) {
clearInterval(pollingInterval);
}
};
});
</script>
<div class="px-4 py-8 mx-auto max-w-7xl sm:px-6 lg:px-8">
{#if isLoading}
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
<p class="mt-2 text-gray-600">Cargando ticket...</p>
</div>
{:else if ticket}
<!-- Breadcrumb -->
<div class="flex items-center space-x-2 text-sm text-gray-500 mb-6">
<a href="/tickets" class="hover:text-indigo-600">Tickets</a>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span>#{ticket.ticket_number || ticket.id.substring(0, 8)}</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Ticket Header -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
<div class="flex justify-between items-start">
<div class="flex-1">
<h1 class="text-2xl font-bold text-gray-900 mb-2">
{ticket.subject || ticket.title}
</h1>
<div class="flex items-center space-x-3">
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getStatusBadge(ticket.status).color}-100 text-{getStatusBadge(ticket.status).color}-800">
{getStatusBadge(ticket.status).label}
</span>
<span class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 bg-{getPriorityBadge(ticket.priority).color}-100 text-{getPriorityBadge(ticket.priority).color}-800">
{getPriorityBadge(ticket.priority).label}
</span>
<span class="text-sm text-gray-500">
Creado {formatDate(ticket.created_at)}
</span>
</div>
</div>
<button
on:click={() => goto('/tickets')}
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Volver
</button>
</div>
</div>
<div class="px-6 py-5">
<div class="prose max-w-none">
<p class="whitespace-pre-wrap text-gray-700">
{ticket.description}
</p>
</div>
</div>
</div>
<!-- Comments Section -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Conversación</h3>
</div>
<div class="px-6 py-5">
{#if comments.length === 0}
<p class="text-gray-500 text-center py-4">
No hay comentarios aún. ¡Sé el primero en comentar!
</p>
{:else}
<div class="space-y-4">
{#each comments as comment}
<div class="flex space-x-3">
<div class="w-8 h-8 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-indigo-600 text-xs font-medium">
{comment.author_name ? comment.author_name.split(' ').map(n => n[0]).join('') : '??'}
</span>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2 mb-1">
<span class="text-sm font-medium text-gray-900">
{comment.author_name || 'Usuario Desconocido'}
</span>
<span class="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
{#if comment.is_internal}
<span class="bg-red-100 text-red-700 text-xs px-2 py-0.5 rounded">
Interno
</span>
{/if}
</div>
<p class="text-gray-700 whitespace-pre-wrap">
{comment.content}
</p>
</div>
</div>
{/each}
</div>
{/if}
<!-- Add Comment Form -->
<div class="mt-6 pt-6 border-t border-gray-200">
<form on:submit|preventDefault={handleAddComment} class="space-y-4">
<textarea
rows="4"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm border p-2"
placeholder="Escribe tu comentario o respuesta..."
bind:value={newComment}
disabled={isSubmittingComment}
></textarea>
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
disabled={isSubmittingComment || !newComment.trim()}
>
{#if isSubmittingComment}
<div class="flex items-center space-x-2">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Enviando...</span>
</div>
{:else}
Enviar Comentario
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Ticket Info -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-5 border-b border-gray-200">
<h3 class="text-lg font-semibold text-gray-900">Información</h3>
</div>
<div class="px-6 py-5 space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">ID del Ticket</dt>
<dd class="text-sm text-gray-900 font-mono">#{ticket.ticket_number || ticket.id.substring(0, 8)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Creado por</dt>
<dd class="text-sm text-gray-900">{getUserName(ticket.created_by)}</dd>
</div>
{#if ticket.assigned_to}
<div>
<dt class="text-sm font-medium text-gray-500">Asignado a</dt>
<dd class="text-sm text-gray-900">{getUserName(ticket.assigned_to)}</dd>
</div>
{/if}
<div>
<dt class="text-sm font-medium text-gray-500">Creado</dt>
<dd class="text-sm text-gray-900">{formatDate(ticket.created_at)}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Última actualización</dt>
<dd class="text-sm text-gray-900">{formatDate(ticket.updated_at)}</dd>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>

View File

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

View File

@@ -0,0 +1,28 @@
// vite.config.js
import { sveltekit } from "file:///app/node_modules/@sveltejs/kit/src/exports/vite/index.js";
import { defineConfig } from "file:///app/node_modules/vite/dist/node/index.js";
var vite_config_default = defineConfig({
plugins: [sveltekit()],
server: {
port: 3e3,
host: "0.0.0.0",
proxy: {
"/api": {
target: "http://servicemanager-backend:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, "")
}
}
},
preview: {
port: 3e3,
host: "0.0.0.0"
},
build: {
target: "esnext"
}
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvYXBwXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvYXBwL3ZpdGUuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9hcHAvdml0ZS5jb25maWcuanNcIjtcdUZFRkZpbXBvcnQgeyBzdmVsdGVraXQgfSBmcm9tICdAc3ZlbHRlanMva2l0L3ZpdGUnO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSAndml0ZSc7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gICAgICAgIHBsdWdpbnM6IFtzdmVsdGVraXQoKV0sXG4gICAgICAgIHNlcnZlcjoge1xuICAgICAgICAgICAgICAgIHBvcnQ6IDMwMDAsXG4gICAgICAgICAgICAgICAgaG9zdDogJzAuMC4wLjAnLFxuICAgICAgICAgICAgICAgIHByb3h5OiB7XG4gICAgICAgICAgICAgICAgICAgICAgICAnL2FwaSc6IHtcbiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgdGFyZ2V0OiAnaHR0cDovL3NlcnZpY2VtYW5hZ2VyLWJhY2tlbmQ6ODAwMCcsXG4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNoYW5nZU9yaWdpbjogdHJ1ZSxcbiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgcmV3cml0ZTogKHBhdGgpID0+IHBhdGgucmVwbGFjZSgvXlxcL2FwaS8sICcnKVxuICAgICAgICAgICAgICAgICAgICAgICAgfVxuICAgICAgICAgICAgICAgIH1cbiAgICAgICAgfSxcbiAgICAgICAgcHJldmlldzoge1xuICAgICAgICAgICAgICAgIHBvcnQ6IDMwMDAsXG4gICAgICAgICAgICAgICAgaG9zdDogJzAuMC4wLjAnXG4gICAgICAgIH0sXG4gICAgICAgIGJ1aWxkOiB7XG4gICAgICAgICAgICAgICAgdGFyZ2V0OiAnZXNuZXh0J1xuICAgICAgICB9XG59KTtcclxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUErTCxTQUFTLGlCQUFpQjtBQUN6TixTQUFTLG9CQUFvQjtBQUU3QixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUNwQixTQUFTLENBQUMsVUFBVSxDQUFDO0FBQUEsRUFDckIsUUFBUTtBQUFBLElBQ0EsTUFBTTtBQUFBLElBQ04sTUFBTTtBQUFBLElBQ04sT0FBTztBQUFBLE1BQ0MsUUFBUTtBQUFBLFFBQ0EsUUFBUTtBQUFBLFFBQ1IsY0FBYztBQUFBLFFBQ2QsU0FBUyxDQUFDLFNBQVMsS0FBSyxRQUFRLFVBQVUsRUFBRTtBQUFBLE1BQ3BEO0FBQUEsSUFDUjtBQUFBLEVBQ1I7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNELE1BQU07QUFBQSxJQUNOLE1BQU07QUFBQSxFQUNkO0FBQUEsRUFDQSxPQUFPO0FBQUEsSUFDQyxRQUFRO0FBQUEsRUFDaEI7QUFDUixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=

Binary file not shown.

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "ServiceManagerWeb",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

File diff suppressed because it is too large Load Diff

BIN
temp_response.txt Normal file

Binary file not shown.

View File

@@ -1,54 +0,0 @@
import sys
import os
import pytest
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenant import Tenant
from app.core.database import get_db
# Add the project root to PYTHONPATH
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
from app.main import app
@pytest.fixture(scope="function")
async def setup_test_data():
async with get_db() as db:
# Insert a valid tenant
valid_tenant = Tenant(id="valid-tenant-id", slug="valid-tenant", status="active")
await db.add(valid_tenant)
await db.commit()
# Insert an invalid tenant
invalid_tenant = Tenant(id="invalid-tenant-id", slug="invalid-tenant", status="inactive")
await db.add(invalid_tenant)
await db.commit()
yield
# Cleanup
await db.delete(valid_tenant)
await db.delete(invalid_tenant)
await db.commit()
@pytest.mark.asyncio
async def test_request_without_tenant(setup_test_data):
async with AsyncClient(app=app, base_url="http://testserver") as client:
response = await client.get("/v1/categories/")
assert response.status_code == 400
assert response.json()["detail"] == "Tenant information required (X-Tenant-ID or X-Tenant-Slug header)"
@pytest.mark.asyncio
async def test_request_with_invalid_tenant(setup_test_data):
async with AsyncClient(app=app, base_url="http://testserver") as client:
headers = {"X-Tenant-ID": "invalid-tenant-id"}
response = await client.get("/v1/categories/", headers=headers)
assert response.status_code == 404
assert response.json()["detail"] == "Tenant not found or inactive"
@pytest.mark.asyncio
async def test_request_with_valid_tenant(setup_test_data):
async with AsyncClient(app=app, base_url="http://testserver") as client:
headers = {"X-Tenant-ID": "valid-tenant-id"}
response = await client.get("/v1/categories/", headers=headers)
assert response.status_code == 200