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

190 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-11 21:37 -0400

1""" 

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

3 

4 METADATA: 

5 

6 File: base.py 

7 Project: paperap 

8 Created: 2025-03-04 

9 Version: 0.0.5 

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 logging 

25import types 

26from abc import ABC, abstractmethod 

27from datetime import datetime 

28from decimal import Decimal 

29from enum import StrEnum 

30from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, Self, cast, override 

31 

32import pydantic 

33from pydantic import Field, PrivateAttr 

34from typing_extensions import TypeVar 

35from yarl import URL 

36 

37from paperap.const import FilteringStrategies, ModelStatus 

38from paperap.exceptions import ConfigurationError 

39from paperap.models.abstract.meta import StatusContext 

40from paperap.models.abstract.queryset import BaseQuerySet 

41from paperap.signals import registry 

42 

43if TYPE_CHECKING: 

44 from paperap.client import PaperlessClient 

45 from paperap.resources.base import BaseResource, StandardResource 

46 

47logger = logging.getLogger(__name__) 

48 

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

50 

51 

52class BaseModel(pydantic.BaseModel, ABC): 

53 """ 

54 Base model for all Paperless-ngx API objects. 

55 

56 Provides automatic serialization, deserialization, and API interactions 

57 with minimal configuration needed. 

58 

59 Attributes: 

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

61 

62 Returns: 

63 A new instance of BaseModel. 

64 

65 Raises: 

66 ValueError: If resource is not provided. 

67 

68 Examples: 

69 from paperap.models.abstract.model import StandardModel 

70 class Document(StandardModel): 

71 filename: str 

72 contents : bytes 

73 

74 class Meta: 

75 api_endpoint: = URL("http://localhost:8000/api/documents/") 

76 

77 """ 

78 

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

80 

81 class Meta(Generic[_Self]): 

82 """ 

83 Metadata for the BaseModel. 

84 

85 Attributes: 

86 name: The name of the model. 

87 read_only_fields: Fields that should not be modified. 

88 filtering_disabled: Fields disabled for filtering. 

89 filtering_fields: Fields allowed for filtering. 

90 supported_filtering_params: Params allowed during queryset filtering. 

91 blacklist_filtering_params: Params disallowed during queryset filtering. 

92 filtering_strategies: Strategies for filtering. 

93 resource: The BaseResource instance. 

94 queryset: The type of QuerySet for the model. 

95 

96 Raises: 

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

98 

99 """ 

100 

101 # The name of the model. 

102 # It will default to the classname 

103 name: str 

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

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

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

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

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

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

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

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

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

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

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

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

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

117 # Strategies for filtering. 

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

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

120 resource: "BaseResource" 

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

122 # Updating attributes will not trigger save() 

123 status: ModelStatus = ModelStatus.INITIALIZING 

124 original_data: dict[str, Any] = {} 

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

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

127 # None will respect client.settings.save_on_write 

128 save_on_write: bool | None = None 

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

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

131 # This will be populated from all parent classes. 

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

133 

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

135 

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

137 self.model = model 

138 

139 # Validate filtering strategies 

140 if all( 

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

142 ): 

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

144 

145 super().__init__() 

146 

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

148 """ 

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

150 

151 Args: 

152 filter_param: The filter parameter to check. 

153 

154 Returns: 

155 True if the filter is allowed, False otherwise. 

156 

157 """ 

158 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies: 

159 return True 

160 

161 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies: 

162 return False 

163 

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

165 if FilteringStrategies.WHITELIST in self.filtering_strategies: 

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

167 return False 

168 # Allow other rules to fire 

169 

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

171 if FilteringStrategies.BLACKLIST in self.filtering_strategies: 

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

173 return False 

174 # Allow other rules to fire 

175 

176 # Check if the filtering key is disabled 

177 split_key = filter_param.split("__") 

178 if len(split_key) > 1: 

179 field, _lookup = split_key[-2:] 

180 else: 

181 field, _lookup = filter_param, None 

182 

183 # If key is in filtering_disabled, throw an error 

184 if field in self.filtering_disabled: 

185 return False 

186 

187 # Not disabled, so it's allowed 

188 return True 

189 

190 @classmethod 

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

192 """ 

