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

1""" 

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

3 

4 METADATA: 

5 

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 

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, Self, TypedDict, cast, override 

34 

35import pydantic 

36from pydantic import Field, PrivateAttr 

37from typing_extensions import TypeVar 

38 

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 

43 

44if TYPE_CHECKING: 

45 from paperap.client import PaperlessClient 

46 from paperap.resources.base import BaseResource, StandardResource 

47 

48logger = logging.getLogger(__name__) 

49 

50 

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 

58 

59 

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} 

68 

69 

70class BaseModel(pydantic.BaseModel, ABC): 

71 """ 

72 Base model for all Paperless-ngx API objects. 

73 

74 Provides automatic serialization, deserialization, and API interactions 

75 with minimal configuration needed. 

76 

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. 

81 

82 Raises: 

83 ValueError: If resource is not provided. 

84 

85 """ 

86 

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]" 

99 

100 class Meta[_Self: "BaseModel"]: 

101 """ 

102 Metadata for the Model. 

103 

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. 

114 

115 Raises: 

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

117 

118 """ 

119 

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 

149 

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

151 

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

153 self.model = model 

154 

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__}") 

158 

159 super().__init__() 

160 

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

162 """ 

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

164 

165 Args: 

166 filter_param: The filter parameter to check. 

167 

168 Returns: 

169 True if the filter is allowed, False otherwise. 

170 

171 """ 

172 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies: 

173 return True 

174 

175 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies: 

176 return False 

177 

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 

183 

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 

189 

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 

196 

197 # If key is in filtering_disabled, throw an error 

198 if field in self.filtering_disabled: 

199 return False 

200 

201 # Not disabled, so it's allowed 

202 return True 

203 

204 @override 

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

206 """ 

207 Initialize subclass and set up metadata. 

208 

209 Args: 

210 **kwargs: Additional keyword arguments. 

211 

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") 

227 

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 ) 

240 

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) 

265 

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 

273 

274 # Instantiate _meta 

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

276 

277 # Set name defaults 

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

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

280 

281 # Configure Pydantic behavior 

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

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

284 

285 def __init__(self, **data: Any) -> None: 

286 """ 

287 Initialize the model with resource and data. 

288 

289 Args: 

290 resource: The BaseResource instance. 

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

292 

293 Raises: 

294 ValueError: If resource is not provided. 

295 

296 """ 

297 super().__init__(**data) 

298 

299 if not hasattr(self, "_resource"): 

300 raise ValueError(f"Resource required. Initialize resource for {self.__class__.__name__} before instantiating models.") 

301 

302 @property 

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

304 """ 

305 Get the client associated with this model. 

306 

307 Returns: 

308 The PaperlessClient instance. 

309 

310 """ 

311 return self._resource.client 

312 

313 @property 

314 def resource(self) -> "BaseResource[Self]": 

315 return self._resource 

316 

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 

322 

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 

328 

329 @override 

330 def model_post_init(self, __context: Any) -> None: 

331 super().model_post_init(__context) 

332 

333 # Save original_data to support dirty fields 

334 self._original_data = self.model_dump() 

335 

336 # Allow updating attributes to trigger save() automatically 

337 self._status = ModelStatus.READY 

338 

339 super().model_post_init(__context) 

340 

341 @classmethod 

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

343 """ 

344 Create a model instance from API response data. 

345 

346 Args: 

347 data: Dictionary containing the API response data. 

348 

349 Returns: 

350 A model instance initialized with the provided data. 

351 

352 Examples: 

353 # Create a Document instance from API data 

354 doc = Document.from_dict(api_data) 

355 

356 """ 

357 return cls._resource.parse_to_model(data) 

358 

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. 

368 

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. 

373 

374 Returns: 

375 A dictionary with model data ready for API submission. 

376 

377 Examples: 

378 # Convert a Document instance to a dictionary 

379 data = doc.to_dict() 

