Files
leocrm/app/plugins/builtins/contacts/contracts.py
T

476 lines
17 KiB
Python
Raw Normal View History

"""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
# ─── Workspace Scopes contribution (Phase N1, #359 pattern) ───
# Declares the scope dimensions the contacts module supports; the N2
# workspace editor renders its filter UI from these definitions. Scope
# VALUES live per workspace in workspace_modules.config (JSONB).
@staticmethod
def workspace_scopes() -> list[dict]:
"""Scope-Dimensionen des contacts-Moduls für den Workspace-Editor."""
return [
{
"module_key": "contacts",
"dimensions": [
{
"key": "folder_ids",
"label": "Kontakt-Ordner",
"control": "multiselect",
"value_source": {
"endpoint": "/api/v1/contact-folders",
"items_path": "items",
"value_key": "id",
"label_key": "name",
},
},
{
"key": "contact_types",
"label": "Kontakt-Typen",
"control": "multiselect",
"options": [
{"value": "company", "label": "Firmen"},
{"value": "person", "label": "Personen"},
],
},
{
"key": "default_saved_view_id",
"label": "Standard-Ansicht",
"control": "select",
"value_source": {
"endpoint": "/api/v1/saved-views?entity_type=contact",
"items_path": "",
"value_key": "id",
"label_key": "name",
},
},
],
}
]
@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