- 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.
260 lines
8.0 KiB
Python
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}")
|