193 Initialize subclass and set up metadata. 

194 

195 Args: 

196 **kwargs: Additional keyword arguments. 

197 

198 """ 

199 super().__init_subclass__(**kwargs) 

200 # Ensure the subclass has its own Meta definition. 

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

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

203 if "Meta" not in cls.__dict__: 

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

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

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

207 if "Meta" in base.__dict__: 

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

209 break 

210 if top_meta is None: 

211 # This should never happen. 

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

213 

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

215 meta_attrs = { 

216 k: v 

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

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

219 } 

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

221 logger.debug( 

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

223 cls.__name__, 

224 top_meta.__name__, 

225 ) 

226 

227 # Append read_only_fields from all parents to Meta 

228 # Same with filtering_disabled 

229 # Retrieve filtering_fields from the attributes of the class 

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

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

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

233 supported_filtering_params = cls.Meta.supported_filtering_params 

234 blacklist_filtering_params = cls.Meta.blacklist_filtering_params 

235 field_map = cls.Meta.field_map 

236 for base in cls.__bases__: 

237 _meta: BaseModel.Meta[Self] | None 

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

239 if hasattr(_meta, "read_only_fields"): 

240 read_only_fields.update(_meta.read_only_fields) 

241 if hasattr(_meta, "filtering_disabled"): 

242 filtering_disabled.update(_meta.filtering_disabled) 

243 if hasattr(_meta, "filtering_fields"): 

244 filtering_fields.update(_meta.filtering_fields) 

245 if hasattr(_meta, "supported_filtering_params"): 

246 supported_filtering_params.update(_meta.supported_filtering_params) 

247 if hasattr(_meta, "blacklist_filtering_params"): 

248 blacklist_filtering_params.update(_meta.blacklist_filtering_params) 

249 if hasattr(_meta, "field_map"): 

250 field_map.update(_meta.field_map) 

251 

252 cls.Meta.read_only_fields = read_only_fields 

253 cls.Meta.filtering_disabled = filtering_disabled 

254 # excluding filtering_disabled from filtering_fields 

255 cls.Meta.filtering_fields = filtering_fields - filtering_disabled 

256 cls.Meta.supported_filtering_params = supported_filtering_params 

257 cls.Meta.blacklist_filtering_params = blacklist_filtering_params 

258 cls.Meta.field_map = field_map 

259 

260 # Instantiate _meta 

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

262 

263 # Set name defaults 

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

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

266 

267 # Configure Pydantic behavior 

268 model_config = pydantic.ConfigDict( 

269 populate_by_name=True, 

270 validate_assignment=True, 

271 use_enum_values=True, 

272 extra="ignore", 

273 ) 

274 

275 @property 

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

277 """ 

278 Get the resource associated with this model. 

279 

280 Returns: 

281 The BaseResource instance. 

282 

283 """ 

284 return self._meta.resource 

285 

286 @property 

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

288 """ 

289 Get the client associated with this model. 

290 

291 Returns: 

292 The PaperlessClient instance. 

293 

294 """ 

295 return self._meta.resource.client 

296 

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

298 """ 

299 Initialize the model with resource and data. 

300 

301 Args: 

302 resource: The BaseResource instance. 

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

304 

305 Raises: 

306 ValueError: If resource is not provided. 

307 

308 """ 

309 super().__init__(**data) 

310 

311 if resource: 

312 self._meta.resource = resource 

313 

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

315 raise ValueError( 

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

317 ) 

318 

319 @override 

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

321 super().model_post_init(__context) 

322 

323 # Save original_data to support dirty fields 

324 self._meta.original_data = self.model_dump() 

325 

326 # Allow updating attributes to trigger save() automatically 

327 self._meta.status = ModelStatus.READY 

328 

329 super().model_post_init(__context) 

330 

331 @classmethod 

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

333 """ 

