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

196 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 23:40 -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, TypedDict, 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 ModelConfigType(TypedDict): 

53 populate_by_name: bool 

54 validate_assignment: bool 

55 use_enum_values: bool 

56 extra: Literal["ignore"] 

57 

58 

59BASE_MODEL_CONFIG: ModelConfigType = { 

60 "populate_by_name": True, 

61 "validate_assignment": True, 

62 "use_enum_values": True, 

63 "extra": "ignore", 

64} 

65 

66 

67class BaseModel(pydantic.BaseModel, ABC): 

68 """ 

69 Base model for all Paperless-ngx API objects. 

70 

71 Provides automatic serialization, deserialization, and API interactions 

72 with minimal configuration needed. 

73 

74 Attributes: 

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

76 

77 Returns: 

78 A new instance of BaseModel. 

79 

80 Raises: 

81 ValueError: If resource is not provided. 

82 

83 Examples: 

84 from paperap.models.abstract.model import StandardModel 

85 class Document(StandardModel): 

86 filename: str 

87 contents : bytes 

88 

89 class Meta: 

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

91 

92 """ 

93 

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

95 

96 class Meta(Generic[_Self]): 

97 """ 

98 Metadata for the BaseModel. 

99 

100 Attributes: 

101 name: The name of the model. 

102 read_only_fields: Fields that should not be modified. 

103 filtering_disabled: Fields disabled for filtering. 

104 filtering_fields: Fields allowed for filtering. 

105 supported_filtering_params: Params allowed during queryset filtering. 

106 blacklist_filtering_params: Params disallowed during queryset filtering. 

107 filtering_strategies: Strategies for filtering. 

108 resource: The BaseResource instance. 

109 queryset: The type of QuerySet for the model. 

110 

111 Raises: 

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

113 

114 """ 

115 

116 # The name of the model. 

117 # It will default to the classname 

118 name: str 

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

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

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

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

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

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

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

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

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

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

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

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

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

132 # Strategies for filtering. 

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

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

135 resource: "BaseResource" 

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

137 # Updating attributes will not trigger save() 

138 status: ModelStatus = ModelStatus.INITIALIZING 

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

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

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

142 # None will respect client.settings.save_on_write 

143 save_on_write: bool | None = None 

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

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

146 # This will be populated from all parent classes. 

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

148 

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

150 

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

152 self.model = model 

153 

154 # Validate filtering strategies 

155 if all( 

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

157 ): 

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

159 

160 super().__init__() 

161 

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

163 """ 

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

165 

166 Args: 

167 filter_param: The filter parameter to check. 

168 

169 Returns: 

170 True if the filter is allowed, False otherwise. 

171 

172 """ 

173 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies: 

174 return True 

175 

176 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies: 

177 return False 

178 

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

180 if FilteringStrategies.WHITELIST in self.filtering_strategies: 

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

182 return False 

183 # Allow other rules to fire 

184 

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

186 if FilteringStrategies.BLACKLIST in self.filtering_strategies: 

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

188 return False 

189 # Allow other rules to fire 

190 

191 # Check if the filtering key is disabled 

192 split_key = filter_param.split("__") 

193 if len(split_key) > 1: 

194 field, _lookup = split_key[-2:] 

195 else: 

196 field, _lookup = filter_param, None 

197 

198 # If key is in filtering_disabled, throw an error 

199 if field in self.filtering_disabled: 

200 return False 

201 

202 # Not disabled, so it's allowed 

203 return True 

204 

205 @classmethod 

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

207 """ 

208 Initialize subclass and set up metadata. 

209 

210 Args: 

211 **kwargs: Additional keyword arguments. 

212 

213 """ 

214 super().__init_subclass__(**kwargs) 

215 # Ensure the subclass has its own Meta definition. 

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

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

218 if "Meta" not in cls.__dict__: 

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

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

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

222 if "Meta" in base.__dict__: 

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

224 break 

225 if top_meta is None: 

226 # This should never happen. 

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

228 

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

230 meta_attrs = { 

231 k: v 

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

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

234 } 

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

236 logger.debug( 

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

238 cls.__name__, 

239 top_meta.__name__, 

240 ) 

