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

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 

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_Self = TypeVar("_Self", bound="BaseModel") 

51 

52 

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 

60 

61 

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} 

70 

71 

72class BaseModel(pydantic.BaseModel, ABC): 

73 """ 

74 Base model for all Paperless-ngx API objects. 

75 

76 Provides automatic serialization, deserialization, and API interactions 

77 with minimal configuration needed. 

78 

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. 

83 

84 Raises: 

85 ValueError: If resource is not provided. 

86 

87 """ 

88 

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

101 

102 class Meta[_Self]: 

103 """ 

104 Metadata for the Model. 

105 

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. 

116 

117 Raises: 

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

119 

120 """ 

121 

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 

151 

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

153 

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

155 self.model = model 

156 

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

162 

163 super().__init__() 

164 

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

166 """ 

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

168 

169 Args: 

170 filter_param: The filter parameter to check. 

171 

172 Returns: 

173 True if the filter is allowed, False otherwise. 

174 

175 """ 

176 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies: 

177 return True 

178 

179 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies: 

180 return False 

181 

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 

187 

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 

193 

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 

200 

201 # If key is in filtering_disabled, throw an error 

202 if field in self.filtering_disabled: 

203 return False 

204 

205 # Not disabled, so it's allowed 

206 return True 

207 

208 @override 

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

210 """ 

211 Initialize subclass and set up metadata. 

212 

213 Args: 

214 **kwargs: Additional keyword arguments. 

215 

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

231 

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 ) 

244 

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) 

269 

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 

277 

278 # Instantiate _meta 

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

280 

281 # Set name defaults 

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

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

284 

285 # Configure Pydantic behavior 

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

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

288 

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

290 """ 

291 Initialize the model with resource and data. 

292 

293 Args: 

294 resource: The BaseResource instance. 

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

296 

297 Raises: 

298 ValueError: If resource is not provided. 

299 

300 """ 

301 super().__init__(**data) 

302 

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

304 raise ValueError( 

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

306 ) 

307 

308 @property 

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

310 """ 

311 Get the client associated with this model. 

312 

313 Returns: 

314 The PaperlessClient instance. 

315 

316 """ 

317 return self._resource.client 

318 

319 @property 

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

321 return self._resource 

322 

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 

330 

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 

336 

337 @override 

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

339 super().model_post_init(__context) 

340 

341 # Save original_data to support dirty fields 

342 self._original_data = self.model_dump() 

343 

344 # Allow updating attributes to trigger save() automatically 

345 self._status = ModelStatus.READY 

346 

347 super().model_post_init(__context) 

348 

349 @classmethod 

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

351 """ 

352 Create a model instance from API response data. 

353 

354 Args: 

355 data: Dictionary containing the API response data. 

356 

357 Returns: 

358 A model instance initialized with the provided data. 

359 

360 Examples: 

361 # Create a Document instance from API data 

362 doc = Document.from_dict(api_data) 

363 

364 """ 

365 return cls._resource.parse_to_model(data) 

366 

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. 

376 

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. 

381 

382 Returns: 

383 A dictionary with model data ready for API submission. 

384 

385 Examples: 

386 # Convert a Document instance to a dictionary 

387 data = doc.to_dict() 

388 

389 """ 

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

391 

392 return self.model_dump( 

393 exclude=exclude, 

394 exclude_none=exclude_none, 

395 exclude_unset=exclude_unset, 

396 ) 

397 

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. 

401 

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 

407 

408 Returns: 

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

410 changed since last update from the paperless ngx db. 

411 

412 """ 

413 current_data = self.model_dump() 

414 

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

426 

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 } 

432 

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. 

436 

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 

442 

443 Returns: 

444 True if any field has changed. 

445 

446 """ 

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

448 

449 @classmethod 

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

451 """ 

452 Create a new model instance. 

453 

454 Args: 

455 **kwargs: Field values to set. 

456 

457 Returns: 

458 A new model instance. 

459 

460 Examples: 

461 # Create a new Document instance 

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

463 

464 """ 

465 # TODO save 

466 return cls(**kwargs) 

467 

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. 

471 

472 Args: 

473 **kwargs: Field values to update 

474 

475 Returns: 

476 Self with updated values 

477 

478 """ 

479 from_db = from_db if from_db is not None else False 

480 

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

488 

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} 

494 

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

496 setattr(self, name, value) 

497 

498 # Dirty has been reset 

499 if from_db: 

500 self._original_data = self.model_dump() 

501 

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

503 """ 

504 Update this model with new values. 

505 

506 Subclasses implement this with auto-saving features. 

507 However, base BaseModel instances simply call update_locally. 

508 

509 Args: 

510 **kwargs: New field values. 

511 

