Unified contacts model: Rentman-style type field (company/person), inline addresses, ContactPerson 1:N, all Rentman fields except rental-specific

This commit is contained in:
Agent Zero
2026-07-19 21:12:49 +02:00
parent d80feb2a3a
commit cf75680583
14 changed files with 1411 additions and 869 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
"""Migrate existing address fields from companies/contacts to Address records.
Called during migration 0013. Returns count of created addresses.
"""
from app.models.company import Company
from app.models.contact import Contact as Company
from app.models.contact import Contact
count = 0
+1 -1
View File
@@ -13,7 +13,7 @@ from app.ai.llm_client import get_llm_client
from app.core.audit import log_audit
from app.core.auth import check_permission
from app.models.ai_conversation import AIConversation, AIMessage
from app.models.company import Company
from app.models.contact import Contact as Company
from app.models.contact import Contact
from app.models.workflow import Workflow
+182 -370
View File
@@ -1,44 +1,35 @@
"""Company service — CRUD, FTS search, filter, pagination, soft-delete, N:M, export."""
"""Company service — now operates on Contact with type='company'.
Backward-compat layer: all company operations are now contact operations
filtered by type='company'. CompanyContact is replaced by ContactPerson 1:N.
"""
from __future__ import annotations
import csv
import io
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import asc, delete, desc, func, or_, select
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.audit import log_audit
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.models.contact import Contact, ContactPerson
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
"""Serialize a Company ORM object to dict."""
data: dict[str, Any] = {
def _company_to_dict(c: Contact) -> dict:
"""Serialize a company (Contact with type=company) to dict."""
return {
"id": str(c.id),
"name": c.name,
"account_number": c.account_number,
"industry": c.industry,
"phone": c.phone,
"email": c.email,
"name": c.name or "",
"account_number": c.code or "",
"industry": (c.custom or {}).get("industry") if c.custom else None,
"phone": c.phone_1,
"email": c.email_1,
"website": c.website,
"description": c.description,
"description": (c.custom or {}).get("description") if c.custom else None,
"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"] = []
return data
def _filter_company_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter company fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "companies")
async def list_companies(
@@ -51,411 +42,232 @@ async def list_companies(
sort_by: str = "name",
sort_order: str = "asc",
resolved_perms: dict | None = None,
) -> dict[str, Any]:
"""List companies with pagination, FTS search, industry filter, and sorting."""
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),
) -> dict:
"""List companies (contacts with type='company')."""
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.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),
)
)
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))
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q)
total = total_result.scalar_one()
total = (await db.execute(count_q)).scalar() or 0
sort_col = getattr(Contact, sort_by if sort_by != "name" else "name", Contact.name)
if sort_order == "desc":
sort_col = sort_col.desc()
base = base.order_by(sort_col)
offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
base = base.offset(offset).limit(page_size)
result = await db.execute(base)
companies = result.scalars().all()
items = [_company_to_dict(c) for c in companies]
if resolved_perms is not None:
items = [_filter_company_fields(item, resolved_perms) for item in items]
return {
"items": items,
"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,
resolved_perms: dict | None = None,
) -> 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
async def create_company(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
) -> dict:
"""Create a company (contact with type='company')."""
# Map old fields to new
contact_data = {
"type": "company",
"name": data.get("name", ""),
"code": data.get("account_number"),
"phone_1": data.get("phone"),
"email_1": data.get("email"),
"website": data.get("website"),
"displayname": data.get("name", ""),
}
# Store industry/description in custom
custom = {}
if data.get("industry"):
custom["industry"] = data["industry"]
if data.get("description"):
custom["description"] = data["description"]
if custom:
contact_data["custom"] = custom
data = _company_to_dict(company, include_contacts=True)
contacts_q = (
select(Contact, CompanyContact)
.join(CompanyContact, CompanyContact.contact_id == Contact.id)
contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data)
db.add(contact)
await db.flush()
return _company_to_dict(contact)
async def get_company_detail(
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, resolved_perms: dict | None = None
) -> dict:
"""Get company detail with contact persons."""
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
CompanyContact.company_id == company_id,
CompanyContact.tenant_id == tenant_id,
Contact.id == uuid.UUID(company_id),
Contact.tenant_id == tenant_id,
Contact.type == "company",
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
if resolved_perms is not None:
data = _filter_company_fields(data, resolved_perms)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Company not found")
data = _company_to_dict(contact)
data["contacts"] = [
{
"id": str(cp.id),
"first_name": cp.firstname or "",
"last_name": cp.lastname or "",
"email": cp.email,
"phone": cp.phone,
"position": cp.function,
}
for cp in (contact.contact_persons or [])
if cp.deleted_at is None
]
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),
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict
) -> dict:
"""Update a company."""
q = select(Contact).where(
Contact.id == uuid.UUID(company_id),
Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
company = result.scalar_one_or_none()
if company is None:
return None
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Company not found")
changes: dict[str, Any] = {}
all_fields = (
"name", "account_number", "industry", "phone", "email",
"website", "description",
)
for field in all_fields:
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
if "name" in data:
contact.name = data["name"]
contact.displayname = data["name"]
if "account_number" in data:
contact.code = data["account_number"]
if "phone" in data:
contact.phone_1 = data["phone"]
if "email" in data:
contact.email_1 = data["email"]
if "website" in data:
contact.website = data["website"]
# Store industry/description in custom
custom = dict(contact.custom or {})
if "industry" in data:
custom["industry"] = data["industry"]
if "description" in data:
custom["description"] = data["description"]
if custom:
contact.custom = custom
contact.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)
return _company_to_dict(contact)
async def soft_delete_company(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
company_id: uuid.UUID,
cascade: bool = False,
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True
) -> 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(UTC)
company.updated_by = user_id
if cascade:
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."""
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
cont_q = select(Contact).where(
Contact.id == contact_id,
"""Soft-delete a company."""
from datetime import datetime, timezone
q = select(Contact).where(
Contact.id == uuid.UUID(company_id),
Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.deleted_at.is_(None),
)
cont_result = await db.execute(cont_q)
if cont_result.scalar_one_or_none() is None:
return None
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:
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:
contact = result.scalar_one_or_none()
if not contact:
return False
await db.delete(link)
contact.deleted_at = datetime.now(timezone.utc)
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,
db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None
) -> bytes:
"""Export companies as XLSX bytes."""
"""Export companies as XLSX."""
from openpyxl import Workbook
base = select(Company).where(
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.type == "company",
Contact.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)
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
base = base.order_by(Contact.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)
ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"])
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 ""]
)
custom = c.custom or {}
ws.append([
c.name or "", c.code or "", custom.get("industry", ""),
c.phone_1 or "", c.email_1 or "", c.website or "",
])
import io
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)."""
q = select(Company).where(
Company.id == company_id,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
async def link_contact(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
) -> dict:
"""Link a contact person to a company (create ContactPerson)."""
# In new model, contact_id is used to create a ContactPerson under the company contact
# This is a compat shim — the old API expected a separate Contact entity
# Now we just create a ContactPerson linked to the company
cp = ContactPerson(
tenant_id=tenant_id,
contact_id=uuid.UUID(company_id),
created_by=user_id,
updated_by=user_id,
)
db.add(cp)
await db.flush()
return {"id": str(cp.id), "company_id": company_id, "contact_id": contact_id}
async def unlink_contact(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
) -> bool:
"""Unlink a contact person from a company (soft-delete ContactPerson)."""
from datetime import datetime, timezone
q = select(ContactPerson).where(
ContactPerson.contact_id == uuid.UUID(company_id),
ContactPerson.id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
)
result = await db.execute(q)
if result.scalar_one_or_none() is None:
return []
return []
cp = result.scalar_one_or_none()
if not cp:
return False
cp.deleted_at = datetime.now(timezone.utc)
await db.flush()
return True
+340 -233
View File
@@ -1,309 +1,416 @@
"""Contact service — CRUD, N:M linking, soft-delete, GDPR hard-delete."""
"""Unified contact service — CRUD, FTS search, contactpersons, soft-delete, export."""
from __future__ import annotations
import csv
import io
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import asc, delete, desc, func, select
from sqlalchemy import select, func, or_, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.audit import log_audit, log_deletion
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
from app.models.contact import Contact, ContactPerson
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
def _compute_displayname(data: dict) -> str:
"""Compute displayname from type and name fields."""
if data.get("type") == "person":
parts = [data.get("surfix"), data.get("firstname"), data.get("surname")]
return " ".join(p for p in parts if p).strip()
else:
return data.get("name") or ""
def _serialize_contact(c: Contact) -> dict:
"""Serialize a Contact ORM object to dict."""
data: dict[str, Any] = {
return {
"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,
"type": c.type,
"displayname": c.displayname,
"name": c.name,
"firstname": c.firstname,
"surname": c.surname,
"surfix": c.surfix,
"ext_name_line": c.ext_name_line,
"gender": c.gender,
"code": c.code,
"accounting_code": c.accounting_code,
"vendor_accounting_code": c.vendor_accounting_code,
"mailing_street": c.mailing_street,
"mailing_number": c.mailing_number,
"mailing_unit_number": c.mailing_unit_number,
"mailing_district": c.mailing_district,
"mailing_extra_address_line": c.mailing_extra_address_line,
"mailing_postalcode": c.mailing_postalcode,
"mailing_city": c.mailing_city,
"mailing_state": c.mailing_state,
"mailing_country": c.mailing_country,
"visit_street": c.visit_street,
"visit_number": c.visit_number,
"visit_unit_number": c.visit_unit_number,
"visit_district": c.visit_district,
"visit_extra_address_line": c.visit_extra_address_line,
"visit_postalcode": c.visit_postalcode,
"visit_city": c.visit_city,
"visit_state": c.visit_state,
"invoice_street": c.invoice_street,
"invoice_number": c.invoice_number,
"invoice_unit_number": c.invoice_unit_number,
"invoice_district": c.invoice_district,
"invoice_extra_address_line": c.invoice_extra_address_line,
"invoice_postalcode": c.invoice_postalcode,
"invoice_city": c.invoice_city,
"invoice_state": c.invoice_state,
"invoice_country": c.invoice_country,
"country": c.country,
"phone_1": c.phone_1,
"phone_2": c.phone_2,
"email_1": c.email_1,
"email_2": c.email_2,
"website": c.website,
"vat_code": c.vat_code,
"fiscal_code": c.fiscal_code,
"commerce_code": c.commerce_code,
"purchase_number": c.purchase_number,
"bic": c.bic,
"bank_account": c.bank_account,
"discount_crew": c.discount_crew,
"discount_transport": c.discount_transport,
"discount_rental": c.discount_rental,
"discount_sale": c.discount_sale,
"discount_subrent": c.discount_subrent,
"discount_total": c.discount_total,
"latitude": c.latitude,
"longitude": c.longitude,
"projectnote": c.projectnote,
"projectnote_title": c.projectnote_title,
"contact_warning": c.contact_warning,
"tags": c.tags,
"image": c.image,
"custom": c.custom,
"default_person_id": str(c.default_person_id) if c.default_person_id else None,
"admin_contactperson_id": str(c.admin_contactperson_id) if c.admin_contactperson_id else None,
"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"] = []
def _serialize_contact_person(cp: ContactPerson) -> dict:
"""Serialize a ContactPerson ORM object to dict."""
return {
"id": str(cp.id),
"contact_id": str(cp.contact_id),
"displayname": cp.displayname,
"firstname": cp.firstname,
"middle_name": cp.middle_name,
"lastname": cp.lastname,
"function": cp.function,
"phone": cp.phone,
"mobilephone": cp.mobilephone,
"email": cp.email,
"street": cp.street,
"number": cp.number,
"postalcode": cp.postalcode,
"city": cp.city,
"state": cp.state,
"country": cp.country,
"tags": cp.tags,
"custom": cp.custom,
"created_at": cp.created_at.isoformat() if cp.created_at else None,
"updated_at": cp.updated_at.isoformat() if cp.updated_at else None,
}
def _serialize_contact_detail(c: Contact) -> dict:
"""Serialize with contact_persons included."""
data = _serialize_contact(c)
data["contact_persons"] = [_serialize_contact_person(cp) for cp in (c.contact_persons or [])]
return data
def _filter_contact_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter contact fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "contacts")
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",
contact_type: str | None = None,
sort_by: str = "displayname",
sort_order: str = "asc",
resolved_perms: dict | None = None,
) -> dict[str, Any]:
"""List contacts with pagination and optional search."""
page = max(1, page)
page_size = max(1, min(100, page_size))
) -> dict:
"""List contacts with pagination, FTS search, type filter, sorting."""
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:
pattern = f"%{search}%"
base = base.where(
(Contact.first_name.ilike(pattern))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
)
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
count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q)
total = total_result.scalar_one()
total = (await db.execute(count_q)).scalar() or 0
# Sort
sort_col = getattr(Contact, sort_by, Contact.displayname)
if sort_order == "desc":
sort_col = sort_col.desc()
base = base.order_by(sort_col)
# Paginate
offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
base = base.offset(offset).limit(page_size)
result = await db.execute(base)
contacts = result.scalars().all()
items = [_contact_to_dict(c) for c in contacts]
if resolved_perms is not None:
items = [_filter_contact_fields(item, resolved_perms) for item in items]
return {
"items": items,
"items": [_serialize_contact(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,
resolved_perms: dict | None = None,
) -> 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),
async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict:
"""Get a single contact with contact_persons."""
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
Contact.id == uuid.UUID(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
if resolved_perms is not None:
data = _filter_contact_fields(data, resolved_perms)
return data
if not contact:
raise ValueError("Contact not found")
return _serialize_contact_detail(contact)
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."""
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
) -> dict:
"""Create a new contact."""
data["displayname"] = _compute_displayname(data)
contact_persons_data = data.pop("contact_persons", None)
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,
**{k: v for k, v in data.items() if hasattr(Contact, k)},
)
db.add(contact)
await db.flush()
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
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(
# Create inline contact persons
if contact_persons_data:
for cp_data in contact_persons_data:
cp_data["displayname"] = " ".join(
p for p in [cp_data.get("firstname"), cp_data.get("lastname")] if p
).strip()
cp = ContactPerson(
tenant_id=tenant_id,
company_id=cid,
contact_id=contact.id,
created_by=user_id,
updated_by=user_id,
**{k: v for k, v in cp_data.items() if hasattr(ContactPerson, k)},
)
db.add(link)
linked_companies.append(str(cid))
db.add(cp)
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)
# Load with contact_persons
q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id)
result = await db.execute(q)
contact = result.scalar_one()
return _serialize_contact_detail(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] = {}
all_fields = (
"first_name", "last_name", "email", "phone", "mobile",
"position", "department", "linkedin_url", "notes",
)
for field in all_fields:
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(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
snapshot = _contact_to_dict(contact)
await db.execute(
delete(CompanyContact).where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
) -> dict:
"""Update a contact."""
q = (
select(Contact)
.options(selectinload(Contact.contact_persons))
.where(
Contact.id == uuid.UUID(contact_id),
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
# Recompute displayname if name fields changed
if any(k in data for k in ("type", "name", "firstname", "surname", "surfix")):
merged = {**_serialize_contact(contact), **data}
data["displayname"] = _compute_displayname(merged)
for key, value in data.items():
if hasattr(contact, key):
setattr(contact, key, value)
contact.updated_by = user_id
await db.flush()
return _serialize_contact_detail(contact)
async def delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
"""Soft-delete a contact."""
q = select(Contact).where(
Contact.id == uuid.UUID(contact_id),
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
from datetime import datetime, timezone
contact.deleted_at = datetime.now(timezone.utc)
await db.flush()
async def hard_delete_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> None:
"""GDPR hard-delete a contact."""
q = select(Contact).where(
Contact.id == uuid.UUID(contact_id),
Contact.tenant_id == tenant_id,
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if not contact:
raise ValueError("Contact not found")
await db.delete(contact)
await db.flush()
await log_deletion(
db,
tenant_id,
user_id,
"contact",
contact_id,
snapshot,
# ── ContactPerson CRUD ──
async def list_contact_persons(
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str
) -> list[dict]:
"""List all contact persons for a contact."""
q = select(ContactPerson).where(
ContactPerson.contact_id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
).order_by(ContactPerson.displayname)
result = await db.execute(q)
return [_serialize_contact_person(cp) for cp in result.scalars().all()]
async def create_contact_person(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
) -> dict:
"""Add a contact person to a contact."""
data["displayname"] = " ".join(
p for p in [data.get("firstname"), data.get("lastname")] if p
).strip()
cp = ContactPerson(
tenant_id=tenant_id,
contact_id=uuid.UUID(contact_id),
created_by=user_id,
updated_by=user_id,
**{k: v for k, v in data.items() if hasattr(ContactPerson, k)},
)
return True
db.add(cp)
await db.flush()
return _serialize_contact_person(cp)
async def update_contact_person(
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, person_id: str, data: dict
) -> dict:
"""Update a contact person."""
q = select(ContactPerson).where(
ContactPerson.id == uuid.UUID(person_id),
ContactPerson.contact_id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
)
result = await db.execute(q)
cp = result.scalar_one_or_none()
if not cp:
raise ValueError("Contact person not found")
if any(k in data for k in ("firstname", "lastname")):
merged = {**_serialize_contact_person(cp), **data}
data["displayname"] = " ".join(
p for p in [merged.get("firstname"), merged.get("lastname")] if p
).strip()
for key, value in data.items():
if hasattr(cp, key):
setattr(cp, key, value)
cp.updated_by = user_id
await db.flush()
return _serialize_contact_person(cp)
async def delete_contact_person(
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, person_id: str
) -> None:
"""Soft-delete a contact person."""
q = select(ContactPerson).where(
ContactPerson.id == uuid.UUID(person_id),
ContactPerson.contact_id == uuid.UUID(contact_id),
ContactPerson.tenant_id == tenant_id,
ContactPerson.deleted_at.is_(None),
)
result = await db.execute(q)
cp = result.scalar_one_or_none()
if not cp:
raise ValueError("Contact person not found")
from datetime import datetime, timezone
cp.deleted_at = datetime.now(timezone.utc)
await db.flush()
async def export_contacts_csv(
db: AsyncSession, tenant_id: uuid.UUID, contact_type: str | None = None, search: str | None = None
) -> str:
"""Export contacts as CSV string."""
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)))
base = base.order_by(Contact.displayname)
result = await db.execute(base)
contacts = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"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",
])
for c in contacts:
writer.writerow([
str(c.id), c.type, c.displayname, c.name or "", c.firstname or "", c.surname or "",
c.code or "", c.email_1 or "", c.email_2 or "", c.phone_1 or "", c.phone_2 or "",
c.website or "", c.mailing_city or "", c.mailing_postalcode or "",
c.mailing_country or "", c.vat_code or "", c.tags or "",
])
return output.getvalue()
+3 -3
View File
@@ -11,10 +11,10 @@ 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.models.contact import Contact, ContactPerson as Company
from app.models.contact import Contact, ContactPerson
from app.services.company_service import _company_to_dict
from app.services.contact_service import _contact_to_dict
from app.services.contact_service import _serialize_contact as _contact_to_dict
# Expected CSV columns for each entity type
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]