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:
+2
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, users, vehicles
|
||||
from app.routers import auth, contacts, users, vehicles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -41,6 +41,7 @@ api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(auth.router)
|
||||
api_v1_router.include_router(users.router)
|
||||
api_v1_router.include_router(vehicles.router)
|
||||
api_v1_router.include_router(contacts.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Contacts router: CRUD, search, filter, and contact person management endpoints."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, get_pagination, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import (
|
||||
ContactCreate,
|
||||
ContactListResponse,
|
||||
ContactPersonCreate,
|
||||
ContactPersonResponse,
|
||||
ContactResponse,
|
||||
ContactUpdate,
|
||||
)
|
||||
from app.services import contact_service
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.get("/", response_model=ContactListResponse, status_code=status.HTTP_200_OK)
|
||||
async def list_contacts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
pagination: dict = Depends(get_pagination),
|
||||
search: str | None = Query(None, description="Search in company_name, city, email, vat_id"),
|
||||
role: str | None = Query(None, description="Filter by role (kaeufer, verkaeufer, beide)"),
|
||||
is_eu: bool | None = Query(None, description="Filter EU (true) or Inland (false)"),
|
||||
is_private: bool | None = Query(None, description="Filter private contacts"),
|
||||
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List contacts with pagination, filtering, and sorting.
|
||||
|
||||
All authenticated roles can read.
|
||||
"""
|
||||
contacts, total = await contact_service.list_contacts(
|
||||
db,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
search=search,
|
||||
role=role,
|
||||
is_eu=is_eu,
|
||||
is_private=is_private,
|
||||
sort=sort,
|
||||
)
|
||||
return ContactListResponse(
|
||||
items=[ContactResponse.model_validate(c) for c in contacts],
|
||||
total=total,
|
||||
page=pagination["page"],
|
||||
page_size=pagination["page_size"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contact(
|
||||
body: ContactCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Create a new contact. Requires admin or verkaeufer role."""
|
||||
data = body.model_dump(exclude_unset=False)
|
||||
contact = await contact_service.create_contact(db, data)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.get("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single contact by ID with contact persons."""
|
||||
contact = await contact_service.get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.put("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
async def update_contact(
|
||||
contact_id: uuid.UUID,
|
||||
body: ContactUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Update a contact's fields. Requires admin or verkaeufer role."""
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||
)
|
||||
contact = await contact_service.update_contact(db, contact_id, updates)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.delete("/{contact_id}", response_model=ContactResponse, status_code=status.HTTP_200_OK)
|
||||
async def delete_contact(
|
||||
contact_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Soft-delete a contact (sets deleted_at). Requires admin or verkaeufer role."""
|
||||
contact = await contact_service.soft_delete_contact(db, contact_id)
|
||||
if contact is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactResponse.model_validate(contact)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{contact_id}/persons",
|
||||
response_model=ContactPersonResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_contact_person(
|
||||
contact_id: uuid.UUID,
|
||||
body: ContactPersonCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Add a contact person to an existing contact. Requires admin or verkaeufer role."""
|
||||
person = await contact_service.add_contact_person(
|
||||
db, contact_id, body.model_dump(exclude_unset=False)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "CONTACT_NOT_FOUND", "message": "Contact not found"}},
|
||||
)
|
||||
return ContactPersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{contact_id}/persons/{person_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def remove_contact_person(
|
||||
contact_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "verkaeufer"])),
|
||||
):
|
||||
"""Remove a contact person from a contact. Requires admin or verkaeufer role."""
|
||||
deleted = await contact_service.remove_contact_person(db, contact_id, person_id)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "PERSON_NOT_FOUND", "message": "Contact person not found"}},
|
||||
)
|
||||
return None
|
||||
@@ -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
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Contact service: CRUD, search, filter, pagination, and soft-delete operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.contact import Contact, ContactPerson
|
||||
|
||||
|
||||
_SORTABLE_FIELDS: set[str] = {
|
||||
"company_name",
|
||||
"address_city",
|
||||
"address_country",
|
||||
"role",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"email",
|
||||
}
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
stmt: select,
|
||||
search: str | None = None,
|
||||
role: str | None = None,
|
||||
is_eu: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
) -> select:
|
||||
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||
conditions = [Contact.deleted_at.is_(None)]
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Contact.company_name.ilike(search_pattern),
|
||||
Contact.address_city.ilike(search_pattern),
|
||||
Contact.email.ilike(search_pattern),
|
||||
Contact.vat_id.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if role:
|
||||
# When filtering by 'kaeufer' or 'verkaeufer', include 'beide' as well
|
||||
if role in ("kaeufer", "verkaeufer"):
|
||||
conditions.append(
|
||||
or_(
|
||||
Contact.role == role,
|
||||
Contact.role == "beide",
|
||||
)
|
||||
)
|
||||
else:
|
||||
conditions.append(Contact.role == role)
|
||||
|
||||
if is_eu is not None:
|
||||
if is_eu:
|
||||
# EU = address_country != 'DE'
|
||||
conditions.append(Contact.address_country != "DE")
|
||||
else:
|
||||
# Inland = address_country == 'DE'
|
||||
conditions.append(Contact.address_country == "DE")
|
||||
|
||||
if is_private is not None:
|
||||
conditions.append(Contact.is_private == is_private)
|
||||
|
||||
return stmt.where(and_(*conditions))
|
||||
|
||||
|
||||
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||
"""Apply ORDER BY to a select statement based on sort param.
|
||||
|
||||
Format: 'field' for ascending, '-field' for descending.
|
||||
"""
|
||||
if not sort:
|
||||
return stmt.order_by(Contact.created_at.desc())
|
||||
|
||||
descending = sort.startswith("-")
|
||||
field_name = sort.lstrip("-")
|
||||
|
||||
if field_name not in _SORTABLE_FIELDS:
|
||||
return stmt.order_by(Contact.created_at.desc())
|
||||
|
||||
column = getattr(Contact, field_name)
|
||||
if descending:
|
||||
return stmt.order_by(column.desc())
|
||||
return stmt.order_by(column.asc())
|
||||
|
||||
|
||||
async def list_contacts(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
search: str | None = None,
|
||||
role: str | None = None,
|
||||
is_eu: bool | None = None,
|
||||
is_private: bool | None = None,
|
||||
sort: str | None = None,
|
||||
) -> tuple[list[Contact], int]:
|
||||
"""List contacts with pagination, filtering, and sorting.
|
||||
|
||||
Returns (contacts, total_count).
|
||||
"""
|
||||
# Build count query
|
||||
count_stmt = select(func.count(Contact.id))
|
||||
count_stmt = _apply_filters(
|
||||
count_stmt,
|
||||
search=search,
|
||||
role=role,
|
||||
is_eu=is_eu,
|
||||
is_private=is_private,
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# Build data query with eager-loaded contact persons
|
||||
data_stmt = select(Contact).options(selectinload(Contact.contact_persons))
|
||||
data_stmt = _apply_filters(
|
||||
data_stmt,
|
||||
search=search,
|
||||
role=role,
|
||||
is_eu=is_eu,
|
||||
is_private=is_private,
|
||||
)
|
||||
data_stmt = _apply_sort(data_stmt, sort)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(data_stmt)
|
||||
contacts = list(result.scalars().all())
|
||||
|
||||
return contacts, total
|
||||
|
||||
|
||||
async def get_contact_by_id(
|
||||
db: AsyncSession, contact_id: uuid.UUID
|
||||
) -> Contact | None:
|
||||
"""Get a single contact by ID, excluding soft-deleted. Eager-loads contact persons."""
|
||||
stmt = (
|
||||
select(Contact)
|
||||
.options(selectinload(Contact.contact_persons))
|
||||
.where(
|
||||
and_(Contact.id == contact_id, Contact.deleted_at.is_(None))
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_contact(
|
||||
db: AsyncSession, data: dict[str, Any]
|
||||
) -> Contact:
|
||||
"""Create a new contact with optional nested contact persons.
|
||||
|
||||
The data dict may contain a 'contact_persons' list of dicts.
|
||||
"""
|
||||
persons_data = data.pop("contact_persons", [])
|
||||
|
||||
contact = Contact(**data)
|
||||
db.add(contact)
|
||||
await db.flush() # Flush to generate contact.id
|
||||
|
||||
# Add contact persons if provided
|
||||
for person_data in persons_data:
|
||||
person = ContactPerson(
|
||||
contact_id=contact.id,
|
||||
name=person_data["name"],
|
||||
function=person_data.get("function"),
|
||||
phone=person_data.get("phone"),
|
||||
email=person_data.get("email"),
|
||||
)
|
||||
db.add(person)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def update_contact(
|
||||
db: AsyncSession, contact_id: uuid.UUID, updates: dict[str, Any]
|
||||
) -> Contact | None:
|
||||
"""Update a contact's fields. Returns None if not found or deleted."""
|
||||
contact = await get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
|
||||
for key, value in updates.items():
|
||||
if hasattr(contact, key):
|
||||
setattr(contact, key, value)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def soft_delete_contact(
|
||||
db: AsyncSession, contact_id: uuid.UUID
|
||||
) -> Contact | None:
|
||||
"""Soft-delete a contact by setting deleted_at. Returns None if not found."""
|
||||
contact = await get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
|
||||
contact.deleted_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def add_contact_person(
|
||||
db: AsyncSession,
|
||||
contact_id: uuid.UUID,
|
||||
person_data: dict[str, Any],
|
||||
) -> ContactPerson | None:
|
||||
"""Add a contact person to an existing contact.
|
||||
|
||||
Returns None if the contact is not found or is deleted.
|
||||
"""
|
||||
contact = await get_contact_by_id(db, contact_id)
|
||||
if contact is None:
|
||||
return None
|
||||
|
||||
person = ContactPerson(
|
||||
contact_id=contact_id,
|
||||
name=person_data["name"],
|
||||
function=person_data.get("function"),
|
||||
phone=person_data.get("phone"),
|
||||
email=person_data.get("email"),
|
||||
)
|
||||
db.add(person)
|
||||
await db.flush()
|
||||
await db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
async def remove_contact_person(
|
||||
db: AsyncSession,
|
||||
contact_id: uuid.UUID,
|
||||
person_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Remove (hard-delete) a contact person from a contact.
|
||||
|
||||
Returns True if the person was found and deleted, False otherwise.
|
||||
"""
|
||||
stmt = select(ContactPerson).where(
|
||||
and_(
|
||||
ContactPerson.id == person_id,
|
||||
ContactPerson.contact_id == contact_id,
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
person = result.scalar_one_or_none()
|
||||
if person is None:
|
||||
return False
|
||||
|
||||
await db.delete(person)
|
||||
await db.flush()
|
||||
return True
|
||||
@@ -0,0 +1,114 @@
|
||||
"""USt-IdNr. (VAT ID) format validation for DE and common EU countries.
|
||||
|
||||
Provides basic regex-based format validation. Does NOT perform live API verification.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Country-specific VAT ID regex patterns.
|
||||
# Each pattern validates the format after the 2-letter country code prefix.
|
||||
_VAT_PATTERNS: dict[str, re.Pattern[str]] = {
|
||||
# DE: DE + 9 digits (e.g. DE123456789)
|
||||
"DE": re.compile(r"^DE\d{9}$"),
|
||||
# AT: AT + U + 8 digits (e.g. ATU12345678)
|
||||
"AT": re.compile(r"^ATU\d{8}$"),
|
||||
# FR: FR + 2 alphanumeric + 9 digits (e.g. FRAB123456789)
|
||||
"FR": re.compile(r"^FR[A-Za-z0-9]{2}\d{9}$"),
|
||||
# NL: NL + 9 digits + B + 2 digits (e.g. NL123456789B01)
|
||||
"NL": re.compile(r"^NL\d{9}B\d{2}$"),
|
||||
# PL: PL + 10 digits (e.g. PL1234567890)
|
||||
"PL": re.compile(r"^PL\d{10}$"),
|
||||
# CZ: CZ + 8-10 digits (e.g. CZ1234567890)
|
||||
"CZ": re.compile(r"^CZ\d{8,10}$"),
|
||||
# IT: IT + 11 digits (e.g. IT12345678901)
|
||||
"IT": re.compile(r"^IT\d{11}$"),
|
||||
# ES: ES + 1 alphanumeric + 7 digits + 1 alphanumeric (e.g. ESA1234567B)
|
||||
"ES": re.compile(r"^ES[A-Za-z0-9]\d{7}[A-Za-z0-9]$"),
|
||||
# BE: BE + 10 digits (e.g. BE1234567890)
|
||||
"BE": re.compile(r"^BE\d{10}$"),
|
||||
# DK: DK + 8 digits (e.g. DK12345678)
|
||||
"DK": re.compile(r"^DK\d{8}$"),
|
||||
# SE: SE + 10 digits (e.g. SE1234567890)
|
||||
"SE": re.compile(r"^SE\d{10}$"),
|
||||
}
|
||||
|
||||
# Fallback pattern for EU countries not explicitly listed above.
|
||||
# Accepts <2-letter country code> + 5-15 alphanumeric characters.
|
||||
_EU_FALLBACK_PATTERN = re.compile(r"^[A-Z]{2}[A-Za-z0-9]{5,15}$")
|
||||
|
||||
# Set of supported EU country codes (ISO 3166-1 alpha-2).
|
||||
_EU_COUNTRY_CODES: set[str] = {
|
||||
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR",
|
||||
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
|
||||
"SE", "SI", "SK",
|
||||
}
|
||||
|
||||
|
||||
def get_country_code_from_vat_id(vat_id: str) -> str | None:
|
||||
"""Extract the 2-letter country code from a VAT ID string.
|
||||
|
||||
Returns None if the string is too short or does not start with letters.
|
||||
"""
|
||||
if not vat_id or len(vat_id) < 3:
|
||||
return None
|
||||
prefix = vat_id[:2].upper()
|
||||
if not prefix.isalpha():
|
||||
return None
|
||||
return prefix
|
||||
|
||||
|
||||
def validate_vat_id(vat_id: str) -> bool:
|
||||
"""Validate the format of a VAT ID (USt-IdNr.).
|
||||
|
||||
Supports DE and common EU countries with specific regex patterns.
|
||||
For other EU countries, a fallback pattern is used.
|
||||
Non-EU or unrecognised formats return False.
|
||||
|
||||
Args:
|
||||
vat_id: The VAT ID string to validate (case-insensitive, will be uppercased).
|
||||
|
||||
Returns:
|
||||
True if the format is valid, False otherwise.
|
||||
"""
|
||||
if not vat_id:
|
||||
return True # Empty VAT ID is valid (nullable field)
|
||||
|
||||
normalized = vat_id.strip().upper().replace(" ", "")
|
||||
country_code = get_country_code_from_vat_id(normalized)
|
||||
if country_code is None:
|
||||
return False
|
||||
|
||||
# Check against country-specific pattern if available
|
||||
pattern = _VAT_PATTERNS.get(country_code)
|
||||
if pattern is not None:
|
||||
return bool(pattern.match(normalized))
|
||||
|
||||
# Fallback for EU countries without a specific pattern
|
||||
if country_code in _EU_COUNTRY_CODES:
|
||||
return bool(_EU_FALLBACK_PATTERN.match(normalized))
|
||||
|
||||
# Non-EU country code: reject (this module validates EU VAT IDs only)
|
||||
return False
|
||||
|
||||
|
||||
def validate_vat_id_or_raise(vat_id: str | None) -> str | None:
|
||||
"""Validate VAT ID format and return the normalised value.
|
||||
|
||||
Raises ValueError if the format is invalid.
|
||||
|
||||
Args:
|
||||
vat_id: The VAT ID string to validate, or None.
|
||||
|
||||
Returns:
|
||||
The normalised (uppercased, whitespace-stripped) VAT ID, or None.
|
||||
|
||||
Raises:
|
||||
ValueError: If the VAT ID format is invalid.
|
||||
"""
|
||||
if vat_id is None or vat_id == "":
|
||||
return None
|
||||
|
||||
normalized = vat_id.strip().upper().replace(" ", "")
|
||||
if not validate_vat_id(normalized):
|
||||
raise ValueError(f"Invalid VAT ID format: '{vat_id}'")
|
||||
return normalized
|
||||
Reference in New Issue
Block a user