241 

242 # Append read_only_fields from all parents to Meta 

243 # Same with filtering_disabled 

244 # Retrieve filtering_fields from the attributes of the class 

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

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

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

248 supported_filtering_params = cls.Meta.supported_filtering_params 

249 blacklist_filtering_params = cls.Meta.blacklist_filtering_params 

250 field_map = cls.Meta.field_map 

251 for base in cls.__bases__: 

252 _meta: BaseModel.Meta[Self] | None 

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

254 if hasattr(_meta, "read_only_fields"): 

255 read_only_fields.update(_meta.read_only_fields) 

256 if hasattr(_meta, "filtering_disabled"): 

257 filtering_disabled.update(_meta.filtering_disabled) 

258 if hasattr(_meta, "filtering_fields"): 

259 filtering_fields.update(_meta.filtering_fields) 

260 if hasattr(_meta, "supported_filtering_params"): 

261 supported_filtering_params.update(_meta.supported_filtering_params) 

262 if hasattr(_meta, "blacklist_filtering_params"): 

263 blacklist_filtering_params.update(_meta.blacklist_filtering_params) 

264 if hasattr(_meta, "field_map"): 

265 field_map.update(_meta.field_map) 

266 

267 cls.Meta.read_only_fields = read_only_fields 

268 cls.Meta.filtering_disabled = filtering_disabled 

269 # excluding filtering_disabled from filtering_fields 

270 cls.Meta.filtering_fields = filtering_fields - filtering_disabled 

271 cls.Meta.supported_filtering_params = supported_filtering_params 

272 cls.Meta.blacklist_filtering_params = blacklist_filtering_params 

273 cls.Meta.field_map = field_map 

274 

275 # Instantiate _meta 

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

277 

278 # Set name defaults 

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

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

281 

282 # Configure Pydantic behavior 

283 model_config = pydantic.ConfigDict(**BASE_MODEL_CONFIG) 

284 

285 def __init__(self, resource: "BaseResource[Self] | None" = None, **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 resource: 

300 self._meta.resource = resource 

301 

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

303 raise ValueError( 

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

305 ) 

306 

307 @property 

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

309 """ 

310 Get the resource associated with this model. 

311 

312 Returns: 

313 The BaseResource instance. 

314 

315 """ 

316 return self._meta.resource 

317 

318 @property 

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

320 """ 

321 Get the client associated with this model. 

322 

323 Returns: 

324 The PaperlessClient instance. 

325 

326 """ 

327 return self._meta.resource.client 

328 

329 @override 

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

331 super().model_post_init(__context) 

332 

333 # Save original_data to support dirty fields 

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

335 

336 # Allow updating attributes to trigger save() automatically 

337 self._meta.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._meta.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) -> dict[str, Any]: 

391 """ 

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

393 

394 Returns: 

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

396 

397 """ 

398 return { 

399 field: value 

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

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

402 } 

403 

404 def is_dirty(self) -> bool: 

405 """ 

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

407 

408 Returns: 

409 True if any field has changed. 

410 

411 """ 

412 return bool(self.dirty_fields()) 

413 

414 @classmethod 

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

416 """ 

417 Create a new model instance. 

418 

419 Args: 

420 **kwargs: Field values to set. 

421 

422 Returns: 

423 A new model instance. 

424 

425 Examples: 

426 # Create a new Document instance 

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

428 

429 """ 

430 # TODO save 

431 return cls(**kwargs) 

432 

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

434 """ 

435 Update model attributes without triggering automatic save. 

436 

437 Args: 

438 **kwargs: Field values to update 

439 

440 Returns: 

441 Self with updated values 

442 

443 """ 

444 from_db = from_db if from_db is not None else False 

445 

446 # Avoid infinite saving loops 

447 with StatusContext(self, ModelStatus.UPDATING): 

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

449 setattr(self, name, value) 

450 

451 # Dirty has been reset 

452 if from_db: 

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

454 

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

456 """ 

457 Update this model with new values. 

458 

459 Subclasses implement this with auto-saving features. 

460 However, base BaseModel instances simply call update_locally. 

461 

462 Args: 

463 **kwargs: New field values. 

464 

465 Examples: 

466 # Update a Document instance 

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

468 

469 """ 

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

