""" 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): """Legal forms for 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 for EspoCRM entities (Beteiligte)""" PENDING_SYNC = "pending_sync" SYNCING = "syncing" CLEAN = "clean" FAILED = "failed" CONFLICT = "conflict" PERMANENTLY_FAILED = "permanently_failed" class FileStatus(str, Enum): """Valid values for CDokumente.fileStatus field""" NEW = "new" CHANGED = "changed" SYNCED = "synced" def __str__(self) -> str: return self.value class XAISyncStatus(str, Enum): """Valid values for CDokumente.xaiSyncStatus field""" NO_SYNC = "no_sync" # Entity has no xAI collections PENDING_SYNC = "pending_sync" # Sync in progress (locked) CLEAN = "clean" # Synced successfully UNCLEAN = "unclean" # Needs re-sync (file changed) FAILED = "failed" # Sync failed (see xaiSyncError) def __str__(self) -> str: return self.value class SalutationType(str, Enum): """Salutation types""" HERR = "Herr" FRAU = "Frau" DIVERS = "Divers" FIRMA = "" class AIKnowledgeActivationStatus(str, Enum): """Activation status for CAIKnowledge collections""" NEW = "new" # Collection noch nicht in XAI erstellt ACTIVE = "active" # Collection aktiv, Sync läuft PAUSED = "paused" # Collection existiert, aber kein Sync DEACTIVATED = "deactivated" # Collection aus XAI gelöscht def __str__(self) -> str: return self.value class AIKnowledgeSyncStatus(str, Enum): """Sync status for CAIKnowledge""" UNCLEAN = "unclean" # Änderungen pending PENDING_SYNC = "pending_sync" # Sync läuft (locked) SYNCED = "synced" # Alles synced FAILED = "failed" # Sync fehlgeschlagen def __str__(self) -> str: return self.value class JunctionSyncStatus(str, Enum): """Sync status for junction tables (CAIKnowledgeCDokumente)""" NEW = "new" UNCLEAN = "unclean" SYNCED = "synced" FAILED = "failed" UNSUPPORTED = "unsupported" def __str__(self) -> str: return self.value # ========== 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}")