Coverage for src/paperap/models/abstract/model.py: 85%
317 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: base.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.8
10 Author: Jess Mann
11 Email: jess@jmann.me
12 Copyright (c) 2025 Jess Mann
14----------------------------------------------------------------------------
16 LAST MODIFIED:
18 2025-03-04 By Jess Mann
20"""
22from __future__ import annotations
24import concurrent.futures
25import logging
26import threading
27import time
28import types
29from abc import ABC, abstractmethod
30from datetime import datetime
31from decimal import Decimal
32from enum import StrEnum
33from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, Optional, Self, TypedDict, cast, override
35import pydantic
36from pydantic import Field, PrivateAttr
37from typing_extensions import TypeVar
39from paperap.const import FilteringStrategies, ModelStatus
40from paperap.exceptions import APIError, ConfigurationError, ReadOnlyFieldError, RequestError, ResourceNotFoundError
41from paperap.models.abstract.meta import StatusContext
42from paperap.signals import registry
44if TYPE_CHECKING:
45 from paperap.client import PaperlessClient
46 from paperap.resources.base import BaseResource, StandardResource
48logger = logging.getLogger(__name__)
50_Self = TypeVar("_Self", bound="BaseModel")
53class ModelConfigType(TypedDict):
54 populate_by_name: bool
55 validate_assignment: bool
56 validate_default: bool
57 use_enum_values: bool
58 extra: Literal["ignore"]
59 arbitrary_types_allowed: bool
62BASE_MODEL_CONFIG: ModelConfigType = {
63 "populate_by_name": True,
64 "validate_assignment": True,
65 "validate_default": True,
66 "use_enum_values": True,
67 "extra": "ignore",
68 "arbitrary_types_allowed": True,
69}
72class BaseModel(pydantic.BaseModel, ABC):
73 """
74 Base model for all Paperless-ngx API objects.
76 Provides automatic serialization, deserialization, and API interactions
77 with minimal configuration needed.
79 Attributes:
80 _meta: Metadata for the model, including filtering and resource information.
81 _save_lock: Lock for saving operations.
82 _pending_save: Future object for pending save operations.
84 Raises:
85 ValueError: If resource is not provided.
87 """
89 _meta: "ClassVar[Meta[Self]]" # type: ignore
90 _save_lock: threading.RLock = PrivateAttr(default_factory=threading.RLock)
91 _pending_save: concurrent.futures.Future | None = PrivateAttr(default=None)
92 _save_executor: concurrent.futures.ThreadPoolExecutor | None = None
93 # Updating attributes will not trigger save()
94 _status: ModelStatus = ModelStatus.INITIALIZING # The last data we retrieved from the db
95 # this is used to calculate if the model is dirty
96 _original_data: dict[str, Any] = {}
97 # The last data we sent to the db to save
98 # This is used to determine if the model has been changed in the time it took to perform a save
99 _saved_data: dict[str, Any] = {}
100 _resource: "BaseResource[Self]"
102 class Meta[_Self]:
103 """
104 Metadata for the Model.
106 Attributes:
107 name: The name of the model.
108 read_only_fields: Fields that should not be modified.
109 filtering_disabled: Fields disabled for filtering.
110 filtering_fields: Fields allowed for filtering.
111 supported_filtering_params: Params allowed during queryset filtering.
112 blacklist_filtering_params: Params disallowed during queryset filtering.
113 filtering_strategies: Strategies for filtering.
114 resource: The BaseResource instance.
115 queryset: The type of QuerySet for the model.
117 Raises:
118 ValueError: If both ALLOW_ALL and ALLOW_NONE filtering strategies are set.
120 """
122 model: type[_Self]
123 # The name of the model.
124 # It will default to the classname
125 name: str
126 # Fields that should not be modified. These will be appended to read_only_fields for all parent classes.
127 read_only_fields: ClassVar[set[str]] = set()
128 # Fields that are disabled by Paperless NGX for filtering.
129 # These will be appended to filtering_disabled for all parent classes.
130 filtering_disabled: ClassVar[set[str]] = set()
131 # Fields allowed for filtering. Generated automatically during class init.
132 filtering_fields: ClassVar[set[str]] = set()
133 # If set, only these params will be allowed during queryset filtering. (e.g. {"content__icontains", "id__gt"})
134 # These will be appended to supported_filtering_params for all parent classes.
135 supported_filtering_params: ClassVar[set[str]] = set()
136 # If set, these params will be disallowed during queryset filtering (e.g. {"content__icontains", "id__gt"})
137 # These will be appended to blacklist_filtering_params for all parent classes.
138 blacklist_filtering_params: ClassVar[set[str]] = set()
139 # Strategies for filtering.
140 # This determines which of the above lists will be used to allow or deny filters to QuerySets.
141 filtering_strategies: ClassVar[set[FilteringStrategies]] = {FilteringStrategies.BLACKLIST}
142 # A map of field names to their attribute names.
143 # Parser uses this to transform input and output data.
144 # This will be populated from all parent classes.
145 field_map: dict[str, str] = {}
146 # If true, updating attributes will trigger save(). If false, save() must be called manually
147 # True or False will override client.settings.save_on_write (PAPERLESS_SAVE_ON_WRITE)
148 # None will respect client.settings.save_on_write
149 save_on_write: bool | None = None
150 save_timeout: int = PrivateAttr(default=60) # seconds
152 __type_hints_cache__: dict[str, type] = {}
154 def __init__(self, model: type[_Self]):
155 self.model = model
157 # Validate filtering strategies
158 if all(
159 x in self.filtering_strategies for x in (FilteringStrategies.ALLOW_ALL, FilteringStrategies.ALLOW_NONE)
160 ):
161 raise ValueError(f"Cannot have ALLOW_ALL and ALLOW_NONE filtering strategies in {self.model.__name__}")
163 super().__init__()
165 def filter_allowed(self, filter_param: str) -> bool:
166 """
167 Check if a filter is allowed based on the filtering strategies.
169 Args:
170 filter_param: The filter parameter to check.
172 Returns:
173 True if the filter is allowed, False otherwise.
175 """
176 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies:
177 return True
179 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies:
180 return False
182 # If we have a whitelist, check if the filter_param is in it
183 if FilteringStrategies.WHITELIST in self.filtering_strategies:
184 if self.supported_filtering_params and filter_param not in self.supported_filtering_params:
185 return False
186 # Allow other rules to fire
188 # If we have a blacklist, check if the filter_param is in it
189 if FilteringStrategies.BLACKLIST in self.filtering_strategies:
190 if self.blacklist_filtering_params and filter_param in self.blacklist_filtering_params:
191 return False
192 # Allow other rules to fire
194 # Check if the filtering key is disabled
195 split_key = filter_param.split("__")
196 if len(split_key) > 1:
197 field, _lookup = split_key[-2:]
198 else:
199 field, _lookup = filter_param, None
201 # If key is in filtering_disabled, throw an error
202 if field in self.filtering_disabled:
203 return False
205 # Not disabled, so it's allowed
206 return True
208 @override
209 def __init_subclass__(cls, **kwargs: Any) -> None:
210 """
211 Initialize subclass and set up metadata.
213 Args:
214 **kwargs: Additional keyword arguments.
216 """
217 super().__init_subclass__(**kwargs)
218 # Ensure the subclass has its own Meta definition.
219 # If not, create a new one inheriting from the parent’s Meta.
220 # If the subclass hasn't defined its own Meta, auto-generate one.
221 if "Meta" not in cls.__dict__:
222 top_meta: type[BaseModel.Meta[Self]] | None = None
223 # Iterate over ancestors to get the top-most explicitly defined Meta.
224 for base in cls.__mro__[1:]:
225 if "Meta" in base.__dict__:
226 top_meta = cast(type[BaseModel.Meta[Self]], base.Meta)
227 break
228 if top_meta is None:
229 # This should never happen.
230 raise ConfigurationError(f"Meta class not found in {cls.__name__} or its bases")
232 # Create a new Meta class that inherits from the top-most Meta.
233 meta_attrs = {
234 k: v
235 for k, v in vars(top_meta).items()
236 if not k.startswith("_") # Avoid special attributes like __parameters__
237 }
238 cls.Meta = type("Meta", (top_meta,), meta_attrs) # type: ignore # mypy complains about setting to a type
239 logger.debug(
240 "Auto-generated Meta for %s inheriting from %s",
241 cls.__name__,
242 top_meta.__name__,
243 )
245 # Append read_only_fields from all parents to Meta
246 # Same with filtering_disabled
247 # Retrieve filtering_fields from the attributes of the class
248 read_only_fields = (cls.Meta.read_only_fields or set[str]()).copy()
249 filtering_disabled = (cls.Meta.filtering_disabled or set[str]()).copy()
250 filtering_fields = set(cls.__annotations__.keys())
251 supported_filtering_params = cls.Meta.supported_filtering_params
252 blacklist_filtering_params = cls.Meta.blacklist_filtering_params
253 field_map = cls.Meta.field_map
254 for base in cls.__bases__:
255 _meta: BaseModel.Meta[Self] | None
256 if _meta := getattr(base, "Meta", None): # type: ignore # we are confident this is BaseModel.Meta
257 if hasattr(_meta, "read_only_fields"):
258 read_only_fields.update(_meta.read_only_fields)
259 if hasattr(_meta, "filtering_disabled"):
260 filtering_disabled.update(_meta.filtering_disabled)
261 if hasattr(_meta, "filtering_fields"):
262 filtering_fields.update(_meta.filtering_fields)
263 if hasattr(_meta, "supported_filtering_params"):
264 supported_filtering_params.update(_meta.supported_filtering_params)
265 if hasattr(_meta, "blacklist_filtering_params"):
266 blacklist_filtering_params.update(_meta.blacklist_filtering_params)
267 if hasattr(_meta, "field_map"):
268 field_map.update(_meta.field_map)
270 cls.Meta.read_only_fields = read_only_fields
271 cls.Meta.filtering_disabled = filtering_disabled
272 # excluding filtering_disabled from filtering_fields
273 cls.Meta.filtering_fields = filtering_fields - filtering_disabled
274 cls.Meta.supported_filtering_params = supported_filtering_params
275 cls.Meta.blacklist_filtering_params = blacklist_filtering_params
276 cls.Meta.field_map = field_map
278 # Instantiate _meta
279 cls._meta = cls.Meta(cls) # type: ignore # due to a mypy bug in version 1.15.0 (issue #18776)
281 # Set name defaults
282 if not hasattr(cls._meta, "name"):
283 cls._meta.name = cls.__name__.lower()
285 # Configure Pydantic behavior
286 # type ignore because mypy complains about non-required keys
287 model_config = pydantic.ConfigDict(**BASE_MODEL_CONFIG) # type: ignore
289 def __init__(self, **data: Any) -> None:
290 """
291 Initialize the model with resource and data.
293 Args:
294 resource: The BaseResource instance.
295 **data: Additional data to initialize the model.
297 Raises:
298 ValueError: If resource is not provided.
300 """
301 super().__init__(**data)
303 if not hasattr(self, "_resource"):
304 raise ValueError(
305 f"Resource required. Initialize resource for {self.__class__.__name__} before instantiating models."
306 )
308 @property
309 def _client(self) -> "PaperlessClient":
310 """
311 Get the client associated with this model.
313 Returns:
314 The PaperlessClient instance.
316 """
317 return self._resource.client
319 @property
320 def resource(self) -> "BaseResource[Self]":
321 return self._resource
323 @property
324 def save_executor(self) -> concurrent.futures.ThreadPoolExecutor:
325 if not self._save_executor:
326 self._save_executor = concurrent.futures.ThreadPoolExecutor(
327 max_workers=5, thread_name_prefix="model_save_worker"
328 )
329 return self._save_executor
331 def cleanup(self) -> None:
332 """Clean up resources used by the model class."""
333 if self._save_executor:
334 self._save_executor.shutdown(wait=True)
335 self._save_executor = None
337 @override
338 def model_post_init(self, __context) -> None:
339 super().model_post_init(__context)
341 # Save original_data to support dirty fields
342 self._original_data = self.model_dump()
344 # Allow updating attributes to trigger save() automatically
345 self._status = ModelStatus.READY
347 super().model_post_init(__context)
349 @classmethod
350 def from_dict(cls, data: dict[str, Any]) -> Self:
351 """
352 Create a model instance from API response data.
354 Args:
355 data: Dictionary containing the API response data.
357 Returns:
358 A model instance initialized with the provided data.
360 Examples:
361 # Create a Document instance from API data
362 doc = Document.from_dict(api_data)
364 """
365 return cls._resource.parse_to_model(data)
367 def to_dict(
368 self,
369 *,
370 include_read_only: bool = True,
371 exclude_none: bool = False,
372 exclude_unset: bool = True,
373 ) -> dict[str, Any]:
374 """
375 Convert the model to a dictionary for API requests.
377 Args:
378 include_read_only: Whether to include read-only fields.
379 exclude_none: Whether to exclude fields with None values.
380 exclude_unset: Whether to exclude fields that are not set.
382 Returns:
383 A dictionary with model data ready for API submission.
385 Examples:
386 # Convert a Document instance to a dictionary
387 data = doc.to_dict()
389 """
390 exclude: set[str] = set() if include_read_only else set(self._meta.read_only_fields)
392 return self.model_dump(
393 exclude=exclude,
394 exclude_none=exclude_none,
395 exclude_unset=exclude_unset,
396 )
398 def dirty_fields(self, comparison: Literal["saved", "db", "both"] = "both") -> dict[str, tuple[Any, Any]]:
399 """
400 Show which fields have changed since last update from the paperless ngx db.
402 Args:
403 comparison:
404 Specify the data to compare ('saved' or 'db').
405 Db is the last data retrieved from Paperless NGX
406 Saved is the last data sent to Paperless NGX to be saved
408 Returns:
409 A dictionary {field: (original_value, new_value)} of fields that have
410 changed since last update from the paperless ngx db.
412 """
413 current_data = self.model_dump()
415 if comparison == "saved":
416 compare_dict = self._saved_data
417 elif comparison == "db":
418 compare_dict = self._original_data
419 else:
420 # For 'both', we want to compare against both original and saved data
421 # A field is dirty if it differs from either original or saved data
422 compare_dict = {}
423 for field in set(list(self._original_data.keys()) + list(self._saved_data.keys())):
424 # Prefer original data (from DB) over saved data when both exist
425 compare_dict[field] = self._original_data.get(field, self._saved_data.get(field))
427 return {
428 field: (compare_dict[field], current_data[field])
429 for field in current_data
430 if field in compare_dict and compare_dict[field] != current_data[field]
431 }
433 def is_dirty(self, comparison: Literal["saved", "db", "both"] = "both") -> bool:
434 """
435 Check if any field has changed since last update from the paperless ngx db.
437 Args:
438 comparison:
439 Specify the data to compare ('saved' or 'db').
440 Db is the last data retrieved from Paperless NGX
441 Saved is the last data sent to Paperless NGX to be saved
443 Returns:
444 True if any field has changed.
446 """
447 return bool(self.dirty_fields(comparison=comparison))
449 @classmethod
450 def create(cls, **kwargs: Any) -> Self:
451 """
452 Create a new model instance.
454 Args:
455 **kwargs: Field values to set.
457 Returns:
458 A new model instance.
460 Examples:
461 # Create a new Document instance
462 doc = Document.create(filename="example.pdf", contents=b"PDF data")
464 """
465 # TODO save
466 return cls(**kwargs)
468 def update_locally(self, *, from_db: bool | None = None, skip_changed_fields: bool = False, **kwargs: Any) -> None:
469 """
470 Update model attributes without triggering automatic save.
472 Args:
473 **kwargs: Field values to update
475 Returns:
476 Self with updated values
478 """
479 from_db = from_db if from_db is not None else False
481 # Avoid infinite saving loops
482 with StatusContext(self, ModelStatus.UPDATING):
483 # Ensure read-only fields were not changed
484 if not from_db:
485 for field in self._meta.read_only_fields:
486 if field in kwargs and kwargs[field] != self._original_data.get(field, None):
487 raise ReadOnlyFieldError(f"Cannot change read-only field {field}")
489 # If the field contains unsaved changes, skip updating it
490 # Determine unsaved changes based on the dirty fields before we last called save
491 if skip_changed_fields:
492 unsaved_changes = self.dirty_fields(comparison="saved")
493 kwargs = {k: v for k, v in kwargs.items() if k not in unsaved_changes}
495 for name, value in kwargs.items():
496 setattr(self, name, value)
498 # Dirty has been reset
499 if from_db:
500 self._original_data = self.model_dump()
502 def update(self, **kwargs: Any) -> None:
503 """
504 Update this model with new values.
506 Subclasses implement this with auto-saving features.
507 However, base BaseModel instances simply call update_locally.
509 Args:
510 **kwargs: New field values.
512 Examples:
513 # Update a Document instance
514 doc.update(filename="new_example.pdf")
516 """
517 # Since we have no id, we can't save. Therefore, all updates are silent updates
518 # subclasses may implement this.
519 self.update_locally(**kwargs)
521 @abstractmethod
522 def is_new(self) -> bool:
523 """
524 Check if this model represents a new (unsaved) object.
526 Returns:
527 True if the model is new, False otherwise.
529 Examples:
530 # Check if a Document instance is new
531 is_new = doc.is_new()
533 """
535 def should_save_on_write(self) -> bool:
536 """
537 Check if the model should save on attribute write, factoring in the client settings.
538 """
539 if self._meta.save_on_write is not None:
540 return self._meta.save_on_write
541 return self._resource.client.settings.save_on_write
543 def enable_save_on_write(self) -> None:
544 """
545 Enable automatic saving on attribute write.
546 """
547 self._meta.save_on_write = True
549 def disable_save_on_write(self) -> None:
550 """
551 Disable automatic saving on attribute write.
552 """
553 self._meta.save_on_write = False
555 def matches_dict(self, data: dict[str, Any]) -> bool:
556 """
557 Check if the model matches the provided data.
559 Args:
560 data: Dictionary containing the data to compare.
562 Returns:
563 True if the model matches the data, False otherwise.
565 Examples:
566 # Check if a Document instance matches API data
567 matches = doc.matches_dict(api_data)
569 """
570 return self.to_dict() == data
572 @override
573 def __str__(self) -> str:
574 """
575 Human-readable string representation.
577 Returns:
578 A string representation of the model.
580 """
581 return f"{self._meta.name.capitalize()}"
584class StandardModel(BaseModel, ABC):
585 """
586 Standard model for Paperless-ngx API objects with an ID field.
588 Attributes:
589 id: Unique identifier for the model.
591 """
593 id: int = Field(description="Unique identifier from Paperless NGX", default=0)
594 _resource: "StandardResource[Self]" # type: ignore # override
596 class Meta(BaseModel.Meta):
597 """
598 Metadata for the StandardModel.
600 Attributes:
601 read_only_fields: Fields that should not be modified.
602 supported_filtering_params: Params allowed during queryset filtering.
604 """
606 # Fields that should not be modified
607 read_only_fields: ClassVar[set[str]] = {"id"}
608 supported_filtering_params = {"id__in", "id"}
610 @override
611 def update(self, **kwargs: Any) -> None:
612 """
613 Update this model with new values and save changes.
615 NOTE: new instances will not be saved automatically.
616 (I'm not sure if that's the right design decision or not)
618 Args:
619 **kwargs: New field values.
621 """
622 # Hold off on saving until all updates are complete
623 self.update_locally(**kwargs)
624 if not self.is_new():
625 self.save()
627 def refresh(self) -> bool:
628 """
629 Refresh the model with the latest data from the server.
631 Returns:
632 True if the model data changes, False on failure or if the data does not change.
634 Raises:
635 ResourceNotFoundError: If the model is not found on Paperless. (e.g. it was deleted remotely)
637 """
638 if self.is_new():
639 raise ResourceNotFoundError("Model does not have an id, so cannot be refreshed. Save first.")
641 new_model = self._resource.get(self.id)
643 if self == new_model:
644 return False
646 self.update_locally(from_db=True, **new_model.to_dict())
647 return True
649 def save(self, *, force: bool = False):
650 return self.save_sync(force=force)
652 def save_sync(self, *, force: bool = False) -> None:
653 """
654 Save this model instance synchronously.
656 Changes are sent to the server immediately, and the model is updated
657 when the server responds.
659 Raises:
660 ResourceNotFoundError: If the resource doesn't exist on the server
661 RequestError: If there's a communication error with the server
662 PermissionError: If the user doesn't have permission to update the resource
664 """
665 if not force:
666 if self._status == ModelStatus.SAVING:
667 return
669 # Only start a save if there are changes
670 if not self.is_dirty():
671 return
673 with StatusContext(self, ModelStatus.SAVING):
674 # Prepare and send the update to the server
675 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True)
676 self._saved_data = {**current_data}
678 registry.emit(
679 "model.save:before",
680 "Fired before the model data is sent to paperless ngx to be saved.",
681 kwargs={"model": self, "current_data": current_data},
682 )
684 new_model = self._resource.update(self) # type: ignore # basedmypy complaining about self
686 if not new_model:
687 logger.warning(f"Result of save was none for model id {self.id}")
688 return
690 if not isinstance(new_model, StandardModel):
691 # This should never happen
692 logger.error("Result of save was not a StandardModel instance")
693 return
695 try:
696 # Update the model with the server response
697 new_data = new_model.to_dict()
698 self.update_locally(from_db=True, **new_data)
700 registry.emit(
701 "model.save:after",
702 "Fired after the model data is saved in paperless ngx.",
703 kwargs={"model": self, "updated_data": new_data},
704 )
706 except APIError as e:
707 logger.error(f"API error during save of {self}: {e}")
708 registry.emit(
709 "model.save:error",
710 "Fired when a network error occurs during save.",
711 kwargs={"model": self, "error": e},
712 )
714 except Exception as e:
715 # Log unexpected errors but don't swallow them
716 logger.exception(f"Unexpected error during save of {self}")
717 registry.emit(
718 "model.save:error",
719 "Fired when an unexpected error occurs during save.",
720 kwargs={"model": self, "error": e},
721 )
722 # Re-raise so the executor can handle it properly
723 raise
725 def save_async(self, *, force: bool = False) -> None:
726 """
727 Save this model instance asynchronously.
729 Changes are sent to the server in a background thread, and the model
730 is updated when the server responds.
731 """
732 if not force:
733 if self._status == ModelStatus.SAVING:
734 return
736 # Only start a save if there are changes
737 if not self.is_dirty():
738 if hasattr(self, "_save_lock") and self._save_lock._is_owned(): # type: ignore # temporary TODO
739 self._save_lock.release()
740 return
742 # If there's a pending save, skip saving until it finishes
743 if self._pending_save is not None and not self._pending_save.done():
744 return
746 self._status = ModelStatus.SAVING
747 self._save_lock.acquire(timeout=30)
749 # Start a new save operation
750 executor = self.save_executor
751 future = executor.submit(self._perform_save_async)
752 self._pending_save = future
753 future.add_done_callback(self._handle_save_result_async)
755 def _perform_save_async(self) -> Self | None:
756 """
757 Perform the actual save operation.
759 Returns:
760 The updated model from the server or None if no save was needed.
762 Raises:
763 ResourceNotFoundError: If the resource doesn't exist on the server
764 RequestError: If there's a communication error with the server
765 PermissionError: If the user doesn't have permission to update the resource
767 """
768 # Prepare and send the update to the server
769 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True)
770 self._saved_data = {**current_data}
772 registry.emit(
773 "model.save:before",
774 "Fired before the model data is sent to paperless ngx to be saved.",
775 kwargs={"model": self, "current_data": current_data},
776 )
778 return self._resource.update(self)
780 def _handle_save_result_async(self, future: concurrent.futures.Future) -> None:
781 """
782 Handle the result of an asynchronous save operation.
784 Args:
785 future: The completed Future object containing the save result.
787 """
788 try:
789 # Get the result with a timeout
790 new_model: Self = future.result(timeout=self._meta.save_timeout)
792 if not new_model:
793 logger.warning(f"Result of save was none for model id {self.id}")
794 return
796 if not isinstance(new_model, StandardModel):
797 # This should never happen
798 logger.error("Result of save was not a StandardModel instance")
799 return
801 # Update the model with the server response
802 new_data = new_model.to_dict()
803 # Use direct attribute setting instead of update_locally to avoid mocking issues
804 with StatusContext(self, ModelStatus.UPDATING):
805 for name, value in new_data.items():
806 if self.is_dirty("saved") and name in self.dirty_fields("saved"):
807 continue # Skip fields changed during save
808 setattr(self, name, value)
809 # Mark as from DB
810 self._original_data = self.model_dump()
812 registry.emit(
813 "model.save:after",
814 "Fired after the model data is saved in paperless ngx.",
815 kwargs={"model": self, "updated_data": new_data},
816 )
818 except concurrent.futures.TimeoutError:
819 logger.error(f"Save operation timed out for {self}")
820 registry.emit(
821 "model.save:error",
822 "Fired when a save operation times out.",
823 kwargs={"model": self, "error": "Timeout"},
824 )
826 except APIError as e:
827 logger.error(f"API error during save of {self}: {e}")
828 registry.emit(
829 "model.save:error",
830 "Fired when a network error occurs during save.",
831 kwargs={"model": self, "error": e},
832 )
834 except Exception as e:
835 # Log unexpected errors but don't swallow them
836 logger.exception(f"Unexpected error during save of {self}")
837 registry.emit(
838 "model.save:error",
839 "Fired when an unexpected error occurs during save.",
840 kwargs={"model": self, "error": e},
841 )
842 # Re-raise so the executor can handle it properly
843 raise
845 finally:
846 self._pending_save = None
847 try:
848 self._save_lock.release()
849 except RuntimeError:
850 logger.debug("Save lock already released")
851 self._status = ModelStatus.READY
853 # If the model was changed while the save was in progress,
854 # we need to save again
855 if self.is_dirty("saved"):
856 # Small delay to avoid hammering the server
857 time.sleep(0.1)
858 # Save, and reset unsaved data
859 self.save()
861 @override
862 def is_new(self) -> bool:
863 """
864 Check if this model represents a new (unsaved) object.
866 Returns:
867 True if the model is new, False otherwise.
869 Examples:
870 # Check if a Document instance is new
871 is_new = doc.is_new()
873 """
874 return self.id == 0
876 def _autosave(self) -> None:
877 # Skip autosave for:
878 # - New models (not yet saved)
879 # - When auto-save is disabled
880 if self.is_new() or self.should_save_on_write() is False or not self.is_dirty():
881 return
883 self.save()
885 @override
886 def __setattr__(self, name: str, value: Any) -> None:
887 """
888 Override attribute setting to automatically trigger async save.
890 Args:
891 name: Attribute name
892 value: New attribute value
894 """
895 # Set the new value
896 super().__setattr__(name, value)
898 # Autosave logic below
899 if self._status != ModelStatus.READY:
900 return
902 # Skip autosave for private fields
903 if not name.startswith("_"):
904 self._autosave()
906 @override
907 def __str__(self) -> str:
908 """
909 Human-readable string representation.
911 Returns:
912 A string representation of the model.
914 """
915 return f"{self._meta.name.capitalize()} #{self.id}"