feat(#359): export_service.py konsolidiert — /export via ContactsContract
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user