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
+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()