first commit

This commit is contained in:
2025-11-19 01:09:25 -03:00
parent e7a380f36e
commit be10a888fb
28 changed files with 2481 additions and 464 deletions

View File

@@ -1,17 +0,0 @@
# Variables de Entorno para Prueba de Producción Local
# Database
POSTGRES_DB=checklist_db
POSTGRES_USER=checklist_user
POSTGRES_PASSWORD=checklist_pass_2024_prod
# Backend
SECRET_KEY=production-secret-key-super-segura-minimo-32-caracteres-123456
OPENAI_API_KEY=
ENVIRONMENT=production
# CORS - Para prueba local
ALLOWED_ORIGINS=http://localhost,http://localhost:80,http://127.0.0.1
# Frontend - Para prueba local (apunta al backend en puerto 8000)
VITE_API_URL=http://localhost:8000

View File

@@ -1,17 +1,22 @@
# Variables de Entorno para Producción - Dockploy # Producción - CAMBIAR TODOS ESTOS VALORES
POSTGRES_DB=syntria_db
POSTGRES_USER=syntria_user
POSTGRES_PASSWORD=syntria_db
# Database # Secret key para JWT (mínimo 32 caracteres)
POSTGRES_DB=checklist_db SECRET_KEY=CHANGE_THIS_TO_RANDOM_STRING_MIN_32_CHARS_PRODUCTION
POSTGRES_USER=checklist_user
POSTGRES_PASSWORD=CAMBIAR-PASSWORD-SUPER-SEGURA-AQUI
# Backend # OpenAI API Key (opcional, para IA)
SECRET_KEY=CAMBIAR-CLAVE-SECRETA-MINIMO-32-CARACTERES-SUPER-SEGURA OPENAI_API_KEY=
OPENAI_API_KEY=tu-openai-api-key-aqui
# Docker Hub username
DOCKER_USERNAME=dymai
# API URL para el frontend
API_URL=http://your-domain.com:8000
# Orígenes permitidos (CORS)
ALLOWED_ORIGINS=http://your-domain.com,https://your-domain.com
# Environment
ENVIRONMENT=production ENVIRONMENT=production
# CORS - URL del FRONTEND (de donde vienen las peticiones)
ALLOWED_ORIGINS=http://checklist-frontend-n5eten-9cb24a-72-61-106-199.traefik.me
# Frontend - Vacío para usar URL relativa con proxy de Nginx
VITE_API_URL=

170
AI_FUNCTIONALITY.md Normal file
View File

@@ -0,0 +1,170 @@
# Sistema de IA para Checklists Inteligentes
## Modos de IA
### 1. **OFF (Sin IA)**
- El mecánico completa manualmente todas las respuestas
- Sin sugerencias ni asistencia automática
- Control total del usuario
### 2. **ASSISTED (IA Asistida)**
**Funcionalidades:**
- **Análisis de fotos**: Cuando el mecánico sube una foto, la IA analiza la imagen y sugiere:
- Estado del componente (bueno/malo)
- Nivel de criticidad (ok/minor/critical)
- Observaciones automáticas basadas en lo que detecta
- **Sugerencias contextuales**: Basándose en respuestas previas
- **Detección de anomalías**: Si detecta algo crítico, lo marca automáticamente
- **El mecánico puede aceptar o rechazar** las sugerencias
**Ejemplo de flujo:**
```
1. Mecánico sube foto de pastillas de freno
2. IA analiza: "Desgaste del 85%, menos de 2mm de material"
3. Sugiere: Estado=Crítico, Requiere reemplazo inmediato
4. Mecánico revisa y confirma o modifica
```
### 3. **FULL (IA Completa - Copilot)**
**Funcionalidades:**
- **Inspección automática por fotos**: El mecánico solo toma fotos
- **Análisis completo**: La IA responde todas las preguntas automáticamente
- **Generación de informe**: Crea observaciones y recomendaciones
- **Detección de problemas**: Marca automáticamente items críticos
- **El mecánico solo revisa y firma** al final
**Ejemplo de flujo:**
```
1. Mecánico toma 20 fotos del vehículo
2. IA procesa todas las fotos
3. Responde las 99 preguntas automáticamente
4. Genera observaciones detalladas
5. Marca 3 items como críticos
6. Mecánico revisa el informe completo
7. Ajusta si es necesario
8. Firma y completa
```
## Implementación Técnica
### Análisis de Imágenes con OpenAI Vision
```javascript
// Frontend: Subir foto
const analyzeImage = async (imageFile, questionType) => {
const formData = new FormData()
formData.append('image', imageFile)
formData.append('question_id', questionId)
const response = await fetch('/api/analyze-image', {
method: 'POST',
body: formData
})
return response.json() // { status, observations, confidence }
}
```
### Backend: Procesamiento con IA
```python
# Backend: Analizar imagen con OpenAI GPT-4 Vision
import openai
from PIL import Image
async def analyze_vehicle_component(image_path: str, question: Question):
# Cargar imagen
with open(image_path, 'rb') as f:
image_data = f.read()
# Prompt especializado según tipo de pregunta
prompts = {
"brakes": "Analiza el estado de las pastillas de freno. Indica desgaste, grietas, material restante.",
"tires": "Evalúa la banda de rodadura, presión aparente, desgaste irregular.",
"lights": "Verifica funcionamiento de luces, roturas, opacidad.",
# ... más prompts especializados
}
# Llamada a OpenAI Vision API
response = openai.ChatCompletion.create(
model="gpt-4-vision-preview",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": prompts.get(question.section, "Analiza este componente")},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}}
]
}],
max_tokens=300
)
# Parsear respuesta
ai_analysis = response.choices[0].message.content
# Determinar status según análisis
if "crítico" in ai_analysis.lower() or "falla" in ai_analysis.lower():
status = "critical"
elif "menor" in ai_analysis.lower() or "atención" in ai_analysis.lower():
status = "minor"
else:
status = "ok"
return {
"status": status,
"observations": ai_analysis,
"confidence": 0.85,
"ai_analysis": {
"raw_response": ai_analysis,
"model": "gpt-4-vision",
"timestamp": datetime.now()
}
}
```
## Ventajas de cada modo
### OFF
✅ Control total del mecánico
✅ Sin dependencia de conectividad
✅ Sin costos de API
### ASSISTED
✅ Ayuda al mecánico a no olvidar detalles
✅ Documenta mejor con análisis de fotos
✅ Reduce errores humanos
✅ Aprende patrones comunes
⚠️ Requiere internet y API key
### FULL
✅ Rapidez extrema (5-10 min vs 30-40 min)
✅ Consistencia en evaluaciones
✅ Ideal para inspecciones masivas
✅ Genera informes detallados automáticamente
⚠️ Requiere validación del mecánico
⚠️ Mayor costo de API
## Casos de Uso Recomendados
**OFF**:
- Talleres sin internet estable
- Inspecciones básicas
- Presupuesto limitado
**ASSISTED**:
- Talleres medianos/grandes
- Inspecciones preventivas
- Documentación detallada requerida
**FULL**:
- Flotas de vehículos
- Inspecciones pre-compra masivas
- Talleres de alto volumen
- Empresas de rent-a-car
## Próximos pasos de implementación
1. ✅ Estructura de base de datos preparada
2. ⏳ Endpoint `/api/analyze-image` para análisis
3. ⏳ Integración con OpenAI Vision API
4. ⏳ UI para mostrar sugerencias de IA
5. ⏳ Sistema de confianza (confidence score)
6. ⏳ Historial de sugerencias aceptadas/rechazadas
7. ⏳ Fine-tuning del modelo con datos reales