380 

381 """ 

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

383 

384 return self.model_dump( 

385 exclude=exclude, 

386 exclude_none=exclude_none, 

387 exclude_unset=exclude_unset, 

388 ) 

389 

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. 

393 

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 

399 

400 Returns: 

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

402 changed since last update from the paperless ngx db. 

403 

404 """ 

405 current_data = self.model_dump() 

406 current_data.pop("id", None) 

407 

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 

420 

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)) 

423 

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 } 

429 

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. 

433 

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 

439 

440 Returns: 

441 True if any field has changed. 

442 

443 """ 

444 if self.is_new(): 

445 return True 

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

447 

448 @classmethod 

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

450 """ 

451 Create a new model instance. 

452 

453 Args: 

454 **kwargs: Field values to set. 

455 

456 Returns: 

457 A new model instance. 

458 

459 Examples: 

460 # Create a new Document instance 

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

462 

463 """ 

464 return cls._resource.create(**kwargs) 

465 

466 def delete(self) -> None: 

467 return self._resource.delete(self) 

468 

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. 

472 

473 Args: 

474 **kwargs: Field values to update 

475 

476 Returns: 

477 Self with updated values 

478 

479 """ 

480 from_db = from_db if from_db is not None else False 

481 

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}") 

489 

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} 

495 

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

497 setattr(self, name, value) 

498 

499 # Dirty has been reset 

500 if from_db: 

501 self._original_data = self.model_dump() 

502 

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

504 """ 

505 Update this model with new values. 

506 

507 Subclasses implement this with auto-saving features. 

508 However, base BaseModel instances simply call update_locally. 

509 

510 Args: 

511 **kwargs: New field values. 

512 

513 Examples: 

514 # Update a Document instance 

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

516 

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) 

521 

522 @abstractmethod 

523 def is_new(self) -> bool: 

524 """ 

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

526 

527 Returns: 

528 True if the model is new, False otherwise. 

529 

530 Examples: 

531 # Check if a Document instance is new 

532 is_new = doc.is_new() 

533 

534 """ 

535 

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 

543 

544 def enable_save_on_write(self) -> None: 

545 """ 

546 Enable automatic saving on attribute write. 

547 """ 

548 self._meta.save_on_write = True 

549 

550 def disable_save_on_write(self) -> None: 

551 """ 

552 Disable automatic saving on attribute write. 

553 """ 

554 self._meta.save_on_write = False 

555 

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

557 """ 

558 Check if the model matches the provided data. 

559 

560 Args: 

561 data: Dictionary containing the data to compare. 

562 

563 Returns: 

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

565 

566 Examples: 

567 # Check if a Document instance matches API data 

568 matches = doc.matches_dict(api_data) 

569 

570 """ 

571 return self.to_dict() == data 

572 

573 @override 

574 def __str__(self) -> str: 

575 """ 

576 Human-readable string representation. 

577 

578 Returns: 

579 A string representation of the model. 

580 

581 """ 

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

583 

584 

585class StandardModel(BaseModel, ABC): 

586 """ 

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

588 

589 Attributes: 

590 id: Unique identifier for the model. 

591 

592 """ 

593 

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

595 _resource: "StandardResource[Self]" # type: ignore # override 

596 

597 class Meta(BaseModel.Meta): 

598 """ 

599 Metadata for the StandardModel. 

600 

601 Attributes: 

602 read_only_fields: Fields that should not be modified. 

603 supported_filtering_params: Params allowed during queryset filtering. 

604 

605 """ 

606 

607 # Fields that should not be modified 

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

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

610 

611 @property 

612 def resource(self) -> "StandardResource[Self]": # type: ignore 

613 return self._resource 

614 

615 @override 

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

617 """ 

618 Update this model with new values and save changes. 

619 

620 NOTE: new instances will not be saved automatically. 

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

622 

623 Args: 

624 **kwargs: New field values. 

