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,
|
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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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