193
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,193 @@
# Syntria - Despliegue en Producción
## 📋 Requisitos Previos
- Docker y Docker Compose instalados
- Cuenta en Docker Hub
- Servidor Linux/Windows con puertos 80, 8000 y 5432 disponibles
## 🚀 Despliegue Rápido
### 1. Construir y Publicar Imágenes
**Windows (PowerShell):**
```powershell
.\build-and-push.ps1
```
**Linux/Mac:**
```bash
chmod +x build-and-push.sh
./build-and-push.sh
```
### 2. Configurar Producción
Edita `.env.production` con tus valores:
```env
POSTGRES_PASSWORD=tu_password_seguro_aqui
SECRET_KEY=clave_secreta_minimo_32_caracteres
DOCKER_USERNAME=tu_usuario_dockerhub
API_URL=http://tu-dominio.com:8000
ALLOWED_ORIGINS=http://tu-dominio.com,https://tu-dominio.com
```
### 3. Desplegar en Servidor
En tu servidor de producción:
```bash
# Clonar o subir archivos necesarios
# - docker-compose.prod.yml
# - .env.production
# - init-db.sh
# Desplegar
docker-compose -f docker-compose.prod.yml --env-file .env.production up -d
# Verificar estado
docker-compose -f docker-compose.prod.yml ps
# Ver logs
docker-compose -f docker-compose.prod.yml logs -f
```
### 4. Inicializar Base de Datos
```bash
# Crear usuarios iniciales
docker exec -it syntria-backend-prod python init_db.py
```
## 🔧 Mantenimiento
### Ver Logs
```bash
# Todos los servicios
docker-compose -f docker-compose.prod.yml logs -f
# Solo backend
docker-compose -f docker-compose.prod.yml logs -f backend
# Solo frontend
docker-compose -f docker-compose.prod.yml logs -f frontend
```
### Actualizar Imágenes
```bash
# Pull nuevas versiones
docker-compose -f docker-compose.prod.yml pull
# Reiniciar servicios
docker-compose -f docker-compose.prod.yml up -d
```
### Backup Base de Datos
```bash
# Crear backup
docker exec syntria-db-prod pg_dump -U syntria_user syntria_db > backup_$(date +%Y%m%d).sql
# Restaurar backup
docker exec -i syntria-db-prod psql -U syntria_user syntria_db < backup_20241118.sql
```
## 🌐 Acceso
- **Frontend:** http://tu-servidor
- **Backend API:** http://tu-servidor:8000
- **Docs API:** http://tu-servidor:8000/docs
## 🔒 Seguridad
### Recomendaciones:
1. **Cambiar passwords por defecto**
- PostgreSQL password
- SECRET_KEY (mínimo 32 caracteres aleatorios)
2. **Usar HTTPS**
- Configurar certificado SSL
- Usar reverse proxy (Nginx/Traefik)
3. **Firewall**
- Abrir solo puertos necesarios (80, 443)
- Cerrar puerto 5432 al público
4. **Backups automáticos**
- Configurar cron job para backups diarios
## 📊 Monitoreo
### Health Checks
```bash
# Backend
curl http://localhost:8000/health
# Frontend
curl http://localhost
# Database
docker exec syntria-db-prod pg_isready -U syntria_user
```
## 🐳 Imágenes Docker Hub
Las imágenes están disponibles en:
- `usuario/syntria-backend:latest`
- `usuario/syntria-frontend:latest`
## ⚙️ Variables de Entorno
### Backend
- `DATABASE_URL` - PostgreSQL connection string
- `SECRET_KEY` - JWT secret key (32+ chars)
- `OPENAI_API_KEY` - OpenAI API key (opcional)
- `ENVIRONMENT` - `production`
- `ALLOWED_ORIGINS` - CORS origins
### Frontend
- `VITE_API_URL` - Backend API URL
## 🆘 Troubleshooting
### Error: Connection refused
```bash
# Verificar que todos los servicios estén corriendo
docker-compose -f docker-compose.prod.yml ps
# Verificar logs
docker-compose -f docker-compose.prod.yml logs backend
```
### Error: Database connection
```bash
# Verificar variables de entorno
docker exec syntria-backend-prod env | grep DATABASE_URL
# Verificar que postgres esté healthy
docker inspect syntria-db-prod | grep Health
```
### Error: CORS
- Actualizar `ALLOWED_ORIGINS` en `.env.production`
- Incluir http:// y https:// si usas ambos
## 📝 Usuarios por Defecto
Después de `init_db.py`:
- **Admin:** admin / admin123
- **Mecánico:** mechanic / mechanic123
⚠️ **Cambiar passwords en producción**
## 🔄 Actualización de Versiones
1. Hacer cambios en código
2. Ejecutar `build-and-push.ps1` con nueva versión
3. En servidor: `docker-compose pull && docker-compose up -d`
## 📞 Soporte
Para issues y preguntas: GitHub Issues

View File

