mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Versamento su API AgID
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
"""Aggiunge la tabella tenant_settings per configurazione archiviazione sostitutiva
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-03-19 00:00:00.000000
|
||||
|
||||
Aggiunge:
|
||||
- tenant_settings (configurazione per-tenant: modalità archivio mock/produzione,
|
||||
credenziali conservatore AgID cifrate)
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE tenant_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Archiviazione sostitutiva (Fase 6)
|
||||
-- 'mock' → usa conservatore simulato in locale (default sicuro per dev)
|
||||
-- 'production' → usa endpoint reale AgID conservatore
|
||||
archival_mode VARCHAR(20) NOT NULL DEFAULT 'mock',
|
||||
|
||||
-- Identificativo del conservatore (es. 'aruba-cons', 'docuvision', 'mock')
|
||||
conservatore_id VARCHAR(100) NOT NULL DEFAULT 'mock',
|
||||
|
||||
-- URL API conservatore (NULL in modalità mock)
|
||||
conservatore_endpoint TEXT,
|
||||
|
||||
-- Credenziali cifrate AES-256-GCM (NULL in modalità mock)
|
||||
conservatore_username_enc TEXT,
|
||||
conservatore_password_enc TEXT,
|
||||
|
||||
-- Note operative libere
|
||||
archival_notes TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_tenant_settings_tenant UNIQUE (tenant_id)
|
||||
)
|
||||
""")
|
||||
op.execute("CREATE INDEX idx_tenant_settings_tenant ON tenant_settings (tenant_id)")
|
||||
|
||||
# Trigger updated_at
|
||||
op.execute("""
|
||||
CREATE TRIGGER trg_tenant_settings_updated_at
|
||||
BEFORE UPDATE ON tenant_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS tenant_settings CASCADE")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Router Impostazioni Tenant (Fase 6).
|
||||
|
||||
Endpoint:
|
||||
GET /settings → legge le impostazioni del tenant corrente (admin)
|
||||
PUT /settings → aggiorna le impostazioni del tenant corrente (admin)
|
||||
|
||||
Solo gli admin e super_admin possono accedere.
|
||||
La sezione "archiviazione" gestisce il toggle mock/produzione per il
|
||||
conservatore AgID (Fase 6 – Archiviazione Sostitutiva).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.dependencies import AdminUser, DB
|
||||
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
|
||||
from app.services.tenant_settings_service import TenantSettingsService
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["Impostazioni"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Legge le impostazioni del tenant",
|
||||
description=(
|
||||
"Restituisce la configurazione operativa del tenant: "
|
||||
"modalità archiviazione (mock/produzione), endpoint e stato credenziali conservatore."
|
||||
),
|
||||
)
|
||||
async def get_settings(
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> TenantSettingsResponse:
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.get_or_create(current_user.tenant_id)
|
||||
return TenantSettingsService.to_response(settings)
|
||||
|
||||
|
||||
@router.put(
|
||||
"",
|
||||
response_model=TenantSettingsResponse,
|
||||
summary="Aggiorna le impostazioni del tenant",
|
||||
description=(
|
||||
"Aggiorna la configurazione operativa del tenant. "
|
||||
"Tutti i campi sono opzionali (semantica PATCH). "
|
||||
"Il passaggio a modalità 'production' richiede un endpoint conservatore configurato."
|
||||
),
|
||||
)
|
||||
async def update_settings(
|
||||
body: TenantSettingsUpdate,
|
||||
current_user: AdminUser,
|
||||
db: DB,
|
||||
) -> TenantSettingsResponse:
|
||||
service = TenantSettingsService(db)
|
||||
settings = await service.update(current_user.tenant_id, body)
|
||||
await db.commit()
|
||||
await db.refresh(settings)
|
||||
return TenantSettingsService.to_response(settings)
|
||||
@@ -121,3 +121,11 @@ class ValidationError(HTTPException):
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
class BadRequestError(HTTPException):
|
||||
def __init__(self, detail: str = "Richiesta non valida") -> None:
|
||||
super().__init__(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from app.api.v1 import auth, labels, mailboxes, messages, notifications, permissions, send, tenants, users, virtual_boxes, ws
|
||||
from app.api.v1 import settings as settings_router
|
||||
from app.config import get_settings
|
||||
from app.core.logging import get_logger, setup_logging
|
||||
from app.database import engine
|
||||
@@ -94,6 +95,7 @@ app.include_router(ws.router, prefix=API_PREFIX)
|
||||
app.include_router(virtual_boxes.router, prefix=API_PREFIX)
|
||||
app.include_router(notifications.router, prefix=API_PREFIX)
|
||||
app.include_router(labels.router, prefix=API_PREFIX)
|
||||
app.include_router(settings_router.router, prefix=API_PREFIX)
|
||||
|
||||
|
||||
# ─── Health check ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,3 +9,4 @@ from app.models.label import Label, MessageLabel # noqa: F401
|
||||
from app.models.permission import MailboxPermission # noqa: F401
|
||||
from app.models.virtual_box import VirtualBox, VirtualBoxRule, VirtualBoxAssignment # noqa: F401
|
||||
from app.models.notification import NotificationChannel, NotificationRule, NotificationLog # noqa: F401
|
||||
from app.models.tenant_settings import TenantSettings # noqa: F401
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Modello TenantSettings – configurazione per-tenant (archiviazione sostitutiva, ecc.).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TenantSettings(Base):
|
||||
"""
|
||||
Configurazione operativa per tenant.
|
||||
|
||||
Attualmente gestisce:
|
||||
- Modalità archiviazione sostitutiva (mock / production)
|
||||
- Credenziali conservatore AgID (cifrate AES-256-GCM)
|
||||
|
||||
Una riga per tenant, creata al primo accesso (upsert).
|
||||
"""
|
||||
|
||||
__tablename__ = "tenant_settings"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=False, index=True
|
||||
)
|
||||
|
||||
# ── Archiviazione sostitutiva ─────────────────────────────────────────────
|
||||
# 'mock' → conservatore simulato (risposte false, default per dev)
|
||||
# 'production' → endpoint reale AgID conservatore
|
||||
archival_mode: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="mock"
|
||||
)
|
||||
|
||||
# Identificativo conservatore (es. 'aruba-cons', 'docuvision', 'mock')
|
||||
conservatore_id: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, default="mock"
|
||||
)
|
||||
|
||||
# URL endpoint API conservatore (None in modalità mock)
|
||||
conservatore_endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Credenziali cifrate AES-256-GCM (None in modalità mock)
|
||||
conservatore_username_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
conservatore_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Note operative opzionali
|
||||
archival_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", name="uq_tenant_settings_tenant"),
|
||||
Index("idx_tenant_settings_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TenantSettings tenant={self.tenant_id} mode={self.archival_mode!r}>"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Schema Pydantic per TenantSettings – lettura e aggiornamento impostazioni tenant.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl, field_validator
|
||||
|
||||
|
||||
ArchivalMode = Literal["mock", "production"]
|
||||
|
||||
|
||||
class TenantSettingsResponse(BaseModel):
|
||||
"""
|
||||
Risposta GET /settings.
|
||||
|
||||
Le credenziali del conservatore non vengono mai esposte in chiaro:
|
||||
vengono restituite come flag booleani (is_configured).
|
||||
"""
|
||||
|
||||
id: uuid.UUID
|
||||
tenant_id: uuid.UUID
|
||||
|
||||
# Archiviazione
|
||||
archival_mode: ArchivalMode
|
||||
conservatore_id: str
|
||||
conservatore_endpoint: str | None
|
||||
conservatore_username_configured: bool # TRUE se la username è già salvata
|
||||
conservatore_password_configured: bool # TRUE se la password è già salvata
|
||||
archival_notes: str | None
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TenantSettingsUpdate(BaseModel):
|
||||
"""
|
||||
Body PUT /settings.
|
||||
|
||||
Tutti i campi sono opzionali (PATCH semantics).
|
||||
Le credenziali vengono aggiornate solo se fornite.
|
||||
Un valore esplicitamente None può essere usato per cancellare le credenziali.
|
||||
"""
|
||||
|
||||
archival_mode: ArchivalMode | None = None
|
||||
|
||||
conservatore_id: str | None = Field(
|
||||
default=None, min_length=1, max_length=100
|
||||
)
|
||||
|
||||
# URL endpoint del conservatore (obbligatorio in produzione, ignorato in mock)
|
||||
conservatore_endpoint: str | None = None
|
||||
|
||||
# Credenziali in chiaro: vengono cifrate prima del salvataggio.
|
||||
# Valore stringa vuota ("") = cancella la credenziale.
|
||||
conservatore_username: str | None = None
|
||||
conservatore_password: str | None = None
|
||||
|
||||
archival_notes: str | None = None
|
||||
|
||||
@field_validator("archival_mode")
|
||||
@classmethod
|
||||
def validate_archival_mode(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in ("mock", "production"):
|
||||
raise ValueError("archival_mode deve essere 'mock' o 'production'")
|
||||
return v
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Servizio TenantSettings – lettura e aggiornamento configurazione per-tenant.
|
||||
|
||||
Responsabilità:
|
||||
- Upsert della riga settings (crea se non esiste, aggiorna altrimenti)
|
||||
- Cifratura/decifratura AES-256-GCM delle credenziali conservatore
|
||||
- Validazione di consistenza (produzione richiede endpoint + credenziali)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.security import decrypt_credential, encrypt_credential
|
||||
from app.models.tenant_settings import TenantSettings
|
||||
from app.schemas.tenant_settings import TenantSettingsResponse, TenantSettingsUpdate
|
||||
|
||||
|
||||
class TenantSettingsService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
# ─── Lettura (o creazione defaults) ──────────────────────────────────────
|
||||
|
||||
async def get_or_create(self, tenant_id: uuid.UUID) -> TenantSettings:
|
||||
"""
|
||||
Restituisce le impostazioni del tenant.
|
||||
Se non esistono, crea una riga con i valori di default (mock).
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
|
||||
)
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
if settings is None:
|
||||
settings = TenantSettings(
|
||||
tenant_id=tenant_id,
|
||||
archival_mode="mock",
|
||||
conservatore_id="mock",
|
||||
)
|
||||
self.db.add(settings)
|
||||
await self.db.flush()
|
||||
|
||||
return settings
|
||||
|
||||
# ─── Aggiornamento ────────────────────────────────────────────────────────
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
data: TenantSettingsUpdate,
|
||||
) -> TenantSettings:
|
||||
"""
|
||||
Aggiornamento parziale delle impostazioni.
|
||||
|
||||
Regole:
|
||||
- Il passaggio a 'production' è consentito solo se conservatore_endpoint
|
||||
è già configurato oppure viene fornito nel body corrente.
|
||||
- Le credenziali vengono cifrate prima del salvataggio.
|
||||
- Una stringa vuota "" su username/password cancella la credenziale.
|
||||
"""
|
||||
settings = await self.get_or_create(tenant_id)
|
||||
|
||||
# Applica aggiornamenti campi non-credenziali
|
||||
if data.archival_mode is not None:
|
||||
settings.archival_mode = data.archival_mode
|
||||
|
||||
if data.conservatore_id is not None:
|
||||
settings.conservatore_id = data.conservatore_id
|
||||
|
||||
if data.conservatore_endpoint is not None:
|
||||
settings.conservatore_endpoint = data.conservatore_endpoint or None
|
||||
|
||||
if data.archival_notes is not None:
|
||||
settings.archival_notes = data.archival_notes or None
|
||||
|
||||
# Aggiorna credenziali (cifra se fornite, cancella se stringa vuota)
|
||||
if data.conservatore_username is not None:
|
||||
if data.conservatore_username == "":
|
||||
settings.conservatore_username_enc = None
|
||||
else:
|
||||
settings.conservatore_username_enc = encrypt_credential(
|
||||
data.conservatore_username
|
||||
)
|
||||
|
||||
if data.conservatore_password is not None:
|
||||
if data.conservatore_password == "":
|
||||
settings.conservatore_password_enc = None
|
||||
else:
|
||||
settings.conservatore_password_enc = encrypt_credential(
|
||||
data.conservatore_password
|
||||
)
|
||||
|
||||
# Validazione: produzione richiede endpoint configurato
|
||||
if settings.archival_mode == "production":
|
||||
if not settings.conservatore_endpoint:
|
||||
raise BadRequestError(
|
||||
"La modalità produzione richiede un endpoint conservatore valido. "
|
||||
"Inserisci l'URL prima di attivare la modalità produzione."
|
||||
)
|
||||
|
||||
await self.db.flush()
|
||||
return settings
|
||||
|
||||
# ─── Costruzione response ─────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def to_response(settings: TenantSettings) -> TenantSettingsResponse:
|
||||
"""
|
||||
Converte il modello in DTO di risposta.
|
||||
Le credenziali NON vengono mai esposte in chiaro.
|
||||
"""
|
||||
return TenantSettingsResponse(
|
||||
id=settings.id,
|
||||
tenant_id=settings.tenant_id,
|
||||
archival_mode=settings.archival_mode, # type: ignore[arg-type]
|
||||
conservatore_id=settings.conservatore_id,
|
||||
conservatore_endpoint=settings.conservatore_endpoint,
|
||||
conservatore_username_configured=settings.conservatore_username_enc is not None,
|
||||
conservatore_password_configured=settings.conservatore_password_enc is not None,
|
||||
archival_notes=settings.archival_notes,
|
||||
created_at=settings.created_at,
|
||||
updated_at=settings.updated_at,
|
||||
)
|
||||
|
||||
# ─── Helper per il worker (recupera credenziali decifrate) ───────────────
|
||||
|
||||
async def get_conservatore_credentials(
|
||||
self, tenant_id: uuid.UUID
|
||||
) -> dict:
|
||||
"""
|
||||
Restituisce le credenziali del conservatore decifrate.
|
||||
Usato dal worker per effettuare versamenti.
|
||||
"""
|
||||
settings = await self.get_or_create(tenant_id)
|
||||
return {
|
||||
"mode": settings.archival_mode,
|
||||
"conservatore_id": settings.conservatore_id,
|
||||
"endpoint": settings.conservatore_endpoint,
|
||||
"username": (
|
||||
decrypt_credential(settings.conservatore_username_enc)
|
||||
if settings.conservatore_username_enc
|
||||
else None
|
||||
),
|
||||
"password": (
|
||||
decrypt_credential(settings.conservatore_password_enc)
|
||||
if settings.conservatore_password_enc
|
||||
else None
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user