"""Public contract for the contacts plugin. Exposes the symbols that other core modules and plugins need without importing from internal modules directly (Block C7: dashboard counts). """ from __future__ import annotations from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.visibility import apply_visibility_filter from app.models.contact import Contact from app.plugins.builtins.contracts import get_contract_registry class ContactsContract: """Public API surface for the contacts plugin.""" contract_name = "contacts" @staticmethod async def get_counts( db: AsyncSession, tenant_id: Any, user_id: Any, is_system_admin: bool = False, ) -> dict[str, int]: """Return visibility-filtered contact/company/person counts.""" queries = [] for type_filter in (None, "company", "person"): query = select(func.count(Contact.id)).where( Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) if type_filter is not None: query = query.where(Contact.type == type_filter) query = await apply_visibility_filter( db, query, "contact", Contact, user_id, tenant_id, is_system_admin ) queries.append(query) results = [((await db.execute(q)).scalar() or 0) for q in queries] return { "contacts": results[0], "companies": results[1], "persons": results[2], "total": results[0], } @staticmethod async def dsar_collect( db: AsyncSession, tenant_id: Any, user_id: Any ) -> dict[str, Any]: """GDPR Art. 15: collect contact data owned by the user. Owned by the contacts plugin — core/jobs.py calls this generically via the contract, it must not know contact internals. """ contacts = ( await db.execute( select(Contact).where( Contact.tenant_id == tenant_id, Contact.owner_id == user_id, Contact.deleted_at.is_(None), ) ) ).scalars().all() return { "contacts": [ { "id": str(c.id), "type": c.type, "displayname": c.displayname, "email_1": c.email_1, "email_2": c.email_2, } for c in contacts ] } @staticmethod async def dsar_erase( db: AsyncSession, tenant_id: Any, user_id: Any ) -> dict[str, int]: """GDPR Art. 17: soft-delete contacts owned by the user. Soft-delete via deleted_at (audit history must remain intact — it is a business record, not personal data of the subject; retention policy governs its cleanup). """ from datetime import UTC, datetime contacts = ( await db.execute( select(Contact).where( Contact.tenant_id == tenant_id, Contact.owner_id == user_id, Contact.deleted_at.is_(None), ) ) ).scalars().all() for c in contacts: c.deleted_at = datetime.now(UTC) return {"contacts_soft_deleted": len(contacts)} # ─── Import/Export contribution (W4a, Spec #359) ─── # The contacts plugin owns its import/export domain logic; the core # orchestrator resolves formats via the format registry and enforces # the security policy (sensitive filter, tenant scoping, audit). IE_COLUMNS = { "contacts": ["firstname", "surname", "email", "phone", "mobile", "function", "department"], "companies": ["name", "industry", "phone", "email", "website"], } IE_TARGET_FIELDS = { "contacts": ["firstname", "surname", "email", "phone", "mobile", "function", "department"], "companies": ["name", "industry", "phone", "email", "website"], } IE_VALIDATORS = { "contacts": {"email": {"type": "email"}}, "companies": {"email": {"type": "email"}, "website": {"type": "url"}}, } @staticmethod def importexport_entities() -> list[str]: """Entity types offered by this plugin's import/export.""" return ["contacts", "companies"] @staticmethod def importexport_formats() -> list[str]: """File formats this plugin's import/export supports.""" return ["csv", "json", "xlsx"] @staticmethod def ie_columns(entity_type: str) -> list[str]: return list(ContactsContract.IE_COLUMNS[entity_type]) @staticmethod def ie_target_fields(entity_type: str) -> list[str]: return list(ContactsContract.IE_TARGET_FIELDS[entity_type]) @staticmethod def ie_validators(entity_type: str) -> dict[str, dict]: return dict(ContactsContract.IE_VALIDATORS[entity_type]) @staticmethod def ie_normalize_row(entity_type: str, row: dict[str, str]) -> dict[str, str]: """Normalize an imported row to unified field names.""" if entity_type == "contacts": firstname = (row.get("firstname") or row.get("first_name") or "").strip() surname = (row.get("surname") or row.get("last_name") or "").strip() row["firstname"] = firstname row["surname"] = surname if "email" not in row and "email_address" in row: row["email"] = row["email_address"] if "mobile" not in row and "phone_2" in row: row["mobile"] = row["phone_2"] if "function" not in row and "position" in row: row["function"] = row["position"] return row name = (row.get("name") or row.get("company") or row.get("company_name") or "").strip() row["name"] = name if "email" not in row and "email_address" in row: row["email"] = row["email_address"] if "website" not in row and "url" in row: row["website"] = row["url"] if "website" not in row and "homepage" in row: row["website"] = row["homepage"] return row @staticmethod def ie_required(entity_type: str) -> list[str]: """Required columns enforced by generic validate_row (old semantics).""" return ["name"] if entity_type == "companies" else [] @staticmethod def ie_row_valid(entity_type: str, row: dict[str, str]) -> tuple[bool, str]: """Early either-or check for contacts only. NOTE: companies' required-name check must NOT happen here — generic validate_row(row, ['name'], validators) must see the row so a missing name AND an invalid email yield two errors (as the original semantics did). """ if entity_type == "contacts": if not row.get("firstname") and not row.get("surname"): return False, "Missing required field: firstname or surname" return True, "" @staticmethod async def ie_fetch_rows( db: AsyncSession, tenant_id: Any, entity_type: str, user_id: Any = None, is_system_admin: bool = False, contact_type: str | None = None, search: str | None = None, ) -> tuple[list[str], list[dict[str, Any]]]: """Fetch export rows (headers + row dicts), visibility-filtered. Optional filters mirror the former export_service.py semantics: - contact_type: 'company' or 'person' (None = both) - search: FTS full-text search via contacts.search_tsv """ from app.core.sensitive_data import get_sensitive_fields q = select(Contact).where( Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) if entity_type == "companies": q = q.where(Contact.type == "company").order_by(Contact.name) else: if contact_type: q = q.where(Contact.type == contact_type) q = q.order_by(Contact.surname, Contact.firstname) if search: q = q.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))) if user_id: q = await apply_visibility_filter( db, q, "contact", Contact, user_id, tenant_id, is_system_admin ) records = (await db.execute(q)).scalars().all() # Sensitive-data safety net (core policy) — drop sensitive headers sensitive = get_sensitive_fields("contact") if entity_type == "companies": all_headers = ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"] export_headers = [h for h in all_headers if h not in sensitive] rows = [ {h: (getattr(c, h, None) or "") for h in export_headers} for c in records ] else: # Original export_service.py profile (test_performance.py contract): # 17 columns incl. displayname, code, email_1/email_2, phone_1/phone_2, # website, mailing_*, vat_code, tags — NOT the import profile. all_headers = [ "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", ] export_headers = [h for h in all_headers if h not in sensitive] rows = [ {h: (getattr(c, h, None) or "") for h in export_headers} for c in records ] return export_headers, rows @staticmethod async def ie_persist_row( db: AsyncSession, tenant_id: Any, user_id: Any, entity_type: str, row: dict[str, str], ) -> dict[str, Any]: """Persist one imported row as Contact; returns the serialized record.""" from app.core.audit import log_audit from app.services.contact_service import _serialize_contact if entity_type == "companies": contact = Contact( tenant_id=tenant_id, type="company", name=row["name"].strip(), displayname=row["name"].strip(), email_1=row.get("email", "").strip() or None, phone_1=row.get("phone", "").strip() or None, website=row.get("website", "").strip() or None, owner_id=user_id, created_by=user_id, updated_by=user_id, ) else: contact = Contact( tenant_id=tenant_id, type="person", firstname=row["firstname"].strip() or None, surname=row["surname"].strip() or None, displayname=f"{row['firstname']} {row['surname']}".strip(), email_1=row.get("email", "").strip() or None, phone_1=row.get("phone", "").strip() or None, phone_2=row.get("mobile", "").strip() or None, owner_id=user_id, created_by=user_id, updated_by=user_id, ) db.add(contact) await db.flush() await log_audit( db, tenant_id, user_id, "import", "contact", contact.id, changes={ "type": entity_type.rstrip("s"), "name": contact.name or f"{contact.firstname} {contact.surname}", }, ) return _serialize_contact(contact) # ─── Document Generator contribution (Phase L1, #359 pattern) ─── # The documents generator resolves placeholders + entity data via these # contract hooks. Same philosophy as importexport_entities(): the module # owns its domain data, the generic renderer stays module-agnostic. @staticmethod def document_entity_types() -> list[str]: """Entity types this plugin serves in the documents generator.""" return ["contact", "company", "person"] @staticmethod def document_placeholders(entity_type: str) -> list[dict]: """Placeholder descriptors (key/label/example) for the drag/drop editor.""" return _placeholders_for(entity_type) @staticmethod async def document_data( db: AsyncSession, tenant_id: Any, entity_id: Any, entity_type: str, ) -> dict[str, Any]: """Load one entity as template data ({} when not found).""" contact = ( await db.execute( select(Contact).where( Contact.id == entity_id, Contact.tenant_id == tenant_id, Contact.deleted_at.is_(None), ) ) ).scalar_one_or_none() if contact is None: return {} fields = _contacts_document_fields() data: dict[str, Any] = {} for key in fields: value = getattr(contact, key, None) data[key] = value if value is not None else "" return data @classmethod def get_function(cls, name: str): """Return a callable exposed by this contract, or None if absent.""" return getattr(cls, name, None) # ─── self-registration ─── _contract = ContactsContract() get_contract_registry().register("contacts", _contract) def _contacts_document_fields() -> dict[str, str]: """Contact/company fields available in document templates (L1). Keys map to Contact model attributes; labels/examples feed the drag/drop editor palette and the preview fallback values. """ return { "displayname": "Anzeigename", "firstname": "Vorname", "surname": "Nachname", "name": "Firmenname", "email": "E-Mail", "email_1": "E-Mail 1", "email_2": "E-Mail 2", "phone": "Telefon", "phone_1": "Telefon 1", "phone_2": "Telefon 2", "mobile": "Mobil", "website": "Website", "industry": "Branche", "city": "Stadt", "postalcode": "PLZ", "country": "Land", "vat_code": "USt-IdNr.", "function": "Funktion", "department": "Abteilung", } _CONTACT_DOC_EXAMPLES = { "displayname": "Max Mustermann", "firstname": "Max", "surname": "Mustermann", "name": "Muster GmbH", "email": "max@example.com", "email_1": "max@example.com", "email_2": "buero@example.com", "phone": "+49 30 123456", "phone_1": "+49 30 123456", "phone_2": "+49 171 1234567", "mobile": "+49 171 1234567", "website": "https://example.com", "industry": "IT", "city": "Berlin", "postalcode": "10115", "country": "Deutschland", "vat_code": "DE123456789", "function": "Geschäftsführer", "department": "Vertrieb", } def _placeholders_for(entity_type: str) -> list[dict]: """Placeholder descriptors for contact/company templates.""" if entity_type not in ("contact", "company", "person"): return [] fields = _contacts_document_fields() result = [] for key, label in fields.items(): result.append({ "key": key, "label": label, "example": _CONTACT_DOC_EXAMPLES.get(key, "…"), }) return result