@@ -1,256 +0,0 @@
# Guía de Deployment en Dockploy
## Arquitectura de Conexión
### Desarrollo (Docker Compose Local)
```
Frontend (localhost:5173) → Backend (localhost:8000) → PostgreSQL (localhost:5432)
```
### Producción (Dockploy)
```
Frontend (tudominio.com) → Backend (api.tudominio.com) → PostgreSQL (interno)
```
---
## 📋 Pasos para Deploy en Dockploy
### 1. Preparar Variables de Entorno
**Backend (.env)**
```bash
DATABASE_URL=postgresql://user:pass@postgres:5432/checklist_db
SECRET_KEY=tu-clave-secreta-minimo-32-caracteres-super-segura
OPENAI_API_KEY=tu-api-key-de-openai
ENVIRONMENT=production
ALLOWED_ORIGINS=https://tudominio.com,https://www.tudominio.com
```
**Frontend (.env)**
```bash
VITE_API_URL=https://api.tudominio.com
```
### 2. Actualizar CORS en Backend
Editar `backend/app/main.py`:
```python
import os
# CORS
allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
### 3. Crear Dockerfile de Producción para Frontend
Crear `frontend/Dockerfile.prod`:
```dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# Servidor Nginx para servir archivos estáticos
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
Crear `frontend/nginx.conf`:
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Configuración de cache para assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
### 4. Estructura Recomendada en Dockploy
**Opción A: Servicios Separados (Recomendado)**
- **Servicio 1**: PostgreSQL (Base de datos interna)
- **Servicio 2**: Backend API (api.tudominio.com)
- **Servicio 3**: Frontend (tudominio.com)
**Opción B: Docker Compose en Dockploy**
Usar `docker-compose.prod.yml`:
```yaml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
backend:
build: ./backend
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
SECRET_KEY: ${SECRET_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
ENVIRONMENT: production
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS}
ports:
- "8000:8000"
depends_on:
- postgres
networks:
- app-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
args:
VITE_API_URL: ${VITE_API_URL}
ports:
- "80:80"
depends_on:
- backend
networks:
- app-network
volumes:
postgres_data:
networks:
app-network:
driver: bridge
```
### 5. Configuración de Dominios en Dockploy
1. **Backend API**:
- Dominio: `api.tudominio.com`
- Puerto interno: `8000`
- Habilitar SSL/TLS
2. **Frontend**:
- Dominio: `tudominio.com`
- Puerto interno: `80`
- Habilitar SSL/TLS
3. **PostgreSQL**:
- Solo acceso interno (no exponer públicamente)
### 6. Scripts de Migración
Crear `backend/migrate.sh`:
```bash
#!/bin/bash
# Ejecutar migraciones en producción
python -c "from app.core.database import Base, engine; Base.metadata.create_all(bind=engine)"
```
### 7. Health Checks
El backend ya tiene el endpoint de health. Dockploy puede monitorearlo:
```
GET https://api.tudominio.com/docs
```
---
## 🔐 Seguridad en Producción
1. **Variables de Entorno**:
- Usar secretos de Dockploy, NO hardcodear
- Cambiar `SECRET_KEY` a algo seguro (min 32 caracteres)
- No exponer `DATABASE_URL` públicamente
2. **HTTPS**:
- Dockploy maneja SSL automáticamente con Let's Encrypt
- Asegurar que CORS solo acepte HTTPS en producción
3. **Database**:
- NO exponer puerto 5432 públicamente
- Usar credenciales fuertes
- Configurar backups automáticos
---
## 📝 Checklist Pre-Deploy
- [ ] Cambiar `SECRET_KEY` en variables de entorno
- [ ] Configurar `VITE_API_URL` con dominio de producción
- [ ] Actualizar CORS con dominios de producción
- [ ] Crear Dockerfile.prod para frontend
- [ ] Configurar nginx.conf
- [ ] Probar build local: `docker-compose -f docker-compose.prod.yml up`
- [ ] Configurar dominios en Dockploy
- [ ] Habilitar SSL/TLS en ambos servicios
- [ ] Crear usuario admin inicial en producción
- [ ] Configurar backups de PostgreSQL
---
## 🎯 Comandos Útiles
**Build local de producción:**
```bash
docker-compose -f docker-compose.prod.yml build
docker-compose -f docker-compose.prod.yml up -d
```
**Ver logs:**
```bash
docker-compose -f docker-compose.prod.yml logs -f backend
docker-compose -f docker-compose.prod.yml logs -f frontend
```
**Crear usuario admin en producción:**
```bash
docker-compose -f docker-compose.prod.yml exec backend python -c "
from app.core.database import SessionLocal
from app.core.security import get_password_hash
from app.models import User
db = SessionLocal()
admin = User(
username='admin',
email='admin@tudominio.com',
full_name='Administrador',
hashed_password=get_password_hash('tu-password-segura'),
role='admin',
is_active=True
)
db.add(admin)
db.commit()
print('Admin creado exitosamente')
"
```

24
backend/.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# Backend .dockerignore
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
htmlcov/
*.log
.git
.gitignore
.dockerignore
Dockerfile
Dockerfile.prod
README.md
.env
.env.*
*.md

27
backend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# Instalar dependencias del sistema
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copiar requirements
COPY requirements.txt .
# Instalar dependencias de Python
RUN pip install --no-cache-dir -r requirements.txt
# Copiar código
COPY . .
# Crear directorio para uploads
RUN mkdir -p /app/uploads
# Exponer puerto
EXPOSE 8000
# Comando de producción (múltiples workers)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

View File

@@ -1,22 +1,39 @@
""" """
Script para agregar preguntas a un checklist basado en el formato del PDF Script para agregar preguntas a un checklist basado en el formato del PDF
Ejecutar: docker cp add_questions.py checklist-backend:/app/ && docker-compose exec -T backend python /app/add_questions.py Ejecutar: docker-compose exec -T backend python /app/add_questions.py
""" """
from app.core.database import SessionLocal from app.core.database import SessionLocal
from app.models import Checklist, Question from app.models import Checklist, Question, User
# ID del checklist al que quieres agregar preguntas
CHECKLIST_ID = 2 # Cambia este número según el ID de tu checklist
db = SessionLocal() db = SessionLocal()
# Verificar que el checklist existe # Obtener el usuario admin
checklist = db.query(Checklist).filter(Checklist.id == CHECKLIST_ID).first() admin = db.query(User).filter(User.username == "admin").first()
if not checklist: if not admin:
print(f"Checklist con ID {CHECKLIST_ID} no encontrado") print("Usuario admin no encontrado")
exit(1) exit(1)
# Verificar si ya existe un checklist, si no, crearlo
checklist = db.query(Checklist).filter(Checklist.name == "Inspección Preventiva de Vehículos").first()
if not checklist:
print("📋 Creando checklist...")
checklist = Checklist(
name="Inspección Preventiva de Vehículos",
description="Checklist completo para inspección preventiva de vehículos",
ai_mode="off",
scoring_enabled=True,
created_by=admin.id
)
db.add(checklist)
db.commit()
db.refresh(checklist)
print(f"✅ Checklist creado con ID: {checklist.id}")
else:
print(f"✅ Usando checklist existente: {checklist.name} (ID: {checklist.id})")
CHECKLIST_ID = checklist.id
print(f"✅ Agregando preguntas al checklist: {checklist.name}") print(f"✅ Agregando preguntas al checklist: {checklist.name}")
# Definir todas las preguntas por sección # Definir todas las preguntas por sección

View File

