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.
This commit is contained in:
259
services/models.py
Normal file
259
services/models.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
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}")
|
||||
Reference in New Issue
Block a user