Files
leocrm/app/services/contact_service.py
Agent Zero 29202325a6 feat: Kontakt-Ordner mit Drag&Drop Baum (wie KI-Chat Sidebar)
- Backend: ContactFolder model (hierarchisch, parent_id, sort_order)
- Migration 0022: contact_folders Tabelle + folder_id FK auf contacts
- CRUD Routes: /api/v1/contact-folders (list, create, update, delete, reorder)
- Move Contact API: /api/v1/contact-folders/contacts/{id}/move
- folder_id Filter in contacts list API
- Frontend: ContactFolderTree mit Baum-Struktur, Drag&Drop, Kontext-Menü
- Kontakt-Personen-Icon statt Ordner-Symbol
- ContactList Items draggable (List/Table/Cards View)
- React Query Hooks für Folder CRUD + Move Contact
- Alle 266 Frontend-Tests passing
2026-07-20 01:33:44 +02:00

422 lines
14 KiB
Python

"""Unified contact service — CRUD, FTS search, contactpersons, soft-delete, export."""
from __future__ import annotations
import csv
import io
import uuid
from typing import Any
from sqlalchemy import select, func, or_, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.contact import Contact, ContactPerson
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."""
return {
"id": str(c.id),
"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,
"folder_id": str(c.folder_id) if c.folder_id else None,
"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,
}
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
async def list_contacts(
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
search: str | None = None,
contact_type: str | None = None,
folder_id: str | None = None,
sort_by: str = "displayname",
sort_order: str = "asc",
resolved_perms: dict | None = None,
) -> dict:
"""List contacts with pagination, FTS search, type/folder 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 folder_id:
base = base.where(Contact.folder_id == uuid.UUID(folder_id))
if search:
base = base.where(
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
)
# Count
count_q = select(func.count()).select_from(base.subquery())
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
base = base.offset(offset).limit(page_size)
result = await db.execute(base)
contacts = result.scalars().all()
return {
"items": [_serialize_contact(c) for c in contacts],
"total": total,
"page": page,
"page_size": page_size,
}
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 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
) -> dict:
"""Create a new contact."""
data["displayname"] = _compute_displayname(data)
contact_persons_data = data.pop("contact_persons", None)
contact = Contact(
tenant_id=tenant_id,
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()
# 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,
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(cp)
await db.flush()
# 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: 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()
# ── 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)},
)
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()