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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-12 23:40 -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, TypedDict, 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 ModelConfigType(TypedDict):
53 populate_by_name: bool
54 validate_assignment: bool
55 use_enum_values: bool
56 extra: Literal["ignore"]
59BASE_MODEL_CONFIG: ModelConfigType = {
60 "populate_by_name": True,
61 "validate_assignment": True,
62 "use_enum_values": True,
63 "extra": "ignore",
64}
67class BaseModel(pydantic.BaseModel, ABC):
68 """
69 Base model for all Paperless-ngx API objects.
71 Provides automatic serialization, deserialization, and API interactions
72 with minimal configuration needed.
74 Attributes:
75 _meta: Metadata for the model, including filtering and resource information.
77 Returns:
78 A new instance of BaseModel.
80 Raises:
81 ValueError: If resource is not provided.
83 Examples:
84 from paperap.models.abstract.model import StandardModel
85 class Document(StandardModel):
86 filename: str
87 contents : bytes
89 class Meta:
90 api_endpoint: = URL("http://localhost:8000/api/documents/")
92 """
94 _meta: "ClassVar[Meta[Self]]"
96 class Meta(Generic[_Self]):
97 """
98 Metadata for the BaseModel.
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.
111 Raises:
112 ValueError: If both ALLOW_ALL and ALLOW_NONE filtering strategies are set.
114 """
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] = {}
149 __type_hints_cache__: dict[str, type] = {}
151 def __init__(self, model: type[_Self]):
152 self.model = model
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__}")
160 super().__init__()
162 def filter_allowed(self, filter_param: str) -> bool:
163 """
164 Check if a filter is allowed based on the filtering strategies.
166 Args:
167 filter_param: The filter parameter to check.
169 Returns:
170 True if the filter is allowed, False otherwise.
172 """
173 if FilteringStrategies.ALLOW_ALL in self.filtering_strategies:
174 return True
176 if FilteringStrategies.ALLOW_NONE in self.filtering_strategies:
177 return False
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
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
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
198 # If key is in filtering_disabled, throw an error
199 if field in self.filtering_disabled:
200 return False
202 # Not disabled, so it's allowed
203 return True
205 @classmethod
206 def __init_subclass__(cls, **kwargs: Any) -> None:
207 """
208 Initialize subclass and set up metadata.
210 Args:
211 **kwargs: Additional keyword arguments.
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")
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 )
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)
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
275 # Instantiate _meta
276 cls._meta = cls.Meta(cls) # type: ignore # due to a mypy bug in version 1.15.0 (issue #18776)
278 # Set name defaults
279 if not hasattr(cls._meta, "name"):
280 cls._meta.name = cls.__name__.lower()
282 # Configure Pydantic behavior
283 model_config = pydantic.ConfigDict(**BASE_MODEL_CONFIG)
285 def __init__(self, resource: "BaseResource[Self] | None" = None, **data: Any) -> None:
286 """
287 Initialize the model with resource and data.
289 Args:
290 resource: The BaseResource instance.
291 **data: Additional data to initialize the model.
293 Raises:
294 ValueError: If resource is not provided.
296 """
297 super().__init__(**data)
299 if resource:
300 self._meta.resource = resource
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 )
307 @property
308 def _resource(self) -> "BaseResource[Self]":
309 """
310 Get the resource associated with this model.
312 Returns:
313 The BaseResource instance.
315 """
316 return self._meta.resource
318 @property
319 def _client(self) -> "PaperlessClient":
320 """
321 Get the client associated with this model.
323 Returns:
324 The PaperlessClient instance.
326 """
327 return self._meta.resource.client
329 @override
330 def model_post_init(self, __context) -> None:
331 super().model_post_init(__context)
333 # Save original_data to support dirty fields
334 self._meta.original_data = self.model_dump()
336 # Allow updating attributes to trigger save() automatically
337 self._meta.status = ModelStatus.READY
339 super().model_post_init(__context)
341 @classmethod
342 def from_dict(cls, data: dict[str, Any]) -> Self:
343 """
344 Create a model instance from API response data.
346 Args:
347 data: Dictionary containing the API response data.
349 Returns:
350 A model instance initialized with the provided data.
352 Examples:
353 # Create a Document instance from API data
354 doc = Document.from_dict(api_data)
356 """
357 return cls._meta.resource.parse_to_model(data)
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.
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.
374 Returns:
375 A dictionary with model data ready for API submission.
377 Examples:
378 # Convert a Document instance to a dictionary
379 data = doc.to_dict()
381 """
382 exclude: set[str] = set() if include_read_only else set(self._meta.read_only_fields)
384 return self.model_dump(
385 exclude=exclude,
386 exclude_none=exclude_none,
387 exclude_unset=exclude_unset,
388 )
390 def dirty_fields(self) -> dict[str, Any]:
391 """
392 Show which fields have changed since last update from the paperless ngx db.
394 Returns:
395 A dictionary of fields that have changed since last update from the paperless ngx db.
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 }
404 def is_dirty(self) -> bool:
405 """
406 Check if any field has changed since last update from the paperless ngx db.
408 Returns:
409 True if any field has changed.
411 """
412 return bool(self.dirty_fields())
414 @classmethod
415 def create(cls, **kwargs: Any) -> Self:
416 """
417 Create a new model instance.
419 Args:
420 **kwargs: Field values to set.
422 Returns:
423 A new model instance.
425 Examples:
426 # Create a new Document instance
427 doc = Document.create(filename="example.pdf", contents=b"PDF data")
429 """
430 # TODO save
431 return cls(**kwargs)
433 def update_locally(self, from_db: bool | None = None, **kwargs: Any) -> None:
434 """
435 Update model attributes without triggering automatic save.
437 Args:
438 **kwargs: Field values to update
440 Returns:
441 Self with updated values
443 """
444 from_db = from_db if from_db is not None else False
446 # Avoid infinite saving loops
447 with StatusContext(self, ModelStatus.UPDATING):
448 for name, value in kwargs.items():
449 setattr(self, name, value)
451 # Dirty has been reset
452 if from_db:
453 self._meta.original_data = self.model_dump()
455 def update(self, **kwargs: Any) -> None:
456 """
457 Update this model with new values.
459 Subclasses implement this with auto-saving features.
460 However, base BaseModel instances simply call update_locally.
462 Args:
463 **kwargs: New field values.
465 Examples:
466 # Update a Document instance
467 doc.update(filename="new_example.pdf")
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)
474 @abstractmethod
475 def is_new(self) -> bool:
476 """
477 Check if this model represents a new (unsaved) object.
479 Returns:
480 True if the model is new, False otherwise.
482 Examples:
483 # Check if a Document instance is new
484 is_new = doc.is_new()
486 """
488 def matches_dict(self, data: dict[str, Any]) -> bool:
489 """
490 Check if the model matches the provided data.
492 Args:
493 data: Dictionary containing the data to compare.
495 Returns:
496 True if the model matches the data, False otherwise.
498 Examples:
499 # Check if a Document instance matches API data
500 matches = doc.matches_dict(api_data)
502 """
503 return self.to_dict() == data
505 @override
506 def __str__(self) -> str:
507 """
508 Human-readable string representation.
510 Returns:
511 A string representation of the model.
513 """
514 return f"{self._meta.name.capitalize()}"
517class StandardModel(BaseModel, ABC):
518 """
519 Standard model for Paperless-ngx API objects with an ID field.
521 Attributes:
522 id: Unique identifier for the model.
524 Returns:
525 A new instance of StandardModel.
527 Examples:
528 from paperap.models.abstract.model import StandardModel
529 class Document(StandardModel):
530 filename: str
531 contents : bytes
533 """
535 id: int = Field(description="Unique identifier from Paperless NGX", default=0)
537 class Meta(BaseModel.Meta):
538 """
539 Metadata for the StandardModel.
541 Attributes:
542 read_only_fields: Fields that should not be modified.
543 supported_filtering_params: Params allowed during queryset filtering.
545 """
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 }
554 @override
555 def update(self, **kwargs: Any) -> None:
556 """
557 Update this model with new values and save changes.
559 NOTE: new instances will not be saved automatically.
560 (I'm not sure if that's the right design decision or not)
562 Args:
563 **kwargs: New field values.
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()
571 def save(self) -> None:
572 """
573 Save this model instance within paperless ngx.
575 Raises:
576 ResourceNotFoundError: If the resource with the given id is not found
578 Examples:
579 # Save a Document instance
580 doc = client.documents().get(1)
581 doc.title = "New Title"
582 doc.save()
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
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
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 )
605 new_model = self._meta.resource.update(self)
606 new_data = new_model.to_dict()
607 self.update_locally(from_db=True, **new_data)
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 )
619 @override
620 def is_new(self) -> bool:
621 """
622 Check if this model represents a new (unsaved) object.
624 Returns:
625 True if the model is new, False otherwise.
627 Examples:
628 # Check if a Document instance is new
629 is_new = doc.is_new()
631 """
632 return self.id == 0
634 @override
635 def __setattr__(self, name: str, value: Any) -> None:
636 """
637 Override attribute setting to automatically call save when attributes change.
639 Args:
640 name: Attribute name
641 value: New attribute value
643 """
644 # Call parent's setattr
645 super().__setattr__(name, value)
647 # Skip for private attributes (those starting with underscore)
648 if name.startswith("_"):
649 return
651 # Check if the model is initialized or is new
652 if not hasattr(self, "_meta") or self.is_new():
653 return
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
659 # Only trigger a save if the model is in a ready status
660 if self._meta.status != ModelStatus.READY:
661 return
663 # All attribute changes trigger a save automatically
664 self.save()
666 @override
667 def __str__(self) -> str:
668 """
669 Human-readable string representation.
671 Returns:
672 A string representation of the model.
674 """
675 return f"{self._meta.name.capitalize()} #{self.id}"