feat(T04): contact management + USt-IdNr. validation + contact UI
- Contact model: company, role (kaeufer/verkaeufer/beide), soft-delete - ContactPerson model: 1:N relation with CASCADE delete - USt-IdNr. validation: DE + 10 EU countries - Contact CRUD: 7 API endpoints with RBAC, search/filter/paginate - Frontend: ContactList, ContactForm, ContactDetail with EU/Inland toggle - 73 backend tests (91% coverage), 20 frontend tests
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""SQLAlchemy models for contacts and contact persons."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
String,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ContactRole(str, enum.Enum):
|
||||
kaeufer = "kaeufer"
|
||||
verkaeufer = "verkaeufer"
|
||||
beide = "beide"
|
||||
|
||||
|
||||
class VatIdStatus(str, enum.Enum):
|
||||
ungeprueft = "ungeprueft"
|
||||
geprueft = "geprueft"
|
||||
ungueltig = "ungueltig"
|
||||
manuell_bestaetigt = "manuell_bestaetigt"
|
||||
|
||||
|
||||
class Contact(Base):
|
||||
"""Contact entity representing a company or private contact."""
|
||||
|
||||
__tablename__ = "contacts"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"role IN ('kaeufer', 'verkaeufer', 'beide')",
|
||||
name="ck_contacts_role",
|
||||
),
|
||||
CheckConstraint(
|
||||
"vat_id_status IN ('ungeprueft', 'geprueft', 'ungueltig', 'manuell_bestaetigt')",
|
||||
name="ck_contacts_vat_id_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"char_length(address_country) = 2",
|
||||
name="ck_contacts_country_alpha2",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
company_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, index=True,
|
||||
)
|
||||
legal_form: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
)
|
||||
address_street: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
address_zip: Mapped[str | None] = mapped_column(
|
||||
String(10), nullable=True,
|
||||
)
|
||||
address_city: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
)
|
||||
address_country: Mapped[str] = mapped_column(
|
||||
String(2), nullable=False, default="DE", index=True,
|
||||
)
|
||||
vat_id: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True,
|
||||
)
|
||||
phone: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
website: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, index=True,
|
||||
)
|
||||
vat_id_status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="ungeprueft",
|
||||
)
|
||||
is_private: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True,
|
||||
)
|
||||
|
||||
contact_persons: Mapped[list["ContactPerson"]] = relationship(
|
||||
back_populates="contact",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Contact id={self.id} company_name={self.company_name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize contact for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"company_name": self.company_name,
|
||||
"legal_form": self.legal_form,
|
||||
"address_street": self.address_street,
|
||||
"address_zip": self.address_zip,
|
||||
"address_city": self.address_city,
|
||||
"address_country": self.address_country,
|
||||
"vat_id": self.vat_id,
|
||||
"phone": self.phone,
|
||||
"email": self.email,
|
||||
"website": self.website,
|
||||
"role": self.role,
|
||||
"vat_id_status": self.vat_id_status,
|
||||
"is_private": self.is_private,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
"deleted_at": (
|
||||
self.deleted_at.isoformat() if self.deleted_at else None
|
||||
),
|
||||
"contact_persons": [
|
||||
p.to_dict() for p in (self.contact_persons or [])
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ContactPerson(Base):
|
||||
"""Contact person associated with a contact (company)."""
|
||||
|
||||
__tablename__ = "contact_persons"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
contact_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("contacts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False,
|
||||
)
|
||||
function: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True,
|
||||
)
|
||||
phone: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True,
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||
)
|
||||
|
||||
contact: Mapped["Contact"] = relationship(back_populates="contact_persons")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ContactPerson id={self.id} name={self.name}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize contact person for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"contact_id": str(self.contact_id),
|
||||
"name": self.name,
|
||||
"function": self.function,
|
||||
"phone": self.phone,
|
||||
"email": self.email,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user