- Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido" (catálogo medio_contacto). Backend leads.preferred_contact_method + migración f0a1b2c3d4e5 reversible. - Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al host interno minio:9000. RelatedManager.openDoc usa blob→objectURL. Suite backend en verde (109). svelte-check sin errores nuevos. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""Subida de archivos a MinIO/S3 para documentos del CRM y Operaciones.
|
|
|
|
Flujo: el frontend sube el archivo aquí, recibe ``file_key`` (permanente) y lo
|
|
guarda en el documento (crm.documents / ops.shipment_documents). Para abrirlo se
|
|
pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran).
|
|
"""
|
|
import re
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status
|
|
|
|
from core.security import get_current_user
|
|
from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes
|
|
|
|
router = APIRouter()
|
|
|
|
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
|
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
|
|
|
|
|
def _safe_filename(name: str | None) -> str:
|
|
base = (name or "archivo").strip().replace(" ", "_")
|
|
base = _SAFE_NAME.sub("", base) or "archivo"
|
|
return base[:120]
|
|
|
|
|
|
@router.post("/uploads")
|
|
async def upload_file(
|
|
file: UploadFile = File(...),
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
content = await file.read()
|
|
if len(content) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="El archivo excede el tamaño máximo permitido (25 MB)",
|
|
)
|
|
filename = _safe_filename(file.filename)
|
|
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}"
|
|
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
|
|
return {
|
|
"file_key": key,
|
|
"file_url": presigned_get_url(key),
|
|
"name": file.filename,
|
|
"content_type": file.content_type,
|
|
"size_bytes": len(content),
|
|
}
|
|
|
|
|
|
@router.get("/uploads/url")
|
|
def get_upload_url(
|
|
key: str = Query(..., description="Object key del archivo en el almacén"),
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
tenant_id = current_user["tenant_id"]
|
|
# Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento).
|
|
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
|
if not key.startswith(prefix):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
|
return {"url": presigned_get_url(key)}
|
|
|
|
|
|
@router.get("/uploads/download")
|
|
def download_file(
|
|
key: str = Query(..., description="Object key del archivo en el almacén"),
|
|
company_id: int = Query(..., description="Company ID"),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""Transmite el archivo por el backend (sin exponer MinIO al navegador).
|
|
|
|
Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``.
|
|
"""
|
|
tenant_id = current_user["tenant_id"]
|
|
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
|
if not key.startswith(prefix):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
|
try:
|
|
data = get_object_bytes(key)
|
|
except Exception:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado")
|
|
filename = key.rsplit("/", 1)[-1]
|
|
return Response(
|
|
content=data,
|
|
media_type="application/octet-stream",
|
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
)
|