625 

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() 

631 

632 def refresh(self) -> bool: 

633 """ 

634 Refresh the model with the latest data from the server. 

635 

636 Returns: 

637 True if the model data changes, False on failure or if the data does not change. 

638 

639 Raises: 

640 ResourceNotFoundError: If the model is not found on Paperless. (e.g. it was deleted remotely) 

641 

642 """ 

643 if self.is_new(): 

644 raise ResourceNotFoundError("Model does not have an id, so cannot be refreshed. Save first.") 

645 

646 new_model = self._resource.get(self.id) 

647 

648 if self == new_model: 

649 return False 

650 

651 self.update_locally(from_db=True, **new_model.to_dict()) 

652 return True 

653 

654 def save(self, *, force: bool = False) -> bool: 

655 return self.save_sync(force=force) 

656 

657 def save_sync(self, *, force: bool = False) -> bool: 

658 """ 

659 Save this model instance synchronously. 

660 

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

662 when the server responds. 

663 

664 Returns: 

665 True if the save was successful, False otherwise. 

666 

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 

671 

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 

677 

678 if not force: 

679 if self._status == ModelStatus.SAVING: 

680 logger.warning("Model is already saving, skipping save") 

681 return False 

682 

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 

687 

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} 

692 

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 ) 

698 

699 new_model = self._resource.update(self) # type: ignore # basedmypy complaining about self 

700 

701 if not new_model: 

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

703 return False 

704 

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 

709 

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) 

714 

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 ) 

720 

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 ) 

728 

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 

739 

740 return True 

741 

742 def save_async(self, *, force: bool = False) -> bool: 

743 """ 

744 Save this model instance asynchronously. 

745 

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

747 is updated when the server responds. 

748 

749 Returns: 

750 True if the save was successfully submitted async, False otherwise. 

751 

752 """ 

753 if not force: 

754 if self._status == ModelStatus.SAVING: 

755 return False 

756 

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 

762 

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 

766 

767 self._status = ModelStatus.SAVING 

768 self._save_lock.acquire(timeout=30) 

769 

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 

776 

777 def _perform_save_async(self) -> Self | None: 

778 """ 

779 Perform the actual save operation. 

780 

781 Returns: 

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

783 

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 

788 

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} 

793 

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 ) 

799 

800 return self._resource.update(self) 

801 

802 def _handle_save_result_async(self, future: concurrent.futures.Future[Any]) -> bool: 

803 """ 

804 Handle the result of an asynchronous save operation. 

805 

806 Args: 

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

808 

809 """ 

810 try: 

811 # Get the result with a timeout 

812 new_model: Self = future.result(timeout=self._meta.save_timeout) 

813 

814 if not new_model: 

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

816 return False 

817 

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 

822 

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() 

833 

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 ) 

839 

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 ) 

847 

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 ) 

855 

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 

866 

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 

874 

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() 

882 

883 return True 

884 

885 @override 

886 def is_new(self) -> bool: 

887 """ 

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

889 

890 Returns: 

891 True if the model is new, False otherwise. 

892 

893 Examples: 

894 # Check if a Document instance is new 

895 is_new = doc.is_new() 

896 

897 """ 

898 return self.id == 0 

899 

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 

906 

907 self.save() 

908 

909 @override 

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

911 """ 

912 Override attribute setting to automatically trigger async save. 

913 

914 Args: 

915 name: Attribute name 

916 value: New attribute value 

917 

918 """ 

919 # Set the new value 

920 super().__setattr__(name, value) 

921 

922 # Autosave logic below 

923 if self._status != ModelStatus.READY: 

924 return 

925 

926 # Skip autosave for private fields 

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

928 self._autosave() 

929 

930 @override 

931 def __str__(self) -> str: 

932 """ 

933 Human-readable string representation. 

934 

935 Returns: 

936 A string representation of the model. 

937 

938 """ 

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