feat(#357): Paket 6 — Contact-Model ins ContactsPlugin (physischer Move + PEP-562-Lazy-Re-Export-Bruecke, ALEMBIC_OWNED_TABLES gegen Schema-Dual-Ownership, outbox-Vorbestands-Fix in models/__init__)
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-29 02:48:31 +02:00
parent df85fdcb5b
commit 67c0dcd34c
7 changed files with 429 additions and 253 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
3. **ContactEditModal File-Level** — ✅ erledigt (2026-08-28): Geister-Test nach §10 gelöscht. Ursache bewiesen: ContactEditModal.tsx (349 Z.) wurde in db4701b (BUG-080/082) gelöscht, Test blieb → Vitest 'Failed to resolve import' (1 failed, no tests). Ersatz ContactEditForm.tsx lebt und wird von ContactsList/ContactDetailPage genutzt 3. **ContactEditModal File-Level** — ✅ erledigt (2026-08-28): Geister-Test nach §10 gelöscht. Ursache bewiesen: ContactEditModal.tsx (349 Z.) wurde in db4701b (BUG-080/082) gelöscht, Test blieb → Vitest 'Failed to resolve import' (1 failed, no tests). Ersatz ContactEditForm.tsx lebt und wird von ContactsList/ContactDetailPage genutzt
4. **custom_field_definitions generisch machen** — ✅ erledigt (2026-08-29): W4b-Muster komplett angewendet. Route: 422-Entity-Validierung + 403-Owner-Modul-Check (contacts:read etc.) auf create/list/update/delete, ACL-Loch gefixt (delete übergab user_id nie → 500 für alle Nicht-Admins), PermissionError→403-Handler. Shape-Fix: Backend lieferte Array, alle 6 Frontend-Consumer lesen `data.items` → CustomFields-UI zeigte seit jeher leer; jetzt `{items,total}` (CustomFieldDefinitionListResponse). entity_permission_service: Plural-Ableitungs-Fix ('workflow'→workflows:read statt Phantom contacts:read; 'address'→addresses:read via +es), unregister_entity_model räumt ENTITY_PLUGIN_OWNERS mit auf (Lifecycle-Leak), zentrale Helper validate_entity_type/check_entity_read_permission — saved_filters+saved_views Duplikate entfernt (Aliase, Call-Sites unverändert). Neue Suite tests/test_custom_field_definitions.py **13 Tests**: Rot bewiesen 10 failed/4 passed → Grün **25 passed** (13 cfd + 12 saved_filters-Regression), Permission-Suiten 22/22, custom_fields+lifecycle 13/13, ruff exit=0, create_app OK. 3er-Kombi-Failures (custom_fields+entity_registry+lifecycle) per Stash als identischer Vorbestand bewiesen (clean HEAD: gleiche 7 Failures — Suite-Isolation, kein Zusammenhang mit Änderung). api-documentation.md ergänzt (4 Endpoints) 4. **custom_field_definitions generisch machen** — ✅ erledigt (2026-08-29): W4b-Muster komplett angewendet. Route: 422-Entity-Validierung + 403-Owner-Modul-Check (contacts:read etc.) auf create/list/update/delete, ACL-Loch gefixt (delete übergab user_id nie → 500 für alle Nicht-Admins), PermissionError→403-Handler. Shape-Fix: Backend lieferte Array, alle 6 Frontend-Consumer lesen `data.items` → CustomFields-UI zeigte seit jeher leer; jetzt `{items,total}` (CustomFieldDefinitionListResponse). entity_permission_service: Plural-Ableitungs-Fix ('workflow'→workflows:read statt Phantom contacts:read; 'address'→addresses:read via +es), unregister_entity_model räumt ENTITY_PLUGIN_OWNERS mit auf (Lifecycle-Leak), zentrale Helper validate_entity_type/check_entity_read_permission — saved_filters+saved_views Duplikate entfernt (Aliase, Call-Sites unverändert). Neue Suite tests/test_custom_field_definitions.py **13 Tests**: Rot bewiesen 10 failed/4 passed → Grün **25 passed** (13 cfd + 12 saved_filters-Regression), Permission-Suiten 22/22, custom_fields+lifecycle 13/13, ruff exit=0, create_app OK. 3er-Kombi-Failures (custom_fields+entity_registry+lifecycle) per Stash als identischer Vorbestand bewiesen (clean HEAD: gleiche 7 Failures — Suite-Isolation, kein Zusammenhang mit Änderung). api-documentation.md ergänzt (4 Endpoints)
5. **Sidebar /contacts statische Route entfernen** — ✅ erledigt (2026-08-29): Kritikpunkt 21 zuerst erfüllt (Renderer bewiesen, DANN entfernt). PluginRouteRenderer komplett neu: verschachtelte `<Routes>` statt manueller find()-Logik — vorher bewiesen kaputt: ':id'-Patterns konnten NIE matchen (Detail-Deep-Link /contacts/abc hätte die Liste gerendert), '/contacts/dedup' wäre auf die Liste gefallen, useParams() blieb im catch-all leer. Rot 4 failed → **Grün 9/9 Tests** (inkl. :id-Match, useParams={"id":"abc-123"}-Beweis, dedup-schlägt-:id-Spezifität). STATIC_COMPONENT_MAP um 3 Contacts-Seiten ergänzt (ARCH-019: Production-Build kann Runtime-Fallback nicht laden; Named-Exports explizit, Pages haben kein default). Statische Routen /contacts, /contacts/:id, /contacts/dedup aus index.tsx entfernt + 3 ungenutzte lazy-Imports; /trash (contacts:read) und /guest/contacts unberührt. Gates: tsc OK · **Production-Build BUILD_EXIT=0 (2.76s) mit frischen Chunks ContactsList-DZ-WOyL2.js/ContactDetailPage-LZBUypcB.js/DedupMerge-H6U7AYKS.js (02:04)** · Renderer 9/9 · routePermissions 6/6 · Router 2/2 · Sidebar-Nav-Quelle bewiesen: Sidebar.tsx Z.97 flatMap menu_items (Manifest, unberührt). AppShell-Solo/Combo-Worker-Crash = dokumentierter Vorbestand (heute 3× vor Änderung reproduziert) 5. **Sidebar /contacts statische Route entfernen** — ✅ erledigt (2026-08-29): Kritikpunkt 21 zuerst erfüllt (Renderer bewiesen, DANN entfernt). PluginRouteRenderer komplett neu: verschachtelte `<Routes>` statt manueller find()-Logik — vorher bewiesen kaputt: ':id'-Patterns konnten NIE matchen (Detail-Deep-Link /contacts/abc hätte die Liste gerendert), '/contacts/dedup' wäre auf die Liste gefallen, useParams() blieb im catch-all leer. Rot 4 failed → **Grün 9/9 Tests** (inkl. :id-Match, useParams={"id":"abc-123"}-Beweis, dedup-schlägt-:id-Spezifität). STATIC_COMPONENT_MAP um 3 Contacts-Seiten ergänzt (ARCH-019: Production-Build kann Runtime-Fallback nicht laden; Named-Exports explizit, Pages haben kein default). Statische Routen /contacts, /contacts/:id, /contacts/dedup aus index.tsx entfernt + 3 ungenutzte lazy-Imports; /trash (contacts:read) und /guest/contacts unberührt. Gates: tsc OK · **Production-Build BUILD_EXIT=0 (2.76s) mit frischen Chunks ContactsList-DZ-WOyL2.js/ContactDetailPage-LZBUypcB.js/DedupMerge-H6U7AYKS.js (02:04)** · Renderer 9/9 · routePermissions 6/6 · Router 2/2 · Sidebar-Nav-Quelle bewiesen: Sidebar.tsx Z.97 flatMap menu_items (Manifest, unberührt). AppShell-Solo/Combo-Worker-Crash = dokumentierter Vorbestand (heute 3× vor Änderung reproduziert)
6. **Kontakt-Model ins ContactsPlugin**groß (~30 Import-Stellen, Alembic-Kette), bewusst zurückgestellt 6. **Kontakt-Model ins ContactsPlugin**✅ erledigt (2026-08-29): Contact/ContactPerson (258 Z.) physisch nach `app/plugins/builtins/contacts/models.py` (Mail-Vorbild). `app/models/contact.py` = PEP-562-Lazy-Re-Export-Brücke: alle 35 Import-Stellen (8 Core + 7 Plugin + 19 Tests + env.py) unverändert lauffähig; models/__init__.py Contact-Import lazy via Package-__getattr__. Rot-Lauf entlarvte ECHTEN Zirkel (conftest→core.auth→models→Shim→plugins→registry→cache→core.auth teilweise initialisiert → ImportError) — mit Lazy-__getattr__ bewiesen behoben, jedes Einstiegsschema zirkelfrei. TDD: Rot 6 failed → **Grün 9/9** (neue Suite test_contacts_model_ownership.py: Ownership, Shim-Identität, Alembic-Metadata-Integrität, Sync-Exclude, FK-Kette). sync_plugin_schema.py: ALEMBIC_OWNED_TABLES={contacts,contactpersons} verhindert Dual-Ownership (142 Alembic-Migrationen besitzen das Schema). Checker: Shim in EXEMPT_PATHS (dokumentierte Brücke), **0 Verstöße/483 Dateien**. Gates: create_app OK · ruff exit=0 · Regressionen: entity_registry 3/3, lifecycle 2/2, custom_fields 11/11, **auth solo 11/11** (Kombi-Failures = per Solo-Lauf bewiesener Isolations-Vorbestand) · **alembic upgrade head auf frischer DB OK** (0105 contacts_tsv_trigger recreated). Keine neue Migration, migration_hashes unberührt
7. **Phase L: Dokumente-Generator** — PLATFORM_ROADMAP.md 'Phase L' (L1-L5, ~9-15 Tage), user-abgestimmt, Basis: report_generator-Plugin 7. **Phase L: Dokumente-Generator** — PLATFORM_ROADMAP.md 'Phase L' (L1-L5, ~9-15 Tage), user-abgestimmt, Basis: report_generator-Plugin
**Wichtig:** AGENTS.md-Regeln zuerst lesen (§0.0 Sub-Agents nur für einfache Jobs, §0.2 auf bestehendem Code aufbauen, §10 'PROGRESS.md als Source of Truth'). **Wichtig:** AGENTS.md-Regeln zuerst lesen (§0.0 Sub-Agents nur für einfache Jobs, §0.2 auf bestehendem Code aufbauen, §10 'PROGRESS.md als Source of Truth').
+20 -1
View File
@@ -8,7 +8,11 @@ from app.models.backup import Backup
from app.models.bank_account import BankAccount from app.models.bank_account import BankAccount
from app.models.compliance import ComplianceIncident from app.models.compliance import ComplianceIncident
from app.models.consumer_inbox import ConsumerInbox from app.models.consumer_inbox import ConsumerInbox
from app.models.contact import Contact, ContactPerson
# Contact/ContactPerson: lazy via package __getattr__ (Paket 6) — the physical
# model lives in app.plugins.builtins.contacts.models; importing the plugin
# framework while app.models is still initializing caused a proven circular
# ImportError (app.core.auth -> app.models.session -> ... -> app.plugins).
from app.models.contact_folder import ContactFolder from app.models.contact_folder import ContactFolder
from app.models.contact_merge import ContactMergeHistory from app.models.contact_merge import ContactMergeHistory
from app.models.currency import Currency from app.models.currency import Currency
@@ -18,6 +22,7 @@ from app.models.entity_permission import EntityPermission
from app.models.entity_policy import EntityPolicy from app.models.entity_policy import EntityPolicy
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
from app.models.outbox import EventOutbox, OutboxDelivery
from app.models.owned_mixin import OwnedMixin from app.models.owned_mixin import OwnedMixin
from app.models.permission_delegation import PermissionDelegation from app.models.permission_delegation import PermissionDelegation
from app.models.permission_template import PermissionTemplate from app.models.permission_template import PermissionTemplate
@@ -83,3 +88,17 @@ from app.models.workspace import ( # noqa: F401
WorkspaceUser, WorkspaceUser,
WorkspaceWidget, WorkspaceWidget,
) )
# ── Lazy Contact re-export (Paket 6, #357) ──────────────────────────────────
# The physical home of Contact/ContactPerson is the ContactsPlugin
# (app.plugins.builtins.contacts.models). Resolving them lazily via package
# __getattr__ keeps ``from app.models import *`` (alembic/env.py) working
# while avoiding a plugin-framework import during app.models initialization
# (proven circular ImportError, see app/models/contact.py).
def __getattr__(name: str):
if name in {"Contact", "ContactPerson"}:
from app.models.contact import Contact, ContactPerson
return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+31 -248
View File
@@ -1,258 +1,41 @@
"""Unified Contact model — company or person, with inline addresses. """Contact model - backwards-compatibility re-export (Paket 6, #357).
Based on Rentman's contact model: a single table with type field The physical home of Contact/ContactPerson moved to the ContactsPlugin:
('company' or 'person'). ContactPerson is a 1:N child for app/plugins.builtins.contacts.models
ansprechpartner (company employees / contact persons).
This module re-exports both classes lazily (PEP 562 ``__getattr__``) so every
existing import keeps working - ``from app.models.contact import Contact``
resolves at attribute-access time:
- alembic/env.py (``from app.models import *`` -> Base.metadata stays
complete; Autogenerate never sees the tables as removed)
- Core services (worker.py, jobs.py, address_service.py, ...)
- 19 test files and scripts
Why LAZY and not a top-level import: app/models/__init__.py is imported very
early (app.core.auth imports app.models.session). A top-level plugin import
here would pull in app.plugins -> registry -> service_container -> cache ->
app.core.auth while app.core.auth is still initializing -> circular ImportError
(proven in the Paket 6 red run). With PEP 562 the plugin framework is only
touched when Contact is actually accessed, long after app.models finished
initializing - every entry order is cycle-free.
The cross-plugin checker (scripts/check_cross_plugin_imports.py) lists this
file in EXEMPT_PATHS: the re-export is the deliberate, documented bridge -
the plugin OWNS the model; the core only mirrors it for import stability.
""" """
from __future__ import annotations from __future__ import annotations
import uuid _EXPORTS = {"Contact", "ContactPerson"}
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Computed,
DateTime,
Float,
ForeignKey,
Index,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
class Contact(Base, TenantMixin, OwnedMixin): def __getattr__(name: str):
"""Unified contact entity — can be a company or a person. if name in _EXPORTS:
from app.plugins.builtins.contacts.models import Contact, ContactPerson
type='company': name is the company name, firstname/surname empty. return {"Contact": Contact, "ContactPerson": ContactPerson}[name]
type='person': firstname/surname are the person's name, name empty. raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Both types can have contactpersons (1:N) and inline addresses
(mailing, visit, invoice).
"""
__tablename__ = "contacts"
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contacts_tenant_type", "tenant_id", "type"),
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(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Identity & Type ──
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
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[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), 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
)
# ── Folder assignment ──
folder_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("contact_folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSONB, 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,
)
# ── Embedding (pgvector, 768-dim) ──
from pgvector.sqlalchemy import Vector
embedding: Mapped[Any | None] = mapped_column(
Vector(768), nullable=True, default=None
)
# ── 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
)
# ── Relationships ──
contact_persons: Mapped[list[ContactPerson]] = relationship(
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
)
class ContactPerson(Base, TenantMixin, OwnedMixin): def __dir__() -> list[str]:
"""Ansprechpartner — 1:N child of a Contact. return sorted(_EXPORTS | {"__getattr__", "__dir__"})
Represents a person working at / associated with a company contact.
Has its own address and communication fields.
"""
__tablename__ = "contactpersons"
__table_args__ = (
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contactpersons_contact", "contact_id"),
Index("ix_contactpersons_email", "email"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Parent contact ──
contact_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=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(JSONB, 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
+262
View File
@@ -0,0 +1,262 @@
"""Unified Contact model - company or person, with inline addresses.
Plugin-owned since Paket 6 (#357): this module is the physical home of the
Contact/ContactPerson ORM models. app/models/contact.py re-exports them
for backwards compatibility (Alembic env.py, Core services, tests).
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
import uuid
from decimal import Decimal
from typing import Any
from sqlalchemy import (
Computed,
DateTime,
Float,
ForeignKey,
Index,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
class Contact(Base, TenantMixin, OwnedMixin):
"""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"
indexed_at: Mapped[Any] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_contacts_tenant_code"),
UniqueConstraint("tenant_id", "accounting_code", name="uq_contacts_tenant_accounting_code"),
Index("ix_contacts_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contacts_tenant_type", "tenant_id", "type"),
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(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Identity & Type ──
type: Mapped[str] = mapped_column(String(20), nullable=False, default="company") # 'company' or 'person'
displayname: Mapped[str] = mapped_column(String(255), nullable=False, default="")
# ── Lifecycle Status (state machine: lead → qualified → customer → inactive) ──
status: Mapped[str] = mapped_column(String(30), nullable=False, default="lead", index=True)
name: Mapped[str | None] = mapped_column(String(255), nullable=True) # company name
firstname: Mapped[str | None] = mapped_column(String(100), nullable=True)
surname: Mapped[str | None] = mapped_column(String(100), nullable=True)
suffix: Mapped[str | None] = mapped_column(String(50), nullable=True) # name prefix (Dr., Prof.)
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[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_transport: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_rental: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_sale: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_subrent: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=0)
discount_total: Mapped[Decimal] = mapped_column(Numeric(5, 2), 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
)
# ── Folder assignment ──
folder_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("contact_folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
# ── Custom fields ──
custom: Mapped[dict | None] = mapped_column(JSONB, 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,
)
# ── Embedding (pgvector, 768-dim) ──
from pgvector.sqlalchemy import Vector
embedding: Mapped[Any | None] = mapped_column(
Vector(768), nullable=True, default=None
)
# ── 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
)
# ── Relationships ──
contact_persons: Mapped[list[ContactPerson]] = relationship(
back_populates="contact", cascade="all, delete-orphan", foreign_keys="ContactPerson.contact_id"
)
class ContactPerson(Base, TenantMixin, OwnedMixin):
"""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__ = (
Index("ix_contactpersons_tenant_deleted", "tenant_id", "deleted_at"),
Index("ix_contactpersons_contact", "contact_id"),
Index("ix_contactpersons_email", "email"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
# ── Parent contact ──
contact_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("contacts.id", ondelete="CASCADE"), nullable=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(JSONB, 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
+4
View File
@@ -64,6 +64,10 @@ EXEMPT_PATHS = {
PROJECT_ROOT / "app" / "plugins" / "base.py", PROJECT_ROOT / "app" / "plugins" / "base.py",
PROJECT_ROOT / "app" / "plugins" / "manifest.py", PROJECT_ROOT / "app" / "plugins" / "manifest.py",
PROJECT_ROOT / "app" / "plugins" / "migration_runner.py", PROJECT_ROOT / "app" / "plugins" / "migration_runner.py",
# Paket 6 (#357): deliberate re-export bridge — the ContactsPlugin owns the
# Contact model; app/models/contact.py only mirrors it so alembic env.py,
# Core services and tests keep their stable import path.
PROJECT_ROOT / "app" / "models" / "contact.py",
} }
# Pattern for cross-plugin imports # Pattern for cross-plugin imports
+18 -3
View File
@@ -15,10 +15,10 @@ Environment:
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import logging
import os import os
import pkgutil import pkgutil
import sys import sys
import logging
from typing import Any from typing import Any
# Ensure /app is in sys.path for container execution # Ensure /app is in sys.path for container execution
@@ -33,6 +33,16 @@ from sqlalchemy.orm import DeclarativeBase
logger = logging.getLogger("sync_plugin_schema") logger = logging.getLogger("sync_plugin_schema")
logging.basicConfig(level=logging.INFO, format="[sync] %(levelname)s: %(message)s") logging.basicConfig(level=logging.INFO, format="[sync] %(levelname)s: %(message)s")
# ── Alembic-owned tables (Paket 6, #357) ────────────────────────────────────
# Tables whose ORM model classes moved into a plugin package but whose schema
# is still owned by the Alembic migration chain (142 migrations reference
# them). The plugin schema sync must NOT manage these tables — dual ownership
# would cause schema drift between the two mechanisms.
ALEMBIC_OWNED_TABLES: set[str] = {
"contacts",
"contactpersons",
}
# ── Plugin model discovery ────────────────────────────────────────────────── # ── Plugin model discovery ──────────────────────────────────────────────────
def _import_all_plugin_models() -> list[type[DeclarativeBase]]: def _import_all_plugin_models() -> list[type[DeclarativeBase]]:
@@ -48,7 +58,7 @@ def _import_all_plugin_models() -> list[type[DeclarativeBase]]:
return models return models
# Import the builtins package to trigger model registrations # Import the builtins package to trigger model registrations
for importer, modname, ispkg in pkgutil.iter_modules(builtins_pkg.__path__): for _importer, modname, ispkg in pkgutil.iter_modules(builtins_pkg.__path__):
if not ispkg: if not ispkg:
continue continue
module_path = f"app.plugins.builtins.{modname}.models" module_path = f"app.plugins.builtins.{modname}.models"
@@ -66,7 +76,12 @@ def _import_all_plugin_models() -> list[type[DeclarativeBase]]:
# Filter to only plugin tables — we identify them by checking if the table # Filter to only plugin tables — we identify them by checking if the table
# name appears in any plugin models module. We use metadata.tables which # name appears in any plugin models module. We use metadata.tables which
# contains all registered tables. # contains all registered tables.
for table_name, table in Base.metadata.tables.items(): for table_name, _table in Base.metadata.tables.items():
# Alembic-owned tables never go through the plugin sync, even when
# their model class lives in a plugin package (Paket 6: contacts,
# contactpersons — 142 Alembic migrations own their schema).
if table_name in ALEMBIC_OWNED_TABLES:
continue
# We only process tables that are defined in plugin modules. # We only process tables that are defined in plugin modules.
# Core models are handled by alembic migrations. # Core models are handled by alembic migrations.
# We check the module of the model class. # We check the module of the model class.
+93
View File
@@ -0,0 +1,93 @@
"""Contact model ownership tests - Paket 6 (#357).
Beweist: Contact/ContactPerson leben physisch im ContactsPlugin (models.py),
app/models/contact.py ist nur noch eine Re-Export-Bruecke (Kompatibilitaet fuer
Alembic env.py, Core-Services, Tests) und die contacts/contactpersons-Tabellen
bleiben Alembic-owned (kein Plugin-Schema-Sync-Dual-Ownership).
"""
from __future__ import annotations
class TestContactModelOwnership:
"""Contact/ContactPerson sind physisch im ContactsPlugin beheimatet."""
def test_contact_class_owned_by_contacts_plugin(self):
"""Contact.__module__ zeigt auf das Plugin, nicht auf app.models."""
from app.models.contact import Contact
assert Contact.__module__ == "app.plugins.builtins.contacts.models"
def test_contact_person_class_owned_by_contacts_plugin(self):
"""ContactPerson.__module__ zeigt auf das Plugin."""
from app.models.contact import ContactPerson
assert ContactPerson.__module__ == "app.plugins.builtins.contacts.models"
def test_plugin_models_module_importable_directly(self):
"""Das Plugin-Modul ist direkt importierbar (Vorbild: mail.models)."""
from app.plugins.builtins.contacts.models import Contact, ContactPerson
assert Contact.__tablename__ == "contacts"
assert ContactPerson.__tablename__ == "contactpersons"
def test_shim_identity_same_class_objects(self):
"""Shim-Import und Plugin-Import liefern dieselbe Klasse (kein Duplikat)."""
from app.models.contact import Contact as ViaShim
from app.plugins.builtins.contacts.models import Contact as ViaPlugin
assert ViaShim is ViaPlugin
class TestAlembicMetadataIntegrity:
"""Base.metadata bleibt nach dem Move vollstaendig (Alembic-Sicherheit)."""
def test_contacts_table_in_base_metadata(self):
"""contacts/contactpersons sind in Base.metadata registriert.
env.py laedt from app.models import * - ohne funktionierenden Re-Export
wuerde Alembic-Autogenerate die Tabellen als entfernt betrachten (DROP).
"""
from app.core.db import Base
assert "contacts" in Base.metadata.tables
assert "contactpersons" in Base.metadata.tables
def test_models_package_export_unchanged(self):
"""from app.models import Contact funktioniert weiter (env.py-Signal)."""
import app.models
assert app.models.Contact is not None
assert app.models.ContactPerson is not None
class TestSyncPluginSchemaExclude:
"""contacts/contactpersons bleiben Alembic-owned, kein Plugin-Sync."""
def test_alembic_owned_tables_constant_declares_contacts(self):
"""sync_plugin_schema deklariert contacts/contactpersons als Alembic-owned.
Der __module__-Filter des Scripts wuerde nach dem Move sonst die
Alembic-verwalteten Tabellen in den Plugin-Schema-Sync-Kanal ziehen
(Dual-Ownership -> Schema-Drift gegen 142 Migrationen).
"""
from scripts.sync_plugin_schema import ALEMBIC_OWNED_TABLES
assert "contacts" in ALEMBIC_OWNED_TABLES
assert "contactpersons" in ALEMBIC_OWNED_TABLES
class TestCoreImportCompatibility:
"""Bestehende Core-Import-Stellen funktionieren ueber den Shim weiter."""
def test_core_worker_imports_contact(self):
"""app.core.worker (dokumentierter P16-Fall) importiert Contact weiter."""
from app.models.contact import Contact # noqa: F401
def test_contact_merge_fk_resolves(self):
"""FK-Kette contact_merge_history -> contacts loest sich weiter auf."""
from app.core.db import Base
merge = Base.metadata.tables["contact_merge_history"]
fk_targets = {fk.target_fullname for fk in merge.foreign_keys}
assert "contacts.id" in fk_targets