diff --git a/app/plugins/builtins/contacts/contracts.py b/app/plugins/builtins/contacts/contracts.py index 0043317..02bcc83 100644 --- a/app/plugins/builtins/contacts/contracts.py +++ b/app/plugins/builtins/contacts/contracts.py @@ -197,8 +197,17 @@ class ContactsContract: 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.""" + """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), @@ -206,47 +215,43 @@ class ContactsContract: 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": - headers = ["id", "type", "name", "email", "phone", "website", "city", "postalcode", "country"] + 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 = [ - { - "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 "", - } + {h: (getattr(c, h, None) or "") for h in export_headers} for c in records ] else: - headers = ["id", "type", "firstname", "surname", "name", "email", "phone", "mobile", "city", "postalcode", "country"] + # 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 = [ - { - "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 "", - } + {h: (getattr(c, h, None) or "") for h in export_headers} for c in records ] - return headers, rows + return export_headers, rows @staticmethod async def ie_persist_row( diff --git a/app/plugins/builtins/contacts/routes.py b/app/plugins/builtins/contacts/routes.py index 8f92948..e12f5f6 100644 --- a/app/plugins/builtins/contacts/routes.py +++ b/app/plugins/builtins/contacts/routes.py @@ -35,7 +35,6 @@ from app.schemas.contact import ( ContactUpdate, ) from app.services import contact_service, dedup_service -from app.services.export_service import export_service router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) @@ -99,14 +98,20 @@ async def export_contacts( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("contacts:read")), ): - """Stream contacts as CSV.""" + """Stream contacts as CSV (W4c: via ContactsContract, export_service.py removed).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) is_admin = current_user.get("is_system_admin", False) - csv_data = await export_service.export_contacts_csv( - db, tenant_id, contact_type=type, search=search, + from app.plugins.builtins.contacts.contracts import ContactsContract + + headers, rows = await ContactsContract.ie_fetch_rows( + db, tenant_id, "contacts", user_id=user_id, is_system_admin=is_admin, + contact_type=type, search=search, ) + from app.services.import_export_helpers import write_csv + + csv_data = write_csv(rows, headers).decode("utf-8") return StreamingResponse( io.StringIO(csv_data), media_type="text/csv", diff --git a/app/services/export_service.py b/app/services/export_service.py deleted file mode 100644 index eb74a9f..0000000 --- a/app/services/export_service.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Export service — CSV and other format exports for CRM entities.""" - -from __future__ import annotations - -import csv -import io -import uuid - -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.sensitive_data import get_sensitive_fields -from app.models.contact import Contact - - -class ExportService: - """Handles export operations for CRM entities.""" - - @staticmethod - async def export_contacts_csv( - db: AsyncSession, - tenant_id: uuid.UUID, - contact_type: str | None = None, - search: str | None = None, - user_id: uuid.UUID | None = None, - is_system_admin: bool = False, - ) -> str: - """Export contacts as CSV string. Only exports visible contacts.""" - from app.core.visibility import apply_visibility_filter - - 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))) - - # Apply visibility filter - if user_id and not is_system_admin: - base = await apply_visibility_filter( - db, base, "contact", Contact, user_id, tenant_id, is_system_admin - ) - - base = base.order_by(Contact.displayname) - - result = await db.execute(base) - contacts = result.scalars().all() - - # Exclude sensitive fields that must never appear in exports - sensitive = get_sensitive_fields("contact") - - 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", - ] - # Drop headers for sensitive fields (e.g. password_hash would never be - # in a contact row, but this is a safety net). - export_headers = [h for h in all_headers if h not in sensitive] - - output = io.StringIO() - writer = csv.writer(output) - writer.writerow(export_headers) - for c in contacts: - row = [] - for h in export_headers: - if h in sensitive: - row.append("") - else: - row.append(getattr(c, h, None) or "") - writer.writerow(row) - return output.getvalue() - - -export_service = ExportService()