Compare commits

...

2 Commits

7 changed files with 138 additions and 286 deletions

View File

@@ -1,3 +1,13 @@
import os
# Variables de conexión S3/MinIO
MINIO_HOST = os.getenv('MINIO_HOST', 'localhost')
MINIO_SECURE = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
MINIO_PORT = int(os.getenv('MINIO_PORT', '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')
MINIO_ENDPOINT = f"{'https' if MINIO_SECURE else 'http'}://{MINIO_HOST}:{MINIO_PORT}"
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):

View File

@@ -5,6 +5,25 @@ 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
from app.core import config as app_config
# S3/MinIO configuration
S3_ENDPOINT = app_config.MINIO_ENDPOINT
S3_ACCESS_KEY = app_config.MINIO_ACCESS_KEY
S3_SECRET_KEY = app_config.MINIO_SECRET_KEY
S3_IMAGE_BUCKET = app_config.MINIO_IMAGE_BUCKET
S3_PDF_BUCKET = app_config.MINIO_PDF_BUCKET
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 +846,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 +1692,25 @@ 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)
# Guardar localmente para depuración
with open(f"/tmp/test_inspeccion_{inspection_id}.pdf", "wb") as f:
f.write(buffer.getvalue())
now = datetime.now()
folder = f"{now.year}/{now.month:02d}"
filename = f"inspeccion_{inspection_id}_{inspection.vehicle_plate or 'sin-patente'}.pdf" 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, buffer.seek(0) # Asegura que el puntero esté al inicio
media_type="application/pdf", s3_client.upload_fileobj(buffer, S3_PDF_BUCKET, s3_key, ExtraArgs={"ContentType": "application/pdf"})
headers={ pdf_url = f"{S3_ENDPOINT}/{S3_PDF_BUCKET}/{s3_key}"
"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

@@ -15,3 +15,4 @@ 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
boto3==1.34.89

52
backend/s3test.py Normal file
View File

@@ -0,0 +1,52 @@
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
MINIO_ENDPOINT = "minioapi.rshtech.com.py"
MINIO_ACCESS_KEY = "6uEIJyKR2Fi4UXiSgIeG"
MINIO_SECRET_KEY = "8k0kYuvxD9ePuvjdxvDk8WkGhhlaaee8BxU1mqRW"
MINIO_IMAGE_BUCKET = "images"
MINIO_PDF_BUCKET = "pdfs"
MINIO_SECURE = True # HTTPS
MINIO_PORT = 443
def main():
try:
endpoint_url = f"https://{MINIO_ENDPOINT}:{MINIO_PORT}" if MINIO_SECURE \
else f"http://{MINIO_ENDPOINT}:{MINIO_PORT}"
# Crear cliente S3 compatible para MinIO
s3 = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=MINIO_ACCESS_KEY,
aws_secret_access_key=MINIO_SECRET_KEY,
config=Config(signature_version="s3v4"),
region_name="us-east-1"
)
print("🔍 Probando conexión…")
# Listar buckets
response = s3.list_buckets()
print("✅ Conexión exitosa. Buckets disponibles:")
for bucket in response.get("Buckets", []):
print(f" - {bucket['Name']}")
# Verificar acceso a buckets específicos
for bucket_name in [MINIO_IMAGE_BUCKET, MINIO_PDF_BUCKET]:
try:
s3.head_bucket(Bucket=bucket_name)
print(f"✔ Acceso OK al bucket: {bucket_name}")
except ClientError:
print(f"❌ No se pudo acceder al bucket: {bucket_name}")
print("🎉 Test finalizado correctamente.")
except Exception as e:
print("❌ Error:", e)
if __name__ == "__main__":
main()

30
backend/test_minio.py Normal file
View File

@@ -0,0 +1,30 @@
import os
from app.core import config as app_config
import boto3
from botocore.client import Config
scheme = 'https' if app_config.MINIO_SECURE else 'http'
endpoint = f"{scheme}://{os.getenv('MINIO_ENDPOINT', 'localhost')}:{app_config.MINIO_PORT}"
access_key = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
secret_key = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
bucket = os.getenv('MINIO_IMAGE_BUCKET', 'images')
s3 = boto3.client(
's3',
endpoint_url=endpoint,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=Config(signature_version='s3v4'),
region_name='us-east-1'
)
try:
# List buckets
response = s3.list_buckets()
print('Buckets:', [b['Name'] for b in response['Buckets']])
# Upload test file
with open('test_minio.py', 'rb') as f:
s3.upload_fileobj(f, bucket, 'test_minio.py')
print(f'Archivo subido a bucket {bucket} correctamente.')
except Exception as e:
print('Error:', e)

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: