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:
@@ -0,0 +1,165 @@
|
|||||||
|
"""Unified contacts model — company or person with inline addresses.
|
||||||
|
|
||||||
|
Revision ID: 0021
|
||||||
|
Revises: 0020
|
||||||
|
Create Date: 2026-07-19
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, TSVECTOR, JSON
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0021_unified_contacts"
|
||||||
|
down_revision = "0020_notifications_updated_at"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# 1. Drop old company_contacts join table
|
||||||
|
op.execute("DROP TABLE IF EXISTS company_contacts CASCADE")
|
||||||
|
|
||||||
|
# 2. Drop old contacts table (will recreate with new schema)
|
||||||
|
op.execute("DROP TABLE IF EXISTS contacts CASCADE")
|
||||||
|
|
||||||
|
# 3. Drop old companies table
|
||||||
|
op.execute("DROP TABLE IF EXISTS companies CASCADE")
|
||||||
|
|
||||||
|
# 4. Create contacts table (without default_person_id/admin_contactperson_id FKs first)
|
||||||
|
op.create_table(
|
||||||
|
"contacts",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False, index=True),
|
||||||
|
# Identity & Type
|
||||||
|
sa.Column("type", sa.String(20), nullable=False, server_default="company"),
|
||||||
|
sa.Column("displayname", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("firstname", sa.String(100), nullable=True),
|
||||||
|
sa.Column("surname", sa.String(100), nullable=True),
|
||||||
|
sa.Column("surfix", sa.String(50), nullable=True),
|
||||||
|
sa.Column("ext_name_line", sa.String(255), nullable=True),
|
||||||
|
sa.Column("gender", sa.String(20), nullable=True),
|
||||||
|
# Customer / Accounting
|
||||||
|
sa.Column("code", sa.String(100), nullable=True),
|
||||||
|
sa.Column("accounting_code", sa.String(100), nullable=True),
|
||||||
|
sa.Column("vendor_accounting_code", sa.String(100), nullable=True),
|
||||||
|
# Mailing Address
|
||||||
|
sa.Column("mailing_street", sa.String(255), nullable=True),
|
||||||
|
sa.Column("mailing_number", sa.String(20), nullable=True),
|
||||||
|
sa.Column("mailing_unit_number", sa.String(50), nullable=True),
|
||||||
|
sa.Column("mailing_district", sa.String(100), nullable=True),
|
||||||
|
sa.Column("mailing_extra_address_line", sa.String(255), nullable=True),
|
||||||
|
sa.Column("mailing_postalcode", sa.String(20), nullable=True),
|
||||||
|
sa.Column("mailing_city", sa.String(100), nullable=True),
|
||||||
|
sa.Column("mailing_state", sa.String(100), nullable=True),
|
||||||
|
sa.Column("mailing_country", sa.String(2), nullable=True),
|
||||||
|
# Visit Address
|
||||||
|
sa.Column("visit_street", sa.String(255), nullable=True),
|
||||||
|
sa.Column("visit_number", sa.String(20), nullable=True),
|
||||||
|
sa.Column("visit_unit_number", sa.String(50), nullable=True),
|
||||||
|
sa.Column("visit_district", sa.String(100), nullable=True),
|
||||||
|
sa.Column("visit_extra_address_line", sa.String(255), nullable=True),
|
||||||
|
sa.Column("visit_postalcode", sa.String(20), nullable=True),
|
||||||
|
sa.Column("visit_city", sa.String(100), nullable=True),
|
||||||
|
sa.Column("visit_state", sa.String(100), nullable=True),
|
||||||
|
# Invoice Address
|
||||||
|
sa.Column("invoice_street", sa.String(255), nullable=True),
|
||||||
|
sa.Column("invoice_number", sa.String(20), nullable=True),
|
||||||
|
sa.Column("invoice_unit_number", sa.String(50), nullable=True),
|
||||||
|
sa.Column("invoice_district", sa.String(100), nullable=True),
|
||||||
|
sa.Column("invoice_extra_address_line", sa.String(255), nullable=True),
|
||||||
|
sa.Column("invoice_postalcode", sa.String(20), nullable=True),
|
||||||
|
sa.Column("invoice_city", sa.String(100), nullable=True),
|
||||||
|
sa.Column("invoice_state", sa.String(100), nullable=True),
|
||||||
|
sa.Column("invoice_country", sa.String(2), nullable=True),
|
||||||
|
# General country
|
||||||
|
sa.Column("country", sa.String(2), nullable=True),
|
||||||
|
# Communication
|
||||||
|
sa.Column("phone_1", sa.String(50), nullable=True),
|
||||||
|
sa.Column("phone_2", sa.String(50), nullable=True),
|
||||||
|
sa.Column("email_1", sa.String(255), nullable=True),
|
||||||
|
sa.Column("email_2", sa.String(255), nullable=True),
|
||||||
|
sa.Column("website", sa.String(500), nullable=True),
|
||||||
|
# Financial & Tax
|
||||||
|
sa.Column("vat_code", sa.String(50), nullable=True),
|
||||||
|
sa.Column("fiscal_code", sa.String(50), nullable=True),
|
||||||
|
sa.Column("commerce_code", sa.String(100), nullable=True),
|
||||||
|
sa.Column("purchase_number", sa.String(100), nullable=True),
|
||||||
|
sa.Column("bic", sa.String(50), nullable=True),
|
||||||
|
sa.Column("bank_account", sa.String(50), nullable=True),
|
||||||
|
# Discounts
|
||||||
|
sa.Column("discount_crew", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount_transport", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount_rental", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount_sale", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount_subrent", sa.Float, nullable=False, server_default="0"),
|
||||||
|
sa.Column("discount_total", sa.Float, nullable=False, server_default="0"),
|
||||||
|
# Geo
|
||||||
|
sa.Column("latitude", sa.Float, nullable=True),
|
||||||
|
sa.Column("longitude", sa.Float, nullable=True),
|
||||||
|
# Notes & Warnings
|
||||||
|
sa.Column("projectnote", sa.Text, nullable=True),
|
||||||
|
sa.Column("projectnote_title", sa.String(255), nullable=True),
|
||||||
|
sa.Column("contact_warning", sa.Text, nullable=True),
|
||||||
|
sa.Column("tags", sa.String(500), nullable=True),
|
||||||
|
sa.Column("image", sa.Text, nullable=True),
|
||||||
|
# Custom fields
|
||||||
|
sa.Column("custom", JSON, nullable=True, server_default=sa.text("'{}'::json")),
|
||||||
|
# FTS
|
||||||
|
sa.Column("search_tsv", TSVECTOR, sa.Computed(
|
||||||
|
"to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))",
|
||||||
|
persisted=True,
|
||||||
|
), nullable=True),
|
||||||
|
# Audit
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_contacts_tenant_deleted", "contacts", ["tenant_id", "deleted_at"])
|
||||||
|
op.create_index("ix_contacts_tenant_type", "contacts", ["tenant_id", "type"])
|
||||||
|
op.create_index("ix_contacts_tenant_name", "contacts", ["tenant_id", "name"])
|
||||||
|
op.create_index("ix_contacts_tenant_displayname", "contacts", ["tenant_id", "displayname"])
|
||||||
|
op.create_index("ix_contacts_email", "contacts", ["email_1"])
|
||||||
|
op.create_index("ix_contacts_code", "contacts", ["code"])
|
||||||
|
op.create_index("ix_contacts_search_vec", "contacts", ["search_tsv"], postgresql_using="gin")
|
||||||
|
|
||||||
|
# 5. Create contactpersons table
|
||||||
|
op.create_table(
|
||||||
|
"contactpersons",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||||
|
sa.Column("tenant_id", UUID(as_uuid=True), nullable=False, index=True),
|
||||||
|
sa.Column("contact_id", UUID(as_uuid=True), sa.ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("displayname", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("firstname", sa.String(100), nullable=True),
|
||||||
|
sa.Column("middle_name", sa.String(100), nullable=True),
|
||||||
|
sa.Column("lastname", sa.String(100), nullable=True),
|
||||||
|
sa.Column("function", sa.String(255), nullable=True),
|
||||||
|
sa.Column("phone", sa.String(50), nullable=True),
|
||||||
|
sa.Column("mobilephone", sa.String(50), nullable=True),
|
||||||
|
sa.Column("email", sa.String(255), nullable=True),
|
||||||
|
sa.Column("street", sa.String(255), nullable=True),
|
||||||
|
sa.Column("number", sa.String(20), nullable=True),
|
||||||
|
sa.Column("postalcode", sa.String(20), nullable=True),
|
||||||
|
sa.Column("city", sa.String(100), nullable=True),
|
||||||
|
sa.Column("state", sa.String(100), nullable=True),
|
||||||
|
sa.Column("country", sa.String(2), nullable=True),
|
||||||
|
sa.Column("tags", sa.String(500), nullable=True),
|
||||||
|
sa.Column("custom", JSON, nullable=True, server_default=sa.text("'{}'::json")),
|
||||||
|
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_contactpersons_tenant_deleted", "contactpersons", ["tenant_id", "deleted_at"])
|
||||||
|
op.create_index("ix_contactpersons_contact", "contactpersons", ["contact_id"])
|
||||||
|
op.create_index("ix_contactpersons_email", "contactpersons", ["email"])
|
||||||
|
|
||||||
|
# 6. Add FK columns to contacts that reference contactpersons
|
||||||
|
op.add_column("contacts", sa.Column("default_person_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True))
|
||||||
|
op.add_column("contacts", sa.Column("admin_contactperson_id", UUID(as_uuid=True), sa.ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("contacts")
|
||||||
|
op.drop_table("contactpersons")
|
||||||
@@ -6,7 +6,7 @@ from app.models.attachment import Attachment
|
|||||||
from app.models.audit import AuditLog, DeletionLog
|
from app.models.audit import AuditLog, DeletionLog
|
||||||
from app.models.auth import ApiToken, PasswordResetToken
|
from app.models.auth import ApiToken, PasswordResetToken
|
||||||
from app.models.company import Company
|
from app.models.company import Company
|
||||||
from app.models.contact import CompanyContact, Contact
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.models.currency import Currency
|
from app.models.currency import Currency
|
||||||
from app.models.group import Group, UserGroup
|
from app.models.group import Group, UserGroup
|
||||||
from app.models.notification import Notification, NotificationPreference, NotificationType
|
from app.models.notification import Notification, NotificationPreference, NotificationType
|
||||||
@@ -37,7 +37,7 @@ __all__ = [
|
|||||||
"ApiToken",
|
"ApiToken",
|
||||||
"Company",
|
"Company",
|
||||||
"Contact",
|
"Contact",
|
||||||
"CompanyContact",
|
"ContactPerson",
|
||||||
"Currency",
|
"Currency",
|
||||||
"TaxRate",
|
"TaxRate",
|
||||||
"Sequence",
|
"Sequence",
|
||||||
|
|||||||
+9
-47
@@ -1,51 +1,13 @@
|
|||||||
"""Company model — with soft-delete, FTS tsvector, and tenant scoping."""
|
"""Backward-compat shim — Company is now Contact with type='company'.
|
||||||
|
|
||||||
from __future__ import annotations
|
This module re-exports Contact as Company for code that still imports
|
||||||
|
from app.models.company. The old companies table no longer exists;
|
||||||
|
all company data lives in the contacts table with type='company'.
|
||||||
|
"""
|
||||||
|
|
||||||
import uuid
|
from app.models.contact import Contact, ContactPerson
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import Computed, DateTime, ForeignKey, Index, String, Text
|
# Backward-compat: Company is now just a Contact with type='company'
|
||||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
Company = Contact
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
__all__ = ["Company", "ContactPerson"]
|
||||||
|
|
||||||
|
|
||||||
class Company(Base, TenantMixin):
|
|
||||||
"""Company entity with full-text search support."""
|
|
||||||
|
|
||||||
__tablename__ = "companies"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_companies_tenant_deleted", "tenant_id", "deleted_at"),
|
|
||||||
Index("ix_companies_tenant_name", "tenant_id", "name"),
|
|
||||||
Index("ix_companies_industry", "tenant_id", "industry"),
|
|
||||||
Index("ix_companies_search_vec", "search_tsv", postgresql_using="gin"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
account_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
|
||||||
industry: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
|
||||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
# FTS vector
|
|
||||||
search_tsv: Mapped[Any] = mapped_column(
|
|
||||||
TSVECTOR,
|
|
||||||
Computed(
|
|
||||||
"to_tsvector('english', coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || coalesce(industry, ''))",
|
|
||||||
persisted=True,
|
|
||||||
),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|||||||
+191
-28
@@ -1,45 +1,161 @@
|
|||||||
"""Contact and CompanyContact (N:M join) models."""
|
"""Unified Contact model — company or person, with inline addresses.
|
||||||
|
|
||||||
|
Based on Rentman's contact model: a single table with type field
|
||||||
|
('company' or 'person'). ContactPerson is a 1:N child for
|
||||||
|
ansprechpartner (company employees / contact persons).
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Computed,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
Float,
|
||||||
|
JSON,
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.db import Base, TenantMixin
|
from app.core.db import Base, TenantMixin
|
||||||
|
|
||||||
|
|
||||||
class Contact(Base, TenantMixin):
|
class Contact(Base, TenantMixin):
|
||||||
"""Contact person entity — can be linked to multiple companies via CompanyContact."""
|
"""Unified contact entity — can be a company or a person.
|
||||||
|
|
||||||
|
type='company': name is the company name, firstname/surname empty.
|
||||||
|
type='person': firstname/surname are the person's name, name empty.
|
||||||
|
Both types can have contactpersons (1:N) and inline addresses
|
||||||
|
(mailing, visit, invoice).
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "contacts"
|
__tablename__ = "contacts"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
Index("ix_contacts_tenant_name", "tenant_id", "last_name", "first_name"),
|
Index("ix_contacts_tenant_type", "tenant_id", "type"),
|
||||||
Index("ix_contacts_email", "email"),
|
Index("ix_contacts_tenant_name", "tenant_id", "name"),
|
||||||
|
Index("ix_contacts_tenant_displayname", "tenant_id", "displayname"),
|
||||||
|
Index("ix_contacts_email", "email_1"),
|
||||||
|
Index("ix_contacts_code", "code"),
|
||||||
|
Index("ix_contacts_search_vec", "search_tsv", postgresql_using="gin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
)
|
)
|
||||||
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
# ── Identity & Type ──
|
||||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
|
||||||
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
|
||||||
position: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
department: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
surfix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
ext_name_line: Mapped[str | None] = mapped_column(String(255), nullable=True) # additional name line / subtitle
|
||||||
|
gender: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
|
||||||
|
# ── Customer / Accounting ──
|
||||||
|
code: Mapped[str | None] = mapped_column(String(100), nullable=True) # customer number
|
||||||
|
accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
vendor_accounting_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# ── Mailing Address (inline) ──
|
||||||
|
mailing_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
mailing_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
mailing_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
mailing_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mailing_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
mailing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
mailing_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mailing_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mailing_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── Visit Address (inline) ──
|
||||||
|
visit_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
visit_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
visit_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
visit_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
visit_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
visit_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
visit_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
visit_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# ── Invoice Address (inline) ──
|
||||||
|
invoice_street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
invoice_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
invoice_unit_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
invoice_district: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
invoice_extra_address_line: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
invoice_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
invoice_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
invoice_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
invoice_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── General country ──
|
||||||
|
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── Communication ──
|
||||||
|
phone_1: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
phone_2: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
email_1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
email_2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# ── Financial & Tax ──
|
||||||
|
vat_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # USt-IdNr.
|
||||||
|
fiscal_code: Mapped[str | None] = mapped_column(String(50), nullable=True) # Steuernummer
|
||||||
|
commerce_code: Mapped[str | None] = mapped_column(String(100), nullable=True) # Handelsregister
|
||||||
|
purchase_number: Mapped[str | None] = mapped_column(String(100), nullable=True) # Bestellnummer
|
||||||
|
bic: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
bank_account: Mapped[str | None] = mapped_column(String(50), nullable=True) # IBAN
|
||||||
|
|
||||||
|
# ── Discounts ──
|
||||||
|
discount_crew: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
discount_transport: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
discount_rental: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
discount_sale: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
discount_subrent: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
discount_total: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||||
|
|
||||||
|
# ── Geo ──
|
||||||
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
|
# ── Notes & Warnings ──
|
||||||
|
projectnote: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
projectnote_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
contact_warning: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(500), nullable=True) # comma-separated
|
||||||
|
image: Mapped[str | None] = mapped_column(Text, nullable=True) # logo/image URL or base64
|
||||||
|
|
||||||
|
# ── Default contact persons (self-referential via contactpersons table) ──
|
||||||
|
default_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
admin_contactperson_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("contactpersons.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Custom fields ──
|
||||||
|
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# ── FTS ──
|
||||||
|
search_tsv: Mapped[Any] = mapped_column(
|
||||||
|
TSVECTOR,
|
||||||
|
Computed(
|
||||||
|
"to_tsvector('german', coalesce(name, '') || ' ' || coalesce(displayname, '') || ' ' || coalesce(firstname, '') || ' ' || coalesce(surname, '') || ' ' || coalesce(email_1, '') || ' ' || coalesce(email_2, '') || ' ' || coalesce(code, '') || ' ' || coalesce(phone_1, '') || ' ' || coalesce(phone_2, '') || ' ' || coalesce(mailing_city, '') || ' ' || coalesce(mailing_postalcode, '') || ' ' || coalesce(tags, ''))",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Audit ──
|
||||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
@@ -47,25 +163,72 @@ class Contact(Base, TenantMixin):
|
|||||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Relationships ──
|
||||||
|
contact_persons: Mapped[list[ContactPerson]] = relationship(
|
||||||
|
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
|
||||||
|
)
|
||||||
|
|
||||||
class CompanyContact(Base, TenantMixin):
|
|
||||||
"""N:M join table between Company and Contact."""
|
|
||||||
|
|
||||||
__tablename__ = "company_contacts"
|
class ContactPerson(Base, TenantMixin):
|
||||||
|
"""Ansprechpartner — 1:N child of a Contact.
|
||||||
|
|
||||||
|
Represents a person working at / associated with a company contact.
|
||||||
|
Has its own address and communication fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "contactpersons"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("company_id", "contact_id", "tenant_id", name="uq_company_contact_tenant"),
|
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
|
||||||
Index("ix_cc_company", "company_id"),
|
Index("ix_contactpersons_contact", "contact_id"),
|
||||||
Index("ix_cc_contact", "contact_id"),
|
Index("ix_contactpersons_email", "email"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
)
|
)
|
||||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
PGUUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False
|
# ── Parent contact ──
|
||||||
)
|
|
||||||
contact_id: Mapped[uuid.UUID] = mapped_column(
|
contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
|
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
role_at_company: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
# ── Name ──
|
||||||
|
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
middle_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
lastname: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
function: Mapped[str | None] = mapped_column(String(255), nullable=True) # position/role
|
||||||
|
|
||||||
|
# ── Communication ──
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
mobilephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# ── Own address ──
|
||||||
|
street: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||||
|
|
||||||
|
# ── Other ──
|
||||||
|
tags: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
custom: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# ── Audit ──
|
||||||
|
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Relationship ──
|
||||||
|
contact: Mapped[Contact] = relationship(
|
||||||
|
back_populates="contact_persons", foreign_keys=[contact_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Keep old names for backward compat during migration
|
||||||
|
CompanyContact = None # deprecated — replaced by ContactPerson 1:N
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ async def deep_analysis(
|
|||||||
extended_context["contact"] = _serialize_row(contact) if contact else None
|
extended_context["contact"] = _serialize_row(contact) if contact else None
|
||||||
|
|
||||||
elif entity_type == "company":
|
elif entity_type == "company":
|
||||||
from app.models.company import Company, CompanyContact
|
from app.models.contact import Contact as Company
|
||||||
|
|
||||||
comp_result = await db.execute(
|
comp_result = await db.execute(
|
||||||
select(Company)
|
select(Company)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ from app.core.cache import get_cache
|
|||||||
from app.core.db import create_db_session, get_session_factory
|
from app.core.db import create_db_session, get_session_factory
|
||||||
from app.core.notifications import create_notification
|
from app.core.notifications import create_notification
|
||||||
from app.models.audit import AuditLog
|
from app.models.audit import AuditLog
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact as Company
|
||||||
from app.models.contact import CompanyContact, Contact
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.plugins.builtins.ai_proactive.models import (
|
from app.plugins.builtins.ai_proactive.models import (
|
||||||
ContextLog,
|
ContextLog,
|
||||||
ProactiveSettings,
|
ProactiveSettings,
|
||||||
@@ -177,9 +177,9 @@ async def gather_context(
|
|||||||
|
|
||||||
# Company via company_contacts
|
# Company via company_contacts
|
||||||
cc_result = await db.execute(
|
cc_result = await db.execute(
|
||||||
select(CompanyContact)
|
select(ContactPerson)
|
||||||
.where(CompanyContact.contact_id == entity_id)
|
.where(ContactPerson.contact_id == entity_id)
|
||||||
.where(CompanyContact.tenant_id == tenant_id)
|
.where(ContactPerson.tenant_id == tenant_id)
|
||||||
.limit(5)
|
.limit(5)
|
||||||
)
|
)
|
||||||
companies: list[dict[str, Any]] = []
|
companies: list[dict[str, Any]] = []
|
||||||
@@ -279,9 +279,9 @@ async def gather_context(
|
|||||||
|
|
||||||
# Contacts via company_contacts
|
# Contacts via company_contacts
|
||||||
cc_result = await db.execute(
|
cc_result = await db.execute(
|
||||||
select(CompanyContact)
|
select(ContactPerson)
|
||||||
.where(CompanyContact.company_id == entity_id)
|
.where(ContactPerson.contact_id == entity_id)
|
||||||
.where(CompanyContact.tenant_id == tenant_id)
|
.where(ContactPerson.tenant_id == tenant_id)
|
||||||
)
|
)
|
||||||
contacts: list[dict[str, Any]] = []
|
contacts: list[dict[str, Any]] = []
|
||||||
for cc in cc_result.scalars().all():
|
for cc in cc_result.scalars().all():
|
||||||
|
|||||||
+96
-145
@@ -1,4 +1,4 @@
|
|||||||
"""Contact routes — CRUD, N:M, soft-delete, GDPR hard-delete, streaming CSV export."""
|
"""Unified contact routes — CRUD, contactpersons, FTS search, export, soft-delete."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,13 +8,16 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.deps import require_permission
|
from app.deps import require_permission
|
||||||
from app.models.contact import Contact
|
from app.schemas.contact import (
|
||||||
from app.schemas.contact import ContactCreate, ContactUpdate
|
ContactCreate,
|
||||||
|
ContactUpdate,
|
||||||
|
ContactPersonCreate,
|
||||||
|
ContactPersonUpdate,
|
||||||
|
)
|
||||||
from app.services import contact_service
|
from app.services import contact_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
router = APIRouter(prefix="/api/v1/contacts", tags=["contacts"])
|
||||||
@@ -25,120 +28,35 @@ async def list_contacts(
|
|||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
sort_by: str = Query("last_name"),
|
type: str | None = Query(None, pattern="^(company|person)$"),
|
||||||
|
sort_by: str = Query("displayname"),
|
||||||
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
sort_order: str = Query("asc", pattern="^(asc|desc)$"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:read")),
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
):
|
):
|
||||||
"""List contacts with pagination and optional search.
|
"""List contacts with pagination, FTS search, type filter, sorting."""
|
||||||
|
|
||||||
page_size is capped at 100 — values >100 return 422.
|
|
||||||
"""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
result = await contact_service.list_contacts(
|
return await contact_service.list_contacts(
|
||||||
db,
|
db, tenant_id,
|
||||||
tenant_id,
|
page=page, page_size=page_size, search=search,
|
||||||
page=page,
|
contact_type=type, sort_by=sort_by, sort_order=sort_order,
|
||||||
page_size=page_size,
|
|
||||||
search=search,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_order=sort_order,
|
|
||||||
resolved_perms=current_user,
|
resolved_perms=current_user,
|
||||||
)
|
)
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/export")
|
@router.get("/export")
|
||||||
async def export_contacts(
|
async def export_contacts(
|
||||||
format: str = Query("csv", pattern="^(csv)$"),
|
format: str = Query("csv", pattern="^(csv)$"),
|
||||||
|
type: str | None = Query(None, pattern="^(company|person)$"),
|
||||||
search: str | None = Query(None),
|
search: str | None = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:read")),
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
):
|
):
|
||||||
"""Stream contacts as CSV (not buffered — uses StreamingResponse).
|
"""Stream contacts as CSV."""
|
||||||
|
|
||||||
For large exports (>1000 records), an ARQ background job should be started
|
|
||||||
with a notification on completion. This endpoint streams directly for
|
|
||||||
immediate download.
|
|
||||||
"""
|
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
from app.core.db import get_session_factory
|
csv_data = await contact_service.export_contacts_csv(db, tenant_id, contact_type=type, search=search)
|
||||||
|
|
||||||
async def generate_csv():
|
|
||||||
"""Async generator yielding CSV rows one at a time (streaming).
|
|
||||||
Creates its own DB session to avoid using the request-scoped session
|
|
||||||
which gets closed after the endpoint function returns.
|
|
||||||
"""
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
|
|
||||||
# Write header row
|
|
||||||
header = [
|
|
||||||
"id",
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"email",
|
|
||||||
"phone",
|
|
||||||
"mobile",
|
|
||||||
"position",
|
|
||||||
"department",
|
|
||||||
"linkedin_url",
|
|
||||||
"notes",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
]
|
|
||||||
writer.writerow(header)
|
|
||||||
yield output.getvalue()
|
|
||||||
output.seek(0)
|
|
||||||
output.truncate(0)
|
|
||||||
|
|
||||||
# Stream rows in batches to avoid loading all into memory
|
|
||||||
batch_size = 500
|
|
||||||
offset = 0
|
|
||||||
factory = get_session_factory()
|
|
||||||
async with factory() as session:
|
|
||||||
while True:
|
|
||||||
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))
|
|
||||||
)
|
|
||||||
base = base.order_by(Contact.last_name.asc()).offset(offset).limit(batch_size)
|
|
||||||
result = await session.execute(base)
|
|
||||||
contacts = result.scalars().all()
|
|
||||||
if not contacts:
|
|
||||||
break
|
|
||||||
|
|
||||||
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 "",
|
|
||||||
c.linkedin_url or "",
|
|
||||||
c.notes or "",
|
|
||||||
c.created_at.isoformat() if c.created_at else "",
|
|
||||||
c.updated_at.isoformat() if c.updated_at else "",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
yield output.getvalue()
|
|
||||||
output.seek(0)
|
|
||||||
output.truncate(0)
|
|
||||||
offset += batch_size
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
generate_csv(),
|
io.StringIO(csv_data),
|
||||||
media_type="text/csv",
|
media_type="text/csv",
|
||||||
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
|
headers={"Content-Disposition": "attachment; filename=contacts.csv"},
|
||||||
)
|
)
|
||||||
@@ -150,12 +68,10 @@ async def create_contact(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:write")),
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
):
|
):
|
||||||
"""Create a contact. Optionally link to companies via company_ids array."""
|
"""Create a new contact (company or person)."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
data = body.model_dump()
|
|
||||||
return await contact_service.create_contact(db, tenant_id, user_id, data)
|
return await contact_service.create_contact(db, tenant_id, user_id, data)
|
||||||
|
|
||||||
|
|
||||||
@@ -165,19 +81,12 @@ async def get_contact(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:read")),
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
):
|
):
|
||||||
"""Get a single contact with companies array. Cross-tenant returns 404."""
|
"""Get a single contact with contact_persons."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
try:
|
try:
|
||||||
cid = uuid.UUID(contact_id)
|
return await contact_service.get_contact(db, tenant_id, contact_id)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
data = await contact_service.get_contact_detail(db, tenant_id, cid, resolved_perms=current_user)
|
|
||||||
if data is None:
|
|
||||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{contact_id}")
|
@router.put("/{contact_id}")
|
||||||
@@ -190,47 +99,89 @@ async def update_contact(
|
|||||||
"""Update a contact."""
|
"""Update a contact."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cid = uuid.UUID(contact_id)
|
return await contact_service.update_contact(db, tenant_id, user_id, contact_id, data)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
|
||||||
result = await contact_service.update_contact(db, tenant_id, user_id, cid, data)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{contact_id}")
|
@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_contact(
|
async def delete_contact(
|
||||||
contact_id: str,
|
contact_id: str,
|
||||||
gdpr: bool = Query(False),
|
hard: bool = Query(False, description="GDPR hard-delete"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(require_permission("contacts:write")),
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
):
|
):
|
||||||
"""Delete a contact. Default: soft-delete. Use gdpr=true for hard-delete with deletion_log."""
|
"""Soft-delete (or hard-delete with ?hard=true) a contact."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
if hard:
|
||||||
|
await contact_service.hard_delete_contact(db, tenant_id, contact_id)
|
||||||
|
else:
|
||||||
|
await contact_service.delete_contact(db, tenant_id, contact_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── ContactPersons ──
|
||||||
|
|
||||||
|
@router.get("/{contact_id}/persons")
|
||||||
|
async def list_contact_persons(
|
||||||
|
contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:read")),
|
||||||
|
):
|
||||||
|
"""List all contact persons for a contact."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
return await contact_service.list_contact_persons(db, tenant_id, contact_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{contact_id}/persons", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_contact_person(
|
||||||
|
contact_id: str,
|
||||||
|
body: ContactPersonCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
"""Add a contact person to a contact."""
|
||||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
user_id = uuid.UUID(current_user["user_id"])
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cid = uuid.UUID(contact_id)
|
return await contact_service.create_contact_person(db, tenant_id, user_id, contact_id, data)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
400, detail={"detail": "Invalid contact_id", "code": "invalid_id"}
|
|
||||||
) from None
|
|
||||||
|
|
||||||
if gdpr:
|
|
||||||
deleted = await contact_service.gdpr_hard_delete_contact(db, tenant_id, user_id, cid)
|
|
||||||
else:
|
|
||||||
deleted = await contact_service.soft_delete_contact(db, tenant_id, user_id, cid)
|
|
||||||
|
|
||||||
if not deleted:
|
@router.put("/{contact_id}/persons/{person_id}")
|
||||||
raise HTTPException(404, detail={"detail": "Contact not found", "code": "not_found"})
|
async def update_contact_person(
|
||||||
|
contact_id: str,
|
||||||
|
person_id: str,
|
||||||
|
body: ContactPersonUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
"""Update a contact person."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
user_id = uuid.UUID(current_user["user_id"])
|
||||||
|
data = body.model_dump(exclude_none=True)
|
||||||
|
try:
|
||||||
|
return await contact_service.update_contact_person(db, tenant_id, user_id, contact_id, person_id, data)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
|
@router.delete("/{contact_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_contact_person(
|
||||||
|
contact_id: str,
|
||||||
|
person_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(require_permission("contacts:write")),
|
||||||
|
):
|
||||||
|
"""Delete a contact person."""
|
||||||
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||||
|
try:
|
||||||
|
await contact_service.delete_contact_person(db, tenant_id, contact_id, person_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|||||||
+270
-30
@@ -1,52 +1,291 @@
|
|||||||
"""Contact schemas — create, update, read, list."""
|
"""Unified contact schemas — create, update, read, list, contactpersons."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class ContactCreate(BaseModel):
|
# ── ContactPerson ──
|
||||||
first_name: str = Field(..., min_length=1, max_length=100)
|
|
||||||
last_name: str = Field(..., min_length=1, max_length=100)
|
class ContactPersonCreate(BaseModel):
|
||||||
|
firstname: str | None = Field(None, max_length=100)
|
||||||
|
middle_name: str | None = Field(None, max_length=100)
|
||||||
|
lastname: str | None = Field(None, max_length=100)
|
||||||
|
function: str | None = Field(None, max_length=255)
|
||||||
|
phone: str | None = Field(None, max_length=50)
|
||||||
|
mobilephone: str | None = Field(None, max_length=50)
|
||||||
email: str | None = Field(None, max_length=255)
|
email: str | None = Field(None, max_length=255)
|
||||||
phone: str | None = Field(None, max_length=30)
|
street: str | None = Field(None, max_length=255)
|
||||||
mobile: str | None = Field(None, max_length=30)
|
number: str | None = Field(None, max_length=20)
|
||||||
position: str | None = Field(None, max_length=100)
|
postalcode: str | None = Field(None, max_length=20)
|
||||||
department: str | None = Field(None, max_length=100)
|
city: str | None = Field(None, max_length=100)
|
||||||
linkedin_url: str | None = Field(None, max_length=500)
|
state: str | None = Field(None, max_length=100)
|
||||||
notes: str | None = None
|
country: str | None = Field(None, max_length=2)
|
||||||
company_ids: list[str] | None = None
|
tags: str | None = Field(None, max_length=500)
|
||||||
|
custom: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPersonUpdate(BaseModel):
|
||||||
|
firstname: str | None = Field(None, max_length=100)
|
||||||
|
middle_name: str | None = Field(None, max_length=100)
|
||||||
|
lastname: str | None = Field(None, max_length=100)
|
||||||
|
function: str | None = Field(None, max_length=255)
|
||||||
|
phone: str | None = Field(None, max_length=50)
|
||||||
|
mobilephone: str | None = Field(None, max_length=50)
|
||||||
|
email: str | None = Field(None, max_length=255)
|
||||||
|
street: str | None = Field(None, max_length=255)
|
||||||
|
number: str | None = Field(None, max_length=20)
|
||||||
|
postalcode: str | None = Field(None, max_length=20)
|
||||||
|
city: str | None = Field(None, max_length=100)
|
||||||
|
state: str | None = Field(None, max_length=100)
|
||||||
|
country: str | None = Field(None, max_length=2)
|
||||||
|
tags: str | None = Field(None, max_length=500)
|
||||||
|
custom: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactPersonResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
contact_id: str
|
||||||
|
displayname: str
|
||||||
|
firstname: str | None = None
|
||||||
|
middle_name: str | None = None
|
||||||
|
lastname: str | None = None
|
||||||
|
function: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
mobilephone: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
street: str | None = None
|
||||||
|
number: str | None = None
|
||||||
|
postalcode: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
tags: str | None = None
|
||||||
|
custom: dict | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Contact ──
|
||||||
|
|
||||||
|
class ContactCreate(BaseModel):
|
||||||
|
type: str = Field("company", pattern="^(company|person)$")
|
||||||
|
name: str | None = Field(None, max_length=255)
|
||||||
|
firstname: str | None = Field(None, max_length=100)
|
||||||
|
surname: str | None = Field(None, max_length=100)
|
||||||
|
surfix: str | None = Field(None, max_length=50)
|
||||||
|
ext_name_line: str | None = Field(None, max_length=255)
|
||||||
|
gender: str | None = Field(None, max_length=20)
|
||||||
|
code: str | None = Field(None, max_length=100)
|
||||||
|
accounting_code: str | None = Field(None, max_length=100)
|
||||||
|
vendor_accounting_code: str | None = Field(None, max_length=100)
|
||||||
|
# Mailing
|
||||||
|
mailing_street: str | None = Field(None, max_length=255)
|
||||||
|
mailing_number: str | None = Field(None, max_length=20)
|
||||||
|
mailing_unit_number: str | None = Field(None, max_length=50)
|
||||||
|
mailing_district: str | None = Field(None, max_length=100)
|
||||||
|
mailing_extra_address_line: str | None = Field(None, max_length=255)
|
||||||
|
mailing_postalcode: str | None = Field(None, max_length=20)
|
||||||
|
mailing_city: str | None = Field(None, max_length=100)
|
||||||
|
mailing_state: str | None = Field(None, max_length=100)
|
||||||
|
mailing_country: str | None = Field(None, max_length=2)
|
||||||
|
# Visit
|
||||||
|
visit_street: str | None = Field(None, max_length=255)
|
||||||
|
visit_number: str | None = Field(None, max_length=20)
|
||||||
|
visit_unit_number: str | None = Field(None, max_length=50)
|
||||||
|
visit_district: str | None = Field(None, max_length=100)
|
||||||
|
visit_extra_address_line: str | None = Field(None, max_length=255)
|
||||||
|
visit_postalcode: str | None = Field(None, max_length=20)
|
||||||
|
visit_city: str | None = Field(None, max_length=100)
|
||||||
|
visit_state: str | None = Field(None, max_length=100)
|
||||||
|
# Invoice
|
||||||
|
invoice_street: str | None = Field(None, max_length=255)
|
||||||
|
invoice_number: str | None = Field(None, max_length=20)
|
||||||
|
invoice_unit_number: str | None = Field(None, max_length=50)
|
||||||
|
invoice_district: str | None = Field(None, max_length=100)
|
||||||
|
invoice_extra_address_line: str | None = Field(None, max_length=255)
|
||||||
|
invoice_postalcode: str | None = Field(None, max_length=20)
|
||||||
|
invoice_city: str | None = Field(None, max_length=100)
|
||||||
|
invoice_state: str | None = Field(None, max_length=100)
|
||||||
|
invoice_country: str | None = Field(None, max_length=2)
|
||||||
|
# General
|
||||||
|
country: str | None = Field(None, max_length=2)
|
||||||
|
# Communication
|
||||||
|
phone_1: str | None = Field(None, max_length=50)
|
||||||
|
phone_2: str | None = Field(None, max_length=50)
|
||||||
|
email_1: str | None = Field(None, max_length=255)
|
||||||
|
email_2: str | None = Field(None, max_length=255)
|
||||||
|
website: str | None = Field(None, max_length=500)
|
||||||
|
# Financial
|
||||||
|
vat_code: str | None = Field(None, max_length=50)
|
||||||
|
fiscal_code: str | None = Field(None, max_length=50)
|
||||||
|
commerce_code: str | None = Field(None, max_length=100)
|
||||||
|
purchase_number: str | None = Field(None, max_length=100)
|
||||||
|
bic: str | None = Field(None, max_length=50)
|
||||||
|
bank_account: str | None = Field(None, max_length=50)
|
||||||
|
# Discounts
|
||||||
|
discount_crew: float = 0
|
||||||
|
discount_transport: float = 0
|
||||||
|
discount_rental: float = 0
|
||||||
|
discount_sale: float = 0
|
||||||
|
discount_subrent: float = 0
|
||||||
|
discount_total: float = 0
|
||||||
|
# Geo
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
# Notes
|
||||||
|
projectnote: str | None = None
|
||||||
|
projectnote_title: str | None = Field(None, max_length=255)
|
||||||
|
contact_warning: str | None = None
|
||||||
|
tags: str | None = Field(None, max_length=500)
|
||||||
|
image: str | None = None
|
||||||
|
# Custom
|
||||||
|
custom: dict | None = None
|
||||||
|
# Contact persons (optional inline create)
|
||||||
|
contact_persons: list[ContactPersonCreate] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContactUpdate(BaseModel):
|
class ContactUpdate(BaseModel):
|
||||||
first_name: str | None = Field(None, min_length=1, max_length=100)
|
type: str | None = Field(None, pattern="^(company|person)$")
|
||||||
last_name: str | None = Field(None, min_length=1, max_length=100)
|
name: str | None = Field(None, max_length=255)
|
||||||
email: str | None = Field(None, max_length=255)
|
firstname: str | None = Field(None, max_length=100)
|
||||||
phone: str | None = Field(None, max_length=30)
|
surname: str | None = Field(None, max_length=100)
|
||||||
mobile: str | None = Field(None, max_length=30)
|
surfix: str | None = Field(None, max_length=50)
|
||||||
position: str | None = Field(None, max_length=100)
|
ext_name_line: str | None = Field(None, max_length=255)
|
||||||
department: str | None = Field(None, max_length=100)
|
gender: str | None = Field(None, max_length=20)
|
||||||
linkedin_url: str | None = Field(None, max_length=500)
|
code: str | None = Field(None, max_length=100)
|
||||||
notes: str | None = None
|
accounting_code: str | None = Field(None, max_length=100)
|
||||||
|
vendor_accounting_code: str | None = Field(None, max_length=100)
|
||||||
|
mailing_street: str | None = Field(None, max_length=255)
|
||||||
|
mailing_number: str | None = Field(None, max_length=20)
|
||||||
|
mailing_unit_number: str | None = Field(None, max_length=50)
|
||||||
|
mailing_district: str | None = Field(None, max_length=100)
|
||||||
|
mailing_extra_address_line: str | None = Field(None, max_length=255)
|
||||||
|
mailing_postalcode: str | None = Field(None, max_length=20)
|
||||||
|
mailing_city: str | None = Field(None, max_length=100)
|
||||||
|
mailing_state: str | None = Field(None, max_length=100)
|
||||||
|
mailing_country: str | None = Field(None, max_length=2)
|
||||||
|
visit_street: str | None = Field(None, max_length=255)
|
||||||
|
visit_number: str | None = Field(None, max_length=20)
|
||||||
|
visit_unit_number: str | None = Field(None, max_length=50)
|
||||||
|
visit_district: str | None = Field(None, max_length=100)
|
||||||
|
visit_extra_address_line: str | None = Field(None, max_length=255)
|
||||||
|
visit_postalcode: str | None = Field(None, max_length=20)
|
||||||
|
visit_city: str | None = Field(None, max_length=100)
|
||||||
|
visit_state: str | None = Field(None, max_length=100)
|
||||||
|
invoice_street: str | None = Field(None, max_length=255)
|
||||||
|
invoice_number: str | None = Field(None, max_length=20)
|
||||||
|
invoice_unit_number: str | None = Field(None, max_length=50)
|
||||||
|
invoice_district: str | None = Field(None, max_length=100)
|
||||||
|
invoice_extra_address_line: str | None = Field(None, max_length=255)
|
||||||
|
invoice_postalcode: str | None = Field(None, max_length=20)
|
||||||
|
invoice_city: str | None = Field(None, max_length=100)
|
||||||
|
invoice_state: str | None = Field(None, max_length=100)
|
||||||
|
invoice_country: str | None = Field(None, max_length=2)
|
||||||
|
country: str | None = Field(None, max_length=2)
|
||||||
|
phone_1: str | None = Field(None, max_length=50)
|
||||||
|
phone_2: str | None = Field(None, max_length=50)
|
||||||
|
email_1: str | None = Field(None, max_length=255)
|
||||||
|
email_2: str | None = Field(None, max_length=255)
|
||||||
|
website: str | None = Field(None, max_length=500)
|
||||||
|
vat_code: str | None = Field(None, max_length=50)
|
||||||
|
fiscal_code: str | None = Field(None, max_length=50)
|
||||||
|
commerce_code: str | None = Field(None, max_length=100)
|
||||||
|
purchase_number: str | None = Field(None, max_length=100)
|
||||||
|
bic: str | None = Field(None, max_length=50)
|
||||||
|
bank_account: str | None = Field(None, max_length=50)
|
||||||
|
discount_crew: float | None = None
|
||||||
|
discount_transport: float | None = None
|
||||||
|
discount_rental: float | None = None
|
||||||
|
discount_sale: float | None = None
|
||||||
|
discount_subrent: float | None = None
|
||||||
|
discount_total: float | None = None
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
projectnote: str | None = None
|
||||||
|
projectnote_title: str | None = Field(None, max_length=255)
|
||||||
|
contact_warning: str | None = None
|
||||||
|
tags: str | None = Field(None, max_length=500)
|
||||||
|
image: str | None = None
|
||||||
|
custom: dict | None = None
|
||||||
|
default_person_id: str | None = None
|
||||||
|
admin_contactperson_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContactResponse(BaseModel):
|
class ContactResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
first_name: str
|
type: str
|
||||||
last_name: str
|
displayname: str
|
||||||
email: str | None = None
|
name: str | None = None
|
||||||
phone: str | None = None
|
firstname: str | None = None
|
||||||
mobile: str | None = None
|
surname: str | None = None
|
||||||
position: str | None = None
|
surfix: str | None = None
|
||||||
department: str | None = None
|
ext_name_line: str | None = None
|
||||||
linkedin_url: str | None = None
|
gender: str | None = None
|
||||||
notes: str | None = None
|
code: str | None = None
|
||||||
|
accounting_code: str | None = None
|
||||||
|
vendor_accounting_code: str | None = None
|
||||||
|
# Addresses (flat)
|
||||||
|
mailing_street: str | None = None
|
||||||
|
mailing_number: str | None = None
|
||||||
|
mailing_unit_number: str | None = None
|
||||||
|
mailing_district: str | None = None
|
||||||
|
mailing_extra_address_line: str | None = None
|
||||||
|
mailing_postalcode: str | None = None
|
||||||
|
mailing_city: str | None = None
|
||||||
|
mailing_state: str | None = None
|
||||||
|
mailing_country: str | None = None
|
||||||
|
visit_street: str | None = None
|
||||||
|
visit_number: str | None = None
|
||||||
|
visit_unit_number: str | None = None
|
||||||
|
visit_district: str | None = None
|
||||||
|
visit_extra_address_line: str | None = None
|
||||||
|
visit_postalcode: str | None = None
|
||||||
|
visit_city: str | None = None
|
||||||
|
visit_state: str | None = None
|
||||||
|
invoice_street: str | None = None
|
||||||
|
invoice_number: str | None = None
|
||||||
|
invoice_unit_number: str | None = None
|
||||||
|
invoice_district: str | None = None
|
||||||
|
invoice_extra_address_line: str | None = None
|
||||||
|
invoice_postalcode: str | None = None
|
||||||
|
invoice_city: str | None = None
|
||||||
|
invoice_state: str | None = None
|
||||||
|
invoice_country: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
phone_1: str | None = None
|
||||||
|
phone_2: str | None = None
|
||||||
|
email_1: str | None = None
|
||||||
|
email_2: str | None = None
|
||||||
|
website: str | None = None
|
||||||
|
vat_code: str | None = None
|
||||||
|
fiscal_code: str | None = None
|
||||||
|
commerce_code: str | None = None
|
||||||
|
purchase_number: str | None = None
|
||||||
|
bic: str | None = None
|
||||||
|
bank_account: str | None = None
|
||||||
|
discount_crew: float = 0
|
||||||
|
discount_transport: float = 0
|
||||||
|
discount_rental: float = 0
|
||||||
|
discount_sale: float = 0
|
||||||
|
discount_subrent: float = 0
|
||||||
|
discount_total: float = 0
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
projectnote: str | None = None
|
||||||
|
projectnote_title: str | None = None
|
||||||
|
contact_warning: str | None = None
|
||||||
|
tags: str | None = None
|
||||||
|
image: str | None = None
|
||||||
|
custom: dict | None = None
|
||||||
|
default_person_id: str | None = None
|
||||||
|
admin_contactperson_id: str | None = None
|
||||||
created_at: str | None = None
|
created_at: str | None = None
|
||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ContactDetailResponse(ContactResponse):
|
class ContactDetailResponse(ContactResponse):
|
||||||
companies: list[dict] = Field(default_factory=list)
|
contact_persons: list[ContactPersonResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ContactListResponse(BaseModel):
|
class ContactListResponse(BaseModel):
|
||||||
@@ -56,6 +295,7 @@ class ContactListResponse(BaseModel):
|
|||||||
page_size: int
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
# Keep old names for compat
|
||||||
class CompanyLinkRequest(BaseModel):
|
class CompanyLinkRequest(BaseModel):
|
||||||
role_at_company: str | None = Field(None, max_length=100)
|
role_at_company: str | None = Field(None, max_length=100)
|
||||||
is_primary: bool = False
|
is_primary: bool = False
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ async def migrate_existing_addresses(db: AsyncSession) -> int:
|
|||||||
"""Migrate existing address fields from companies/contacts to Address records.
|
"""Migrate existing address fields from companies/contacts to Address records.
|
||||||
Called during migration 0013. Returns count of created addresses.
|
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
|
from app.models.contact import Contact
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.ai.llm_client import get_llm_client
|
|||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.core.auth import check_permission
|
from app.core.auth import check_permission
|
||||||
from app.models.ai_conversation import AIConversation, AIMessage
|
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.contact import Contact
|
||||||
from app.models.workflow import Workflow
|
from app.models.workflow import Workflow
|
||||||
|
|
||||||
|
|||||||
+182
-370
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
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.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.models.company import Company
|
|
||||||
from app.models.contact import CompanyContact, Contact
|
|
||||||
|
|
||||||
|
|
||||||
def _company_to_dict(c: Company, include_contacts: bool = False) -> dict[str, Any]:
|
def _company_to_dict(c: Contact) -> dict:
|
||||||
"""Serialize a Company ORM object to dict."""
|
"""Serialize a company (Contact with type=company) to dict."""
|
||||||
data: dict[str, Any] = {
|
return {
|
||||||
"id": str(c.id),
|
"id": str(c.id),
|
||||||
"name": c.name,
|
"name": c.name or "",
|
||||||
"account_number": c.account_number,
|
"account_number": c.code or "",
|
||||||
"industry": c.industry,
|
"industry": (c.custom or {}).get("industry") if c.custom else None,
|
||||||
"phone": c.phone,
|
"phone": c.phone_1,
|
||||||
"email": c.email,
|
"email": c.email_1,
|
||||||
"website": c.website,
|
"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,
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
"updated_at": c.updated_at.isoformat() if c.updated_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(
|
async def list_companies(
|
||||||
@@ -51,411 +42,232 @@ async def list_companies(
|
|||||||
sort_by: str = "name",
|
sort_by: str = "name",
|
||||||
sort_order: str = "asc",
|
sort_order: str = "asc",
|
||||||
resolved_perms: dict | None = None,
|
resolved_perms: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict:
|
||||||
"""List companies with pagination, FTS search, industry filter, and sorting."""
|
"""List companies (contacts with type='company')."""
|
||||||
page = max(1, page)
|
base = select(Contact).where(
|
||||||
page_size = max(1, min(100, page_size))
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company",
|
||||||
base = select(Company).where(
|
Contact.deleted_at.is_(None),
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
Company.deleted_at.is_(None),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if industry:
|
|
||||||
base = base.where(Company.industry == industry)
|
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
pattern = f"%{search}%"
|
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", 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))
|
|
||||||
|
|
||||||
count_q = select(func.count()).select_from(base.subquery())
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
total_result = await db.execute(count_q)
|
total = (await db.execute(count_q)).scalar() or 0
|
||||||
total = total_result.scalar_one()
|
|
||||||
|
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
|
offset = (page - 1) * page_size
|
||||||
paginated = base.offset(offset).limit(page_size)
|
base = base.offset(offset).limit(page_size)
|
||||||
result = await db.execute(paginated)
|
|
||||||
|
result = await db.execute(base)
|
||||||
companies = result.scalars().all()
|
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 {
|
return {
|
||||||
"items": items,
|
"items": [_company_to_dict(c) for c in companies],
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_company_detail(
|
async def create_company(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||||
tenant_id: uuid.UUID,
|
) -> dict:
|
||||||
company_id: uuid.UUID,
|
"""Create a company (contact with type='company')."""
|
||||||
resolved_perms: dict | None = None,
|
# Map old fields to new
|
||||||
) -> dict[str, Any] | None:
|
contact_data = {
|
||||||
"""Get a single company with its contacts array."""
|
"type": "company",
|
||||||
q = select(Company).where(
|
"name": data.get("name", ""),
|
||||||
Company.id == company_id,
|
"code": data.get("account_number"),
|
||||||
Company.tenant_id == tenant_id,
|
"phone_1": data.get("phone"),
|
||||||
Company.deleted_at.is_(None),
|
"email_1": data.get("email"),
|
||||||
)
|
"website": data.get("website"),
|
||||||
result = await db.execute(q)
|
"displayname": data.get("name", ""),
|
||||||
company = result.scalar_one_or_none()
|
}
|
||||||
if company is None:
|
# Store industry/description in custom
|
||||||
return None
|
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)
|
contact = Contact(tenant_id=tenant_id, created_by=user_id, updated_by=user_id, **contact_data)
|
||||||
contacts_q = (
|
db.add(contact)
|
||||||
select(Contact, CompanyContact)
|
await db.flush()
|
||||||
.join(CompanyContact, CompanyContact.contact_id == Contact.id)
|
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(
|
.where(
|
||||||
CompanyContact.company_id == company_id,
|
Contact.id == uuid.UUID(company_id),
|
||||||
CompanyContact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company",
|
||||||
Contact.deleted_at.is_(None),
|
Contact.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
contacts_result = await db.execute(contacts_q)
|
result = await db.execute(q)
|
||||||
contacts_list = []
|
contact = result.scalar_one_or_none()
|
||||||
for contact, link in contacts_result.all():
|
if not contact:
|
||||||
contacts_list.append(
|
raise ValueError("Company not found")
|
||||||
{
|
|
||||||
"id": str(contact.id),
|
data = _company_to_dict(contact)
|
||||||
"first_name": contact.first_name,
|
data["contacts"] = [
|
||||||
"last_name": contact.last_name,
|
{
|
||||||
"email": contact.email,
|
"id": str(cp.id),
|
||||||
"phone": contact.phone,
|
"first_name": cp.firstname or "",
|
||||||
"position": contact.position,
|
"last_name": cp.lastname or "",
|
||||||
"role_at_company": link.role_at_company,
|
"email": cp.email,
|
||||||
"is_primary": link.is_primary,
|
"phone": cp.phone,
|
||||||
}
|
"position": cp.function,
|
||||||
)
|
}
|
||||||
data["contacts"] = contacts_list
|
for cp in (contact.contact_persons or [])
|
||||||
if resolved_perms is not None:
|
if cp.deleted_at is None
|
||||||
data = _filter_company_fields(data, resolved_perms)
|
]
|
||||||
return data
|
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(
|
async def update_company(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, data: dict
|
||||||
tenant_id: uuid.UUID,
|
) -> dict:
|
||||||
user_id: uuid.UUID,
|
"""Update a company."""
|
||||||
company_id: uuid.UUID,
|
q = select(Contact).where(
|
||||||
data: dict[str, Any],
|
Contact.id == uuid.UUID(company_id),
|
||||||
) -> dict[str, Any] | None:
|
Contact.tenant_id == tenant_id,
|
||||||
"""Update a company (partial update) and audit-log changes."""
|
Contact.type == "company",
|
||||||
q = select(Company).where(
|
Contact.deleted_at.is_(None),
|
||||||
Company.id == company_id,
|
|
||||||
Company.tenant_id == tenant_id,
|
|
||||||
Company.deleted_at.is_(None),
|
|
||||||
)
|
)
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
company = result.scalar_one_or_none()
|
contact = result.scalar_one_or_none()
|
||||||
if company is None:
|
if not contact:
|
||||||
return None
|
raise ValueError("Company not found")
|
||||||
|
|
||||||
changes: dict[str, Any] = {}
|
if "name" in data:
|
||||||
all_fields = (
|
contact.name = data["name"]
|
||||||
"name", "account_number", "industry", "phone", "email",
|
contact.displayname = data["name"]
|
||||||
"website", "description",
|
if "account_number" in data:
|
||||||
)
|
contact.code = data["account_number"]
|
||||||
for field in all_fields:
|
if "phone" in data:
|
||||||
if field in data and data[field] is not None:
|
contact.phone_1 = data["phone"]
|
||||||
old_val = getattr(company, field)
|
if "email" in data:
|
||||||
changes[field] = {"old": old_val, "new": data[field]}
|
contact.email_1 = data["email"]
|
||||||
setattr(company, field, data[field])
|
if "website" in data:
|
||||||
company.updated_by = user_id
|
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.flush()
|
||||||
await db.refresh(company)
|
return _company_to_dict(contact)
|
||||||
await log_audit(db, tenant_id, user_id, "update", "company", company_id, changes=changes)
|
|
||||||
return _company_to_dict(company)
|
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete_company(
|
async def soft_delete_company(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, company_id: str, cascade: bool = True
|
||||||
tenant_id: uuid.UUID,
|
|
||||||
user_id: uuid.UUID,
|
|
||||||
company_id: uuid.UUID,
|
|
||||||
cascade: bool = False,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Soft-delete a company. If cascade=True, also soft-delete linked CompanyContact rows."""
|
"""Soft-delete a company."""
|
||||||
q = select(Company).where(
|
from datetime import datetime, timezone
|
||||||
Company.id == company_id,
|
q = select(Contact).where(
|
||||||
Company.tenant_id == tenant_id,
|
Contact.id == uuid.UUID(company_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,
|
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
|
Contact.type == "company",
|
||||||
Contact.deleted_at.is_(None),
|
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)
|
result = await db.execute(q)
|
||||||
link = result.scalar_one_or_none()
|
contact = result.scalar_one_or_none()
|
||||||
if link is None:
|
if not contact:
|
||||||
return False
|
return False
|
||||||
|
contact.deleted_at = datetime.now(timezone.utc)
|
||||||
await db.delete(link)
|
|
||||||
await db.flush()
|
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
|
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(
|
async def export_companies_xlsx(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, industry: str | None = None, search: str | None = None
|
||||||
tenant_id: uuid.UUID,
|
|
||||||
industry: str | None = None,
|
|
||||||
search: str | None = None,
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Export companies as XLSX bytes."""
|
"""Export companies as XLSX."""
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
|
|
||||||
base = select(Company).where(
|
base = select(Contact).where(
|
||||||
Company.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
Company.deleted_at.is_(None),
|
Contact.type == "company",
|
||||||
|
Contact.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
if industry:
|
|
||||||
base = base.where(Company.industry == industry)
|
|
||||||
if search:
|
if search:
|
||||||
pattern = f"%{search}%"
|
base = base.where(Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search)))
|
||||||
base = base.where(
|
base = base.order_by(Contact.name)
|
||||||
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)
|
result = await db.execute(base)
|
||||||
companies = result.scalars().all()
|
companies = result.scalars().all()
|
||||||
|
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "Companies"
|
ws.title = "Companies"
|
||||||
headers = [
|
ws.append(["Name", "Account Number", "Industry", "Phone", "Email", "Website"])
|
||||||
"id", "name", "account_number", "industry", "phone", "email", "website", "description",
|
|
||||||
]
|
|
||||||
ws.append(headers)
|
|
||||||
for c in companies:
|
for c in companies:
|
||||||
ws.append(
|
custom = c.custom or {}
|
||||||
[str(c.id), c.name, c.account_number or "", c.industry or "",
|
ws.append([
|
||||||
c.phone or "", c.email or "", c.website or "", c.description or ""]
|
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()
|
buf = io.BytesIO()
|
||||||
wb.save(buf)
|
wb.save(buf)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
async def get_company_emails(
|
async def link_contact(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, company_id: str, contact_id: str
|
||||||
tenant_id: uuid.UUID,
|
) -> dict:
|
||||||
company_id: uuid.UUID,
|
"""Link a contact person to a company (create ContactPerson)."""
|
||||||
) -> list[dict[str, Any]]:
|
# In new model, contact_id is used to create a ContactPerson under the company contact
|
||||||
"""Get emails for a company (mail plugin inactive — returns empty array)."""
|
# This is a compat shim — the old API expected a separate Contact entity
|
||||||
q = select(Company).where(
|
# Now we just create a ContactPerson linked to the company
|
||||||
Company.id == company_id,
|
cp = ContactPerson(
|
||||||
Company.tenant_id == tenant_id,
|
tenant_id=tenant_id,
|
||||||
Company.deleted_at.is_(None),
|
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)
|
result = await db.execute(q)
|
||||||
if result.scalar_one_or_none() is None:
|
cp = result.scalar_one_or_none()
|
||||||
return []
|
if not cp:
|
||||||
return []
|
return False
|
||||||
|
cp.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
return True
|
||||||
|
|||||||
+340
-233
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
|
||||||
from typing import Any
|
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.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.core.audit import log_audit, log_deletion
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.models.company import Company
|
|
||||||
from app.models.contact import CompanyContact, Contact
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Serialize a Contact ORM object to dict."""
|
||||||
data: dict[str, Any] = {
|
return {
|
||||||
"id": str(c.id),
|
"id": str(c.id),
|
||||||
"first_name": c.first_name,
|
"type": c.type,
|
||||||
"last_name": c.last_name,
|
"displayname": c.displayname,
|
||||||
"email": c.email,
|
"name": c.name,
|
||||||
"phone": c.phone,
|
"firstname": c.firstname,
|
||||||
"mobile": c.mobile,
|
"surname": c.surname,
|
||||||
"position": c.position,
|
"surfix": c.surfix,
|
||||||
"department": c.department,
|
"ext_name_line": c.ext_name_line,
|
||||||
"linkedin_url": c.linkedin_url,
|
"gender": c.gender,
|
||||||
"notes": c.notes,
|
"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,
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
"updated_at": c.updated_at.isoformat() if c.updated_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
|
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(
|
async def list_contacts(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
sort_by: str = "last_name",
|
contact_type: str | None = None,
|
||||||
|
sort_by: str = "displayname",
|
||||||
sort_order: str = "asc",
|
sort_order: str = "asc",
|
||||||
resolved_perms: dict | None = None,
|
resolved_perms: dict | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict:
|
||||||
"""List contacts with pagination and optional search."""
|
"""List contacts with pagination, FTS search, type filter, sorting."""
|
||||||
page = max(1, page)
|
|
||||||
page_size = max(1, min(100, page_size))
|
|
||||||
|
|
||||||
base = select(Contact).where(
|
base = select(Contact).where(
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.tenant_id == tenant_id,
|
||||||
Contact.deleted_at.is_(None),
|
Contact.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if contact_type:
|
||||||
|
base = base.where(Contact.type == contact_type)
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
pattern = f"%{search}%"
|
|
||||||
base = base.where(
|
base = base.where(
|
||||||
(Contact.first_name.ilike(pattern))
|
Contact.search_tsv.op("@@")(func.plainto_tsquery("german", search))
|
||||||
| (Contact.last_name.ilike(pattern))
|
|
||||||
| (Contact.email.ilike(pattern))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sort_col = getattr(Contact, sort_by, Contact.last_name)
|
# Count
|
||||||
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())
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
total_result = await db.execute(count_q)
|
total = (await db.execute(count_q)).scalar() or 0
|
||||||
total = total_result.scalar_one()
|
|
||||||
|
|
||||||
|
# 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
|
offset = (page - 1) * page_size
|
||||||
paginated = base.offset(offset).limit(page_size)
|
base = base.offset(offset).limit(page_size)
|
||||||
result = await db.execute(paginated)
|
|
||||||
|
result = await db.execute(base)
|
||||||
contacts = result.scalars().all()
|
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 {
|
return {
|
||||||
"items": items,
|
"items": [_serialize_contact(c) for c in contacts],
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_contact_detail(
|
async def get_contact(db: AsyncSession, tenant_id: uuid.UUID, contact_id: str) -> dict:
|
||||||
db: AsyncSession,
|
"""Get a single contact with contact_persons."""
|
||||||
tenant_id: uuid.UUID,
|
q = (
|
||||||
contact_id: uuid.UUID,
|
select(Contact)
|
||||||
resolved_perms: dict | None = None,
|
.options(selectinload(Contact.contact_persons))
|
||||||
) -> dict[str, Any] | None:
|
.where(
|
||||||
"""Get a single contact with its linked companies array."""
|
Contact.id == uuid.UUID(contact_id),
|
||||||
q = select(Contact).where(
|
Contact.tenant_id == tenant_id,
|
||||||
Contact.id == contact_id,
|
Contact.deleted_at.is_(None),
|
||||||
Contact.tenant_id == tenant_id,
|
)
|
||||||
Contact.deleted_at.is_(None),
|
|
||||||
)
|
)
|
||||||
result = await db.execute(q)
|
result = await db.execute(q)
|
||||||
contact = result.scalar_one_or_none()
|
contact = result.scalar_one_or_none()
|
||||||
if contact is None:
|
if not contact:
|
||||||
return None
|
raise ValueError("Contact not found")
|
||||||
|
return _serialize_contact_detail(contact)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def create_contact(
|
async def create_contact(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict
|
||||||
tenant_id: uuid.UUID,
|
) -> dict:
|
||||||
user_id: uuid.UUID,
|
"""Create a new contact."""
|
||||||
data: dict[str, Any],
|
data["displayname"] = _compute_displayname(data)
|
||||||
) -> dict[str, Any]:
|
contact_persons_data = data.pop("contact_persons", None)
|
||||||
"""Create a new contact, optionally linking to companies via company_ids."""
|
|
||||||
contact = Contact(
|
contact = Contact(
|
||||||
tenant_id=tenant_id,
|
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,
|
created_by=user_id,
|
||||||
updated_by=user_id,
|
updated_by=user_id,
|
||||||
|
**{k: v for k, v in data.items() if hasattr(Contact, k)},
|
||||||
)
|
)
|
||||||
db.add(contact)
|
db.add(contact)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
company_ids = data.get("company_ids")
|
# Create inline contact persons
|
||||||
linked_companies = []
|
if contact_persons_data:
|
||||||
if company_ids:
|
for cp_data in contact_persons_data:
|
||||||
for cid_str in company_ids:
|
cp_data["displayname"] = " ".join(
|
||||||
try:
|
p for p in [cp_data.get("firstname"), cp_data.get("lastname")] if p
|
||||||
cid = uuid.UUID(cid_str) if isinstance(cid_str, str) else cid_str
|
).strip()
|
||||||
except (ValueError, TypeError):
|
cp = ContactPerson(
|
||||||
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(
|
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
company_id=cid,
|
|
||||||
contact_id=contact.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(link)
|
db.add(cp)
|
||||||
linked_companies.append(str(cid))
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
await db.refresh(contact)
|
# Load with contact_persons
|
||||||
await log_audit(
|
q = select(Contact).options(selectinload(Contact.contact_persons)).where(Contact.id == contact.id)
|
||||||
db,
|
result = await db.execute(q)
|
||||||
tenant_id,
|
contact = result.scalar_one()
|
||||||
user_id,
|
return _serialize_contact_detail(contact)
|
||||||
"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(
|
async def update_contact(
|
||||||
db: AsyncSession,
|
db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, contact_id: str, data: dict
|
||||||
tenant_id: uuid.UUID,
|
) -> dict:
|
||||||
user_id: uuid.UUID,
|
"""Update a contact."""
|
||||||
contact_id: uuid.UUID,
|
q = (
|
||||||
data: dict[str, Any],
|
select(Contact)
|
||||||
) -> dict[str, Any] | None:
|
.options(selectinload(Contact.contact_persons))
|
||||||
"""Update a contact (partial update) and audit-log changes."""
|
.where(
|
||||||
q = select(Contact).where(
|
Contact.id == uuid.UUID(contact_id),
|
||||||
Contact.id == contact_id,
|
Contact.tenant_id == tenant_id,
|
||||||
Contact.tenant_id == tenant_id,
|
Contact.deleted_at.is_(None),
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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.delete(contact)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
await log_deletion(
|
|
||||||
db,
|
# ── ContactPerson CRUD ──
|
||||||
tenant_id,
|
|
||||||
user_id,
|
async def list_contact_persons(
|
||||||
"contact",
|
db: AsyncSession, tenant_id: uuid.UUID, contact_id: str
|
||||||
contact_id,
|
) -> list[dict]:
|
||||||
snapshot,
|
"""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()
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.audit import log_audit
|
from app.core.audit import log_audit
|
||||||
from app.models.company import Company
|
from app.models.contact import Contact, ContactPerson as Company
|
||||||
from app.models.contact import Contact
|
from app.models.contact import Contact, ContactPerson
|
||||||
from app.services.company_service import _company_to_dict
|
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
|
# Expected CSV columns for each entity type
|
||||||
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
|
COMPANY_COLUMNS = ["name", "industry", "phone", "email", "website", "description"]
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
-- Migration 0021: Unified contacts model
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS company_contacts CASCADE;
|
||||||
|
DROP TABLE IF EXISTS contacts CASCADE;
|
||||||
|
DROP TABLE IF EXISTS companies CASCADE;
|
||||||
|
|
||||||
|
-- Create contacts table (without contactperson FKs first)
|
||||||
|
CREATE TABLE contacts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL DEFAULT 'company',
|
||||||
|
displayname VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
name VARCHAR(255),
|
||||||
|
firstname VARCHAR(100),
|
||||||
|
surname VARCHAR(100),
|
||||||
|
surfix VARCHAR(50),
|
||||||
|
ext_name_line VARCHAR(255),
|
||||||
|
gender VARCHAR(20),
|
||||||
|
code VARCHAR(100),
|
||||||
|
accounting_code VARCHAR(100),
|
||||||
|
vendor_accounting_code VARCHAR(100),
|
||||||
|
mailing_street VARCHAR(255),
|
||||||
|
mailing_number VARCHAR(20),
|
||||||
|
mailing_unit_number VARCHAR(50),
|
||||||
|
mailing_district VARCHAR(100),
|
||||||
|
mailing_extra_address_line VARCHAR(255),
|
||||||
|
mailing_postalcode VARCHAR(20),
|
||||||
|
mailing_city VARCHAR(100),
|
||||||
|
mailing_state VARCHAR(100),
|
||||||
|
mailing_country VARCHAR(2),
|
||||||
|
visit_street VARCHAR(255),
|
||||||
|
visit_number VARCHAR(20),
|
||||||
|
visit_unit_number VARCHAR(50),
|
||||||
|
visit_district VARCHAR(100),
|
||||||
|
visit_extra_address_line VARCHAR(255),
|
||||||
|
visit_postalcode VARCHAR(20),
|
||||||
|
visit_city VARCHAR(100),
|
||||||
|
visit_state VARCHAR(100),
|
||||||
|
invoice_street VARCHAR(255),
|
||||||
|
invoice_number VARCHAR(20),
|
||||||
|
invoice_unit_number VARCHAR(50),
|
||||||
|
invoice_district VARCHAR(100),
|
||||||
|
invoice_extra_address_line VARCHAR(255),
|
||||||
|
invoice_postalcode VARCHAR(20),
|
||||||
|
invoice_city VARCHAR(100),
|
||||||
|
invoice_state VARCHAR(100),
|
||||||
|
invoice_country VARCHAR(2),
|
||||||
|
country VARCHAR(2),
|
||||||
|
phone_1 VARCHAR(50),
|
||||||
|
phone_2 VARCHAR(50),
|
||||||
|
email_1 VARCHAR(255),
|
||||||
|
email_2 VARCHAR(255),
|
||||||
|
website VARCHAR(500),
|
||||||
|
vat_code VARCHAR(50),
|
||||||
|
fiscal_code VARCHAR(50),
|
||||||
|
commerce_code VARCHAR(100),
|
||||||
|
purchase_number VARCHAR(100),
|
||||||
|
bic VARCHAR(50),
|
||||||
|
bank_account VARCHAR(50),
|
||||||
|
discount_crew FLOAT NOT NULL DEFAULT 0,
|
||||||
|
discount_transport FLOAT NOT NULL DEFAULT 0,
|
||||||
|
discount_rental FLOAT NOT NULL DEFAULT 0,
|
||||||
|
discount_sale FLOAT NOT NULL DEFAULT 0,
|
||||||
|
discount_subrent FLOAT NOT NULL DEFAULT 0,
|
||||||
|
discount_total FLOAT NOT NULL DEFAULT 0,
|
||||||
|
latitude FLOAT,
|
||||||
|
longitude FLOAT,
|
||||||
|
projectnote TEXT,
|
||||||
|
projectnote_title VARCHAR(255),
|
||||||
|
contact_warning TEXT,
|
||||||
|
tags VARCHAR(500),
|
||||||
|
image TEXT,
|
||||||
|
custom JSON DEFAULT '{}'::json,
|
||||||
|
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('german',
|
||||||
|
coalesce(name, '') || ' ' ||
|
||||||
|
coalesce(displayname, '') || ' ' ||
|
||||||
|
coalesce(firstname, '') || ' ' ||
|
||||||
|
coalesce(surname, '') || ' ' ||
|
||||||
|
coalesce(email_1, '') || ' ' ||
|
||||||
|
coalesce(email_2, '') || ' ' ||
|
||||||
|
coalesce(code, '') || ' ' ||
|
||||||
|
coalesce(phone_1, '') || ' ' ||
|
||||||
|
coalesce(phone_2, '') || ' ' ||
|
||||||
|
coalesce(mailing_city, '') || ' ' ||
|
||||||
|
coalesce(mailing_postalcode, '') || ' ' ||
|
||||||
|
coalesce(tags, '')
|
||||||
|
)
|
||||||
|
) STORED,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_contacts_tenant_deleted ON contacts (tenant_id, deleted_at);
|
||||||
|
CREATE INDEX ix_contacts_tenant_type ON contacts (tenant_id, type);
|
||||||
|
CREATE INDEX ix_contacts_tenant_name ON contacts (tenant_id, name);
|
||||||
|
CREATE INDEX ix_contacts_tenant_displayname ON contacts (tenant_id, displayname);
|
||||||
|
CREATE INDEX ix_contacts_email ON contacts (email_1);
|
||||||
|
CREATE INDEX ix_contacts_code ON contacts (code);
|
||||||
|
CREATE INDEX ix_contacts_search_vec ON contacts USING gin (search_tsv);
|
||||||
|
|
||||||
|
-- Create contactpersons table
|
||||||
|
CREATE TABLE contactpersons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
displayname VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
firstname VARCHAR(100),
|
||||||
|
middle_name VARCHAR(100),
|
||||||
|
lastname VARCHAR(100),
|
||||||
|
function VARCHAR(255),
|
||||||
|
phone VARCHAR(50),
|
||||||
|
mobilephone VARCHAR(50),
|
||||||
|
email VARCHAR(255),
|
||||||
|
street VARCHAR(255),
|
||||||
|
number VARCHAR(20),
|
||||||
|
postalcode VARCHAR(20),
|
||||||
|
city VARCHAR(100),
|
||||||
|
state VARCHAR(100),
|
||||||
|
country VARCHAR(2),
|
||||||
|
tags VARCHAR(500),
|
||||||
|
custom JSON DEFAULT '{}'::json,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_contactpersons_tenant_deleted ON contactpersons (tenant_id, deleted_at);
|
||||||
|
CREATE INDEX ix_contactpersons_contact ON contactpersons (contact_id);
|
||||||
|
CREATE INDEX ix_contactpersons_email ON contactpersons (email);
|
||||||
|
|
||||||
|
-- Add FK columns to contacts referencing contactpersons
|
||||||
|
ALTER TABLE contacts ADD COLUMN default_person_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE contacts ADD COLUMN admin_contactperson_id UUID REFERENCES contactpersons(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Update alembic version
|
||||||
|
UPDATE alembic_version SET version_num = '0021_unified_contacts';
|
||||||
Reference in New Issue
Block a user