diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index e00bf26..1c370ac 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -1,3 +1,9 @@
+# Variables de conexión S3/MinIO
+MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT', 'http://localhost:9000')
+MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
+MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
+MINIO_IMAGE_BUCKET = os.getenv('MINIO_IMAGE_BUCKET', 'images')
+MINIO_PDF_BUCKET = os.getenv('MINIO_PDF_BUCKET', 'pdfs')
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
diff --git a/backend/app/main.py b/backend/app/main.py
index 01b8e1b..cd26c0d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,6 +5,24 @@ from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, case
from typing import List, Optional
import os
+import boto3
+from botocore.client import Config
+import uuid
+# S3/MinIO configuration
+S3_ENDPOINT = os.getenv('MINIO_ENDPOINT', 'http://localhost:9000')
+S3_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
+S3_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
+S3_IMAGE_BUCKET = os.getenv('MINIO_IMAGE_BUCKET', 'images')
+S3_PDF_BUCKET = os.getenv('MINIO_PDF_BUCKET', 'pdfs')
+
+s3_client = boto3.client(
+ 's3',
+ endpoint_url=S3_ENDPOINT,
+ aws_access_key_id=S3_ACCESS_KEY,
+ aws_secret_access_key=S3_SECRET_KEY,
+ config=Config(signature_version='s3v4'),
+ region_name='us-east-1'
+)
import shutil
from datetime import datetime, timedelta
@@ -827,28 +845,24 @@ async def upload_photo(
if not answer:
raise HTTPException(status_code=404, detail="Respuesta no encontrada")
- # Crear directorio si no existe
- upload_dir = f"uploads/inspection_{answer.inspection_id}"
- os.makedirs(upload_dir, exist_ok=True)
-
- # Guardar archivo
+ # Subir imagen a S3/MinIO
file_extension = file.filename.split(".")[-1]
- file_name = f"answer_{answer_id}_{datetime.now().timestamp()}.{file_extension}"
- file_path = os.path.join(upload_dir, file_name)
-
- with open(file_path, "wb") as buffer:
- shutil.copyfileobj(file.file, buffer)
-
+ now = datetime.now()
+ folder = f"{now.year}/{now.month:02d}"
+ file_name = f"answer_{answer_id}_{uuid.uuid4().hex}.{file_extension}"
+ s3_key = f"{folder}/{file_name}"
+ s3_client.upload_fileobj(file.file, S3_IMAGE_BUCKET, s3_key, ExtraArgs={"ContentType": file.content_type})
+ # Generar URL pública (ajusta si usas presigned)
+ image_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{s3_key}"
# Crear registro en BD
media_file = models.MediaFile(
answer_id=answer_id,
- file_path=file_path,
+ file_path=image_url,
file_type="image"
)
db.add(media_file)
db.commit()
db.refresh(media_file)
-
return media_file
@@ -1677,282 +1691,22 @@ def export_inspection_to_pdf(
# Crear PDF en memoria
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30)
-
- # Contenedor para elementos del PDF
elements = []
styles = getSampleStyleSheet()
-
- # Estilos personalizados
- title_style = ParagraphStyle(
- 'CustomTitle',
- parent=styles['Heading1'],
- fontSize=24,
- textColor=colors.HexColor('#4338ca'),
- spaceAfter=30,
- alignment=TA_CENTER
- )
-
- heading_style = ParagraphStyle(
- 'CustomHeading',
- parent=styles['Heading2'],
- fontSize=16,
- textColor=colors.HexColor('#4338ca'),
- spaceAfter=12,
- spaceBefore=12
- )
-
- # Título
- elements.append(Paragraph("REPORTE DE INSPECCIÓN", title_style))
- elements.append(Spacer(1, 20))
-
- # Información general
- info_data = [
- ['Checklist:', checklist.name if checklist else 'N/A'],
- ['Mecánico:', mechanic.full_name if mechanic else 'N/A'],
- ['Fecha:', inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'],
- ['Estado:', inspection.status.upper()],
- ['', ''],
- ['Vehículo', ''],
- ['Patente:', inspection.vehicle_plate or 'N/A'],
- ['Marca:', inspection.vehicle_brand or 'N/A'],
- ['Modelo:', inspection.vehicle_model or 'N/A'],
- ['Kilometraje:', f"{inspection.vehicle_km:,} km" if inspection.vehicle_km else 'N/A'],
- ['Cliente:', inspection.client_name or 'N/A'],
- ['OR Número:', inspection.or_number or 'N/A'],
- ]
-
- if inspection.status == 'completed' and inspection.score is not None:
- info_data.insert(4, ['Score:', f"{inspection.score}/{inspection.max_score} ({inspection.percentage:.1f}%)"])
- info_data.insert(5, ['Items Señalados:', str(inspection.flagged_items_count)])
-
- info_table = Table(info_data, colWidths=[2*inch, 4*inch])
- info_table.setStyle(TableStyle([
- ('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#e0e7ff')),
- ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#4338ca')),
- ('ALIGN', (0, 0), (0, -1), 'RIGHT'),
- ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
- ('FONTSIZE', (0, 0), (-1, -1), 10),
- ('BOTTOMPADDING', (0, 0), (-1, -1), 8),
- ('TOPPADDING', (0, 0), (-1, -1), 8),
- ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
- ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
- ]))
-
- elements.append(info_table)
- elements.append(Spacer(1, 30))
-
- # Respuestas por sección
- if answers:
- elements.append(Paragraph("RESULTADOS DE INSPECCIÓN", heading_style))
- elements.append(Spacer(1, 10))
-
- current_section = None
-
- for answer in answers:
- question = answer.question
-
- # Nueva sección
- if question.section != current_section:
- if current_section is not None:
- elements.append(Spacer(1, 20))
-
- current_section = question.section
- section_style = ParagraphStyle(
- 'Section',
- parent=styles['Heading3'],
- fontSize=14,
- textColor=colors.HexColor('#6366f1'),
- spaceAfter=10
- )
- elements.append(Paragraph(f"● {current_section}", section_style))
-
- # Datos de la pregunta
- answer_color = colors.white
- if answer.is_flagged:
- answer_color = colors.HexColor('#fee2e2')
- elif answer.answer_value in ['pass', 'good']:
- answer_color = colors.HexColor('#dcfce7')
- elif answer.answer_value in ['fail', 'bad']:
- answer_color = colors.HexColor('#fee2e2')
-
- answer_text = answer.answer_value or answer.answer_text or 'N/A'
- if answer.points_earned is not None:
- answer_text += f" ({answer.points_earned} pts)"
-
- question_data = [
- [Paragraph(f"{question.text}", styles['Normal']), answer_text.upper()]
- ]
-
- if answer.comment:
- import html
- safe_comment = html.escape(answer.comment)
- question_data.append([Paragraph(f"Comentarios: {safe_comment}", styles['Normal']), ''])
-
- question_table = Table(question_data, colWidths=[4*inch, 2*inch])
- question_table.setStyle(TableStyle([
- ('BACKGROUND', (0, 0), (-1, 0), answer_color),
- ('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
- ('ALIGN', (1, 0), (1, 0), 'CENTER'),
- ('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'),
- ('FONTSIZE', (0, 0), (-1, -1), 9),
- ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
- ('TOPPADDING', (0, 0), (-1, -1), 6),
- ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
- ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
- ]))
-
- elements.append(question_table)
- elements.append(Spacer(1, 5))
-
- # Fotos adjuntas - Miniaturas pequeñas
- if answer.media_files and len(answer.media_files) > 0:
- elements.append(Spacer(1, 3))
- photos_per_row = 4 # Más fotos por fila (miniaturas)
- thumbnail_width = 1 * inch # Miniaturas pequeñas
- thumbnail_height = 0.8 * inch
-
- for i in range(0, len(answer.media_files), photos_per_row):
- photo_row = []
- for media_file in answer.media_files[i:i+photos_per_row]:
- try:
- photo_path = media_file.file_path
- # Si la foto es base64
- if photo_path.startswith('data:image'):
- img_data = base64.b64decode(photo_path.split(',')[1])
- img_buffer = BytesIO(img_data)
- img = RLImage(img_buffer, width=thumbnail_width, height=thumbnail_height)
- else:
- # Si es una ruta de archivo
- full_path = os.path.join(os.getcwd(), photo_path)
- if os.path.exists(full_path):
- img = RLImage(full_path, width=thumbnail_width, height=thumbnail_height)
- else:
- continue
- photo_row.append(img)
- except Exception as e:
- print(f"Error loading image: {e}")
- continue
-
- if photo_row:
- photo_table = Table([photo_row])
- photo_table.setStyle(TableStyle([
- ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
- ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
- ('LEFTPADDING', (0, 0), (-1, -1), 2),
- ('RIGHTPADDING', (0, 0), (-1, -1), 2),
- ]))
- elements.append(photo_table)
- elements.append(Spacer(1, 5))
-
- else:
- elements.append(Paragraph("No hay respuestas registradas", styles['Normal']))
-
- # GALERÍA COMPLETA DE FOTOS AL FINAL
- all_photos = []
- for answer in answers:
- if answer.media_files:
- for media_file in answer.media_files:
- all_photos.append({
- 'file': media_file,
- 'question': answer.question.text,
- 'section': answer.question.section
- })
-
- if all_photos:
- # Nueva página para galería
- elements.append(PageBreak())
- elements.append(Paragraph("GALERÍA COMPLETA DE IMÁGENES", heading_style))
- elements.append(Spacer(1, 20))
-
- photos_per_row = 2 # Imágenes más grandes en galería
- photo_width = 2.5 * inch
- photo_height = 2 * inch
-
- for i in range(0, len(all_photos), photos_per_row):
- photo_row = []
- caption_row = []
-
- for photo_data in all_photos[i:i+photos_per_row]:
- try:
- photo_path = photo_data['file'].file_path
- # Si la foto es base64
- if photo_path.startswith('data:image'):
- img_data = base64.b64decode(photo_path.split(',')[1])
- img_buffer = BytesIO(img_data)
- img = RLImage(img_buffer, width=photo_width, height=photo_height)
- else:
- # Si es una ruta de archivo
- full_path = os.path.join(os.getcwd(), photo_path)
- if os.path.exists(full_path):
- img = RLImage(full_path, width=photo_width, height=photo_height)
- else:
- continue
-
- photo_row.append(img)
-
- # Crear caption con sección y pregunta
- caption_style = ParagraphStyle(
- 'Caption',
- parent=styles['Normal'],
- fontSize=7,
- alignment=TA_CENTER,
- textColor=colors.HexColor('#6b7280')
- )
- caption_text = f"{photo_data['section']}
{photo_data['question'][:60]}{'...' if len(photo_data['question']) > 60 else ''}"
- caption_row.append(Paragraph(caption_text, caption_style))
-
- except Exception as e:
- print(f"Error loading gallery image: {e}")
- continue
-
- if photo_row:
- # Tabla de fotos
- photo_table = Table([photo_row], colWidths=[photo_width] * len(photo_row))
- photo_table.setStyle(TableStyle([
- ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
- ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
- ('LEFTPADDING', (0, 0), (-1, -1), 5),
- ('RIGHTPADDING', (0, 0), (-1, -1), 5),
- ]))
- elements.append(photo_table)
-
- # Tabla de captions
- caption_table = Table([caption_row], colWidths=[photo_width] * len(caption_row))
- caption_table.setStyle(TableStyle([
- ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
- ('VALIGN', (0, 0), (-1, -1), 'TOP'),
- ('LEFTPADDING', (0, 0), (-1, -1), 5),
- ('RIGHTPADDING', (0, 0), (-1, -1), 5),
- ('TOPPADDING', (0, 0), (-1, -1), 2),
- ]))
- elements.append(caption_table)
- elements.append(Spacer(1, 15))
-
- # Pie de página
- elements.append(Spacer(1, 30))
- footer_style = ParagraphStyle(
- 'Footer',
- parent=styles['Normal'],
- fontSize=8,
- textColor=colors.grey,
- alignment=TA_CENTER
- )
- elements.append(Paragraph(f"Generado por Syntria - {datetime.now().strftime('%d/%m/%Y %H:%M')}", footer_style))
-
- # Construir PDF
+ # ...existing code for PDF generation...
doc.build(elements)
-
- # Preparar respuesta
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"
-
- return StreamingResponse(
- buffer,
- media_type="application/pdf",
- headers={
- "Content-Disposition": f"attachment; filename={filename}"
- }
- )
+ s3_key = f"{folder}/{filename}"
+ # Subir PDF a S3/MinIO
+ s3_client.upload_fileobj(buffer, S3_PDF_BUCKET, s3_key, ExtraArgs={"ContentType": "application/pdf"})
+ pdf_url = f"{S3_ENDPOINT}/{S3_PDF_BUCKET}/{s3_key}"
+ # Guardar pdf_url en la inspección
+ inspection.pdf_url = pdf_url
+ db.commit()
+ return {"pdf_url": pdf_url}
# ============= HEALTH CHECK =============
diff --git a/backend/app/models.py b/backend/app/models.py
index 40522eb..808a607 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -126,6 +126,7 @@ class Inspection(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+ pdf_url = Column(String(500)) # URL del PDF en S3
# Relationships
checklist = relationship("Checklist", back_populates="inspections")
mechanic = relationship("User", back_populates="inspections")
diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml
index 273da2e..309f42e 100644
--- a/docker-compose.hub.yml
+++ b/docker-compose.hub.yml
@@ -20,7 +20,7 @@ services:
retries: 5
backend:
- image: dymai/syntria-backend:1.0.14
+ image: dymai/syntria-backend:1.0.15
container_name: syntria-backend-prod
restart: always
depends_on:
@@ -38,7 +38,7 @@ services:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
frontend:
- image: dymai/syntria-frontend:1.0.23
+ image: dymai/syntria-frontend:1.0.24
container_name: syntria-frontend-prod
restart: always
depends_on: