Implementazioni varie

This commit is contained in:
2026-03-27 20:59:06 +01:00
parent 047990811f
commit 46784aca4c
40 changed files with 4090 additions and 34 deletions
+240
View File
@@ -0,0 +1,240 @@
"""
Service per la gestione della rubrica indirizzi PEC (Feature 6).
"""
import csv
import io
import uuid
from sqlalchemy import func, or_, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import ConflictError, NotFoundError
from app.models.pec_contact import PecContact
from app.schemas.pec_contact import (
PecContactCreate,
PecContactImportResult,
PecContactUpdate,
)
class PecContactService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_contacts(
self,
tenant_id: uuid.UUID,
q: str | None = None,
page: int = 1,
page_size: int = 50,
) -> tuple[list[PecContact], int]:
query = select(PecContact).where(PecContact.tenant_id == tenant_id)
if q:
pattern = f"%{q.lower()}%"
query = query.where(
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
PecContact.organization.ilike(pattern),
)
)
query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email)
count_q = select(func.count()).select_from(query.subquery())
total = (await self.db.execute(count_q)).scalar_one()
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
items = list((await self.db.execute(query)).scalars().all())
return items, total
async def get_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> PecContact:
result = await self.db.execute(
select(PecContact).where(
PecContact.id == contact_id,
PecContact.tenant_id == tenant_id,
)
)
contact = result.scalar_one_or_none()
if not contact:
raise NotFoundError(f"Contatto {contact_id} non trovato")
return contact
async def create_contact(
self,
tenant_id: uuid.UUID,
data: PecContactCreate,
created_by: uuid.UUID | None = None,
) -> PecContact:
email = data.email.lower().strip()
# Verifica unicita'
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
if existing.scalar_one_or_none():
raise ConflictError(f"Contatto '{email}' gia' esistente")
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=data.name,
organization=data.organization,
notes=data.notes,
is_favorite=data.is_favorite,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
await self.db.commit()
await self.db.refresh(contact)
return contact
async def update_contact(
self,
tenant_id: uuid.UUID,
contact_id: uuid.UUID,
data: PecContactUpdate,
) -> PecContact:
contact = await self.get_contact(tenant_id, contact_id)
if data.name is not None:
contact.name = data.name
if data.organization is not None:
contact.organization = data.organization
if data.notes is not None:
contact.notes = data.notes
if data.is_favorite is not None:
contact.is_favorite = data.is_favorite
await self.db.commit()
await self.db.refresh(contact)
return contact
async def delete_contact(
self, tenant_id: uuid.UUID, contact_id: uuid.UUID
) -> None:
contact = await self.get_contact(tenant_id, contact_id)
await self.db.delete(contact)
await self.db.commit()
async def auto_save_sender(
self,
tenant_id: uuid.UUID,
email: str,
) -> None:
"""
Salva automaticamente il mittente nella rubrica se non esiste ancora.
Operazione non bloccante: gli errori vengono ignorati silenziosamente.
Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound.
"""
if not email:
return
email = email.lower().strip()
try:
# Upsert: inserisce solo se non esiste
stmt = insert(PecContact).values(
id=uuid.uuid4(),
tenant_id=tenant_id,
email=email,
auto_saved=True,
).on_conflict_do_nothing(
constraint="uq_pec_contact_email_tenant"
)
await self.db.execute(stmt)
await self.db.commit()
except Exception:
await self.db.rollback()
async def import_csv(
self,
tenant_id: uuid.UUID,
csv_content: str,
created_by: uuid.UUID | None = None,
) -> PecContactImportResult:
"""
Importa contatti da un CSV con colonne: email, name, organization.
Aggiorna i record esistenti con name/organization se forniti.
"""
result = PecContactImportResult(created=0, updated=0, skipped=0)
reader = csv.DictReader(io.StringIO(csv_content))
for row_num, row in enumerate(reader, start=2):
email = row.get("email", "").strip().lower()
if not email or "@" not in email:
result.skipped += 1
result.errors.append(f"Riga {row_num}: email non valida '{email}'")
continue
name = row.get("name", "").strip() or None
organization = row.get("organization", "").strip() or None
try:
existing = await self.db.execute(
select(PecContact).where(
PecContact.tenant_id == tenant_id,
PecContact.email == email,
)
)
contact = existing.scalar_one_or_none()
if contact:
# Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali)
updated = False
if name and not contact.name:
contact.name = name
updated = True
if organization and not contact.organization:
contact.organization = organization
updated = True
if updated:
result.updated += 1
else:
result.skipped += 1
else:
contact = PecContact(
tenant_id=tenant_id,
email=email,
name=name,
organization=organization,
auto_saved=False,
created_by=created_by,
)
self.db.add(contact)
result.created += 1
except Exception as e:
result.errors.append(f"Riga {row_num} ({email}): {e}")
result.skipped += 1
await self.db.commit()
return result
async def search_for_autocomplete(
self,
tenant_id: uuid.UUID,
q: str,
limit: int = 10,
) -> list[PecContact]:
"""Ricerca veloce per autocomplete nel compose."""
if not q or len(q) < 2:
return []
pattern = f"%{q.lower()}%"
result = await self.db.execute(
select(PecContact)
.where(
PecContact.tenant_id == tenant_id,
or_(
PecContact.email.ilike(pattern),
PecContact.name.ilike(pattern),
)
)
.order_by(PecContact.is_favorite.desc(), PecContact.email)
.limit(limit)
)
return list(result.scalars().all())