Coverage for src/paperap/models/abstract/model.py: 87%

306 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-18 12:26 -0400

1""" 

2---------------------------------------------------------------------------- 

3 

4 METADATA: 

5 

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 

13 

14---------------------------------------------------------------------------- 

15 

16 LAST MODIFIED: 

17 

18 2025-03-04 By Jess Mann 

19 

20""" 

21 

22from __future__ import annotations 

23 

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 

34 

35import pydantic 

36from pydantic import Field, PrivateAttr 

37from typing_extensions import TypeVar 

38from yarl import URL 

39 

40from paperap.const import FilteringStrategies, ModelStatus 

41from paperap.exceptions import APIError, ConfigurationError, RequestError, ResourceNotFoundError 

42from paperap.models.abstract.meta import StatusContext 

43from paperap.models.abstract.queryset import BaseQuerySet 

44from paperap.signals import registry 

45 

46if TYPE_CHECKING: 

47 from paperap.client import PaperlessClient 

48 from paperap.resources.base import BaseResource, StandardResource 

49 

50logger = logging.getLogger(__name__) 

51 

52_Self = TypeVar("_Self", bound="BaseModel") 

53 

54 

55class ModelConfigType(TypedDict): 

56 populate_by_name: bool 

57 validate_assignment: bool 

58 validate_default: bool 

59 use_enum_values: bool 

60 extra: Literal["ignore"] 

61 arbitrary_types_allowed: bool 

62 

63 

64BASE_MODEL_CONFIG: ModelConfigType = { 

65 "populate_by_name": True, 

66 "validate_assignment": True, 

67 "validate_default": True, 

68 "use_enum_values": True, 

69 "extra": "ignore", 

70 "arbitrary_types_allowed": True, 

71} 

72 

73 

74class BaseModel(pydantic.BaseModel, ABC): 

75 """ 

76 Base model for all Paperless-ngx API objects. 

77 

78 Provides automatic serialization, deserialization, and API interactions 

79 with minimal configuration needed. 

80 

81 Attributes: 

82 _meta: Metadata for the model, including filtering and resource information. 

83 _save_lock: Lock for saving operations. 

84 _pending_save: Future object for pending save operations. 

85 

86 Raises: 

87 ValueError: If resource is not provided. 

88 

89 """ 

90 

91 _meta: "ClassVar[Meta[Self]]" 

92 _save_lock: threading.RLock = PrivateAttr(default_factory=threading.RLock) 

93 _pending_save: concurrent.futures.Future | None = PrivateAttr(default=None) 

94 _save_executor: concurrent.futures.ThreadPoolExecutor | None = None 

95 # Updating attributes will not trigger save() 

96 _status: ModelStatus = ModelStatus.INITIALIZING # The last data we retrieved from the db 

97 # this is used to calculate if the model is dirty 

98 _original_data: dict[str, Any] = {} 

99 # The last data we sent to the db to save 

100 # This is used to determine if the model has been changed in the time it took to perform a save 

101 _saved_data: dict[str, Any] = {} 

102 

103 if TYPE_CHECKING: 

104 # This is actually set in Meta, but declaring it here helps pydantic handle dynamic __init__ arguments 

105 # TODO: Provide a better solution for pydantic, so there isn't confusion with intellisense 

106 resource: "BaseResource[Self]" 

107 

108 class Meta(Generic[_Self]): 

109 """ 

110 Metadata for the Model. 

111 

112 Attributes: 

113 name: The name of the model. 

114 read_only_fields: Fields that should not be modified. 

115 filtering_disabled: Fields disabled for filtering. 

116 filtering_fields: Fields allowed for filtering. 

117 supported_filtering_params: Params allowed during queryset filtering. 

118 blacklist_filtering_params: Params disallowed during queryset filtering. 

119 filtering_strategies: Strategies for filtering. 

120 resource: The BaseResource instance. 

121 queryset: The type of QuerySet for the model. 

122 

123 Raises: 

124 ValueError: If both ALLOW_ALL and ALLOW_NONE filtering strategies are set. 

125 

126 """ 

127 

128 # The name of the model. 

129 # It will default to the classname 

130 name: str 

131 # Fields that should not be modified. These will be appended to read_only_fields for all parent classes. 

132 read_only_fields: ClassVar[set[str]] = set() 

133 # Fields that are disabled by Paperless NGX for filtering. 

134 # These will be appended to filtering_disabled for all parent classes. 

