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

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

22 from __future__ import annotations

23

24 import logging

25 import types

26 from abc import ABC, abstractmethod

27 from datetime import datetime

28 from decimal import Decimal

29 from enum import StrEnum

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

31

32 import pydantic

33 from pydantic import Field, PrivateAttr

34 from typing_extensions import TypeVar

35 from yarl import URL

36

37 from paperap.const import FilteringStrategies, ModelStatus

38 from paperap.exceptions import ConfigurationError

39 from paperap.models.abstract.meta import StatusContext

40 from paperap.models.abstract.queryset import BaseQuerySet

41 from paperap.signals import registry

42

43 if TYPE_CHECKING:

44 from paperap.client import PaperlessClient

45 from paperap.resources.base import BaseResource, StandardResource

46

47 logger = logging.getLogger(__name__)

48

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

50

51

52 class ModelConfigType(TypedDict):

53 populate_by_name: bool

54 validate_assignment: bool

55 use_enum_values: bool

56 extra: Literal["ignore"]

57

58

59 BASE_MODEL_CONFIG: ModelConfigType = {

60 "populate_by_name": True,

61 "validate_assignment": True,

62 "use_enum_values": True,

63 "extra": "ignore",

64}

65

66

67 class 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

517 class 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}"