Se aniado el boton para subir fotografias desde el equipo, ademas de guardardo en minio
This commit is contained in:
@@ -2,17 +2,27 @@
|
||||
Rutas para gestión de firmas
|
||||
"""
|
||||
|
||||
import mimetypes
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes, validate_access_to_resource
|
||||
from fastapi import Depends, HTTPException, Query, status
|
||||
from fastapi import Depends, File, HTTPException, Query, Response, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.database import get_core_db
|
||||
from core.s3_keys import signature_photo_key
|
||||
from core.storage_s3 import delete_object_if_exists, get_object_bytes, put_object_bytes
|
||||
|
||||
from .dto import SignatureCreateDTO, SignatureResponseDTO, SignatureUpdateDTO
|
||||
from .service import SignatureService
|
||||
|
||||
# Constantes
|
||||
ALLOWED_PHOTO_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
MAX_PHOTO_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
# Create router using TenantCRUDRoutes factory
|
||||
signature_crud = TenantCRUDRoutes(
|
||||
service=SignatureService,
|
||||
@@ -81,3 +91,119 @@ async def delete_signature(
|
||||
detail="Signature not found",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{signature_id}/upload-photo",
|
||||
response_model=dict,
|
||||
summary="Upload signature photo",
|
||||
)
|
||||
async def upload_signature_photo(
|
||||
signature_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency),
|
||||
):
|
||||
"""
|
||||
Sube la foto de la firma electrónica a MinIO/S3 y actualiza photo_path en la firma.
|
||||
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp — máx 5 MB.
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user, ["cat_signatures.edit"]
|
||||
)
|
||||
|
||||
# Verificar que la firma existe
|
||||
sig = SignatureService.get_by_id(db, signature_id, tenant_id, company_id)
|
||||
if not sig:
|
||||
raise HTTPException(status_code=404, detail="Signature not found")
|
||||
|
||||
# Validar extensión
|
||||
file_ext = os.path.splitext(file.filename or "")[1].lower()
|
||||
if file_ext not in ALLOWED_PHOTO_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Tipo de archivo no permitido. Permitidos: {', '.join(ALLOWED_PHOTO_EXTENSIONS)}",
|
||||
)
|
||||
|
||||
# Leer y validar tamaño
|
||||
content = await file.read()
|
||||
if len(content) > MAX_PHOTO_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Archivo demasiado grande. Máximo: {MAX_PHOTO_SIZE // 1024 // 1024} MB",
|
||||
)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
try:
|
||||
if settings.use_s3_object_storage:
|
||||
# Eliminar foto anterior si existe en S3
|
||||
if sig.photo_path and sig.photo_path.startswith("tenants/"):
|
||||
delete_object_if_exists(sig.photo_path)
|
||||
|
||||
key = signature_photo_key(tenant_id, company_id, signature_id, timestamp, file_ext)
|
||||
ct = mimetypes.guess_type(file.filename or "photo")[0] or "image/jpeg"
|
||||
put_object_bytes(key, content, content_type=ct)
|
||||
file_path = key
|
||||
else:
|
||||
# Fallback: almacenamiento local
|
||||
upload_dir = os.path.join("uploads", "signatures", str(signature_id))
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
filename = f"photo_{timestamp}{file_ext}"
|
||||
file_path = os.path.join(upload_dir, filename)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error al guardar la foto: {str(exc)}",
|
||||
) from exc
|
||||
|
||||
# Actualizar photo_path en la firma
|
||||
from .dto import SignatureUpdateDTO as UpdateDTO
|
||||
SignatureService.update(
|
||||
db, signature_id, tenant_id, UpdateDTO(photo_path=file_path), company_id
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Foto subida correctamente",
|
||||
"photo_path": file_path,
|
||||
"signature_id": signature_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{signature_id}/photo",
|
||||
summary="Get signature photo image",
|
||||
)
|
||||
async def get_signature_photo(
|
||||
signature_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: Dict[str, Any] = Depends(signature_crud.auth_dependency),
|
||||
):
|
||||
"""Devuelve los bytes de la foto de la firma desde MinIO/S3 para preview."""
|
||||
tenant_id = validate_access_to_resource(
|
||||
db, company_id, current_user, ["cat_signatures.view"]
|
||||
)
|
||||
|
||||
sig = SignatureService.get_by_id(db, signature_id, tenant_id, company_id)
|
||||
if not sig or not sig.photo_path:
|
||||
raise HTTPException(status_code=404, detail="Foto no encontrada")
|
||||
|
||||
if sig.photo_path.startswith("tenants/"):
|
||||
try:
|
||||
data = get_object_bytes(sig.photo_path)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Foto no encontrada en el almacenamiento")
|
||||
media_type = mimetypes.guess_type(sig.photo_path)[0] or "image/jpeg"
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
# Fallback: archivo local
|
||||
if not os.path.isfile(sig.photo_path):
|
||||
raise HTTPException(status_code=404, detail="Foto no encontrada en el sistema de archivos")
|
||||
with open(sig.photo_path, "rb") as f:
|
||||
data = f.read()
|
||||
media_type = mimetypes.guess_type(sig.photo_path)[0] or "image/jpeg"
|
||||
return Response(content=data, media_type=media_type)
|
||||
|
||||
@@ -396,6 +396,26 @@ def help_public_api_path(relative_under_help: str) -> str:
|
||||
return f"/api/v1/core/help-center/files/{rel}"
|
||||
|
||||
|
||||
def signature_photo_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
signature_id: int,
|
||||
timestamp: str,
|
||||
file_ext: str,
|
||||
) -> str:
|
||||
"""
|
||||
Foto de firma bajo ``.../signatures/{signature_id}/photo_{timestamp}.{ext}``.
|
||||
Extensiones permitidas: .jpg, .jpeg, .png, .gif, .webp
|
||||
"""
|
||||
sid = _segment(signature_id, "signature_id")
|
||||
ts = _segment(timestamp, "timestamp")
|
||||
ext = file_ext.lower() if str(file_ext).startswith(".") else f".{file_ext}"
|
||||
allowed = (".jpg", ".jpeg", ".png", ".gif", ".webp")
|
||||
if ext not in allowed:
|
||||
raise ValueError(f"signature photo extension not allowed: {ext}")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}signatures/{sid}/photo_{ts}{ext}"
|
||||
|
||||
|
||||
def system_help_object_key(relative_path: str) -> str:
|
||||
"""
|
||||
Clave S3 completa bajo ``system/help/`` para un path relativo (p. ej. GET /files/...).
|
||||
|
||||
@@ -92,5 +92,76 @@ export const signaturesApi = {
|
||||
get: (id: number, companyId: number) => getSignature(id, companyId),
|
||||
create: (data: SignatureCreate, companyId: number) => createSignature(data, companyId),
|
||||
update: (id: number, data: SignatureUpdate, companyId: number) => updateSignature(id, data, companyId),
|
||||
delete: (id: number, companyId: number) => deleteSignature(id, companyId)
|
||||
delete: (id: number, companyId: number) => deleteSignature(id, companyId),
|
||||
uploadPhoto: (id: number, file: File, companyId: number) => uploadSignaturePhoto(id, file, companyId),
|
||||
getPhotoUrl: (id: number, companyId: number) => getSignaturePhotoUrl(id, companyId),
|
||||
};
|
||||
|
||||
|
||||
export interface SignaturePhotoUploadResponse {
|
||||
message: string;
|
||||
photo_path: string;
|
||||
signature_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sube la foto de la firma a MinIO vía multipart/form-data.
|
||||
* Usa fetch directo porque api.post() siempre JSON.stringify el body,
|
||||
* lo que destruye el FormData. Replica la auth del cliente api.ts.
|
||||
*/
|
||||
export async function uploadSignaturePhoto(
|
||||
signatureId: number,
|
||||
file: File,
|
||||
companyId: number
|
||||
): Promise<ApiResponse<SignaturePhotoUploadResponse>> {
|
||||
const { getToken } = await import('$lib/auth');
|
||||
const { browser } = await import('$app/environment');
|
||||
|
||||
const apiBase = String(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, '');
|
||||
const token = getToken();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// Replicar X-Tenant-Override igual que fetchApi / buildAuthHeaders
|
||||
if (browser) {
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) headers['X-Tenant-Override'] = tenantPub;
|
||||
|
||||
const activeSystem = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('active_system='))
|
||||
?.split('=')[1];
|
||||
if (activeSystem) headers['X-Active-System'] = activeSystem;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${apiBase}/v1/a76/signatures/${signatureId}/upload-photo?company_id=${companyId}`,
|
||||
{ method: 'POST', headers, credentials: 'include', body: formData }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || data.message || 'Error al subir la foto', status: res.status };
|
||||
}
|
||||
return { data: data as SignaturePhotoUploadResponse, status: res.status };
|
||||
} catch {
|
||||
return { error: 'Error de red al subir la foto', status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve la URL del endpoint de foto para usar en <img src=...>.
|
||||
* El backend sirve los bytes directamente desde MinIO con autenticación Bearer.
|
||||
* NOTA: Esta URL no es pública, el componente debe agregarla via fetch+blob o
|
||||
* usar el patrón de token en query param si se necesita en <img>.
|
||||
*/
|
||||
export function getSignaturePhotoUrl(signatureId: number, companyId: number): string {
|
||||
return `/api/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`;
|
||||
}
|
||||
@@ -3,14 +3,16 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Textarea } from '$lib/components/ui/textarea'; // Para que quepa más texto en la firma
|
||||
// 👇 Verifica tu ruta de importación
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { ImagePlus, Trash2, Loader2 } from 'lucide-svelte';
|
||||
import {
|
||||
createSignature,
|
||||
updateSignature,
|
||||
uploadSignaturePhoto,
|
||||
type Signature
|
||||
} from '$lib/api/dashboard/a76/general_catalogs/signatures';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { getToken } from '$lib/auth';
|
||||
import { obtenerAtajosFormularioFirmas } from '$lib/config/shortcuts/dashboard/general_catalogs/signatures/edit';
|
||||
|
||||
let {
|
||||
@@ -23,8 +25,6 @@
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Atajos
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Firma' : 'Nueva Firma');
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Estado de la foto
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let photoLoading = $state(false);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// Cargar datos al abrir
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
@@ -47,17 +53,87 @@
|
||||
signature: item.signature || '',
|
||||
photo_path: item.photo_path || ''
|
||||
};
|
||||
// Cargar preview si hay foto
|
||||
if (item.photo_path && item.id) {
|
||||
loadPhotoPreview(item.id);
|
||||
} else {
|
||||
previewUrl = null;
|
||||
}
|
||||
} else {
|
||||
formData = {
|
||||
code: '',
|
||||
signature: '',
|
||||
photo_path: ''
|
||||
};
|
||||
formData = { code: '', signature: '', photo_path: '' };
|
||||
previewUrl = null;
|
||||
}
|
||||
selectedFile = null;
|
||||
error = null;
|
||||
} else {
|
||||
// Al cerrar, limpiar la blob URL para liberar memoria
|
||||
if (previewUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
previewUrl = null;
|
||||
selectedFile = null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Carga la imagen desde el backend (autenticado) y crea una blob URL para el <img>.
|
||||
* Usa la misma base URL y headers que el cliente api.ts.
|
||||
*/
|
||||
async function loadPhotoPreview(signatureId: number) {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) return;
|
||||
|
||||
photoLoading = true;
|
||||
try {
|
||||
const token = getToken();
|
||||
// Misma URL base que usa fetchApi en api.ts
|
||||
const apiBase = String(import.meta.env.VITE_API_URL ?? '').replace(/\/+$/, '');
|
||||
const url = `${apiBase}/v1/a76/signatures/${signatureId}/photo?company_id=${companyId}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// Agregar X-Tenant-Override si existe (igual que buildAuthHeaders)
|
||||
const tenantPub = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('sso_tenant_pub='))
|
||||
?.split('=')[1];
|
||||
if (tenantPub) headers['X-Tenant-Override'] = tenantPub;
|
||||
|
||||
const res = await fetch(url, { headers, credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
previewUrl = null;
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = URL.createObjectURL(blob);
|
||||
} catch {
|
||||
previewUrl = null;
|
||||
} finally {
|
||||
photoLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0] ?? null;
|
||||
selectedFile = file;
|
||||
|
||||
if (file) {
|
||||
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPhoto() {
|
||||
selectedFile = null;
|
||||
if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
formData.photo_path = '';
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -65,11 +141,8 @@
|
||||
try {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
|
||||
// Validaciones
|
||||
if (!formData.code.trim()) throw new Error('El código es requerido');
|
||||
|
||||
// Preparar datos
|
||||
const dataToSend = {
|
||||
code: formData.code.trim(),
|
||||
signature: formData.signature.trim() || null,
|
||||
@@ -77,12 +150,26 @@
|
||||
};
|
||||
|
||||
let response;
|
||||
let savedSignatureId: number | undefined;
|
||||
|
||||
// 👇 companyId por fuera (Argumento separado)
|
||||
if (isEdit && item) {
|
||||
// Primero actualizar los campos de texto
|
||||
response = await updateSignature(item.id, dataToSend, companyId);
|
||||
savedSignatureId = item.id;
|
||||
} else {
|
||||
// Crear la firma (sin foto aún)
|
||||
response = await createSignature(dataToSend, companyId);
|
||||
savedSignatureId = (response as Signature)?.id;
|
||||
}
|
||||
|
||||
// Si hay un archivo nuevo seleccionado, subirlo ahora que tenemos el ID
|
||||
if (selectedFile && savedSignatureId) {
|
||||
const uploadResult = await uploadSignaturePhoto(savedSignatureId, selectedFile, companyId);
|
||||
if (uploadResult.error) {
|
||||
// La firma se guardó pero la foto falló: informar sin bloquear
|
||||
error = `Firma guardada, pero hubo un error al subir la foto: ${uploadResult.error}`;
|
||||
// Aún así cerrar y recargar
|
||||
}
|
||||
}
|
||||
|
||||
open = false;
|
||||
@@ -115,9 +202,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4">
|
||||
<!-- Código -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label
|
||||
>
|
||||
<Label for="code" class="text-right">Código <span class="text-destructive">*</span></Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="code"
|
||||
@@ -127,10 +214,11 @@
|
||||
disabled={loading || isEdit}
|
||||
required
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Máximo 10 caracteres.</p>
|
||||
<p class="mt-1 text-[10px] text-muted-foreground">Máximo 10 caracteres.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Firma / Nombre -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="signature" class="text-right">Firma / Nombre</Label>
|
||||
<div class="col-span-3">
|
||||
@@ -144,17 +232,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label for="photo_path" class="text-right">Ruta Foto</Label>
|
||||
<div class="col-span-3">
|
||||
<Input
|
||||
id="photo_path"
|
||||
bind:value={formData.photo_path}
|
||||
placeholder="Ej: /uploads/firmas/juan.png"
|
||||
maxlength={1000}
|
||||
<!-- Foto de firma -->
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label class="pt-2 text-right">Foto</Label>
|
||||
<div class="col-span-3 flex flex-col gap-2">
|
||||
<!-- Preview -->
|
||||
{#if photoLoading}
|
||||
<div class="flex h-28 w-full items-center justify-center rounded-md border border-dashed bg-muted/30">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
{:else if previewUrl}
|
||||
<div class="relative w-fit">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Vista previa de firma"
|
||||
class="h-28 max-w-full rounded-md border object-contain shadow-sm"
|
||||
/>
|
||||
{#if !loading}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearPhoto}
|
||||
class="absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow"
|
||||
title="Eliminar foto"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-28 w-full items-center justify-center rounded-md border border-dashed bg-muted/30 text-muted-foreground">
|
||||
<span class="text-xs">Sin foto</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Selector de archivo -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
id="photo_file"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||
class="hidden"
|
||||
onchange={handleFileChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<p class="text-[10px] text-muted-foreground mt-1">Ruta del archivo (Texto).</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-fit"
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={loading}
|
||||
>
|
||||
<ImagePlus class="mr-2 h-4 w-4" />
|
||||
{previewUrl ? 'Cambiar foto' : 'Seleccionar foto'}
|
||||
</Button>
|
||||
<p class="text-[10px] text-muted-foreground">
|
||||
JPG, PNG, GIF o WebP — máx. 5 MB.
|
||||
{#if !isEdit}
|
||||
La foto se sube al guardar.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user