135 filtering_disabled: ClassVar[set[str]] = set() 

136 # Fields allowed for filtering. Generated automatically during class init. 

137 filtering_fields: ClassVar[set[str]] = set() 

138 # If set, only these params will be allowed during queryset filtering. (e.g. {"content__icontains", "id__gt"}) 

139 # These will be appended to supported_filtering_params for all parent classes. 

140 supported_filtering_params: ClassVar[set[str]] = set() 

141 # If set, these params will be disallowed during queryset filtering (e.g. {"content__icontains", "id__gt"}) 

142 # These will be appended to blacklist_filtering_params for all parent classes. 

143 blacklist_filtering_params: ClassVar[set[str]] = set() 

144 # Strategies for filtering. 

145 # This determines which of the above lists will be used to allow or deny filters to QuerySets. 

146 filtering_strategies: ClassVar[set[FilteringStrategies]] = {FilteringStrategies.BLACKLIST} 

147 resource: "BaseResource" 

148 queryset: type[BaseQuerySet[_Self]] = BaseQuerySet 

149 # A map of field names to their attribute names. 

150 # Parser uses this to transform input and output data. 

151 # This will be populated from all parent classes. 

152 field_map: dict[str, str] = {} 

153 # If true, updating attributes will trigger save(). If false, save() must be called manually 

154 # True or False will override client.settings.save_on_write (PAPERLESS_SAVE_ON_WRITE) 

155 # None will respect client.settings.save_on_write 

156 save_on_write: bool | None = None 

157 save_timeout: int = PrivateAttr(default=60) # seconds 

158 

159 __type_hints_cache__: dict[str, type] = {} 

160 

161 def __init__(self, model: type[_Self]): 

162 self.model = model 

163 

164 # Validate filtering strategies 

165 if all( 

166 x in self.filtering_strategies for x in (FilteringStrategies.ALLOW_ALL, FilteringStrategies.ALLOW_NONE) 

167 ): 

168 raise ValueError(f"Cannot have ALLOW_ALL and ALLOW_NONE filtering strategies in {self.model.__name__}") 

169 

170 super().__init__() 

171 

172 def filter_allowed(self, filter_param: str) -> bool: 

173 """ 

174 Check if a filter is allowed based on the filtering strategies. 

175 

176 Args: 

177 filter_param: The filter parameter to check. 

178 

179 Returns: 

180 True if the filter is allowed, False otherwise. 

181 

182 """ 

183 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies: 

184 return True 

185 

186 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies: 

187 return False 

188 

189 # If we have a whitelist, check if the filter_param is in it 

190 if FilteringStrategies.WHITELIST in self.filtering_strategies: 

191 if self.supported_filtering_params and filter_param not in self.supported_filtering_params: 

192 return False 

193 # Allow other rules to fire 

194 

195 # If we have a blacklist, check if the filter_param is in it 

196 if FilteringStrategies.BLACKLIST in self.filtering_strategies: 

197 if self.blacklist_filtering_params and filter_param in self.blacklist_filtering_params: 

198 return False 

199 # Allow other rules to fire 

200 

201 # Check if the filtering key is disabled 

202 split_key = filter_param.split("__") 

203 if len(split_key) > 1: 

204 field, _lookup = split_key[-2:] 

205 else: 

206 field, _lookup = filter_param, None 

207 

208 # If key is in filtering_disabled, throw an error 

209 if field in self.filtering_disabled: 

210 return False 

211 

212 # Not disabled, so it's allowed 

213 return True 

214 

215 @classmethod 

216 def __init_subclass__(cls, **kwargs: Any) -> None: 

217 """ 

218 Initialize subclass and set up metadata. 

219 

220 Args: 

221 **kwargs: Additional keyword arguments. 

222 

223 """ 

224 super().__init_subclass__(**kwargs) 

225 # Ensure the subclass has its own Meta definition. 

226 # If not, create a new one inheriting from the parent’s Meta. 

227 # If the subclass hasn't defined its own Meta, auto-generate one. 

228 if "Meta" not in cls.__dict__: 

229 top_meta: type[BaseModel.Meta[Self]] | None = None 

230 # Iterate over ancestors to get the top-most explicitly defined Meta. 

231 for base in cls.__mro__[1:]: 

232 if "Meta" in base.__dict__: 

233 top_meta = cast(type[BaseModel.Meta[Self]], base.Meta) 

