"""Unified contact service — CRUD, FTS search, contactpersons, soft-delete, export.""" from __future__ import annotations import csv import io import uuid from typing import Any from sqlalchemy import select, func, or_, text from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.contact import Contact, ContactPerson def _compute_displayname(data: dict) -> str: """Compute displayname from type and name fields.""" if data.get("type") == "person": parts = [data.get("surfix"), data.get("firstname"), data.get("surname")] return " ".join(p for p in parts if p).strip() else: return data.get("name") or "" def _serialize_contact(c: Contact) -> dict: """Serialize a Contact ORM object to dict.""" return { "id": str(c.id), "type": c.type, "displayname": c.displayname, "name": c.name, "firstname": c.firstname, "surname": c.surname, "surfix": c.surfix, "ext_name_line": c.ext_name_line, "gender": c.gender, "code": c.code, "accounting_code": c.accounting_code, "vendor_accounting_code": c.vendor_accounting_code, "mailing_street": c.mailing_street, "mailing_number": c.mailing_number, "mailing_unit_number": c.mailing_unit_number, "mailing_district": c.mailing_district, "mailing_extra_address_line": c.mailing_extra_address_line, "mailing_postalcode": c.mailing_postalcode, "mailing_city": c.mailing_city, "mailing_state": c.mailing_state, "mailing_country": c.mailing_country, "visit_street": c.visit_street, "visit_number": c.visit_number, "visit_unit_number": c.visit_unit_number, "visit_district": c.visit_district, "visit_extra_address_line": c.visit_extra_address_line, "visit_postalcode": c.visit_postalcode, "visit_city": c.visit_city, "visit_state": c.visit_state, "invoice_street": c.invoice_street, "invoice_number": c.invoice_number, "invoice_unit_number": c.invoice_unit_number, "invoice_district": c.invoice_district, "invoice_extra_address_line": c.invoice_extra_address_line, "invoice_postalcode": c.invoice_postalcode, "invoice_city": c.invoice_city, "invoice_state": c.invoice_state, "invoice_country": c.invoice_country, "country": c.country, "phone_1": c.phone_1, "phone_2": c.phone_2, "email_1": c.email_1, "email_2": c.email_2, "website": c.website, "vat_code": c.vat_code, "fiscal_code": c.fiscal_code, "commerce_code": c.commerce_code, "purchase_number": c.purchase_number, "bic": c.bic, "bank_account": c.bank_account, "discount_crew": c.discount_crew, "discount_transport": c.discount_transport, "discount_rental": c.discount_rental, "discount_sale": c.discount_sale, "discount_subrent": c.discount_subrent, "discount_total": c.discount_total, "latitude": c.latitude, "longitude": c.longitude, "projectnote": c.projectnote, "projectnote_title": c.projectnote_title, "contact_warning": c.contact_warning, "tags": c.tags, "image": c.image, "custom": c.custom, "folder_id": str(c.folder_id) if c.folder_id else None, "default_person_id": str(c.default_person_id) if c.default_person_id else None, "admin_contactperson_id": str(c.admin_contactperson_id) if c.admin_contactperson_id else None, "created_at": c.created_at.isoformat() if c.created_at else None, "updated_at": c.updated_at.isoformat() if c.updated_at else None, } def _serialize_contact_person(cp: ContactPerson) -> dict: """Serialize a ContactPerson ORM object to dict.""" return { "id": str(cp.id), "contact_id": str(cp.contact_id), "displayname": cp.displayname, "firstname": cp.firstname, "middle_name": cp.middle_name, "lastname": cp.lastname, "function": cp.function, "phone": cp.phone, "mobilephone": cp.mobilephone, "email": cp.email, "street": cp.street, "number": cp.number, "postalcode": cp.postalcode, "city": cp.city, "state": cp.state, "country": cp.country, "tags": cp.tags, "custom": cp.custom, "created_at": cp.created_at.isoformat() if cp.created_at else None, "updated_at": cp.updated_at.isoformat() if cp.updated_at else None, } def _serialize_contact_detail(c: Contact) -> dict: """Serialize with contact_persons included.""" data = _serialize_contact(c) data["contact_persons"] = [_serialize_contact_person(cp) for cp in (c.contact_persons or [])] return data async def list_contacts( db: AsyncSession, tenant_id: uuid.UUID, page: int = 1, page_size: int = 20, search: str | None = None, contact_type: str | None = None, folder_id: str | None = None, sort_by: str = "displayname", sort_order: str = "asc", resolved_perms: dict | None = None, ) -> dict: """List contacts with pagination, FTS search, type/folder filter, sorting.""" base = select(Contact).where( Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) if contact_type: base = base.where(Contact.type == contact_type) if folder_id: base = base.where(Contact.folder_id == uuid.UUID(folder_id)) if search: base = base.where( Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)) ) # Count count_q = select(func.count()).select_from(base.subquery()) total = (await db.execute(count_q)).scalar() or 0 # Sort sort_col = getattr(Contact, sort_by, Contact.displayname) if sort_order == "desc": sort_col = sort_col.desc() base = base.order_by(sort_col) # Paginate offset = (page - 1) * page_size base = base.offset(offset).limit(page_size) result = await db.execute(base) contacts = result.scalars().all() return { "items": [_serialize_contact(c) for c in contacts], "total": total, "page": page, "page_size": page_size, } async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict: """Get a single contact with contact_persons.""" q = ( select(Contact) .options(selectinload(Contact.contact_persons)) .where( Contact.id == uuid.UUID(contact_id), Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) ) result = await db.execute(q) contact = result.scalar_one_or_none() if not contact: raise ValueError("Contact not found") return _serialize_contact_detail(contact) async def create_contact( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict ) -> dict: """Create a new contact.""" data["displayname"] = _compute_displayname(data) contact_persons_data = data.pop("contact_persons", None) contact = Contact( tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **{k: v for k, v in data.items() if hasattr(Contact, k)}, ) db.add(contact) await db.flush() # Create inline contact persons if contact_persons_data: for cp_data in contact_persons_data: cp_data["displayname"] = " ".join( p for p in [cp_data.get("firstname"), cp_data.get("lastname")] if p ).strip() cp = ContactPerson( tenant_id=tenant_id, contact_id=contact.id, created_by=user_id, updated_by=user_id, **{k: v for k, v in cp_data.items() if hasattr(ContactPerson, k)}, ) db.add(cp) await db.flush() # Load with contact_persons q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id) result = await db.execute(q) contact = result.scalar_one() return _serialize_contact_detail(contact) async def update_contact( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict ) -> dict: """Update a contact.""" q = ( select(Contact) .options(selectinload(Contact.contact_persons)) .where( Contact.id == uuid.UUID(contact_id), Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) ) result = await db.execute(q) contact = result.scalar_one_or_none() if not contact: raise ValueError("Contact not found") # Recompute displayname if name fields changed if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")): merged = {**_serialize_contact(contact), **data} data["displayname"] = _compute_displayname(merged) for key, value in data.items(): if hasattr(contact, key): setattr(contact, key, value) contact.updated_by = user_id await db.flush() return _serialize_contact_detail(contact) async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None: """Soft-delete a contact.""" q = select(Contact).where( Contact.id == uuid.UUID(contact_id), Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) result = await db.execute(q) contact = result.scalar_one_or_none() if not contact: raise ValueError("Contact not found") from datetime import datetime, timezone contact.deleted_at = datetime.now(timezone.utc) await db.flush() async def hard_delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None: """GDPR hard-delete a contact.""" q = select(Contact).where( Contact.id == uuid.UUID(contact_id), Contact.tenant_id == tenant_id, ) result = await db.execute(q) contact = result.scalar_one_or_none() if not contact: raise ValueError("Contact not found") await db.delete(contact) await db.flush() # ── ContactPerson CRUD ── async def list_contact_persons( db: AsyncSession, tenant_id: uuid.UUID, contact_id: str ) -> list[dict]: """List all contact persons for a contact.""" q = select(ContactPerson).where( ContactPerson.contact_id == uuid.UUID(contact_id), ContactPerson.tenant_id == tenant_id, ContactPerson.deleted_at.is_(None), ).order_by(ContactPerson.displayname) result = await db.execute(q) return [_serialize_contact_person(cp) for cp in result.scalars().all()] async def create_contact_person( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict ) -> dict: """Add a contact person to a contact.""" data["displayname"] = " ".join( p for p in [data.get("firstname"), data.get("lastname")] if p ).strip() cp = ContactPerson( tenant_id=tenant_id, contact_id=uuid.UUID(contact_id), created_by=user_id, updated_by=user_id, **{k: v for k, v in data.items() if hasattr(ContactPerson, k)}, ) db.add(cp) await db.flush() return _serialize_contact_person(cp) async def update_contact_person( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, person_id: str, data: dict ) -> dict: """Update a contact person.""" q = select(ContactPerson).where( ContactPerson.id == uuid.UUID(person_id), ContactPerson.contact_id == uuid.UUID(contact_id), ContactPerson.tenant_id == tenant_id, ContactPerson.deleted_at.is_(None), ) result = await db.execute(q) cp = result.scalar_one_or_none() if not cp: raise ValueError("Contact person not found") if any(k in data for k in ("firstname", "lastname")): merged = {**_serialize_contact_person(cp), **data} data["displayname"] = " ".join( p for p in [merged.get("firstname"), merged.get("lastname")] if p ).strip() for key, value in data.items(): if hasattr(cp, key): setattr(cp, key, value) cp.updated_by = user_id await db.flush() return _serialize_contact_person(cp) async def delete_contact_person( db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, person_id: str ) -> None: """Soft-delete a contact person.""" q = select(ContactPerson).where( ContactPerson.id == uuid.UUID(person_id), ContactPerson.contact_id == uuid.UUID(contact_id), ContactPerson.tenant_id == tenant_id, ContactPerson.deleted_at.is_(None), ) result = await db.execute(q) cp = result.scalar_one_or_none() if not cp: raise ValueError("Contact person not found") from datetime import datetime, timezone cp.deleted_at = datetime.now(timezone.utc) await db.flush() async def export_contacts_csv( db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None ) -> str: """Export contacts as CSV string.""" base = select(Contact).where( Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) if contact_type: base = base.where(Contact.type == contact_type) if search: base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))) base = base.order_by(Contact.displayname) result = await db.execute(base) contacts = result.scalars().all() output = io.StringIO() writer = csv.writer(output) writer.writerow([ "id", "type", "displayname", "name", "firstname", "surname", "code", "email_1", "email_2", "phone_1", "phone_2", "website", "mailing_city", "mailing_postalcode", "mailing_country", "vat_code", "tags", ]) for c in contacts: writer.writerow([ str(c.id), c.type, c.displayname, c.name or "", c.firstname or "", c.surname or "", c.code or "", c.email_1 or "", c.email_2 or "", c.phone_1 or "", c.phone_2 or "", c.website or "", c.mailing_city or "", c.mailing_postalcode or "", c.mailing_country or "", c.vat_code or "", c.tags or "", ]) return output.getvalue()