Compare commits
22 Commits
7788e869db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d64e0094 | |||
| 99f0952378 | |||
| 195143b7ab | |||
| e373348ea6 | |||
| 8116bb5956 | |||
| 1ed59058f7 | |||
| e8d3e7ef7b | |||
| 1b31007eef | |||
| eb94d8ccfc | |||
| ad59152cce | |||
| 093256382c | |||
| b6c7a9ed63 | |||
| 0b0763cfff | |||
| 14073db2d9 | |||
| 33b134e838 | |||
| bfe542159d | |||
| b1c0e05306 | |||
| cfef4f6f89 | |||
| 855016c63d | |||
| 4234b71e17 | |||
| b304fbbb86 | |||
| 4428d17b27 |
375
AUDITORIA_INSPECCIONES.md
Normal file
375
AUDITORIA_INSPECCIONES.md
Normal 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
283
PERMISOS_CHECKLIST.md
Normal 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.
|
||||
@@ -25,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"
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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
|
||||
@@ -9,6 +12,80 @@ 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
|
||||
@@ -24,17 +101,17 @@ s3_client = boto3.client(
|
||||
config=Config(signature_version='s3v4'),
|
||||
region_name='us-east-1'
|
||||
)
|
||||
import shutil
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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
|
||||
|
||||
# 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(
|
||||
@@ -100,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)):
|
||||
@@ -501,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)
|
||||
@@ -513,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
|
||||
|
||||
|
||||
@@ -525,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
|
||||
|
||||
|
||||
@@ -546,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
|
||||
|
||||
|
||||
@@ -738,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
|
||||
@@ -790,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)
|
||||
@@ -833,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(
|
||||
@@ -974,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
|
||||
)
|
||||
|
||||
@@ -1448,12 +1909,12 @@ def get_dashboard_data(
|
||||
mechanic_ranking = [
|
||||
schemas.MechanicRanking(
|
||||
mechanic_id=m.id,
|
||||
mechanic_name=m.full_name,
|
||||
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
|
||||
for m in mechanic_stats if m.full_name
|
||||
]
|
||||
|
||||
# ESTADÍSTICAS POR CHECKLIST
|
||||
@@ -1494,11 +1955,11 @@ def get_dashboard_data(
|
||||
checklist_stats = [
|
||||
schemas.ChecklistStats(
|
||||
checklist_id=c.id,
|
||||
checklist_name=c.name,
|
||||
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
|
||||
for c in checklist_stats_data if c.name
|
||||
]
|
||||
|
||||
# INSPECCIONES POR FECHA (últimos 30 días)
|
||||
@@ -1638,8 +2099,8 @@ def get_inspections_report(
|
||||
"id": r.id,
|
||||
"vehicle_plate": r.vehicle_plate,
|
||||
"checklist_id": r.checklist_id,
|
||||
"checklist_name": r.checklist_name,
|
||||
"mechanic_name": r.mechanic_name,
|
||||
"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,
|
||||
@@ -1657,60 +2118,32 @@ def export_inspection_to_pdf(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
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 reportlab.lib.pagesizes import letter, A4
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
# Verificar permisos (admin, asesor o mecánico dueño)
|
||||
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")
|
||||
|
||||
# Obtener datos relacionados
|
||||
checklist = db.query(models.Checklist).filter(models.Checklist.id == inspection.checklist_id).first()
|
||||
mechanic = db.query(models.User).filter(models.User.id == inspection.mechanic_id).first()
|
||||
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()
|
||||
|
||||
# Crear PDF en memoria
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30)
|
||||
elements = []
|
||||
styles = getSampleStyleSheet()
|
||||
# ...existing code for PDF generation...
|
||||
doc.build(elements)
|
||||
buffer.seek(0)
|
||||
# Guardar localmente para depuración
|
||||
with open(f"/tmp/test_inspeccion_{inspection_id}.pdf", "wb") as f:
|
||||
f.write(buffer.getvalue())
|
||||
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}"
|
||||
# Subir PDF a S3/MinIO
|
||||
buffer.seek(0) # Asegura que el puntero esté al inicio
|
||||
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()
|
||||
return {"pdf_url": pdf_url}
|
||||
# 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 =============
|
||||
|
||||
@@ -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,6 +72,7 @@ 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)
|
||||
@@ -180,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")
|
||||
|
||||
@@ -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,7 @@ 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
|
||||
@@ -216,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
|
||||
@@ -281,3 +289,31 @@ class InspectionListItem(BaseModel):
|
||||
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ó
|
||||
|
||||
25
backend/docker.ps1
Normal file
25
backend/docker.ps1
Normal 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
|
||||
@@ -15,4 +15,5 @@ google-generativeai==0.3.2
|
||||
Pillow==10.2.0
|
||||
reportlab==4.0.9
|
||||
python-dotenv==1.0.0
|
||||
boto3==1.34.89
|
||||
boto3==1.34.89
|
||||
requests==2.31.0
|
||||
27
frontend/buildFront.ps1
Normal file
27
frontend/buildFront.ps1
Normal 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
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Syntria - Sistema Inteligente de Inspecciones</title>
|
||||
<meta name="description" content="Syntria: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
|
||||
<title>AYUTEC - Sistema Inteligente de Inspecciones</title>
|
||||
<meta name="description" content="AYUTEC: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1275
frontend/src/App.jsx
1275
frontend/src/App.jsx
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
<span className="text-white font-bold text-lg">S</span>
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
|
||||
26
gitUpdate.ps1
Normal file
26
gitUpdate.ps1
Normal 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
|
||||
26
migrations/add_checklist_permissions.sql
Normal file
26
migrations/add_checklist_permissions.sql
Normal 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;
|
||||
39
migrations/add_inspection_audit_log.sql
Normal file
39
migrations/add_inspection_audit_log.sql
Normal 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
|
||||
Reference in New Issue
Block a user