234 break 

235 if top_meta is None: 

236 # This should never happen. 

237 raise ConfigurationError(f"Meta class not found in {cls.__name__} or its bases") 

238 

239 # Create a new Meta class that inherits from the top-most Meta. 

240 meta_attrs = { 

241 k: v 

242 for k, v in vars(top_meta).items() 

243 if not k.startswith("_") # Avoid special attributes like __parameters__ 

244 } 

245 cls.Meta = type("Meta", (top_meta,), meta_attrs) # type: ignore # mypy complains about setting to a type 

246 logger.debug( 

247 "Auto-generated Meta for %s inheriting from %s", 

248 cls.__name__, 

249 top_meta.__name__, 

250 ) 

251 

252 # Append read_only_fields from all parents to Meta 

253 # Same with filtering_disabled 

254 # Retrieve filtering_fields from the attributes of the class 

255 read_only_fields = (cls.Meta.read_only_fields or set[str]()).copy() 

256 filtering_disabled = (cls.Meta.filtering_disabled or set[str]()).copy() 

257 filtering_fields = set(cls.__annotations__.keys()) 

258 supported_filtering_params = cls.Meta.supported_filtering_params 

259 blacklist_filtering_params = cls.Meta.blacklist_filtering_params 

260 field_map = cls.Meta.field_map 

261 for base in cls.__bases__: 

262 _meta: BaseModel.Meta[Self] | None 

263 if _meta := getattr(base, "Meta", None): 

264 if hasattr(_meta, "read_only_fields"): 

265 read_only_fields.update(_meta.read_only_fields) 

266 if hasattr(_meta, "filtering_disabled"): 

267 filtering_disabled.update(_meta.filtering_disabled) 

268 if hasattr(_meta, "filtering_fields"): 

269 filtering_fields.update(_meta.filtering_fields) 

270 if hasattr(_meta, "supported_filtering_params"): 

271 supported_filtering_params.update(_meta.supported_filtering_params) 

272 if hasattr(_meta, "blacklist_filtering_params"): 

273 blacklist_filtering_params.update(_meta.blacklist_filtering_params) 

274 if hasattr(_meta, "field_map"): 

275 field_map.update(_meta.field_map) 

276 

277 cls.Meta.read_only_fields = read_only_fields 

278 cls.Meta.filtering_disabled = filtering_disabled 

279 # excluding filtering_disabled from filtering_fields 

280 cls.Meta.filtering_fields = filtering_fields - filtering_disabled 

281 cls.Meta.supported_filtering_params = supported_filtering_params 

282 cls.Meta.blacklist_filtering_params = blacklist_filtering_params 

283 cls.Meta.field_map = field_map 

284 

285 # Instantiate _meta 

286 cls._meta = cls.Meta(cls) # type: ignore # due to a mypy bug in version 1.15.0 (issue #18776) 

287 

288 # Set name defaults 

289 if not hasattr(cls._meta, "name"): 

290 cls._meta.name = cls.__name__.lower() 

291 

292 # Configure Pydantic behavior 

293 # type ignore because mypy complains about non-required keys 

294 model_config = pydantic.ConfigDict(**BASE_MODEL_CONFIG) # type: ignore 

295 

296 def __init__(self, resource: "BaseResource[Self] | None" = None, **data: Any) -> None: 

297 """ 

298 Initialize the model with resource and data. 

299 

300 Args: 

301 resource: The BaseResource instance. 

302 **data: Additional data to initialize the model. 

303 

304 Raises: 

305 ValueError: If resource is not provided. 

306 

307 """ 

308 super().__init__(**data) 

309 

310 if resource: 

311 self._meta.resource = resource 

312 

313 if not getattr(self._meta, "resource", None): 

314 raise ValueError( 

315 f"Resource required. Initialize resource for {self.__class__.__name__} before instantiating models." 

316 ) 

317 

318 @property 

319 def _resource(self) -> "BaseResource[Self]": 

320 """ 

321 Get the resource associated with this model. 

322 

323 Returns: 

324 The BaseResource instance. 

325 

326 """ 

327 return self._meta.resource 

328 

329 @property 

330 def _client(self) -> "PaperlessClient": 

331 """ 

332 Get the client associated with this model. 

333 

334 Returns: 

335 The PaperlessClient instance. 

336 

337 """ 

338 return self._meta.resource.client 

339 

340 @property 

341 def save_executor(self) -> concurrent.futures.ThreadPoolExecutor: 

