Migración a S3/MinIO para imágenes y PDFs, campo pdf_url en Inspection

This commit is contained in:
2025-11-24 15:38:20 -03:00
parent 871f81277c
commit de5900a4ab
4 changed files with 47 additions and 286 deletions

View File

@@ -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 from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -5,6 +5,24 @@ from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, case from sqlalchemy import func, case
from typing import List, Optional from typing import List, Optional
import os import os
import 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 import shutil
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -827,28 +845,24 @@ async def upload_photo(
if not answer: if not answer:
raise HTTPException(status_code=404, detail="Respuesta no encontrada") raise HTTPException(status_code=404, detail="Respuesta no encontrada")
# Crear directorio si no existe # Subir imagen a S3/MinIO
upload_dir = f"uploads/inspection_{answer.inspection_id}"
os.makedirs(upload_dir, exist_ok=True)
# Guardar archivo
file_extension = file.filename.split(".")[-1] file_extension = file.filename.split(".")[-1]
file_name = f"answer_{answer_id}_{datetime.now().timestamp()}.{file_extension}" now = datetime.now()
file_path = os.path.join(upload_dir, file_name) folder = f"{now.year}/{now.month:02d}"
file_name = f"answer_{answer_id}_{uuid.uuid4().hex}.{file_extension}"
with open(file_path, "wb") as buffer: s3_key = f"{folder}/{file_name}"
shutil.copyfileobj(file.file, buffer) s3_client.upload_fileobj(file.file, S3_IMAGE_BUCKET, s3_key, ExtraArgs={"ContentType": file.content_type})
# Generar URL pública (ajusta si usas presigned)
image_url = f"{S3_ENDPOINT}/{S3_IMAGE_BUCKET}/{s3_key}"
# Crear registro en BD # Crear registro en BD
media_file = models.MediaFile( media_file = models.MediaFile(
answer_id=answer_id, answer_id=answer_id,
file_path=file_path, file_path=image_url,
file_type="image" file_type="image"
) )
db.add(media_file) db.add(media_file)
db.commit() db.commit()
db.refresh(media_file) db.refresh(media_file)
return media_file return media_file
@@ -1677,282 +1691,22 @@ def export_inspection_to_pdf(
# Crear PDF en memoria # Crear PDF en memoria
buffer = BytesIO() buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30) doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=30, leftMargin=30, topMargin=30, bottomMargin=30)
# Contenedor para elementos del PDF
elements = [] elements = []
styles = getSampleStyleSheet() styles = getSampleStyleSheet()
# ...existing code for PDF generation...
# Estilos personalizados
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#4338ca'),
spaceAfter=30,
alignment=TA_CENTER
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=16,
textColor=colors.HexColor('#4338ca'),
spaceAfter=12,
spaceBefore=12
)
# Título
elements.append(Paragraph("REPORTE DE INSPECCIÓN", title_style))
elements.append(Spacer(1, 20))
# Información general
info_data = [
['Checklist:', checklist.name if checklist else 'N/A'],
['Mecánico:', mechanic.full_name if mechanic else 'N/A'],
['Fecha:', inspection.started_at.strftime('%d/%m/%Y %H:%M') if inspection.started_at else 'N/A'],
['Estado:', inspection.status.upper()],
['', ''],
['Vehículo', ''],
['Patente:', inspection.vehicle_plate or 'N/A'],
['Marca:', inspection.vehicle_brand or 'N/A'],
['Modelo:', inspection.vehicle_model or 'N/A'],
['Kilometraje:', f"{inspection.vehicle_km:,} km" if inspection.vehicle_km else 'N/A'],
['Cliente:', inspection.client_name or 'N/A'],
['OR Número:', inspection.or_number or 'N/A'],
]
if inspection.status == 'completed' and inspection.score is not None:
info_data.insert(4, ['Score:', f"{inspection.score}/{inspection.max_score} ({inspection.percentage:.1f}%)"])
info_data.insert(5, ['Items Señalados:', str(inspection.flagged_items_count)])
info_table = Table(info_data, colWidths=[2*inch, 4*inch])
info_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#e0e7ff')),
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#4338ca')),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 8),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(info_table)
elements.append(Spacer(1, 30))
# Respuestas por sección
if answers:
elements.append(Paragraph("RESULTADOS DE INSPECCIÓN", heading_style))
elements.append(Spacer(1, 10))
current_section = None
for answer in answers:
question = answer.question
# Nueva sección
if question.section != current_section:
if current_section is not None:
elements.append(Spacer(1, 20))
current_section = question.section
section_style = ParagraphStyle(
'Section',
parent=styles['Heading3'],
fontSize=14,
textColor=colors.HexColor('#6366f1'),
spaceAfter=10
)
elements.append(Paragraph(f"{current_section}", section_style))
# Datos de la pregunta
answer_color = colors.white
if answer.is_flagged:
answer_color = colors.HexColor('#fee2e2')
elif answer.answer_value in ['pass', 'good']:
answer_color = colors.HexColor('#dcfce7')
elif answer.answer_value in ['fail', 'bad']:
answer_color = colors.HexColor('#fee2e2')
answer_text = answer.answer_value or answer.answer_text or 'N/A'
if answer.points_earned is not None:
answer_text += f" ({answer.points_earned} pts)"
question_data = [
[Paragraph(f"<b>{question.text}</b>", styles['Normal']), answer_text.upper()]
]
if answer.comment:
import html
safe_comment = html.escape(answer.comment)
question_data.append([Paragraph(f"<i>Comentarios: {safe_comment}</i>", styles['Normal']), ''])
question_table = Table(question_data, colWidths=[4*inch, 2*inch])
question_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), answer_color),
('TEXTCOLOR', (0, 0), (-1, -1), colors.black),
('ALIGN', (1, 0), (1, 0), 'CENTER'),
('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 9),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
('TOPPADDING', (0, 0), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(question_table)
elements.append(Spacer(1, 5))
# Fotos adjuntas - 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"<b>{photo_data['section']}</b><br/>{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
doc.build(elements) doc.build(elements)
# Preparar respuesta
buffer.seek(0) 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" filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf"
s3_key = f"{folder}/{filename}"
return StreamingResponse( # Subir PDF a S3/MinIO
buffer, s3_client.upload_fileobj(buffer, S3_PDF_BUCKET, s3_key, ExtraArgs={"ContentType": "application/pdf"})
media_type="application/pdf", pdf_url = f"{S3_ENDPOINT}/{S3_PDF_BUCKET}/{s3_key}"
headers={ # Guardar pdf_url en la inspección
"Content-Disposition": f"attachment; filename={filename}" inspection.pdf_url = pdf_url
} db.commit()
) return {"pdf_url": pdf_url}
# ============= HEALTH CHECK ============= # ============= HEALTH CHECK =============

View File

@@ -126,6 +126,7 @@ class Inspection(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
pdf_url = Column(String(500)) # URL del PDF en S3
# Relationships # Relationships
checklist = relationship("Checklist", back_populates="inspections") checklist = relationship("Checklist", back_populates="inspections")
mechanic = relationship("User", back_populates="inspections") mechanic = relationship("User", back_populates="inspections")

View File

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