Coverage for src/paperap/models/abstract/model.py: 85%
335 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-22 16:02 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-22 16:02 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: base.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.9
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, 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__)
51class ModelConfigType(TypedDict):
52 populate_by_name: bool
53 validate_assignment: bool
54 validate_default: bool
55 use_enum_values: bool
56 extra: Literal["ignore"]
57 arbitrary_types_allowed: bool
60BASE_MODEL_CONFIG: ModelConfigType = {
61 "populate_by_name": True,
62 "validate_assignment": True,
63 "validate_default": True,
64 "use_enum_values": True,
65 "extra": "ignore",
66 "arbitrary_types_allowed": True,
67}
70class BaseModel(pydantic.BaseModel, ABC):
71 """
72 Base model for all Paperless-ngx API objects.
74 Provides automatic serialization, deserialization, and API interactions
75 with minimal configuration needed.
77 Attributes:
78 _meta: Metadata for the model, including filtering and resource information.
79 _save_lock: Lock for saving operations.
80 _pending_save: Future object for pending save operations.
82 Raises:
83 ValueError: If resource is not provided.
85 """
87 _meta: ClassVar["Meta[Self]"]
88 _save_lock: threading.RLock = PrivateAttr(default_factory=threading.RLock)
89 _pending_save: concurrent.futures.Future[Any] | None = PrivateAttr(default=None)
90 _save_executor: concurrent.futures.ThreadPoolExecutor | None = None
91 # Updating attributes will not trigger save()
92 _status: ModelStatus = ModelStatus.INITIALIZING # The last data we retrieved from the db
93 # this is used to calculate if the model is dirty
94 _original_data: dict[str, Any] = {}
95 # The last data we sent to the db to save
96 # This is used to determine if the model has been changed in the time it took to perform a save
97 _saved_data: dict[str, Any] = {}
98 _resource: "BaseResource[Self]"
100 class Meta[_Self: "BaseModel"]:
101 """
102 Metadata for the Model.
104 Attributes:
105 name: The name of the model.
106 read_only_fields: Fields that should not be modified.
107 filtering_disabled: Fields disabled for filtering.
108 filtering_fields: Fields allowed for filtering.
109 supported_filtering_params: Params allowed during queryset filtering.
110 blacklist_filtering_params: Params disallowed during queryset filtering.
111 filtering_strategies: Strategies for filtering.
112 resource: The BaseResource instance.
113 queryset: The type of QuerySet for the model.
115 Raises:
116 ValueError: If both ALLOW_ALL and ALLOW_NONE filtering strategies are set.
118 """
120 model: type[_Self]
121 # The name of the model.
122 # It will default to the classname
123 name: str
124 # Fields that should not be modified. These will be appended to read_only_fields for all parent classes.
125 read_only_fields: ClassVar[set[str]] = set()
126 # Fields that are disabled by Paperless NGX for filtering.
127 # These will be appended to filtering_disabled for all parent classes.
128 filtering_disabled: ClassVar[set[str]] = set()
129 # Fields allowed for filtering. Generated automatically during class init.
130 filtering_fields: ClassVar[set[str]] = set()
131 # If set, only these params will be allowed during queryset filtering. (e.g. {"content__icontains", "id__gt"})
132 # These will be appended to supported_filtering_params for all parent classes.
133 supported_filtering_params: ClassVar[set[str]] = {"limit"}
134 # If set, these params will be disallowed during queryset filtering (e.g. {"content__icontains", "id__gt"})
135 # These will be appended to blacklist_filtering_params for all parent classes.
136 blacklist_filtering_params: ClassVar[set[str]] = set()
137 # Strategies for filtering.
138 # This determines which of the above lists will be used to allow or deny filters to QuerySets.
139 filtering_strategies: ClassVar[set[FilteringStrategies]] = {FilteringStrategies.BLACKLIST}
140 # A map of field names to their attribute names.
141 # Parser uses this to transform input and output data.
142 # This will be populated from all parent classes.
143 field_map: dict[str, str] = {}
144 # If true, updating attributes will trigger save(). If false, save() must be called manually
145 # True or False will override client.settings.save_on_write (PAPERLESS_SAVE_ON_WRITE)
146 # None will respect client.settings.save_on_write
147 save_on_write: bool | None = None
148 save_timeout: int = PrivateAttr(default=60) # seconds
150 __type_hints_cache__: dict[str, type] = {}
152 def __init__(self, model: type[_Self]):
153 self.model = model
155 # Validate filtering strategies
156 if all(x in self.filtering_strategies for x in (FilteringStrategies.ALLOW_ALL, FilteringStrategies.ALLOW_NONE)):
157 raise ValueError(f"Cannot have ALLOW_ALL and ALLOW_NONE filtering strategies in {self.model.__name__}")
159 super().__init__()
161 def filter_allowed(self, filter_param: str) -> bool:
162 """
163 Check if a filter is allowed based on the filtering strategies.
165 Args:
166 filter_param: The filter parameter to check.
168 Returns:
169 True if the filter is allowed, False otherwise.
171 """
172 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies:
173 return True
175 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies:
176 return False
178 # If we have a whitelist, check if the filter_param is in it
179 if FilteringStrategies.WHITELIST in self.filtering_strategies:
180 if self.supported_filtering_params and filter_param not in self.supported_filtering_params:
181 return False
182 # Allow other rules to fire
184 # If we have a blacklist, check if the filter_param is in it
185 if FilteringStrategies.BLACKLIST in self.filtering_strategies:
186 if self.blacklist_filtering_params and filter_param in self.blacklist_filtering_params:
187 return False
188 # Allow other rules to fire
190 # Check if the filtering key is disabled
191 split_key = filter_param.split("__")
192 if len(split_key) > 1:
193 field, _lookup = split_key[-2:]
194 else:
195 field, _lookup = filter_param, None
197 # If key is in filtering_disabled, throw an error
198 if field in self.filtering_disabled:
199 return False
201 # Not disabled, so it's allowed
202 return True
204 @override
205 def __init_subclass__(cls, **kwargs: Any) -> None:
206 """
207 Initialize subclass and set up metadata.
209 Args:
210 **kwargs: Additional keyword arguments.
212 """
213 super().__init_subclass__(**kwargs)
214 # Ensure the subclass has its own Meta definition.
215 # If not, create a new one inheriting from the parent’s Meta.
216 # If the subclass hasn't defined its own Meta, auto-generate one.
217 if "Meta" not in cls.__dict__:
218 top_meta: type[BaseModel.Meta[Self]] | None = None
219 # Iterate over ancestors to get the top-most explicitly defined Meta.
220 for base in cls.__mro__[1:]:
221 if "Meta" in base.__dict__:
222 top_meta = cast("type[BaseModel.Meta[Self]]", base.Meta)
223 break
224 if top_meta is None:
225 # This should never happen.
226 raise ConfigurationError(f"Meta class not found in {cls.__name__} or its bases")
228 # Create a new Meta class that inherits from the top-most Meta.
229 meta_attrs = {
230 k: v
231 for k, v in vars(top_meta).items()
232 if not k.startswith("_") # Avoid special attributes like __parameters__
233 }
234 cls.Meta = type("Meta", (top_meta,), meta_attrs) # type: ignore # mypy complains about setting to a type
235 logger.debug(
236 "Auto-generated Meta for %s inheriting from %s",
237 cls.__name__,
238 top_meta.__name__,
239 )
241 # Append read_only_fields from all parents to Meta
242 # Same with filtering_disabled
243 # Retrieve filtering_fields from the attributes of the class
244 read_only_fields = (cls.Meta.read_only_fields or set[str]()).copy()
245 filtering_disabled = (cls.Meta.filtering_disabled or set[str]()).copy()
246 filtering_fields = set(cls.__annotations__.keys())
247 supported_filtering_params = cls.Meta.supported_filtering_params
248 blacklist_filtering_params = cls.Meta.blacklist_filtering_params
249 field_map = cls.Meta.field_map
250 for base in cls.__bases__:
251 _meta: BaseModel.Meta[Self] | None
252 if _meta := getattr(base, "Meta", None): # type: ignore # we are confident this is BaseModel.Meta
253 if hasattr(_meta, "read_only_fields"):
254 read_only_fields.update(_meta.read_only_fields)
255 if hasattr(_meta, "filtering_disabled"):
256 filtering_disabled.update(_meta.filtering_disabled)
257 if hasattr(_meta, "filtering_fields"):
258 filtering_fields.update(_meta.filtering_fields)
259 if hasattr(_meta, "supported_filtering_params"):
260 supported_filtering_params.update(_meta.supported_filtering_params)
261 if hasattr(_meta, "blacklist_filtering_params"):
262 blacklist_filtering_params.update(_meta.blacklist_filtering_params)
263 if hasattr(_meta, "field_map"):
264 field_map.update(_meta.field_map)
266 cls.Meta.read_only_fields = read_only_fields
267 cls.Meta.filtering_disabled = filtering_disabled
268 # excluding filtering_disabled from filtering_fields
269 cls.Meta.filtering_fields = filtering_fields - filtering_disabled
270 cls.Meta.supported_filtering_params = supported_filtering_params
271 cls.Meta.blacklist_filtering_params = blacklist_filtering_params
272 cls.Meta.field_map = field_map
274 # Instantiate _meta
275 cls._meta = cls.Meta(cls) # type: ignore # due to a mypy bug in version 1.15.0 (issue #18776)
277 # Set name defaults
278 if not hasattr(cls._meta, "name"):
279 cls._meta.name = cls.__name__.lower()
281 # Configure Pydantic behavior
282 # type ignore because mypy complains about non-required keys
283 model_config = pydantic.ConfigDict(**BASE_MODEL_CONFIG) # type: ignore
285 def __init__(self, **data: Any) -> None:
286 """
287 Initialize the model with resource and data.
289 Args:
290 resource: The BaseResource instance.
291 **data: Additional data to initialize the model.
293 Raises:
294 ValueError: If resource is not provided.
296 """
297 super().__init__(**data)
299 if not hasattr(self, "_resource"):
300 raise ValueError(f"Resource required. Initialize resource for {self.__class__.__name__} before instantiating models.")
302 @property
303 def _client(self) -> "PaperlessClient":
304 """
305 Get the client associated with this model.
307 Returns:
308 The PaperlessClient instance.
310 """
311 return self._resource.client
313 @property
314 def resource(self) -> "BaseResource[Self]":
315 return self._resource
317 @property
318 def save_executor(self) -> concurrent.futures.ThreadPoolExecutor:
319 if not self._save_executor:
320 self._save_executor = concurrent.futures.ThreadPoolExecutor(max_workers=5, thread_name_prefix="model_save_worker")
321 return self._save_executor
323 def cleanup(self) -> None:
324 """Clean up resources used by the model class."""
325 if self._save_executor:
326 self._save_executor.shutdown(wait=True)
327 self._save_executor = None
329 @override
330 def model_post_init(self, __context: Any) -> None:
331 super().model_post_init(__context)
333 # Save original_data to support dirty fields
334 self._original_data = self.model_dump()
336 # Allow updating attributes to trigger save() automatically
337 self._status = ModelStatus.READY
339 super().model_post_init(__context)
341 @classmethod
342 def from_dict(cls, data: dict[str, Any]) -> Self:
343 """
344 Create a model instance from API response data.
346 Args:
347 data: Dictionary containing the API response data.
349 Returns:
350 A model instance initialized with the provided data.
352 Examples:
353 # Create a Document instance from API data
354 doc = Document.from_dict(api_data)
356 """
357 return cls._resource.parse_to_model(data)
359 def to_dict(
360 self,
361 *,
362 include_read_only: bool = True,
363 exclude_none: bool = False,
364 exclude_unset: bool = True,
365 ) -> dict[str, Any]:
366 """
367 Convert the model to a dictionary for API requests.
369 Args:
370 include_read_only: Whether to include read-only fields.
371 exclude_none: Whether to exclude fields with None values.
372 exclude_unset: Whether to exclude fields that are not set.
374 Returns:
375 A dictionary with model data ready for API submission.
377 Examples:
378 # Convert a Document instance to a dictionary
379 data = doc.to_dict()
381 """
382 exclude: set[str] = set() if include_read_only else set(self._meta.read_only_fields)
384 return self.model_dump(
385 exclude=exclude,
386 exclude_none=exclude_none,
387 exclude_unset=exclude_unset,
388 )
390 def dirty_fields(self, comparison: Literal["saved", "db", "both"] = "both") -> dict[str, tuple[Any, Any]]:
391 """
392 Show which fields have changed since last update from the paperless ngx db.
394 Args:
395 comparison:
396 Specify the data to compare ('saved' or 'db').
397 Db is the last data retrieved from Paperless NGX
398 Saved is the last data sent to Paperless NGX to be saved
400 Returns:
401 A dictionary {field: (original_value, new_value)} of fields that have
402 changed since last update from the paperless ngx db.
404 """
405 current_data = self.model_dump()
406 current_data.pop("id", None)
408 if comparison == "saved":
409 compare_dict = self._saved_data
410 elif comparison == "db":
411 compare_dict = self._original_data
412 else:
413 # For 'both', we want to compare against both original and saved data
414 # A field is dirty if it differs from either original or saved data
415 compare_dict = {}
416 for field in set(list(self._original_data.keys()) + list(self._saved_data.keys())):
417 # ID cannot change, and is not set before first save sometimes
418 if field == "id":
419 continue
421 # Prefer original data (from DB) over saved data when both exist
422 compare_dict[field] = self._original_data.get(field, self._saved_data.get(field))
424 return {
425 field: (compare_dict.get(field, None), current_data.get(field, None))
426 for field in current_data
427 if compare_dict.get(field, None) != current_data.get(field, None)
428 }
430 def is_dirty(self, comparison: Literal["saved", "db", "both"] = "both") -> bool:
431 """
432 Check if any field has changed since last update from the paperless ngx db.
434 Args:
435 comparison:
436 Specify the data to compare ('saved' or 'db').
437 Db is the last data retrieved from Paperless NGX
438 Saved is the last data sent to Paperless NGX to be saved
440 Returns:
441 True if any field has changed.
443 """
444 if self.is_new():
445 return True
446 return bool(self.dirty_fields(comparison=comparison))
448 @classmethod
449 def create(cls, **kwargs: Any) -> Self:
450 """
451 Create a new model instance.
453 Args:
454 **kwargs: Field values to set.
456 Returns:
457 A new model instance.
459 Examples:
460 # Create a new Document instance
461 doc = Document.create(filename="example.pdf", contents=b"PDF data")
463 """
464 return cls._resource.create(**kwargs)
466 def delete(self) -> None:
467 return self._resource.delete(self)
469 def update_locally(self, *, from_db: bool | None = None, skip_changed_fields: bool = False, **kwargs: Any) -> None:
470 """
471 Update model attributes without triggering automatic save.
473 Args:
474 **kwargs: Field values to update
476 Returns:
477 Self with updated values
479 """
480 from_db = from_db if from_db is not None else False
482 # Avoid infinite saving loops
483 with StatusContext(self, ModelStatus.UPDATING):
484 # Ensure read-only fields were not changed
485 if not from_db:
486 for field in self._meta.read_only_fields:
487 if field in kwargs and kwargs[field] != self._original_data.get(field, None):
488 raise ReadOnlyFieldError(f"Cannot change read-only field {field}")
490 # If the field contains unsaved changes, skip updating it
491 # Determine unsaved changes based on the dirty fields before we last called save
492 if skip_changed_fields:
493 unsaved_changes = self.dirty_fields(comparison="saved")
494 kwargs = {k: v for k, v in kwargs.items() if k not in unsaved_changes}
496 for name, value in kwargs.items():
497 setattr(self, name, value)
499 # Dirty has been reset
500 if from_db:
501 self._original_data = self.model_dump()
503 def update(self, **kwargs: Any) -> None:
504 """
505 Update this model with new values.
507 Subclasses implement this with auto-saving features.
508 However, base BaseModel instances simply call update_locally.
510 Args:
511 **kwargs: New field values.
513 Examples:
514 # Update a Document instance
515 doc.update(filename="new_example.pdf")
517 """
518 # Since we have no id, we can't save. Therefore, all updates are silent updates
519 # subclasses may implement this.
520 self.update_locally(**kwargs)
522 @abstractmethod
523 def is_new(self) -> bool:
524 """
525 Check if this model represents a new (unsaved) object.
527 Returns:
528 True if the model is new, False otherwise.
530 Examples:
531 # Check if a Document instance is new
532 is_new = doc.is_new()
534 """
536 def should_save_on_write(self) -> bool:
537 """
538 Check if the model should save on attribute write, factoring in the client settings.
539 """
540 if self._meta.save_on_write is not None:
541 return self._meta.save_on_write
542 return self._resource.client.settings.save_on_write
544 def enable_save_on_write(self) -> None:
545 """
546 Enable automatic saving on attribute write.
547 """
548 self._meta.save_on_write = True
550 def disable_save_on_write(self) -> None:
551 """
552 Disable automatic saving on attribute write.
553 """
554 self._meta.save_on_write = False
556 def matches_dict(self, data: dict[str, Any]) -> bool:
557 """
558 Check if the model matches the provided data.
560 Args:
561 data: Dictionary containing the data to compare.
563 Returns:
564 True if the model matches the data, False otherwise.
566 Examples:
567 # Check if a Document instance matches API data
568 matches = doc.matches_dict(api_data)
570 """
571 return self.to_dict() == data
573 @override
574 def __str__(self) -> str:
575 """
576 Human-readable string representation.
578 Returns:
579 A string representation of the model.
581 """
582 return f"{self._meta.name.capitalize()}"
585class StandardModel(BaseModel, ABC):
586 """
587 Standard model for Paperless-ngx API objects with an ID field.
589 Attributes:
590 id: Unique identifier for the model.
592 """
594 id: int = Field(description="Unique identifier from Paperless NGX", default=0)
595 _resource: "StandardResource[Self]" # type: ignore # override
597 class Meta(BaseModel.Meta):
598 """
599 Metadata for the StandardModel.
601 Attributes:
602 read_only_fields: Fields that should not be modified.
603 supported_filtering_params: Params allowed during queryset filtering.
605 """
607 # Fields that should not be modified
608 read_only_fields: ClassVar[set[str]] = {"id"}
609 supported_filtering_params = {"id__in", "id"}
611 @property
612 def resource(self) -> "StandardResource[Self]": # type: ignore
613 return self._resource
615 @override
616 def update(self, **kwargs: Any) -> None:
617 """
618 Update this model with new values and save changes.
620 NOTE: new instances will not be saved automatically.
621 (I'm not sure if that's the right design decision or not)
623 Args:
624 **kwargs: New field values.
626 """
627 # Hold off on saving until all updates are complete
628 self.update_locally(**kwargs)
629 if not self.is_new():
630 self.save()
632 def refresh(self) -> bool:
633 """
634 Refresh the model with the latest data from the server.
636 Returns:
637 True if the model data changes, False on failure or if the data does not change.
639 Raises:
640 ResourceNotFoundError: If the model is not found on Paperless. (e.g. it was deleted remotely)
642 """
643 if self.is_new():
644 raise ResourceNotFoundError("Model does not have an id, so cannot be refreshed. Save first.")
646 new_model = self._resource.get(self.id)
648 if self == new_model:
649 return False
651 self.update_locally(from_db=True, **new_model.to_dict())
652 return True
654 def save(self, *, force: bool = False) -> bool:
655 return self.save_sync(force=force)
657 def save_sync(self, *, force: bool = False) -> bool:
658 """
659 Save this model instance synchronously.
661 Changes are sent to the server immediately, and the model is updated
662 when the server responds.
664 Returns:
665 True if the save was successful, False otherwise.
667 Raises:
668 ResourceNotFoundError: If the resource doesn't exist on the server
669 RequestError: If there's a communication error with the server
670 PermissionError: If the user doesn't have permission to update the resource
672 """
673 if self.is_new():
674 model = self.create(**self.to_dict())
675 self.update_locally(from_db=True, **model.to_dict())
676 return True
678 if not force:
679 if self._status == ModelStatus.SAVING:
680 logger.warning("Model is already saving, skipping save")
681 return False
683 # Only start a save if there are changes
684 if not self.is_dirty():
685 logger.warning("Model is not dirty, skipping save")
686 return False
688 with StatusContext(self, ModelStatus.SAVING):
689 # Prepare and send the update to the server
690 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True)
691 self._saved_data = {**current_data}
693 registry.emit(
694 "model.save:before",
695 "Fired before the model data is sent to paperless ngx to be saved.",
696 kwargs={"model": self, "current_data": current_data},
697 )
699 new_model = self._resource.update(self) # type: ignore # basedmypy complaining about self
701 if not new_model:
702 logger.warning(f"Result of save was none for model id {self.id}")
703 return False
705 if not isinstance(new_model, StandardModel):
706 # This should never happen
707 logger.error("Result of save was not a StandardModel instance")
708 return False
710 try:
711 # Update the model with the server response
712 new_data = new_model.to_dict()
713 self.update_locally(from_db=True, **new_data)
715 registry.emit(
716 "model.save:after",
717 "Fired after the model data is saved in paperless ngx.",
718 kwargs={"model": self, "updated_data": new_data},
719 )
721 except APIError as e:
722 logger.error(f"API error during save of {self}: {e}")
723 registry.emit(
724 "model.save:error",
725 "Fired when a network error occurs during save.",
726 kwargs={"model": self, "error": e},
727 )
729 except Exception as e:
730 # Log unexpected errors but don't swallow them
731 logger.exception(f"Unexpected error during save of {self}")
732 registry.emit(
733 "model.save:error",
734 "Fired when an unexpected error occurs during save.",
735 kwargs={"model": self, "error": e},
736 )
737 # Re-raise so the executor can handle it properly
738 raise
740 return True
742 def save_async(self, *, force: bool = False) -> bool:
743 """
744 Save this model instance asynchronously.
746 Changes are sent to the server in a background thread, and the model
747 is updated when the server responds.
749 Returns:
750 True if the save was successfully submitted async, False otherwise.
752 """
753 if not force:
754 if self._status == ModelStatus.SAVING:
755 return False
757 # Only start a save if there are changes
758 if not self.is_dirty():
759 if hasattr(self, "_save_lock") and self._save_lock._is_owned(): # type: ignore # temporary TODO
760 self._save_lock.release()
761 return False
763 # If there's a pending save, skip saving until it finishes
764 if self._pending_save is not None and not self._pending_save.done():
765 return False
767 self._status = ModelStatus.SAVING
768 self._save_lock.acquire(timeout=30)
770 # Start a new save operation
771 executor = self.save_executor
772 future = executor.submit(self._perform_save_async)
773 self._pending_save = future
774 future.add_done_callback(self._handle_save_result_async)
775 return True
777 def _perform_save_async(self) -> Self | None:
778 """
779 Perform the actual save operation.
781 Returns:
782 The updated model from the server or None if no save was needed.
784 Raises:
785 ResourceNotFoundError: If the resource doesn't exist on the server
786 RequestError: If there's a communication error with the server
787 PermissionError: If the user doesn't have permission to update the resource
789 """
790 # Prepare and send the update to the server
791 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True)
792 self._saved_data = {**current_data}
794 registry.emit(
795 "model.save:before",
796 "Fired before the model data is sent to paperless ngx to be saved.",
797 kwargs={"model": self, "current_data": current_data},
798 )
800 return self._resource.update(self)
802 def _handle_save_result_async(self, future: concurrent.futures.Future[Any]) -> bool:
803 """
804 Handle the result of an asynchronous save operation.
806 Args:
807 future: The completed Future object containing the save result.
809 """
810 try:
811 # Get the result with a timeout
812 new_model: Self = future.result(timeout=self._meta.save_timeout)
814 if not new_model:
815 logger.warning(f"Result of save was none for model id {self.id}")
816 return False
818 if not isinstance(new_model, StandardModel):
819 # This should never happen
820 logger.error("Result of save was not a StandardModel instance")
821 return False
823 # Update the model with the server response
824 new_data = new_model.to_dict()
825 # Use direct attribute setting instead of update_locally to avoid mocking issues
826 with StatusContext(self, ModelStatus.UPDATING):
827 for name, value in new_data.items():
828 if self.is_dirty("saved") and name in self.dirty_fields("saved"):
829 continue # Skip fields changed during save
830 setattr(self, name, value)
831 # Mark as from DB
832 self._original_data = self.model_dump()
834 registry.emit(
835 "model.save:after",
836 "Fired after the model data is saved in paperless ngx.",
837 kwargs={"model": self, "updated_data": new_data},
838 )
840 except concurrent.futures.TimeoutError:
841 logger.error(f"Save operation timed out for {self}")
842 registry.emit(
843 "model.save:error",
844 "Fired when a save operation times out.",
845 kwargs={"model": self, "error": "Timeout"},
846 )
848 except APIError as e:
849 logger.error(f"API error during save of {self}: {e}")
850 registry.emit(
851 "model.save:error",
852 "Fired when a network error occurs during save.",
853 kwargs={"model": self, "error": e},
854 )
856 except Exception as e:
857 # Log unexpected errors but don't swallow them
858 logger.exception(f"Unexpected error during save of {self}")
859 registry.emit(
860 "model.save:error",
861 "Fired when an unexpected error occurs during save.",
862 kwargs={"model": self, "error": e},
863 )
864 # Re-raise so the executor can handle it properly
865 raise
867 finally:
868 self._pending_save = None
869 try:
870 self._save_lock.release()
871 except RuntimeError:
872 logger.debug("Save lock already released")
873 self._status = ModelStatus.READY
875 # If the model was changed while the save was in progress,
876 # we need to save again
877 if self.is_dirty("saved"):
878 # Small delay to avoid hammering the server
879 time.sleep(0.1)
880 # Save, and reset unsaved data
881 self.save()
883 return True
885 @override
886 def is_new(self) -> bool:
887 """
888 Check if this model represents a new (unsaved) object.
890 Returns:
891 True if the model is new, False otherwise.
893 Examples:
894 # Check if a Document instance is new
895 is_new = doc.is_new()
897 """
898 return self.id == 0
900 def _autosave(self) -> None:
901 # Skip autosave for:
902 # - New models (not yet saved)
903 # - When auto-save is disabled
904 if self.is_new() or self.should_save_on_write() is False or not self.is_dirty():
905 return
907 self.save()
909 @override
910 def __setattr__(self, name: str, value: Any) -> None:
911 """
912 Override attribute setting to automatically trigger async save.
914 Args:
915 name: Attribute name
916 value: New attribute value
918 """
919 # Set the new value
920 super().__setattr__(name, value)
922 # Autosave logic below
923 if self._status != ModelStatus.READY:
924 return
926 # Skip autosave for private fields
927 if not name.startswith("_"):
928 self._autosave()
930 @override
931 def __str__(self) -> str:
932 """
933 Human-readable string representation.
935 Returns:
936 A string representation of the model.
938 """
939 return f"{self._meta.name.capitalize()} #{self.id}"