From 680ca729e319e4328d79d969a671223ef48536c8 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 14 Feb 2026 12:30:18 +0100 Subject: [PATCH 1/2] fix: event and replay schemas moved to schemas folder, all other domain obj - converted to python dataclasses --- backend/app/api/routes/admin/events.py | 2 +- backend/app/api/routes/admin/users.py | 2 +- backend/app/api/routes/notifications.py | 2 +- backend/app/api/routes/replay.py | 3 +- backend/app/api/routes/saved_scripts.py | 4 +- backend/app/api/routes/user_settings.py | 6 +- .../admin/admin_events_repository.py | 3 +- .../admin/admin_user_repository.py | 13 +- backend/app/db/repositories/dlq_repository.py | 2 +- .../db/repositories/execution_repository.py | 9 +- .../repositories/notification_repository.py | 35 ++-- .../app/db/repositories/replay_repository.py | 12 +- .../resource_allocation_repository.py | 5 +- .../app/db/repositories/saga_repository.py | 17 +- .../repositories/saved_script_repository.py | 14 +- backend/app/db/repositories/sse_repository.py | 2 +- .../app/db/repositories/user_repository.py | 15 +- .../repositories/user_settings_repository.py | 10 +- backend/app/dlq/__init__.py | 2 - backend/app/dlq/models.py | 12 -- backend/app/domain/admin/__init__.py | 5 +- backend/app/domain/admin/replay_models.py | 37 ++-- backend/app/domain/admin/replay_updates.py | 8 +- backend/app/domain/admin/settings_models.py | 26 --- backend/app/domain/events/__init__.py | 34 ++-- backend/app/domain/events/event_models.py | 162 ------------------ backend/app/domain/execution/models.py | 42 ++--- backend/app/domain/notification/models.py | 73 +++----- .../domain/rate_limit/rate_limit_models.py | 5 +- backend/app/domain/replay/models.py | 79 ++++----- backend/app/domain/saga/models.py | 98 +++++------ backend/app/domain/saved_script/models.py | 26 ++- backend/app/domain/sse/models.py | 13 +- backend/app/domain/user/settings_models.py | 77 +++++---- backend/app/domain/user/user_models.py | 52 +++--- .../app/schemas_pydantic/admin_settings.py | 28 ++- backend/app/schemas_pydantic/dlq.py | 14 ++ backend/app/schemas_pydantic/event_schemas.py | 161 +++++++++++++++++ .../app/schemas_pydantic/replay_schemas.py | 24 +++ .../services/admin/admin_events_service.py | 3 +- .../app/services/admin/admin_user_service.py | 14 +- backend/app/services/execution_service.py | 7 +- .../idempotency/idempotency_manager.py | 10 +- .../services/result_processor/processor.py | 12 +- .../app/services/saga/saga_orchestrator.py | 5 +- backend/app/services/user_settings_service.py | 24 +-- .../saga/test_saga_orchestrator_unit.py | 4 +- 47 files changed, 625 insertions(+), 588 deletions(-) create mode 100644 backend/app/schemas_pydantic/event_schemas.py create mode 100644 backend/app/schemas_pydantic/replay_schemas.py diff --git a/backend/app/api/routes/admin/events.py b/backend/app/api/routes/admin/events.py index bc735d3f..47eaab46 100644 --- a/backend/app/api/routes/admin/events.py +++ b/backend/app/api/routes/admin/events.py @@ -105,7 +105,7 @@ async def replay_events( """Replay events by filter criteria, with optional dry-run mode.""" replay_id = f"replay-{uuid4().hex}" result = await service.prepare_or_schedule_replay( - replay_filter=ReplayFilter.model_validate(request), + replay_filter=ReplayFilter(**request.model_dump(include=set(ReplayFilter.__dataclass_fields__))), dry_run=request.dry_run, replay_id=replay_id, target_service=request.target_service, diff --git a/backend/app/api/routes/admin/users.py b/backend/app/api/routes/admin/users.py index 675dd725..6d9c6fbf 100644 --- a/backend/app/api/routes/admin/users.py +++ b/backend/app/api/routes/admin/users.py @@ -122,7 +122,7 @@ async def update_user( if not existing_user: raise HTTPException(status_code=404, detail="User not found") - domain_update = DomainUserUpdate.model_validate(user_update) + domain_update = DomainUserUpdate(**user_update.model_dump()) updated_user = await admin_user_service.update_user( admin_user_id=admin.user_id, user_id=user_id, update=domain_update diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index 99fb9d72..e826b18f 100644 --- a/backend/app/api/routes/notifications.py +++ b/backend/app/api/routes/notifications.py @@ -86,7 +86,7 @@ async def update_subscription( notification_service: FromDishka[NotificationService], ) -> NotificationSubscription: """Update subscription settings for a notification channel.""" - update_data = DomainSubscriptionUpdate.model_validate(subscription) + update_data = DomainSubscriptionUpdate(**subscription.model_dump()) updated_sub = await notification_service.update_subscription( user_id=user.user_id, channel=channel, diff --git a/backend/app/api/routes/replay.py b/backend/app/api/routes/replay.py index 258cb860..e91f1582 100644 --- a/backend/app/api/routes/replay.py +++ b/backend/app/api/routes/replay.py @@ -25,7 +25,8 @@ async def create_replay_session( service: FromDishka[EventReplayService], ) -> ReplayResponse: """Create a new event replay session from a configuration.""" - result = await service.create_session_from_config(ReplayConfig.model_validate(replay_request)) + config_fields = set(ReplayConfig.__dataclass_fields__) + result = await service.create_session_from_config(ReplayConfig(**replay_request.model_dump(include=config_fields))) return ReplayResponse.model_validate(result) diff --git a/backend/app/api/routes/saved_scripts.py b/backend/app/api/routes/saved_scripts.py index 0416fd05..3f10baf9 100644 --- a/backend/app/api/routes/saved_scripts.py +++ b/backend/app/api/routes/saved_scripts.py @@ -26,7 +26,7 @@ async def create_saved_script( saved_script_service: FromDishka[SavedScriptService], ) -> SavedScriptResponse: """Save a new script to the user's collection.""" - create = DomainSavedScriptCreate.model_validate(saved_script) + create = DomainSavedScriptCreate(**saved_script.model_dump()) domain = await saved_script_service.create_saved_script(create, user.user_id) return SavedScriptResponse.model_validate(domain) @@ -68,7 +68,7 @@ async def update_saved_script( saved_script_service: FromDishka[SavedScriptService], ) -> SavedScriptResponse: """Update an existing saved script.""" - update_data = DomainSavedScriptUpdate.model_validate(script_update) + update_data = DomainSavedScriptUpdate(**script_update.model_dump()) domain = await saved_script_service.update_saved_script(script_id, user.user_id, update_data) return SavedScriptResponse.model_validate(domain) diff --git a/backend/app/api/routes/user_settings.py b/backend/app/api/routes/user_settings.py index 97487c25..5d77d566 100644 --- a/backend/app/api/routes/user_settings.py +++ b/backend/app/api/routes/user_settings.py @@ -43,7 +43,7 @@ async def update_user_settings( settings_service: FromDishka[UserSettingsService], ) -> UserSettings: """Update the authenticated user's settings.""" - domain_updates = DomainUserSettingsUpdate.model_validate(updates) + domain_updates = DomainUserSettingsUpdate(**updates.model_dump()) domain = await settings_service.update_user_settings(current_user.user_id, domain_updates) return UserSettings.model_validate(domain) @@ -68,7 +68,7 @@ async def update_notification_settings( """Update notification preferences.""" domain = await settings_service.update_notification_settings( current_user.user_id, - DomainNotificationSettings.model_validate(notifications), + DomainNotificationSettings(**notifications.model_dump()), ) return UserSettings.model_validate(domain) @@ -82,7 +82,7 @@ async def update_editor_settings( """Update code editor preferences.""" domain = await settings_service.update_editor_settings( current_user.user_id, - DomainEditorSettings.model_validate(editor), + DomainEditorSettings(**editor.model_dump()), ) return UserSettings.model_validate(domain) diff --git a/backend/app/db/repositories/admin/admin_events_repository.py b/backend/app/db/repositories/admin/admin_events_repository.py index 0dfaa715..7e1d8a0b 100644 --- a/backend/app/db/repositories/admin/admin_events_repository.py +++ b/backend/app/db/repositories/admin/admin_events_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime, timedelta, timezone from typing import Any @@ -211,7 +212,7 @@ async def archive_event(self, event: DomainEvent, deleted_by: str) -> bool: return True async def update_replay_session(self, session_id: str, updates: ReplaySessionUpdate) -> bool: - update_dict = updates.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(updates).items() if v is not None} if not update_dict: return False doc = await ReplaySessionDocument.find_one(ReplaySessionDocument.session_id == session_id) diff --git a/backend/app/db/repositories/admin/admin_user_repository.py b/backend/app/db/repositories/admin/admin_user_repository.py index fed21355..4044a719 100644 --- a/backend/app/db/repositories/admin/admin_user_repository.py +++ b/backend/app/db/repositories/admin/admin_user_repository.py @@ -1,3 +1,4 @@ +import dataclasses import re from datetime import datetime, timezone @@ -28,9 +29,9 @@ class AdminUserRepository: async def create_user(self, create_data: DomainUserCreate) -> User: - doc = UserDocument(**create_data.model_dump()) + doc = UserDocument(**dataclasses.asdict(create_data)) await doc.insert() - return User.model_validate(doc) + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) async def list_users( self, limit: int = 100, offset: int = 0, search: str | None = None, role: UserRole | None = None @@ -52,26 +53,26 @@ async def list_users( query = UserDocument.find(*conditions) total = await query.count() docs = await query.skip(offset).limit(limit).to_list() - users = [User.model_validate(doc) for doc in docs] + users = [User(**doc.model_dump(include=set(User.__dataclass_fields__))) for doc in docs] return UserListResult(users=users, total=total, offset=offset, limit=limit) async def get_user_by_id(self, user_id: str) -> User | None: doc = await UserDocument.find_one(UserDocument.user_id == user_id) - return User.model_validate(doc) if doc else None + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) if doc else None async def update_user(self, user_id: str, update_data: UserUpdate) -> User | None: doc = await UserDocument.find_one(UserDocument.user_id == user_id) if not doc: return None - update_dict = update_data.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(update_data).items() if v is not None} if "password" in update_dict: update_dict["hashed_password"] = update_dict.pop("password") if update_dict: update_dict["updated_at"] = datetime.now(timezone.utc) await doc.set(update_dict) - return User.model_validate(doc) + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) async def delete_user(self, user_id: str, cascade: bool = True) -> UserDeleteResult: doc = await UserDocument.find_one(UserDocument.user_id == user_id) diff --git a/backend/app/db/repositories/dlq_repository.py b/backend/app/db/repositories/dlq_repository.py index b228ce51..94f6da04 100644 --- a/backend/app/db/repositories/dlq_repository.py +++ b/backend/app/db/repositories/dlq_repository.py @@ -12,9 +12,9 @@ DLQMessageListResult, DLQMessageStatus, DLQMessageUpdate, - DLQTopicSummary, ) from app.domain.enums import EventType +from app.schemas_pydantic.dlq import DLQTopicSummary class DLQRepository: diff --git a/backend/app/db/repositories/execution_repository.py b/backend/app/db/repositories/execution_repository.py index a90af22c..5579d697 100644 --- a/backend/app/db/repositories/execution_repository.py +++ b/backend/app/db/repositories/execution_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime, timezone from typing import Any @@ -17,11 +18,11 @@ def __init__(self, logger: structlog.stdlib.BoundLogger): self.logger = logger async def create_execution(self, create_data: DomainExecutionCreate) -> DomainExecution: - doc = ExecutionDocument(**create_data.model_dump()) + doc = ExecutionDocument(**dataclasses.asdict(create_data)) self.logger.info("Inserting execution into MongoDB", execution_id=doc.execution_id) await doc.insert() self.logger.info("Inserted execution", execution_id=doc.execution_id) - return DomainExecution.model_validate(doc) + return DomainExecution(**doc.model_dump(include=set(DomainExecution.__dataclass_fields__))) async def get_execution(self, execution_id: str) -> DomainExecution | None: self.logger.info("Searching for execution in MongoDB", execution_id=execution_id) @@ -31,7 +32,7 @@ async def get_execution(self, execution_id: str) -> DomainExecution | None: return None self.logger.info("Found execution in MongoDB", execution_id=execution_id) - return DomainExecution.model_validate(doc) + return DomainExecution(**doc.model_dump(include=set(DomainExecution.__dataclass_fields__))) async def write_terminal_result(self, result: ExecutionResultDomain) -> bool: doc = await ExecutionDocument.find_one(ExecutionDocument.execution_id == result.execution_id) @@ -63,7 +64,7 @@ async def get_executions( ] find_query = find_query.sort(beanie_sort) docs = await find_query.skip(skip).limit(limit).to_list() - return [DomainExecution.model_validate(doc) for doc in docs] + return [DomainExecution(**doc.model_dump(include=set(DomainExecution.__dataclass_fields__))) for doc in docs] async def count_executions(self, query: dict[str, Any]) -> int: return await ExecutionDocument.find(query).count() diff --git a/backend/app/db/repositories/notification_repository.py b/backend/app/db/repositories/notification_repository.py index eb8718ce..a7d17d55 100644 --- a/backend/app/db/repositories/notification_repository.py +++ b/backend/app/db/repositories/notification_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import UTC, datetime, timedelta import structlog @@ -20,9 +21,9 @@ def __init__(self, logger: structlog.stdlib.BoundLogger): self.logger = logger async def create_notification(self, create_data: DomainNotificationCreate) -> DomainNotification: - doc = NotificationDocument(**create_data.model_dump()) + doc = NotificationDocument(**dataclasses.asdict(create_data)) await doc.insert() - return DomainNotification.model_validate(doc) + return DomainNotification(**doc.model_dump(include=set(DomainNotification.__dataclass_fields__))) async def update_notification( self, notification_id: str, user_id: str, update_data: DomainNotificationUpdate @@ -33,7 +34,7 @@ async def update_notification( ) if not doc: return False - update_dict = update_data.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(update_data).items() if v is not None} if update_dict: await doc.set(update_dict) return True @@ -90,7 +91,10 @@ async def list_notifications( .limit(limit) .to_list() ) - return [DomainNotification.model_validate(doc) for doc in docs] + return [ + DomainNotification(**doc.model_dump(include=set(DomainNotification.__dataclass_fields__))) + for doc in docs + ] async def count_notifications( self, @@ -129,7 +133,10 @@ async def find_due_notifications(self, limit: int = 50) -> list[DomainNotificati .limit(limit) .to_list() ) - return [DomainNotification.model_validate(doc) for doc in docs] + return [ + DomainNotification(**doc.model_dump(include=set(DomainNotification.__dataclass_fields__))) + for doc in docs + ] async def try_claim_pending(self, notification_id: str) -> bool: now = datetime.now(UTC) @@ -158,7 +165,9 @@ async def get_subscription( if not doc: # Default: enabled=True for new users (consistent with get_all_subscriptions) return DomainNotificationSubscription(user_id=user_id, channel=channel, enabled=True) - return DomainNotificationSubscription.model_validate(doc) + return DomainNotificationSubscription( + **doc.model_dump(include=set(DomainNotificationSubscription.__dataclass_fields__)) + ) async def upsert_subscription( self, user_id: str, channel: NotificationChannel, update_data: DomainSubscriptionUpdate @@ -167,12 +176,14 @@ async def upsert_subscription( NotificationSubscriptionDocument.user_id == user_id, NotificationSubscriptionDocument.channel == channel, ) - update_dict = update_data.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(update_data).items() if v is not None} update_dict["updated_at"] = datetime.now(UTC) if existing: await existing.set(update_dict) - return DomainNotificationSubscription.model_validate(existing) + return DomainNotificationSubscription( + **existing.model_dump(include=set(DomainNotificationSubscription.__dataclass_fields__)) + ) else: doc = NotificationSubscriptionDocument( user_id=user_id, @@ -180,7 +191,9 @@ async def upsert_subscription( **update_dict, ) await doc.insert() - return DomainNotificationSubscription.model_validate(doc) + return DomainNotificationSubscription( + **doc.model_dump(include=set(DomainNotificationSubscription.__dataclass_fields__)) + ) async def get_all_subscriptions(self, user_id: str) -> list[DomainNotificationSubscription]: subs: list[DomainNotificationSubscription] = [] @@ -190,7 +203,9 @@ async def get_all_subscriptions(self, user_id: str) -> list[DomainNotificationSu NotificationSubscriptionDocument.channel == channel, ) if doc: - subs.append(DomainNotificationSubscription.model_validate(doc)) + subs.append(DomainNotificationSubscription( + **doc.model_dump(include=set(DomainNotificationSubscription.__dataclass_fields__)) + )) else: subs.append(DomainNotificationSubscription(user_id=user_id, channel=channel, enabled=True)) return subs diff --git a/backend/app/db/repositories/replay_repository.py b/backend/app/db/repositories/replay_repository.py index 10f1a6e7..1c523ca0 100644 --- a/backend/app/db/repositories/replay_repository.py +++ b/backend/app/db/repositories/replay_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime from typing import Any, AsyncIterator @@ -17,7 +18,7 @@ def __init__(self, logger: structlog.stdlib.BoundLogger) -> None: async def save_session(self, session: ReplaySessionState) -> None: existing = await ReplaySessionDocument.find_one(ReplaySessionDocument.session_id == session.session_id) - doc = ReplaySessionDocument(**session.model_dump()) + doc = ReplaySessionDocument(**dataclasses.asdict(session)) if existing: doc.id = existing.id await doc.save() @@ -26,7 +27,7 @@ async def get_session(self, session_id: str) -> ReplaySessionState | None: doc = await ReplaySessionDocument.find_one(ReplaySessionDocument.session_id == session_id) if not doc: return None - return ReplaySessionState.model_validate(doc) + return ReplaySessionState(**doc.model_dump(include=set(ReplaySessionState.__dataclass_fields__))) async def list_sessions( self, status: ReplayStatus | None = None, user_id: str | None = None, limit: int = 100, skip: int = 0 @@ -43,7 +44,10 @@ async def list_sessions( .limit(limit) .to_list() ) - return [ReplaySessionState.model_validate(doc) for doc in docs] + return [ + ReplaySessionState(**doc.model_dump(include=set(ReplaySessionState.__dataclass_fields__))) + for doc in docs + ] async def update_session_status(self, session_id: str, status: ReplayStatus) -> bool: doc = await ReplaySessionDocument.find_one(ReplaySessionDocument.session_id == session_id) @@ -66,7 +70,7 @@ async def delete_old_sessions(self, cutoff_time: datetime) -> int: return result.deleted_count if result else 0 async def update_replay_session(self, session_id: str, updates: ReplaySessionUpdate) -> bool: - update_dict = updates.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(updates).items() if v is not None} if not update_dict: return False doc = await ReplaySessionDocument.find_one(ReplaySessionDocument.session_id == session_id) diff --git a/backend/app/db/repositories/resource_allocation_repository.py b/backend/app/db/repositories/resource_allocation_repository.py index a12ba2ed..627ed9a0 100644 --- a/backend/app/db/repositories/resource_allocation_repository.py +++ b/backend/app/db/repositories/resource_allocation_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime, timezone from uuid import uuid4 @@ -15,10 +16,10 @@ async def count_active(self, language: str) -> int: async def create_allocation(self, create_data: DomainResourceAllocationCreate) -> DomainResourceAllocation: doc = ResourceAllocationDocument( allocation_id=str(uuid4()), - **create_data.model_dump(), + **dataclasses.asdict(create_data), ) await doc.insert() - return DomainResourceAllocation.model_validate(doc) + return DomainResourceAllocation(**doc.model_dump(include=set(DomainResourceAllocation.__dataclass_fields__))) async def release_allocation(self, allocation_id: str) -> bool: doc = await ResourceAllocationDocument.find_one(ResourceAllocationDocument.allocation_id == allocation_id) diff --git a/backend/app/db/repositories/saga_repository.py b/backend/app/db/repositories/saga_repository.py index 405db64b..639369e6 100644 --- a/backend/app/db/repositories/saga_repository.py +++ b/backend/app/db/repositories/saga_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime from typing import Any @@ -36,7 +37,7 @@ def _filter_conditions(self, saga_filter: SagaFilter) -> list[BaseFindOperator]: async def upsert_saga(self, saga: Saga) -> bool: existing = await SagaDocument.find_one(SagaDocument.saga_id == saga.saga_id) - doc = SagaDocument(**saga.model_dump()) + doc = SagaDocument(**dataclasses.asdict(saga)) if existing: doc.id = existing.id await doc.save() @@ -48,7 +49,7 @@ async def get_or_create_saga(self, saga: Saga) -> tuple[Saga, bool]: Uses MongoDB findOneAndUpdate with $setOnInsert + upsert in a single atomic round-trip. Returns (saga, created). """ - insert_doc = SagaDocument(**saga.model_dump()) + insert_doc = SagaDocument(**dataclasses.asdict(saga)) insert_data = insert_doc.model_dump() insert_data.pop("id", None) insert_data.pop("revision_id", None) @@ -64,18 +65,18 @@ async def get_or_create_saga(self, saga: Saga) -> tuple[Saga, bool]: ) assert doc is not None created = doc.saga_id == saga.saga_id - return Saga.model_validate(doc), created + return Saga(**doc.model_dump(include=set(Saga.__dataclass_fields__))), created async def get_saga_by_execution_and_name(self, execution_id: str, saga_name: str) -> Saga | None: doc = await SagaDocument.find_one( SagaDocument.execution_id == execution_id, SagaDocument.saga_name == saga_name, ) - return Saga.model_validate(doc) if doc else None + return Saga(**doc.model_dump(include=set(Saga.__dataclass_fields__))) if doc else None async def get_saga(self, saga_id: str) -> Saga | None: doc = await SagaDocument.find_one(SagaDocument.saga_id == saga_id) - return Saga.model_validate(doc) if doc else None + return Saga(**doc.model_dump(include=set(Saga.__dataclass_fields__))) if doc else None async def get_sagas_by_execution( self, execution_id: str, state: SagaState | None = None, limit: int = 100, skip: int = 0 @@ -88,7 +89,7 @@ async def get_sagas_by_execution( total = await query.count() docs = await query.sort([("created_at", SortDirection.DESCENDING)]).skip(skip).limit(limit).to_list() return SagaListResult( - sagas=[Saga.model_validate(d) for d in docs], + sagas=[Saga(**d.model_dump(include=set(Saga.__dataclass_fields__))) for d in docs], total=total, skip=skip, limit=limit, @@ -100,7 +101,7 @@ async def list_sagas(self, saga_filter: SagaFilter, limit: int = 100, skip: int total = await query.count() docs = await query.sort([("created_at", SortDirection.DESCENDING)]).skip(skip).limit(limit).to_list() return SagaListResult( - sagas=[Saga.model_validate(d) for d in docs], + sagas=[Saga(**d.model_dump(include=set(Saga.__dataclass_fields__))) for d in docs], total=total, skip=skip, limit=limit, @@ -125,7 +126,7 @@ async def find_timed_out_sagas( .limit(limit) .to_list() ) - return [Saga.model_validate(d) for d in docs] + return [Saga(**d.model_dump(include=set(Saga.__dataclass_fields__))) for d in docs] async def get_saga_statistics(self, saga_filter: SagaFilter | None = None) -> dict[str, Any]: conditions = self._filter_conditions(saga_filter) if saga_filter else [] diff --git a/backend/app/db/repositories/saved_script_repository.py b/backend/app/db/repositories/saved_script_repository.py index e209d641..5b1eec44 100644 --- a/backend/app/db/repositories/saved_script_repository.py +++ b/backend/app/db/repositories/saved_script_repository.py @@ -1,19 +1,21 @@ +import dataclasses + from app.db.docs import SavedScriptDocument from app.domain.saved_script import DomainSavedScript, DomainSavedScriptCreate, DomainSavedScriptUpdate class SavedScriptRepository: async def create_saved_script(self, create_data: DomainSavedScriptCreate, user_id: str) -> DomainSavedScript: - doc = SavedScriptDocument(**create_data.model_dump(), user_id=user_id) + doc = SavedScriptDocument(**dataclasses.asdict(create_data), user_id=user_id) await doc.insert() - return DomainSavedScript.model_validate(doc) + return DomainSavedScript(**doc.model_dump(include=set(DomainSavedScript.__dataclass_fields__))) async def get_saved_script(self, script_id: str, user_id: str) -> DomainSavedScript | None: doc = await SavedScriptDocument.find_one( SavedScriptDocument.script_id == script_id, SavedScriptDocument.user_id == user_id, ) - return DomainSavedScript.model_validate(doc) if doc else None + return DomainSavedScript(**doc.model_dump(include=set(DomainSavedScript.__dataclass_fields__))) if doc else None async def update_saved_script( self, @@ -28,9 +30,9 @@ async def update_saved_script( if not doc: return None - update_dict = update_data.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(update_data).items() if v is not None} await doc.set(update_dict) - return DomainSavedScript.model_validate(doc) + return DomainSavedScript(**doc.model_dump(include=set(DomainSavedScript.__dataclass_fields__))) async def delete_saved_script(self, script_id: str, user_id: str) -> bool: doc = await SavedScriptDocument.find_one( @@ -44,4 +46,4 @@ async def delete_saved_script(self, script_id: str, user_id: str) -> bool: async def list_saved_scripts(self, user_id: str) -> list[DomainSavedScript]: docs = await SavedScriptDocument.find(SavedScriptDocument.user_id == user_id).to_list() - return [DomainSavedScript.model_validate(d) for d in docs] + return [DomainSavedScript(**d.model_dump(include=set(DomainSavedScript.__dataclass_fields__))) for d in docs] diff --git a/backend/app/db/repositories/sse_repository.py b/backend/app/db/repositories/sse_repository.py index 4cbfbd8f..6c371aad 100644 --- a/backend/app/db/repositories/sse_repository.py +++ b/backend/app/db/repositories/sse_repository.py @@ -20,4 +20,4 @@ async def get_execution(self, execution_id: str) -> DomainExecution | None: doc = await ExecutionDocument.find_one(ExecutionDocument.execution_id == execution_id) if not doc: return None - return DomainExecution.model_validate(doc) + return DomainExecution(**doc.model_dump(include=set(DomainExecution.__dataclass_fields__))) diff --git a/backend/app/db/repositories/user_repository.py b/backend/app/db/repositories/user_repository.py index 213119d7..3079d328 100644 --- a/backend/app/db/repositories/user_repository.py +++ b/backend/app/db/repositories/user_repository.py @@ -1,3 +1,4 @@ +import dataclasses import re from datetime import datetime, timezone @@ -14,19 +15,19 @@ class UserRepository: async def get_user(self, username: str) -> User | None: doc = await UserDocument.find_one(UserDocument.username == username) - return User.model_validate(doc) if doc else None + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) if doc else None async def create_user(self, create_data: DomainUserCreate) -> User: - doc = UserDocument(**create_data.model_dump()) + doc = UserDocument(**dataclasses.asdict(create_data)) try: await doc.insert() except DuplicateKeyError as e: raise ConflictError("User already exists") from e - return User.model_validate(doc) + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) async def get_user_by_id(self, user_id: str) -> User | None: doc = await UserDocument.find_one(UserDocument.user_id == user_id) - return User.model_validate(doc) if doc else None + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) if doc else None async def list_users( self, limit: int = 100, offset: int = 0, search: str | None = None, role: UserRole | None = None @@ -49,7 +50,7 @@ async def list_users( total = await query.count() docs = await query.skip(offset).limit(limit).to_list() return UserListResult( - users=[User.model_validate(d) for d in docs], + users=[User(**d.model_dump(include=set(User.__dataclass_fields__))) for d in docs], total=total, offset=offset, limit=limit, @@ -60,11 +61,11 @@ async def update_user(self, user_id: str, update_data: DomainUserUpdate) -> User if not doc: return None - update_dict = update_data.model_dump(exclude_none=True) + update_dict = {k: v for k, v in dataclasses.asdict(update_data).items() if v is not None} if update_dict: update_dict["updated_at"] = datetime.now(timezone.utc) await doc.set(update_dict) - return User.model_validate(doc) + return User(**doc.model_dump(include=set(User.__dataclass_fields__))) async def delete_user(self, user_id: str) -> bool: doc = await UserDocument.find_one(UserDocument.user_id == user_id) diff --git a/backend/app/db/repositories/user_settings_repository.py b/backend/app/db/repositories/user_settings_repository.py index 08639ba8..aa1a53bd 100644 --- a/backend/app/db/repositories/user_settings_repository.py +++ b/backend/app/db/repositories/user_settings_repository.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime import structlog @@ -18,11 +19,11 @@ async def get_snapshot(self, user_id: str) -> DomainUserSettings | None: doc = await UserSettingsDocument.find_one(UserSettingsDocument.user_id == user_id) if not doc: return None - return DomainUserSettings.model_validate(doc) + return DomainUserSettings(**doc.model_dump(include=set(DomainUserSettings.__dataclass_fields__))) async def create_snapshot(self, settings: DomainUserSettings) -> None: existing = await UserSettingsDocument.find_one(UserSettingsDocument.user_id == settings.user_id) - doc = UserSettingsDocument(**settings.model_dump()) + doc = UserSettingsDocument(**dataclasses.asdict(settings)) if existing: doc.id = existing.id await doc.save() @@ -52,7 +53,10 @@ async def get_settings_events( find_query = find_query.limit(limit) docs = await find_query.to_list() - return [DomainUserSettingsChangedEvent.model_validate(e) for e in docs] + return [ + DomainUserSettingsChangedEvent(**e.model_dump(include=set(DomainUserSettingsChangedEvent.__dataclass_fields__))) + for e in docs + ] async def count_events_since_snapshot(self, user_id: str) -> int: aggregate_id = f"user_settings_{user_id}" diff --git a/backend/app/dlq/__init__.py b/backend/app/dlq/__init__.py index 73f37738..34651793 100644 --- a/backend/app/dlq/__init__.py +++ b/backend/app/dlq/__init__.py @@ -12,7 +12,6 @@ DLQMessageStatus, DLQMessageUpdate, DLQRetryResult, - DLQTopicSummary, RetryPolicy, RetryStrategy, ) @@ -27,5 +26,4 @@ "DLQRetryResult", "DLQBatchRetryResult", "DLQMessageListResult", - "DLQTopicSummary", ] diff --git a/backend/app/dlq/models.py b/backend/app/dlq/models.py index c4fb5168..4eb0f9df 100644 --- a/backend/app/dlq/models.py +++ b/backend/app/dlq/models.py @@ -143,15 +143,3 @@ class DLQMessageListResult: limit: int -class DLQTopicSummary(BaseModel): - """Summary of a topic in DLQ.""" - - model_config = ConfigDict(from_attributes=True) - - topic: str - total_messages: int - status_breakdown: dict[DLQMessageStatus, int] - oldest_message: datetime - newest_message: datetime - avg_retry_count: float - max_retry_count: int diff --git a/backend/app/domain/admin/__init__.py b/backend/app/domain/admin/__init__.py index 74dfca3c..9753ce8b 100644 --- a/backend/app/domain/admin/__init__.py +++ b/backend/app/domain/admin/__init__.py @@ -1,10 +1,12 @@ +from app.schemas_pydantic.admin_settings import SystemSettings +from app.schemas_pydantic.replay_schemas import ExecutionResultSummary + from .overview_models import ( AdminUserOverviewDomain, DerivedCountsDomain, RateLimitSummaryDomain, ) from .replay_models import ( - ExecutionResultSummary, ReplaySessionData, ReplaySessionStatusDetail, ReplaySessionStatusInfo, @@ -13,7 +15,6 @@ from .settings_models import ( AuditAction, LogLevel, - SystemSettings, ) __all__ = [ diff --git a/backend/app/domain/admin/replay_models.py b/backend/app/domain/admin/replay_models.py index c6c22704..cc3df43f 100644 --- a/backend/app/domain/admin/replay_models.py +++ b/backend/app/domain/admin/replay_models.py @@ -1,37 +1,19 @@ -from dataclasses import field +from dataclasses import dataclass, field from datetime import datetime +from typing import Any -from pydantic import BaseModel, ConfigDict, Field -from pydantic.dataclasses import dataclass - -from app.domain.enums import ExecutionErrorType, ExecutionStatus, ReplayStatus -from app.domain.events import EventSummary, ResourceUsageDomain +from app.domain.enums import ReplayStatus +from app.domain.events import EventSummary from app.domain.replay import ReplayFilter, ReplaySessionState +from app.schemas_pydantic.replay_schemas import ExecutionResultSummary -class ExecutionResultSummary(BaseModel): - """Summary of an execution result for replay status.""" - - model_config = ConfigDict(from_attributes=True) - - execution_id: str - status: ExecutionStatus | None - stdout: str | None - stderr: str | None - exit_code: int | None - lang: str - lang_version: str - created_at: datetime - updated_at: datetime - resource_usage: ResourceUsageDomain | None = None - error_type: ExecutionErrorType | None = None - - +@dataclass class ReplaySessionStatusDetail(ReplaySessionState): """Status detail with computed metadata for admin API.""" estimated_completion: datetime | None = None - execution_results: list[ExecutionResultSummary] = Field(default_factory=list) + execution_results: list[ExecutionResultSummary] = field(default_factory=list) @dataclass @@ -61,3 +43,8 @@ class ReplaySessionData: dry_run: bool filter: ReplayFilter events_preview: list[EventSummary] = field(default_factory=list) + + def __post_init__(self) -> None: + raw: Any = self.filter + if isinstance(raw, dict): + self.filter = ReplayFilter(**raw) diff --git a/backend/app/domain/admin/replay_updates.py b/backend/app/domain/admin/replay_updates.py index 89f8eb5e..40118ab7 100644 --- a/backend/app/domain/admin/replay_updates.py +++ b/backend/app/domain/admin/replay_updates.py @@ -1,15 +1,13 @@ +from dataclasses import dataclass from datetime import datetime -from pydantic import BaseModel, ConfigDict - from app.domain.enums import ReplayStatus -class ReplaySessionUpdate(BaseModel): +@dataclass +class ReplaySessionUpdate: """Domain model for replay session updates.""" - model_config = ConfigDict(from_attributes=True) - status: ReplayStatus | None = None total_events: int | None = None replayed_events: int | None = None diff --git a/backend/app/domain/admin/settings_models.py b/backend/app/domain/admin/settings_models.py index 1ab52ca1..d29a97ee 100644 --- a/backend/app/domain/admin/settings_models.py +++ b/backend/app/domain/admin/settings_models.py @@ -1,10 +1,5 @@ -from pydantic import BaseModel, ConfigDict, Field - from app.core.utils import StringEnum -K8S_MEMORY_PATTERN = r"^[1-9]\d*(Ki|Mi|Gi)$" -K8S_CPU_PATTERN = r"^[1-9]\d*m$" - class AuditAction(StringEnum): """Audit log action types.""" @@ -21,24 +16,3 @@ class LogLevel(StringEnum): WARNING = "WARNING" ERROR = "ERROR" CRITICAL = "CRITICAL" - - -class SystemSettings(BaseModel): - """Flat system-wide settings — execution, security, and monitoring.""" - - model_config = ConfigDict(from_attributes=True, extra="ignore", use_enum_values=True) - - max_timeout_seconds: int = Field(300, ge=1, le=3600) - memory_limit: str = Field("512Mi", pattern=K8S_MEMORY_PATTERN) - cpu_limit: str = Field("2000m", pattern=K8S_CPU_PATTERN) - max_concurrent_executions: int = Field(10, ge=1, le=100) - - password_min_length: int = Field(8, ge=8, le=32) - session_timeout_minutes: int = Field(60, ge=5, le=1440) - max_login_attempts: int = Field(5, ge=3, le=10) - lockout_duration_minutes: int = Field(15, ge=5, le=60) - - metrics_retention_days: int = Field(30, ge=7, le=90) - log_level: LogLevel = LogLevel.INFO - enable_tracing: bool = True - sampling_rate: float = Field(0.1, ge=0.0, le=1.0) diff --git a/backend/app/domain/events/__init__.py b/backend/app/domain/events/__init__.py index 18d34eb8..4700380d 100644 --- a/backend/app/domain/events/__init__.py +++ b/backend/app/domain/events/__init__.py @@ -1,19 +1,3 @@ -from app.domain.events.event_models import ( - EventBrowseResult, - EventDetail, - EventExportRow, - EventFilter, - EventListResult, - EventProjection, - EventReplayInfo, - EventStatistics, - EventSummary, - EventTypeCount, - ExecutionEventsResult, - HourlyEventCount, - ServiceEventCount, - UserEventCount, -) from app.domain.events.typed import ( # Saga Command Events AllocateResourcesCommandEvent, @@ -95,9 +79,25 @@ UserSettingsUpdatedEvent, UserUpdatedEvent, ) +from app.schemas_pydantic.event_schemas import ( + EventBrowseResult, + EventDetail, + EventExportRow, + EventFilter, + EventListResult, + EventProjection, + EventReplayInfo, + EventStatistics, + EventSummary, + EventTypeCount, + ExecutionEventsResult, + HourlyEventCount, + ServiceEventCount, + UserEventCount, +) __all__ = [ - # Query/filter/result types + # Query/filter/result types (now in schemas_pydantic/event_schemas.py) "EventBrowseResult", "EventDetail", "EventExportRow", diff --git a/backend/app/domain/events/event_models.py b/backend/app/domain/events/event_models.py index 7561054f..e69de29b 100644 --- a/backend/app/domain/events/event_models.py +++ b/backend/app/domain/events/event_models.py @@ -1,162 +0,0 @@ -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -from app.domain.enums import EventType -from app.domain.events.typed import DomainEvent, EventMetadata - -MongoQueryValue = str | dict[str, str | list[str] | float | datetime] -MongoQuery = dict[str, MongoQueryValue] - - -class EventSummary(BaseModel): - """Lightweight event summary for lists and previews.""" - - model_config = ConfigDict(from_attributes=True) - - event_id: str - event_type: EventType - timestamp: datetime - aggregate_id: str | None = None - - -class EventFilter(BaseModel): - """Filter criteria for querying events.""" - - model_config = ConfigDict(from_attributes=True) - - event_types: list[EventType] | None = None - aggregate_id: str | None = None - user_id: str | None = None - service_name: str | None = None - start_time: datetime | None = None - end_time: datetime | None = None - search_text: str | None = None - status: str | None = None - - -class EventListResult(BaseModel): - """Result of event list query.""" - - model_config = ConfigDict(from_attributes=True) - - events: list[DomainEvent] - total: int - skip: int - limit: int - has_more: bool - - -class EventBrowseResult(BaseModel): - """Result for event browsing.""" - - model_config = ConfigDict(from_attributes=True) - - events: list[DomainEvent] - total: int - skip: int - limit: int - - -class EventDetail(BaseModel): - """Detailed event information with related events.""" - - model_config = ConfigDict(from_attributes=True) - - event: DomainEvent - related_events: list[EventSummary] = Field(default_factory=list) - timeline: list[EventSummary] = Field(default_factory=list) - - -class EventTypeCount(BaseModel): - model_config = ConfigDict(from_attributes=True) - - event_type: EventType - count: int - - -class HourlyEventCount(BaseModel): - model_config = ConfigDict(from_attributes=True) - - hour: str - count: int - - -class ServiceEventCount(BaseModel): - model_config = ConfigDict(from_attributes=True) - - service_name: str - count: int - - -class UserEventCount(BaseModel): - model_config = ConfigDict(from_attributes=True) - - user_id: str - event_count: int - - -class EventStatistics(BaseModel): - """Event statistics.""" - - model_config = ConfigDict(from_attributes=True) - - total_events: int - events_by_type: list[EventTypeCount] = Field(default_factory=list) - events_by_service: list[ServiceEventCount] = Field(default_factory=list) - events_by_hour: list[HourlyEventCount] = Field(default_factory=list) - top_users: list[UserEventCount] = Field(default_factory=list) - error_rate: float = 0.0 - avg_processing_time: float = 0.0 - start_time: datetime | None = None - end_time: datetime | None = None - - -class EventProjection(BaseModel): - """Configuration for event projections.""" - - model_config = ConfigDict(from_attributes=True) - - name: str - pipeline: list[dict[str, Any]] - output_collection: str - description: str | None = None - source_events: list[EventType] | None = None - refresh_interval_seconds: int = 300 - last_updated: datetime | None = None - - -class EventReplayInfo(BaseModel): - """Information for event replay.""" - - model_config = ConfigDict(from_attributes=True) - - events: list[DomainEvent] - event_count: int - event_types: list[EventType] - start_time: datetime - end_time: datetime - - -class ExecutionEventsResult(BaseModel): - """Result of execution events query.""" - - model_config = ConfigDict(from_attributes=True) - - events: list[DomainEvent] - access_allowed: bool - include_system_events: bool - - - -class EventExportRow(BaseModel): - """Event export row for CSV.""" - - model_config = ConfigDict(from_attributes=True) - - event_id: str - event_type: EventType - timestamp: datetime - aggregate_id: str | None = None - metadata: EventMetadata diff --git a/backend/app/domain/execution/models.py b/backend/app/domain/execution/models.py index 1e03751c..016e04e9 100644 --- a/backend/app/domain/execution/models.py +++ b/backend/app/domain/execution/models.py @@ -1,11 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Field - from app.domain.enums import CancelStatus, ExecutionErrorType, ExecutionStatus from app.domain.events import EventMetadata, ResourceUsageDomain @@ -18,52 +16,48 @@ class CancelResult: event_id: str | None -class DomainExecution(BaseModel): - model_config = ConfigDict(from_attributes=True) - - execution_id: str = Field(default_factory=lambda: str(uuid4())) +@dataclass +class DomainExecution: + execution_id: str = field(default_factory=lambda: str(uuid4())) script: str = "" status: ExecutionStatus = ExecutionStatus.QUEUED stdout: str | None = None stderr: str | None = None lang: str = "python" lang_version: str = "3.11" - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) resource_usage: ResourceUsageDomain | None = None user_id: str | None = None exit_code: int | None = None error_type: ExecutionErrorType | None = None -class ExecutionResultDomain(BaseModel): - model_config = ConfigDict(from_attributes=True, extra="ignore") - +@dataclass +class ExecutionResultDomain: execution_id: str status: ExecutionStatus exit_code: int stdout: str stderr: str resource_usage: ResourceUsageDomain | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) metadata: EventMetadata | None = None error_type: ExecutionErrorType | None = None -class LanguageInfoDomain(BaseModel): +@dataclass +class LanguageInfoDomain: """Language runtime information.""" - model_config = ConfigDict(from_attributes=True) - versions: list[str] file_ext: str -class ResourceLimitsDomain(BaseModel): +@dataclass +class ResourceLimitsDomain: """K8s resource limits configuration.""" - model_config = ConfigDict(from_attributes=True) - cpu_limit: str memory_limit: str cpu_request: str @@ -72,11 +66,10 @@ class ResourceLimitsDomain(BaseModel): supported_runtimes: dict[str, LanguageInfoDomain] -class DomainExecutionCreate(BaseModel): +@dataclass +class DomainExecutionCreate: """Execution creation data for repository.""" - model_config = ConfigDict(from_attributes=True) - script: str user_id: str lang: str = "python" @@ -84,11 +77,10 @@ class DomainExecutionCreate(BaseModel): status: ExecutionStatus = ExecutionStatus.QUEUED -class DomainExecutionUpdate(BaseModel): +@dataclass +class DomainExecutionUpdate: """Execution update data for repository.""" - model_config = ConfigDict(from_attributes=True) - status: ExecutionStatus | None = None stdout: str | None = None stderr: str | None = None diff --git a/backend/app/domain/notification/models.py b/backend/app/domain/notification/models.py index cb17297b..e9e14165 100644 --- a/backend/app/domain/notification/models.py +++ b/backend/app/domain/notification/models.py @@ -1,105 +1,89 @@ -from __future__ import annotations - +from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Field - from app.domain.enums import NotificationChannel, NotificationSeverity, NotificationStatus -class DomainNotification(BaseModel): - model_config = ConfigDict(from_attributes=True) - - notification_id: str = Field(default_factory=lambda: str(uuid4())) +@dataclass +class DomainNotification: + notification_id: str = field(default_factory=lambda: str(uuid4())) user_id: str = "" channel: NotificationChannel = NotificationChannel.IN_APP severity: NotificationSeverity = NotificationSeverity.MEDIUM status: NotificationStatus = NotificationStatus.PENDING - subject: str = "" body: str = "" action_url: str = "" - tags: list[str] = Field(default_factory=list) - - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + tags: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) scheduled_for: datetime | None = None sent_at: datetime | None = None delivered_at: datetime | None = None read_at: datetime | None = None clicked_at: datetime | None = None failed_at: datetime | None = None - retry_count: int = 0 - max_retries: int = Field(3, ge=1) + max_retries: int = 3 error_message: str | None = None - - metadata: dict[str, Any] = Field(default_factory=dict) - + metadata: dict[str, Any] = field(default_factory=dict) webhook_url: str | None = None webhook_headers: dict[str, str] | None = None -class DomainNotificationSubscription(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainNotificationSubscription: user_id: str channel: NotificationChannel enabled: bool = True - severities: list[NotificationSeverity] = Field(default_factory=list) - include_tags: list[str] = Field(default_factory=list) - exclude_tags: list[str] = Field(default_factory=list) + severities: list[NotificationSeverity] = field(default_factory=list) + include_tags: list[str] = field(default_factory=list) + exclude_tags: list[str] = field(default_factory=list) webhook_url: str | None = None slack_webhook: str | None = None - quiet_hours_enabled: bool = False quiet_hours_start: str | None = None quiet_hours_end: str | None = None timezone: str = "UTC" batch_interval_minutes: int = 60 + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + updated_at: datetime = field(default_factory=lambda: datetime.now(UTC)) - created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) - - -class DomainNotificationListResult(BaseModel): - model_config = ConfigDict(from_attributes=True) +@dataclass +class DomainNotificationListResult: notifications: list[DomainNotification] total: int unread_count: int -class DomainSubscriptionListResult(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainSubscriptionListResult: subscriptions: list[DomainNotificationSubscription] -class DomainNotificationCreate(BaseModel): +@dataclass +class DomainNotificationCreate: """Data for creating a notification.""" - model_config = ConfigDict(from_attributes=True) - user_id: str channel: NotificationChannel subject: str body: str - severity: NotificationSeverity = NotificationSeverity.MEDIUM action_url: str - tags: list[str] = Field(default_factory=list) + severity: NotificationSeverity = NotificationSeverity.MEDIUM + tags: list[str] = field(default_factory=list) scheduled_for: datetime | None = None webhook_url: str | None = None webhook_headers: dict[str, str] | None = None - metadata: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) -class DomainNotificationUpdate(BaseModel): +@dataclass +class DomainNotificationUpdate: """Data for updating a notification.""" - model_config = ConfigDict(from_attributes=True) - status: NotificationStatus | None = None sent_at: datetime | None = None delivered_at: datetime | None = None @@ -110,11 +94,10 @@ class DomainNotificationUpdate(BaseModel): error_message: str | None = None -class DomainSubscriptionUpdate(BaseModel): +@dataclass +class DomainSubscriptionUpdate: """Data for updating a subscription.""" - model_config = ConfigDict(from_attributes=True) - enabled: bool | None = None severities: list[NotificationSeverity] | None = None include_tags: list[str] | None = None diff --git a/backend/app/domain/rate_limit/rate_limit_models.py b/backend/app/domain/rate_limit/rate_limit_models.py index 034c388e..aaac7a88 100644 --- a/backend/app/domain/rate_limit/rate_limit_models.py +++ b/backend/app/domain/rate_limit/rate_limit_models.py @@ -1,11 +1,10 @@ from __future__ import annotations import re -from dataclasses import field +from dataclasses import dataclass, field from datetime import datetime, timezone from pydantic import BaseModel, ConfigDict, Field -from pydantic.dataclasses import dataclass from app.core.utils import StringEnum @@ -27,7 +26,7 @@ class EndpointGroup(StringEnum): API = "api" -@dataclass(config=ConfigDict(from_attributes=True)) +@dataclass class EndpointUsageStats: """Usage statistics for a single endpoint (IETF RateLimit-style).""" diff --git a/backend/app/domain/replay/models.py b/backend/app/domain/replay/models.py index 3914e52c..856099bc 100644 --- a/backend/app/domain/replay/models.py +++ b/backend/app/domain/replay/models.py @@ -1,13 +1,13 @@ +from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Field - from app.domain.enums import EventType, KafkaTopic, ReplayStatus, ReplayTarget, ReplayType -class ReplayError(BaseModel): +@dataclass +class ReplayError: """Error details for replay operations. Attributes: @@ -19,29 +19,21 @@ class ReplayError(BaseModel): event_id: ID of the event that failed to replay. Present for event-level errors. """ - model_config = ConfigDict(from_attributes=True) - timestamp: datetime error: str error_type: str | None = None event_id: str | None = None -class ReplayFilter(BaseModel): - model_config = ConfigDict(from_attributes=True) - - # Event selection filters +@dataclass +class ReplayFilter: event_ids: list[str] | None = None execution_id: str | None = None aggregate_id: str | None = None event_types: list[EventType] | None = None exclude_event_types: list[EventType] | None = None - - # Time range start_time: datetime | None = None end_time: datetime | None = None - - # Metadata filters user_id: str | None = None service_name: str | None = None @@ -97,68 +89,73 @@ def to_mongo_query(self) -> dict[str, Any]: return query -class ReplayConfig(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class ReplayConfig: replay_type: ReplayType target: ReplayTarget = ReplayTarget.KAFKA - filter: ReplayFilter = Field(default_factory=ReplayFilter) - - speed_multiplier: float = Field(default=1.0, ge=0.1, le=100.0) + filter: ReplayFilter = field(default_factory=ReplayFilter) + speed_multiplier: float = 1.0 preserve_timestamps: bool = False - batch_size: int = Field(default=100, ge=1, le=1000) - max_events: int | None = Field(default=None, ge=1) - + batch_size: int = 100 + max_events: int | None = None target_topics: dict[EventType, KafkaTopic] | None = None target_file_path: str | None = None - skip_errors: bool = True retry_failed: bool = False retry_attempts: int = 3 - enable_progress_tracking: bool = True + def __post_init__(self) -> None: + raw: Any = self.filter + if isinstance(raw, dict): + self.filter = ReplayFilter(**raw) + if not (0.1 <= self.speed_multiplier <= 100.0): + raise ValueError("speed_multiplier must be between 0.1 and 100.0") + if not (1 <= self.batch_size <= 1000): + raise ValueError("batch_size must be between 1 and 1000") + if self.max_events is not None and self.max_events < 1: + raise ValueError("max_events must be >= 1") -class ReplaySessionState(BaseModel): - """Domain replay session model used by services and repository.""" - model_config = ConfigDict(from_attributes=True) +@dataclass +class ReplaySessionState: + """Domain replay session model used by services and repository.""" session_id: str config: ReplayConfig status: ReplayStatus = ReplayStatus.CREATED - total_events: int = 0 replayed_events: int = 0 failed_events: int = 0 skipped_events: int = 0 - - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) started_at: datetime | None = None completed_at: datetime | None = None last_event_at: datetime | None = None - - errors: list[ReplayError] = Field(default_factory=list) - - # Tracking and admin fields - replay_id: str = Field(default_factory=lambda: str(uuid4())) + errors: list[ReplayError] = field(default_factory=list) + replay_id: str = field(default_factory=lambda: str(uuid4())) created_by: str | None = None target_service: str | None = None dry_run: bool = False - triggered_executions: list[str] = Field(default_factory=list) + triggered_executions: list[str] = field(default_factory=list) error: str | None = None + def __post_init__(self) -> None: + raw: Any = self.config + if isinstance(raw, dict): + self.config = ReplayConfig(**raw) + raw_errors: Any = self.errors + self.errors = [ReplayError(**e) if isinstance(e, dict) else e for e in raw_errors] -class ReplayOperationResult(BaseModel): - model_config = ConfigDict(from_attributes=True) +@dataclass +class ReplayOperationResult: session_id: str status: ReplayStatus message: str -class CleanupResult(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class CleanupResult: removed_sessions: int message: str diff --git a/backend/app/domain/saga/models.py b/backend/app/domain/saga/models.py index e0ede7f6..4c69a66c 100644 --- a/backend/app/domain/saga/models.py +++ b/backend/app/domain/saga/models.py @@ -1,16 +1,15 @@ +from dataclasses import dataclass, field from datetime import datetime, timezone +from typing import Any from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Field - from app.domain.enums import SagaState -class SagaContextData(BaseModel): +@dataclass +class SagaContextData: """Typed saga execution context. Populated incrementally by saga steps.""" - model_config = ConfigDict(from_attributes=True) - execution_id: str = "" language: str = "" language_version: str | None = None @@ -19,33 +18,36 @@ class SagaContextData(BaseModel): allocation_id: str | None = None resources_allocated: bool = False pod_creation_triggered: bool = False - user_id: str = Field(default_factory=lambda: str(uuid4())) + user_id: str = field(default_factory=lambda: str(uuid4())) -class Saga(BaseModel): +@dataclass +class Saga: """Domain model for saga.""" - model_config = ConfigDict(from_attributes=True) - saga_id: str saga_name: str execution_id: str state: SagaState current_step: str | None = None - completed_steps: list[str] = Field(default_factory=list) - compensated_steps: list[str] = Field(default_factory=list) - context_data: SagaContextData = Field(default_factory=SagaContextData) + completed_steps: list[str] = field(default_factory=list) + compensated_steps: list[str] = field(default_factory=list) + context_data: SagaContextData = field(default_factory=SagaContextData) error_message: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) completed_at: datetime | None = None retry_count: int = 0 + def __post_init__(self) -> None: + raw: Any = self.context_data + if isinstance(raw, dict): + self.context_data = SagaContextData(**raw) -class SagaFilter(BaseModel): - """Filter criteria for saga queries.""" - model_config = ConfigDict(from_attributes=True) +@dataclass +class SagaFilter: + """Filter criteria for saga queries.""" state: SagaState | None = None execution_ids: list[str] | None = None @@ -56,22 +58,25 @@ class SagaFilter(BaseModel): error_status: bool | None = None -class SagaQuery(BaseModel): +@dataclass +class SagaQuery: """Query parameters for saga search.""" - model_config = ConfigDict(from_attributes=True) - filter: SagaFilter sort_by: str = "created_at" sort_order: str = "desc" limit: int = 100 skip: int = 0 + def __post_init__(self) -> None: + raw: Any = self.filter + if isinstance(raw, dict): + self.filter = SagaFilter(**raw) -class SagaListResult(BaseModel): - """Result of saga list query.""" - model_config = ConfigDict(from_attributes=True) +@dataclass +class SagaListResult: + """Result of saga list query.""" sagas: list[Saga] total: int @@ -84,57 +89,55 @@ def has_more(self) -> bool: return (self.skip + len(self.sagas)) < self.total -class SagaCancellationResult(BaseModel): +@dataclass +class SagaCancellationResult: """Result of saga cancellation.""" - model_config = ConfigDict(from_attributes=True) - success: bool message: str saga_id: str -class SagaConfig(BaseModel): +@dataclass +class SagaConfig: """Configuration for saga orchestration (domain).""" - model_config = ConfigDict(from_attributes=True) - name: str timeout_seconds: int = 300 max_retries: int = 3 retry_delay_seconds: int = 5 enable_compensation: bool = True store_events: bool = True - # When True, saga steps publish orchestration commands (e.g., to k8s worker). - # Keep False when another component (e.g., coordinator) publishes commands - # to avoid duplicate actions while still creating saga instances. publish_commands: bool = False -class SagaInstance(BaseModel): +@dataclass +class SagaInstance: """Runtime instance of a saga execution (domain).""" - model_config = ConfigDict(from_attributes=True) - saga_name: str execution_id: str state: SagaState = SagaState.CREATED - saga_id: str = Field(default_factory=lambda: str(uuid4())) + saga_id: str = field(default_factory=lambda: str(uuid4())) current_step: str | None = None - completed_steps: list[str] = Field(default_factory=list) - compensated_steps: list[str] = Field(default_factory=list) - context_data: SagaContextData = Field(default_factory=SagaContextData) + completed_steps: list[str] = field(default_factory=list) + compensated_steps: list[str] = field(default_factory=list) + context_data: SagaContextData = field(default_factory=SagaContextData) error_message: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) completed_at: datetime | None = None retry_count: int = 0 + def __post_init__(self) -> None: + raw: Any = self.context_data + if isinstance(raw, dict): + self.context_data = SagaContextData(**raw) -class DomainResourceAllocation(BaseModel): - """Domain model for resource allocation.""" - model_config = ConfigDict(from_attributes=True) +@dataclass +class DomainResourceAllocation: + """Domain model for resource allocation.""" allocation_id: str execution_id: str @@ -144,15 +147,14 @@ class DomainResourceAllocation(BaseModel): cpu_limit: str memory_limit: str status: str = "active" - allocated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + allocated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) released_at: datetime | None = None -class DomainResourceAllocationCreate(BaseModel): +@dataclass +class DomainResourceAllocationCreate: """Data for creating a resource allocation.""" - model_config = ConfigDict(from_attributes=True) - execution_id: str language: str cpu_request: str diff --git a/backend/app/domain/saved_script/models.py b/backend/app/domain/saved_script/models.py index ee4e6a90..58df0f17 100644 --- a/backend/app/domain/saved_script/models.py +++ b/backend/app/domain/saved_script/models.py @@ -1,45 +1,43 @@ from __future__ import annotations +from dataclasses import dataclass, field from datetime import datetime, timezone -from pydantic import BaseModel, ConfigDict, Field - - -class DomainSavedScriptBase(BaseModel): - model_config = ConfigDict(from_attributes=True) +@dataclass +class DomainSavedScriptBase: name: str script: str +@dataclass class DomainSavedScriptCreate(DomainSavedScriptBase): lang: str = "python" lang_version: str = "3.11" description: str | None = None +@dataclass class DomainSavedScript(DomainSavedScriptBase): script_id: str user_id: str lang: str = "python" lang_version: str = "3.11" description: str | None = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) -class DomainSavedScriptListResult(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainSavedScriptListResult: scripts: list[DomainSavedScript] -class DomainSavedScriptUpdate(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainSavedScriptUpdate: name: str | None = None script: str | None = None lang: str | None = None lang_version: str | None = None description: str | None = None - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/domain/sse/models.py b/backend/app/domain/sse/models.py index 2a783dc4..6d0165b5 100644 --- a/backend/app/domain/sse/models.py +++ b/backend/app/domain/sse/models.py @@ -1,22 +1,19 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime -from pydantic import BaseModel, ConfigDict - from app.domain.enums import ExecutionStatus -class SSEExecutionStatusDomain(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class SSEExecutionStatusDomain: execution_id: str status: ExecutionStatus timestamp: datetime -class SSEEventDomain(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class SSEEventDomain: aggregate_id: str timestamp: datetime diff --git a/backend/app/domain/user/settings_models.py b/backend/app/domain/user/settings_models.py index cc16917b..d10f0a18 100644 --- a/backend/app/domain/user/settings_models.py +++ b/backend/app/domain/user/settings_models.py @@ -1,26 +1,21 @@ -from __future__ import annotations - +from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any -from pydantic import BaseModel, ConfigDict, Field - from app.domain.enums import EventType, NotificationChannel, Theme -class DomainNotificationSettings(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainNotificationSettings: execution_completed: bool = True execution_failed: bool = True system_updates: bool = True security_alerts: bool = True - channels: list[NotificationChannel] = Field(default_factory=lambda: [NotificationChannel.IN_APP]) + channels: list[NotificationChannel] = field(default_factory=lambda: [NotificationChannel.IN_APP]) -class DomainEditorSettings(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainEditorSettings: theme: Theme = Theme.AUTO font_size: int = 14 tab_size: int = 4 @@ -29,25 +24,31 @@ class DomainEditorSettings(BaseModel): show_line_numbers: bool = True -class DomainUserSettings(BaseModel): - model_config = ConfigDict(from_attributes=True) - +@dataclass +class DomainUserSettings: user_id: str theme: Theme = Theme.AUTO timezone: str = "UTC" date_format: str = "YYYY-MM-DD" time_format: str = "24h" - notifications: DomainNotificationSettings = Field(default_factory=DomainNotificationSettings) - editor: DomainEditorSettings = Field(default_factory=DomainEditorSettings) - custom_settings: dict[str, Any] = Field(default_factory=dict) + notifications: DomainNotificationSettings = field(default_factory=DomainNotificationSettings) + editor: DomainEditorSettings = field(default_factory=DomainEditorSettings) + custom_settings: dict[str, Any] = field(default_factory=dict) version: int = 1 - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + def __post_init__(self) -> None: + raw: Any = self.notifications + if isinstance(raw, dict): + self.notifications = DomainNotificationSettings(**raw) + raw = self.editor + if isinstance(raw, dict): + self.editor = DomainEditorSettings(**raw) -class DomainUserSettingsUpdate(BaseModel): - model_config = ConfigDict(from_attributes=True) +@dataclass +class DomainUserSettingsUpdate: theme: Theme | None = None timezone: str | None = None date_format: str | None = None @@ -56,11 +57,18 @@ class DomainUserSettingsUpdate(BaseModel): editor: DomainEditorSettings | None = None custom_settings: dict[str, Any] | None = None + def __post_init__(self) -> None: + raw: Any = self.notifications + if isinstance(raw, dict): + self.notifications = DomainNotificationSettings(**raw) + raw = self.editor + if isinstance(raw, dict): + self.editor = DomainEditorSettings(**raw) -class DomainUserSettingsChangedEvent(BaseModel): - """Well-typed domain event for user settings changes.""" - model_config = ConfigDict(from_attributes=True, extra="ignore") +@dataclass +class DomainUserSettingsChangedEvent: + """Well-typed domain event for user settings changes.""" event_id: str event_type: EventType @@ -76,10 +84,17 @@ class DomainUserSettingsChangedEvent(BaseModel): custom_settings: dict[str, Any] | None = None reason: str | None = None + def __post_init__(self) -> None: + raw: Any = self.notifications + if isinstance(raw, dict): + self.notifications = DomainNotificationSettings(**raw) + raw = self.editor + if isinstance(raw, dict): + self.editor = DomainEditorSettings(**raw) -class DomainSettingsHistoryEntry(BaseModel): - model_config = ConfigDict(from_attributes=True) +@dataclass +class DomainSettingsHistoryEntry: timestamp: datetime event_type: EventType field: str @@ -88,10 +103,14 @@ class DomainSettingsHistoryEntry(BaseModel): reason: str | None = None -class CachedSettings(BaseModel): +@dataclass +class CachedSettings: """Wrapper for cached user settings with expiration time.""" - model_config = ConfigDict(from_attributes=True) - settings: DomainUserSettings expires_at: datetime + + def __post_init__(self) -> None: + raw: Any = self.settings + if isinstance(raw, dict): + self.settings = DomainUserSettings(**raw) diff --git a/backend/app/domain/user/user_models.py b/backend/app/domain/user/user_models.py index 457adb44..24d7af84 100644 --- a/backend/app/domain/user/user_models.py +++ b/backend/app/domain/user/user_models.py @@ -1,8 +1,7 @@ import re +from dataclasses import dataclass from datetime import datetime -from pydantic import BaseModel, ConfigDict - from app.core.utils import StringEnum from app.domain.enums import UserRole @@ -31,20 +30,18 @@ class UserFilterType(StringEnum): ROLE = "role" -class UserSearchFilter(BaseModel): +@dataclass +class UserSearchFilter: """User search filter criteria.""" - model_config = ConfigDict(from_attributes=True) - search_text: str | None = None role: UserRole | None = None -class User(BaseModel): +@dataclass +class User: """User domain model.""" - model_config = ConfigDict(from_attributes=True) - user_id: str username: str email: str @@ -54,17 +51,15 @@ class User(BaseModel): hashed_password: str created_at: datetime updated_at: datetime - # Rate limit summary (optional, populated by admin service) bypass_rate_limit: bool | None = None global_multiplier: float | None = None has_custom_limits: bool | None = None -class UserUpdate(BaseModel): +@dataclass +class UserUpdate: """User update domain model.""" - model_config = ConfigDict(from_attributes=True) - username: str | None = None email: str | None = None role: UserRole | None = None @@ -72,23 +67,20 @@ class UserUpdate(BaseModel): password: str | None = None - -class UserListResult(BaseModel): +@dataclass +class UserListResult: """Result of listing users.""" - model_config = ConfigDict(from_attributes=True) - users: list[User] total: int offset: int limit: int -class PasswordReset(BaseModel): +@dataclass +class PasswordReset: """Password reset domain model.""" - model_config = ConfigDict(from_attributes=True) - user_id: str new_password: str @@ -96,11 +88,10 @@ def is_valid(self) -> bool: return bool(self.user_id and self.new_password and len(self.new_password) >= 8) -class UserCreation(BaseModel): +@dataclass +class UserCreation: """User creation domain model (API-facing, with plain password).""" - model_config = ConfigDict(from_attributes=True) - username: str email: str password: str @@ -114,16 +105,15 @@ def is_valid(self) -> bool: self.username, self.email, self.password and len(self.password) >= 8, - EMAIL_PATTERN.match(self.email) is not None, # Proper email validation + EMAIL_PATTERN.match(self.email) is not None, ] ) -class DomainUserCreate(BaseModel): +@dataclass +class DomainUserCreate: """User creation data for repository (with hashed password).""" - model_config = ConfigDict(from_attributes=True) - username: str email: str hashed_password: str @@ -132,11 +122,10 @@ class DomainUserCreate(BaseModel): is_superuser: bool = False -class DomainUserUpdate(BaseModel): +@dataclass +class DomainUserUpdate: """User update data for repository (with hashed password).""" - model_config = ConfigDict(from_attributes=True) - username: str | None = None email: str | None = None role: UserRole | None = None @@ -144,11 +133,10 @@ class DomainUserUpdate(BaseModel): hashed_password: str | None = None -class UserDeleteResult(BaseModel): +@dataclass +class UserDeleteResult: """Result of deleting a user and optionally cascading to related data.""" - model_config = ConfigDict(from_attributes=True) - user_deleted: bool executions: int = 0 saved_scripts: int = 0 diff --git a/backend/app/schemas_pydantic/admin_settings.py b/backend/app/schemas_pydantic/admin_settings.py index d37c7cc9..958af6d9 100644 --- a/backend/app/schemas_pydantic/admin_settings.py +++ b/backend/app/schemas_pydantic/admin_settings.py @@ -1,4 +1,30 @@ -from app.domain.admin import SystemSettings +from pydantic import BaseModel, ConfigDict, Field + +from app.domain.admin.settings_models import LogLevel + +K8S_MEMORY_PATTERN = r"^[1-9]\d*(Ki|Mi|Gi)$" +K8S_CPU_PATTERN = r"^[1-9]\d*m$" + + +class SystemSettings(BaseModel): + """Flat system-wide settings — execution, security, and monitoring.""" + + model_config = ConfigDict(from_attributes=True, extra="ignore", use_enum_values=True) + + max_timeout_seconds: int = Field(300, ge=1, le=3600) + memory_limit: str = Field("512Mi", pattern=K8S_MEMORY_PATTERN) + cpu_limit: str = Field("2000m", pattern=K8S_CPU_PATTERN) + max_concurrent_executions: int = Field(10, ge=1, le=100) + + password_min_length: int = Field(8, ge=8, le=32) + session_timeout_minutes: int = Field(60, ge=5, le=1440) + max_login_attempts: int = Field(5, ge=3, le=10) + lockout_duration_minutes: int = Field(15, ge=5, le=60) + + metrics_retention_days: int = Field(30, ge=7, le=90) + log_level: LogLevel = LogLevel.INFO + enable_tracing: bool = True + sampling_rate: float = Field(0.1, ge=0.0, le=1.0) class SystemSettingsSchema(SystemSettings): diff --git a/backend/app/schemas_pydantic/dlq.py b/backend/app/schemas_pydantic/dlq.py index bef631f4..fb953328 100644 --- a/backend/app/schemas_pydantic/dlq.py +++ b/backend/app/schemas_pydantic/dlq.py @@ -81,6 +81,20 @@ class DLQTopicSummaryResponse(BaseModel): max_retry_count: int +class DLQTopicSummary(BaseModel): + """Summary of a topic in DLQ.""" + + model_config = ConfigDict(from_attributes=True) + + topic: str + total_messages: int + status_breakdown: dict[DLQMessageStatus, int] + oldest_message: datetime + newest_message: datetime + avg_retry_count: float + max_retry_count: int + + class DLQMessageDetail(BaseModel): """Detailed DLQ message response. Mirrors DLQMessage for direct model_validate.""" diff --git a/backend/app/schemas_pydantic/event_schemas.py b/backend/app/schemas_pydantic/event_schemas.py new file mode 100644 index 00000000..60af4780 --- /dev/null +++ b/backend/app/schemas_pydantic/event_schemas.py @@ -0,0 +1,161 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.domain.enums import EventType +from app.domain.events.typed import DomainEvent, EventMetadata + +MongoQueryValue = str | dict[str, str | list[str] | float | datetime] +MongoQuery = dict[str, MongoQueryValue] + + +class EventSummary(BaseModel): + """Lightweight event summary for lists and previews.""" + + model_config = ConfigDict(from_attributes=True) + + event_id: str + event_type: EventType + timestamp: datetime + aggregate_id: str | None = None + + +class EventFilter(BaseModel): + """Filter criteria for querying events.""" + + model_config = ConfigDict(from_attributes=True) + + event_types: list[EventType] | None = None + aggregate_id: str | None = None + user_id: str | None = None + service_name: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + search_text: str | None = None + status: str | None = None + + +class EventListResult(BaseModel): + """Result of event list query.""" + + model_config = ConfigDict(from_attributes=True) + + events: list[DomainEvent] + total: int + skip: int + limit: int + has_more: bool + + +class EventBrowseResult(BaseModel): + """Result for event browsing.""" + + model_config = ConfigDict(from_attributes=True) + + events: list[DomainEvent] + total: int + skip: int + limit: int + + +class EventDetail(BaseModel): + """Detailed event information with related events.""" + + model_config = ConfigDict(from_attributes=True) + + event: DomainEvent + related_events: list[EventSummary] = Field(default_factory=list) + timeline: list[EventSummary] = Field(default_factory=list) + + +class EventTypeCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + + event_type: EventType + count: int + + +class HourlyEventCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + + hour: str + count: int + + +class ServiceEventCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + + service_name: str + count: int + + +class UserEventCount(BaseModel): + model_config = ConfigDict(from_attributes=True) + + user_id: str + event_count: int + + +class EventStatistics(BaseModel): + """Event statistics.""" + + model_config = ConfigDict(from_attributes=True) + + total_events: int + events_by_type: list[EventTypeCount] = Field(default_factory=list) + events_by_service: list[ServiceEventCount] = Field(default_factory=list) + events_by_hour: list[HourlyEventCount] = Field(default_factory=list) + top_users: list[UserEventCount] = Field(default_factory=list) + error_rate: float = 0.0 + avg_processing_time: float = 0.0 + start_time: datetime | None = None + end_time: datetime | None = None + + +class EventProjection(BaseModel): + """Configuration for event projections.""" + + model_config = ConfigDict(from_attributes=True) + + name: str + pipeline: list[dict[str, Any]] + output_collection: str + description: str | None = None + source_events: list[EventType] | None = None + refresh_interval_seconds: int = 300 + last_updated: datetime | None = None + + +class EventReplayInfo(BaseModel): + """Information for event replay.""" + + model_config = ConfigDict(from_attributes=True) + + events: list[DomainEvent] + event_count: int + event_types: list[EventType] + start_time: datetime + end_time: datetime + + +class ExecutionEventsResult(BaseModel): + """Result of execution events query.""" + + model_config = ConfigDict(from_attributes=True) + + events: list[DomainEvent] + access_allowed: bool + include_system_events: bool + + +class EventExportRow(BaseModel): + """Event export row for CSV.""" + + model_config = ConfigDict(from_attributes=True) + + event_id: str + event_type: EventType + timestamp: datetime + aggregate_id: str | None = None + metadata: EventMetadata diff --git a/backend/app/schemas_pydantic/replay_schemas.py b/backend/app/schemas_pydantic/replay_schemas.py new file mode 100644 index 00000000..ffae0364 --- /dev/null +++ b/backend/app/schemas_pydantic/replay_schemas.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.domain.enums import ExecutionErrorType, ExecutionStatus +from app.domain.events.typed import ResourceUsageDomain + + +class ExecutionResultSummary(BaseModel): + """Summary of an execution result for replay status.""" + + model_config = ConfigDict(from_attributes=True) + + execution_id: str + status: ExecutionStatus | None + stdout: str | None + stderr: str | None + exit_code: int | None + lang: str + lang_version: str + created_at: datetime + updated_at: datetime + resource_usage: ResourceUsageDomain | None = None + error_type: ExecutionErrorType | None = None diff --git a/backend/app/services/admin/admin_events_service.py b/backend/app/services/admin/admin_events_service.py index c9d600ca..3f416958 100644 --- a/backend/app/services/admin/admin_events_service.py +++ b/backend/app/services/admin/admin_events_service.py @@ -193,7 +193,8 @@ async def get_replay_status(self, session_id: str) -> ReplaySessionStatusDetail if not doc: return None - result = ReplaySessionStatusDetail.model_validate(doc) + _detail_fields = set(ReplaySessionStatusDetail.__dataclass_fields__) + result = ReplaySessionStatusDetail(**doc.model_dump(include=_detail_fields)) result.estimated_completion = self._estimate_completion(doc, datetime.now(timezone.utc)) result.execution_results = await self._repo.get_execution_results_for_filter(doc.config.filter) return result diff --git a/backend/app/services/admin/admin_user_service.py b/backend/app/services/admin/admin_user_service.py index f68cb97a..1c089d56 100644 --- a/backend/app/services/admin/admin_user_service.py +++ b/backend/app/services/admin/admin_user_service.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime, timedelta, timezone import structlog @@ -117,11 +118,12 @@ async def list_users( # Enrich users with rate limit summaries summaries = await self._rate_limits.get_user_rate_limit_summaries([u.user_id for u in result.users]) enriched_users = [ - user.model_copy(update={ - "bypass_rate_limit": s.bypass_rate_limit, - "global_multiplier": s.global_multiplier, - "has_custom_limits": s.has_custom_limits, - }) if (s := summaries.get(user.user_id)) else user + dataclasses.replace( + user, + bypass_rate_limit=s.bypass_rate_limit, + global_multiplier=s.global_multiplier, + has_custom_limits=s.has_custom_limits, + ) if (s := summaries.get(user.user_id)) else user for user in result.users ] @@ -167,7 +169,7 @@ async def update_user(self, *, admin_user_id: str, user_id: str, update: UserUpd target_user_id=user_id, ) if update.password is not None: - update = update.model_copy(update={"password": self._security.get_password_hash(update.password)}) + update = dataclasses.replace(update, password=self._security.get_password_hash(update.password)) return await self._users.update_user(user_id, update) async def delete_user(self, *, admin_user_id: str, user_id: str, cascade: bool) -> UserDeleteResult: diff --git a/backend/app/services/execution_service.py b/backend/app/services/execution_service.py index bb3ffef4..f0e55175 100644 --- a/backend/app/services/execution_service.py +++ b/backend/app/services/execution_service.py @@ -4,6 +4,7 @@ from uuid import uuid4 import structlog +from pydantic import TypeAdapter from app.core.metrics import ExecutionMetrics from app.db.repositories import ExecutionRepository @@ -31,6 +32,8 @@ from app.services.runtime_settings import RuntimeSettingsLoader from app.settings import Settings +_DomainExecutionAdapter = TypeAdapter(DomainExecution) + class ExecutionService: """ @@ -315,7 +318,7 @@ async def execute_script_idempotent( ) if not cached_json: raise ConflictError(f"Cached result for '{idempotency_key}' is no longer available") - return DomainExecution.model_validate_json(cached_json) + return _DomainExecutionAdapter.validate_json(cached_json) try: exec_result = await self.execute_script( @@ -327,7 +330,7 @@ async def execute_script_idempotent( await self.idempotency_manager.mark_completed_with_json( event=pseudo_event, - cached_json=exec_result.model_dump_json(), + cached_json=_DomainExecutionAdapter.dump_json(exec_result).decode(), key_strategy=KeyStrategy.CUSTOM, custom_key=custom_key, ) diff --git a/backend/app/services/idempotency/idempotency_manager.py b/backend/app/services/idempotency/idempotency_manager.py index 0a6c781b..38fbc130 100644 --- a/backend/app/services/idempotency/idempotency_manager.py +++ b/backend/app/services/idempotency/idempotency_manager.py @@ -1,9 +1,9 @@ import hashlib import json +from dataclasses import dataclass from datetime import datetime, timedelta, timezone import structlog -from pydantic import BaseModel from pymongo.errors import DuplicateKeyError from app.core.metrics import DatabaseMetrics @@ -13,18 +13,20 @@ from app.services.idempotency.redis_repository import RedisIdempotencyRepository -class IdempotencyResult(BaseModel): +@dataclass +class IdempotencyResult: is_duplicate: bool status: IdempotencyStatus created_at: datetime + key: str completed_at: datetime | None = None processing_duration_ms: int | None = None error: str | None = None has_cached_result: bool = False - key: str -class IdempotencyConfig(BaseModel): +@dataclass +class IdempotencyConfig: key_prefix: str = "idempotency" default_ttl_seconds: int = 3600 processing_timeout_seconds: int = 300 diff --git a/backend/app/services/result_processor/processor.py b/backend/app/services/result_processor/processor.py index 09f58caa..265106d0 100644 --- a/backend/app/services/result_processor/processor.py +++ b/backend/app/services/result_processor/processor.py @@ -63,7 +63,9 @@ async def handle_execution_completed(self, event: DomainEvent) -> None: memory_percent, attributes={"lang_and_version": lang_and_version} ) - result = ExecutionResultDomain(**event.model_dump(), status=ExecutionStatus.COMPLETED) + _fields = ExecutionResultDomain.__dataclass_fields__ + data = {k: v for k, v in event.model_dump().items() if k in _fields} + result = ExecutionResultDomain(**data, status=ExecutionStatus.COMPLETED) meta = event.metadata try: @@ -86,7 +88,9 @@ async def handle_execution_failed(self, event: DomainEvent) -> None: lang_and_version = f"{exec_obj.lang}-{exec_obj.lang_version}" self._metrics.record_script_execution(ExecutionStatus.FAILED, lang_and_version) - result = ExecutionResultDomain(**event.model_dump(), status=ExecutionStatus.FAILED) + _fields = ExecutionResultDomain.__dataclass_fields__ + data = {k: v for k, v in event.model_dump().items() if k in _fields} + result = ExecutionResultDomain(**data, status=ExecutionStatus.FAILED) meta = event.metadata try: await self._execution_repo.write_terminal_result(result) @@ -110,8 +114,10 @@ async def handle_execution_timeout(self, event: DomainEvent) -> None: self._metrics.record_script_execution(ExecutionStatus.TIMEOUT, lang_and_version) self._metrics.record_execution_duration(event.timeout_seconds, lang_and_version) + _fields = ExecutionResultDomain.__dataclass_fields__ result = ExecutionResultDomain( - **event.model_dump(), status=ExecutionStatus.TIMEOUT, exit_code=-1, error_type=ExecutionErrorType.TIMEOUT, + **{k: v for k, v in event.model_dump().items() if k in _fields}, + status=ExecutionStatus.TIMEOUT, exit_code=-1, error_type=ExecutionErrorType.TIMEOUT, ) meta = event.metadata try: diff --git a/backend/app/services/saga/saga_orchestrator.py b/backend/app/services/saga/saga_orchestrator.py index 554d2fb8..8968917f 100644 --- a/backend/app/services/saga/saga_orchestrator.py +++ b/backend/app/services/saga/saga_orchestrator.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import UTC, datetime, timedelta from uuid import uuid4 @@ -162,7 +163,7 @@ async def _execute_saga( if success: instance.completed_steps.append(step.name) - instance.context_data = SagaContextData.model_validate(context.data) + instance.context_data = SagaContextData(**context.data) await self._save_saga(instance) compensation = step.get_compensation() @@ -287,7 +288,7 @@ async def cancel_saga(self, saga_id: str) -> bool: saga = self._create_saga_instance() context = SagaContext(saga_instance.saga_id, saga_instance.execution_id) - for key, value in saga_instance.context_data.model_dump().items(): + for key, value in dataclasses.asdict(saga_instance.context_data).items(): context.set(key, value) steps = saga.get_steps() diff --git a/backend/app/services/user_settings_service.py b/backend/app/services/user_settings_service.py index d181e4b1..e78390fa 100644 --- a/backend/app/services/user_settings_service.py +++ b/backend/app/services/user_settings_service.py @@ -1,3 +1,4 @@ +import dataclasses from datetime import datetime, timedelta, timezone from typing import Any @@ -78,18 +79,20 @@ async def update_user_settings( """Upsert provided fields into current settings, publish minimal event, and cache.""" current = await self.get_user_settings(user_id) - changes = updates.model_dump(exclude_none=True) + changes = {k: v for k, v in dataclasses.asdict(updates).items() if v is not None} if not changes: return current - new_settings = DomainUserSettings.model_validate({ - **current.model_dump(), + new_settings = DomainUserSettings(**{ + **dataclasses.asdict(current), **changes, "version": (current.version or 0) + 1, "updated_at": datetime.now(timezone.utc), }) - await self._publish_settings_event(user_id, updates.model_dump(exclude_none=True, mode="json"), reason) + await self._publish_settings_event( + user_id, {k: v for k, v in dataclasses.asdict(updates).items() if v is not None}, reason + ) self._add_to_cache(user_id, new_settings) if (await self.repository.count_events_since_snapshot(user_id)) >= 10: @@ -157,7 +160,7 @@ async def get_settings_history(self, user_id: str, limit: int = 50) -> list[Doma event_type=event.event_type, field=f"/{fld}", old_value=None, - new_value=event.model_dump().get(fld), + new_value=dataclasses.asdict(event).get(fld), reason=event.reason, ) ) @@ -190,10 +193,12 @@ async def restore_settings_to_point(self, user_id: str, timestamp: datetime) -> return settings def _apply_event(self, settings: DomainUserSettings, event: DomainUserSettingsChangedEvent) -> DomainUserSettings: - """Apply a settings update event via dict merge + model_validate.""" - return DomainUserSettings.model_validate( - {**settings.model_dump(), **event.model_dump(exclude_none=True), "updated_at": event.timestamp} - ) + """Apply a settings update event via dict merge.""" + return DomainUserSettings(**{ + **dataclasses.asdict(settings), + **{k: v for k, v in dataclasses.asdict(event).items() if v is not None}, + "updated_at": event.timestamp, + }) async def invalidate_cache(self, user_id: str) -> None: """Invalidate cached settings for a user.""" @@ -204,4 +209,3 @@ def _add_to_cache(self, user_id: str, settings: DomainUserSettings) -> None: """Add settings to TTL+LRU cache.""" self._cache[user_id] = settings self.logger.debug(f"Cached settings for user {user_id}", cache_size=len(self._cache)) - diff --git a/backend/tests/unit/services/saga/test_saga_orchestrator_unit.py b/backend/tests/unit/services/saga/test_saga_orchestrator_unit.py index 53b8ae9e..3f8e8478 100644 --- a/backend/tests/unit/services/saga/test_saga_orchestrator_unit.py +++ b/backend/tests/unit/services/saga/test_saga_orchestrator_unit.py @@ -1,3 +1,5 @@ +import dataclasses + import structlog import pytest @@ -62,7 +64,7 @@ async def count_active(self, language: str) -> int: async def create_allocation(self, create_data: DomainResourceAllocationCreate) -> DomainResourceAllocation: alloc = DomainResourceAllocation( allocation_id="alloc-1", - **create_data.model_dump(), + **dataclasses.asdict(create_data), ) self.allocations.append(alloc) return alloc From 58756c7bda7543385044b0e221ad838ed26a95f0 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Sat, 14 Feb 2026 13:03:25 +0100 Subject: [PATCH 2/2] fix: further updates --- backend/app/api/routes/admin/settings.py | 9 +- backend/app/api/routes/replay.py | 4 +- backend/app/db/docs/admin_settings.py | 5 +- backend/app/db/docs/saga.py | 5 +- .../admin/admin_settings_repository.py | 12 +- backend/app/domain/admin/__init__.py | 2 +- backend/app/domain/admin/settings_models.py | 53 +++++++ backend/app/domain/saga/__init__.py | 2 - backend/app/domain/saga/models.py | 24 --- backend/app/schemas_pydantic/admin_events.py | 17 +-- .../app/schemas_pydantic/admin_settings.py | 8 +- .../schemas_pydantic/admin_user_overview.py | 2 +- backend/app/schemas_pydantic/event_schemas.py | 29 +++- backend/app/schemas_pydantic/events.py | 65 -------- backend/app/schemas_pydantic/replay.py | 78 ---------- backend/app/schemas_pydantic/replay_models.py | 61 -------- .../app/schemas_pydantic/replay_schemas.py | 141 +++++++++++++++++- backend/app/services/saga/__init__.py | 3 +- .../tests/e2e/test_admin_settings_routes.py | 8 +- backend/tests/e2e/test_replay_routes.py | 4 +- .../tests/unit/domain/test_system_settings.py | 28 ++-- 21 files changed, 256 insertions(+), 304 deletions(-) delete mode 100644 backend/app/schemas_pydantic/events.py delete mode 100644 backend/app/schemas_pydantic/replay.py delete mode 100644 backend/app/schemas_pydantic/replay_models.py diff --git a/backend/app/api/routes/admin/settings.py b/backend/app/api/routes/admin/settings.py index 239a934d..aa703675 100644 --- a/backend/app/api/routes/admin/settings.py +++ b/backend/app/api/routes/admin/settings.py @@ -27,7 +27,7 @@ async def get_system_settings( ) -> SystemSettingsSchema: """Get the current system-wide settings.""" result = await service.get_system_settings(admin.user_id) - return SystemSettingsSchema.model_validate(result) + return SystemSettingsSchema.model_validate(result, from_attributes=True) @router.put( @@ -45,9 +45,8 @@ async def update_system_settings( service: FromDishka[AdminSettingsService], ) -> SystemSettingsSchema: """Replace system-wide settings.""" - domain_settings = SystemSettings.model_validate(settings) - result = await service.update_system_settings(domain_settings, admin.user_id) - return SystemSettingsSchema.model_validate(result) + result = await service.update_system_settings(SystemSettings(**settings.model_dump()), admin.user_id) + return SystemSettingsSchema.model_validate(result, from_attributes=True) @router.post( @@ -61,4 +60,4 @@ async def reset_system_settings( ) -> SystemSettingsSchema: """Reset system-wide settings to defaults.""" result = await service.reset_system_settings(admin.user_id) - return SystemSettingsSchema.model_validate(result) + return SystemSettingsSchema.model_validate(result, from_attributes=True) diff --git a/backend/app/api/routes/replay.py b/backend/app/api/routes/replay.py index e91f1582..b643a0ba 100644 --- a/backend/app/api/routes/replay.py +++ b/backend/app/api/routes/replay.py @@ -7,13 +7,13 @@ from app.api.dependencies import admin_user from app.domain.enums import ReplayStatus from app.domain.replay import ReplayConfig -from app.schemas_pydantic.replay import ( +from app.schemas_pydantic.replay_schemas import ( CleanupResponse, ReplayRequest, ReplayResponse, + ReplaySession, SessionSummary, ) -from app.schemas_pydantic.replay_models import ReplaySession from app.services.event_replay import EventReplayService router = APIRouter(prefix="/replay", tags=["Event Replay"], route_class=DishkaRoute, dependencies=[Depends(admin_user)]) diff --git a/backend/app/db/docs/admin_settings.py b/backend/app/db/docs/admin_settings.py index fac01566..9085944c 100644 --- a/backend/app/db/docs/admin_settings.py +++ b/backend/app/db/docs/admin_settings.py @@ -5,12 +5,13 @@ from beanie import Document, Indexed from pydantic import ConfigDict, Field -from app.domain.admin import AuditAction, SystemSettings +from app.domain.admin import AuditAction +from app.schemas_pydantic.admin_settings import SystemSettingsSchema class SystemSettingsDocument(Document): settings_id: str = "global" - config: SystemSettings = Field(default_factory=SystemSettings) + config: SystemSettingsSchema = Field(default_factory=SystemSettingsSchema) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/db/docs/saga.py b/backend/app/db/docs/saga.py index a84b999e..64e4bbe0 100644 --- a/backend/app/db/docs/saga.py +++ b/backend/app/db/docs/saga.py @@ -10,10 +10,7 @@ class SagaDocument(Document): - """Domain model for saga stored in database. - - Copied from Saga/SagaInstance dataclass. - """ + """Saga document stored in database.""" saga_id: Indexed(str, unique=True) = Field(default_factory=lambda: str(uuid4())) # type: ignore[valid-type] saga_name: Indexed(str) # type: ignore[valid-type] diff --git a/backend/app/db/repositories/admin/admin_settings_repository.py b/backend/app/db/repositories/admin/admin_settings_repository.py index fa9df639..517ca926 100644 --- a/backend/app/db/repositories/admin/admin_settings_repository.py +++ b/backend/app/db/repositories/admin/admin_settings_repository.py @@ -1,9 +1,11 @@ +import dataclasses from datetime import datetime, timezone import structlog from app.db.docs.admin_settings import AuditLogDocument, SystemSettingsDocument from app.domain.admin import AuditAction, SystemSettings +from app.schemas_pydantic.admin_settings import SystemSettingsSchema class AdminSettingsRepository: @@ -17,27 +19,27 @@ async def get_system_settings( doc = await SystemSettingsDocument.find_one(SystemSettingsDocument.settings_id == "global") if not doc: self.logger.info("System settings not found, creating defaults") - doc = SystemSettingsDocument(config=defaults) + doc = SystemSettingsDocument(config=SystemSettingsSchema(**dataclasses.asdict(defaults))) await doc.insert() - return doc.config + return SystemSettings(**doc.config.model_dump()) async def update_system_settings(self, settings: SystemSettings, user_id: str) -> SystemSettings: doc = await SystemSettingsDocument.find_one(SystemSettingsDocument.settings_id == "global") if not doc: doc = SystemSettingsDocument() - doc.config = settings + doc.config = SystemSettingsSchema(**dataclasses.asdict(settings)) doc.updated_at = datetime.now(timezone.utc) await doc.save() audit_entry = AuditLogDocument( action=AuditAction.SYSTEM_SETTINGS_UPDATED, user_id=user_id, - changes=settings.model_dump(), + changes=dataclasses.asdict(settings), ) await audit_entry.insert() - return doc.config + return SystemSettings(**doc.config.model_dump()) async def reset_system_settings(self, user_id: str) -> SystemSettings: doc = await SystemSettingsDocument.find_one(SystemSettingsDocument.settings_id == "global") diff --git a/backend/app/domain/admin/__init__.py b/backend/app/domain/admin/__init__.py index 9753ce8b..0246f9f2 100644 --- a/backend/app/domain/admin/__init__.py +++ b/backend/app/domain/admin/__init__.py @@ -1,4 +1,3 @@ -from app.schemas_pydantic.admin_settings import SystemSettings from app.schemas_pydantic.replay_schemas import ExecutionResultSummary from .overview_models import ( @@ -15,6 +14,7 @@ from .settings_models import ( AuditAction, LogLevel, + SystemSettings, ) __all__ = [ diff --git a/backend/app/domain/admin/settings_models.py b/backend/app/domain/admin/settings_models.py index d29a97ee..757be347 100644 --- a/backend/app/domain/admin/settings_models.py +++ b/backend/app/domain/admin/settings_models.py @@ -1,5 +1,23 @@ +import re +from dataclasses import dataclass +from typing import Any + from app.core.utils import StringEnum +K8S_MEMORY_PATTERN = re.compile(r"^[1-9]\d*(Ki|Mi|Gi)$") +K8S_CPU_PATTERN = re.compile(r"^[1-9]\d*m$") + +_RANGE_RULES: dict[str, tuple[int | float, int | float]] = { + "max_timeout_seconds": (1, 3600), + "max_concurrent_executions": (1, 100), + "password_min_length": (8, 32), + "session_timeout_minutes": (5, 1440), + "max_login_attempts": (3, 10), + "lockout_duration_minutes": (5, 60), + "metrics_retention_days": (7, 90), + "sampling_rate": (0.0, 1.0), +} + class AuditAction(StringEnum): """Audit log action types.""" @@ -16,3 +34,38 @@ class LogLevel(StringEnum): WARNING = "WARNING" ERROR = "ERROR" CRITICAL = "CRITICAL" + + +@dataclass +class SystemSettings: + """Flat system-wide settings — execution, security, and monitoring.""" + + max_timeout_seconds: int = 300 + memory_limit: str = "512Mi" + cpu_limit: str = "2000m" + max_concurrent_executions: int = 10 + + password_min_length: int = 8 + session_timeout_minutes: int = 60 + max_login_attempts: int = 5 + lockout_duration_minutes: int = 15 + + metrics_retention_days: int = 30 + log_level: LogLevel = LogLevel.INFO + enable_tracing: bool = True + sampling_rate: float = 0.1 + + def __post_init__(self) -> None: + for name, (lo, hi) in _RANGE_RULES.items(): + val = getattr(self, name) + if not (lo <= val <= hi): + raise ValueError(f"{name} must be between {lo} and {hi}") + + if not K8S_MEMORY_PATTERN.match(self.memory_limit): + raise ValueError(f"memory_limit must match K8s resource format, got '{self.memory_limit}'") + if not K8S_CPU_PATTERN.match(self.cpu_limit): + raise ValueError(f"cpu_limit must match K8s millicore format, got '{self.cpu_limit}'") + + raw: Any = self.log_level + if not isinstance(raw, LogLevel): + self.log_level = LogLevel(raw) diff --git a/backend/app/domain/saga/__init__.py b/backend/app/domain/saga/__init__.py index 19b5d482..96e670ff 100644 --- a/backend/app/domain/saga/__init__.py +++ b/backend/app/domain/saga/__init__.py @@ -13,7 +13,6 @@ SagaConfig, SagaContextData, SagaFilter, - SagaInstance, SagaListResult, SagaQuery, ) @@ -25,7 +24,6 @@ "SagaCancellationResult", "SagaConfig", "SagaContextData", - "SagaInstance", "SagaFilter", "SagaListResult", "SagaQuery", diff --git a/backend/app/domain/saga/models.py b/backend/app/domain/saga/models.py index 4c69a66c..2814d24f 100644 --- a/backend/app/domain/saga/models.py +++ b/backend/app/domain/saga/models.py @@ -111,30 +111,6 @@ class SagaConfig: publish_commands: bool = False -@dataclass -class SagaInstance: - """Runtime instance of a saga execution (domain).""" - - saga_name: str - execution_id: str - state: SagaState = SagaState.CREATED - saga_id: str = field(default_factory=lambda: str(uuid4())) - current_step: str | None = None - completed_steps: list[str] = field(default_factory=list) - compensated_steps: list[str] = field(default_factory=list) - context_data: SagaContextData = field(default_factory=SagaContextData) - error_message: str | None = None - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - completed_at: datetime | None = None - retry_count: int = 0 - - def __post_init__(self) -> None: - raw: Any = self.context_data - if isinstance(raw, dict): - self.context_data = SagaContextData(**raw) - - @dataclass class DomainResourceAllocation: """Domain model for resource allocation.""" diff --git a/backend/app/schemas_pydantic/admin_events.py b/backend/app/schemas_pydantic/admin_events.py index 360a0c54..16642aaf 100644 --- a/backend/app/schemas_pydantic/admin_events.py +++ b/backend/app/schemas_pydantic/admin_events.py @@ -5,7 +5,7 @@ from app.domain.enums import EventType, ReplayStatus from app.domain.events import DomainEvent, EventSummary from app.domain.replay import ReplayError -from app.schemas_pydantic.events import EventTypeCountSchema, HourlyEventCountSchema +from app.schemas_pydantic.event_schemas import EventTypeCount, HourlyEventCount, UserEventCount from app.schemas_pydantic.execution import ExecutionResult @@ -108,23 +108,14 @@ class EventDeleteResponse(BaseModel): event_id: str -class UserEventCountSchema(BaseModel): - """User event count schema""" - - model_config = ConfigDict(from_attributes=True) - - user_id: str - event_count: int - - class EventStatsResponse(BaseModel): """Response model for event statistics""" model_config = ConfigDict(from_attributes=True) total_events: int - events_by_type: list[EventTypeCountSchema] - events_by_hour: list[HourlyEventCountSchema] - top_users: list[UserEventCountSchema] + events_by_type: list[EventTypeCount] + events_by_hour: list[HourlyEventCount] + top_users: list[UserEventCount] error_rate: float avg_processing_time: float diff --git a/backend/app/schemas_pydantic/admin_settings.py b/backend/app/schemas_pydantic/admin_settings.py index 958af6d9..014fa550 100644 --- a/backend/app/schemas_pydantic/admin_settings.py +++ b/backend/app/schemas_pydantic/admin_settings.py @@ -6,8 +6,8 @@ K8S_CPU_PATTERN = r"^[1-9]\d*m$" -class SystemSettings(BaseModel): - """Flat system-wide settings — execution, security, and monitoring.""" +class SystemSettingsSchema(BaseModel): + """API schema for system-wide settings.""" model_config = ConfigDict(from_attributes=True, extra="ignore", use_enum_values=True) @@ -25,7 +25,3 @@ class SystemSettings(BaseModel): log_level: LogLevel = LogLevel.INFO enable_tracing: bool = True sampling_rate: float = Field(0.1, ge=0.0, le=1.0) - - -class SystemSettingsSchema(SystemSettings): - """API schema for system settings — inherits all fields from domain model.""" diff --git a/backend/app/schemas_pydantic/admin_user_overview.py b/backend/app/schemas_pydantic/admin_user_overview.py index 08c34921..c06630f6 100644 --- a/backend/app/schemas_pydantic/admin_user_overview.py +++ b/backend/app/schemas_pydantic/admin_user_overview.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict from app.domain.events import DomainEvent -from app.schemas_pydantic.events import EventStatistics +from app.schemas_pydantic.event_schemas import EventStatistics from app.schemas_pydantic.user import UserResponse diff --git a/backend/app/schemas_pydantic/event_schemas.py b/backend/app/schemas_pydantic/event_schemas.py index 60af4780..52336033 100644 --- a/backend/app/schemas_pydantic/event_schemas.py +++ b/backend/app/schemas_pydantic/event_schemas.py @@ -6,9 +6,6 @@ from app.domain.enums import EventType from app.domain.events.typed import DomainEvent, EventMetadata -MongoQueryValue = str | dict[str, str | list[str] | float | datetime] -MongoQuery = dict[str, MongoQueryValue] - class EventSummary(BaseModel): """Lightweight event summary for lists and previews.""" @@ -98,9 +95,7 @@ class UserEventCount(BaseModel): class EventStatistics(BaseModel): - """Event statistics.""" - - model_config = ConfigDict(from_attributes=True) + """Event statistics response.""" total_events: int events_by_type: list[EventTypeCount] = Field(default_factory=list) @@ -112,6 +107,28 @@ class EventStatistics(BaseModel): start_time: datetime | None = None end_time: datetime | None = None + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "total_events": 1543, + "events_by_type": [ + {"event_type": "EXECUTION_REQUESTED", "count": 523}, + {"event_type": "EXECUTION_COMPLETED", "count": 498}, + {"event_type": "POD_CREATED", "count": 522}, + ], + "events_by_service": [ + {"service_name": "api-gateway", "count": 523}, + {"service_name": "execution-service", "count": 1020}, + ], + "events_by_hour": [ + {"hour": "2024-01-20 10:00", "count": 85}, + {"hour": "2024-01-20 11:00", "count": 92}, + ], + } + }, + ) + class EventProjection(BaseModel): """Configuration for event projections.""" diff --git a/backend/app/schemas_pydantic/events.py b/backend/app/schemas_pydantic/events.py deleted file mode 100644 index 70ab8c5e..00000000 --- a/backend/app/schemas_pydantic/events.py +++ /dev/null @@ -1,65 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict - -from app.domain.enums import EventType - - -class EventTypeCountSchema(BaseModel): - """Event count by type.""" - - model_config = ConfigDict(from_attributes=True) - - event_type: EventType - count: int - - -class HourlyEventCountSchema(BaseModel): - """Hourly event count for statistics.""" - - model_config = ConfigDict(from_attributes=True) - - hour: str - count: int - - -class ServiceEventCountSchema(BaseModel): - """Event count by service.""" - - model_config = ConfigDict(from_attributes=True) - - service_name: str - count: int - - -class EventStatistics(BaseModel): - """Event statistics response.""" - - total_events: int - events_by_type: list[EventTypeCountSchema] - events_by_service: list[ServiceEventCountSchema] - events_by_hour: list[HourlyEventCountSchema] - start_time: datetime | None = None - end_time: datetime | None = None - - model_config = ConfigDict( - from_attributes=True, - json_schema_extra={ - "example": { - "total_events": 1543, - "events_by_type": [ - {"event_type": "EXECUTION_REQUESTED", "count": 523}, - {"event_type": "EXECUTION_COMPLETED", "count": 498}, - {"event_type": "POD_CREATED", "count": 522}, - ], - "events_by_service": [ - {"service_name": "api-gateway", "count": 523}, - {"service_name": "execution-service", "count": 1020}, - ], - "events_by_hour": [ - {"hour": "2024-01-20 10:00", "count": 85}, - {"hour": "2024-01-20 11:00", "count": 92}, - ], - } - }, - ) diff --git a/backend/app/schemas_pydantic/replay.py b/backend/app/schemas_pydantic/replay.py deleted file mode 100644 index 19d77b1e..00000000 --- a/backend/app/schemas_pydantic/replay.py +++ /dev/null @@ -1,78 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field, computed_field - -from app.domain.enums import EventType, KafkaTopic, ReplayStatus, ReplayTarget, ReplayType -from app.domain.replay import ReplayFilter - - -class ReplayRequest(BaseModel): - """Request schema for creating replay sessions""" - - replay_type: ReplayType - target: ReplayTarget = ReplayTarget.KAFKA - filter: ReplayFilter = Field(default_factory=ReplayFilter) - - speed_multiplier: float = Field(default=1.0, ge=0.1, le=100.0) - preserve_timestamps: bool = False - batch_size: int = Field(default=100, ge=1, le=1000) - max_events: int | None = Field(default=None, ge=1) - skip_errors: bool = True - target_file_path: str | None = None - target_topics: dict[EventType, KafkaTopic] | None = None - retry_failed: bool = False - retry_attempts: int = Field(default=3, ge=1, le=10) - enable_progress_tracking: bool = True - - -class ReplayResponse(BaseModel): - """Response schema for replay operations""" - - model_config = ConfigDict(from_attributes=True) - - session_id: str - status: ReplayStatus - message: str - - -class SessionConfigSummary(BaseModel): - """Lightweight config included in session listings.""" - - model_config = ConfigDict(from_attributes=True) - - replay_type: ReplayType - target: ReplayTarget - - -class SessionSummary(BaseModel): - """Summary information for replay sessions""" - - model_config = ConfigDict(from_attributes=True) - - session_id: str - config: SessionConfigSummary - status: ReplayStatus - total_events: int - replayed_events: int - failed_events: int - skipped_events: int - created_at: datetime - started_at: datetime | None - completed_at: datetime | None - - @computed_field # type: ignore[prop-decorator] - @property - def duration_seconds(self) -> float | None: - if self.started_at and self.completed_at: - return (self.completed_at - self.started_at).total_seconds() - return None - - - -class CleanupResponse(BaseModel): - """Response schema for cleanup operations""" - - model_config = ConfigDict(from_attributes=True) - - removed_sessions: int - message: str diff --git a/backend/app/schemas_pydantic/replay_models.py b/backend/app/schemas_pydantic/replay_models.py deleted file mode 100644 index 8f2769b6..00000000 --- a/backend/app/schemas_pydantic/replay_models.py +++ /dev/null @@ -1,61 +0,0 @@ -from datetime import datetime, timezone -from uuid import uuid4 - -from pydantic import BaseModel, ConfigDict, Field - -from app.domain.enums import EventType, KafkaTopic, ReplayStatus, ReplayTarget, ReplayType -from app.domain.replay import ReplayError - - -class ReplayFilterSchema(BaseModel): - model_config = ConfigDict(from_attributes=True) - - execution_id: str | None = None - event_types: list[EventType] | None = None - start_time: datetime | None = None - end_time: datetime | None = None - user_id: str | None = None - service_name: str | None = None - exclude_event_types: list[EventType] | None = None - - -class ReplayConfigSchema(BaseModel): - model_config = ConfigDict(from_attributes=True) - - replay_type: ReplayType - target: ReplayTarget = ReplayTarget.KAFKA - filter: ReplayFilterSchema = Field(default_factory=ReplayFilterSchema) - - speed_multiplier: float = Field(default=1.0, ge=0.1, le=100.0) - preserve_timestamps: bool = False - batch_size: int = Field(default=100, ge=1, le=1000) - max_events: int | None = Field(default=None, ge=1) - - target_topics: dict[EventType, KafkaTopic] | None = None - target_file_path: str | None = None - - skip_errors: bool = True - retry_failed: bool = False - retry_attempts: int = 3 - - enable_progress_tracking: bool = True - - -class ReplaySession(BaseModel): - model_config = ConfigDict(from_attributes=True) - - session_id: str = Field(default_factory=lambda: str(uuid4())) - config: ReplayConfigSchema - status: ReplayStatus = ReplayStatus.CREATED - - total_events: int = 0 - replayed_events: int = 0 - failed_events: int = 0 - skipped_events: int = 0 - - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - started_at: datetime | None = None - completed_at: datetime | None = None - last_event_at: datetime | None = None - - errors: list[ReplayError] = Field(default_factory=list) diff --git a/backend/app/schemas_pydantic/replay_schemas.py b/backend/app/schemas_pydantic/replay_schemas.py index ffae0364..8a4d9a83 100644 --- a/backend/app/schemas_pydantic/replay_schemas.py +++ b/backend/app/schemas_pydantic/replay_schemas.py @@ -1,9 +1,19 @@ -from datetime import datetime +from datetime import datetime, timezone +from uuid import uuid4 -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, computed_field -from app.domain.enums import ExecutionErrorType, ExecutionStatus +from app.domain.enums import ( + EventType, + ExecutionErrorType, + ExecutionStatus, + KafkaTopic, + ReplayStatus, + ReplayTarget, + ReplayType, +) from app.domain.events.typed import ResourceUsageDomain +from app.domain.replay import ReplayError, ReplayFilter class ExecutionResultSummary(BaseModel): @@ -22,3 +32,128 @@ class ExecutionResultSummary(BaseModel): updated_at: datetime resource_usage: ResourceUsageDomain | None = None error_type: ExecutionErrorType | None = None + + +class ReplayFilterSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + execution_id: str | None = None + event_types: list[EventType] | None = None + start_time: datetime | None = None + end_time: datetime | None = None + user_id: str | None = None + service_name: str | None = None + exclude_event_types: list[EventType] | None = None + + +class ReplayConfigSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + replay_type: ReplayType + target: ReplayTarget = ReplayTarget.KAFKA + filter: ReplayFilterSchema = Field(default_factory=ReplayFilterSchema) + + speed_multiplier: float = Field(default=1.0, ge=0.1, le=100.0) + preserve_timestamps: bool = False + batch_size: int = Field(default=100, ge=1, le=1000) + max_events: int | None = Field(default=None, ge=1) + + target_topics: dict[EventType, KafkaTopic] | None = None + target_file_path: str | None = None + + skip_errors: bool = True + retry_failed: bool = False + retry_attempts: int = 3 + + enable_progress_tracking: bool = True + + +class ReplaySession(BaseModel): + model_config = ConfigDict(from_attributes=True) + + session_id: str = Field(default_factory=lambda: str(uuid4())) + config: ReplayConfigSchema + status: ReplayStatus = ReplayStatus.CREATED + + total_events: int = 0 + replayed_events: int = 0 + failed_events: int = 0 + skipped_events: int = 0 + + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + started_at: datetime | None = None + completed_at: datetime | None = None + last_event_at: datetime | None = None + + errors: list[ReplayError] = Field(default_factory=list) + + +class ReplayRequest(BaseModel): + """Request schema for creating replay sessions""" + + replay_type: ReplayType + target: ReplayTarget = ReplayTarget.KAFKA + filter: ReplayFilter = Field(default_factory=ReplayFilter) + + speed_multiplier: float = Field(default=1.0, ge=0.1, le=100.0) + preserve_timestamps: bool = False + batch_size: int = Field(default=100, ge=1, le=1000) + max_events: int | None = Field(default=None, ge=1) + skip_errors: bool = True + target_file_path: str | None = None + target_topics: dict[EventType, KafkaTopic] | None = None + retry_failed: bool = False + retry_attempts: int = Field(default=3, ge=1, le=10) + enable_progress_tracking: bool = True + + +class ReplayResponse(BaseModel): + """Response schema for replay operations""" + + model_config = ConfigDict(from_attributes=True) + + session_id: str + status: ReplayStatus + message: str + + +class SessionConfigSummary(BaseModel): + """Lightweight config included in session listings.""" + + model_config = ConfigDict(from_attributes=True) + + replay_type: ReplayType + target: ReplayTarget + + +class SessionSummary(BaseModel): + """Summary information for replay sessions""" + + model_config = ConfigDict(from_attributes=True) + + session_id: str + config: SessionConfigSummary + status: ReplayStatus + total_events: int + replayed_events: int + failed_events: int + skipped_events: int + created_at: datetime + started_at: datetime | None + completed_at: datetime | None + + @computed_field # type: ignore[prop-decorator] + @property + def duration_seconds(self) -> float | None: + if self.started_at and self.completed_at: + return (self.completed_at - self.started_at).total_seconds() + return None + + +class CleanupResponse(BaseModel): + """Response schema for cleanup operations""" + + model_config = ConfigDict(from_attributes=True) + + removed_sessions: int + message: str diff --git a/backend/app/services/saga/__init__.py b/backend/app/services/saga/__init__.py index 045d66aa..7c085bc8 100644 --- a/backend/app/services/saga/__init__.py +++ b/backend/app/services/saga/__init__.py @@ -1,5 +1,5 @@ from app.domain.enums import SagaState -from app.domain.saga import SagaConfig, SagaInstance +from app.domain.saga import SagaConfig from app.services.saga.execution_saga import ( AllocateResourcesStep, CreatePodStep, @@ -17,7 +17,6 @@ "SagaService", "SagaConfig", "SagaState", - "SagaInstance", "SagaContext", "SagaStep", "CompensationStep", diff --git a/backend/tests/e2e/test_admin_settings_routes.py b/backend/tests/e2e/test_admin_settings_routes.py index 8a144add..b5d19295 100644 --- a/backend/tests/e2e/test_admin_settings_routes.py +++ b/backend/tests/e2e/test_admin_settings_routes.py @@ -17,7 +17,7 @@ async def test_get_system_settings(self, test_admin: AsyncClient) -> None: response = await test_admin.get("/api/v1/admin/settings/") assert response.status_code == 200 - settings = SystemSettings.model_validate(response.json()) + settings = SystemSettings(**response.json()) assert settings.max_timeout_seconds >= 1 assert settings.max_concurrent_executions >= 1 @@ -71,7 +71,7 @@ async def test_update_system_settings_full( response = await test_admin.put("/api/v1/admin/settings/", json=request_body) assert response.status_code == 200 - settings = SystemSettings.model_validate(response.json()) + settings = SystemSettings(**response.json()) assert settings.max_timeout_seconds == 600 assert settings.memory_limit == "1024Mi" @@ -95,7 +95,7 @@ async def test_update_partial_fields( response = await test_admin.put("/api/v1/admin/settings/", json=request_body) assert response.status_code == 200 - settings = SystemSettings.model_validate(response.json()) + settings = SystemSettings(**response.json()) assert settings.max_timeout_seconds == 120 assert settings.max_concurrent_executions == 15 @@ -164,7 +164,7 @@ async def test_reset_system_settings( response = await test_admin.post("/api/v1/admin/settings/reset") assert response.status_code == 200 - settings = SystemSettings.model_validate(response.json()) + settings = SystemSettings(**response.json()) # Reset returns TOML-derived defaults for fields mapped from Settings, # and SystemSettings() model defaults for the rest. diff --git a/backend/tests/e2e/test_replay_routes.py b/backend/tests/e2e/test_replay_routes.py index 59b27566..eff74517 100644 --- a/backend/tests/e2e/test_replay_routes.py +++ b/backend/tests/e2e/test_replay_routes.py @@ -1,13 +1,13 @@ import pytest from app.domain.enums import EventType, ReplayStatus, ReplayTarget, ReplayType from app.domain.replay import ReplayFilter -from app.schemas_pydantic.replay import ( +from app.schemas_pydantic.replay_schemas import ( CleanupResponse, ReplayRequest, ReplayResponse, + ReplaySession, SessionSummary, ) -from app.schemas_pydantic.replay_models import ReplaySession from httpx import AsyncClient pytestmark = [pytest.mark.e2e, pytest.mark.admin, pytest.mark.kafka] diff --git a/backend/tests/unit/domain/test_system_settings.py b/backend/tests/unit/domain/test_system_settings.py index 0453f4ae..795930a9 100644 --- a/backend/tests/unit/domain/test_system_settings.py +++ b/backend/tests/unit/domain/test_system_settings.py @@ -1,5 +1,4 @@ import pytest -from pydantic import ValidationError from app.domain.admin import SystemSettings @@ -31,7 +30,7 @@ def test_valid_memory_limit(self, value: str) -> None: @pytest.mark.parametrize("value", ["512mb", "1G", "abc", "512", "Mi512"]) def test_invalid_memory_limit(self, value: str) -> None: - with pytest.raises(ValidationError, match="memory_limit"): + with pytest.raises(ValueError, match="memory_limit"): SystemSettings(memory_limit=value) @pytest.mark.parametrize("value", ["1000m", "500m", "2000m"]) @@ -41,7 +40,7 @@ def test_valid_cpu_limit(self, value: str) -> None: @pytest.mark.parametrize("value", ["1000", "2 cores", "500mc", "m500"]) def test_invalid_cpu_limit(self, value: str) -> None: - with pytest.raises(ValidationError, match="cpu_limit"): + with pytest.raises(ValueError, match="cpu_limit"): SystemSettings(cpu_limit=value) @@ -59,33 +58,26 @@ class TestBoundaryValidation: ], ) def test_rejects_out_of_range(self, field: str, too_low: int, too_high: int) -> None: - with pytest.raises(ValidationError): - SystemSettings.model_validate({field: too_low}) - with pytest.raises(ValidationError): - SystemSettings.model_validate({field: too_high}) + with pytest.raises(ValueError): + SystemSettings(**{field: too_low}) # type: ignore[arg-type] + with pytest.raises(ValueError): + SystemSettings(**{field: too_high}) # type: ignore[arg-type] def test_sampling_rate_boundaries(self) -> None: assert SystemSettings(sampling_rate=0.0).sampling_rate == 0.0 assert SystemSettings(sampling_rate=1.0).sampling_rate == 1.0 - with pytest.raises(ValidationError): + with pytest.raises(ValueError): SystemSettings(sampling_rate=-0.1) - with pytest.raises(ValidationError): + with pytest.raises(ValueError): SystemSettings(sampling_rate=1.1) class TestLogLevel: @pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) def test_valid_log_levels(self, level: str) -> None: - s = SystemSettings.model_validate({"log_level": level}) + s = SystemSettings(log_level=level) # type: ignore[arg-type] assert s.log_level == level def test_invalid_log_level(self) -> None: - with pytest.raises(ValidationError): + with pytest.raises(ValueError): SystemSettings(log_level="TRACE") # type: ignore[arg-type] - - -class TestExtraFieldsIgnored: - def test_extra_fields_ignored(self) -> None: - s = SystemSettings.model_validate({"max_timeout_seconds": 100, "unknown_field": "whatever"}) - assert s.max_timeout_seconds == 100 - assert not hasattr(s, "unknown_field")