feat(files): upload/preview/delete allegati su fatture, ULA, documenti
- models: colonne file inline (storage_path, mime, size_bytes, sha256, uploaded_by, uploaded_at) su remission_invoice, remission_ula_employee, remission_document - migrations: ALTER idempotente al lifespan per evolvere schema in sandbox - storage: FS adapter /var/uploads con validazione MIME/size, dedup sha256, sanitize - routers/files: POST upload / GET download (con ?inline=1) / DELETE matrix autorizzazioni: beneficiary su DRAFT|AWAITING_AMENDMENT, istruttore read-only, superadmin full - main: include router files, version bump 0.2.0 Testato E2E con admin JWT: upload 549B PDF -> DB coerente, storage 1/invoice/<uuid>/<sha12>-file.pdf, download con magic bytes PDF corretti, delete chirurgico con cleanup FS e metadata.
This commit is contained in:
17
app/main.py
17
app/main.py
@@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
|
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
|
||||||
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
|
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
|
||||||
fatture, ULA, soccorso istruttorio.
|
fatture, ULA, soccorso istruttorio, upload file, verbale istruttoria.
|
||||||
|
|
||||||
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic).
|
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic) + weasyprint.
|
||||||
Auth: JWT condiviso con GEPAFIN-BE.
|
Auth: JWT condiviso con GEPAFIN-BE.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
@@ -14,7 +14,8 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import engine, Base
|
from .db import engine, Base
|
||||||
from .routers import health, schemas, practices, debug, instructor
|
from .migrations import run_migrations
|
||||||
|
from .routers import health, schemas, practices, debug, instructor, files, verbale
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
log = logging.getLogger("rendicontazione-api")
|
log = logging.getLogger("rendicontazione-api")
|
||||||
@@ -25,12 +26,12 @@ settings = get_settings()
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
log.info("Avvio rendicontazione-api")
|
log.info("Avvio rendicontazione-api")
|
||||||
# Crea schema e tabelle se non esistono (bootstrap sandbox)
|
|
||||||
try:
|
try:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
|
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
log.info(f"Schema '{settings.db_schema}' e tabelle inizializzate")
|
run_migrations(engine)
|
||||||
|
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Errore bootstrap DB: {e}")
|
log.error(f"Errore bootstrap DB: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -41,7 +42,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="rendicontazione-api",
|
title="rendicontazione-api",
|
||||||
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
|
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
|
||||||
version="0.1.0",
|
version="0.3.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,13 +59,15 @@ app.include_router(schemas.router)
|
|||||||
app.include_router(practices.router)
|
app.include_router(practices.router)
|
||||||
app.include_router(debug.router)
|
app.include_router(debug.router)
|
||||||
app.include_router(instructor.router)
|
app.include_router(instructor.router)
|
||||||
|
app.include_router(files.router)
|
||||||
|
app.include_router(verbale.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["root"])
|
@app.get("/", tags=["root"])
|
||||||
def root():
|
def root():
|
||||||
return {
|
return {
|
||||||
"service": "rendicontazione-api",
|
"service": "rendicontazione-api",
|
||||||
"version": "0.1.0",
|
"version": "0.3.0",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"health": "/health",
|
"health": "/health",
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/migrations.py
Normal file
56
app/migrations.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Migration idempotente per sandbox.
|
||||||
|
Base.metadata.create_all() crea solo tabelle mancanti, non colonne aggiuntive.
|
||||||
|
Qui gestiamo ALTER TABLE ADD COLUMN IF NOT EXISTS per l'evoluzione dello schema.
|
||||||
|
|
||||||
|
Ogni migration è una stringa SQL che puo essere eseguita piu volte senza errori.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("rendicontazione-api.migrations")
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATIONS = [
|
||||||
|
# 2026-04-18: colonne file upload su remission_invoice
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_invoice
|
||||||
|
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS mime varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS size_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
|
||||||
|
""",
|
||||||
|
# 2026-04-18: colonne file upload su remission_ula_employee
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_ula_employee
|
||||||
|
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS mime varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS size_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
|
||||||
|
""",
|
||||||
|
# 2026-04-18: colonne file upload su remission_document
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_document
|
||||||
|
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS mime varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS size_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by integer;
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations(engine) -> None:
|
||||||
|
"""Esegue tutte le migration in transazione. Log su ciascuna."""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for i, sql in enumerate(MIGRATIONS, 1):
|
||||||
|
try:
|
||||||
|
conn.execute(text(sql))
|
||||||
|
log.info(f"migration {i}/{len(MIGRATIONS)} OK")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"migration {i} FAILED: {e}")
|
||||||
|
raise
|
||||||
@@ -4,7 +4,7 @@ Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox).
|
|||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date, BigInteger
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -102,7 +102,15 @@ class RemissionInvoice(Base):
|
|||||||
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
|
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
|
||||||
vat = Column(Numeric(14, 2), nullable=False, default=0)
|
vat = Column(Numeric(14, 2), nullable=False, default=0)
|
||||||
total = Column(Numeric(14, 2), nullable=False)
|
total = Column(Numeric(14, 2), nullable=False)
|
||||||
pdf_filename = Column(String(512), nullable=True) # per ora solo nome, upload vero dopo
|
pdf_filename = Column(String(512), nullable=True) # nome originale
|
||||||
|
|
||||||
|
# File upload (bind mount /var/uploads dentro container)
|
||||||
|
storage_path = Column(String(1024), nullable=True) # relativo a /var/uploads
|
||||||
|
mime = Column(String(128), nullable=True)
|
||||||
|
size_bytes = Column(BigInteger, nullable=True)
|
||||||
|
sha256 = Column(String(64), nullable=True)
|
||||||
|
uploaded_by = Column(Integer, nullable=True)
|
||||||
|
uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Campi istruttoria (dual declared/verified)
|
# Campi istruttoria (dual declared/verified)
|
||||||
taxable_verified = Column(Numeric(14, 2), nullable=True)
|
taxable_verified = Column(Numeric(14, 2), nullable=True)
|
||||||
@@ -139,7 +147,15 @@ class RemissionUlaEmployee(Base):
|
|||||||
period_end_date = Column(Date, nullable=False)
|
period_end_date = Column(Date, nullable=False)
|
||||||
|
|
||||||
supporting_doc_type = Column(String(64), nullable=True)
|
supporting_doc_type = Column(String(64), nullable=True)
|
||||||
supporting_doc_filename = Column(String(512), nullable=True)
|
supporting_doc_filename = Column(String(512), nullable=True) # nome originale
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
storage_path = Column(String(1024), nullable=True)
|
||||||
|
mime = Column(String(128), nullable=True)
|
||||||
|
size_bytes = Column(BigInteger, nullable=True)
|
||||||
|
sha256 = Column(String(64), nullable=True)
|
||||||
|
uploaded_by = Column(Integer, nullable=True)
|
||||||
|
uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
# Campi istruttoria
|
# Campi istruttoria
|
||||||
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
|
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
|
||||||
@@ -164,11 +180,18 @@ class RemissionDocument(Base):
|
|||||||
nullable=False)
|
nullable=False)
|
||||||
|
|
||||||
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
|
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
|
||||||
filename = Column(String(512), nullable=True)
|
filename = Column(String(512), nullable=True) # nome originale
|
||||||
uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
expires_at = Column(Date, nullable=True)
|
expires_at = Column(Date, nullable=True)
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# File upload
|
||||||
|
storage_path = Column(String(1024), nullable=True)
|
||||||
|
mime = Column(String(128), nullable=True)
|
||||||
|
size_bytes = Column(BigInteger, nullable=True)
|
||||||
|
sha256 = Column(String(64), nullable=True)
|
||||||
|
uploaded_by = Column(Integer, nullable=True)
|
||||||
|
|
||||||
# Campi istruttoria
|
# Campi istruttoria
|
||||||
verification_status = Column(String(16), nullable=False, default="PENDING")
|
verification_status = Column(String(16), nullable=False, default="PENDING")
|
||||||
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
||||||
|
|||||||
269
app/routers/files.py
Normal file
269
app/routers/files.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Endpoint file upload/download/delete per fatture, ULA, documenti.
|
||||||
|
Storage: FS locale via app.storage.
|
||||||
|
Autorizzazione:
|
||||||
|
- UPLOAD: beneficiario su sua pratica in DRAFT/AWAITING_AMENDMENT, o istruttore
|
||||||
|
- DOWNLOAD: beneficiario su sua pratica, istruttore, superadmin
|
||||||
|
- DELETE: solo beneficiario su DRAFT/AWAITING_AMENDMENT
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..models import (
|
||||||
|
RemissionPractice, RemissionInvoice, RemissionUlaEmployee, RemissionDocument
|
||||||
|
)
|
||||||
|
from ..storage import (
|
||||||
|
save_upload, delete_file, open_file,
|
||||||
|
FileTooLargeError, MimeNotAllowedError, StorageError
|
||||||
|
)
|
||||||
|
from ..schemas import ApiResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/remission-files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_instructor(user: AuthUser) -> bool:
|
||||||
|
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
|
||||||
|
if _is_instructor(user):
|
||||||
|
return True
|
||||||
|
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||||
|
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
if _is_instructor(user):
|
||||||
|
return True
|
||||||
|
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
|
||||||
|
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||||
|
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
|
if user.is_superadmin():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_entity(db: Session, entity_type: str, entity_id: UUID):
|
||||||
|
if entity_type == "invoice":
|
||||||
|
return db.query(RemissionInvoice).filter(RemissionInvoice.id == entity_id).first()
|
||||||
|
if entity_type == "ula":
|
||||||
|
return db.query(RemissionUlaEmployee).filter(RemissionUlaEmployee.id == entity_id).first()
|
||||||
|
if entity_type == "document":
|
||||||
|
return db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
|
||||||
|
raise HTTPException(status_code=400, detail=f"entity_type non valido: {entity_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_file_meta(entity, entity_type: str) -> dict:
|
||||||
|
"""Estrae metadata file dall'entita (invoice/ula/document)."""
|
||||||
|
filename_field = {
|
||||||
|
"invoice": "pdf_filename",
|
||||||
|
"ula": "supporting_doc_filename",
|
||||||
|
"document": "filename",
|
||||||
|
}[entity_type]
|
||||||
|
|
||||||
|
has_file = bool(getattr(entity, "storage_path", None))
|
||||||
|
return {
|
||||||
|
"has_file": has_file,
|
||||||
|
"filename_original": getattr(entity, filename_field, None),
|
||||||
|
"storage_path": entity.storage_path if has_file else None,
|
||||||
|
"mime": entity.mime,
|
||||||
|
"size_bytes": entity.size_bytes,
|
||||||
|
"sha256": entity.sha256,
|
||||||
|
"uploaded_at": entity.uploaded_at.isoformat() if getattr(entity, "uploaded_at", None) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{entity_type}/{entity_id}/upload", response_model=ApiResponse)
|
||||||
|
async def upload_entity_file(
|
||||||
|
entity_type: Literal["invoice", "ula", "document"],
|
||||||
|
entity_id: UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload file per una entita (invoice/ula/document).
|
||||||
|
Se l'entita ha gia un file, lo sostituisce (vecchio file eliminato da FS).
|
||||||
|
"""
|
||||||
|
entity = _load_entity(db, entity_type, entity_id)
|
||||||
|
if not entity:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata")
|
||||||
|
|
||||||
|
# Pratica di riferimento
|
||||||
|
practice = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.id == entity.practice_id
|
||||||
|
).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
if not _can_upload(user, practice):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Non autorizzato a caricare file su questa pratica"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Salva su FS
|
||||||
|
try:
|
||||||
|
rel_path, size, digest, mime, safe_name = save_upload(
|
||||||
|
application_id=practice.application_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity.id,
|
||||||
|
file_obj=file.file,
|
||||||
|
original_filename=file.filename or "file.bin",
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
except FileTooLargeError as e:
|
||||||
|
raise HTTPException(status_code=413, detail=str(e))
|
||||||
|
except MimeNotAllowedError as e:
|
||||||
|
raise HTTPException(status_code=415, detail=str(e))
|
||||||
|
except StorageError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
|
||||||
|
|
||||||
|
# Rimuovi eventuale file precedente su FS se diverso
|
||||||
|
old_path = entity.storage_path
|
||||||
|
if old_path and old_path != rel_path:
|
||||||
|
try:
|
||||||
|
delete_file(old_path)
|
||||||
|
except Exception:
|
||||||
|
pass # non blocchiamo l'upload per cleanup fallito
|
||||||
|
|
||||||
|
# Aggiorna metadata
|
||||||
|
entity.storage_path = rel_path
|
||||||
|
entity.mime = mime
|
||||||
|
entity.size_bytes = size
|
||||||
|
entity.sha256 = digest
|
||||||
|
entity.uploaded_by = user.user_id
|
||||||
|
if hasattr(entity, "uploaded_at"):
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Aggiorna filename originale a seconda del tipo
|
||||||
|
if entity_type == "invoice":
|
||||||
|
entity.pdf_filename = safe_name
|
||||||
|
elif entity_type == "ula":
|
||||||
|
entity.supporting_doc_filename = safe_name
|
||||||
|
elif entity_type == "document":
|
||||||
|
entity.filename = safe_name
|
||||||
|
# document ha gia uploaded_at proprio campo
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entity)
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
data=_serialize_file_meta(entity, entity_type),
|
||||||
|
message="File caricato",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_type}/{entity_id}")
|
||||||
|
def download_entity_file(
|
||||||
|
entity_type: Literal["invoice", "ula", "document"],
|
||||||
|
entity_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
inline: int = 0,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download (inline=0) o preview (inline=1) del file.
|
||||||
|
Ritorna il file con header appropriati.
|
||||||
|
"""
|
||||||
|
entity = _load_entity(db, entity_type, entity_id)
|
||||||
|
if not entity or not entity.storage_path:
|
||||||
|
raise HTTPException(status_code=404, detail="File non presente")
|
||||||
|
|
||||||
|
practice = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.id == entity.practice_id
|
||||||
|
).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
if not _can_download(user, practice):
|
||||||
|
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||||
|
|
||||||
|
try:
|
||||||
|
abs_path = open_file(entity.storage_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
|
||||||
|
|
||||||
|
filename = None
|
||||||
|
if entity_type == "invoice":
|
||||||
|
filename = entity.pdf_filename
|
||||||
|
elif entity_type == "ula":
|
||||||
|
filename = entity.supporting_doc_filename
|
||||||
|
elif entity_type == "document":
|
||||||
|
filename = entity.filename
|
||||||
|
|
||||||
|
disposition = "inline" if inline else "attachment"
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'{disposition}; filename="{filename or "file"}"',
|
||||||
|
}
|
||||||
|
return FileResponse(
|
||||||
|
path=str(abs_path),
|
||||||
|
media_type=entity.mime or "application/octet-stream",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entity_type}/{entity_id}", response_model=ApiResponse)
|
||||||
|
def delete_entity_file(
|
||||||
|
entity_type: Literal["invoice", "ula", "document"],
|
||||||
|
entity_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Elimina il file allegato. Entita resta, metadata file azzerati."""
|
||||||
|
entity = _load_entity(db, entity_type, entity_id)
|
||||||
|
if not entity:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata")
|
||||||
|
|
||||||
|
practice = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.id == entity.practice_id
|
||||||
|
).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
if not _can_delete(user, practice):
|
||||||
|
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||||
|
|
||||||
|
if not entity.storage_path:
|
||||||
|
return ApiResponse(success=True, message="Nessun file da eliminare")
|
||||||
|
|
||||||
|
try:
|
||||||
|
delete_file(entity.storage_path)
|
||||||
|
except Exception:
|
||||||
|
pass # rimuoviamo comunque i metadati
|
||||||
|
|
||||||
|
entity.storage_path = None
|
||||||
|
entity.mime = None
|
||||||
|
entity.size_bytes = None
|
||||||
|
entity.sha256 = None
|
||||||
|
entity.uploaded_by = None
|
||||||
|
if hasattr(entity, "uploaded_at"):
|
||||||
|
entity.uploaded_at = None
|
||||||
|
|
||||||
|
if entity_type == "invoice":
|
||||||
|
entity.pdf_filename = None
|
||||||
|
elif entity_type == "ula":
|
||||||
|
entity.supporting_doc_filename = None
|
||||||
|
elif entity_type == "document":
|
||||||
|
entity.filename = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(success=True, message="File eliminato")
|
||||||
159
app/storage.py
Normal file
159
app/storage.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Storage adapter per file upload.
|
||||||
|
|
||||||
|
Implementazione attuale: filesystem locale con bind mount /var/uploads.
|
||||||
|
Struttura: /var/uploads/{application_id}/{entity_type}/{entity_id}/{sha256}-{filename}
|
||||||
|
|
||||||
|
Migrazione futura a S3/MinIO: cambiare solo questa classe.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO, Optional, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
BASE_PATH = Path(os.environ.get("RENDIC_UPLOAD_BASE", "/var/uploads"))
|
||||||
|
MAX_SIZE_BYTES = 15 * 1024 * 1024 # 15 MB
|
||||||
|
|
||||||
|
ALLOWED_MIMES = {
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StorageError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FileTooLargeError(StorageError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MimeNotAllowedError(StorageError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str, max_len: int = 120) -> str:
|
||||||
|
"""Rimuove caratteri pericolosi, tronca."""
|
||||||
|
keep = "-_.() "
|
||||||
|
clean = "".join(c if (c.isalnum() or c in keep) else "_" for c in name)
|
||||||
|
clean = clean.strip().replace(" ", "_")
|
||||||
|
if len(clean) > max_len:
|
||||||
|
root, ext = os.path.splitext(clean)
|
||||||
|
clean = root[: max_len - len(ext)] + ext
|
||||||
|
return clean or "file"
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(
|
||||||
|
application_id: int,
|
||||||
|
entity_type: str, # invoice | ula | document
|
||||||
|
entity_id: UUID,
|
||||||
|
file_obj: BinaryIO,
|
||||||
|
original_filename: str,
|
||||||
|
content_type: Optional[str],
|
||||||
|
) -> Tuple[str, int, str, str, str]:
|
||||||
|
"""
|
||||||
|
Salva il file su FS e ritorna (storage_path, size_bytes, sha256, mime, safe_filename).
|
||||||
|
storage_path è RELATIVO a BASE_PATH (es: "1/invoice/xxx/yyy-fattura.pdf").
|
||||||
|
|
||||||
|
Valida:
|
||||||
|
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
|
||||||
|
- dimensione <= MAX_SIZE_BYTES
|
||||||
|
"""
|
||||||
|
if entity_type not in ("invoice", "ula", "document"):
|
||||||
|
raise StorageError(f"entity_type non valido: {entity_type}")
|
||||||
|
|
||||||
|
safe_name = _safe_filename(original_filename)
|
||||||
|
ext = os.path.splitext(safe_name)[1].lower()
|
||||||
|
|
||||||
|
# Risolvi mime: prima content_type client, poi da estensione
|
||||||
|
mime = (content_type or "").lower().split(";")[0].strip()
|
||||||
|
if mime not in ALLOWED_MIMES:
|
||||||
|
# Fallback da estensione
|
||||||
|
ext_to_mime = {v: k for k, v in ALLOWED_MIMES.items()}
|
||||||
|
mime = ext_to_mime.get(ext, "")
|
||||||
|
if mime not in ALLOWED_MIMES:
|
||||||
|
raise MimeNotAllowedError(
|
||||||
|
f"MIME non consentito: '{content_type}' / estensione '{ext}'. "
|
||||||
|
f"Accettati: {list(ALLOWED_MIMES.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calcola sha256 e size streaming per non tenere tutto in RAM
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
# Salva in tmp poi rename atomico
|
||||||
|
target_dir = BASE_PATH / str(application_id) / entity_type / str(entity_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = target_dir / f".tmp-{entity_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = file_obj.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_SIZE_BYTES:
|
||||||
|
raise FileTooLargeError(
|
||||||
|
f"File {size} byte oltre limite {MAX_SIZE_BYTES}"
|
||||||
|
)
|
||||||
|
hasher.update(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
digest = hasher.hexdigest()
|
||||||
|
final_name = f"{digest[:12]}-{safe_name}"
|
||||||
|
final_path = target_dir / final_name
|
||||||
|
|
||||||
|
# Se già esiste (dedup per sha+nome) rimuovi tmp e usa esistente
|
||||||
|
if final_path.exists():
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
os.replace(tmp_path, final_path)
|
||||||
|
|
||||||
|
rel_path = str(final_path.relative_to(BASE_PATH))
|
||||||
|
return rel_path, size, digest, mime, safe_name
|
||||||
|
except Exception:
|
||||||
|
# cleanup tmp in caso di errore
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(storage_path: str) -> bool:
|
||||||
|
"""Elimina fisicamente il file. Ritorna True se rimosso."""
|
||||||
|
if not storage_path:
|
||||||
|
return False
|
||||||
|
abs_path = BASE_PATH / storage_path
|
||||||
|
# hardening: resta sotto BASE_PATH
|
||||||
|
try:
|
||||||
|
abs_path.resolve().relative_to(BASE_PATH.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
|
||||||
|
if abs_path.exists():
|
||||||
|
abs_path.unlink()
|
||||||
|
# prova a rimuovere directory vuote (entity_id/entity_type)
|
||||||
|
try:
|
||||||
|
abs_path.parent.rmdir()
|
||||||
|
abs_path.parent.parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def open_file(storage_path: str) -> Path:
|
||||||
|
"""Ritorna Path assoluto del file. Solleva FileNotFoundError se mancante."""
|
||||||
|
if not storage_path:
|
||||||
|
raise FileNotFoundError("storage_path vuoto")
|
||||||
|
abs_path = BASE_PATH / storage_path
|
||||||
|
try:
|
||||||
|
abs_path.resolve().relative_to(BASE_PATH.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
|
||||||
|
if not abs_path.is_file():
|
||||||
|
raise FileNotFoundError(f"File non trovato: {storage_path}")
|
||||||
|
return abs_path
|
||||||
Reference in New Issue
Block a user