@@ -9,7 +9,6 @@ from datetime import datetime, timedelta
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.core.config import settings
from app import models, schemas from app import models, schemas
# Crear tablas # Crear tablas
@@ -17,18 +16,15 @@ Base.metadata.create_all(bind=engine)
app = FastAPI(title="Checklist Inteligente API", version="1.0.0") app = FastAPI(title="Checklist Inteligente API", version="1.0.0")
# CORS - Usar configuración de settings # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=["http://localhost:5173", "http://localhost:3000"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Log para debug
print(f"🌐 CORS configured for origins: {settings.cors_origins}")
security = HTTPBearer() security = HTTPBearer()
# Dependency para obtener usuario actual # Dependency para obtener usuario actual
@@ -451,6 +447,428 @@ async def upload_photo(
return media_file return media_file
# ============= AI ANALYSIS =============
@app.get("/api/ai/models", response_model=List[schemas.AIModelInfo])
def get_available_ai_models(current_user: models.User = Depends(get_current_user)):
"""Obtener lista de modelos de IA disponibles"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden ver modelos de IA")
models_list = [
# OpenAI Models
{
"id": "gpt-4o",
"name": "GPT-4o (Recomendado)",
"provider": "openai",
"description": "Modelo multimodal más avanzado de OpenAI, rápido y preciso para análisis de imágenes"
},
{
"id": "gpt-4o-mini",
"name": "GPT-4o Mini",
"provider": "openai",
"description": "Versión compacta y económica de GPT-4o, ideal para análisis rápidos"
},
{
"id": "gpt-4-turbo",
"name": "GPT-4 Turbo",
"provider": "openai",
"description": "Modelo potente con capacidades de visión y contexto amplio"
},
{
"id": "gpt-4-vision-preview",
"name": "GPT-4 Vision (Preview)",
"provider": "openai",
"description": "Modelo específico para análisis de imágenes (versión previa)"
},
# Gemini Models - Actualizados a versiones 2.0, 2.5 y 3.0
{
"id": "gemini-3-pro-preview",
"name": "Gemini 3 Pro Preview (Último)",
"provider": "gemini",
"description": "Modelo de próxima generación en preview, máxima capacidad de análisis"
},
{
"id": "gemini-2.5-pro",
"name": "Gemini 2.5 Pro (Recomendado)",
"provider": "gemini",
"description": "Último modelo estable con excelente análisis visual y razonamiento avanzado"
},
{
"id": "gemini-2.5-flash",
"name": "Gemini 2.5 Flash",
"provider": "gemini",
"description": "Versión rápida del 2.5, ideal para inspecciones en tiempo real"
},
{
"id": "gemini-2.0-flash",
"name": "Gemini 2.0 Flash",
"provider": "gemini",
"description": "Modelo rápido y eficiente de la generación 2.0"
},
{
"id": "gemini-1.5-pro-latest",
"name": "Gemini 1.5 Pro Latest",
"provider": "gemini",
"description": "Versión estable 1.5 con contexto de 2M tokens"
},
{
"id": "gemini-1.5-flash-latest",
"name": "Gemini 1.5 Flash Latest",
"provider": "gemini",
"description": "Modelo 1.5 rápido para análisis básicos"
}
]
return models_list
@app.get("/api/ai/configuration", response_model=schemas.AIConfiguration)
def get_ai_configuration(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""Obtener configuración de IA actual"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden ver configuración de IA")
config = db.query(models.AIConfiguration).filter(
models.AIConfiguration.is_active == True
).first()
if not config:
raise HTTPException(status_code=404, detail="No hay configuración de IA activa")
return config
@app.post("/api/ai/configuration", response_model=schemas.AIConfiguration)
def create_ai_configuration(
config: schemas.AIConfigurationCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""Crear o actualizar configuración de IA"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden configurar IA")
# Desactivar configuraciones anteriores
db.query(models.AIConfiguration).update({"is_active": False})
# Crear nueva configuración
new_config = models.AIConfiguration(
provider=config.provider,
api_key=config.api_key,
model_name=config.model_name,
is_active=True
)
db.add(new_config)
db.commit()
db.refresh(new_config)
return new_config
@app.put("/api/ai/configuration/{config_id}", response_model=schemas.AIConfiguration)
def update_ai_configuration(
config_id: int,
config_update: schemas.AIConfigurationUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""Actualizar configuración de IA existente"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden actualizar configuración de IA")
config = db.query(models.AIConfiguration).filter(
models.AIConfiguration.id == config_id
).first()
if not config:
raise HTTPException(status_code=404, detail="Configuración no encontrada")
# Actualizar campos
for key, value in config_update.dict(exclude_unset=True).items():
setattr(config, key, value)
db.commit()
db.refresh(config)
return config
@app.delete("/api/ai/configuration/{config_id}")
def delete_ai_configuration(
config_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""Eliminar configuración de IA"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden eliminar configuración de IA")
config = db.query(models.AIConfiguration).filter(
models.AIConfiguration.id == config_id
).first()
if not config:
raise HTTPException(status_code=404, detail="Configuración no encontrada")
db.delete(config)
db.commit()
return {"message": "Configuración eliminada correctamente"}
@app.post("/api/analyze-image")
async def analyze_image(
file: UploadFile = File(...),
question_id: int = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""
Analiza una imagen usando IA para sugerir respuestas
Usa la configuración de IA activa (OpenAI o Gemini)
"""
# Obtener configuración de IA activa
ai_config = db.query(models.AIConfiguration).filter(
models.AIConfiguration.is_active == True
).first()
if not ai_config:
return {
"status": "disabled",
"message": "No hay configuración de IA activa. Configure en Settings."
}
# Guardar imagen temporalmente
import base64
contents = await file.read()
image_b64 = base64.b64encode(contents).decode('utf-8')
# Obtener contexto de la pregunta si se proporciona
question_obj = None
if question_id:
question_obj = db.query(models.Question).filter(models.Question.id == question_id).first()
try:
# Construir prompt dinámico basado en la pregunta específica
if question_obj:
# Prompt altamente específico para la pregunta
question_text = question_obj.text
question_type = question_obj.type
section = question_obj.section
system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular.
PREGUNTA ESPECÍFICA A RESPONDER: "{question_text}"
Sección: {section}
Analiza la imagen ÚNICAMENTE para responder esta pregunta específica.
Sé directo y enfócate solo en lo que la pregunta solicita.
Responde en formato JSON:
{{
"status": "ok|minor|critical",
"observations": "Respuesta específica a: {question_text}",
"recommendation": "Acción si aplica",
"confidence": 0.0-1.0
}}
IMPORTANTE:
- Responde SOLO lo que la pregunta pide
- No des información genérica del vehículo
- Sé específico y técnico
- Si la pregunta es pass/fail, indica claramente si pasa o falla
- Si la pregunta es bueno/regular/malo, indica el estado específico del componente"""
user_message = f"Inspecciona la imagen y responde específicamente: {question_text}"
else:
# Fallback para análisis general
system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona:
1. Estado del componente (bueno/regular/malo)
2. Nivel de criticidad (ok/minor/critical)
3. Observaciones técnicas breves
4. Recomendación de acción
Responde en formato JSON:
{
"status": "ok|minor|critical",
"observations": "descripción técnica",
"recommendation": "acción sugerida",
"confidence": 0.0-1.0
}"""
user_message = "Analiza este componente del vehículo para la inspección general."
if ai_config.provider == "openai":
import openai
openai.api_key = ai_config.api_key
response = openai.ChatCompletion.create(
model=ai_config.model_name,
messages=[
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": [
{
"type": "text",
"text": user_message
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}
]
}
],
max_tokens=500
)
ai_response = response.choices[0].message.content
elif ai_config.provider == "gemini":
import google.generativeai as genai
from PIL import Image
from io import BytesIO
genai.configure(api_key=ai_config.api_key)
model = genai.GenerativeModel(ai_config.model_name)
# Convertir base64 a imagen PIL
image = Image.open(BytesIO(contents))
prompt = f"{system_prompt}\n\n{user_message}"
response = model.generate_content([prompt, image])
ai_response = response.text
else:
return {
"success": False,
"error": f"Provider {ai_config.provider} no soportado"
}
# Intentar parsear como JSON, si falla, usar texto plano
try:
import json
import re
# Limpiar markdown code blocks si existen
cleaned_response = ai_response.strip()
# Remover ```json ... ``` si existe
if cleaned_response.startswith('```'):
# Extraer contenido entre ``` markers
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', cleaned_response, re.DOTALL)
if match:
cleaned_response = match.group(1).strip()
analysis = json.loads(cleaned_response)
except:
# Si no es JSON válido, crear estructura básica
analysis = {
"status": "ok",
"observations": ai_response,
"recommendation": "Revisar manualmente",
"confidence": 0.7
}
return {
"success": True,
"analysis": analysis,
"raw_response": ai_response,
"model": ai_config.model_name,
"provider": ai_config.provider
}
except Exception as e:
print(f"Error en análisis AI: {e}")
import traceback
traceback.print_exc()
return {
"success": False,
"error": str(e),
"message": "Error analyzing image with AI. Please check AI configuration in Settings."
}
try:
import openai
openai.api_key = settings.OPENAI_API_KEY
# Prompt especializado para inspección vehicular
system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona:
1. Estado del componente (bueno/regular/malo)
2. Nivel de criticidad (ok/minor/critical)
3. Observaciones técnicas breves
4. Recomendación de acción
Responde en formato JSON:
{
"status": "ok|minor|critical",
"observations": "descripción técnica",
"recommendation": "acción sugerida",
"confidence": 0.0-1.0
}"""
response = openai.ChatCompletion.create(
model="gpt-4-vision-preview" if "gpt-4" in str(settings.OPENAI_API_KEY) else "gpt-4o",
messages=[
{
"role": "system",
"content": system_prompt
},
{
"role": "user",
"content": [
{
"type": "text",
"text": f"Analiza este componente del vehículo.\n{question_context}"
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_b64}"
}
}
]
}
],
max_tokens=500
)
ai_response = response.choices[0].message.content
# Intentar parsear como JSON, si falla, usar texto plano
try:
import json
analysis = json.loads(ai_response)
except:
# Si no es JSON válido, crear estructura básica
analysis = {
"status": "ok",
"observations": ai_response,
"recommendation": "Revisar manualmente",
"confidence": 0.7
}
return {
"success": True,
"analysis": analysis,
"raw_response": ai_response,
"model": "gpt-4-vision"
}
except Exception as e:
print(f"Error en análisis AI: {e}")
return {
"success": False,
"error": str(e),
"message": "Error analyzing image with AI"
}
# ============= HEALTH CHECK ============= # ============= HEALTH CHECK =============
@app.get("/") @app.get("/")
def root(): def root():

View File

@@ -144,3 +144,15 @@ class MediaFile(Base):
# Relationships # Relationships
answer = relationship("Answer", back_populates="media_files") answer = relationship("Answer", back_populates="media_files")
class AIConfiguration(Base):
__tablename__ = "ai_configurations"
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)
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())

14
backend/app/models_ai.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
from app.core.database import Base
class AIConfiguration(Base):
__tablename__ = "ai_configurations"
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)
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())

View File

@@ -175,3 +175,33 @@ class InspectionDetail(Inspection):
checklist: ChecklistWithQuestions checklist: ChecklistWithQuestions
mechanic: User mechanic: User
answers: List[AnswerWithMedia] = [] answers: List[AnswerWithMedia] = []
# AI Configuration Schemas
class AIConfigurationBase(BaseModel):
provider: str # openai, gemini
api_key: str
model_name: str
class AIConfigurationCreate(AIConfigurationBase):
pass
class AIConfigurationUpdate(BaseModel):
provider: Optional[str] = None
api_key: Optional[str] = None
model_name: Optional[str] = None
is_active: Optional[bool] = None
class AIConfiguration(AIConfigurationBase):
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class AIModelInfo(BaseModel):
id: str
name: str
provider: str
description: Optional[str] = None

61
backend/init_db.py Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""
Script para inicializar la base de datos con usuarios de prueba
"""
from app.core.database import SessionLocal, engine, Base
from app.core.security import get_password_hash
from app.models import User
from sqlalchemy import text
def init_db():
db = SessionLocal()
try:
# Verificar conexión
db.execute(text("SELECT 1"))
print("✓ Conexión a la base de datos exitosa")
# Verificar si ya existen usuarios
existing_users = db.query(User).count()
if existing_users > 0:
print(f"⚠ Ya existen {existing_users} usuario(s) en la base de datos")
return
# Crear usuario administrador
admin = User(
username="admin",
email="admin@checklist.com",
full_name="Administrador",
password_hash=get_password_hash("admin123"),
role="admin",
is_active=True
)
db.add(admin)
# Crear usuario mecánico de prueba
mechanic = User(
username="mechanic",
email="mechanic@checklist.com",
full_name="Mecánico de Prueba",
password_hash=get_password_hash("mechanic123"),
role="mechanic",
is_active=True
)
db.add(mechanic)
db.commit()
print("✓ Usuarios creados exitosamente:")
print(" - Admin: username='admin', password='admin123'")
print(" - Mechanic: username='mechanic', password='mechanic123'")
except Exception as e:
print(f"✗ Error al inicializar la base de datos: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
print("Inicializando base de datos...")
init_db()
print("¡Inicialización completada!")

View File

@@ -11,6 +11,7 @@ passlib==1.7.4
bcrypt==4.0.1 bcrypt==4.0.1
python-multipart==0.0.6 python-multipart==0.0.6
openai==1.10.0 openai==1.10.0
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

87
build-and-push.ps1 Normal file
View File

@@ -0,0 +1,87 @@
# Script PowerShell para construir y publicar imagenes de Syntria a Docker Hub
$ErrorActionPreference = "Stop"
Write-Host "🚀 Syntria - Build & Push to Docker Hub" -ForegroundColor Green
Write-Host "==========================================" -ForegroundColor Green
Write-Host ""
# Verificar que estamos en el directorio correcto
if (-not (Test-Path "docker-compose.yml")) {
Write-Host "❌ Error: Ejecuta este script desde el directorio raiz del proyecto" -ForegroundColor Red
exit 1
}
# Leer variables de entorno
if (Test-Path ".env.production") {
Get-Content ".env.production" | ForEach-Object {
if ($_ -match "^([^#][^=]+)=(.*)$") {
Set-Item -Path "env:$($matches[1])" -Value $matches[2]
}
}
}
# Verificar Docker Hub username
if (-not $env:DOCKER_USERNAME) {
$env:DOCKER_USERNAME = Read-Host "Ingresa tu username de Docker Hub"
}
# Verificar version
$version = Read-Host "Version a construir (default: latest)"
if ([string]::IsNullOrWhiteSpace($version)) {
$version = "latest"
}
Write-Host ""
Write-Host "Configuracion:" -ForegroundColor Green
Write-Host " Docker Hub User: $env:DOCKER_USERNAME"
Write-Host " Version: $version"
Write-Host ""
# Confirmar
$confirm = Read-Host "Continuar? (y/n)"
if ($confirm -ne "y") {
Write-Host "Cancelado" -ForegroundColor Yellow
exit 0
}
# Login a Docker Hub
Write-Host ""
Write-Host "📦 Iniciando sesion en Docker Hub..." -ForegroundColor Green
docker login
# Build Backend
Write-Host ""
Write-Host "🔨 Construyendo Backend..." -ForegroundColor Green
docker build -t "$env:DOCKER_USERNAME/syntria-backend:$version" -f backend/Dockerfile.prod backend/
docker tag "$env:DOCKER_USERNAME/syntria-backend:$version" "$env:DOCKER_USERNAME/syntria-backend:latest"
# Build Frontend
Write-Host ""
Write-Host "🔨 Construyendo Frontend..." -ForegroundColor Green
docker build -t "$env:DOCKER_USERNAME/syntria-frontend:$version" -f frontend/Dockerfile.prod frontend/
docker tag "$env:DOCKER_USERNAME/syntria-frontend:$version" "$env:DOCKER_USERNAME/syntria-frontend:latest"
# Push images
Write-Host ""
Write-Host "⬆️ Subiendo imagenes a Docker Hub..." -ForegroundColor Green
docker push "$env:DOCKER_USERNAME/syntria-backend:$version"
docker push "$env:DOCKER_USERNAME/syntria-backend:latest"
docker push "$env:DOCKER_USERNAME/syntria-frontend:$version"
docker push "$env:DOCKER_USERNAME/syntria-frontend:latest"
Write-Host ""
Write-Host "✅ Imagenes publicadas exitosamente!" -ForegroundColor Green
Write-Host ""
Write-Host "Imagenes disponibles:"
Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-backend:$version"
Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-backend:latest"
Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-frontend:$version"
Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-frontend:latest"
Write-Host ""
Write-Host "Proximos pasos:" -ForegroundColor Yellow
Write-Host "1. Actualiza DOCKER_USERNAME en .env.production"
Write-Host "2. En tu servidor de produccion, ejecuta:"
Write-Host " docker-compose -f docker-compose.prod.yml --env-file .env.production up -d"

88
build-and-push.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/bin/bash
# Script para construir y publicar imágenes de Syntria a Docker Hub
set -e
# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}🚀 Syntria - Build & Push to Docker Hub${NC}"
echo "=========================================="
# Verificar que estamos en el directorio correcto
if [ ! -f "docker-compose.yml" ]; then
echo -e "${RED}❌ Error: Ejecuta este script desde el directorio raíz del proyecto${NC}"
exit 1
fi
# Leer variables de entorno
if [ -f ".env.production" ]; then
export $(cat .env.production | grep -v '^#' | xargs)
fi
# Verificar Docker Hub username
if [ -z "$DOCKER_USERNAME" ]; then
echo -e "${YELLOW}⚠️ DOCKER_USERNAME no está configurado en .env.production${NC}"
read -p "Ingresa tu username de Docker Hub: " DOCKER_USERNAME
fi
# Verificar versión
read -p "Versión a construir (default: latest): " VERSION
VERSION=${VERSION:-latest}
echo ""
echo -e "${GREEN}Configuración:${NC}"
echo " Docker Hub User: $DOCKER_USERNAME"
echo " Versión: $VERSION"
echo ""
# Confirmar
read -p "¿Continuar? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Cancelado${NC}"
exit 1
fi
# Login a Docker Hub
echo ""
echo -e "${GREEN}📦 Iniciando sesión en Docker Hub...${NC}"
docker login
# Build Backend
echo ""
echo -e "${GREEN}🔨 Construyendo Backend...${NC}"
docker build -t $DOCKER_USERNAME/syntria-backend:$VERSION -f backend/Dockerfile.prod backend/
docker tag $DOCKER_USERNAME/syntria-backend:$VERSION $DOCKER_USERNAME/syntria-backend:latest
# Build Frontend
echo ""
echo -e "${GREEN}🔨 Construyendo Frontend...${NC}"
docker build -t $DOCKER_USERNAME/syntria-frontend:$VERSION -f frontend/Dockerfile.prod frontend/
docker tag $DOCKER_USERNAME/syntria-frontend:$VERSION $DOCKER_USERNAME/syntria-frontend:latest
# Push images
echo ""
echo -e "${GREEN}⬆️ Subiendo imágenes a Docker Hub...${NC}"
docker push $DOCKER_USERNAME/syntria-backend:$VERSION
docker push $DOCKER_USERNAME/syntria-backend:latest
docker push $DOCKER_USERNAME/syntria-frontend:$VERSION
docker push $DOCKER_USERNAME/syntria-frontend:latest
echo ""
echo -e "${GREEN}✅ ¡Imágenes publicadas exitosamente!${NC}"
echo ""
echo "Imágenes disponibles:"
echo " 🐳 $DOCKER_USERNAME/syntria-backend:$VERSION"
echo " 🐳 $DOCKER_USERNAME/syntria-backend:latest"
echo " 🐳 $DOCKER_USERNAME/syntria-frontend:$VERSION"
echo " 🐳 $DOCKER_USERNAME/syntria-frontend:latest"
echo ""
echo -e "${YELLOW}Próximos pasos:${NC}"
echo "1. Actualiza DOCKER_USERNAME en .env.production"
echo "2. En tu servidor de producción, ejecuta:"
echo " docker-compose -f docker-compose.prod.yml --env-file .env.production up -d"

56
docker-compose.hub.yml Normal file
View File

@@ -0,0 +1,56 @@
version: '3.8'
services:
db:
image: postgres:15
container_name: syntria-db-prod
restart: always
environment:
POSTGRES_DB: syntria_db
POSTGRES_USER: syntria_user
POSTGRES_PASSWORD: syntria_secure_2024
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- syntria-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U syntria_user -d syntria_db"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: dymai/syntria-backend:latest
container_name: syntria-backend-prod
restart: always
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://syntria_user:syntria_secure_2024@db:5432/syntria_db
SECRET_KEY: tu_clave_secreta_super_segura_minimo_32_caracteres_prod
OPENAI_API_KEY: tu_api_key_de_openai
GEMINI_API_KEY: tu_api_key_de_gemini
ENVIRONMENT: production
ALLOWED_ORIGINS: http://localhost,http://localhost:80
networks:
- syntria-network
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
frontend:
image: dymai/syntria-frontend:latest
container_name: syntria-frontend-prod
restart: always
depends_on:
- backend
ports:
- "80:80"
networks:
- syntria-network
networks:
syntria-network:
driver: bridge
volumes:
postgres_data:

View File

@@ -1,64 +1,63 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: checklist-db-prod container_name: syntria-db-prod
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-checklist_db} POSTGRES_DB: ${POSTGRES_DB:-syntria_db}
POSTGRES_USER: ${POSTGRES_USER:-checklist_user} POSTGRES_USER: ${POSTGRES_USER:-syntria_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
- app-network
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-checklist_user}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-syntria_user} -d ${POSTGRES_DB:-syntria_db}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: always
networks:
- syntria-network
backend: backend:
build: image: ${DOCKER_USERNAME}/syntria-backend:latest
context: ./backend container_name: syntria-backend-prod
dockerfile: Dockerfile
container_name: checklist-backend-prod
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-checklist_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-checklist_db} DATABASE_URL: postgresql://${POSTGRES_USER:-syntria_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-syntria_db}
SECRET_KEY: ${SECRET_KEY} SECRET_KEY: ${SECRET_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
ENVIRONMENT: production ENVIRONMENT: production
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost,http://localhost:5173}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- ./uploads:/app/uploads - uploads_data:/app/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
restart: always
networks: networks:
- app-network - syntria-network
restart: unless-stopped command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
frontend: frontend:
build: image: ${DOCKER_USERNAME}/syntria-frontend:latest
context: ./frontend container_name: syntria-frontend-prod
dockerfile: Dockerfile.prod
args:
VITE_API_URL: ${VITE_API_URL}
container_name: checklist-frontend-prod
ports: ports:
- "80:80" - "80:80"
environment:
- VITE_API_URL=${API_URL:-http://localhost:8000}
restart: always
networks:
- syntria-network
depends_on: depends_on:
- backend - backend
networks:
- app-network
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
uploads_data:
networks: networks:
app-network: syntria-network:
driver: bridge driver: bridge

View File

@@ -1,19 +1,18 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: checklist-db container_name: checklist-db
environment: environment:
POSTGRES_DB: checklist_db POSTGRES_DB: ${POSTGRES_DB:-checklist_db}
POSTGRES_USER: checklist_user POSTGRES_USER: ${POSTGRES_USER:-checklist_user}
POSTGRES_PASSWORD: checklist_pass_2024 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-checklist_pass_2024}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U checklist_user"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-checklist_user} -d ${POSTGRES_DB:-checklist_db}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -22,10 +21,10 @@ services:
build: ./backend build: ./backend
container_name: checklist-backend container_name: checklist-backend
environment: environment:
DATABASE_URL: postgresql://checklist_user:checklist_pass_2024@postgres:5432/checklist_db DATABASE_URL: postgresql://${POSTGRES_USER:-checklist_user}:${POSTGRES_PASSWORD:-checklist_pass_2024}@postgres:5432/${POSTGRES_DB:-checklist_db}
SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production-min-32-chars} SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production-min-32-chars}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
ENVIRONMENT: development ENVIRONMENT: ${ENVIRONMENT:-development}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:

