Files
motia-iii/services/models.py
bsiggel 69a48f5f9a Implement central configuration, custom exceptions, logging utilities, Pydantic models, and Redis client for BitByLaw integration
- Added `config.py` for centralized configuration management including Sync, API, Advoware, EspoCRM, Redis, Logging, Calendar Sync, and Feature Flags.
- Created `exceptions.py` with a hierarchy of custom exceptions for integration errors, API errors, sync errors, and Redis errors.
- Developed `logging_utils.py` for a unified logging wrapper supporting structured logging and performance tracking.
- Defined Pydantic models in `models.py` for data validation of Advoware and EspoCRM entities, including sync operation models.
- Introduced `redis_client.py` for a centralized Redis client factory with connection pooling, automatic reconnection, and health checks.
2026-03-03 17:18:49 +00:00

260 lines
8.0 KiB
Python

"""
Pydantic Models für Datenvalidierung
Definiert strenge Schemas für:
- Advoware Entities
- EspoCRM Entities
- Sync Operations
"""
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Optional, Literal
from datetime import date, datetime
from enum import Enum
# ========== Enums ==========
class Rechtsform(str, Enum):
"""Rechtsformen für Beteiligte"""
NATUERLICHE_PERSON = ""
GMBH = "GmbH"
AG = "AG"
GMBH_CO_KG = "GmbH & Co. KG"
KG = "KG"
OHG = "OHG"
EV = "e.V."
EINZELUNTERNEHMEN = "Einzelunternehmen"
FREIBERUFLER = "Freiberufler"
class SyncStatus(str, Enum):
"""Sync Status für EspoCRM Entities"""
PENDING_SYNC = "pending_sync"
SYNCING = "syncing"
CLEAN = "clean"
FAILED = "failed"
CONFLICT = "conflict"
PERMANENTLY_FAILED = "permanently_failed"
class SalutationType(str, Enum):
"""Anredetypen"""
HERR = "Herr"
FRAU = "Frau"
DIVERS = "Divers"
FIRMA = ""
# ========== Advoware Models ==========
class AdvowareBeteiligteBase(BaseModel):
"""Base Model für Advoware Beteiligte (POST/PUT)"""
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: str = Field(..., min_length=1, max_length=200)
vorname: Optional[str] = Field(None, max_length=100)
rechtsform: str = Field(default="")
anrede: Optional[str] = Field(None, max_length=50)
titel: Optional[str] = Field(None, max_length=50)
bAnrede: Optional[str] = Field(None, max_length=200, description="Briefanrede")
zusatz: Optional[str] = Field(None, max_length=200)
geburtsdatum: Optional[date] = None
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('Name darf nicht leer sein')
return v.strip()
@field_validator('geburtsdatum')
@classmethod
def validate_birthdate(cls, v: Optional[date]) -> Optional[date]:
if v and v > date.today():
raise ValueError('Geburtsdatum kann nicht in der Zukunft liegen')
if v and v.year < 1900:
raise ValueError('Geburtsdatum vor 1900 nicht erlaubt')
return v
class AdvowareBeteiligteRead(AdvowareBeteiligteBase):
"""Advoware Beteiligte Response (GET)"""
betNr: int = Field(..., ge=1)
rowId: str = Field(..., description="Change detection ID")
# Optional fields die Advoware zurückgibt
strasse: Optional[str] = None
plz: Optional[str] = None
ort: Optional[str] = None
land: Optional[str] = None
class AdvowareBeteiligteCreate(AdvowareBeteiligteBase):
"""Advoware Beteiligte für POST"""
pass
class AdvowareBeteiligteUpdate(AdvowareBeteiligteBase):
"""Advoware Beteiligte für PUT"""
pass
# ========== EspoCRM Models ==========
class EspoCRMBeteiligteBase(BaseModel):
"""Base Model für EspoCRM CBeteiligte"""
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: str = Field(..., min_length=1, max_length=255)
firstName: Optional[str] = Field(None, max_length=100)
lastName: Optional[str] = Field(None, max_length=100)
firmenname: Optional[str] = Field(None, max_length=255)
rechtsform: str = Field(default="")
salutationName: Optional[str] = None
titel: Optional[str] = Field(None, max_length=100)
briefAnrede: Optional[str] = Field(None, max_length=255)
zusatz: Optional[str] = Field(None, max_length=255)
dateOfBirth: Optional[date] = None
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('Name darf nicht leer sein')
return v.strip()
@field_validator('dateOfBirth')
@classmethod
def validate_birthdate(cls, v: Optional[date]) -> Optional[date]:
if v and v > date.today():
raise ValueError('Geburtsdatum kann nicht in der Zukunft liegen')
if v and v.year < 1900:
raise ValueError('Geburtsdatum vor 1900 nicht erlaubt')
return v
@field_validator('firstName', 'lastName')
@classmethod
def validate_person_fields(cls, v: Optional[str]) -> Optional[str]:
"""Validiere dass Person-Felder nur bei natürlichen Personen gesetzt sind"""
if v:
return v.strip()
return None
class EspoCRMBeteiligteRead(EspoCRMBeteiligteBase):
"""EspoCRM CBeteiligte Response (GET)"""
id: str = Field(..., min_length=1)
betnr: Optional[int] = Field(None, ge=1)
advowareRowId: Optional[str] = None
syncStatus: SyncStatus = Field(default=SyncStatus.PENDING_SYNC)
syncRetryCount: int = Field(default=0, ge=0, le=10)
syncErrorMessage: Optional[str] = None
advowareLastSync: Optional[datetime] = None
syncNextRetry: Optional[datetime] = None
syncAutoResetAt: Optional[datetime] = None
class EspoCRMBeteiligteCreate(EspoCRMBeteiligteBase):
"""EspoCRM CBeteiligte für POST"""
syncStatus: SyncStatus = Field(default=SyncStatus.PENDING_SYNC)
class EspoCRMBeteiligteUpdate(BaseModel):
"""EspoCRM CBeteiligte für PUT (alle Felder optional)"""
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
name: Optional[str] = Field(None, min_length=1, max_length=255)
firstName: Optional[str] = Field(None, max_length=100)
lastName: Optional[str] = Field(None, max_length=100)
firmenname: Optional[str] = Field(None, max_length=255)
rechtsform: Optional[str] = None
salutationName: Optional[str] = None
titel: Optional[str] = Field(None, max_length=100)
briefAnrede: Optional[str] = Field(None, max_length=255)
zusatz: Optional[str] = Field(None, max_length=255)
dateOfBirth: Optional[date] = None
betnr: Optional[int] = Field(None, ge=1)
advowareRowId: Optional[str] = None
syncStatus: Optional[SyncStatus] = None
syncRetryCount: Optional[int] = Field(None, ge=0, le=10)
syncErrorMessage: Optional[str] = Field(None, max_length=2000)
advowareLastSync: Optional[datetime] = None
syncNextRetry: Optional[datetime] = None
def model_dump_clean(self) -> dict:
"""Gibt nur nicht-None Werte zurück (für PATCH-ähnliches Update)"""
return {k: v for k, v in self.model_dump().items() if v is not None}
# ========== Sync Operation Models ==========
class SyncOperation(BaseModel):
"""Model für Sync-Operation Tracking"""
entity_id: str
action: Literal["create", "update", "delete", "sync_check"]
source: Literal["webhook", "cron", "api", "manual"]
timestamp: datetime = Field(default_factory=datetime.utcnow)
entity_type: str = "CBeteiligte"
class SyncResult(BaseModel):
"""Result einer Sync-Operation"""
success: bool
entity_id: str
action: str
message: Optional[str] = None
error: Optional[str] = None
details: Optional[dict] = None
duration_ms: Optional[int] = None
# ========== Validation Helpers ==========
def validate_beteiligte_advoware(data: dict) -> AdvowareBeteiligteCreate:
"""
Validiert Advoware Beteiligte Daten.
Args:
data: Dict mit Advoware Daten
Returns:
Validiertes Model
Raises:
ValidationError: Bei Validierungsfehlern
"""
try:
return AdvowareBeteiligteCreate.model_validate(data)
except Exception as e:
from services.exceptions import ValidationError
raise ValidationError(f"Invalid Advoware data: {e}")
def validate_beteiligte_espocrm(data: dict) -> EspoCRMBeteiligteCreate:
"""
Validiert EspoCRM Beteiligte Daten.
Args:
data: Dict mit EspoCRM Daten
Returns:
Validiertes Model
Raises:
ValidationError: Bei Validierungsfehlern
"""
try:
return EspoCRMBeteiligteCreate.model_validate(data)
except Exception as e:
from services.exceptions import ValidationError
raise ValidationError(f"Invalid EspoCRM data: {e}")