342 if not self._save_executor: 

343 self._save_executor = concurrent.futures.ThreadPoolExecutor( 

344 max_workers=5, thread_name_prefix="model_save_worker" 

345 ) 

346 return self._save_executor 

347 

348 def cleanup(self) -> None: 

349 """Clean up resources used by the model class.""" 

350 if self._save_executor: 

351 self._save_executor.shutdown(wait=True) 

352 self._save_executor = None 

353 

354 @override 

355 def model_post_init(self, __context) -> None: 

356 super().model_post_init(__context) 

357 

358 # Save original_data to support dirty fields 

359 self._original_data = self.model_dump() 

360 

361 # Allow updating attributes to trigger save() automatically 

362 self._status = ModelStatus.READY 

363 

364 super().model_post_init(__context) 

365 

366 @classmethod 

367 def from_dict(cls, data: dict[str, Any]) -> Self: 

368 """ 

369 Create a model instance from API response data. 

370 

371 Args: 

372 data: Dictionary containing the API response data. 

373 

374 Returns: 

375 A model instance initialized with the provided data. 

376 

377 Examples: 

378 # Create a Document instance from API data 

379 doc = Document.from_dict(api_data) 

380 

381 """ 

382 return cls._meta.resource.parse_to_model(data) 

383 

384 def to_dict( 

385 self, 

386 *, 

387 include_read_only: bool = True, 

388 exclude_none: bool = False, 

389 exclude_unset: bool = True, 

390 ) -> dict[str, Any]: 

391 """ 

392 Convert the model to a dictionary for API requests. 

393 

394 Args: 

395 include_read_only: Whether to include read-only fields. 

396 exclude_none: Whether to exclude fields with None values. 

397 exclude_unset: Whether to exclude fields that are not set. 

398 

399 Returns: 

400 A dictionary with model data ready for API submission. 

401 

402 Examples: 

403 # Convert a Document instance to a dictionary 

404 data = doc.to_dict() 

405 

406 """ 

407 exclude: set[str] = set() if include_read_only else set(self._meta.read_only_fields) 

408 

409 return self.model_dump( 

410 exclude=exclude, 

411 exclude_none=exclude_none, 

412 exclude_unset=exclude_unset, 

413 ) 

414 

415 def dirty_fields(self, comparison: Literal["saved", "db", "both"] = "both") -> dict[str, tuple[Any, Any]]: 

416 """ 

417 Show which fields have changed since last update from the paperless ngx db. 

418 

419 Args: 

420 comparison: 

421 Specify the data to compare ('saved' or 'db'). 

422 Db is the last data retrieved from Paperless NGX 

423 Saved is the last data sent to Paperless NGX to be saved 

424 

425 Returns: 

426 A dictionary {field: (original_value, new_value)} of fields that have 

427 changed since last update from the paperless ngx db. 

428 

429 """ 

430 current_data = self.model_dump() 

431 

432 if comparison == "saved": 

433 compare_dict = self._saved_data 

434 elif comparison == "db": 

435 compare_dict = self._original_data 

436 else: 

437 # For 'both', we want to compare against both original and saved data 

438 # A field is dirty if it differs from either original or saved data 

439 compare_dict = {} 

440 for field in set(list(self._original_data.keys()) + list(self._saved_data.keys())): 

441 # Prefer original data (from DB) over saved data when both exist 

442 compare_dict[field] = self._original_data.get(field, self._saved_data.get(field)) 

443 

444 return { 

445 field: (compare_dict[field], current_data[field]) 

446 for field in current_data 

447 if field in compare_dict and compare_dict[field] != current_data[field] 

448 } 

449 

450 def is_dirty(self, comparison: Literal["saved", "db", "both"] = "both") -> bool: 

451 """ 

452 Check if any field has changed since last update from the paperless ngx db. 

453 

454 Args: 

455 comparison: 

456 Specify the data to compare ('saved' or 'db'). 

457 Db is the last data retrieved from Paperless NGX 

458 Saved is the last data sent to Paperless NGX to be saved 

459 

460 Returns: 

461 True if any field has changed. 

462 

463 """ 

464 return bool(self.dirty_fields(comparison=comparison)) 

465 

466 @classmethod 

467 def create(cls, **kwargs: Any) -> Self: 

