Coverage for functions \ flipdare \ firestore \ core \ app_base_model.py: 91%
86 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
13from __future__ import annotations
15from typing import Any, Self
17from pydantic import BaseModel
18from pydantic.fields import FieldInfo
19from pydantic_core import ValidationError
21from flipdare.app_log import LOG
24class AppBaseModel(BaseModel):
25 def __init__(self, **data: Any) -> None:
26 try:
27 super().__init__(**data)
28 except ValidationError as e:
29 msg = f"Validation error creating {self.__class__.__name__}\n"
30 for error in e.errors():
31 msg += f" -> {error['loc'][0]}: {error['msg']}\n"
33 msg += f"\nError: {e}\n\n"
34 LOG().error(msg)
35 raise
37 @classmethod
38 def from_dict(cls, data: dict[str, Any]) -> Self:
39 """
40 Load model from dict with validation and error handling.
42 Args:
43 data: Dictionary of model data
45 Returns:
46 Instance of the model
48 Raises:
49 DataLoadError: If validation fails
50 CodePathError: If unexpected error occurs
52 """
53 from flipdare.app_log import LOG
54 from flipdare.error.app_error import CodePathError
55 from flipdare.error.data_load_error import DataLoadError
56 from flipdare.error.message_format import ValidationErrorMsgFormat
58 try:
59 return cls(**data)
60 except ValidationError as e:
61 formatter = ValidationErrorMsgFormat(
62 class_type=cls,
63 error=e,
64 )
65 LOG().error(str(formatter))
66 raise DataLoadError.model(
67 class_name=formatter.class_name,
68 missing_code=formatter.user_error_code,
69 error=e,
70 ) from e
71 except Exception as e:
72 msg = f"Unexpected error creating model from dict: {e}"
73 LOG().error(msg)
74 raise CodePathError(message=msg) from e
76 @property
77 def searchable_values(self) -> list[str]:
78 """Should be overridden by subclasses that have searchable values"""
79 return []
81 def calculate_change_score(self, other: Any) -> float: # noqa: ARG002
82 """
83 NOTE: This should be overridden by models where we want to calculate a change score for updates.
84 """
85 from flipdare.constants import DEFAULT_CHANGE_SCORE
87 return DEFAULT_CHANGE_SCORE
89 def update(self, **kwargs: Any) -> bool:
90 # NOTE: this does not update timestamps which need to be updated in the wrapper..
91 has_changed = False
92 # Access the schema definition from the class, not the instance
93 # model_definition: dict[str, FieldInfo] = type(self).model_fields
95 for key, value in kwargs.items():
96 if key not in self.__pydantic_fields__:
97 continue
99 changed = self._update(key, value)
100 if changed:
101 has_changed = True
103 return has_changed
105 def _update(self, key: str, value: Any) -> bool:
106 # Use .get() to satisfy subscriptable checks and handle missing keys safely
107 # note: this should be only called internally, since it has
108 # note: no real checks on key or value
109 fields: dict[str, FieldInfo] = type(self).model_fields
110 field_info = fields.get(key)
112 if field_info is None:
113 return False
115 if value is None and field_info.is_required():
116 raise ValueError(f"Field '{key}' is mandatory and cannot be set to None.")
118 if getattr(self, key) != value:
119 setattr(self, key, value)
120 return True
122 return False
124 def to_json_dict(self) -> dict[str, Any]:
125 """
126 Convert model to JSON-compatible dict with aliases.
127 Handles Firestore Sentinel timestamps by converting to current UTC time.
128 """
129 from google.cloud.firestore_v1.transforms import Sentinel
130 from flipdare.util.time_util import TimeUtil
132 data: dict[str, Any] = self.model_dump(mode="json", by_alias=True, exclude={"id"})
134 # Convert any sentinels to current utc time for JSON serialization
135 for key in ["created_at", "updated_at"]:
136 if key in data and isinstance(data[key], Sentinel):
137 data[key] = TimeUtil.get_current_utc_dt()
139 return data
141 def to_dict(self) -> dict[str, Any]:
142 """Convert model to dict with aliases, excluding id."""
143 return self.model_dump(by_alias=True, exclude={"id"})
145 def to_dict_with_id(self) -> dict[str, Any]:
146 """
147 Convert model to dict including id field.
148 Used for debugging/logging purposes.
150 Raises:
151 ValueError: If model has no id field or id is None
153 """
154 doc_id = getattr(self, "id", None)
155 if doc_id is None:
156 raise ValueError("Cannot convert to dict with id when id is None")
158 data: dict[str, Any] = self.model_dump(by_alias=False)
159 data["id"] = doc_id
160 return data
162 def debug_str(self) -> str:
163 """Pretty prints fields, current values, and their defaults."""
164 lines = [f"<{self.__class__.__name__}>"]
166 for field_name, field_info in self.__class__.model_fields.items():
167 # Get current value (or fallback to default if not set)
168 current_value = getattr(self, field_name)
170 # Identify the default
171 default = field_info.default if field_info.default is not None else "Required"
172 if field_info.default_factory:
173 default = f"Factory({field_info.default_factory.__name__})"
175 alias = f" (alias: {field_info.alias})" if field_info.alias else ""
177 lines.append(
178 f" • {field_name}{alias}:"
179 f"\n Value: {current_value!r}"
180 f"\n Default: {default}",
181 )
183 return "\n".join(lines)