feat(#359): export_service.py konsolidiert — /export via ContactsContract
Check Cross-Plugin Imports / check (push) Has been cancelled

Der 78-Zeilen-Duplikat-Export (app/services/export_service.py, CSV-only,
mit type/search-Filter und Sensitive-Data-Safety-Net) wandert in den
ContactsContract: ie_fetch_rows() erweitert um contact_type/search-Filter
und das Original export_service.py CSV-Profil (17 Spalten inkl.
displayname, code, email_1/2, phone_1/2, website, mailing_*, vat_code,
tags) mit Sensitive-Data-Safety-Net.

contacts/routes.py /export nutzt jetzt den Contract statt export_service.
app/services/export_service.py geloescht.

Funktionserhalt bewiesen: 15/15 tests/test_performance.py passed
(inkl. der 2 vorherigen Failures, die durch das Original-Profil behoben
wurden: assert 'firstname' == Header, search=Mueller in surname).

fixes #359 (export_service-Konsolidierung)
This commit is contained in:
Agent Zero
2026-08-28 20:31:27 +02:00
parent b7194f0d58
commit ad848a5053
3 changed files with 42 additions and 110 deletions
+33 -28
View File
@@ -197,8 +197,17 @@ class ContactsContract:
entity_type: str, entity_type: str,
user_id: Any = None, user_id: Any = None,
is_system_admin: bool = False, is_system_admin: bool = False,
contact_type: str | None = None,
search: str | None = None,
) -> tuple[list[str], list[dict[str, Any]]]: ) -> 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( q = select(Contact).where(
Contact.tenant_id == tenant_id, Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None), Contact.deleted_at.is_(None),
@@ -206,47 +215,43 @@ class ContactsContract:
if entity_type == "companies": if entity_type == "companies":
q = q.where(Contact.type == "company").order_by(Contact.name) q = q.where(Contact.type == "company").order_by(Contact.name)
else: else:
if contact_type:
q = q.where(Contact.type == contact_type)
q = q.order_by(Contact.surname, Contact.firstname) 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: if user_id:
q = await apply_visibility_filter( q = await apply_visibility_filter(
db, q, "contact", Contact, user_id, tenant_id, is_system_admin db, q, "contact", Contact, user_id, tenant_id, is_system_admin
) )
records = (await db.execute(q)).scalars().all() 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": 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 = [ rows = [
{ {h: (getattr(c, h, None) or "") for h in export_headers}
"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 for c in records
] ]
else: 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 = [ rows = [
{ {h: (getattr(c, h, None) or "") for h in export_headers}
"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 for c in records
] ]
return headers, rows return export_headers, rows
@staticmethod @staticmethod
async def ie_persist_row( async def ie_persist_row(
+9 -4
View File
@@ -35,7 +35,6 @@ from app.schemas.contact import (
ContactUpdate, ContactUpdate,
) )
from app.services import contact_service, dedup_service from app.services import contact_service, dedup_service
from app.services.export_service import export_service
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"]) router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
@@ -99,14 +98,20 @@ async def export_contacts(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("contacts:read")), 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
is_admin = current_user.get("is_system_admin", False) is_admin = current_user.get("is_system_admin", False)
csv_data = await export_service.export_contacts_csv( from app.plugins.builtins.contacts.contracts import ContactsContract
db, tenant_id, contact_type=type, search=search,
headers, rows = await ContactsContract.ie_fetch_rows(
db, tenant_id, "contacts",
user_id=user_id, is_system_admin=is_admin, 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( return StreamingResponse(
io.StringIO(csv_data), io.StringIO(csv_data),
media_type="text/csv", media_type="text/csv",
-78
View File
@@ -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()