From 67c0dcd34ccf1e52b7ac9086b7dc88c942d619c9 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 29 Aug 2026 02:48:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(#357):=20Paket=206=20=E2=80=94=20Contact-M?= =?UTF-8?q?odel=20ins=20ContactsPlugin=20(physischer=20Move=20+=20PEP-562-?= =?UTF-8?q?Lazy-Re-Export-Bruecke,=20ALEMBIC=5FOWNED=5FTABLES=20gegen=20Sc?= =?UTF-8?q?hema-Dual-Ownership,=20outbox-Vorbestands-Fix=20in=20models/=5F?= =?UTF-8?q?=5Finit=5F=5F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 2 +- app/models/__init__.py | 21 +- app/models/contact.py | 279 +++--------------------- app/plugins/builtins/contacts/models.py | 262 ++++++++++++++++++++++ scripts/check_cross_plugin_imports.py | 4 + scripts/sync_plugin_schema.py | 21 +- tests/test_contacts_model_ownership.py | 93 ++++++++ 7 files changed, 429 insertions(+), 253 deletions(-) create mode 100644 app/plugins/builtins/contacts/models.py create mode 100644 tests/test_contacts_model_ownership.py diff --git a/PROGRESS.md b/PROGRESS.md index f5d624c..d94a187 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 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 `` 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 **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'). diff --git a/app/models/__init__.py b/app/models/__init__.py index cc1687e..dddbe2f 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -8,7 +8,11 @@ from app.models.backup import Backup from app.models.bank_account import BankAccount from app.models.compliance import ComplianceIncident 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_merge import ContactMergeHistory 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.group import Group, UserGroup 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.permission_delegation import PermissionDelegation from app.models.permission_template import PermissionTemplate @@ -83,3 +88,17 @@ from app.models.workspace import ( # noqa: F401 WorkspaceUser, 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}") diff --git a/app/models/contact.py b/app/models/contact.py index d3f2445..163d192 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -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 -('company' or 'person'). ContactPerson is a 1:N child for -ansprechpartner (company employees / contact persons). +The physical home of Contact/ContactPerson moved to the ContactsPlugin: + app/plugins.builtins.contacts.models + +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 -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 +_EXPORTS = {"Contact", "ContactPerson"} -class Contact(Base, TenantMixin, OwnedMixin): - """Unified contact entity — can be a company or a person. +def __getattr__(name: str): + if name in _EXPORTS: + from app.plugins.builtins.contacts.models import Contact, ContactPerson - 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" - ) + return {"Contact": Contact, "ContactPerson": ContactPerson}[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -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 - +def __dir__() -> list[str]: + return sorted(_EXPORTS | {"__getattr__", "__dir__"}) diff --git a/app/plugins/builtins/contacts/models.py b/app/plugins/builtins/contacts/models.py new file mode 100644 index 0000000..3c92e15 --- /dev/null +++ b/app/plugins/builtins/contacts/models.py @@ -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 + diff --git a/scripts/check_cross_plugin_imports.py b/scripts/check_cross_plugin_imports.py index 8177252..75b5d8b 100644 --- a/scripts/check_cross_plugin_imports.py +++ b/scripts/check_cross_plugin_imports.py @@ -64,6 +64,10 @@ EXEMPT_PATHS = { PROJECT_ROOT / "app" / "plugins" / "base.py", PROJECT_ROOT / "app" / "plugins" / "manifest.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 diff --git a/scripts/sync_plugin_schema.py b/scripts/sync_plugin_schema.py index c6b1423..2afa55c 100644 --- a/scripts/sync_plugin_schema.py +++ b/scripts/sync_plugin_schema.py @@ -15,10 +15,10 @@ Environment: from __future__ import annotations import importlib +import logging import os import pkgutil import sys -import logging from typing import Any # Ensure /app is in sys.path for container execution @@ -33,6 +33,16 @@ from sqlalchemy.orm import DeclarativeBase logger = logging.getLogger("sync_plugin_schema") 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 ────────────────────────────────────────────────── def _import_all_plugin_models() -> list[type[DeclarativeBase]]: @@ -48,7 +58,7 @@ def _import_all_plugin_models() -> list[type[DeclarativeBase]]: return models # 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: continue 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 # name appears in any plugin models module. We use metadata.tables which # 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. # Core models are handled by alembic migrations. # We check the module of the model class. diff --git a/tests/test_contacts_model_ownership.py b/tests/test_contacts_model_ownership.py new file mode 100644 index 0000000..b1866bf --- /dev/null +++ b/tests/test_contacts_model_ownership.py @@ -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