T02: companies + contacts + import/export + N:M + soft-delete + GDPR + FTS

- Company CRUD with soft-delete, FTS search (tsvector + GIN), filter, pagination
- Contact CRUD with N:M company linking via company_contacts
- CSV/XLSX export, CSV import with dry-run preview
- GDPR hard-delete with deletion_log
- Audit log on all mutations
- 27 new tests (24 ACs), 56 total tests pass
- Migration 0002: contacts, company_contacts, FTS search_tsv
- Fixed T01 tests: POST→201, PATCH→PUT compatibility
This commit is contained in:
leocrm-bot
2026-06-29 00:44:34 +02:00
parent 3ab4925783
commit dd16940bb2
21 changed files with 2518 additions and 125 deletions
+453
View File
@@ -0,0 +1,453 @@
"""Company service — CRUD, FTS search, filter, pagination, soft-delete, N:M, export."""
from __future__ import annotations
import csv
import io
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select, func, or_, desc, asc, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
"""Serialize a Company ORM object to dict."""
data: dict[str, Any] = {
"id": str(c.id),
"name": c.name,
"account_number": c.account_number,
"industry": c.industry,
"phone": c.phone,
"email": c.email,
"website": c.website,
"description": c.description,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
if include_contacts:
data["contacts"] = [] # populated by caller if needed
return data
async def list_companies(
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
search: str | None = None,
industry: str | None = None,
sort_by: str = "name",
sort_order: str = "asc",
) -> dict[str, Any]:
"""List companies with pagination, FTS search, industry filter, and sorting.
Returns {items, total, page, page_size}.
"""
page = max(1, page)
page_size = max(1, min(100, page_size))
base = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
# Industry filter
if industry:
base = base.where(Company.industry == industry)
# FTS search using PostgreSQL tsvector @@ plainto_tsquery, with ILIKE fallback
if search:
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
)
)
# Sorting
sort_col = getattr(Company, sort_by, Company.name)
if sort_order == "desc":
base = base.order_by(desc(sort_col))
else:
base = base.order_by(asc(sort_col))
# Count total (without pagination)
count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q)
total = total_result.scalar_one()
# Paginate
offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
companies = result.scalars().all()
return {
"items": [_company_to_dict(c) for c in companies],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_company_detail(
db: AsyncSession,
tenant_id: uuid.UUID,
company_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Get a single company with its contacts array."""
q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
return None
data = _company_to_dict(company, include_contacts=True)
# Fetch linked contacts
contacts_q = (
select(Contact, CompanyContact)
.join(CompanyContact, CompanyContact.contact_id == Contact.id)
.where(
CompanyContact.company_id == company_id,
CompanyContact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
)
contacts_result = await db.execute(contacts_q)
contacts_list = []
for contact, link in contacts_result.all():
contacts_list.append({
"id": str(contact.id),
"first_name": contact.first_name,
"last_name": contact.last_name,
"email": contact.email,
"phone": contact.phone,
"position": contact.position,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
})
data["contacts"] = contacts_list
return data
async def create_company(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new company and audit-log it."""
company = Company(
tenant_id=tenant_id,
name=data["name"],
account_number=data.get("account_number"),
industry=data.get("industry"),
phone=data.get("phone"),
email=data.get("email"),
website=data.get("website"),
description=data.get("description"),
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
await db.refresh(company)
await log_audit(
db, tenant_id, user_id, "create", "company", company.id,
changes={"name": data["name"]},
)
return _company_to_dict(company)
async def update_company(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update a company (partial update) and audit-log changes."""
q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
return None
changes: dict[str, Any] = {}
for field in ("name", "account_number", "industry", "phone", "email", "website", "description"):
if field in data and data[field] is not None:
old_val = getattr(company, field)
changes[field] = {"old": old_val, "new": data[field]}
setattr(company, field, data[field])
company.updated_by = user_id
await db.flush()
await db.refresh(company)
await log_audit(db, tenant_id, user_id, "update", "company", company_id, changes=changes)
return _company_to_dict(company)
async def soft_delete_company(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
cascade: bool = False,
) -> bool:
"""Soft-delete a company. If cascade=True, also soft-delete linked CompanyContact rows."""
q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
return False
company.deleted_at = datetime.now(timezone.utc)
company.updated_by = user_id
if cascade:
# Delete N:M links for this company
await db.execute(
delete(CompanyContact).where(
CompanyContact.company_id == company_id,
CompanyContact.tenant_id == tenant_id,
)
)
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "company", company_id,
changes={"name": company.name, "cascade": cascade},
)
return True
async def link_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
contact_id: uuid.UUID,
role_at_company: str | None = None,
is_primary: bool = False,
) -> dict[str, Any] | None:
"""Link a contact to a company (N:M). Returns link data or None if either not found."""
# Verify company exists and is in tenant
comp_q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
comp_result = await db.execute(comp_q)
if comp_result.scalar_one_or_none() is None:
return None
# Verify contact exists and is in tenant
cont_q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
cont_result = await db.execute(cont_q)
if cont_result.scalar_one_or_none() is None:
return None
# Check if link already exists
existing_q = select(CompanyContact).where(
CompanyContact.company_id == company_id,
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
)
existing_result = await db.execute(existing_q)
existing = existing_result.scalar_one_or_none()
if existing is not None:
# Update existing link
existing.role_at_company = role_at_company
existing.is_primary = is_primary
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", existing.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
"id": str(existing.id),
"company_id": str(company_id),
"contact_id": str(contact_id),
"role_at_company": role_at_company,
"is_primary": is_primary,
}
link = CompanyContact(
tenant_id=tenant_id,
company_id=company_id,
contact_id=contact_id,
role_at_company=role_at_company,
is_primary=is_primary,
)
db.add(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "link", "company_contact", link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return {
"id": str(link.id),
"company_id": str(company_id),
"contact_id": str(contact_id),
"role_at_company": role_at_company,
"is_primary": is_primary,
}
async def unlink_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""Unlink a contact from a company (N:M)."""
q = select(CompanyContact).where(
CompanyContact.company_id == company_id,
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
)
result = await db.execute(q)
link = result.scalar_one_or_none()
if link is None:
return False
await db.delete(link)
await db.flush()
await log_audit(
db, tenant_id, user_id, "unlink", "company_contact", link.id,
changes={"company_id": str(company_id), "contact_id": str(contact_id)},
)
return True
async def export_companies_csv(
db: AsyncSession,
tenant_id: uuid.UUID,
industry: str | None = None,
search: str | None = None,
) -> str:
"""Export companies as CSV string."""
base = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
if industry:
base = base.where(Company.industry == industry)
if search:
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
)
)
base = base.order_by(Company.name)
result = await db.execute(base)
companies = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "name", "account_number", "industry", "phone", "email", "website", "description"])
for c in companies:
writer.writerow([
str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or "",
])
return output.getvalue()
async def export_companies_xlsx(
db: AsyncSession,
tenant_id: uuid.UUID,
industry: str | None = None,
search: str | None = None,
) -> bytes:
"""Export companies as XLSX bytes."""
from openpyxl import Workbook
base = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
if industry:
base = base.where(Company.industry == industry)
if search:
pattern = f"%{search}%"
base = base.where(
or_(
Company.search_tsv.op("@@")(
func.plainto_tsquery("english", search)
),
Company.name.ilike(pattern),
Company.industry.ilike(pattern),
Company.description.ilike(pattern),
)
)
base = base.order_by(Company.name)
result = await db.execute(base)
companies = result.scalars().all()
wb = Workbook()
ws = wb.active
ws.title = "Companies"
headers = ["id", "name", "account_number", "industry", "phone", "email", "website", "description"]
ws.append(headers)
for c in companies:
ws.append([
str(c.id), c.name, c.account_number or "", c.industry or "",
c.phone or "", c.email or "", c.website or "", c.description or "",
])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
async def get_company_emails(
db: AsyncSession,
tenant_id: uuid.UUID,
company_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""Get emails for a company (mail plugin inactive — returns empty array)."""
# Verify company exists
q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
result = await db.execute(q)
if result.scalar_one_or_none() is None:
return [] # Caller should check existence separately
return []
+281
View File
@@ -0,0 +1,281 @@
"""Contact service — CRUD, N:M linking, soft-delete, GDPR hard-delete."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select, func, desc, asc, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.models.contact import Contact, CompanyContact
from app.models.company import Company
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
"""Serialize a Contact ORM object to dict."""
data: dict[str, Any] = {
"id": str(c.id),
"first_name": c.first_name,
"last_name": c.last_name,
"email": c.email,
"phone": c.phone,
"mobile": c.mobile,
"position": c.position,
"department": c.department,
"linkedin_url": c.linkedin_url,
"notes": c.notes,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
if include_companies:
data["companies"] = []
return data
async def list_contacts(
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
search: str | None = None,
sort_by: str = "last_name",
sort_order: str = "asc",
) -> dict[str, Any]:
"""List contacts with pagination and optional search."""
page = max(1, page)
page_size = max(1, min(100, page_size))
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
base = base.where(
(Contact.first_name.ilike(pattern))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
)
sort_col = getattr(Contact, sort_by, Contact.last_name)
if sort_order == "desc":
base = base.order_by(desc(sort_col))
else:
base = base.order_by(asc(sort_col))
count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q)
total = total_result.scalar_one()
offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
contacts = result.scalars().all()
return {
"items": [_contact_to_dict(c) for c in contacts],
"total": total,
"page": page,
"page_size": page_size,
}
async def get_contact_detail(
db: AsyncSession,
tenant_id: uuid.UUID,
contact_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Get a single contact with its linked companies array."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return None
data = _contact_to_dict(contact, include_companies=True)
companies_q = (
select(Company, CompanyContact)
.join(CompanyContact, CompanyContact.company_id == Company.id)
.where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
companies_result = await db.execute(companies_q)
companies_list = []
for company, link in companies_result.all():
companies_list.append({
"id": str(company.id),
"name": company.name,
"industry": company.industry,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
})
data["companies"] = companies_list
return data
async def create_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new contact, optionally linking to companies via company_ids."""
contact = Contact(
tenant_id=tenant_id,
first_name=data["first_name"],
last_name=data["last_name"],
email=data.get("email"),
phone=data.get("phone"),
mobile=data.get("mobile"),
position=data.get("position"),
department=data.get("department"),
linkedin_url=data.get("linkedin_url"),
notes=data.get("notes"),
created_by=user_id,
updated_by=user_id,
)
db.add(contact)
await db.flush()
# Link to companies if company_ids provided
company_ids = data.get("company_ids")
linked_companies = []
if company_ids:
for cid_str in company_ids:
try:
cid = uuid.UUID(cid_str) if isinstance(cid_str, str) else cid_str
except (ValueError, TypeError):
continue
# Verify company exists in tenant
comp_q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
comp_result = await db.execute(comp_q)
if comp_result.scalar_one_or_none() is None:
continue
link = CompanyContact(
tenant_id=tenant_id,
company_id=cid,
contact_id=contact.id,
)
db.add(link)
linked_companies.append(str(cid))
await db.flush()
await db.refresh(contact)
await log_audit(
db, tenant_id, user_id, "create", "contact", contact.id,
changes={"first_name": data["first_name"], "last_name": data["last_name"], "linked_companies": linked_companies},
)
return _contact_to_dict(contact)
async def update_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update a contact (partial update) and audit-log changes."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return None
changes: dict[str, Any] = {}
for field in ("first_name", "last_name", "email", "phone", "mobile", "position", "department", "linkedin_url", "notes"):
if field in data and data[field] is not None:
old_val = getattr(contact, field)
changes[field] = {"old": old_val, "new": data[field]}
setattr(contact, field, data[field])
contact.updated_by = user_id
await db.flush()
await db.refresh(contact)
await log_audit(db, tenant_id, user_id, "update", "contact", contact_id, changes=changes)
return _contact_to_dict(contact)
async def soft_delete_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""Soft-delete a contact (set deleted_at)."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return False
contact.deleted_at = datetime.now(timezone.utc)
contact.updated_by = user_id
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "contact", contact_id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
return True
async def gdpr_hard_delete_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""GDPR hard-delete: physical delete + deletion_log entry with snapshot."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return False
# Capture snapshot before deletion
snapshot = _contact_to_dict(contact)
# Delete N:M links first
await db.execute(
delete(CompanyContact).where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
)
)
# Physical delete
await db.delete(contact)
await db.flush()
# Deletion log (immutable snapshot)
await log_deletion(
db, tenant_id, user_id, "contact", contact_id, snapshot,
)
return True
+216
View File
@@ -0,0 +1,216 @@
"""Import/export service — CSV import with dry-run preview, CSV/XLSX export."""
from __future__ import annotations
import csv
import io
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.models.company import Company
from app.models.contact import Contact
from app.services.company_service import _company_to_dict
from app.services.contact_service import _contact_to_dict
# Expected CSV columns for each entity type
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
CONTACT_COLUMNS = ["first_name", "last_name", "email", "phone", "mobile", "position", "department"]
def _parse_csv(content: str) -> list[dict[str, str]]:
"""Parse CSV content into list of dicts."""
reader = csv.DictReader(io.StringIO(content))
return [dict(row) for row in reader]
def _validate_row(row: dict[str, str], required: list[str]) -> list[str]:
"""Validate a single row. Returns list of error messages (empty if valid)."""
errors = []
for col in required:
val = row.get(col, "").strip()
if not val:
errors.append(f"Missing required field: {col}")
return errors
async def import_companies(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
csv_content: str,
dry_run: bool = False,
) -> dict[str, Any]:
"""Import companies from CSV. If dry_run=True, no DB changes are made.
Returns {total, valid, invalid, errors, created (empty in dry_run)}.
"""
rows = _parse_csv(csv_content)
total = len(rows)
valid_rows = []
errors = []
for idx, row in enumerate(rows, start=1):
row_errors = _validate_row(row, ["name"])
if row_errors:
for e in row_errors:
errors.append({"row": idx, "error": e})
else:
valid_rows.append(row)
if dry_run:
return {
"total": total,
"valid": len(valid_rows),
"invalid": len(errors),
"errors": errors,
"created": [],
"dry_run": True,
}
created = []
for row in valid_rows:
company = Company(
tenant_id=tenant_id,
name=row["name"].strip(),
industry=row.get("industry", "").strip() or None,
phone=row.get("phone", "").strip() or None,
email=row.get("email", "").strip() or None,
website=row.get("website", "").strip() or None,
description=row.get("description", "").strip() or None,
created_by=user_id,
updated_by=user_id,
)
db.add(company)
await db.flush()
await log_audit(
db, tenant_id, user_id, "import", "company", company.id,
changes={"name": company.name},
)
created.append(_company_to_dict(company))
return {
"total": total,
"valid": len(valid_rows),
"invalid": len(errors),
"errors": errors,
"created": created,
"dry_run": False,
}
async def import_contacts(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
csv_content: str,
dry_run: bool = False,
) -> dict[str, Any]:
"""Import contacts from CSV. If dry_run=True, no DB changes are made.
Returns {total, valid, invalid, errors, created (empty in dry_run)}.
"""
rows = _parse_csv(csv_content)
total = len(rows)
valid_rows = []
errors = []
for idx, row in enumerate(rows, start=1):
row_errors = _validate_row(row, ["first_name", "last_name"])
if row_errors:
for e in row_errors:
errors.append({"row": idx, "error": e})
else:
valid_rows.append(row)
if dry_run:
return {
"total": total,
"valid": len(valid_rows),
"invalid": len(errors),
"errors": errors,
"created": [],
"dry_run": True,
}
created = []
for row in valid_rows:
contact = Contact(
tenant_id=tenant_id,
first_name=row["first_name"].strip(),
last_name=row["last_name"].strip(),
email=row.get("email", "").strip() or None,
phone=row.get("phone", "").strip() or None,
mobile=row.get("mobile", "").strip() or None,
position=row.get("position", "").strip() or None,
department=row.get("department", "").strip() or None,
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={"first_name": contact.first_name, "last_name": contact.last_name},
)
created.append(_contact_to_dict(contact))
return {
"total": total,
"valid": len(valid_rows),
"invalid": len(errors),
"errors": errors,
"created": created,
"dry_run": False,
}
async def import_csv(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
csv_content: str,
entity_type: str,
dry_run: bool = False,
) -> dict[str, Any]:
"""Generic CSV import dispatcher based on entity_type ('companies' or 'contacts')."""
if entity_type == "companies":
return await import_companies(db, tenant_id, user_id, csv_content, dry_run=dry_run)
elif entity_type == "contacts":
return await import_contacts(db, tenant_id, user_id, csv_content, dry_run=dry_run)
else:
return {
"total": 0,
"valid": 0,
"invalid": 0,
"errors": [{"row": 0, "error": f"Unknown entity_type: {entity_type}"}],
"created": [],
"dry_run": dry_run,
}
async def export_contacts_csv(
db: AsyncSession,
tenant_id: uuid.UUID,
) -> str:
"""Export contacts as CSV string."""
q = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
).order_by(Contact.last_name, Contact.first_name)
result = await db.execute(q)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "first_name", "last_name", "email", "phone", "mobile", "position", "department"])
for c in contacts:
writer.writerow([
str(c.id), c.first_name, c.last_name, c.email or "",
c.phone or "", c.mobile or "", c.position or "", c.department or "",
])
return output.getvalue()