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,147 @@
|
||||
"""Pydantic schemas for contact-related request and response bodies."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
from app.utils.ust_validation import validate_vat_id
|
||||
|
||||
|
||||
ContactRole = Literal["kaeufer", "verkaeufer", "beide"]
|
||||
VatIdStatus = Literal["ungeprueft", "geprueft", "ungueltig", "manuell_bestaetigt"]
|
||||
|
||||
|
||||
class ContactPersonBase(BaseModel):
|
||||
"""Base contact person fields."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
function: Optional[str] = Field(None, max_length=100)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
|
||||
class ContactPersonCreate(ContactPersonBase):
|
||||
"""POST /api/v1/contacts/:id/persons request body."""
|
||||
pass
|
||||
|
||||
|
||||
class ContactPersonResponse(ContactPersonBase):
|
||||
"""Contact person response schema."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
contact_id: uuid.UUID
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
"""Base contact fields shared across schemas."""
|
||||
|
||||
company_name: str = Field(..., min_length=1, max_length=255)
|
||||
legal_form: Optional[str] = Field(None, max_length=50)
|
||||
address_street: Optional[str] = Field(None, max_length=255)
|
||||
address_zip: Optional[str] = Field(None, max_length=10)
|
||||
address_city: Optional[str] = Field(None, max_length=100)
|
||||
address_country: str = Field("DE", min_length=2, max_length=2)
|
||||
vat_id: Optional[str] = Field(None, max_length=20)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
role: ContactRole
|
||||
is_private: bool = False
|
||||
|
||||
@field_validator("vat_id")
|
||||
@classmethod
|
||||
def validate_vat_id_format(cls, v: str | None) -> str | None:
|
||||
"""Validate VAT ID format if provided."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not validate_vat_id(v):
|
||||
raise ValueError(f"Invalid VAT ID format: '{v}'")
|
||||
return v.strip().upper().replace(" ", "")
|
||||
|
||||
@field_validator("address_country")
|
||||
@classmethod
|
||||
def validate_country_code(cls, v: str) -> str:
|
||||
"""Normalise country code to uppercase."""
|
||||
return v.upper()
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
"""POST /api/v1/contacts request body."""
|
||||
|
||||
contact_persons: list[ContactPersonCreate] = Field(
|
||||
default_factory=list, description="Contact persons to create with the contact"
|
||||
)
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
"""PUT /api/v1/contacts/:id request body (all fields optional)."""
|
||||
|
||||
company_name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
legal_form: Optional[str] = Field(None, max_length=50)
|
||||
address_street: Optional[str] = Field(None, max_length=255)
|
||||
address_zip: Optional[str] = Field(None, max_length=10)
|
||||
address_city: Optional[str] = Field(None, max_length=100)
|
||||
address_country: Optional[str] = Field(None, min_length=2, max_length=2)
|
||||
vat_id: Optional[str] = Field(None, max_length=20)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
email: Optional[str] = Field(None, max_length=255)
|
||||
website: Optional[str] = Field(None, max_length=255)
|
||||
role: Optional[ContactRole] = None
|
||||
is_private: Optional[bool] = None
|
||||
|
||||
@field_validator("vat_id")
|
||||
@classmethod
|
||||
def validate_vat_id_format(cls, v: str | None) -> str | None:
|
||||
"""Validate VAT ID format if provided."""
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not validate_vat_id(v):
|
||||
raise ValueError(f"Invalid VAT ID format: '{v}'")
|
||||
return v.strip().upper().replace(" ", "")
|
||||
|
||||
@field_validator("address_country")
|
||||
@classmethod
|
||||
def validate_country_code(cls, v: str | None) -> str | None:
|
||||
"""Normalise country code to uppercase."""
|
||||
if v is not None:
|
||||
return v.upper()
|
||||
return v
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
"""Contact response schema."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
company_name: str
|
||||
legal_form: Optional[str] = None
|
||||
address_street: Optional[str] = None
|
||||
address_zip: Optional[str] = None
|
||||
address_city: Optional[str] = None
|
||||
address_country: str
|
||||
vat_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
role: str
|
||||
vat_id_status: str = "ungeprueft"
|
||||
is_private: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
deleted_at: Optional[datetime] = None
|
||||
contact_persons: list[ContactPersonResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactListResponse(BaseModel):
|
||||
"""Paginated contact list response."""
|
||||
|
||||
items: list[ContactResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
Reference in New Issue
Block a user