512 Examples: 

513 # Update a Document instance 

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

515 

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) 

520 

521 @abstractmethod 

522 def is_new(self) -> bool: 

523 """ 

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

525 

526 Returns: 

527 True if the model is new, False otherwise. 

528 

529 Examples: 

530 # Check if a Document instance is new 

531 is_new = doc.is_new() 

532 

533 """ 

534 

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 

542 

543 def enable_save_on_write(self) -> None: 

544 """ 

545 Enable automatic saving on attribute write. 

546 """ 

547 self._meta.save_on_write = True 

548 

549 def disable_save_on_write(self) -> None: 

550 """ 

551 Disable automatic saving on attribute write. 

552 """ 

553 self._meta.save_on_write = False 

554 

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

556 """ 

557 Check if the model matches the provided data. 

558 

559 Args: 

560 data: Dictionary containing the data to compare. 

561 

562 Returns: 

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

564 

565 Examples: 

566 # Check if a Document instance matches API data 

567 matches = doc.matches_dict(api_data) 

568 

569 """ 

570 return self.to_dict() == data 

571 

572 @override 

573 def __str__(self) -> str: 

574 """ 

575 Human-readable string representation. 

576 

577 Returns: 

578 A string representation of the model. 

579 

580 """ 

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

582 

583 

584class StandardModel(BaseModel, ABC): 

585 """ 

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

587 

588 Attributes: 

589 id: Unique identifier for the model. 

590 

591 """ 

592 

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

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

595 

596 class Meta(BaseModel.Meta): 

597 """ 

598 Metadata for the StandardModel. 

599 

600 Attributes: 

601 read_only_fields: Fields that should not be modified. 

602 supported_filtering_params: Params allowed during queryset filtering. 

603 

604 """ 

605 

606 # Fields that should not be modified 

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

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

609 

610 @override 

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

612 """ 

613 Update this model with new values and save changes. 

614 

615 NOTE: new instances will not be saved automatically. 

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

617 

618 Args: 

619 **kwargs: New field values. 

620 

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

626 

627 def refresh(self) -> bool: 

628 """ 

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

630 

631 Returns: 

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

633 

634 Raises: 

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

636 

637 """ 

638 if self.is_new(): 

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

640 

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

642 

643 if self == new_model: 

644 return False 

645 

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

647 return True 

648 

649 def save(self, *, force: bool = False): 

650 return self.save_sync(force=force) 

651 

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

653 """ 

654 Save this model instance synchronously. 

655 

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

657 when the server responds. 

658 

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 

663 

664 """ 

665 if not force: 

666 if self._status == ModelStatus.SAVING: 

667 return 

668 

669 # Only start a save if there are changes 

670 if not self.is_dirty(): 

671 return 

672 

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} 

677 

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 ) 

683 

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

685 

686 if not new_model: 

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

688 return 

689 

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 

694 

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) 

699 

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 ) 

705 

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 ) 

713 

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 

724 

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

726 """ 

727 Save this model instance asynchronously. 

728 

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 

735 

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 

741 

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 

745 

746 self._status = ModelStatus.SAVING 

747 self._save_lock.acquire(timeout=30) 

748 

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) 

754 

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

756 """ 

757 Perform the actual save operation. 

758 

759 Returns: 

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

761 

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 

766 

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} 

771 

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 ) 

777 

778 return self._resource.update(self) 

779 

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

781 """ 

782 Handle the result of an asynchronous save operation. 

783 

784 Args: 

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

786 

787 """ 

788 try: 

789 # Get the result with a timeout 

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

791 

792 if not new_model: 

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

794 return 

795 

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 

800 

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

811 

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 ) 

817 

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 ) 

825 

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 ) 

833 

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 

844 

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 

852 

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

860 

861 @override 

862 def is_new(self) -> bool: 

863 """ 

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

865 

866 Returns: 

867 True if the model is new, False otherwise. 

868 

869 Examples: 

870 # Check if a Document instance is new 

871 is_new = doc.is_new() 

872 

873 """ 

874 return self.id == 0 

875 

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 

882 

883 self.save() 

884 

885 @override 

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

887 """ 

888 Override attribute setting to automatically trigger async save. 

889 

890 Args: 

891 name: Attribute name 

892 value: New attribute value 

893 

894 """ 

895 # Set the new value 

896 super().__setattr__(name, value) 

897 

898 # Autosave logic below 

899 if self._status != ModelStatus.READY: 

900 return 

901 

902 # Skip autosave for private fields 

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

904 self._autosave() 

905 

906 @override 

907 def __str__(self) -> str: 

908 """ 

909 Human-readable string representation. 

910 

911 Returns: 

912 A string representation of the model. 

913 

914 """ 

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