334 Create a model instance from API response data. 

335 

336 Args: 

337 data: Dictionary containing the API response data. 

338 

339 Returns: 

340 A model instance initialized with the provided data. 

341 

342 Examples: 

343 # Create a Document instance from API data 

344 doc = Document.from_dict(api_data) 

345 

346 """ 

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

348 

349 def to_dict( 

350 self, 

351 *, 

352 include_read_only: bool = True, 

353 exclude_none: bool = False, 

354 exclude_unset: bool = True, 

355 ) -> dict[str, Any]: 

356 """ 

357 Convert the model to a dictionary for API requests. 

358 

359 Args: 

360 include_read_only: Whether to include read-only fields. 

361 exclude_none: Whether to exclude fields with None values. 

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

363 

364 Returns: 

365 A dictionary with model data ready for API submission. 

366 

367 Examples: 

368 # Convert a Document instance to a dictionary 

369 data = doc.to_dict() 

370 

371 """ 

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

373 

374 return self.model_dump( 

375 exclude=exclude, 

376 exclude_none=exclude_none, 

377 exclude_unset=exclude_unset, 

378 ) 

379 

380 def dirty_fields(self) -> dict[str, Any]: 

381 """ 

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

383 

384 Returns: 

385 A dictionary of fields that have changed since last update from the paperless ngx db. 

386 

387 """ 

388 return { 

389 field: value 

390 for field, value in self.model_dump().items() 

391 if field in self._meta.original_data and self._meta.original_data[field] != value 

392 } 

393 

394 def is_dirty(self) -> bool: 

395 """ 

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

397 

398 Returns: 

399 True if any field has changed. 

400 

401 """ 

402 return bool(self.dirty_fields()) 

403 

404 @classmethod 

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

406 """ 

407 Create a new model instance. 

408 

409 Args: 

410 **kwargs: Field values to set. 

411 

412 Returns: 

413 A new model instance. 

414 

415 Examples: 

416 # Create a new Document instance 

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

418 

419 """ 

420 # TODO save 

421 return cls(**kwargs) 

422 

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

424 """ 

425 Update model attributes without triggering automatic save. 

426 

427 Args: 

428 **kwargs: Field values to update 

429 

430 Returns: 

431 Self with updated values 

432 

433 """ 

434 from_db = from_db if from_db is not None else False 

435 

436 # Avoid infinite saving loops 

437 with StatusContext(self, ModelStatus.UPDATING): 

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

439 setattr(self, name, value) 

440 

441 # Dirty has been reset 

442 if from_db: 

443 self._meta.original_data = self.model_dump() 

444 

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

446 """ 

447 Update this model with new values. 

448 

449 Subclasses implement this with auto-saving features. 

450 However, base BaseModel instances simply call update_locally. 

451 

452 Args: 

453 **kwargs: New field values. 

454 

455 Examples: 

456 # Update a Document instance 

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

458 

459 """ 

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

461 # subclasses may implement this. 

462 self.update_locally(**kwargs) 

463 

464 @abstractmethod 

465 def is_new(self) -> bool: 

466 """ 

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

468 

469 Returns: 

470 True if the model is new, False otherwise. 

471 

472 Examples: 

473 # Check if a Document instance is new 

474 is_new = doc.is_new() 

475 

476 """ 

477 

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

479 """ 

480 Check if the model matches the provided data. 

481 

482 Args: 

483 data: Dictionary containing the data to compare. 

484 

485 Returns: 

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

487 

488 Examples: 

489 # Check if a Document instance matches API data 

490 matches = doc.matches_dict(api_data) 

491 

492 """ 

493 return self.to_dict() == data 

494 

495 @override 

496 def __str__(self) -> str: 

497 """ 

498 Human-readable string representation. 

