ae228bb484
Check Cross-Plugin Imports / check (push) Has been cancelled
B-HOOK-CORE: Company Hooks (6: before/after create/update/delete) B-HOOK-MAIL: Mail Hooks (5: after_receive, before/after_delete, before/after_move) B-HOOK-DMS: DMS Hooks (10: after_upload, before/after_update/delete/restore, folder CRUD) B-HOOK-CAL: Calendar Hooks (4: before/after update/delete) B-HOOK-TASK: Task Hooks (6: before/after create/update/delete) B-HOOK-COMM: Communication Hooks (8: conversation create, message/edit/delete) B-HOOK-AI: Agent Hooks (2: before/after_run) B-HOOK-WF: Workflow Hooks (4: before/after_start, after_complete/cancel) B-HOOK-TAG: Tag Hooks (8: create/assign/unassign/delete) B-HOOK-SEARCH: Search Filters (2: before/after_search) B-EVT-OUTBOX: 10 Domain Events (task.completed, file.created/deleted/restored, mail.received, workflow.started/completed/cancelled, agent.run_started/completed) B-HOOK-TEST: 79 Tests in test_lifecycle_hooks.py — alle grün B-HOOK-DOC: Plugin-Dev-Guide Kapitel 8 aktualisiert (Hook-Liste + Outbox Event-Liste)
336 lines
13 KiB
Python
336 lines
13 KiB
Python
"""Companies routes — CRUD, search, filter, export, contact links.
|
|
|
|
Companies are Contact entities with type='company'.
|
|
Industry and description are stored in the custom JSONB field.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.orm import selectinload
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.db import get_db
|
|
from app.deps import get_current_user, get_redis_dep, require_permission
|
|
from app.models.audit import AuditLog
|
|
from app.models.contact import Contact
|
|
from app.services.entity_history_service import record_history
|
|
|
|
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
|
|
|
|
|
def _serialize_company(c: Contact) -> dict[str, Any]:
|
|
custom = c.custom or {}
|
|
return {
|
|
"id": str(c.id),
|
|
"name": c.name,
|
|
"displayname": c.displayname,
|
|
"status": c.status,
|
|
"industry": custom.get("industry"),
|
|
"description": custom.get("description"),
|
|
"email_1": c.email_1,
|
|
"email_2": c.email_2,
|
|
"phone_1": c.phone_1,
|
|
"phone_2": c.phone_2,
|
|
"website": c.website,
|
|
"mailing_city": c.mailing_city,
|
|
"mailing_postalcode": c.mailing_postalcode,
|
|
"mailing_country": c.mailing_country,
|
|
"tags": c.tags,
|
|
"custom": c.custom,
|
|
|
|
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
async def list_companies(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
search: str | None = Query(None),
|
|
industry: str | None = Query(None),
|
|
sort_by: str = Query("name"),
|
|
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:read")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
q = select(Contact).where(
|
|
Contact.tenant_id == tenant_id,
|
|
Contact.type == "company",
|
|
Contact.deleted_at.is_(None),
|
|
)
|
|
if search:
|
|
q = q.where(Contact.search_tsv.match(search) | Contact.name.ilike(f"%{search}%"))
|
|
if industry:
|
|
q = q.where(Contact.custom["industry"].astext == industry)
|
|
count_q = select(func.count()).select_from(q.subquery())
|
|
total = (await db.execute(count_q)).scalar() or 0
|
|
sort_col = getattr(Contact, sort_by, Contact.name)
|
|
if sort_order == "desc":
|
|
sort_col = sort_col.desc()
|
|
q = q.order_by(sort_col).offset((page - 1) * page_size).limit(page_size)
|
|
result = await db.execute(q)
|
|
companies = result.scalars().all()
|
|
return {"items": [_serialize_company(c) for c in companies], "total": total, "page": page, "page_size": page_size}
|
|
|
|
|
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
|
async def create_company(
|
|
body: dict[str, Any],
|
|
db: AsyncSession = Depends(get_db),
|
|
redis=Depends(get_redis_dep),
|
|
current_user: dict = Depends(require_permission("contacts:write")),
|
|
):
|
|
name = body.get("name")
|
|
if not name:
|
|
raise HTTPException(status_code=422, detail="name is required")
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
custom = {}
|
|
if body.get("industry"):
|
|
custom["industry"] = body["industry"]
|
|
if body.get("description"):
|
|
custom["description"] = body["description"]
|
|
for k, v in body.items():
|
|
if k not in ("name", "industry", "description"):
|
|
custom[k] = v
|
|
from app.core.hooks import do_action
|
|
await do_action("company.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
|
|
company = Contact(
|
|
tenant_id=tenant_id, type="company", name=name, displayname=name,
|
|
status=body.get("status", "lead"), custom=custom,
|
|
created_by=user_id, updated_by=user_id,
|
|
)
|
|
db.add(company)
|
|
await db.flush()
|
|
audit_entry = AuditLog(
|
|
tenant_id=tenant_id, user_id=user_id, action="create",
|
|
entity_type="contact", entity_id=company.id,
|
|
changes={"name": name, "type": "company", **custom},
|
|
)
|
|
db.add(audit_entry)
|
|
await db.flush()
|
|
from app.core.hooks import do_action
|
|
await do_action("company.after_create", _serialize_company(company), db=db, tenant_id=tenant_id, user_id=user_id)
|
|
return _serialize_company(company)
|
|
|
|
|
|
@router.get("/export")
|
|
async def export_companies(
|
|
format: str = Query("csv", pattern="^(csv|xlsx)$"),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:read")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
q = select(Contact).where(
|
|
Contact.tenant_id == tenant_id, Contact.type == "company",
|
|
Contact.deleted_at.is_(None),
|
|
).order_by(Contact.name)
|
|
result = await db.execute(q)
|
|
companies = result.scalars().all()
|
|
rows = [_serialize_company(c) for c in companies]
|
|
if format == "csv":
|
|
import csv
|
|
output = io.StringIO()
|
|
if rows:
|
|
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
return Response(content=output.getvalue(), media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=companies.csv"})
|
|
else:
|
|
try:
|
|
import openpyxl
|
|
except ImportError:
|
|
raise HTTPException(status_code=500, detail="openpyxl not installed")
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Companies"
|
|
if rows:
|
|
headers = list(rows[0].keys())
|
|
ws.append(headers)
|
|
for row in rows:
|
|
ws.append([str(v) if v is not None else "" for v in row.values()])
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
return Response(content=output.getvalue(),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": "attachment; filename=companies.xlsx"})
|
|
|
|
|
|
@router.get("/{company_id}")
|
|
async def get_company(
|
|
company_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:read")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
q = select(Contact).options(selectinload(Contact.contact_persons)).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 not company:
|
|
raise HTTPException(status_code=404, detail="Company not found")
|
|
contacts = []
|
|
if company.contact_persons:
|
|
for cp in company.contact_persons:
|
|
contacts.append({"id": str(cp.id), "firstname": cp.firstname, "lastname": cp.lastname, "email": cp.email, "phone": cp.phone})
|
|
data = _serialize_company(company)
|
|
data["contacts"] = contacts
|
|
return data
|
|
|
|
|
|
@router.put("/{company_id}")
|
|
async def update_company(
|
|
company_id: str,
|
|
body: dict[str, Any],
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:write")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
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 not company:
|
|
raise HTTPException(status_code=404, detail="Company not found")
|
|
from app.core.hooks import do_action
|
|
await do_action("company.before_update", body, db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
|
if "name" in body:
|
|
company.name = body["name"]
|
|
company.displayname = body["name"]
|
|
if "status" in body:
|
|
company.status = body["status"]
|
|
# Update custom fields - use raw SQL to avoid lazy loading issues
|
|
custom = dict(company.custom) if company.custom else {}
|
|
if "industry" in body:
|
|
custom["industry"] = body["industry"]
|
|
if "description" in body:
|
|
custom["description"] = body["description"]
|
|
company.custom = custom
|
|
company.updated_by = user_id
|
|
await db.flush()
|
|
audit_entry = AuditLog(tenant_id=tenant_id, user_id=user_id, action="update",
|
|
entity_type="contact", entity_id=company.id, changes=body)
|
|
db.add(audit_entry)
|
|
await db.flush()
|
|
from app.core.hooks import do_action
|
|
await do_action("company.after_update", _serialize_company(company), db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
|
return _serialize_company(company)
|
|
|
|
|
|
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_company(
|
|
company_id: str,
|
|
cascade: bool = Query(False),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:delete")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
q = select(Contact).options(selectinload(Contact.contact_persons)).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 not company:
|
|
raise HTTPException(status_code=404, detail="Company not found")
|
|
snapshot = _serialize_company(company)
|
|
from app.core.hooks import do_action
|
|
await do_action("company.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
|
# Soft-delete contact persons via SQL to avoid lazy loading
|
|
company.deleted_at = datetime.now(UTC)
|
|
company.updated_by = user_id
|
|
if cascade and company.contact_persons:
|
|
for cp in company.contact_persons:
|
|
cp.deleted_at = datetime.now(UTC)
|
|
await db.flush()
|
|
await record_history(db, tenant_id, user_id, "contact", company.id, "delete", snapshot_before=snapshot)
|
|
audit_entry = AuditLog(tenant_id=tenant_id, user_id=user_id, action="delete",
|
|
entity_type="contact", entity_id=company.id, changes={"name": company.name})
|
|
db.add(audit_entry)
|
|
await db.flush()
|
|
from app.core.hooks import do_action
|
|
await do_action("company.after_delete", snapshot, db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post("/{company_id}/contacts/{contact_id}")
|
|
async def link_contact_to_company(
|
|
company_id: str, contact_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:write")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
comp_q = select(Contact).where(Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
|
Contact.type == "company", Contact.deleted_at.is_(None))
|
|
company = (await db.execute(comp_q)).scalar_one_or_none()
|
|
if not company:
|
|
raise HTTPException(status_code=404, detail="Company not found")
|
|
person_q = select(Contact).where(Contact.id == uuid.UUID(contact_id), Contact.tenant_id == tenant_id,
|
|
Contact.deleted_at.is_(None))
|
|
person = (await db.execute(person_q)).scalar_one_or_none()
|
|
if not person:
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
# Create a ContactPerson link
|
|
from app.models.contact import ContactPerson
|
|
cp = ContactPerson(
|
|
tenant_id=tenant_id,
|
|
contact_id=company.id,
|
|
displayname=person.displayname or person.name or "",
|
|
firstname=person.firstname,
|
|
lastname=person.surname,
|
|
email=person.email_1,
|
|
phone=person.phone_1,
|
|
)
|
|
db.add(cp)
|
|
await db.flush()
|
|
return {"company_id": company_id, "contact_id": contact_id}
|
|
|
|
|
|
@router.delete("/{company_id}/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def unlink_contact_from_company(
|
|
company_id: str, contact_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:write")),
|
|
):
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
comp_q = select(Contact).where(Contact.id == uuid.UUID(company_id), Contact.tenant_id == tenant_id,
|
|
Contact.type == "company", Contact.deleted_at.is_(None))
|
|
company = (await db.execute(comp_q)).scalar_one_or_none()
|
|
if not company:
|
|
raise HTTPException(status_code=404, detail="Company not found")
|
|
# Remove ContactPerson link
|
|
from sqlalchemy import delete as sa_delete
|
|
from app.models.contact import ContactPerson
|
|
await db.execute(
|
|
sa_delete(ContactPerson).where(
|
|
ContactPerson.contact_id == uuid.UUID(company_id),
|
|
ContactPerson.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
await db.flush()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.get("/{company_id}/emails")
|
|
async def get_company_emails(
|
|
company_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_permission("contacts:read")),
|
|
):
|
|
return []
|