Compare commits

...

31 Commits

Author SHA1 Message Date
e7d64e0094 Merge pull request 'develop' (#1) from develop into main
Reviewed-on: #1
2025-11-26 01:15:20 +00:00
99f0952378 Actualizacion del Syntria por Ayutec en SideBar 2025-11-25 21:25:48 -03:00
195143b7ab ACtualizacion de Variable para cambiar dominio y dejar referenciado igual que en las variables de entorno de la Stack en Portainer 2025-11-25 21:23:58 -03:00
e373348ea6 Se Agrego SCript para automatizar Git 2025-11-25 21:17:43 -03:00
8116bb5956 backend y front actualizados se deja auditoria de cambios en chelists preguntas 1.0.34 2025-11-25 21:15:09 -03:00
1ed59058f7 backend y front actualizados se deja auditoria de cambios en chelists preguntas 1.0.33 2025-11-25 13:56:52 -03:00
e8d3e7ef7b backend y front trabajar por version de historial de cambios 2025-11-25 09:55:21 -03:00
1b31007eef backend y front estables ses creo modal para permisos de checklist backend 1.0.31 y frontend 1.0.33 2025-11-25 09:45:31 -03:00
eb94d8ccfc backend crear endpoitns para permisos de checklist por mecanico, 1.0.30 2025-11-25 09:22:38 -03:00
ad59152cce Corregir notificaicon check 2025-11-25 08:43:54 -03:00
093256382c Corregir modelos de IA 2025-11-25 06:32:47 -03:00
b6c7a9ed63 Refactorizacion de subir logo4.5 2025-11-25 06:15:45 -03:00
0b0763cfff Logo feature 1.0.27 v2 2025-11-25 05:59:36 -03:00
14073db2d9 Logo feature 1.0.27r 2025-11-25 05:55:45 -03:00
33b134e838 Agregar paginacion al FrontEnd de preguntas con flechitas si hay mas de 8 v1.0.28 2025-11-24 22:03:00 -03:00
bfe542159d Agregar paginacion al FrontEnd de preguntas v1.0.27 2025-11-24 21:58:01 -03:00
b1c0e05306 Corregir Duplicados en respuestas 1.0.25 Backend 2025-11-24 21:44:34 -03:00
cfef4f6f89 Corregir Duplicados en respuestas 1.0.24 Backend 2025-11-24 21:41:29 -03:00
855016c63d CORREGIR Miniaturas en Pdf Back 1.0.23 2025-11-24 21:29:09 -03:00
4234b71e17 Refactorizacion de PDFs y cambio de biblioteca backend 1.0.22-
-Funcional Usuarios, exportar Pdfs front v1.0.25
2025-11-24 21:10:18 -03:00
b304fbbb86 Refactorizacion de PDFs y cambio de biblioteca backend 1.0.19 v2 2025-11-24 18:13:01 -03:00
4428d17b27 Refactorizacion de PDFs y cambio de biblioteca backend 1.0.18 2025-11-24 17:45:14 -03:00
7788e869db Tests de PDFs con Minio los pdfs se guardan una sola vez ahora 2025-11-24 16:59:07 -03:00
de5900a4ab Migración a S3/MinIO para imágenes y PDFs, campo pdf_url en Inspection 2025-11-24 15:38:20 -03:00
871f81277c feat: Miniaturas en visualización de inspección - frontend v1.0.24
- Correccion de Exportar PDFs
-Correccion de Crear Usuarios
2025-11-24 14:16:43 -03:00
9c55f45c8a feat: Miniaturas en visualización de inspección - frontend v1.0.23
- Muestra miniaturas de fotos en cada pregunta usando media_files y photos
- Permite abrir imagen en grande al hacer click
- Actualización de docker-compose
2025-11-21 17:44:45 -03:00
3100473eb6 feat: Miniaturas en preguntas y galería completa en PDF - backend v1.0.14
- Miniaturas pequeñas (1x0.8 inch) en cada pregunta (4 por fila)
- Nueva página con galería completa al final del PDF
- Imágenes más grandes en galería (2.5x2 inch, 2 por fila)
- Captions con sección y pregunta en galería
- Mejor distribución del espacio en el PDF
2025-11-21 17:38:52 -03:00
ac7b582d8a fix: Ocultar campo AI Prompt cuando checklist no usa IA - frontend v1.0.22
- Campo AI Prompt solo visible cuando checklist.ai_mode !== 'off'
- Evita confusión en checklists sin IA habilitada
2025-11-21 02:53:54 -03:00
2b5424790a feat: AI prompts personalizados por pregunta - backend v1.0.13, frontend v1.0.21
Backend:
- Agregar campo ai_prompt a tabla questions
- Endpoint analyze-image recibe custom_prompt
- Validación de imagen apropiada (sugiere cambiar foto si no corresponde)
- Script de migración migrate_ai_prompt.py

Frontend:
- Campo de texto para configurar prompt de IA en editor de preguntas
- Envía custom_prompt al endpoint de análisis
- UI con fondo morado para sección de IA

La IA ahora analiza fotos según el contexto específico de cada pregunta
y sugiere cambiar la imagen si no corresponde al componente solicitado.
2025-11-21 02:41:50 -03:00
678a8cd24b fix: Corregir lógica de preguntas condicionales - frontend v1.0.20
- Sin valor por defecto en respuestas (usuario debe elegir)
- setAnswers funcional para evitar condiciones de carrera
- visibleQuestions actualizado en tiempo real
- Sub-preguntas se muestran/ocultan dinámicamente según respuesta padre
2025-11-21 02:28:27 -03:00
b730c4f7c2 feat: Editor de preguntas condicionales - frontend v1.0.19 2025-11-21 02:15:45 -03:00
19 changed files with 2763 additions and 605 deletions

375
AUDITORIA_INSPECCIONES.md Normal file
View File

@@ -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

283
PERMISOS_CHECKLIST.md Normal file
View File

@@ -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.

View File

@@ -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 from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -15,6 +25,9 @@ class Settings(BaseSettings):
# Environment # Environment
ENVIRONMENT: str = "development" ENVIRONMENT: str = "development"
# Notificaciones
NOTIFICACION_ENDPOINT: str = ""
# CORS - Orígenes permitidos separados por coma # CORS - Orígenes permitidos separados por coma
ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173" ALLOWED_ORIGINS: str = "http://localhost:3000,http://localhost:5173"

View File

@@ -1,21 +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.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, case from sqlalchemy import func, case
from typing import List, Optional from typing import List, Optional
import os import os
import shutil import boto3
from datetime import datetime, timedelta 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.database import engine, get_db, Base
from app.core.security import verify_password, get_password_hash, create_access_token, decode_access_token from app.core.security import verify_password, get_password_hash, create_access_token, decode_access_token
from app import models, schemas 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 # Crear tablas
Base.metadata.create_all(bind=engine) 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 # CORS
app.add_middleware( app.add_middleware(
@@ -81,6 +177,45 @@ def get_current_user(
return 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 ============= # ============= AUTH ENDPOINTS =============
@app.post("/api/auth/register", response_model=schemas.User) @app.post("/api/auth/register", response_model=schemas.User)
def register(user: schemas.UserCreate, db: Session = Depends(get_db)): def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
@@ -482,7 +617,40 @@ def get_checklists(
query = db.query(models.Checklist) query = db.query(models.Checklist)
if active_only: if active_only:
query = query.filter(models.Checklist.is_active == True) 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) @app.get("/api/checklists/{checklist_id}", response_model=schemas.ChecklistWithQuestions)
@@ -494,6 +662,12 @@ def get_checklist(checklist_id: int, db: Session = Depends(get_db)):
if not checklist: if not checklist:
raise HTTPException(status_code=404, detail="Checklist no encontrado") 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 return checklist
@@ -506,10 +680,28 @@ def create_checklist(
if current_user.role != "admin": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="No autorizado") 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.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.commit()
db.refresh(db_checklist) db.refresh(db_checklist)
# Agregar allowed_mechanics a la respuesta
db_checklist.allowed_mechanics = mechanic_ids
return db_checklist return db_checklist
@@ -527,11 +719,38 @@ def update_checklist(
if not db_checklist: if not db_checklist:
raise HTTPException(status_code=404, detail="Checklist no encontrado") 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) 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.commit()
db.refresh(db_checklist) 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 return db_checklist
@@ -719,7 +938,86 @@ def complete_inspection(
inspection.flagged_items_count = flagged_count inspection.flagged_items_count = flagged_count
inspection.status = "completed" inspection.status = "completed"
inspection.completed_at = datetime.utcnow() 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.commit()
db.refresh(inspection) db.refresh(inspection)
return inspection return inspection
@@ -771,14 +1069,50 @@ def create_answer(
elif answer.status == "warning": elif answer.status == "warning":
points_earned = int(question.points * 0.5) points_earned = int(question.points * 0.5)
db_answer = models.Answer( # Buscar si ya existe una respuesta para esta inspección y pregunta
**answer.dict(), existing_answer = db.query(models.Answer).filter(
points_earned=points_earned models.Answer.inspection_id == answer.inspection_id,
) models.Answer.question_id == answer.question_id
db.add(db_answer) ).first()
db.commit() if existing_answer:
db.refresh(db_answer) # Actualizar la respuesta existente
return db_answer # 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) @app.put("/api/answers/{answer_id}", response_model=schemas.Answer)
@@ -814,6 +1148,142 @@ def update_answer(
return db_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 ============= # ============= MEDIA FILE ENDPOINTS =============
@app.post("/api/answers/{answer_id}/upload", response_model=schemas.MediaFile) @app.post("/api/answers/{answer_id}/upload", response_model=schemas.MediaFile)
async def upload_photo( async def upload_photo(
@@ -827,28 +1297,24 @@ async def upload_photo(
if not answer: if not answer:
raise HTTPException(status_code=404, detail="Respuesta no encontrada") raise HTTPException(status_code=404, detail="Respuesta no encontrada")
# Crear directorio si no existe # Subir imagen a S3/MinIO
upload_dir = f"uploads/inspection_{answer.inspection_id}"
os.makedirs(upload_dir, exist_ok=True)
# Guardar archivo
file_extension = file.filename.split(".")[-1] file_extension = file.filename.split(".")[-1]
file_name = f"answer_{answer_id}_{datetime.now().timestamp()}.{file_extension}" now = datetime.now()
file_path = os.path.join(upload_dir, file_name) folder = f"{now.year}/{now.month:02d}"
file_name = f"answer_{answer_id}_{uuid.uuid4().hex}.{file_extension}"
with open(file_path, "wb") as buffer: s3_key = f"{folder}/{file_name}"
shutil.copyfileobj(file.file, buffer) 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 # Crear registro en BD
media_file = models.MediaFile( media_file = models.MediaFile(
answer_id=answer_id, answer_id=answer_id,
file_path=file_path, file_path=image_url,
file_type="image" file_type="image"
) )
db.add(media_file) db.add(media_file)
db.commit() db.commit()
db.refresh(media_file) db.refresh(media_file)
return media_file return media_file
@@ -959,11 +1425,21 @@ def create_ai_configuration(
# Desactivar configuraciones anteriores # Desactivar configuraciones anteriores
db.query(models.AIConfiguration).update({"is_active": False}) 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 # Crear nueva configuración
new_config = models.AIConfiguration( new_config = models.AIConfiguration(
provider=config.provider, provider=config.provider,
api_key=config.api_key, api_key=config.api_key,
model_name=config.model_name, model_name=model_name,
is_active=True is_active=True
) )
@@ -1029,6 +1505,7 @@ def delete_ai_configuration(
async def analyze_image( async def analyze_image(
file: UploadFile = File(...), file: UploadFile = File(...),
question_id: int = None, question_id: int = None,
custom_prompt: str = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user) current_user: models.User = Depends(get_current_user)
): ):
@@ -1061,12 +1538,37 @@ async def analyze_image(
try: try:
# Construir prompt dinámico basado en la pregunta específica # Construir prompt dinámico basado en la pregunta específica
if question_obj: if question_obj:
# Prompt altamente específico para la pregunta # Usar prompt personalizado si está disponible
question_text = question_obj.text if custom_prompt:
question_type = question_obj.type # Prompt 100% personalizado por el administrador
section = question_obj.section system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular.
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}" PREGUNTA ESPECÍFICA A RESPONDER: "{question_text}"
Sección: {section} Sección: {section}
@@ -1074,11 +1576,15 @@ Sección: {section}
Analiza la imagen ÚNICAMENTE para responder esta pregunta específica. Analiza la imagen ÚNICAMENTE para responder esta pregunta específica.
Sé directo y enfócate solo en lo que la pregunta solicita. 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: Responde en formato JSON:
{{ {{
"status": "ok|minor|critical", "status": "ok|minor|critical",
"observations": "Respuesta específica a: {question_text}", "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 "confidence": 0.0-1.0
}} }}
@@ -1089,7 +1595,7 @@ IMPORTANTE:
- Si la pregunta es pass/fail, indica claramente si pasa o falla - 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""" - 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: else:
# Fallback para análisis general # Fallback para análisis general
system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona: system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona:
@@ -1403,12 +1909,12 @@ def get_dashboard_data(
mechanic_ranking = [ mechanic_ranking = [
schemas.MechanicRanking( schemas.MechanicRanking(
mechanic_id=m.id, mechanic_id=m.id,
mechanic_name=m.full_name, mechanic_name=m.full_name or "Sin nombre",
total_inspections=m.total, total_inspections=m.total,
avg_score=round(m.avg_score, 2) if m.avg_score else 0.0, 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) completion_rate=round((m.completed / m.total * 100) if m.total > 0 else 0, 2)
) )
for m in mechanic_stats for m in mechanic_stats if m.full_name
] ]
# ESTADÍSTICAS POR CHECKLIST # ESTADÍSTICAS POR CHECKLIST
@@ -1449,11 +1955,11 @@ def get_dashboard_data(
checklist_stats = [ checklist_stats = [
schemas.ChecklistStats( schemas.ChecklistStats(
checklist_id=c.id, checklist_id=c.id,
checklist_name=c.name, checklist_name=c.name or "Sin nombre",
total_inspections=c.total, total_inspections=c.total,
avg_score=round(c.avg_score, 2) if c.avg_score else 0.0 avg_score=round(c.avg_score, 2) if c.avg_score else 0.0
) )
for c in checklist_stats_data for c in checklist_stats_data if c.name
] ]
# INSPECCIONES POR FECHA (últimos 30 días) # INSPECCIONES POR FECHA (últimos 30 días)
@@ -1593,8 +2099,8 @@ def get_inspections_report(
"id": r.id, "id": r.id,
"vehicle_plate": r.vehicle_plate, "vehicle_plate": r.vehicle_plate,
"checklist_id": r.checklist_id, "checklist_id": r.checklist_id,
"checklist_name": r.checklist_name, "checklist_name": r.checklist_name or "Sin nombre",
"mechanic_name": r.mechanic_name, "mechanic_name": r.mechanic_name or "Sin nombre",
"status": r.status, "status": r.status,
"score": r.score, "score": r.score,
"max_score": r.max_score, "max_score": r.max_score,
@@ -1612,232 +2118,32 @@ def export_inspection_to_pdf(
current_user: models.User = Depends(get_current_user), current_user: models.User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Exportar inspección a PDF con imágenes""" """Descargar el PDF guardado en MinIO para la inspección"""
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from reportlab.lib.pagesizes import letter, A4 import requests
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, Image as RLImage, PageBreak
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from io import BytesIO
import base64
# Obtener inspección # Obtener inspección
inspection = db.query(models.Inspection).filter( inspection = db.query(models.Inspection).filter(
models.Inspection.id == inspection_id models.Inspection.id == inspection_id
).first() ).first()
if not inspection: if not inspection:
raise HTTPException(status_code=404, detail="Inspección no encontrada") raise HTTPException(status_code=404, detail="Inspección no encontrada")
# Verificar permisos (admin, asesor o mecánico dueño)
if current_user.role not in ["admin", "asesor"] and inspection.mechanic_id != current_user.id: 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") raise HTTPException(status_code=403, detail="No tienes permisos para ver esta inspección")
# Si existe pdf_url, descargar desde MinIO y devolverlo
# Obtener datos relacionados if inspection.pdf_url:
checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first() try:
mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first() pdf_resp = requests.get(inspection.pdf_url, stream=True)
answers = db.query(models.Answer).options( if pdf_resp.status_code == 200:
joinedload(models.Answer.media_files) filename = inspection.pdf_url.split("/")[-1]
).join(models.Question).filter( return StreamingResponse(pdf_resp.raw, media_type="application/pdf", headers={
models.Answer.inspection_id == inspection_id "Content-Disposition": f"attachment; filename={filename}"
).order_by(models.Question.section, models.Question.order).all() })
else:
# Crear PDF en memoria raise HTTPException(status_code=404, detail="No se pudo descargar el PDF desde MinIO")
buffer = BytesIO() except Exception as e:
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30) raise HTTPException(status_code=500, detail=f"Error al descargar PDF: {e}")
# Contenedor para elementos del PDF
elements = []
styles = getSampleStyleSheet()
# Estilos personalizados
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#4338ca'),
spaceAfter=30,
alignment=TA_CENTER
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=16,
textColor=colors.HexColor('#4338ca'),
spaceAfter=12,
spaceBefore=12
)
# Título
elements.append(Paragraph("REPORTE DE INSPECCIÓN", title_style))
elements.append(Spacer(1, 20))
# Información general
info_data = [
['Checklist:', checklist.name if checklist else 'N/A'],
['Mecánico:', mechanic.full_name if mechanic else 'N/A'],
['Fecha:', inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'],
['Estado:', inspection.status.upper()],
['', ''],
['Vehículo', ''],
['Patente:', inspection.vehicle_plate or 'N/A'],
['Marca:', inspection.vehicle_brand or 'N/A'],
['Modelo:', inspection.vehicle_model or 'N/A'],
['Kilometraje:', f"{inspection.vehicle_km:,} km" if inspection.vehicle_km else 'N/A'],
['Cliente:', inspection.client_name or 'N/A'],
['OR Número:', inspection.or_number or 'N/A'],
]
if inspection.status == 'completed' and inspection.score is not None:
info_data.insert(4, ['Score:', f"{inspection.score}/{inspection.max_score} ({inspection.percentage:.1f}%)"])
info_data.insert(5, ['Items Señalados:', str(inspection.flagged_items_count)])
info_table = Table(info_data, colWidths=[2*inch, 4*inch])
info_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#e0e7ff')),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#4338ca')),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(info_table)
elements.append(Spacer(1, 30))
# Respuestas por sección
if answers:
elements.append(Paragraph("RESULTADOS DE INSPECCIÓN", heading_style))
elements.append(Spacer(1, 10))
current_section = None
for answer in answers:
question = answer.question
# Nueva sección
if question.section != current_section:
if current_section is not None:
elements.append(Spacer(1, 20))
current_section = question.section
section_style = ParagraphStyle(
'Section',
parent=styles['Heading3'],
fontSize=14,
textColor=colors.HexColor('#6366f1'),
spaceAfter=10
)
elements.append(Paragraph(f"{current_section}", section_style))
# Datos de la pregunta
answer_color = colors.white
if answer.is_flagged:
answer_color = colors.HexColor('#fee2e2')
elif answer.answer_value in ['pass', 'good']:
answer_color = colors.HexColor('#dcfce7')
elif answer.answer_value in ['fail', 'bad']:
answer_color = colors.HexColor('#fee2e2')
answer_text = answer.answer_value or answer.answer_text or 'N/A'
if answer.points_earned is not None:
answer_text += f" ({answer.points_earned} pts)"
question_data = [
[Paragraph(f"<b>{question.text}</b>", styles['Normal']), answer_text.upper()]
]
if answer.comment:
question_data.append([Paragraph(f"<i>Comentarios: {answer.comment}</i>", styles['Normal']), ''])
question_table = Table(question_data, colWidths=[4*inch, 2*inch])
question_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), answer_color),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
('ALIGN', (1, 0), (1, 0), 'CENTER'),
('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(question_table)
elements.append(Spacer(1, 5))
# Fotos adjuntas
if answer.media_files and len(answer.media_files) > 0:
elements.append(Spacer(1, 5))
photos_per_row = 2
photo_width = 2.5 * inch
photo_height = 2 * inch
for i in range(0, len(answer.media_files), photos_per_row):
photo_row = []
for media_file in answer.media_files[i:i+photos_per_row]:
try:
photo_path = media_file.file_path
# Si la foto es base64
if photo_path.startswith('data:image'):
img_data = base64.b64decode(photo_path.split(',')[1])
img_buffer = BytesIO(img_data)
img = RLImage(img_buffer, width=photo_width, height=photo_height)
else:
# Si es una ruta de archivo
full_path = os.path.join(os.getcwd(), photo_path)
if os.path.exists(full_path):
img = RLImage(full_path, width=photo_width, height=photo_height)
else:
continue
photo_row.append(img)
except Exception as e:
print(f"Error loading image: {e}")
continue
if photo_row:
photo_table = Table([photo_row])
photo_table.setStyle(TableStyle([
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(photo_table)
elements.append(Spacer(1, 10))
else: else:
elements.append(Paragraph("No hay respuestas registradas", styles['Normal'])) raise HTTPException(status_code=404, detail="La inspección no tiene PDF generado")
# Pie de página
elements.append(Spacer(1, 30))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=8,
textColor=colors.grey,
alignment=TA_CENTER
)
elements.append(Paragraph(f"Generado por Syntria - {datetime.now().strftime('%d/%m/%Y %H:%M')}", footer_style))
# Construir PDF
doc.build(elements)
# Preparar respuesta
buffer.seek(0)
filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf"
return StreamingResponse(
buffer,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
# ============= HEALTH CHECK ============= # ============= HEALTH CHECK =============

View File

@@ -55,6 +55,7 @@ class Checklist(Base):
creator = relationship("User", back_populates="checklists_created") creator = relationship("User", back_populates="checklists_created")
questions = relationship("Question", back_populates="checklist", cascade="all, delete-orphan") questions = relationship("Question", back_populates="checklist", cascade="all, delete-orphan")
inspections = relationship("Inspection", back_populates="checklist") inspections = relationship("Inspection", back_populates="checklist")
permissions = relationship("ChecklistPermission", back_populates="checklist", cascade="all, delete-orphan")
class Question(Base): class Question(Base):
@@ -71,11 +72,15 @@ class Question(Base):
allow_photos = Column(Boolean, default=True) allow_photos = Column(Boolean, default=True)
max_photos = Column(Integer, default=3) max_photos = Column(Integer, default=3)
requires_comment_on_fail = Column(Boolean, default=False) requires_comment_on_fail = Column(Boolean, default=False)
send_notification = Column(Boolean, default=False)
# Conditional logic # Conditional logic
parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True) parent_question_id = Column(Integer, ForeignKey("questions.id"), nullable=True)
show_if_answer = Column(String(50), nullable=True) # Valor que dispara esta pregunta 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships
@@ -123,6 +128,7 @@ class Inspection(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
pdf_url = Column(String(500)) # URL del PDF en S3
# Relationships # Relationships
checklist = relationship("Checklist", back_populates="inspections") checklist = relationship("Checklist", back_populates="inspections")
mechanic = relationship("User", back_populates="inspections") mechanic = relationship("User", back_populates="inspections")
@@ -176,7 +182,44 @@ class AIConfiguration(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
provider = Column(String(50), nullable=False) # openai, gemini provider = Column(String(50), nullable=False) # openai, gemini
api_key = Column(Text, nullable=False) 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) is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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")

View File

@@ -70,10 +70,16 @@ class ChecklistBase(BaseModel):
logo_url: Optional[str] = None logo_url: Optional[str] = None
class ChecklistCreate(ChecklistBase): 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 is_active: Optional[bool] = None
mechanic_ids: Optional[List[int]] = None # IDs de mecánicos autorizados
class Checklist(ChecklistBase): class Checklist(ChecklistBase):
id: int id: int
@@ -81,6 +87,7 @@ class Checklist(ChecklistBase):
is_active: bool is_active: bool
created_by: int created_by: int
created_at: datetime created_at: datetime
allowed_mechanics: Optional[List[int]] = [] # IDs de mecánicos permitidos
class Config: class Config:
from_attributes = True from_attributes = True
@@ -97,8 +104,10 @@ class QuestionBase(BaseModel):
allow_photos: bool = True allow_photos: bool = True
max_photos: int = 3 max_photos: int = 3
requires_comment_on_fail: bool = False requires_comment_on_fail: bool = False
send_notification: bool = False
parent_question_id: Optional[int] = None parent_question_id: Optional[int] = None
show_if_answer: Optional[str] = None show_if_answer: Optional[str] = None
ai_prompt: Optional[str] = None
class QuestionCreate(QuestionBase): class QuestionCreate(QuestionBase):
checklist_id: int checklist_id: int
@@ -215,7 +224,7 @@ class InspectionDetail(Inspection):
class AIConfigurationBase(BaseModel): class AIConfigurationBase(BaseModel):
provider: str # openai, gemini provider: str # openai, gemini
api_key: str api_key: str
model_name: str model_name: Optional[str] = None
class AIConfigurationCreate(AIConfigurationBase): class AIConfigurationCreate(AIConfigurationBase):
pass pass
@@ -280,3 +289,31 @@ class InspectionListItem(BaseModel):
flagged_items: int flagged_items: int
started_at: Optional[datetime] started_at: Optional[datetime]
completed_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ó

25
backend/docker.ps1 Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -15,3 +15,5 @@ google-generativeai==0.3.2
Pillow==10.2.0 Pillow==10.2.0
reportlab==4.0.9 reportlab==4.0.9
python-dotenv==1.0.0 python-dotenv==1.0.0
boto3==1.34.89
requests==2.31.0

52
backend/s3test.py Normal file
View File

@@ -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()

30
backend/test_minio.py Normal file
View File

@@ -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)

View File

@@ -20,7 +20,7 @@ services:
retries: 5 retries: 5
backend: backend:
image: dymai/syntria-backend:1.0.12 image: dymai/syntria-backend:1.0.15
container_name: syntria-backend-prod container_name: syntria-backend-prod
restart: always restart: always
depends_on: depends_on:
@@ -38,7 +38,7 @@ services:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
frontend: frontend:
image: dymai/syntria-frontend:1.0.18 image: dymai/syntria-frontend:1.0.24
container_name: syntria-frontend-prod container_name: syntria-frontend-prod
restart: always restart: always
depends_on: depends_on:

27
frontend/buildFront.ps1 Normal file
View File

@@ -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

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Syntria - Sistema Inteligente de Inspecciones</title> <title>AYUTEC - Sistema Inteligente de Inspecciones</title>
<meta name="description" content="Syntria: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" /> <meta name="description" content="AYUTEC: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
<div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg flex items-center justify-center"> <div className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">S</span> <span className="text-white font-bold text-lg">S</span>
</div> </div>
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Syntria</h2> <h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
</div> </div>
)} )}
<button <button

26
gitUpdate.ps1 Normal file
View File

@@ -0,0 +1,26 @@
Clear-Host
# Pedir mensaje de commit
$mensaje = Read-Host "Ingrese el mensaje de commit"
Write-Host "Agregando archivos..."
git add .
Write-Host "Creando commit..."
git commit -m "$mensaje"
Write-Host "Haciendo push a la rama develop..."
$pushOutput = git push origin develop 2>&1
# Revisar si fallo la autenticacion
if ($pushOutput -match "Authentication failed" -or $pushOutput -match "Failed to authenticate") {
Write-Host "`nERROR: Fallo la autenticacion. Ejecutando git init para reconfigurar..." -ForegroundColor Red
git init
Write-Host "Intentando push nuevamente..."
git push origin develop
}
Write-Host "`nProceso finalizado."
pause

View File

@@ -0,0 +1,26 @@
-- Migración: Agregar sistema de permisos por mecánico para checklists
-- Fecha: 2025-11-25
-- Descripción: Crea tabla intermedia para controlar qué mecánicos pueden usar cada checklist
-- 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,
-- Constraint para evitar duplicados
UNIQUE(checklist_id, mechanic_id)
);
-- Crear índices para mejorar rendimiento
CREATE INDEX idx_checklist_permissions_checklist ON checklist_permissions(checklist_id);
CREATE INDEX idx_checklist_permissions_mechanic ON checklist_permissions(mechanic_id);
-- Comentarios para documentación
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.';
COMMENT ON COLUMN checklist_permissions.checklist_id IS 'ID del checklist restringido';
COMMENT ON COLUMN checklist_permissions.mechanic_id IS 'ID del mecánico autorizado';
-- Verificar que la migración se ejecutó correctamente
SELECT 'Tabla checklist_permissions creada exitosamente' AS status;

View File

@@ -0,0 +1,39 @@
-- Migración: Agregar sistema de auditoría para edición de inspecciones
-- Fecha: 2025-11-25
-- Descripción: Crea tabla de auditoría para rastrear todos los cambios en inspecciones y respuestas
-- 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, -- created, updated, deleted, status_changed
entity_type VARCHAR(50) NOT NULL, -- inspection, answer
field_name VARCHAR(100), -- Campo modificado
old_value TEXT, -- Valor anterior
new_value TEXT, -- Valor nuevo
comment TEXT, -- Comentario del cambio
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Crear índices para mejorar rendimiento
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);
-- Comentarios para documentación
COMMENT ON TABLE inspection_audit_log IS 'Registro de auditoría de cambios en inspecciones y respuestas. Registra quién, cuándo y qué cambió.';
COMMENT ON COLUMN inspection_audit_log.action IS 'Tipo de acción: created, updated, deleted, status_changed';
COMMENT ON COLUMN inspection_audit_log.entity_type IS 'Tipo de entidad modificada: inspection, answer';
COMMENT ON COLUMN inspection_audit_log.field_name IS 'Nombre del campo que fue modificado';
COMMENT ON COLUMN inspection_audit_log.old_value IS 'Valor anterior del campo';
COMMENT ON COLUMN inspection_audit_log.new_value IS 'Valor nuevo del campo';
COMMENT ON COLUMN inspection_audit_log.comment IS 'Comentario del administrador sobre por qué realizó el cambio';
-- Verificar que la migración se ejecutó correctamente
SELECT 'Tabla inspection_audit_log creada exitosamente' AS status;
-- Opcional: Ver estructura de la tabla
\d inspection_audit_log