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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
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
14----------------------------------------------------------------------------
16 LAST MODIFIED:
18 2025-03-04 By Jess Mann
20"""
22from __future__ import annotations
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
32import pydantic
33from pydantic import Field, PrivateAttr
34from typing_extensions import TypeVar
35from yarl import URL
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
43if TYPE_CHECKING:
44 from paperap.client import PaperlessClient
45 from paperap.resources.base import BaseResource, StandardResource
47logger = logging.getLogger(__name__)
49_Self = TypeVar("_Self", bound="BaseModel")
52class BaseModel(pydantic.BaseModel, ABC):
53 """
54 Base model for all Paperless-ngx API objects.
56 Provides automatic serialization, deserialization, and API interactions
57 with minimal configuration needed.
59 Attributes:
60 _meta: Metadata for the model, including filtering and resource information.
62 Returns:
63 A new instance of BaseModel.
65 Raises:
66 ValueError: If resource is not provided.
68 Examples:
69 from paperap.models.abstract.model import StandardModel
70 class Document(StandardModel):
71 filename: str
72 contents : bytes
74 class Meta:
75 api_endpoint: = URL("http://localhost:8000/api/documents/")
77 """
79 _meta: "ClassVar[Meta[Self]]"
81 class Meta(Generic[_Self]):
82 """
83 Metadata for the BaseModel.
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.
96 Raises:
97 ValueError: If both ALLOW_ALL and ALLOW_NONE filtering strategies are set.
99 """
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] = {}
134 __type_hints_cache__: dict[str, type] = {}
136 def __init__(self, model: type[_Self]):
137 self.model = model
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__}")
145 super().__init__()
147 def filter_allowed(self, filter_param: str) -> bool:
148 """
149 Check if a filter is allowed based on the filtering strategies.
151 Args:
152 filter_param: The filter parameter to check.
154 Returns:
155 True if the filter is allowed, False otherwise.
157 """
158 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies:
159 return True
161 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies:
162 return False
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
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
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
183 # If key is in filtering_disabled, throw an error
184 if field in self.filtering_disabled:
185 return False
187 # Not disabled, so it's allowed
188 return True
190 @classmethod
191 def __init_subclass__(cls, **kwargs: Any) -> None:
192 """
193 Initialize subclass and set up metadata.
195 Args:
196 **kwargs: Additional keyword arguments.
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")
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 )
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)
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
260 # Instantiate _meta
261 cls._meta = cls.Meta(cls) # type: ignore # due to a mypy bug in version 1.15.0 (issue #18776)
263 # Set name defaults
264 if not hasattr(cls._meta, "name"):
265 cls._meta.name = cls.__name__.lower()
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 )
275 @property
276 def _resource(self) -> "BaseResource[Self]":
277 """
278 Get the resource associated with this model.
280 Returns:
281 The BaseResource instance.
283 """
284 return self._meta.resource
286 @property
287 def _client(self) -> "PaperlessClient":
288 """
289 Get the client associated with this model.
291 Returns:
292 The PaperlessClient instance.
294 """
295 return self._meta.resource.client
297 def __init__(self, resource: "BaseResource[Self] | None" = None, **data: Any) -> None:
298 """
299 Initialize the model with resource and data.
301 Args:
302 resource: The BaseResource instance.
303 **data: Additional data to initialize the model.
305 Raises:
306 ValueError: If resource is not provided.
308 """
309 super().__init__(**data)
311 if resource:
312 self._meta.resource = resource
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 )
319 @override
320 def model_post_init(self, __context) -> None:
321 super().model_post_init(__context)
323 # Save original_data to support dirty fields
324 self._meta.original_data = self.model_dump()
326 # Allow updating attributes to trigger save() automatically
327 self._meta.status = ModelStatus.READY
329 super().model_post_init(__context)
331 @classmethod
332 def from_dict(cls, data: dict[str, Any]) -> Self:
333 """
334 Create a model instance from API response data.
336 Args:
337 data: Dictionary containing the API response data.
339 Returns:
340 A model instance initialized with the provided data.
342 Examples:
343 # Create a Document instance from API data
344 doc = Document.from_dict(api_data)
346 """
347 return cls._meta.resource.parse_to_model(data)
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.
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.
364 Returns:
365 A dictionary with model data ready for API submission.
367 Examples:
368 # Convert a Document instance to a dictionary
369 data = doc.to_dict()
371 """
372 exclude: set[str] = set() if include_read_only else set(self._meta.read_only_fields)
374 return self.model_dump(
375 exclude=exclude,
376 exclude_none=exclude_none,
377 exclude_unset=exclude_unset,
378 )
380 def dirty_fields(self) -> dict[str, Any]:
381 """
382 Show which fields have changed since last update from the paperless ngx db.
384 Returns:
385 A dictionary of fields that have changed since last update from the paperless ngx db.
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 }
394 def is_dirty(self) -> bool:
395 """
396 Check if any field has changed since last update from the paperless ngx db.
398 Returns:
399 True if any field has changed.
401 """
402 return bool(self.dirty_fields())
404 @classmethod
405 def create(cls, **kwargs: Any) -> Self:
406 """
407 Create a new model instance.
409 Args:
410 **kwargs: Field values to set.
412 Returns:
413 A new model instance.
415 Examples:
416 # Create a new Document instance
417 doc = Document.create(filename="example.pdf", contents=b"PDF data")
419 """
420 # TODO save
421 return cls(**kwargs)
423 def update_locally(self, from_db: bool | None = None, **kwargs: Any) -> None:
424 """
425 Update model attributes without triggering automatic save.
427 Args:
428 **kwargs: Field values to update
430 Returns:
431 Self with updated values
433 """
434 from_db = from_db if from_db is not None else False
436 # Avoid infinite saving loops
437 with StatusContext(self, ModelStatus.UPDATING):
438 for name, value in kwargs.items():
439 setattr(self, name, value)
441 # Dirty has been reset
442 if from_db:
443 self._meta.original_data = self.model_dump()
445 def update(self, **kwargs: Any) -> None:
446 """
447 Update this model with new values.
449 Subclasses implement this with auto-saving features.
450 However, base BaseModel instances simply call update_locally.
452 Args:
453 **kwargs: New field values.
455 Examples:
456 # Update a Document instance
457 doc.update(filename="new_example.pdf")
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)
464 @abstractmethod
465 def is_new(self) -> bool:
466 """
467 Check if this model represents a new (unsaved) object.
469 Returns:
470 True if the model is new, False otherwise.
472 Examples:
473 # Check if a Document instance is new
474 is_new = doc.is_new()
476 """
478 def matches_dict(self, data: dict[str, Any]) -> bool:
479 """
480 Check if the model matches the provided data.
482 Args:
483 data: Dictionary containing the data to compare.
485 Returns:
486 True if the model matches the data, False otherwise.
488 Examples:
489 # Check if a Document instance matches API data
490 matches = doc.matches_dict(api_data)
492 """
493 return self.to_dict() == data
495 @override
496 def __str__(self) -> str:
497 """
498 Human-readable string representation.
500 Returns:
501 A string representation of the model.
503 """
504 return f"{self._meta.name.capitalize()}"
507class StandardModel(BaseModel, ABC):
508 """
509 Standard model for Paperless-ngx API objects with an ID field.
511 Attributes:
512 id: Unique identifier for the model.
514 Returns:
515 A new instance of StandardModel.
517 Examples:
518 from paperap.models.abstract.model import StandardModel
519 class Document(StandardModel):
520 filename: str
521 contents : bytes
523 """
525 id: int = Field(description="Unique identifier from Paperless NGX", default=0)
527 class Meta(BaseModel.Meta):
528 """
529 Metadata for the StandardModel.
531 Attributes:
532 read_only_fields: Fields that should not be modified.
533 supported_filtering_params: Params allowed during queryset filtering.
535 """
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 }
544 @override
545 def update(self, **kwargs: Any) -> None:
546 """
547 Update this model with new values and save changes.
549 NOTE: new instances will not be saved automatically.
550 (I'm not sure if that's the right design decision or not)
552 Args:
553 **kwargs: New field values.
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()
561 def save(self) -> None:
562 """
563 Save this model instance within paperless ngx.
565 Raises:
566 ResourceNotFoundError: If the resource with the given id is not found
568 Examples:
569 # Save a Document instance
570 doc = client.documents().get(1)
571 doc.title = "New Title"
572 doc.save()
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
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
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 )
595 new_model = self._meta.resource.update(self)
596 new_data = new_model.to_dict()
597 self.update_locally(from_db=True, **new_data)
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 )
609 @override
610 def is_new(self) -> bool:
611 """
612 Check if this model represents a new (unsaved) object.
614 Returns:
615 True if the model is new, False otherwise.
617 Examples:
618 # Check if a Document instance is new
619 is_new = doc.is_new()
621 """
622 return self.id == 0
624 @override
625 def __setattr__(self, name: str, value: Any) -> None:
626 """
627 Override attribute setting to automatically call save when attributes change.
629 Args:
630 name: Attribute name
631 value: New attribute value
633 """
634 # Call parent's setattr
635 super().__setattr__(name, value)
637 # Skip for private attributes (those starting with underscore)
638 if name.startswith("_"):
639 return
641 # Check if the model is initialized or is new
642 if not hasattr(self, "_meta") or self.is_new():
643 return
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
649 # Only trigger a save if the model is in a ready status
650 if self._meta.status != ModelStatus.READY:
651 return
653 # All attribute changes trigger a save automatically
654 self.save()
656 @override
657 def __str__(self) -> str:
658 """
659 Human-readable string representation.
661 Returns:
662 A string representation of the model.
664 """
665 return f"{self._meta.name.capitalize()} #{self.id}"