468 """ 

469 Create a new model instance. 

470 

471 Args: 

472 **kwargs: Field values to set. 

473 

474 Returns: 

475 A new model instance. 

476 

477 Examples: 

478 # Create a new Document instance 

479 doc = Document.create(filename="example.pdf", contents=b"PDF data") 

480 

481 """ 

482 # TODO save 

483 return cls(**kwargs) 

484 

485 def update_locally(self, *, from_db: bool | None = None, skip_changed_fields: bool = False, **kwargs: Any) -> None: 

486 """ 

487 Update model attributes without triggering automatic save. 

488 

489 Args: 

490 **kwargs: Field values to update 

491 

492 Returns: 

493 Self with updated values 

494 

495 """ 

496 from_db = from_db if from_db is not None else False 

497 

498 # Avoid infinite saving loops 

499 with StatusContext(self, ModelStatus.UPDATING): 

500 # If the field contains unsaved changes, skip updating it 

501 # Determine unsaved changes based on the dirty fields before we last called save 

502 if skip_changed_fields: 

503 unsaved_changes = self.dirty_fields(comparison="saved") 

504 kwargs = {k: v for k, v in kwargs.items() if k not in unsaved_changes} 

505 

506 for name, value in kwargs.items(): 

507 setattr(self, name, value) 

508 

509 # Dirty has been reset 

510 if from_db: 

511 self._original_data = self.model_dump() 

512 

513 def update(self, **kwargs: Any) -> None: 

514 """ 

515 Update this model with new values. 

516 

517 Subclasses implement this with auto-saving features. 

518 However, base BaseModel instances simply call update_locally. 

519 

520 Args: 

521 **kwargs: New field values. 

522 

523 Examples: 

524 # Update a Document instance 

525 doc.update(filename="new_example.pdf") 

526 

527 """ 

528 # Since we have no id, we can't save. Therefore, all updates are silent updates 

529 # subclasses may implement this. 

530 self.update_locally(**kwargs) 

531 

532 @abstractmethod 

533 def is_new(self) -> bool: 

534 """ 

535 Check if this model represents a new (unsaved) object. 

536 

537 Returns: 

538 True if the model is new, False otherwise. 

539 

540 Examples: 

541 # Check if a Document instance is new 

542 is_new = doc.is_new() 

543 

544 """ 

545 

546 def should_save_on_write(self) -> bool: 

547 """ 

548 Check if the model should save on attribute write, factoring in the client settings. 

549 """ 

550 if self._meta.save_on_write is not None: 

551 return self._meta.save_on_write 

552 return self._resource.client.settings.save_on_write 

553 

554 def enable_save_on_write(self) -> None: 

555 """ 

556 Enable automatic saving on attribute write. 

557 """ 

558 self._meta.save_on_write = True 

559 

560 def disable_save_on_write(self) -> None: 

561 """ 

562 Disable automatic saving on attribute write. 

563 """ 

564 self._meta.save_on_write = False 

565 

566 def matches_dict(self, data: dict[str, Any]) -> bool: 

567 """ 

568 Check if the model matches the provided data. 

569 

570 Args: 

571 data: Dictionary containing the data to compare. 

572 

573 Returns: 

574 True if the model matches the data, False otherwise. 

575 

576 Examples: 

577 # Check if a Document instance matches API data 

578 matches = doc.matches_dict(api_data) 

579 

580 """ 

581 return self.to_dict() == data 

582 

583 @override 

584 def __str__(self) -> str: 

585 """ 

586 Human-readable string representation. 

587 

588 Returns: 

589 A string representation of the model. 

590 

591 """ 

592 return f"{self._meta.name.capitalize()}" 

593 

594 

595class StandardModel(BaseModel, ABC): 

596 """ 

597 Standard model for Paperless-ngx API objects with an ID field. 

598 

599 Attributes: 

600 id: Unique identifier for the model. 

601 

602 """ 

603 

604 id: int = Field(description="Unique identifier from Paperless NGX", default=0) 

605 

606 class Meta(BaseModel.Meta): 

607 """ 

608 Metadata for the StandardModel. 

609 

610 Attributes: 

611 read_only_fields: Fields that should not be modified. 

612 supported_filtering_params: Params allowed during queryset filtering. 

613 

614 """ 

615 

616 # Fields that should not be modified 

617 read_only_fields: ClassVar[set[str]] = {"id"} 

618 supported_filtering_params = {"id__in", "id"} 

619 

620 @override 

621 def update(self, **kwargs: Any) -> None: 