499 

500 Returns: 

501 A string representation of the model. 

502 

503 """ 

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

505 

506 

507class StandardModel(BaseModel, ABC): 

508 """ 

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

510 

511 Attributes: 

512 id: Unique identifier for the model. 

513 

514 Returns: 

515 A new instance of StandardModel. 

516 

517 Examples: 

518 from paperap.models.abstract.model import StandardModel 

519 class Document(StandardModel): 

520 filename: str 

521 contents : bytes 

522 

523 """ 

524 

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

526 

527 class Meta(BaseModel.Meta): 

528 """ 

529 Metadata for the StandardModel. 

530 

531 Attributes: 

532 read_only_fields: Fields that should not be modified. 

533 supported_filtering_params: Params allowed during queryset filtering. 

534 

535 """ 

536 

537 # Fields that should not be modified 

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

539 supported_filtering_params = { 

540 "id__in", 

541 "id", 

542 } 

543 

544 @override 

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

546 """ 

547 Update this model with new values and save changes. 

548 

549 NOTE: new instances will not be saved automatically. 

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

551 

552 Args: 

553 **kwargs: New field values. 

554 

555 """ 

556 # Hold off on saving until all updates are complete 

557 self.update_locally(**kwargs) 

558 if not self.is_new(): 

559 self.save() 

560 

561 def save(self) -> None: 

562 """ 

563 Save this model instance within paperless ngx. 

564 

565 Raises: 

566 ResourceNotFoundError: If the resource with the given id is not found 

567 

568 Examples: 

569 # Save a Document instance 

570 doc = client.documents().get(1) 

571 doc.title = "New Title" 

572 doc.save() 

573 

574 """ 

575 # Safety measure to ensure we don't fall into an infinite loop of saving and updating 

576 # this check shouldn't strictly be necessary, but it future proofs this feature 

577 if self._meta.status == ModelStatus.SAVING: 

578 return 

579 

580 with StatusContext(self, ModelStatus.SAVING): 

581 # Nothing has changed, so we can save ourselves a request 

582 if not self.is_dirty(): 

583 return 

584 

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

586 registry.emit( 

587 "model.save:before", 

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

589 kwargs={ 

590 "model": self, 

591 "current_data": current_data, 

592 }, 

593 ) 

594 

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

596 new_data = new_model.to_dict() 

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

598 

599 registry.emit( 

600 "model.save:after", 

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

602 kwargs={ 

603 "model": self, 

604 "previous_data": current_data, 

605 "updated_data": new_data, 

606 }, 

607 ) 

608 

609 @override 

610 def is_new(self) -> bool: 

611 """ 

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

613 

614 Returns: 

615 True if the model is new, False otherwise. 

616 

617 Examples: 

618 # Check if a Document instance is new 

619 is_new = doc.is_new() 

620 

621 """ 

622 return self.id == 0 

623 

624 @override 

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

626 """ 

627 Override attribute setting to automatically call save when attributes change. 

628 

629 Args: 

630 name: Attribute name 

631 value: New attribute value 

632 

633 """ 

634 # Call parent's setattr 

635 super().__setattr__(name, value) 

636 

637 # Skip for private attributes (those starting with underscore) 

638 if name.startswith("_"): 

639 return 

640 

641 # Check if the model is initialized or is new 

642 if not hasattr(self, "_meta") or self.is_new(): 

643 return 

644 

645 # Settings may override this behavior 

646 if self._meta.save_on_write is False or self._meta.resource.client.settings.save_on_write is False: 

647 return 

648 

649 # Only trigger a save if the model is in a ready status 

650 if self._meta.status != ModelStatus.READY: 

651 return 

652 

653 # All attribute changes trigger a save automatically 

654 self.save() 

655 

656 @override 

657 def __str__(self) -> str: 

658 """ 

659 Human-readable string representation. 

660 

661 Returns: 

662 A string representation of the model. 

663 

664 """ 

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