Migración a S3/MinIO para imágenes y PDFs, campo pdf_url en Inspection
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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 =============
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user