diff --git a/AUDITORIA_INSPECCIONES.md b/AUDITORIA_INSPECCIONES.md new file mode 100644 index 0000000..5d3b167 --- /dev/null +++ b/AUDITORIA_INSPECCIONES.md @@ -0,0 +1,375 @@ +# Sistema de Auditoría y Edición de Inspecciones + +## ✅ Implementación Completa + +Se ha implementado un sistema completo de auditoría que permite a los administradores editar inspecciones completadas y mantener un registro detallado de todos los cambios realizados. + +--- + +## 🎯 Características Implementadas + +### **Backend** + +1. **Modelo de Auditoría** + - Tabla `inspection_audit_log` que registra todos los cambios + - Campos: inspection_id, answer_id, user_id, action, entity_type, field_name, old_value, new_value, comment, created_at + - Relaciones con inspections, answers y users + +2. **Endpoints de Auditoría** + - `GET /api/inspections/{id}/audit-log` - Obtener historial de cambios (solo admin) + - `PUT /api/answers/{id}/admin-edit` - Editar respuesta con registro automático (solo admin) + +3. **Registro Automático** + - Cada cambio registra: qué se cambió, valor anterior, valor nuevo, quién lo cambió, cuándo y por qué + - Recalcula puntos automáticamente si cambia el status + - Registra múltiples cambios en una sola edición + +### **Frontend** + +1. **Edición de Respuestas (Solo Admin)** + - Botón "✏️ Editar" en cada respuesta de inspecciones completadas + - Formulario inline con campos editables: + - Estado (OK, Advertencia, Crítico, N/A) + - Valor de respuesta (según tipo de pregunta) + - Observación + - Marcador de señalamiento + - Motivo del cambio (obligatorio) + - Validación: requiere explicar el motivo del cambio + +2. **Modal de Historial de Cambios** + - Botón "📜 Ver Historial de Cambios" en el footer del modal de inspección + - Lista cronológica de todos los cambios (más reciente primero) + - Para cada cambio muestra: + - Quién lo hizo (nombre del usuario) + - Cuándo (fecha y hora) + - Qué acción realizó + - Qué campo modificó + - Valor anterior vs valor nuevo (visual con colores) + - Motivo del cambio + - Iconos visuales según tipo de acción (➕✏️🗑️🔄) + +3. **Restricciones de Seguridad** + - Solo administradores pueden editar respuestas + - Solo administradores pueden ver el historial + - Solo inspecciones completadas pueden editarse + - Cada cambio requiere justificación obligatoria + +--- + +## 📋 Instrucciones de Uso + +### **Para Administradores** + +#### 1. Editar una Respuesta + +1. Abre el detalle de una inspección completada +2. Busca la respuesta que quieres modificar +3. Haz clic en el botón "✏️ Editar" junto a la respuesta +4. Modifica los campos necesarios: + - **Estado**: Cambia entre OK, Advertencia, Crítico o N/A + - **Valor de respuesta**: Solo si la pregunta no es pass/fail + - **Observación**: Agrega o modifica comentarios + - **Señalado**: Marca o desmarca el flag de atención +5. **Importante**: Escribe el motivo del cambio en el campo "Motivo del cambio" +6. Haz clic en "Guardar Cambios" +7. El sistema: + - Actualiza la respuesta + - Recalcula los puntos automáticamente + - Registra cada cambio en el log de auditoría + - Recarga la inspección con los datos actualizados + +**Nota**: No puedes guardar sin escribir un motivo del cambio. + +#### 2. Ver Historial de Cambios + +1. Abre el detalle de cualquier inspección +2. En el footer, haz clic en "📜 Ver Historial de Cambios" +3. Se abrirá un modal con la bitácora completa +4. Revisa: + - Todos los cambios realizados por administradores + - Orden cronológico (más recientes primero) + - Detalles completos de cada modificación + - Quién, cuándo, qué y por qué + +#### 3. Tipos de Cambios Registrados + +El sistema registra automáticamente: +- **answer_value**: Cambio en la respuesta +- **status**: Cambio en el estado (OK/Advertencia/Crítico/N/A) +- **comment**: Cambio en las observaciones +- **is_flagged**: Marcado o desmarcado de señalamiento +- **points_earned**: Recálculo automático de puntos + +### **Para Mecánicos** + +- No pueden editar inspecciones completadas +- No pueden ver el historial de cambios +- Solo ven el estado final de las respuestas + +--- + +## 🗄️ Migración de Base de Datos + +### **Ejecutar SQL** + +```bash +# Usando psql +psql -U tu_usuario -d tu_database -f migrations/add_inspection_audit_log.sql + +# O directamente +psql -U tu_usuario -d tu_database +``` + +Luego ejecuta: + +```sql +-- Crear tabla de auditoría +CREATE TABLE IF NOT EXISTS inspection_audit_log ( + id SERIAL PRIMARY KEY, + inspection_id INTEGER NOT NULL REFERENCES inspections(id) ON DELETE CASCADE, + answer_id INTEGER REFERENCES answers(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + field_name VARCHAR(100), + old_value TEXT, + new_value TEXT, + comment TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Crear índices +CREATE INDEX idx_audit_log_inspection ON inspection_audit_log(inspection_id); +CREATE INDEX idx_audit_log_answer ON inspection_audit_log(answer_id); +CREATE INDEX idx_audit_log_user ON inspection_audit_log(user_id); +CREATE INDEX idx_audit_log_created_at ON inspection_audit_log(created_at DESC); +``` + +### **Reiniciar Backend** + +```bash +# Si usas Docker +docker-compose restart backend + +# Si corres directamente +# Ctrl+C y volver a ejecutar +python -m uvicorn app.main:app --reload +``` + +--- + +## 🔍 Ejemplos de Uso + +### Ejemplo 1: Corregir Estado de Respuesta + +**Escenario**: Un mecánico marcó "Crítico" por error cuando debía ser "Advertencia" + +**Pasos**: +1. Admin abre la inspección +2. Encuentra la respuesta con estado "Crítico" +3. Clic en "✏️ Editar" +4. Cambia Estado a "Advertencia" +5. En "Motivo del cambio": "Error del mecánico, no era crítico sino advertencia menor" +6. Guarda cambios +7. El sistema: + - Actualiza el status de "critical" a "warning" + - Recalcula puntos (de 0 a 50% del total) + - Registra: field_name="status", old_value="critical", new_value="warning" + - Registra: field_name="points_earned", old_value="0", new_value="5" + +**Resultado en Auditoría**: +``` +✏️ Juan Pérez (Admin) • 25 de noviembre de 2025, 14:30 +Acción: updated en answer (Respuesta #45) + +Campo modificado: status +Valor anterior: critical +Valor nuevo: warning + +Campo modificado: points_earned +Valor anterior: 0 +Valor nuevo: 5 + +Motivo: Error del mecánico, no era crítico sino advertencia menor +``` + +--- + +### Ejemplo 2: Agregar Observación Faltante + +**Escenario**: El mecánico no dejó observaciones en un item señalado + +**Pasos**: +1. Admin edita la respuesta +2. Agrega en "Observación": "Necesita cambio de aceite urgente" +3. Mantiene el señalamiento activado +4. Motivo: "Agregando observación faltante para clarity" +5. Guarda + +**Resultado**: Se registra el cambio de observación de vacío a texto. + +--- + +### Ejemplo 3: Revisión de Historial + +**Escenario**: Auditoría mensual de cambios en inspecciones + +**Pasos**: +1. Admin abre inspección +2. Clic en "📜 Ver Historial de Cambios" +3. Revisa todos los cambios del mes +4. Verifica justificaciones +5. Identifica patrones de errores comunes + +--- + +## 📊 Base de Datos + +### Estructura de `inspection_audit_log` + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | SERIAL | ID único del registro | +| inspection_id | INTEGER | ID de la inspección modificada | +| answer_id | INTEGER | ID de la respuesta modificada (nullable) | +| user_id | INTEGER | ID del usuario que hizo el cambio | +| action | VARCHAR(50) | Tipo de acción (created, updated, deleted, status_changed) | +| entity_type | VARCHAR(50) | Tipo de entidad (inspection, answer) | +| field_name | VARCHAR(100) | Nombre del campo modificado | +| old_value | TEXT | Valor anterior | +| new_value | TEXT | Valor nuevo | +| comment | TEXT | Motivo del cambio | +| created_at | TIMESTAMP | Fecha y hora del cambio | + +### Consultas Útiles + +```sql +-- Ver todos los cambios de una inspección +SELECT u.full_name, ial.action, ial.field_name, ial.old_value, ial.new_value, ial.comment, ial.created_at +FROM inspection_audit_log ial +JOIN users u ON ial.user_id = u.id +WHERE ial.inspection_id = 123 +ORDER BY ial.created_at DESC; + +-- Ver cambios realizados por un admin específico +SELECT i.id as inspection_id, i.vehicle_plate, ial.field_name, ial.comment, ial.created_at +FROM inspection_audit_log ial +JOIN inspections i ON ial.inspection_id = i.id +WHERE ial.user_id = 5 +ORDER BY ial.created_at DESC; + +-- Contar cambios por tipo +SELECT action, COUNT(*) as total +FROM inspection_audit_log +GROUP BY action +ORDER BY total DESC; + +-- Ver respuestas más editadas +SELECT answer_id, COUNT(*) as ediciones +FROM inspection_audit_log +WHERE answer_id IS NOT NULL +GROUP BY answer_id +ORDER BY ediciones DESC +LIMIT 10; + +-- Cambios en los últimos 7 días +SELECT i.id, i.vehicle_plate, u.full_name, ial.field_name, ial.created_at +FROM inspection_audit_log ial +JOIN inspections i ON ial.inspection_id = i.id +JOIN users u ON ial.user_id = u.id +WHERE ial.created_at >= NOW() - INTERVAL '7 days' +ORDER BY ial.created_at DESC; +``` + +--- + +## ⚠️ Notas Importantes + +1. **Solo Admins Pueden Editar** + - Los mecánicos NO pueden editar inspecciones completadas + - Solo usuarios con rol `admin` tienen acceso + +2. **Solo Inspecciones Completadas** + - No se pueden editar inspecciones en estado "draft" + - El botón de editar solo aparece en inspecciones completadas + +3. **Motivo Obligatorio** + - Cada cambio DEBE tener una justificación + - El campo "Motivo del cambio" es obligatorio + - No se puede guardar sin completarlo + +4. **Recalculo Automático** + - Al cambiar el status, los puntos se recalculan automáticamente + - OK = 100% de puntos + - Warning = 50% de puntos + - Critical/NA = 0% de puntos + +5. **Registro Completo** + - Cada campo modificado genera un registro separado + - Se guarda el valor anterior y el nuevo + - Se registra quién y cuándo hizo el cambio + - No se pueden borrar registros de auditoría + +6. **Cascada en Borrado** + - Si se borra una inspección, se borran sus logs + - Si se borra una respuesta, se borran sus logs + - Los logs del usuario permanecen aunque se borre el usuario + +--- + +## 🐛 Troubleshooting + +### Problema: "No puedo editar una respuesta" + +**Solución**: +1. Verificar que eres admin: `SELECT role FROM users WHERE id = X;` +2. Verificar que la inspección está completada: `SELECT status FROM inspections WHERE id = Y;` +3. Verificar que el botón "✏️ Editar" aparece +4. Revisar consola del navegador para errores + +### Problema: "Error al guardar cambios" + +**Solución**: +1. Verificar que completaste el campo "Motivo del cambio" +2. Verificar token de autenticación válido +3. Revisar logs del backend para errores específicos +4. Verificar que la tabla `inspection_audit_log` existe + +### Problema: "No veo el historial de cambios" + +**Solución**: +1. Verificar que eres admin +2. Verificar que hay cambios registrados: `SELECT * FROM inspection_audit_log WHERE inspection_id = X;` +3. Limpiar caché del navegador (Ctrl+Shift+R) +4. Revisar consola del navegador para errores de API + +### Problema: "Los puntos no se recalculan correctamente" + +**Solución**: +1. Verificar la lógica en el backend (main.py, admin_edit_answer) +2. Revisar que la pregunta tiene `points` configurados +3. Verificar logs de auditoría para ver si se registró el cambio de puntos +4. Recalcular manualmente si es necesario + +--- + +## 🎉 Resumen + +✅ **Backend**: Sistema completo de auditoría con registro automático +✅ **Frontend**: Edición inline + modal de historial +✅ **Base de Datos**: Tabla de auditoría con índices optimizados +✅ **Seguridad**: Solo admins, motivo obligatorio, registro inmutable +✅ **Documentación**: Completa con ejemplos y troubleshooting + +El sistema está listo para usar después de ejecutar la migración SQL y reiniciar el backend. + +--- + +## 📈 Beneficios + +1. **Trazabilidad Completa**: Saber quién cambió qué y cuándo +2. **Auditoría**: Cumplimiento de normas de calidad y transparencia +3. **Corrección de Errores**: Admins pueden corregir errores sin perder datos +4. **Accountability**: Cada cambio requiere justificación documentada +5. **Historial Inmutable**: Los registros no se pueden borrar ni modificar +6. **Reportes**: Base de datos lista para generar reportes de cambios diff --git a/PERMISOS_CHECKLIST.md b/PERMISOS_CHECKLIST.md new file mode 100644 index 0000000..15e2b6a --- /dev/null +++ b/PERMISOS_CHECKLIST.md @@ -0,0 +1,283 @@ +# Sistema de Permisos por Mecánico - Checklists + +## ✅ Implementación Completa + +Se ha implementado un sistema completo de permisos que permite controlar qué mecánicos pueden usar cada checklist. + +--- + +## 🎯 Características Implementadas + +### **Backend** + +1. **Nueva Tabla de Permisos** + - Tabla `checklist_permissions` con relación many-to-many + - Constraint UNIQUE para evitar duplicados + - Índices optimizados para consultas rápidas + +2. **Lógica de Acceso** + - **Acceso Global**: Si un checklist NO tiene permisos registrados, todos los mecánicos pueden usarlo + - **Acceso Restringido**: Si tiene permisos, solo esos mecánicos específicos pueden verlo + - **Admins**: Siempre ven todos los checklists + +3. **Endpoints Actualizados** + - `GET /api/checklists`: Filtra automáticamente por permisos del mecánico + - `POST /api/checklists`: Guarda permisos al crear + - `PUT /api/checklists/{id}`: Actualiza permisos al editar + - Incluye `allowed_mechanics` en las respuestas + +### **Frontend** + +1. **Creación de Checklists** + - Selector de mecánicos con checkboxes + - Opción "Acceso Global" (no seleccionar ninguno) + - Interfaz clara con íconos 🔐 y 🌍 + +2. **Visualización** + - Badge verde "🌍 Acceso Global" para checklists sin restricciones + - Badge índigo "🔐 Restringido - X mecánicos" para checklists restringidos + - Solo visible para administradores + +3. **Edición de Permisos** + - Botón "🔐 Permisos" en cada checklist (solo admins) + - Modal dedicado para editar permisos + - Cambios se aplican inmediatamente + +4. **Mensajes Mejorados** + - Mensaje específico para mecánicos sin checklists disponibles + - Instrucciones claras para contactar al administrador + +--- + +## 📋 Instrucciones de Uso + +### **Para Administradores** + +#### 1. Crear Checklist con Permisos + +1. Ve a la pestaña "Checklists" +2. Haz clic en "+ Crear Checklist" +3. Completa los datos del checklist +4. En la sección "🔐 Mecánicos Autorizados": + - **Para acceso global**: No selecciones ningún mecánico (deja todo sin marcar) + - **Para acceso restringido**: Marca los mecánicos que tendrán acceso +5. Haz clic en "Crear Checklist" + +#### 2. Editar Permisos de Checklist Existente + +1. Ve a la pestaña "Checklists" +2. Busca el checklist que quieres modificar +3. Haz clic en el botón "🔐 Permisos" +4. Modifica la selección de mecánicos: + - Marca "🌍 Todos los mecánicos" para acceso global + - O selecciona mecánicos específicos +5. Haz clic en "Guardar Permisos" + +**Nota**: Los cambios son inmediatos. Los mecánicos que pierdan acceso dejarán de ver el checklist instantáneamente. + +#### 3. Ver Estado de Permisos + +Cada tarjeta de checklist muestra: +- **🌍 Acceso Global - Todos los mecánicos**: Sin restricciones +- **🔐 Restringido - X mecánicos**: Solo esos mecánicos tienen acceso + +### **Para Mecánicos** + +- Solo verás los checklists donde tienes permiso +- Si no ves ningún checklist, contacta al administrador +- No puedes modificar permisos (solo el admin puede hacerlo) + +--- + +## 🗄️ Migración de Base de Datos + +### **Ejecutar SQL** + +```bash +# Usando psql +psql -U tu_usuario -d tu_database -f migrations/add_checklist_permissions.sql + +# O directamente +psql -U tu_usuario -d tu_database +``` + +Luego ejecuta: + +```sql +-- Crear tabla de permisos checklist-mecánico +CREATE TABLE IF NOT EXISTS checklist_permissions ( + id SERIAL PRIMARY KEY, + checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, + mechanic_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(checklist_id, mechanic_id) +); + +-- Crear índices +CREATE INDEX idx_checklist_permissions_checklist ON checklist_permissions(checklist_id); +CREATE INDEX idx_checklist_permissions_mechanic ON checklist_permissions(mechanic_id); + +-- Comentarios +COMMENT ON TABLE checklist_permissions IS 'Control de acceso de mecánicos a checklists. Si no hay registros para un checklist, todos los mecánicos tienen acceso.'; +``` + +### **Reiniciar Backend** + +Después de ejecutar el SQL, reinicia el backend para que los cambios en los modelos tomen efecto: + +```bash +# Si usas Docker +docker-compose restart backend + +# Si corres directamente +# Ctrl+C y volver a ejecutar +python -m uvicorn app.main:app --reload +``` + +--- + +## 🔍 Ejemplos de Uso + +### Ejemplo 1: Checklist para Todos los Mecánicos + +**Escenario**: Checklist de "Revisión Básica" que todos pueden usar + +**Configuración**: +- Al crear/editar: No seleccionar ningún mecánico +- El sistema muestra: "🌍 Acceso Global - Todos los mecánicos" + +**Resultado**: Todos los mecánicos ven este checklist + +--- + +### Ejemplo 2: Checklist Especializado + +**Escenario**: Checklist de "Mantenimiento Eléctrico" solo para mecánicos certificados + +**Configuración**: +1. Al crear/editar: Seleccionar solo mecánicos con certificación eléctrica +2. El sistema muestra: "🔐 Restringido - 3 mecánicos" + +**Resultado**: Solo esos 3 mecánicos ven este checklist + +--- + +### Ejemplo 3: Cambio de Permisos + +**Escenario**: Un mecánico nuevo se certifica en electricidad + +**Pasos**: +1. Admin hace clic en "🔐 Permisos" del checklist "Mantenimiento Eléctrico" +2. Marca al nuevo mecánico en la lista +3. Guarda cambios +4. El mecánico inmediatamente ve el checklist en su lista + +--- + +## 📊 Base de Datos + +### Estructura de `checklist_permissions` + +| Campo | Tipo | Descripción | +|-------|------|-------------| +| id | SERIAL | ID único de la relación | +| checklist_id | INTEGER | ID del checklist | +| mechanic_id | INTEGER | ID del mecánico autorizado | +| created_at | TIMESTAMP | Fecha de creación del permiso | + +### Consultas Útiles + +```sql +-- Ver permisos de un checklist específico +SELECT u.full_name, u.email +FROM checklist_permissions cp +JOIN users u ON cp.mechanic_id = u.id +WHERE cp.checklist_id = 1; + +-- Ver qué checklists puede usar un mecánico +SELECT c.name, c.description +FROM checklist_permissions cp +JOIN checklists c ON cp.checklist_id = c.id +WHERE cp.mechanic_id = 5; + +-- Checklists sin restricciones (acceso global) +SELECT c.id, c.name +FROM checklists c +LEFT JOIN checklist_permissions cp ON c.id = cp.checklist_id +WHERE cp.id IS NULL; + +-- Dar acceso a un mecánico a un checklist +INSERT INTO checklist_permissions (checklist_id, mechanic_id) +VALUES (1, 5) +ON CONFLICT (checklist_id, mechanic_id) DO NOTHING; + +-- Quitar acceso +DELETE FROM checklist_permissions +WHERE checklist_id = 1 AND mechanic_id = 5; + +-- Convertir checklist a acceso global (borrar todos los permisos) +DELETE FROM checklist_permissions WHERE checklist_id = 1; +``` + +--- + +## ⚠️ Notas Importantes + +1. **Permisos Vacíos = Acceso Global** + - Si un checklist NO tiene registros en `checklist_permissions`, TODOS los mecánicos pueden usarlo + - Es el comportamiento por defecto + +2. **Los Admins Siempre Ven Todo** + - Los usuarios con rol `admin` ven todos los checklists sin importar los permisos + - Útil para gestión y supervisión + +3. **Cambios Inmediatos** + - Al editar permisos, los cambios se aplican instantáneamente + - No requiere logout/login + +4. **Cascada en Borrado** + - Si borras un checklist, sus permisos se borran automáticamente + - Si borras un mecánico, sus permisos se borran automáticamente + +5. **Inspecciones Existentes** + - Las inspecciones ya creadas NO se ven afectadas por cambios de permisos + - Solo afecta la creación de nuevas inspecciones + +--- + +## 🐛 Troubleshooting + +### Problema: "Mecánico no ve ningún checklist" + +**Solución**: +1. Verificar que el mecánico esté activo: `SELECT is_active FROM users WHERE id = X;` +2. Verificar permisos: `SELECT * FROM checklist_permissions WHERE mechanic_id = X;` +3. Verificar si hay checklists con acceso global (sin permisos) +4. Verificar rol del usuario: debe ser `mechanic` o `mecanico` + +### Problema: "Error al crear/editar permisos" + +**Solución**: +1. Verificar que la tabla `checklist_permissions` existe +2. Verificar que los IDs de mecánicos son válidos +3. Revisar logs del backend para errores específicos +4. Verificar que el usuario es admin + +### Problema: "Los permisos no se aplican" + +**Solución**: +1. Hacer logout y login nuevamente +2. Verificar que el backend se reinició después de la migración +3. Limpiar caché del navegador (Ctrl+Shift+R) +4. Verificar en la base de datos que los permisos se guardaron correctamente + +--- + +## 🎉 Resumen + +✅ **Backend**: Filtrado automático por permisos +✅ **Frontend**: Interfaz completa para gestionar permisos +✅ **Base de Datos**: Migración lista para ejecutar +✅ **Documentación**: Completa con ejemplos + +El sistema está listo para usar después de ejecutar la migración SQL y reiniciar el backend. diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e00bf26..6d453d0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,3 +1,13 @@ +import os +# Variables de conexión S3/MinIO +MINIO_HOST = os.getenv('MINIO_HOST', 'localhost') +MINIO_SECURE = os.getenv('MINIO_SECURE', 'false').lower() == 'true' +MINIO_PORT = int(os.getenv('MINIO_PORT', '9000')) +MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'minioadmin') +MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'minioadmin') +MINIO_IMAGE_BUCKET = os.getenv('MINIO_IMAGE_BUCKET', 'images') +MINIO_PDF_BUCKET = os.getenv('MINIO_PDF_BUCKET', 'pdfs') +MINIO_ENDPOINT = f"{'https' if MINIO_SECURE else 'http'}://{MINIO_HOST}:{MINIO_PORT}" from pydantic_settings import BaseSettings class Settings(BaseSettings): @@ -15,6 +25,9 @@ class Settings(BaseSettings): # Environment ENVIRONMENT: str = "development" + # Notificaciones + NOTIFICACION_ENDPOINT: str = "" + # CORS - Orígenes permitidos separados por coma ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173" diff --git a/backend/app/main.py b/backend/app/main.py index e1c4f68..44cc9aa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,20 +1,117 @@ -from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File + +# ============= LOGO CONFIGURABLE ============= + +from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session, joinedload -from typing import List +from sqlalchemy import func, case +from typing import List, Optional import os -import shutil -from datetime import datetime, timedelta - +import boto3 +from botocore.client import Config +import uuid +from app.core import config as app_config from app.core.database import engine, get_db, Base from app.core.security import verify_password, get_password_hash, create_access_token, decode_access_token from app import models, schemas +import shutil +from datetime import datetime, timedelta +import sys +import requests + +# Función para enviar notificaciones al webhook +def send_answer_notification(answer, question, mechanic, db): + """Envía notificación al webhook cuando se responde una pregunta marcada""" + try: + if not app_config.settings.NOTIFICACION_ENDPOINT: + print("No hay endpoint de notificación configurado") + return + + # Obtener datos de la inspección + inspection = db.query(models.Inspection).filter( + models.Inspection.id == answer.inspection_id + ).first() + + if not inspection: + return + + # Preparar datos para enviar + notification_data = { + "tipo": "respuesta_pregunta", + "pregunta": { + "id": question.id, + "texto": question.text, + "seccion": question.section + }, + "respuesta": { + "id": answer.id, + "valor": answer.answer_value, + "estado": answer.status, + "comentario": answer.comment, + "puntos": answer.points_earned + }, + "inspeccion": { + "id": inspection.id, + "vehiculo_placa": inspection.vehicle_plate, + "vehiculo_marca": inspection.vehicle_brand, + "vehiculo_modelo": inspection.vehicle_model, + "cliente": inspection.client_name, + "or_number": inspection.or_number + }, + "mecanico": { + "id": mechanic.id, + "nombre": mechanic.full_name, + "email": mechanic.email + }, + "timestamp": datetime.utcnow().isoformat() + } + + # Enviar al webhook + response = requests.post( + app_config.settings.NOTIFICACION_ENDPOINT, + json=notification_data, + timeout=5 + ) + + if response.status_code == 200: + print(f"✅ Notificación enviada para pregunta {question.id}") + else: + print(f"⚠️ Error al enviar notificación: {response.status_code}") + + except Exception as e: + print(f"❌ Error enviando notificación: {e}") + # No lanzamos excepción para no interrumpir el flujo normal + +BACKEND_VERSION = "1.0.25" +app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) + +# S3/MinIO configuration +S3_ENDPOINT = app_config.MINIO_ENDPOINT +S3_ACCESS_KEY = app_config.MINIO_ACCESS_KEY +S3_SECRET_KEY = app_config.MINIO_SECRET_KEY +S3_IMAGE_BUCKET = app_config.MINIO_IMAGE_BUCKET +S3_PDF_BUCKET = app_config.MINIO_PDF_BUCKET + +s3_client = boto3.client( + 's3', + endpoint_url=S3_ENDPOINT, + aws_access_key_id=S3_ACCESS_KEY, + aws_secret_access_key=S3_SECRET_KEY, + config=Config(signature_version='s3v4'), + region_name='us-east-1' +) # Crear tablas Base.metadata.create_all(bind=engine) -app = FastAPI(title="Checklist Inteligente API", version="1.0.0") +# Información visual al iniciar el backend +print("\n================ BACKEND STARTUP INFO ================") +print(f"Backend version: {BACKEND_VERSION}") +print(f"Database URL: {app_config.settings.DATABASE_URL}") +print(f"Environment: {app_config.settings.ENVIRONMENT}") +print(f"MinIO endpoint: {app_config.MINIO_ENDPOINT}") +print("====================================================\n", flush=True) # CORS app.add_middleware( @@ -80,6 +177,45 @@ def get_current_user( return user +@app.post("/api/config/logo", response_model=dict) +async def upload_logo( + file: UploadFile = File(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Sube un logo y lo guarda en MinIO, actualiza la configuración.""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden cambiar el logo") + + # Subir imagen a MinIO + file_extension = file.filename.split(".")[-1] + now = datetime.now() + folder = f"logo" + file_name = f"logo_{now.strftime('%Y%m%d_%H%M%S')}.{file_extension}" + s3_key = f"{folder}/{file_name}" + s3_client.upload_fileobj(file.file, S3_IMAGE_BUCKET, s3_key, ExtraArgs={"ContentType": file.content_type}) + logo_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{s3_key}" + + # Guardar en configuración (puedes tener una tabla Config o usar AIConfiguration) + config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() + if config: + config.logo_url = logo_url + db.commit() + db.refresh(config) + # Si no hay config, solo retorna la url + return {"logo_url": logo_url} + +@app.get("/api/config/logo", response_model=dict) +def get_logo_url( + db: Session = Depends(get_db) +): + config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() + if config and getattr(config, "logo_url", None): + return {"logo_url": config.logo_url} + # Default logo (puedes poner una url por defecto) + return {"logo_url": f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/logo/default_logo.png"} + + # ============= AUTH ENDPOINTS ============= @app.post("/api/auth/register", response_model=schemas.User) def register(user: schemas.UserCreate, db: Session = Depends(get_db)): @@ -481,7 +617,40 @@ def get_checklists( query = db.query(models.Checklist) if active_only: query = query.filter(models.Checklist.is_active == True) - return query.offset(skip).limit(limit).all() + + # Si es mecánico, solo ver checklists con permiso + if current_user.role == "mechanic": + # Obtener IDs de checklists con permiso o sin permisos (acceso global) + permitted_checklist_ids = db.query(models.ChecklistPermission.checklist_id).filter( + models.ChecklistPermission.mechanic_id == current_user.id + ).distinct().all() + permitted_ids = [id[0] for id in permitted_checklist_ids] + + # Checklists sin permisos = acceso global + checklists_without_permissions = db.query(models.Checklist.id).outerjoin( + models.ChecklistPermission + ).group_by(models.Checklist.id).having( + func.count(models.ChecklistPermission.id) == 0 + ).all() + global_ids = [id[0] for id in checklists_without_permissions] + + all_allowed_ids = list(set(permitted_ids + global_ids)) + if all_allowed_ids: + query = query.filter(models.Checklist.id.in_(all_allowed_ids)) + else: + # Si no hay permisos, devolver lista vacía + return [] + + checklists = query.offset(skip).limit(limit).all() + + # Agregar allowed_mechanics a cada checklist + for checklist in checklists: + permissions = db.query(models.ChecklistPermission.mechanic_id).filter( + models.ChecklistPermission.checklist_id == checklist.id + ).all() + checklist.allowed_mechanics = [p[0] for p in permissions] + + return checklists @app.get("/api/checklists/{checklist_id}", response_model=schemas.ChecklistWithQuestions) @@ -493,6 +662,12 @@ def get_checklist(checklist_id: int, db: Session = Depends(get_db)): if not checklist: raise HTTPException(status_code=404, detail="Checklist no encontrado") + # Agregar allowed_mechanics + permissions = db.query(models.ChecklistPermission.mechanic_id).filter( + models.ChecklistPermission.checklist_id == checklist.id + ).all() + checklist.allowed_mechanics = [p[0] for p in permissions] + return checklist @@ -505,10 +680,28 @@ def create_checklist( if current_user.role != "admin": raise HTTPException(status_code=403, detail="No autorizado") - db_checklist = models.Checklist(**checklist.dict(), created_by=current_user.id) + # Extraer mechanic_ids antes de crear el checklist + checklist_data = checklist.dict(exclude={'mechanic_ids'}) + mechanic_ids = checklist.mechanic_ids or [] + + db_checklist = models.Checklist(**checklist_data, created_by=current_user.id) db.add(db_checklist) + db.flush() # Para obtener el ID + + # Crear permisos para mecánicos seleccionados + for mechanic_id in mechanic_ids: + permission = models.ChecklistPermission( + checklist_id=db_checklist.id, + mechanic_id=mechanic_id + ) + db.add(permission) + db.commit() db.refresh(db_checklist) + + # Agregar allowed_mechanics a la respuesta + db_checklist.allowed_mechanics = mechanic_ids + return db_checklist @@ -526,11 +719,38 @@ def update_checklist( if not db_checklist: raise HTTPException(status_code=404, detail="Checklist no encontrado") - for key, value in checklist.dict(exclude_unset=True).items(): + # Extraer mechanic_ids si se envía + update_data = checklist.dict(exclude_unset=True, exclude={'mechanic_ids'}) + mechanic_ids = checklist.mechanic_ids + + # Actualizar campos del checklist + for key, value in update_data.items(): setattr(db_checklist, key, value) + # Si se proporcionan mechanic_ids, actualizar permisos + if mechanic_ids is not None: + # Eliminar permisos existentes + db.query(models.ChecklistPermission).filter( + models.ChecklistPermission.checklist_id == checklist_id + ).delete() + + # Crear nuevos permisos + for mechanic_id in mechanic_ids: + permission = models.ChecklistPermission( + checklist_id=checklist_id, + mechanic_id=mechanic_id + ) + db.add(permission) + db.commit() db.refresh(db_checklist) + + # Agregar allowed_mechanics a la respuesta + permissions = db.query(models.ChecklistPermission.mechanic_id).filter( + models.ChecklistPermission.checklist_id == checklist_id + ).all() + db_checklist.allowed_mechanics = [p[0] for p in permissions] + return db_checklist @@ -718,7 +938,86 @@ def complete_inspection( inspection.flagged_items_count = flagged_count inspection.status = "completed" inspection.completed_at = datetime.utcnow() - + + # Generar PDF con miniaturas de imágenes y subir a MinIO + from reportlab.lib.pagesizes import A4 + from reportlab.lib import colors + from reportlab.lib.units import inch + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.enums import TA_CENTER + from io import BytesIO + import requests + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30) + elements = [] + styles = getSampleStyleSheet() + title_style = styles['Title'] + normal_style = styles['Normal'] + header_style = ParagraphStyle('Header', parent=styles['Heading2'], alignment=TA_CENTER, spaceAfter=12) + # Portada + elements.append(Paragraph(f"Informe de Inspección #{inspection.id}", title_style)) + elements.append(Spacer(1, 12)) + elements.append(Paragraph(f"Vehículo: {inspection.vehicle_brand or ''} {inspection.vehicle_model or ''} - Placa: {inspection.vehicle_plate}", normal_style)) + elements.append(Paragraph(f"Cliente: {inspection.client_name or ''}", normal_style)) + mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first() + checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first() + elements.append(Paragraph(f"Mecánico: {mechanic.full_name if mechanic else ''}", normal_style)) + elements.append(Paragraph(f"Checklist: {checklist.name if checklist else ''}", normal_style)) + elements.append(Paragraph(f"Fecha: {inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else ''}", normal_style)) + elements.append(Spacer(1, 18)) + # Tabla de respuestas con miniaturas + answers = db.query(models.Answer).options(joinedload(models.Answer.media_files)).join(models.Question).filter(models.Answer.inspection_id == inspection_id).order_by(models.Question.section, models.Question.order).all() + table_data = [["Sección", "Pregunta", "Respuesta", "Estado", "Comentario", "Miniaturas"]] + for ans in answers: + question = ans.question + media_imgs = [] + for media in ans.media_files: + if media.file_type == "image": + try: + img_resp = requests.get(media.file_path) + if img_resp.status_code == 200: + img_bytes = BytesIO(img_resp.content) + rl_img = RLImage(img_bytes, width=0.7*inch, height=0.7*inch) + media_imgs.append(rl_img) + except Exception as e: + print(f"Error cargando imagen {media.file_path}: {e}") + row = [ + question.section or "", + question.text, + ans.answer_value, + ans.status, + ans.comment or "", + media_imgs if media_imgs else "" + ] + table_data.append(row) + table = Table(table_data, colWidths=[1.2*inch, 2.5*inch, 1*inch, 0.8*inch, 2*inch, 1.5*inch]) + table.setStyle(TableStyle([ + ('BACKGROUND', (0,0), (-1,0), colors.lightgrey), + ('TEXTCOLOR', (0,0), (-1,0), colors.black), + ('ALIGN', (0,0), (-1,-1), 'LEFT'), + ('VALIGN', (0,0), (-1,-1), 'TOP'), + ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), + ('FONTSIZE', (0,0), (-1,0), 10), + ('BOTTOMPADDING', (0,0), (-1,0), 8), + ('GRID', (0,0), (-1,-1), 0.5, colors.grey), + ])) + elements.append(table) + elements.append(Spacer(1, 18)) + elements.append(Paragraph(f"Generado por Checklist Inteligente - {datetime.now().strftime('%d/%m/%Y %H:%M')}", header_style)) + try: + doc.build(elements) + except Exception as e: + print(f"Error al generar PDF: {e}") + buffer.seek(0) + now = datetime.now() + folder = f"{now.year}/{now.month:02d}" + filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf" + s3_key = f"{folder}/{filename}" + buffer.seek(0) + s3_client.upload_fileobj(buffer, S3_PDF_BUCKET, s3_key, ExtraArgs={"ContentType": "application/pdf"}) + pdf_url = f"{S3_ENDPOINT}/{S3_PDF_BUCKET}/{s3_key}" + inspection.pdf_url = pdf_url db.commit() db.refresh(inspection) return inspection @@ -770,14 +1069,50 @@ def create_answer( elif answer.status == "warning": points_earned = int(question.points * 0.5) - db_answer = models.Answer( - **answer.dict(), - points_earned=points_earned - ) - db.add(db_answer) - db.commit() - db.refresh(db_answer) - return db_answer + # Buscar si ya existe una respuesta para esta inspección y pregunta + existing_answer = db.query(models.Answer).filter( + models.Answer.inspection_id == answer.inspection_id, + models.Answer.question_id == answer.question_id + ).first() + if existing_answer: + # Actualizar la respuesta existente + # Si status es pass/fail, no poner valor por defecto en answer_value + if answer.status in ["pass", "fail"] and not answer.answer_value: + existing_answer.answer_value = None + else: + existing_answer.answer_value = answer.answer_value + existing_answer.status = answer.status + existing_answer.comment = getattr(answer, "comment", None) + existing_answer.ai_analysis = getattr(answer, "ai_analysis", None) + existing_answer.is_flagged = getattr(answer, "is_flagged", False) + existing_answer.points_earned = points_earned + existing_answer.updated_at = datetime.utcnow() + db.commit() + db.refresh(existing_answer) + + # Enviar notificación si la pregunta lo requiere + if question.send_notification: + send_answer_notification(existing_answer, question, current_user, db) + + return existing_answer + else: + # Si status es pass/fail y no hay valor, no poner valor por defecto en answer_value + answer_data = answer.dict() + if answer.status in ["pass", "fail"] and not answer.answer_value: + answer_data["answer_value"] = None + db_answer = models.Answer( + **answer_data, + points_earned=points_earned + ) + db.add(db_answer) + db.commit() + db.refresh(db_answer) + + # Enviar notificación si la pregunta lo requiere + if question.send_notification: + send_answer_notification(db_answer, question, current_user, db) + + return db_answer @app.put("/api/answers/{answer_id}", response_model=schemas.Answer) @@ -813,6 +1148,142 @@ def update_answer( return db_answer +# ============= AUDIT LOG ENDPOINTS ============= +@app.get("/api/inspections/{inspection_id}/audit-log", response_model=List[schemas.AuditLog]) +def get_inspection_audit_log( + inspection_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Obtener el historial de cambios de una inspección""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden ver el historial") + + logs = db.query(models.InspectionAuditLog).filter( + models.InspectionAuditLog.inspection_id == inspection_id + ).order_by(models.InspectionAuditLog.created_at.desc()).all() + + # Agregar nombre de usuario a cada log + result = [] + for log in logs: + log_dict = { + "id": log.id, + "inspection_id": log.inspection_id, + "answer_id": log.answer_id, + "user_id": log.user_id, + "action": log.action, + "entity_type": log.entity_type, + "field_name": log.field_name, + "old_value": log.old_value, + "new_value": log.new_value, + "comment": log.comment, + "created_at": log.created_at, + "user_name": None + } + + user = db.query(models.User).filter(models.User.id == log.user_id).first() + if user: + log_dict["user_name"] = user.full_name or user.username + + result.append(schemas.AuditLog(**log_dict)) + + return result + + +@app.put("/api/answers/{answer_id}/admin-edit", response_model=schemas.Answer) +def admin_edit_answer( + answer_id: int, + answer_edit: schemas.AnswerEdit, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Editar una respuesta (solo admin) con registro de auditoría""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden editar respuestas") + + db_answer = db.query(models.Answer).filter(models.Answer.id == answer_id).first() + + if not db_answer: + raise HTTPException(status_code=404, detail="Respuesta no encontrada") + + # Registrar cambios en el log de auditoría + changes = [] + + if answer_edit.answer_value is not None and answer_edit.answer_value != db_answer.answer_value: + changes.append({ + "field_name": "answer_value", + "old_value": db_answer.answer_value, + "new_value": answer_edit.answer_value + }) + db_answer.answer_value = answer_edit.answer_value + + if answer_edit.status is not None and answer_edit.status != db_answer.status: + changes.append({ + "field_name": "status", + "old_value": db_answer.status, + "new_value": answer_edit.status + }) + + # Recalcular puntos + question = db.query(models.Question).filter( + models.Question.id == db_answer.question_id + ).first() + + old_points = db_answer.points_earned + if answer_edit.status == "ok": + db_answer.points_earned = question.points + elif answer_edit.status == "warning": + db_answer.points_earned = int(question.points * 0.5) + else: + db_answer.points_earned = 0 + + if old_points != db_answer.points_earned: + changes.append({ + "field_name": "points_earned", + "old_value": str(old_points), + "new_value": str(db_answer.points_earned) + }) + + db_answer.status = answer_edit.status + + if answer_edit.comment is not None and answer_edit.comment != db_answer.comment: + changes.append({ + "field_name": "comment", + "old_value": db_answer.comment or "", + "new_value": answer_edit.comment + }) + db_answer.comment = answer_edit.comment + + if answer_edit.is_flagged is not None and answer_edit.is_flagged != db_answer.is_flagged: + changes.append({ + "field_name": "is_flagged", + "old_value": str(db_answer.is_flagged), + "new_value": str(answer_edit.is_flagged) + }) + db_answer.is_flagged = answer_edit.is_flagged + + # Crear registros de auditoría para cada cambio + for change in changes: + audit_log = models.InspectionAuditLog( + inspection_id=db_answer.inspection_id, + answer_id=answer_id, + user_id=current_user.id, + action="updated", + entity_type="answer", + field_name=change["field_name"], + old_value=change["old_value"], + new_value=change["new_value"], + comment=answer_edit.edit_comment or "Editado por administrador" + ) + db.add(audit_log) + + db_answer.updated_at = datetime.utcnow() + db.commit() + db.refresh(db_answer) + + return db_answer + + # ============= MEDIA FILE ENDPOINTS ============= @app.post("/api/answers/{answer_id}/upload", response_model=schemas.MediaFile) async def upload_photo( @@ -826,28 +1297,24 @@ async def upload_photo( if not answer: raise HTTPException(status_code=404, detail="Respuesta no encontrada") - # Crear directorio si no existe - upload_dir = f"uploads/inspection_{answer.inspection_id}" - os.makedirs(upload_dir, exist_ok=True) - - # Guardar archivo + # Subir imagen a S3/MinIO file_extension = file.filename.split(".")[-1] - file_name = f"answer_{answer_id}_{datetime.now().timestamp()}.{file_extension}" - file_path = os.path.join(upload_dir, file_name) - - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) - + now = datetime.now() + folder = f"{now.year}/{now.month:02d}" + file_name = f"answer_{answer_id}_{uuid.uuid4().hex}.{file_extension}" + s3_key = f"{folder}/{file_name}" + s3_client.upload_fileobj(file.file, S3_IMAGE_BUCKET, s3_key, ExtraArgs={"ContentType": file.content_type}) + # Generar URL pública (ajusta si usas presigned) + image_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{s3_key}" # Crear registro en BD media_file = models.MediaFile( answer_id=answer_id, - file_path=file_path, + file_path=image_url, file_type="image" ) db.add(media_file) db.commit() db.refresh(media_file) - return media_file @@ -958,11 +1425,21 @@ def create_ai_configuration( # Desactivar configuraciones anteriores db.query(models.AIConfiguration).update({"is_active": False}) + # Determinar modelo por defecto según el proveedor si no se especifica + model_name = config.model_name + if not model_name: + if config.provider == "openai": + model_name = "gpt-4o" + elif config.provider == "gemini": + model_name = "gemini-2.5-pro" + else: + model_name = "default" + # Crear nueva configuración new_config = models.AIConfiguration( provider=config.provider, api_key=config.api_key, - model_name=config.model_name, + model_name=model_name, is_active=True ) @@ -1028,6 +1505,7 @@ def delete_ai_configuration( async def analyze_image( file: UploadFile = File(...), question_id: int = None, + custom_prompt: str = None, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user) ): @@ -1060,12 +1538,37 @@ async def analyze_image( try: # Construir prompt dinámico basado en la pregunta específica if question_obj: - # Prompt altamente específico para la pregunta - question_text = question_obj.text - question_type = question_obj.type - section = question_obj.section - - system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular. + # Usar prompt personalizado si está disponible + if custom_prompt: + # Prompt 100% personalizado por el administrador + system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular. + +INSTRUCCIONES ESPECÍFICAS PARA ESTA PREGUNTA: +{custom_prompt} + +PREGUNTA A RESPONDER: "{question_obj.text}" +Sección: {question_obj.section} + +Analiza la imagen siguiendo EXACTAMENTE las instrucciones proporcionadas arriba. + +VALIDACIÓN DE IMAGEN: +- Si la imagen NO corresponde al contexto de la pregunta (por ejemplo, si piden luces pero muestran motor), indica en "recommendation" que deben cambiar la foto +- Si la imagen es borrosa, oscura o no permite análisis, indica en "recommendation" que tomen otra foto más clara + +Responde en formato JSON: +{{ + "status": "ok|minor|critical", + "observations": "Análisis específico según el prompt personalizado", + "recommendation": "Si la imagen no es apropiada, indica 'Por favor tome una foto de [componente correcto]'. Si es apropiada, da recomendación técnica.", + "confidence": 0.0-1.0 +}}""" + else: + # Prompt altamente específico para la pregunta + question_text = question_obj.text + question_type = question_obj.type + section = question_obj.section + + system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular. PREGUNTA ESPECÍFICA A RESPONDER: "{question_text}" Sección: {section} @@ -1073,11 +1576,15 @@ Sección: {section} Analiza la imagen ÚNICAMENTE para responder esta pregunta específica. Sé directo y enfócate solo en lo que la pregunta solicita. +VALIDACIÓN DE IMAGEN: +- Si la imagen NO corresponde al contexto de la pregunta, indica en "recommendation" que deben cambiar la foto +- Si la imagen es borrosa o no permite análisis, indica en "recommendation" que tomen otra foto más clara + Responde en formato JSON: {{ "status": "ok|minor|critical", "observations": "Respuesta específica a: {question_text}", - "recommendation": "Acción si aplica", + "recommendation": "Si la imagen no es apropiada, indica 'Por favor tome una foto de [componente correcto]'. Si es apropiada, da acción técnica si aplica.", "confidence": 0.0-1.0 }} @@ -1088,7 +1595,7 @@ IMPORTANTE: - Si la pregunta es pass/fail, indica claramente si pasa o falla - Si la pregunta es bueno/regular/malo, indica el estado específico del componente""" - user_message = f"Inspecciona la imagen y responde específicamente: {question_text}" + user_message = f"Inspecciona la imagen y responde específicamente: {question_obj.text}" else: # Fallback para análisis general system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona: @@ -1273,6 +1780,372 @@ Responde en formato JSON: } +# ============= REPORTS ============= +@app.get("/api/reports/dashboard", response_model=schemas.DashboardData) +def get_dashboard_data( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + mechanic_id: Optional[int] = None, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Obtener datos del dashboard de informes""" + if current_user.role not in ["admin", "asesor"]: + raise HTTPException(status_code=403, detail="No tienes permisos para acceder a reportes") + + # Construir query base + query = db.query(models.Inspection) + + # Aplicar filtros de fecha + if start_date: + # Parsear fecha y establecer al inicio del día en UTC-3 + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) + if start.tzinfo is None: + start = start.replace(tzinfo=local_tz) + query = query.filter(models.Inspection.started_at >= start) + if end_date: + # Parsear fecha y establecer al final del día en UTC-3 + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) + if end.tzinfo is None: + end = end.replace(tzinfo=local_tz) + query = query.filter(models.Inspection.started_at <= end) + + # Filtro por mecánico + if mechanic_id: + query = query.filter(models.Inspection.mechanic_id == mechanic_id) + + # Solo inspecciones activas + query = query.filter(models.Inspection.is_active == True) + + # ESTADÍSTICAS GENERALES + total = query.count() + completed = query.filter(models.Inspection.status == "completed").count() + pending = total - completed + + # Score promedio + avg_score_result = query.filter( + models.Inspection.score.isnot(None), + models.Inspection.max_score.isnot(None), + models.Inspection.max_score > 0 + ).with_entities( + func.avg(models.Inspection.score * 100.0 / models.Inspection.max_score) + ).scalar() + avg_score = round(avg_score_result, 2) if avg_score_result else 0.0 + + # Items señalados + flagged_items = db.query(func.count(models.Answer.id))\ + .filter(models.Answer.is_flagged == True)\ + .join(models.Inspection)\ + .filter(models.Inspection.is_active == True) + + if start_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) + if start.tzinfo is None: + start = start.replace(tzinfo=local_tz) + flagged_items = flagged_items.filter(models.Inspection.started_at >= start) + if end_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) + if end.tzinfo is None: + end = end.replace(tzinfo=local_tz) + flagged_items = flagged_items.filter(models.Inspection.started_at <= end) + if mechanic_id: + flagged_items = flagged_items.filter(models.Inspection.mechanic_id == mechanic_id) + + total_flagged = flagged_items.scalar() or 0 + + stats = schemas.InspectionStats( + total_inspections=total, + completed_inspections=completed, + pending_inspections=pending, + completion_rate=round((completed / total * 100) if total > 0 else 0, 2), + avg_score=avg_score, + total_flagged_items=total_flagged + ) + + # RANKING DE MECÁNICOS + mechanic_stats = db.query( + models.User.id, + models.User.full_name, + func.count(models.Inspection.id).label('total'), + func.avg( + case( + (models.Inspection.max_score > 0, models.Inspection.score * 100.0 / models.Inspection.max_score), + else_=None + ) + ).label('avg_score'), + func.count(case((models.Inspection.status == 'completed', 1))).label('completed') + ).join(models.Inspection, models.Inspection.mechanic_id == models.User.id)\ + .filter(models.User.role.in_(['mechanic', 'mecanico']))\ + .filter(models.User.is_active == True)\ + .filter(models.Inspection.is_active == True) + + if start_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) + if start.tzinfo is None: + start = start.replace(tzinfo=local_tz) + mechanic_stats = mechanic_stats.filter(models.Inspection.started_at >= start) + if end_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) + if end.tzinfo is None: + end = end.replace(tzinfo=local_tz) + mechanic_stats = mechanic_stats.filter(models.Inspection.started_at <= end) + + mechanic_stats = mechanic_stats.group_by(models.User.id, models.User.full_name)\ + .order_by(func.count(models.Inspection.id).desc())\ + .all() + + mechanic_ranking = [ + schemas.MechanicRanking( + mechanic_id=m.id, + mechanic_name=m.full_name or "Sin nombre", + total_inspections=m.total, + avg_score=round(m.avg_score, 2) if m.avg_score else 0.0, + completion_rate=round((m.completed / m.total * 100) if m.total > 0 else 0, 2) + ) + for m in mechanic_stats if m.full_name + ] + + # ESTADÍSTICAS POR CHECKLIST + checklist_stats_query = db.query( + models.Checklist.id, + models.Checklist.name, + func.count(models.Inspection.id).label('total'), + func.avg( + case( + (models.Inspection.max_score > 0, models.Inspection.score * 100.0 / models.Inspection.max_score), + else_=None + ) + ).label('avg_score') + ).join(models.Inspection)\ + .filter(models.Inspection.is_active == True)\ + .filter(models.Checklist.is_active == True) + + if start_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) + if start.tzinfo is None: + start = start.replace(tzinfo=local_tz) + checklist_stats_query = checklist_stats_query.filter(models.Inspection.started_at >= start) + if end_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) + if end.tzinfo is None: + end = end.replace(tzinfo=local_tz) + checklist_stats_query = checklist_stats_query.filter(models.Inspection.started_at <= end) + if mechanic_id: + checklist_stats_query = checklist_stats_query.filter(models.Inspection.mechanic_id == mechanic_id) + + checklist_stats_query = checklist_stats_query.group_by(models.Checklist.id, models.Checklist.name) + checklist_stats_data = checklist_stats_query.all() + + checklist_stats = [ + schemas.ChecklistStats( + checklist_id=c.id, + checklist_name=c.name or "Sin nombre", + total_inspections=c.total, + avg_score=round(c.avg_score, 2) if c.avg_score else 0.0 + ) + for c in checklist_stats_data if c.name + ] + + # INSPECCIONES POR FECHA (últimos 30 días) + end_date_obj = datetime.fromisoformat(end_date) if end_date else datetime.now() + start_date_obj = datetime.fromisoformat(start_date) if start_date else end_date_obj - timedelta(days=30) + + inspections_by_date_query = db.query( + func.date(models.Inspection.started_at).label('date'), + func.count(models.Inspection.id).label('count') + ).filter( + models.Inspection.started_at.between(start_date_obj, end_date_obj), + models.Inspection.is_active == True + ) + + if mechanic_id: + inspections_by_date_query = inspections_by_date_query.filter( + models.Inspection.mechanic_id == mechanic_id + ) + + inspections_by_date_data = inspections_by_date_query.group_by( + func.date(models.Inspection.started_at) + ).all() + + inspections_by_date = { + str(d.date): d.count for d in inspections_by_date_data + } + + # RATIO PASS/FAIL + pass_fail_data = db.query( + models.Answer.answer_value, + func.count(models.Answer.id).label('count') + ).join(models.Inspection)\ + .filter(models.Inspection.is_active == True)\ + .filter(models.Answer.answer_value.in_(['pass', 'fail', 'good', 'bad', 'regular'])) + + if start_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) + if start.tzinfo is None: + start = start.replace(tzinfo=local_tz) + pass_fail_data = pass_fail_data.filter(models.Inspection.started_at >= start) + if end_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) + if end.tzinfo is None: + end = end.replace(tzinfo=local_tz) + pass_fail_data = pass_fail_data.filter(models.Inspection.started_at <= end) + if mechanic_id: + pass_fail_data = pass_fail_data.filter(models.Inspection.mechanic_id == mechanic_id) + + pass_fail_data = pass_fail_data.group_by(models.Answer.answer_value).all() + + pass_fail_ratio = {d.answer_value: d.count for d in pass_fail_data} + + return schemas.DashboardData( + stats=stats, + mechanic_ranking=mechanic_ranking, + checklist_stats=checklist_stats, + inspections_by_date=inspections_by_date, + pass_fail_ratio=pass_fail_ratio + ) + + +@app.get("/api/reports/inspections") +def get_inspections_report( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + mechanic_id: Optional[int] = None, + checklist_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = 100, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Obtener lista de inspecciones con filtros""" + if current_user.role not in ["admin", "asesor"]: + raise HTTPException(status_code=403, detail="No tienes permisos para acceder a reportes") + + # Query base con select_from explícito + query = db.query( + models.Inspection.id, + models.Inspection.vehicle_plate, + models.Inspection.checklist_id, + models.Checklist.name.label('checklist_name'), + models.User.full_name.label('mechanic_name'), + models.Inspection.status, + models.Inspection.score, + models.Inspection.max_score, + models.Inspection.started_at, + models.Inspection.completed_at, + func.coalesce( + func.count(case((models.Answer.is_flagged == True, 1))), + 0 + ).label('flagged_items') + ).select_from(models.Inspection)\ + .join(models.Checklist, models.Inspection.checklist_id == models.Checklist.id)\ + .join(models.User, models.Inspection.mechanic_id == models.User.id)\ + .outerjoin(models.Answer, models.Answer.inspection_id == models.Inspection.id)\ + .filter(models.Inspection.is_active == True) + + # Aplicar filtros + if start_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + start = datetime.fromisoformat(start_date).replace(hour=0, minute=0, second=0, microsecond=0) + if start.tzinfo is None: + start = start.replace(tzinfo=local_tz) + query = query.filter(models.Inspection.started_at >= start) + if end_date: + from datetime import timezone + local_tz = timezone(timedelta(hours=-3)) + end = datetime.fromisoformat(end_date).replace(hour=23, minute=59, second=59, microsecond=999999) + if end.tzinfo is None: + end = end.replace(tzinfo=local_tz) + query = query.filter(models.Inspection.started_at <= end) + if mechanic_id: + query = query.filter(models.Inspection.mechanic_id == mechanic_id) + if checklist_id: + query = query.filter(models.Inspection.checklist_id == checklist_id) + if status: + query = query.filter(models.Inspection.status == status) + + # Group by y order + query = query.group_by( + models.Inspection.id, + models.Checklist.name, + models.User.full_name + ).order_by(models.Inspection.started_at.desc())\ + .limit(limit) + + results = query.all() + + return [ + { + "id": r.id, + "vehicle_plate": r.vehicle_plate, + "checklist_id": r.checklist_id, + "checklist_name": r.checklist_name or "Sin nombre", + "mechanic_name": r.mechanic_name or "Sin nombre", + "status": r.status, + "score": r.score, + "max_score": r.max_score, + "flagged_items": r.flagged_items, + "started_at": r.started_at.isoformat() if r.started_at else None, + "completed_at": r.completed_at.isoformat() if r.completed_at else None + } + for r in results + ] + + +@app.get("/api/inspections/{inspection_id}/pdf") +def export_inspection_to_pdf( + inspection_id: int, + current_user: models.User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Descargar el PDF guardado en MinIO para la inspección""" + from fastapi.responses import StreamingResponse + import requests + # Obtener inspección + inspection = db.query(models.Inspection).filter( + models.Inspection.id == inspection_id + ).first() + if not inspection: + raise HTTPException(status_code=404, detail="Inspección no encontrada") + if current_user.role not in ["admin", "asesor"] and inspection.mechanic_id != current_user.id: + raise HTTPException(status_code=403, detail="No tienes permisos para ver esta inspección") + # Si existe pdf_url, descargar desde MinIO y devolverlo + if inspection.pdf_url: + try: + pdf_resp = requests.get(inspection.pdf_url, stream=True) + if pdf_resp.status_code == 200: + filename = inspection.pdf_url.split("/")[-1] + return StreamingResponse(pdf_resp.raw, media_type="application/pdf", headers={ + "Content-Disposition": f"attachment; filename={filename}" + }) + else: + raise HTTPException(status_code=404, detail="No se pudo descargar el PDF desde MinIO") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al descargar PDF: {e}") + else: + raise HTTPException(status_code=404, detail="La inspección no tiene PDF generado") + + # ============= HEALTH CHECK ============= @app.get("/") def root(): diff --git a/backend/app/models.py b/backend/app/models.py index 11bfb4b..a88659d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -10,7 +10,7 @@ class User(Base): username = Column(String(50), unique=True, index=True, nullable=False) email = Column(String(100), unique=True, index=True) password_hash = Column(String(255), nullable=False) - role = Column(String(20), nullable=False) # admin, mechanic + role = Column(String(20), nullable=False) # admin, mechanic, asesor full_name = Column(String(100)) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -55,6 +55,7 @@ class Checklist(Base): creator = relationship("User", back_populates="checklists_created") questions = relationship("Question", back_populates="checklist", cascade="all, delete-orphan") inspections = relationship("Inspection", back_populates="checklist") + permissions = relationship("ChecklistPermission", back_populates="checklist", cascade="all, delete-orphan") class Question(Base): @@ -71,11 +72,22 @@ class Question(Base): allow_photos = Column(Boolean, default=True) max_photos = Column(Integer, default=3) requires_comment_on_fail = Column(Boolean, default=False) + send_notification = Column(Boolean, default=False) + + # Conditional logic + parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True) + show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta + + # AI Analysis + ai_prompt = Column(Text, nullable=True) # Prompt personalizado para análisis de IA de esta pregunta + created_at = Column(DateTime(timezone=True), server_default=func.now()) # Relationships checklist = relationship("Checklist", back_populates="questions") answers = relationship("Answer", back_populates="question") + subquestions = relationship("Question", backref="parent", remote_side=[id]) + class Inspection(Base): @@ -116,6 +128,7 @@ class Inspection(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + pdf_url = Column(String(500)) # URL del PDF en S3 # Relationships checklist = relationship("Checklist", back_populates="inspections") mechanic = relationship("User", back_populates="inspections") @@ -169,7 +182,44 @@ class AIConfiguration(Base): id = Column(Integer, primary_key=True, index=True) provider = Column(String(50), nullable=False) # openai, gemini api_key = Column(Text, nullable=False) - model_name = Column(String(100), nullable=False) + model_name = Column(String(100), nullable=True) + logo_url = Column(Text, nullable=True) # URL del logo configurable is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + +class ChecklistPermission(Base): + """Tabla intermedia para permisos de checklist por mecánico""" + __tablename__ = "checklist_permissions" + + id = Column(Integer, primary_key=True, index=True) + checklist_id = Column(Integer, ForeignKey("checklists.id", ondelete="CASCADE"), nullable=False) + mechanic_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + checklist = relationship("Checklist", back_populates="permissions") + mechanic = relationship("User") + + +class InspectionAuditLog(Base): + """Registro de auditoría para cambios en inspecciones y respuestas""" + __tablename__ = "inspection_audit_log" + + id = Column(Integer, primary_key=True, index=True) + inspection_id = Column(Integer, ForeignKey("inspections.id", ondelete="CASCADE"), nullable=False) + answer_id = Column(Integer, ForeignKey("answers.id", ondelete="CASCADE"), nullable=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + action = Column(String(50), nullable=False) # created, updated, deleted, status_changed + entity_type = Column(String(50), nullable=False) # inspection, answer + field_name = Column(String(100), nullable=True) # Campo modificado + old_value = Column(Text, nullable=True) # Valor anterior + new_value = Column(Text, nullable=True) # Valor nuevo + comment = Column(Text, nullable=True) # Comentario del cambio + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + inspection = relationship("Inspection") + answer = relationship("Answer") + user = relationship("User") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 72ab026..5ba49b2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -70,10 +70,16 @@ class ChecklistBase(BaseModel): logo_url: Optional[str] = None class ChecklistCreate(ChecklistBase): - pass + mechanic_ids: Optional[List[int]] = [] # IDs de mecánicos autorizados -class ChecklistUpdate(ChecklistBase): +class ChecklistUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + ai_mode: Optional[str] = None + scoring_enabled: Optional[bool] = None + logo_url: Optional[str] = None is_active: Optional[bool] = None + mechanic_ids: Optional[List[int]] = None # IDs de mecánicos autorizados class Checklist(ChecklistBase): id: int @@ -81,6 +87,7 @@ class Checklist(ChecklistBase): is_active: bool created_by: int created_at: datetime + allowed_mechanics: Optional[List[int]] = [] # IDs de mecánicos permitidos class Config: from_attributes = True @@ -97,6 +104,10 @@ class QuestionBase(BaseModel): allow_photos: bool = True max_photos: int = 3 requires_comment_on_fail: bool = False + send_notification: bool = False + parent_question_id: Optional[int] = None + show_if_answer: Optional[str] = None + ai_prompt: Optional[str] = None class QuestionCreate(QuestionBase): checklist_id: int @@ -113,6 +124,7 @@ class Question(QuestionBase): from_attributes = True + # Inspection Schemas class InspectionBase(BaseModel): or_number: Optional[str] = None @@ -212,7 +224,7 @@ class InspectionDetail(Inspection): class AIConfigurationBase(BaseModel): provider: str # openai, gemini api_key: str - model_name: str + model_name: Optional[str] = None class AIConfigurationCreate(AIConfigurationBase): pass @@ -236,3 +248,72 @@ class AIModelInfo(BaseModel): name: str provider: str description: Optional[str] = None + +# Reports Schemas +class InspectionStats(BaseModel): + total_inspections: int + completed_inspections: int + pending_inspections: int + completion_rate: float + avg_score: float + total_flagged_items: int + +class MechanicRanking(BaseModel): + mechanic_id: int + mechanic_name: str + total_inspections: int + avg_score: float + completion_rate: float + +class ChecklistStats(BaseModel): + checklist_id: int + checklist_name: str + total_inspections: int + avg_score: float + +class DashboardData(BaseModel): + stats: InspectionStats + mechanic_ranking: List[MechanicRanking] + checklist_stats: List[ChecklistStats] + inspections_by_date: dict + pass_fail_ratio: dict + +class InspectionListItem(BaseModel): + id: int + vehicle_plate: str + checklist_name: str + mechanic_name: str + status: str + score: Optional[int] + max_score: Optional[int] + flagged_items: int + started_at: Optional[datetime] + completed_at: Optional[datetime] + + +# Audit Log Schemas +class AuditLogBase(BaseModel): + action: str + entity_type: str + field_name: Optional[str] = None + old_value: Optional[str] = None + new_value: Optional[str] = None + comment: Optional[str] = None + +class AuditLog(AuditLogBase): + id: int + inspection_id: int + answer_id: Optional[int] = None + user_id: int + user_name: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + +class AnswerEdit(BaseModel): + answer_value: Optional[str] = None + status: Optional[str] = None + comment: Optional[str] = None + is_flagged: Optional[bool] = None + edit_comment: Optional[str] = None # Comentario del admin sobre por qué editó diff --git a/backend/docker.ps1 b/backend/docker.ps1 new file mode 100644 index 0000000..3218410 --- /dev/null +++ b/backend/docker.ps1 @@ -0,0 +1,25 @@ +Clear-Host + +# Input +$version = Read-Host "Ingrese el numero de version (ej: 1.0.34)" + +Write-Host "`n=== Construyendo imagen dymai/syntria-backend:$version ===`n" +docker build -t "dymai/syntria-backend:$version" . + +if ($LASTEXITCODE -ne 0) { + Write-Host "`nERROR: El build fallo. No se realizara el push." -ForegroundColor Red + pause + exit 1 +} + +Write-Host "`n=== Subiendo imagen a Docker Hub ===`n" +docker push "dymai/syntria-backend:$version" + +if ($LASTEXITCODE -ne 0) { + Write-Host "`nERROR: El push fallo." -ForegroundColor Red + pause + exit 1 +} + +Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green +pause diff --git a/backend/migrate_ai_prompt.py b/backend/migrate_ai_prompt.py new file mode 100644 index 0000000..bd717c2 --- /dev/null +++ b/backend/migrate_ai_prompt.py @@ -0,0 +1,32 @@ +""" +Migration: Add ai_prompt column to questions table +Date: 2025-11-21 +Description: Adds ai_prompt TEXT column for custom AI analysis prompts per question +""" + +# SQL Migration Script +sql_statements = [ + # Add ai_prompt column + """ + ALTER TABLE questions + ADD COLUMN ai_prompt TEXT; + """, +] + +# To apply this migration, run these SQL statements in your PostgreSQL database: +if __name__ == "__main__": + print("=" * 80) + print("MIGRATION: Add ai_prompt to questions table") + print("=" * 80) + print("\nExecute the following SQL statements in your PostgreSQL database:\n") + + for i, statement in enumerate(sql_statements, 1): + print(f"-- Statement {i}") + print(statement.strip()) + print() + + print("=" * 80) + print("\nTo verify the migration:") + print("SELECT column_name, data_type FROM information_schema.columns") + print("WHERE table_name = 'questions' AND column_name = 'ai_prompt';") + print("=" * 80) diff --git a/backend/migrate_conditional_questions.py b/backend/migrate_conditional_questions.py new file mode 100644 index 0000000..38b7ffc --- /dev/null +++ b/backend/migrate_conditional_questions.py @@ -0,0 +1,45 @@ +""" +Migration script to add conditional questions support +Run this script to add parent_question_id and show_if_answer columns +""" +from sqlalchemy import create_engine, text +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://checklist_user:checklist_pass_2024@localhost:5432/checklist_db") + +engine = create_engine(DATABASE_URL) + +migrations = [ + """ + ALTER TABLE questions + ADD COLUMN IF NOT EXISTS parent_question_id INTEGER REFERENCES questions(id) ON DELETE CASCADE; + """, + """ + ALTER TABLE questions + ADD COLUMN IF NOT EXISTS show_if_answer VARCHAR(50); + """, + """ + CREATE INDEX IF NOT EXISTS idx_questions_parent + ON questions(parent_question_id); + """ +] + +def run_migration(): + print("🔄 Starting migration for conditional questions...") + + with engine.connect() as conn: + for i, migration in enumerate(migrations, 1): + try: + conn.execute(text(migration)) + conn.commit() + print(f"✅ Migration {i}/{len(migrations)} completed") + except Exception as e: + print(f"❌ Error in migration {i}: {e}") + conn.rollback() + return False + + print("✅ All migrations completed successfully!") + return True + +if __name__ == "__main__": + run_migration() diff --git a/backend/requirements.txt b/backend/requirements.txt index 2347e38..7f2fefd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,3 +15,5 @@ google-generativeai==0.3.2 Pillow==10.2.0 reportlab==4.0.9 python-dotenv==1.0.0 +boto3==1.34.89 +requests==2.31.0 \ No newline at end of file diff --git a/backend/s3test.py b/backend/s3test.py new file mode 100644 index 0000000..606be5a --- /dev/null +++ b/backend/s3test.py @@ -0,0 +1,52 @@ +import boto3 +from botocore.client import Config +from botocore.exceptions import ClientError + +MINIO_ENDPOINT = "minioapi.rshtech.com.py" +MINIO_ACCESS_KEY = "6uEIJyKR2Fi4UXiSgIeG" +MINIO_SECRET_KEY = "8k0kYuvxD9ePuvjdxvDk8WkGhhlaaee8BxU1mqRW" +MINIO_IMAGE_BUCKET = "images" +MINIO_PDF_BUCKET = "pdfs" +MINIO_SECURE = True # HTTPS +MINIO_PORT = 443 + + +def main(): + try: + endpoint_url = f"https://{MINIO_ENDPOINT}:{MINIO_PORT}" if MINIO_SECURE \ + else f"http://{MINIO_ENDPOINT}:{MINIO_PORT}" + + # Crear cliente S3 compatible para MinIO + s3 = boto3.client( + "s3", + endpoint_url=endpoint_url, + aws_access_key_id=MINIO_ACCESS_KEY, + aws_secret_access_key=MINIO_SECRET_KEY, + config=Config(signature_version="s3v4"), + region_name="us-east-1" + ) + + print("🔍 Probando conexión…") + + # Listar buckets + response = s3.list_buckets() + print("✅ Conexión exitosa. Buckets disponibles:") + for bucket in response.get("Buckets", []): + print(f" - {bucket['Name']}") + + # Verificar acceso a buckets específicos + for bucket_name in [MINIO_IMAGE_BUCKET, MINIO_PDF_BUCKET]: + try: + s3.head_bucket(Bucket=bucket_name) + print(f"✔ Acceso OK al bucket: {bucket_name}") + except ClientError: + print(f"❌ No se pudo acceder al bucket: {bucket_name}") + + print("🎉 Test finalizado correctamente.") + + except Exception as e: + print("❌ Error:", e) + + +if __name__ == "__main__": + main() diff --git a/backend/test_minio.py b/backend/test_minio.py new file mode 100644 index 0000000..93ff372 --- /dev/null +++ b/backend/test_minio.py @@ -0,0 +1,30 @@ +import os +from app.core import config as app_config +import boto3 +from botocore.client import Config + +scheme = 'https' if app_config.MINIO_SECURE else 'http' +endpoint = f"{scheme}://{os.getenv('MINIO_ENDPOINT', 'localhost')}:{app_config.MINIO_PORT}" +access_key = os.getenv('MINIO_ACCESS_KEY', 'minioadmin') +secret_key = os.getenv('MINIO_SECRET_KEY', 'minioadmin') +bucket = os.getenv('MINIO_IMAGE_BUCKET', 'images') + +s3 = boto3.client( + 's3', + endpoint_url=endpoint, + aws_access_key_id=access_key, + aws_secret_access_key=secret_key, + config=Config(signature_version='s3v4'), + region_name='us-east-1' +) + +try: + # List buckets + response = s3.list_buckets() + print('Buckets:', [b['Name'] for b in response['Buckets']]) + # Upload test file + with open('test_minio.py', 'rb') as f: + s3.upload_fileobj(f, bucket, 'test_minio.py') + print(f'Archivo subido a bucket {bucket} correctamente.') +except Exception as e: + print('Error:', e) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index aaf0683..309f42e 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -20,7 +20,7 @@ services: retries: 5 backend: - image: dymai/syntria-backend:latest + image: dymai/syntria-backend:1.0.15 container_name: syntria-backend-prod restart: always depends_on: @@ -38,7 +38,7 @@ services: command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 frontend: - image: dymai/syntria-frontend:latest + image: dymai/syntria-frontend:1.0.24 container_name: syntria-frontend-prod restart: always depends_on: diff --git a/frontend/buildFront.ps1 b/frontend/buildFront.ps1 new file mode 100644 index 0000000..227f5fd --- /dev/null +++ b/frontend/buildFront.ps1 @@ -0,0 +1,27 @@ +Clear-Host + +# Pedir version +$version = Read-Host "Ingrese el numero de version (ej: 1.0.34)" + +Write-Host "`n=== Construyendo imagen dymai/syntria-frontend:$version ===`n" +docker build -f Dockerfile.prod -t "dymai/syntria-frontend:$version" . + +# Si build falla, no continuar +if ($LASTEXITCODE -ne 0) { + Write-Host "`nERROR: El build fallo. No se realizara el push." -ForegroundColor Red + pause + exit 1 +} + +Write-Host "`n=== Subiendo imagen a Docker Hub ===`n" +docker push "dymai/syntria-frontend:$version" + +# Si push falla, mostrar error +if ($LASTEXITCODE -ne 0) { + Write-Host "`nERROR: El push fallo." -ForegroundColor Red + pause + exit 1 +} + +Write-Host "`n=== Proceso completado exitosamente ===`n" -ForegroundColor Green +pause diff --git a/frontend/index.html b/frontend/index.html index 99a1d1b..9557828 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,8 +4,8 @@ - Syntria - Sistema Inteligente de Inspecciones - + AYUTEC - Sistema Inteligente de Inspecciones +
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a27d3f1..eddf82c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -45,6 +45,20 @@ function LoginPage({ setUser }) { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [logoUrl, setLogoUrl] = useState(null); + useEffect(() => { + const fetchLogo = async () => { + try { + const API_URL = import.meta.env.VITE_API_URL || ''; + const res = await fetch(`${API_URL}/api/config/logo`); + if (res.ok) { + const data = await res.json(); + setLogoUrl(data.logo_url); + } + } catch {} + }; + fetchLogo(); + }, []); const handleLogin = async (e) => { e.preventDefault() @@ -86,24 +100,13 @@ function LoginPage({ setUser }) { {/* Header con Logo */}
- {/* Logo S de Syntria */} -
- - - - - - - - - -
+ {logoUrl ? ( + Logo + ) : ( +
Sin logo
+ )}
-

Syntria

+

AYUTEC

Sistema Inteligente de Inspecciones

@@ -174,6 +177,20 @@ function DashboardPage({ user, setUser }) { const [activeTab, setActiveTab] = useState('checklists') const [activeInspection, setActiveInspection] = useState(null) const [sidebarOpen, setSidebarOpen] = useState(true) + const [logoUrl, setLogoUrl] = useState(null); + useEffect(() => { + const fetchLogo = async () => { + try { + const API_URL = import.meta.env.VITE_API_URL || ''; + const res = await fetch(`${API_URL}/api/config/logo`); + if (res.ok) { + const data = await res.json(); + setLogoUrl(data.logo_url); + } + } catch {} + }; + fetchLogo(); + }, []); useEffect(() => { loadData() @@ -273,23 +290,13 @@ function DashboardPage({ user, setUser }) {
{/* Logo y Nombre del Sistema */}
-
- - - - - - - - - -
+ {logoUrl ? ( + Logo + ) : ( +
Sin logo
+ )}
-

Syntria

+

AYUTEC

Sistema Inteligente de Inspecciones

@@ -339,10 +346,7 @@ function DashboardPage({ user, setUser }) { ) : activeTab === 'users' ? ( ) : activeTab === 'reports' ? ( -
-
📊
-
Módulo de Reportes en desarrollo...
-
+ ) : null}
@@ -366,65 +370,106 @@ function DashboardPage({ user, setUser }) { } function SettingsTab({ user }) { - const [aiConfig, setAiConfig] = useState(null) - const [availableModels, setAvailableModels] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) + // Estado para el logo + const [logoUrl, setLogoUrl] = useState(null); + const [logoUploading, setLogoUploading] = useState(false); + const [aiConfig, setAiConfig] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [formData, setFormData] = useState({ provider: 'openai', api_key: '', model_name: 'gpt-4o' - }) + }); useEffect(() => { - loadSettings() - }, []) + const fetchLogo = async () => { + try { + const API_URL = import.meta.env.VITE_API_URL || ''; + const token = localStorage.getItem('token'); + const res = await fetch(`${API_URL}/api/config/logo`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setLogoUrl(data.logo_url); + } + } catch {} + }; + fetchLogo(); + }, []); + + useEffect(() => { + loadSettings(); + }, []); const loadSettings = async () => { try { - const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || '' - + const token = localStorage.getItem('token'); + const API_URL = import.meta.env.VITE_API_URL || ''; // Cargar modelos disponibles const modelsRes = await fetch(`${API_URL}/api/ai/models`, { headers: { 'Authorization': `Bearer ${token}` } - }) - + }); if (modelsRes.ok) { - const models = await modelsRes.json() - setAvailableModels(models) + const models = await modelsRes.json(); + setAvailableModels(models); } - // Cargar configuración actual const configRes = await fetch(`${API_URL}/api/ai/configuration`, { headers: { 'Authorization': `Bearer ${token}` } - }) - + }); if (configRes.ok) { - const config = await configRes.json() - setAiConfig(config) + const config = await configRes.json(); + setAiConfig(config); setFormData({ provider: config.provider, api_key: config.api_key, model_name: config.model_name - }) + }); } - - setLoading(false) + setLoading(false); } catch (error) { - console.error('Error loading settings:', error) - setLoading(false) + console.error('Error loading settings:', error); + setLoading(false); } - } + }; + + const handleLogoUpload = async (e) => { + const file = e.target.files[0]; + if (!file) return; + setLogoUploading(true); + try { + const API_URL = import.meta.env.VITE_API_URL || ''; + const token = localStorage.getItem('token'); + const formDataLogo = new FormData(); + formDataLogo.append('file', file); + const res = await fetch(`${API_URL}/api/config/logo`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formDataLogo + }); + if (res.ok) { + const data = await res.json(); + setLogoUrl(data.logo_url); + alert('Logo actualizado correctamente'); + } else { + alert('Error al subir el logo'); + } + } catch { + alert('Error al subir el logo'); + } finally { + setLogoUploading(false); + } + }; const handleSave = async (e) => { - e.preventDefault() - setSaving(true) - + e.preventDefault(); + setSaving(true); try { - const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || '' - + const token = localStorage.getItem('token'); + const API_URL = import.meta.env.VITE_API_URL || ''; const response = await fetch(`${API_URL}/api/ai/configuration`, { method: 'POST', headers: { @@ -432,168 +477,119 @@ function SettingsTab({ user }) { 'Content-Type': 'application/json', }, body: JSON.stringify(formData), - }) - + }); if (response.ok) { - alert('Configuración guardada correctamente') - loadSettings() + alert('Configuración guardada correctamente'); + loadSettings(); } else { - alert('Error al guardar configuración') + alert('Error al guardar configuración'); } } catch (error) { - console.error('Error:', error) - alert('Error al guardar configuración') + console.error('Error:', error); + alert('Error al guardar configuración'); } finally { - setSaving(false) + setSaving(false); } - } + }; - const filteredModels = availableModels.filter(m => m.provider === formData.provider) + const filteredModels = availableModels.filter(m => m.provider === formData.provider); return (
-
-

Configuración de IA

-

- Configura el proveedor y modelo de IA para análisis de imágenes -

-
- - {loading ? ( -
-
Cargando configuración...
-
- ) : ( -
- {/* Provider Selection */} -
-

Proveedor de IA

- -
- - - -
-
- - {/* API Key */} -
-

API Key

- + +
+

Logo del Sistema

+
+ {logoUrl ? ( + Logo + ) : ( +
Sin logo
+ )}
- - setFormData({ ...formData, api_key: e.target.value })} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'} - required - /> -

- {formData.provider === 'openai' ? ( - <>Obtén tu API key en OpenAI Platform - ) : ( - <>Obtén tu API key en Google AI Studio - )} -

+ + {logoUploading && Subiendo...}
- - {/* Model Selection */} -
-

Modelo de IA

- -
- {filteredModels.map((model) => ( - - ))} -
-
- - {/* Current Status */} - {aiConfig && ( -
-
- -
-
Configuración Activa
-
- Proveedor: {aiConfig.provider} | - Modelo: {aiConfig.model_name} -
-
- Configurado el {new Date(aiConfig.created_at).toLocaleDateString('es-ES')} -
-
-
-
- )} - - {/* Save Button */} -
+

El logo se mostrará en el login y en la página principal.

+
+
+

Configuración de IA

+

Configura el proveedor y modelo de IA para análisis de imágenes

+
+
- - )} +
+
+

API Key

+
+ + setFormData({ ...formData, api_key: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'} + required + /> +

+ {formData.provider === 'openai' ? ( + <>Obtén tu API key en OpenAI Platform + ) : ( + <>Obtén tu API key en Google AI Studio + )} +

+
+
+
+

Modelo de IA

+
+ {filteredModels.map((model) => ( + + ))} +
+
+
+ +
+
- ) + ); } function APITokensTab({ user }) { @@ -891,7 +887,7 @@ function APITokensTab({ user }) { Incluye el token en el header Authorization de tus requests:

- Authorization: Bearer syntria_tu_token_aqui + Authorization: Bearer AYUTEC_tu_token_aqui
@@ -911,7 +907,11 @@ function QuestionsManagerModal({ checklist, onClose }) { points: 1, allow_photos: true, max_photos: 3, - requires_comment_on_fail: false + requires_comment_on_fail: false, + send_notification: false, + parent_question_id: null, + show_if_answer: '', + ai_prompt: '' }) useEffect(() => { @@ -966,7 +966,11 @@ function QuestionsManagerModal({ checklist, onClose }) { points: 1, allow_photos: true, max_photos: 3, - requires_comment_on_fail: false + requires_comment_on_fail: false, + send_notification: false, + parent_question_id: null, + show_if_answer: '', + ai_prompt: '' }) loadQuestions() } else { @@ -1093,6 +1097,92 @@ function QuestionsManagerModal({ checklist, onClose }) { /> + {/* Pregunta Condicional */} +
+

⚡ Pregunta Condicional (opcional)

+
+
+ + +

+ Esta pregunta aparecerá solo si se responde la pregunta padre +

+
+
+ + +

+ La pregunta solo se mostrará con esta respuesta +

+
+
+
+ + {/* AI Prompt - Solo visible si el checklist tiene IA habilitada */} + {checklist.ai_mode !== 'off' && ( +
+

🤖 Prompt de IA (opcional)

+
+ +