622 """ 

623 Update this model with new values and save changes. 

624 

625 NOTE: new instances will not be saved automatically. 

626 (I'm not sure if that's the right design decision or not) 

627 

628 Args: 

629 **kwargs: New field values. 

630 

631 """ 

632 # Hold off on saving until all updates are complete 

633 self.update_locally(**kwargs) 

634 if not self.is_new(): 

635 self.save() 

636 

637 def save(self): 

638 return self.save_sync() 

639 

640 def save_sync(self) -> None: 

641 """ 

642 Save this model instance synchronously. 

643 

644 Changes are sent to the server immediately, and the model is updated 

645 when the server responds. 

646 

647 Raises: 

648 ResourceNotFoundError: If the resource doesn't exist on the server 

649 RequestError: If there's a communication error with the server 

650 PermissionError: If the user doesn't have permission to update the resource 

651 

652 """ 

653 if self._status == ModelStatus.SAVING: 

654 return 

655 

656 # Only start a save if there are changes 

657 if not self.is_dirty(): 

658 return 

659 

660 with StatusContext(self, ModelStatus.SAVING): 

661 # Prepare and send the update to the server 

662 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True) 

663 self._saved_data = {**current_data} 

664 

665 registry.emit( 

666 "model.save:before", 

667 "Fired before the model data is sent to paperless ngx to be saved.", 

668 kwargs={"model": self, "current_data": current_data}, 

669 ) 

670 

671 new_model = self._meta.resource.update(self) 

672 

673 if not new_model: 

674 logger.warning(f"Result of save was none for model id {self.id}") 

675 return 

676 

677 if not isinstance(new_model, StandardModel): 

678 # This should never happen 

679 logger.error("Result of save was not a StandardModel instance") 

680 return 

681 

682 try: 

683 # Update the model with the server response 

684 new_data = new_model.to_dict() 

685 self.update_locally(from_db=True, **new_data) 

686 

687 registry.emit( 

688 "model.save:after", 

689 "Fired after the model data is saved in paperless ngx.", 

690 kwargs={"model": self, "updated_data": new_data}, 

691 ) 

692 

693 except APIError as e: 

694 logger.error(f"API error during save of {self}: {e}") 

695 registry.emit( 

696 "model.save:error", 

697 "Fired when a network error occurs during save.", 

698 kwargs={"model": self, "error": e}, 

699 ) 

700 

701 except Exception as e: 

702 # Log unexpected errors but don't swallow them 

703 logger.exception(f"Unexpected error during save of {self}") 

704 registry.emit( 

705 "model.save:error", 

706 "Fired when an unexpected error occurs during save.", 

707 kwargs={"model": self, "error": e}, 

708 ) 

709 # Re-raise so the executor can handle it properly 

710 raise 

711 

712 def save_async(self) -> None: 

713 """ 

714 Save this model instance asynchronously. 

715 

716 Changes are sent to the server in a background thread, and the model 

717 is updated when the server responds. 

718 """ 

719 if self._status == ModelStatus.SAVING: 

720 return 

721 

722 # Only start a save if there are changes 

723 if not self.is_dirty(): 

724 if hasattr(self, "_save_lock") and self._save_lock._is_owned(): # type: ignore # temporary TODO 

725 self._save_lock.release() 

726 return 

727 

728 self._status = ModelStatus.SAVING 

729 self._save_lock.acquire(timeout=30) 

730 

731 # If there's a pending save, skip saving until it finishes 

732 if self._pending_save is not None and not self._pending_save.done(): 

733 return 

734 

735 # Start a new save operation 

736 executor = self.save_executor 

737 future = executor.submit(self._perform_save_async) 

738 self._pending_save = future 

739 future.add_done_callback(self._handle_save_result_async) 

740 

741 def _perform_save_async(self) -> Optional["StandardModel"]: 

742 """ 

743 Perform the actual save operation. 

744 

745 Returns: 

746 The updated model from the server or None if no save was needed. 

747 

748 Raises: 

749 ResourceNotFoundError: If the resource doesn't exist on the server 

750 RequestError: If there's a communication error with the server 

751 PermissionError: If the user doesn't have permission to update the resource 

752 

753 """ 

754 # Prepare and send the update to the server 

755 current_data = self.to_dict(include_read_only=False, exclude_none=False, exclude_unset=True) 

756 self._saved_data = {**current_data} 

757 

