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

316 lines
12 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,
) -> tuple[list[str], list[dict[str, Any]]]:
"""Fetch export rows (headers + row dicts), visibility-filtered."""
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:
q = q.order_by(Contact.surname, Contact.firstname)
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()
if entity_type == "companies":
headers = ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"]
rows = [
{
"id": str(c.id),
"type": c.type or "company",
"name": c.name or "",
"email": c.email_1 or "",
"phone": c.phone_1 or "",
"website": c.website or "",
"city": c.mailing_city or "",
"postalcode": c.mailing_postalcode or "",
"country": c.mailing_country or "",
}
for c in records
]
else:
headers = ["id", "type", "firstname", "surname", "name", "email", "phone", "mobile", "city", "postalcode", "country"]
rows = [
{
"id": str(c.id),
"type": c.type or "person",
"firstname": c.firstname or "",
"surname": c.surname or "",
"name": c.name or "",
"email": c.email_1 or "",
"phone": c.phone_1 or "",
"mobile": c.phone_2 or "",
"city": c.mailing_city or "",
"postalcode": c.mailing_postalcode or "",
"country": c.mailing_country or "",
}
for c in records
]
return 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)
@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)