471 # subclasses may implement this. 

472 self.update_locally(**kwargs) 

473 

474 @abstractmethod 

475 def is_new(self) -> bool: 

476 """ 

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

478 

479 Returns: 

480 True if the model is new, False otherwise. 

481 

482 Examples: 

483 # Check if a Document instance is new 

484 is_new = doc.is_new() 

485 

486 """ 

487 

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

489 """ 

490 Check if the model matches the provided data. 

491 

492 Args: 

493 data: Dictionary containing the data to compare. 

494 

495 Returns: 

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

497 

498 Examples: 

499 # Check if a Document instance matches API data 

500 matches = doc.matches_dict(api_data) 

501 

502 """ 

503 return self.to_dict() == data 

504 

505 @override 

506 def __str__(self) -> str: 

507 """ 

508 Human-readable string representation. 

509 

510 Returns: 

511 A string representation of the model. 

512 

513 """ 

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

515 

516 

517class StandardModel(BaseModel, ABC): 

518 """ 

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

520 

521 Attributes: 

522 id: Unique identifier for the model. 

523 

524 Returns: 

525 A new instance of StandardModel. 

526 

527 Examples: 

528 from paperap.models.abstract.model import StandardModel 

529 class Document(StandardModel): 

530 filename: str 

531 contents : bytes 

532 

533 """ 

534 

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

536 

537 class Meta(BaseModel.Meta): 

538 """ 

539 Metadata for the StandardModel. 

540 

541 Attributes: 

542 read_only_fields: Fields that should not be modified. 

543 supported_filtering_params: Params allowed during queryset filtering. 

544 

545 """ 

546 

547 # Fields that should not be modified 

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

549 supported_filtering_params = { 

550 "id__in", 

551 "id", 

552 } 

553 

554 @override 

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

556 """ 

557 Update this model with new values and save changes. 

558 

559 NOTE: new instances will not be saved automatically. 

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

561 

562 Args: 

563 **kwargs: New field values. 

564 

565 """ 

566 # Hold off on saving until all updates are complete 

567 self.update_locally(**kwargs) 

568 if not self.is_new(): 

569 self.save() 

570 

571 def save(self) -> None: 

572 """ 

573 Save this model instance within paperless ngx. 

574 

575 Raises: 

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

577 

578 Examples: 

579 # Save a Document instance 

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

581 doc.title = "New Title" 

582 doc.save() 

583 

584 """ 

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

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

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

588 return 

589 

590 with StatusContext(self, ModelStatus.SAVING): 

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

592 if not self.is_dirty(): 

593 return 

594 

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

596 registry.emit( 

597 "model.save:before", 

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

599 kwargs={ 

600 "model": self, 

601 "current_data": current_data, 

602 }, 

603 ) 

604 

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

606 new_data = new_model.to_dict() 

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

608 

609 registry.emit( 

610 "model.save:after", 

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

612 kwargs={ 

613 "model": self, 

614 "previous_data": current_data, 

615 "updated_data": new_data, 

616 }, 

617 ) 

618 

619 @override 

620 def is_new(self) -> bool: 

621 """ 

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

623 

624 Returns: 

625 True if the model is new, False otherwise. 

626 

627 Examples: 

628 # Check if a Document instance is new 

629 is_new = doc.is_new() 

630 

631 """ 

632 return self.id == 0 

633 

634 @override 

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

636 """ 

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

638 

639 Args: 

640 name: Attribute name 

641 value: New attribute value 

642 

643 """ 

644 # Call parent's setattr 

645 super().__setattr__(name, value) 

646 

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

648 if name.startswith("_"): 

649 return 

650 

651 # Check if the model is initialized or is new 

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

653 return 

654 

655 # Settings may override this behavior 

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

657 return 

658 

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

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

661 return 

662 

663 # All attribute changes trigger a save automatically 

664 self.save() 

665 

666 @override 

667 def __str__(self) -> str: 

668 """ 

669 Human-readable string representation. 

670 

671 Returns: 

672 A string representation of the model. 

673 

674 """ 

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