758 registry.emit( 

759 "model.save:before", 

760 "Fired before the model data is sent to paperless ngx to be saved.", 

761 kwargs={"model": self, "current_data": current_data}, 

762 ) 

763 

764 return self._meta.resource.update(self) 

765 

766 def _handle_save_result_async(self, future: concurrent.futures.Future) -> None: 

767 """ 

768 Handle the result of an asynchronous save operation. 

769 

770 Args: 

771 future: The completed Future object containing the save result. 

772 

773 """ 

774 try: 

775 # Get the result with a timeout 

776 new_model = future.result(timeout=self._meta.save_timeout) 

777 

778 if not new_model: 

779 logger.warning(f"Result of save was none for model id {self.id}") 

780 return 

781 

782 if not isinstance(new_model, StandardModel): 

783 # This should never happen 

784 logger.error("Result of save was not a StandardModel instance") 

785 return 

786 

787 # Update the model with the server response 

788 new_data = new_model.to_dict() 

789 # Use direct attribute setting instead of update_locally to avoid mocking issues 

790 with StatusContext(self, ModelStatus.UPDATING): 

791 for name, value in new_data.items(): 

792 if self.is_dirty("saved") and name in self.dirty_fields("saved"): 

793 continue # Skip fields changed during save 

794 setattr(self, name, value) 

795 # Mark as from DB 

796 self._original_data = self.model_dump() 

797 

798 registry.emit( 

799 "model.save:after", 

800 "Fired after the model data is saved in paperless ngx.", 

801 kwargs={"model": self, "updated_data": new_data}, 

802 ) 

803 

804 except concurrent.futures.TimeoutError: 

805 logger.error(f"Save operation timed out for {self}") 

806 registry.emit( 

807 "model.save:error", 

808 "Fired when a save operation times out.", 

809 kwargs={"model": self, "error": "Timeout"}, 

810 ) 

811 

812 except APIError as e: 

813 logger.error(f"API error during save of {self}: {e}") 

814 registry.emit( 

815 "model.save:error", 

816 "Fired when a network error occurs during save.", 

817 kwargs={"model": self, "error": e}, 

818 ) 

819 

820 except Exception as e: 

821 # Log unexpected errors but don't swallow them 

822 logger.exception(f"Unexpected error during save of {self}") 

823 registry.emit( 

824 "model.save:error", 

825 "Fired when an unexpected error occurs during save.", 

826 kwargs={"model": self, "error": e}, 

827 ) 

828 # Re-raise so the executor can handle it properly 

829 raise 

830 

831 finally: 

832 self._pending_save = None 

833 try: 

834 self._save_lock.release() 

835 except RuntimeError: 

836 logger.debug("Save lock already released") 

837 self._status = ModelStatus.READY 

838 

839 # If the model was changed while the save was in progress, 

840 # we need to save again 

841 if self.is_dirty("saved"): 

842 # Small delay to avoid hammering the server 

843 time.sleep(0.1) 

844 # Save, and reset unsaved data 

845 self.save() 

846 

847 @override 

848 def is_new(self) -> bool: 

849 """ 

850 Check if this model represents a new (unsaved) object. 

851 

852 Returns: 

853 True if the model is new, False otherwise. 

854 

855 Examples: 

856 # Check if a Document instance is new 

857 is_new = doc.is_new() 

858 

859 """ 

860 return self.id == 0 

861 

862 def _autosave(self) -> None: 

863 # Skip autosave for: 

864 # - New models (not yet saved) 

865 # - When auto-save is disabled 

866 if self.is_new() or self.should_save_on_write() is False or not self.is_dirty(): 

867 return 

868 

869 self.save() 

870 

871 @override 

872 def __setattr__(self, name: str, value: Any) -> None: 

873 """ 

874 Override attribute setting to automatically trigger async save. 

875 

876 Args: 

877 name: Attribute name 

878 value: New attribute value 

879 

880 """ 

881 # Set the new value 

882 super().__setattr__(name, value) 

883 

884 # Autosave logic below 

885 if self._status != ModelStatus.READY: 

886 return 

887 

888 # Skip autosave for private fields 

889 if not name.startswith("_"): 

890 self._autosave() 

891 

892 @override 

893 def __str__(self) -> str: 

894 """ 

895 Human-readable string representation. 

896 

897 Returns: 

898 A string representation of the model. 

899 

900 """ 

901 return f"{self._meta.name.capitalize()} #{self.id}"