99
docker-stack.yml Normal file
View File

@@ -0,0 +1,99 @@
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_DB: syntria_db
POSTGRES_USER: syntria_user
POSTGRES_PASSWORD: syntria_secure_2024
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- syntria_network
deploy:
mode: replicated
replicas: 1
placement:
constraints: [node.role == manager]
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
healthcheck:
test: ["CMD-SHELL", "pg_isready -U syntria_user -d syntria_db"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: dymai/syntria-backend:latest
environment:
DATABASE_URL: postgresql://syntria_user:syntria_secure_2024@db:5432/syntria_db
SECRET_KEY: tu_clave_secreta_super_segura_minimo_32_caracteres_prod
OPENAI_API_KEY: tu_api_key_de_openai
GEMINI_API_KEY: tu_api_key_de_gemini
ENVIRONMENT: production
ALLOWED_ORIGINS: http://localhost,https://syntria.tudominio.com
networks:
- syntria_network
- network_public
deploy:
mode: replicated
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
labels:
- "traefik.enable=true"
- "traefik.docker.network=network_public"
- "traefik.http.routers.syntria-api.rule=Host(`syntria.tudominio.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.syntria-api.entrypoints=websecure"
- "traefik.http.routers.syntria-api.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.syntria-api.service=syntria-api"
- "traefik.http.services.syntria-api.loadbalancer.server.port=8000"
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
frontend:
image: dymai/syntria-frontend:latest
networks:
- syntria_network
- network_public
deploy:
mode: replicated
replicas: 2
update_config:
parallelism: 1
delay: 10s
order: start-first
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
labels:
- "traefik.enable=true"
- "traefik.docker.network=network_public"
- "traefik.http.routers.syntria-web.rule=Host(`syntria.tudominio.com`)"
- "traefik.http.routers.syntria-web.entrypoints=websecure"
- "traefik.http.routers.syntria-web.priority=1"
- "traefik.http.routers.syntria-web.tls.certresolver=letsencryptresolver"
- "traefik.http.routers.syntria-web.service=syntria-web"
- "traefik.http.services.syntria-web.loadbalancer.server.port=80"
networks:
syntria_network:
driver: overlay
attachable: true
network_public:
external: true
attachable: true
name: network_public
volumes:
postgres_data:
driver: local

16
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
# Frontend .dockerignore
node_modules
dist
.git
.gitignore
.dockerignore
Dockerfile
Dockerfile.prod
README.md
.env
.env.*
*.md
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store

View File

@@ -1,3 +1,4 @@
# Build stage
FROM node:18-alpine AS builder FROM node:18-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -5,26 +6,19 @@ WORKDIR /app
# Copiar package files # Copiar package files
COPY package*.json ./ COPY package*.json ./
# Instalar todas las dependencias (necesitamos las dev para el build) # Instalar dependencias
RUN npm install RUN npm install
# Copiar código # Copiar código fuente
COPY . . COPY . .
# Build argument para la URL de la API # Build de producción
ARG VITE_API_URL=http://checklist-rons-0e8a3a-63dbc4-72-61-106-199.traefik.me
ENV VITE_API_URL=$VITE_API_URL
# Mostrar la URL que se está usando (para debug)
RUN echo "Building with VITE_API_URL=${VITE_API_URL}"
# Construir la aplicación
RUN npm run build RUN npm run build
# Etapa de producción con Nginx # Production stage
FROM nginx:alpine FROM nginx:alpine
# Copiar archivos construidos # Copiar build al nginx
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Copiar configuración de nginx # Copiar configuración de nginx
@@ -33,5 +27,5 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
# Exponer puerto 80 # Exponer puerto 80
EXPOSE 80 EXPOSE 80
# Comando para iniciar nginx # Comando por defecto
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -4,7 +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>Checklist Inteligente - Sistema de Inspecciones</title> <title>Syntria - Sistema Inteligente de Inspecciones</title>
<meta name="description" content="Syntria: Sistema avanzado de inspecciones vehiculares con inteligencia artificial" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -11,24 +11,30 @@ server {
gzip_min_length 1024; gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# SPA routing - todas las rutas van a index.html # Proxy al backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# Cache para assets estáticos # Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
# No cache para index.html
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires 0;
}
# Security headers # Security headers
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,19 @@
export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) { export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) {
return ( return (
<aside className={`bg-gray-900 text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-16'} flex flex-col fixed h-full z-10`}> <aside className={`bg-gradient-to-b from-gray-900 via-indigo-950 to-purple-950 text-white transition-all duration-300 ${sidebarOpen ? 'w-64' : 'w-16'} flex flex-col fixed h-full z-10 shadow-2xl`}>
{/* Sidebar Header */} {/* Sidebar Header */}
<div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-gray-700`}> <div className={`p-4 flex items-center ${sidebarOpen ? 'justify-between' : 'justify-center'} border-b border-indigo-800/50`}>
{sidebarOpen && <h2 className="text-xl font-bold">Sistema</h2>} {sidebarOpen && (
<div className="flex items-center gap-2">
<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>
</div>
)}
<button <button
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-lg hover:bg-gray-800 transition" className="p-2 rounded-lg hover:bg-indigo-800/50 transition"
title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'} title={sidebarOpen ? 'Ocultar sidebar' : 'Mostrar sidebar'}
> >
{sidebarOpen ? '☰' : '☰'} {sidebarOpen ? '☰' : '☰'}
@@ -21,8 +28,8 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
onClick={() => setActiveTab('checklists')} onClick={() => setActiveTab('checklists')}
className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${ className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${
activeTab === 'checklists' activeTab === 'checklists'
? 'bg-blue-600 text-white' ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg'
: 'text-gray-300 hover:bg-gray-800' : 'text-indigo-200 hover:bg-indigo-900/50'
}`} }`}
title={!sidebarOpen ? 'Checklists' : ''} title={!sidebarOpen ? 'Checklists' : ''}
> >
@@ -35,8 +42,8 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
onClick={() => setActiveTab('inspections')} onClick={() => setActiveTab('inspections')}
className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${ className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${
activeTab === 'inspections' activeTab === 'inspections'
? 'bg-blue-600 text-white' ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg'
: 'text-gray-300 hover:bg-gray-800' : 'text-indigo-200 hover:bg-indigo-900/50'
}`} }`}
title={!sidebarOpen ? 'Inspecciones' : ''} title={!sidebarOpen ? 'Inspecciones' : ''}
> >
@@ -51,8 +58,8 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
onClick={() => setActiveTab('users')} onClick={() => setActiveTab('users')}
className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${ className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${
activeTab === 'users' activeTab === 'users'
? 'bg-blue-600 text-white' ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg'
: 'text-gray-300 hover:bg-gray-800' : 'text-indigo-200 hover:bg-indigo-900/50'
}`} }`}
title={!sidebarOpen ? 'Usuarios' : ''} title={!sidebarOpen ? 'Usuarios' : ''}
> >
@@ -65,8 +72,8 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
onClick={() => setActiveTab('reports')} onClick={() => setActiveTab('reports')}
className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${ className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${
activeTab === 'reports' activeTab === 'reports'
? 'bg-blue-600 text-white' ? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg'
: 'text-gray-300 hover:bg-gray-800' : 'text-indigo-200 hover:bg-indigo-900/50'
}`} }`}
title={!sidebarOpen ? 'Reportes' : ''} title={!sidebarOpen ? 'Reportes' : ''}
> >
@@ -74,27 +81,41 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
{sidebarOpen && <span>Reportes</span>} {sidebarOpen && <span>Reportes</span>}
</button> </button>
</li> </li>
<li>
<button
onClick={() => setActiveTab('settings')}
className={`w-full flex items-center ${sidebarOpen ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition ${
activeTab === 'settings'
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg'
: 'text-indigo-200 hover:bg-indigo-900/50'
}`}
title={!sidebarOpen ? 'Configuración' : ''}
>
<span className="text-xl"></span>
{sidebarOpen && <span>Configuración</span>}
</button>
</li>
</> </>
)} )}
</ul> </ul>
</nav> </nav>
{/* User Info */} {/* User Info */}
<div className="p-4 border-t border-gray-700"> <div className="p-4 border-t border-indigo-800/50">
<div className={`flex items-center gap-3 ${!sidebarOpen && 'justify-center'}`}> <div className={`flex items-center gap-3 ${!sidebarOpen && 'justify-center'}`}>
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0"> <div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-white font-bold flex-shrink-0 shadow-lg">
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
</div> </div>
{sidebarOpen && ( {sidebarOpen && (
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.full_name || user.username}</p> <p className="text-sm font-medium truncate text-white">{user.full_name || user.username}</p>
<p className="text-xs text-gray-400">{user.role === 'admin' ? 'Admin' : 'Mecánico'}</p> <p className="text-xs text-indigo-300">{user.role === 'admin' ? '👑 Admin' : '🔧 Mecánico'}</p>
</div> </div>
)} )}
</div> </div>
<button <button
onClick={onLogout} onClick={onLogout}
className={`mt-3 w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition flex items-center justify-center gap-2`} className={`mt-3 w-full px-4 py-2 bg-gradient-to-r from-red-500 to-pink-500 text-white rounded-lg hover:from-red-600 hover:to-pink-600 transition-all transform hover:scale-105 flex items-center justify-center gap-2 shadow-lg`}
title={!sidebarOpen ? 'Cerrar Sesión' : ''} title={!sidebarOpen ? 'Cerrar Sesión' : ''}
> >
{sidebarOpen ? ( {sidebarOpen ? (

12
init-db.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Verificar que la base de datos existe
SELECT 'Database is ready!' as status;
-- Crear extensiones si son necesarias
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
EOSQL
echo "Database initialization completed successfully!"

74
init_users.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Script para inicializar la base de datos de Syntria
Ejecutar desde Portainer > Stack > Servicios > backend > Console
"""
from app.core.database import SessionLocal, engine, Base
from app.models import User
from app.core.security import get_password_hash
def init_database():
"""Inicializar base de datos y crear usuarios"""
print("🗄️ Creando tablas...")
# Crear todas las tablas
Base.metadata.create_all(bind=engine)
print("✅ Tablas creadas")
# Crear sesión
db = SessionLocal()
try:
# Verificar si ya existen usuarios
existing_users = db.query(User).count()
if existing_users > 0:
print(f"⚠️ Ya existen {existing_users} usuarios en la base de datos")
response = input("¿Deseas crear usuarios de todas formas? (s/n): ")
if response.lower() != 's':
print("❌ Operación cancelada")
return
print("\n👥 Creando usuarios...")
# Usuario Admin
admin = User(
username='admin',
password_hash=get_password_hash('admin123'),
role='admin',
full_name='Administrador',
email='admin@syntria.com',
is_active=True
)
db.add(admin)
print("✅ Usuario 'admin' creado (password: admin123)")
# Usuario Mecánico
mecanico = User(
username='mecanico',
password_hash=get_password_hash('mec123'),
role='mecanico',
full_name='Mecánico',
email='mecanico@syntria.com',
is_active=True
)
db.add(mecanico)
print("✅ Usuario 'mecanico' creado (password: mec123)")
# Guardar cambios
db.commit()
print("\n✨ Base de datos inicializada correctamente!")
print("\n📋 Usuarios creados:")
print(" - admin / admin123 (Administrador)")
print(" - mecanico / mec123 (Mecánico)")
except Exception as e:
print(f"\n❌ Error: {e}")
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
init_database()