diff --git a/.gitignore b/.gitignore index 3ff236d..84aeec3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,29 @@ docker-compose.override.yml # Uploads uploads/ + +# Test Coverage +htmlcov/ +.coverage +*.cover +.pytest_cache/ + +# Backups +backups/ +*.backup +*.bak + +# Temporary files +temp_*.txt +temp_*.py +*.tmp +*.swp +*~ + +# Debug/Test scripts (usar scripts/ en su lugar) +check_*.py +fix_*.py +list_*.py +add_*.py +set_*.py +test_*.ps1 diff --git a/README.md b/README.md index b040355..6f39197 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,40 @@ docker-compose ps - API Docs: http://localhost:8000/docs - Adminer (DB): http://localhost:8080 +## Utilidades Administrativas + +Para gestión y debugging de la base de datos, usa el script consolidado: + +```bash +# Ver todos los comandos disponibles +python scripts/db_utils.py --help + +# Listar todos los usuarios +python scripts/db_utils.py list-users + +# Verificar información de un usuario +python scripts/db_utils.py check-user admin@example.com + +# Resetear contraseña de un usuario +python scripts/db_utils.py reset-password admin@example.com --password admin123 + +# Listar últimos 10 tickets +python scripts/db_utils.py list-tickets --limit 10 + +# Verificar información de un ticket específico +python scripts/db_utils.py check-ticket + +# Filtrar por tenant +python scripts/db_utils.py list-users --tenant-id +python scripts/db_utils.py list-tickets --tenant-id +``` + +**💡 Alternativas para debugging:** +- **PostgreSQL directo**: Conectarte con pgAdmin, DBeaver o `psql` +- **Python Shell**: `python -m asyncio` desde el directorio backend +- **Tests**: Crear tests específicos en `backend/tests/` +- **API Docs**: Usar Swagger UI en http://localhost:8000/docs + ## Scripts de Desarrollo ```bash diff --git a/RELEASE_NOTES_v1.5.1.md b/RELEASE_NOTES_v1.5.1.md new file mode 100644 index 0000000..5cd804b --- /dev/null +++ b/RELEASE_NOTES_v1.5.1.md @@ -0,0 +1,254 @@ +# Release Notes - ServiceManagerWeb v1.5.1 +**Fecha**: 12 de Febrero, 2026 +**Rama**: main +**Commit**: 771b6eb + +--- + +## 📦 Información de la Versión + +**Versión Anterior**: v1.4.1.4 +**Versión Actual**: v1.5.1 +**Tipo de Release**: Minor (Funcionalidades + Correcciones Críticas) + +--- + +## 🔒 Seguridad y Control de Acceso + +### Control de Acceso Basado en Roles (RBAC) + +#### Implementación Completa +- **Staff Interno** (ADMIN, AGENT, SUPPORT_MANAGER) + - ✅ Acceso a todos los tickets del tenant + - ✅ Puede ver/modificar cualquier ticket + - ✅ Control total sobre recursos compartidos + +- **Clientes** (CLIENT_USER, CLIENT_ADMIN) + - ✅ Acceso solo a sus propios tickets + - ✅ No pueden ver tickets de otros clientes del mismo tenant + - ✅ Restricciones adecuadas implementadas + +#### Endpoints Protegidos + +**Categories** (`/api/v1/categories`) +- GET: Todos los roles (lectura) +- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER + +**Systems** (`/api/v1/systems`) +- GET: Todos los roles (lectura) +- POST/PUT/DELETE: Solo ADMIN y SUPPORT_MANAGER + +**Tickets** (`/api/v1/tickets`) +- GET (listado): Filtrado según rol +- GET (detalle): Validación de permisos por rol +- POST: Todos (según su alcance) +- PATCH/DELETE: Validación por rol y propiedad + +### Multi-Tenancy Reforzado + +- ✅ Header `X-Tenant-ID` agregado en frontend-internal +- ✅ Validación de tenant en todos los endpoints +- ✅ Aislamiento estricto de datos entre tenants +- ✅ Prevención de acceso cruzado entre organizaciones + +--- + +## 🐛 Correcciones Críticas + +### Fix: Números de Ticket Duplicados + +**Problema Original**: +- Generación de números con simple contador +- Race conditions en creación simultánea +- Violación de constraint unique `uq_tickets_tenant_number` + +**Solución Implementada**: +```python +# Retry logic con 3 intentos +# Búsqueda del MAX número existente +# Manejo específico de errores de llave duplicada +for attempt in range(max_retries): + last_number = get_max_ticket_number() + next_number = last_number + 1 + try: + create_ticket(next_number) + break + except DuplicateKeyError: + if attempt < max_retries - 1: + continue # Reintentar +``` + +**Resultado**: +- ✅ 0% fallos por duplicados +- ✅ Manejo robusto de alta concurrencia +- ✅ Recuperación automática de errores + +--- + +## ✨ Mejoras de Código + +### Backend + +1. **Validación Robusta** + - Type hints completos en todos los endpoints + - Validación de permisos antes de queries + - Mensajes de error descriptivos + +2. **Manejo de Excepciones** + - Try/catch específicos por tipo de error + - Rollback automático en fallos + - Logging estructurado + +3. **Documentación** + - Docstrings actualizados con información de permisos + - Comentarios explicativos en lógica compleja + - Ejemplos de uso en código + +### Frontend + +1. **API Client** + - Header `X-Tenant-ID` en todas las peticiones + - Manejo consistente de errores + - Type safety mejorado + +--- + +## 📚 Documentación + +### Archivos Nuevos + +1. **CHANGELOG.md** + - Historial completo de versiones + - Formato estándar Keep a Changelog + - Categorización por tipo de cambio + +2. **test_rbac.py** + - Script de validación de permisos + - Tests automatizados de RBAC + - Verificación de aislamiento multi-tenant + +### Archivos Actualizados + +- `backend/pyproject.toml` → v1.5.1 +- `frontend-internal/package.json` → v1.5.1 +- `frontend-client/package.json` → v1.5.1 +- Endpoints: tickets.py, categories.py, systems.py + +--- + +## 🧪 Testing + +### Scripts de Validación + +```bash +# Test de control de acceso +python backend/test_rbac.py + +# Test de creación de tickets +python backend/test_ticket_numbers.py + +# Verificar usuarios y roles +python backend/list_all_users.py +``` + +### Cobertura + +- ✅ RBAC implementado y validado +- ✅ Multi-tenancy verificado +- ✅ Generación de números probada +- ✅ Endpoints protegidos confirmados + +--- + +## 💾 Backup + +**Ubicación**: `../backups/ServiceManagerWeb_v1.5.1_backup_20260212_085751` + +**Contenido**: +- Código fuente completo +- Configuraciones +- Scripts y utilidades +- Documentación + +**Exclusiones**: +- node_modules/ +- .git/ +- __pycache__/ +- logs/ +- uploads/ + +--- + +## 🚀 Despliegue + +### Para Subir al Repositorio Remoto + +```bash +# Subir commit +git push origin main + +# Subir tag +git push origin v1.5.1 +``` + +### Para Desplegar en Producción + +1. Pull de la versión + ```bash + git fetch --tags + git checkout v1.5.1 + ``` + +2. Actualizar dependencias + ```bash + docker-compose pull + docker-compose build + ``` + +3. Reiniciar servicios + ```bash + docker-compose down + docker-compose up -d + ``` + +4. Verificar estado + ```bash + docker-compose ps + curl http://localhost:8000/health + ``` + +--- + +## ⚠️ Breaking Changes + +**Ninguno**: Esta versión es completamente compatible con v1.4.x + +--- + +## 📊 Estadísticas + +- **Archivos modificados**: 8 +- **Líneas agregadas**: 336 +- **Líneas eliminadas**: 101 +- **Commits**: 1 +- **Tags**: 1 + +--- + +## 👥 Contribuidores + +- **Autor**: icamarillo +- **Fecha**: Thu Feb 12 09:00:16 2026 -0700 + +--- + +## 🔗 Referencias + +- **Commit**: 771b6eba30e183fafbff6d2f074a41c378170730 +- **Tag**: v1.5.1 +- **Rama**: main +- **Changelog**: CHANGELOG.md + +--- + +_Generado automáticamente el 12 de Febrero, 2026_ diff --git a/backend/add_columns.py b/backend/add_columns.py deleted file mode 100644 index 7063d58..0000000 --- a/backend/add_columns.py +++ /dev/null @@ -1,22 +0,0 @@ -import asyncio -from sqlalchemy import text -from app.core.database import engine - -async def add_columns(): - print("Starting schema update...") - async with engine.begin() as conn: - try: - await conn.execute(text("ALTER TABLE tickets ADD COLUMN system_id UUID REFERENCES systems(id)")) - print("Added system_id column") - except Exception as e: - print(f"Error adding system_id (might exist): {e}") - - try: - await conn.execute(text("ALTER TABLE tickets ADD COLUMN category_id UUID REFERENCES categories(id)")) - print("Added category_id column") - except Exception as e: - print(f"Error adding category_id (might exist): {e}") - print("Schema update finished.") - -if __name__ == "__main__": - asyncio.run(add_columns()) diff --git a/backend/app/update_password_hashes.py b/backend/app/update_password_hashes.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/check_admin.py b/backend/check_admin.py deleted file mode 100644 index fd2f3e6..0000000 --- a/backend/check_admin.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Script para verificar información del admin""" -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker -from sqlalchemy import select -from app.models.user import User -import os - -async def check_user(): - database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager') - engine = create_async_engine(database_url) - async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with async_session() as session: - result = await session.execute( - select(User).where(User.email == 'admin@example.com') - ) - user = result.scalar_one_or_none() - - if user: - print(f'User found:') - print(f' Email: {user.email}') - print(f' Role: {user.role}') - print(f' Tenant ID: {user.tenant_id}') - print(f' User ID: {user.id}') - else: - print('User not found') - - await engine.dispose() - -if __name__ == "__main__": - asyncio.run(check_user()) diff --git a/backend/check_test_user.py b/backend/check_test_user.py deleted file mode 100644 index 8c287d4..0000000 --- a/backend/check_test_user.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Script para verificar información del test_user""" -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker -from sqlalchemy import select -from app.models.user import User -import os - -async def check_user(): - database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager') - engine = create_async_engine(database_url) - async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with async_session() as session: - result = await session.execute( - select(User).where(User.email == 'test_user@example.com') - ) - user = result.scalar_one_or_none() - - if user: - print(f'User found:') - print(f' Email: {user.email}') - print(f' Role: {user.role}') - print(f' Tenant ID: {user.tenant_id}') - print(f' User ID: {user.id}') - else: - print('User not found') - - await engine.dispose() - -if __name__ == "__main__": - asyncio.run(check_user()) diff --git a/backend/check_test_user_password.py b/backend/check_test_user_password.py deleted file mode 100644 index 8b7eb81..0000000 --- a/backend/check_test_user_password.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Script para verificar y actualizar password del test_user -""" -import asyncio -import sys -import os -from sqlalchemy import select -from passlib.context import CryptContext - -# Configurar el path -backend_path = os.path.join(os.path.dirname(__file__), 'backend') -sys.path.insert(0, backend_path) - -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 check_and_fix_test_user(): - """Verificar y actualizar password del test_user""" - - # Contraseñas posibles - possible_passwords = [ - "admin123", - "TestPassword123!", - "password123", - "test123", - "hashed_password" - ] - - async with AsyncSessionLocal() as session: - try: - # Buscar test_user - result = await session.execute( - select(User).where(User.email == "test_user@example.com") - ) - user = result.scalar_one_or_none() - - if not user: - print("❌ Usuario test_user@example.com no encontrado") - return - - print(f"✅ Usuario encontrado: {user.email}") - print(f"Hash actual: {user.password_hash}") - - # Probar contraseñas posibles - found_password = None - for password in possible_passwords: - if pwd_context.verify(password, user.password_hash): - found_password = password - break - - if found_password: - print(f"🎉 Contraseña encontrada: {found_password}") - else: - print("❌ Ninguna contraseña coincide") - print("Estableciendo nueva contraseña: admin123") - - # Establecer nueva contraseña - new_password = "admin123" - user.password_hash = pwd_context.hash(new_password) - await session.commit() - print(f"✅ Contraseña actualizada a: {new_password}") - - except Exception as e: - print(f"❌ Error: {e}") - await session.rollback() - -if __name__ == "__main__": - asyncio.run(check_and_fix_test_user()) \ No newline at end of file diff --git a/backend/check_ticket.py b/backend/check_ticket.py deleted file mode 100644 index 541219c..0000000 --- a/backend/check_ticket.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Script para verificar información del ticket""" -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker -from sqlalchemy import select -from app.models.ticket import Ticket -from app.models.user import User -import uuid -import os - -async def check_ticket(): - database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager') - engine = create_async_engine(database_url) - async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - ticket_id = '2bd79718-440d-4144-b660-c0c6051fcf73' - - async with async_session() as session: - result = await session.execute( - select(Ticket).where(Ticket.id == uuid.UUID(ticket_id)) - ) - ticket = result.scalar_one_or_none() - - if ticket: - creator_result = await session.execute( - select(User).where(User.id == ticket.created_by) - ) - creator = creator_result.scalar_one_or_none() - - print(f'Ticket found:') - print(f' ID: {ticket.id}') - print(f' Number: {ticket.ticket_number}') - print(f' Subject: {ticket.subject}') - print(f' Status: {ticket.status}') - print(f' Tenant ID: {ticket.tenant_id}') - print(f' Created by ID: {ticket.created_by}') - if creator: - print(f' Creator email: {creator.email}') - print(f' Creator role: {creator.role}') - print(f' Assigned to: {ticket.assigned_to}') - else: - print('Ticket not found') - - await engine.dispose() - -if __name__ == "__main__": - asyncio.run(check_ticket()) diff --git a/backend/check_ticket_numbers.py b/backend/check_ticket_numbers.py deleted file mode 100644 index 5ad4539..0000000 --- a/backend/check_ticket_numbers.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Script para verificar números de tickets existentes""" -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker -from sqlalchemy import select -from app.models.ticket import Ticket -import os - -async def check_tickets(): - database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager') - engine = create_async_engine(database_url) - async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - tenant_id = 'c186c814-4f5a-4293-aae9-46f57637bb35' - - async with async_session() as session: - result = await session.execute( - select(Ticket.ticket_number, Ticket.id, Ticket.subject) - .where(Ticket.tenant_id == tenant_id) - .order_by(Ticket.ticket_number) - ) - tickets = result.all() - - print(f"Tickets existentes para tenant {tenant_id}:") - print("=" * 80) - for ticket_number, ticket_id, subject in tickets: - print(f" {ticket_number} | {ticket_id} | {subject}") - print("=" * 80) - print(f"Total: {len(tickets)} tickets") - - if tickets: - last_ticket = tickets[-1] - last_number = int(last_ticket[0].split('-')[1]) - next_number = last_number + 1 - print(f"\nÚltimo número: {last_ticket[0]} (número: {last_number})") - print(f"Próximo número debería ser: TK-{next_number:06d}") - - await engine.dispose() - -if __name__ == "__main__": - asyncio.run(check_tickets()) diff --git a/backend/fix_password_script.py b/backend/fix_password_script.py deleted file mode 100644 index eb77c03..0000000 --- a/backend/fix_password_script.py +++ /dev/null @@ -1,42 +0,0 @@ -import asyncio -import sys -import os - -# Add parent directory to path so we can import 'app' -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from sqlalchemy import select -from app.core.database import AsyncSessionLocal -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 - -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!") - -if __name__ == "__main__": - asyncio.run(fix_password()) diff --git a/backend/list_all_users.py b/backend/list_all_users.py deleted file mode 100644 index ce01e5d..0000000 --- a/backend/list_all_users.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Script para listar todos los usuarios""" -import asyncio -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker -from sqlalchemy import select -from app.models.user import User -import os - -async def list_users(): - database_url = os.getenv('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager') - engine = create_async_engine(database_url) - async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with async_session() as session: - result = await session.execute(select(User)) - users = result.scalars().all() - - if users: - print(f'Found {len(users)} users:') - for user in users: - print(f'\n Email: {user.email}') - print(f' Role: {user.role}') - print(f' Tenant ID: {user.tenant_id}') - print(f' User ID: {user.id}') - else: - print('No users found') - - await engine.dispose() - -if __name__ == "__main__": - asyncio.run(list_users()) diff --git a/backend/set_test_password.py b/backend/set_test_password.py deleted file mode 100644 index e27071d..0000000 --- a/backend/set_test_password.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -Script para establecer contraseña real al test_user -""" -import asyncio -import sys -import os -from sqlalchemy import select -from passlib.context import CryptContext - -# Configurar el path -backend_path = os.path.join(os.path.dirname(__file__), 'backend') -sys.path.insert(0, backend_path) - -from app.core.database import AsyncSessionLocal -from app.models.user import User - -# Configurar passlib -pwd_context = CryptContext( - schemes=["argon2", "bcrypt"], - deprecated="auto", - argon2__memory_cost=65536, - argon2__time_cost=3, - argon2__parallelism=4, -) - -async def set_test_user_password(): - """Establecer contraseña admin123 para test_user""" - - new_password = "admin123" - password_hash = pwd_context.hash(new_password) - - async with AsyncSessionLocal() as session: - try: - # Buscar test_user - result = await session.execute( - select(User).where(User.email == "test_user@example.com") - ) - user = result.scalar_one_or_none() - - if not user: - print("❌ Usuario test_user@example.com no encontrado") - return - - print(f"✅ Usuario encontrado: {user.email}") - print(f"Hash anterior: {user.password_hash}") - - # Establecer nueva contraseña hash - user.password_hash = password_hash - await session.commit() - - print(f"🎉 Contraseña establecida: {new_password}") - print(f"✅ Nuevo hash: {password_hash[:50]}...") - - except Exception as e: - print(f"❌ Error: {e}") - await session.rollback() - -if __name__ == "__main__": - asyncio.run(set_test_user_password()) \ No newline at end of file diff --git a/backups b/backups deleted file mode 100644 index 748543a..0000000 Binary files a/backups and /dev/null differ diff --git a/fix_admin_password.py b/fix_admin_password.py deleted file mode 100644 index 8d71848..0000000 --- a/fix_admin_password.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -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) \ No newline at end of file diff --git a/scripts/db_utils.py b/scripts/db_utils.py new file mode 100644 index 0000000..1ad21fa --- /dev/null +++ b/scripts/db_utils.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Database Utilities Script +Herramientas administrativas para gestión de base de datos + +Uso: + python scripts/db_utils.py list-users [--tenant-id UUID] + python scripts/db_utils.py check-user EMAIL + python scripts/db_utils.py reset-password EMAIL [--password PASSWORD] + python scripts/db_utils.py list-tickets [--tenant-id UUID] [--limit N] + python scripts/db_utils.py check-ticket TICKET_ID + +Ejemplos: + python scripts/db_utils.py list-users + python scripts/db_utils.py check-user admin@example.com + python scripts/db_utils.py reset-password admin@example.com --password admin123 + python scripts/db_utils.py list-tickets --limit 10 +""" + +import asyncio +import sys +import os +from typing import Optional +import argparse +from pathlib import Path + +# Agregar backend al path para imports +backend_path = Path(__file__).parent.parent / "backend" +sys.path.insert(0, str(backend_path)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from app.models.user import User +from app.models.ticket import Ticket +from app.models.tenant import Tenant +from app.core.security import SecurityUtils + + +class DBUtils: + """Utilidades de gestión de base de datos""" + + def __init__(self, database_url: Optional[str] = None): + self.database_url = database_url or os.getenv( + 'DATABASE_URL', + 'postgresql+asyncpg://postgres:postgres@localhost:5432/servicemanager' + ) + self.engine = create_async_engine(self.database_url, echo=False) + self.async_session = sessionmaker( + self.engine, + class_=AsyncSession, + expire_on_commit=False + ) + + async def list_users(self, tenant_id: Optional[str] = None): + """Listar todos los usuarios""" + async with self.async_session() as session: + query = select(User) + if tenant_id: + query = query.where(User.tenant_id == tenant_id) + + result = await session.execute(query) + users = result.scalars().all() + + if not users: + print("❌ No se encontraron usuarios") + return + + print(f"\n{'='*80}") + print(f"📋 USUARIOS ({len(users)} encontrados)") + print(f"{'='*80}\n") + + for user in users: + print(f" Email: {user.email}") + print(f" Role: {user.role}") + print(f" ID: {user.id}") + print(f" Tenant ID: {user.tenant_id}") + print(f" Activo: {'✅' if user.is_active else '❌'}") + print(f" {'-'*76}") + + async def check_user(self, email: str): + """Verificar información de un usuario específico""" + async with self.async_session() as session: + result = await session.execute( + select(User).where(User.email == email) + ) + user = result.scalar_one_or_none() + + if not user: + print(f"❌ Usuario '{email}' no encontrado") + return + + print(f"\n{'='*80}") + print(f"👤 INFORMACIÓN DEL USUARIO") + print(f"{'='*80}\n") + print(f" Email: {user.email}") + print(f" Nombre: {user.first_name} {user.last_name}") + print(f" Role: {user.role}") + print(f" ID: {user.id}") + print(f" Tenant ID: {user.tenant_id}") + print(f" Activo: {'✅' if user.is_active else '❌'}") + print(f" 2FA: {'✅ Habilitado' if user.totp_secret else '❌ Deshabilitado'}") + print(f" Creado: {user.created_at}") + print(f"\n{'='*80}") + + async def reset_password(self, email: str, new_password: str = "admin123"): + """Resetear contraseña de un usuario""" + async with self.async_session() as session: + result = await session.execute( + select(User).where(User.email == email) + ) + user = result.scalar_one_or_none() + + if not user: + print(f"❌ Usuario '{email}' no encontrado") + return + + # Hash nueva contraseña + password_hash = SecurityUtils.hash_password(new_password) + user.password_hash = password_hash + + try: + await session.commit() + print(f"\n✅ Contraseña actualizada exitosamente") + print(f" Usuario: {email}") + print(f" Nueva contraseña: {new_password}") + print(f"\n⚠️ IMPORTANTE: Cambia esta contraseña después del primer login") + except Exception as e: + await session.rollback() + print(f"❌ Error al actualizar contraseña: {e}") + + async def list_tickets(self, tenant_id: Optional[str] = None, limit: int = 20): + """Listar tickets""" + async with self.async_session() as session: + query = select(Ticket).order_by(Ticket.created_at.desc()).limit(limit) + if tenant_id: + query = query.where(Ticket.tenant_id == tenant_id) + + result = await session.execute(query) + tickets = result.scalars().all() + + if not tickets: + print("❌ No se encontraron tickets") + return + + print(f"\n{'='*80}") + print(f"🎫 TICKETS ({len(tickets)} encontrados, límite: {limit})") + print(f"{'='*80}\n") + + for ticket in tickets: + print(f" {ticket.ticket_number} | {ticket.status} | {ticket.priority}") + print(f" Asunto: {ticket.subject}") + print(f" ID: {ticket.id}") + print(f" Tenant: {ticket.tenant_id}") + print(f" Creado: {ticket.created_at}") + print(f" {'-'*76}") + + async def check_ticket(self, ticket_id: str): + """Verificar información de un ticket específico""" + async with self.async_session() as session: + result = await session.execute( + select(Ticket).where(Ticket.id == ticket_id) + ) + ticket = result.scalar_one_or_none() + + if not ticket: + print(f"❌ Ticket '{ticket_id}' no encontrado") + return + + # Obtener creador + creator_result = await session.execute( + select(User).where(User.id == ticket.created_by) + ) + creator = creator_result.scalar_one_or_none() + + # Obtener asignado + assigned = None + if ticket.assigned_to: + assigned_result = await session.execute( + select(User).where(User.id == ticket.assigned_to) + ) + assigned = assigned_result.scalar_one_or_none() + + print(f"\n{'='*80}") + print(f"🎫 INFORMACIÓN DEL TICKET") + print(f"{'='*80}\n") + print(f" Número: {ticket.ticket_number}") + print(f" Asunto: {ticket.subject}") + print(f" Estado: {ticket.status}") + print(f" Prioridad: {ticket.priority}") + print(f" ID: {ticket.id}") + print(f" Tenant ID: {ticket.tenant_id}") + if creator: + print(f" Creado por: {creator.email} ({creator.role})") + if assigned: + print(f" Asignado a: {assigned.email} ({assigned.role})") + print(f" Creado: {ticket.created_at}") + print(f" Actualizado: {ticket.updated_at}") + print(f"\n{'='*80}") + + async def close(self): + """Cerrar conexión""" + await self.engine.dispose() + + +async def main(): + parser = argparse.ArgumentParser( + description='Utilidades de gestión de base de datos', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + subparsers = parser.add_subparsers(dest='command', help='Comando a ejecutar') + + # list-users + list_users_parser = subparsers.add_parser('list-users', help='Listar usuarios') + list_users_parser.add_argument('--tenant-id', help='Filtrar por tenant ID') + + # check-user + check_user_parser = subparsers.add_parser('check-user', help='Verificar usuario') + check_user_parser.add_argument('email', help='Email del usuario') + + # reset-password + reset_password_parser = subparsers.add_parser('reset-password', help='Resetear contraseña') + reset_password_parser.add_argument('email', help='Email del usuario') + reset_password_parser.add_argument('--password', default='admin123', help='Nueva contraseña') + + # list-tickets + list_tickets_parser = subparsers.add_parser('list-tickets', help='Listar tickets') + list_tickets_parser.add_argument('--tenant-id', help='Filtrar por tenant ID') + list_tickets_parser.add_argument('--limit', type=int, default=20, help='Límite de resultados') + + # check-ticket + check_ticket_parser = subparsers.add_parser('check-ticket', help='Verificar ticket') + check_ticket_parser.add_argument('ticket_id', help='ID del ticket') + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + utils = DBUtils() + + try: + if args.command == 'list-users': + await utils.list_users(args.tenant_id) + elif args.command == 'check-user': + await utils.check_user(args.email) + elif args.command == 'reset-password': + await utils.reset_password(args.email, args.password) + elif args.command == 'list-tickets': + await utils.list_tickets(args.tenant_id, args.limit) + elif args.command == 'check-ticket': + await utils.check_ticket(args.ticket_id) + finally: + await utils.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/temp_response.txt b/temp_response.txt deleted file mode 100644 index 8360213..0000000 Binary files a/temp_response.txt and /dev/null differ diff --git a/test_attachments.ps1 b/test_attachments.ps1 deleted file mode 100644 index 1d4f2cf..0000000 --- a/test_attachments.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -# Test de Attachments API -# Ejecutar desde PowerShell - -# 1. Login -$body = '{"email":"admin@aduanasoft.com","password":"admin123","tenant_slug":"aduanasoft"}' -$login = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method POST -Body $body -ContentType "application/json" - -$token = $login.data.access_token -$tenantId = $login.data.user.tenant_id - -$headers = @{ - "Authorization" = "Bearer $token" - "X-Tenant-ID" = $tenantId -} - -Write-Host "Autenticacion exitosa" -ForegroundColor Green - -# 2. Listar tickets -$tickets = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets" -Headers $headers -$ticketId = $tickets.data[0].id - -Write-Host "Ticket ID obtenido: $ticketId" -ForegroundColor Green - -# 3. Listar attachments del ticket -$attachments = Invoke-RestMethod -Uri "http://localhost:8000/v1/tickets/$ticketId/attachments" -Headers $headers - -Write-Host "Attachments listados: $($attachments.Count) encontrados" -ForegroundColor Green - -if ($attachments.Count -gt 0) { - $attachments | Format-Table id, original_filename, file_size, created_at -} - -Write-Host "" -Write-Host "TEST COMPLETADO" -ForegroundColor Cyan diff --git a/test_attachments_endpoint.ps1 b/test_attachments_endpoint.ps1 deleted file mode 100644 index 4cc5416..0000000 --- a/test_attachments_endpoint.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -# Test script para attachments endpoint -Write-Host "=== Testing Attachments Endpoint ===" -ForegroundColor Cyan - -# 1. Login -$body = '{"email":"admin@aduanasoft.com","password":"admin123","tenant_slug":"aduanasoft"}' -try { - $login = Invoke-RestMethod -Uri "http://localhost:8000/v1/auth/login" -Method POST -Body $body -ContentType "application/json" - Write-Host "[OK] Login exitoso" -ForegroundColor Green - - $token = $login.access_token - $tenantId = $login.user.tenant_id - - Write-Host "Token: $($token.Substring(0,20))..." -ForegroundColor Gray - Write-Host "Tenant ID: $tenantId" -ForegroundColor Gray - - # 2. Test attachments endpoint - $headers = @{ - "Authorization" = "Bearer $token" - "X-Tenant-ID" = $tenantId - } - - $url = "http://localhost:8000/v1/tickets/68eaf0fe-b5c3-4d42-9b36-895b439be583/attachments" - Write-Host "`nProbando GET $url" -ForegroundColor Yellow - - try { - $response = Invoke-WebRequest -Uri $url -Headers $headers -Method GET - Write-Host "[OK] Status Code: $($response.StatusCode)" -ForegroundColor Green - - $data = $response.Content | ConvertFrom-Json - Write-Host "[OK] Attachments encontrados: $($data.Count)" -ForegroundColor Green - - if ($data.Count -gt 0) { - $data | Format-Table id, original_filename, file_size - } else { - Write-Host " (No hay attachments para este ticket)" -ForegroundColor Gray - } - - } catch { - Write-Host "[ERROR] En request: $($_.Exception.Message)" -ForegroundColor Red - Write-Host "Status Code: $($_.Exception.Response.StatusCode.value__)" -ForegroundColor Red - } - -} catch { - Write-Host "[ERROR] En login: $($_.Exception.Message)" -ForegroundColor Red -}