Coverage for src/paperap/models/document/model.py: 60%
275 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"""
5----------------------------------------------------------------------------
7METADATA:
9File: model.py
10 Project: paperap
11Created: 2025-03-09
12 Version: 0.0.7
13Author: Jess Mann
14Email: jess@jmann.me
15 Copyright (c) 2025 Jess Mann
17----------------------------------------------------------------------------
19LAST MODIFIED:
212025-03-09 By Jess Mann
23"""
24from __future__ import annotations
26from datetime import datetime
27from typing import TYPE_CHECKING, Annotated, Any, Iterable, Iterator, Optional, TypedDict, cast, override
29import pydantic
30from pydantic import ConfigDict, Field, conlist, field_serializer, field_validator, model_serializer
31from typing_extensions import TypeVar
32from yarl import URL
34from paperap.models.abstract import FilteringStrategies, StandardModel
35from paperap.models.document.queryset import DocumentQuerySet
37if TYPE_CHECKING:
38 from paperap.models.correspondent import Correspondent
39 from paperap.models.custom_field import CustomField, CustomFieldQuerySet
40 from paperap.models.document_type import DocumentType
41 from paperap.models.storage_path import StoragePath
42 from paperap.models.tag import Tag, TagQuerySet
43 from paperap.models.user import User
46class CustomFieldTypedDict(TypedDict):
47 field: int
48 value: Any
51class CustomFieldValues(pydantic.BaseModel):
52 field: int
53 value: Any
55 model_config = ConfigDict({
56 "extra": "forbid",
57 "use_enum_values": True,
58 })
60 @override
61 def __eq__(self, other: Any) -> bool:
62 if isinstance(other, dict):
63 if other.keys() != {"field", "value"}:
64 return False
65 return self.field == other.get("field") and self.value == other.get("value")
67 if isinstance(other, CustomFieldValues):
68 return self.field == other.field and self.value == other.value
70 return super().__eq__(other)
72class DocumentNote(StandardModel):
73 """
74 Represents a note on a Paperless-NgX document.
75 """
77 deleted_at: datetime | None = None
78 restored_at: datetime | None = None
79 transaction_id: int | None = None
80 note: str
81 created: datetime
82 document: int
83 user: int
85 class Meta(StandardModel.Meta):
86 read_only_fields = {"deleted_at", "restored_at", "transaction_id", "created"}
88 @field_serializer("deleted_at", "restored_at", "created")
89 def serialize_datetime(self, value: datetime | None):
90 """
91 Serialize datetime fields to ISO format.
93 Args:
94 value: The datetime value to serialize.
96 Returns:
97 The serialized datetime value or None if the value is None.
99 """
100 return value.isoformat() if value else None
102 def get_document(self) -> "Document":
103 """
104 Get the document associated with this note.
106 Returns:
107 The document associated with this note.
109 """
110 return self._client.documents().get(self.document)
112 def get_user(self) -> "User":
113 """
114 Get the user who created this note.
116 Returns:
117 The user who created this note.
119 """
120 return self._client.users().get(self.user)
123class Document(StandardModel):
124 """
125 Represents a Paperless-NgX document.
127 Attributes:
128 added: The timestamp when the document was added to the system.
129 archive_serial_number: The serial number of the archive.
130 archived_file_name: The name of the archived file.
131 content: The content of the document.
132 correspondent: The correspondent associated with the document.
133 created: The timestamp when the document was created.
134 created_date: The date when the document was created.
135 updated: The timestamp when the document was last updated.
136 custom_fields: Custom fields associated with the document.
137 deleted_at: The timestamp when the document was deleted.
138 document_type: The document type associated with the document.
139 is_shared_by_requester: Whether the document is shared by the requester.
140 notes: Notes associated with the document.
141 original_file_name: The original file name of the document.
142 owner: The owner of the document.
143 page_count: The number of pages in the document.
144 storage_path: The storage path of the document.
145 tags: The tags associated with the document.
146 title: The title of the document.
147 user_can_change: Whether the user can change the document.
149 Examples:
150 >>> document = client.documents().get(pk=1)
151 >>> document.title = 'Example Document'
152 >>> document.save()
153 >>> document.title
154 'Example Document'
156 """
158 added: datetime | None = None
159 archive_serial_number: int | None = None
160 archived_file_name: str | None = None
161 content: str = ""
162 is_shared_by_requester: bool = False
163 notes: "list[DocumentNote]" = Field(default_factory=list)
164 original_file_name: str | None = None
165 owner: int | None = None
166 page_count: int | None = None
167 title: str = ""
168 user_can_change: bool | None = None
170 created: datetime | None = Field(description="Creation timestamp", default=None)
171 created_date: str | None = None
172 # where did this come from? It's not in sample data?
173 updated: datetime | None = Field(description="Last update timestamp", default=None)
174 deleted_at: datetime | None = None
176 custom_field_dicts: Annotated[list[CustomFieldValues], Field(default_factory=list)]
177 correspondent_id: int | None = None
178 document_type_id: int | None = None
179 storage_path_id: int | None = None
180 tag_ids: Annotated[list[int], Field(default_factory=list)]
182 _correspondent: tuple[int, Correspondent] | None = None
183 _document_type: tuple[int, DocumentType] | None = None
184 _storage_path: tuple[int, StoragePath] | None = None
185 __search_hit__: Optional[dict[str, Any]] = None
187 class Meta(StandardModel.Meta):
188 # NOTE: Filtering appears to be disabled by paperless on page_count
189 queryset = DocumentQuerySet
190 read_only_fields = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}
191 filtering_disabled = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}
192 filtering_strategies = {FilteringStrategies.WHITELIST}
193 field_map = {
194 "tags": "tag_ids",
195 "custom_fields": "custom_field_dicts",
196 "document_type": "document_type_id",
197 "correspondent": "correspondent_id",
198 "storage_path": "storage_path_id",
199 }
200 supported_filtering_params = {
201 "id__in",
202 "id",
203 "title__istartswith",
204 "title__iendswith",
205 "title__icontains",
206 "title__iexact",
207 "content__istartswith",
208 "content__iendswith",
209 "content__icontains",
210 "content__iexact",
211 "archive_serial_number",
212 "archive_serial_number__gt",
213 "archive_serial_number__gte",
214 "archive_serial_number__lt",
215 "archive_serial_number__lte",
216 "archive_serial_number__isnull",
217 "content__contains", # maybe?
218 "correspondent__isnull",
219 "correspondent__id__in",
220 "correspondent__id",
221 "correspondent__name__istartswith",
222 "correspondent__name__iendswith",
223 "correspondent__name__icontains",
224 "correspondent__name__iexact",
225 "correspondent__slug__iexact", # maybe?
226 "created__year",
227 "created__month",
228 "created__day",
229 "created__date__gt",
230 "created__gt",
231 "created__date__lt",
232 "created__lt",
233 "added__year",
234 "added__month",
235 "added__day",
236 "added__date__gt",
237 "added__gt",
238 "added__date__lt",
239 "added__lt",
240 "modified__year",
241 "modified__month",
242 "modified__day",
243 "modified__date__gt",
244 "modified__gt",
245 "modified__date__lt",
246 "modified__lt",
247 "original_filename__istartswith",
248 "original_filename__iendswith",
249 "original_filename__icontains",
250 "original_filename__iexact",
251 "checksum__istartswith",
252 "checksum__iendswith",
253 "checksum__icontains",
254 "checksum__iexact",
255 "tags__id__in",
256 "tags__id",
257 "tags__name__istartswith",
258 "tags__name__iendswith",
259 "tags__name__icontains",
260 "tags__name__iexact",
261 "document_type__isnull",
262 "document_type__id__in",
263 "document_type__id",
264 "document_type__name__istartswith",
265 "document_type__name__iendswith",
266 "document_type__name__icontains",
267 "document_type__name__iexact",
268 "storage_path__isnull",
269 "storage_path__id__in",
270 "storage_path__id",
271 "storage_path__name__istartswith",
272 "storage_path__name__iendswith",
273 "storage_path__name__icontains",
274 "storage_path__name__iexact",
275 "owner__isnull",
276 "owner__id__in",
277 "owner__id",
278 "is_tagged",
279 "tags__id__all",
280 "tags__id__none",
281 "correspondent__id__none",
282 "document_type__id__none",
283 "storage_path__id__none",
284 "is_in_inbox",
285 "title_content",
286 "owner__id__none",
287 "custom_fields__icontains",
288 "custom_fields__id__all",
289 "custom_fields__id__none", # ??
290 "custom_fields__id__in",
291 "custom_field_query", # ??
292 "has_custom_fields",
293 "shared_by__id",
294 "shared_by__id__in",
295 }
297 @field_serializer("added", "created", "updated", "deleted_at")
298 def serialize_datetime(self, value: datetime | None) -> str | None:
299 """
300 Serialize datetime fields to ISO format.
302 Args:
303 value: The datetime value to serialize.
305 Returns:
306 The serialized datetime value.
308 """
309 return value.isoformat() if value else None
311 @field_serializer("notes")
312 def serialize_notes(self, value: list[DocumentNote]):
313 """
314 Serialize notes to a list of dictionaries.
316 Args:
317 value: The list of DocumentNote objects to serialize.
319 Returns:
320 A list of dictionaries representing the notes.
322 """
323 return [note.to_dict() for note in value] if value else []
325 @field_validator("tag_ids", mode="before")
326 @classmethod
327 def validate_tags(cls, value: Any) -> list[int]:
328 """
329 Validate and convert tag IDs to a list of integers.
331 Args:
332 value: The list of tag IDs to validate.
334 Returns:
335 A list of validated tag IDs.
337 """
338 if value is None:
339 return []
341 if isinstance(value, list):
342 return [int(tag) for tag in value]
344 if isinstance(value, int):
345 return [value]
347 raise TypeError(f"Invalid type for tags: {type(value)}")
349 @field_validator("custom_field_dicts", mode="before")
350 @classmethod
351 def validate_custom_fields(cls, value: Any) -> list[CustomFieldValues]:
352 """
353 Validate and return custom field dictionaries.
355 Args:
356 value: The list of custom field dictionaries to validate.
358 Returns:
359 A list of validated custom field dictionaries.
361 """
362 if value is None:
363 return []
365 if isinstance(value, list):
366 return value
368 raise TypeError(f"Invalid type for custom fields: {type(value)}")
370 @field_validator("content", "title", mode="before")
371 @classmethod
372 def validate_text(cls, value: Any) -> str:
373 """
374 Validate and return a text field.
376 Args:
377 value: The value of the text field to validate.
379 Returns:
380 The validated text value.
382 """
383 if value is None:
384 return ""
386 if isinstance(value, (str, int)):
387 return str(value)
389 raise TypeError(f"Invalid type for text: {type(value)}")
391 @field_validator("notes", mode="before")
392 @classmethod
393 def validate_notes(cls, value: Any) -> list[Any]:
394 """
395 Validate and return the list of notes.
397 Args:
398 value: The list of notes to validate.
400 Returns:
401 The validated list of notes.
403 """
404 if value is None:
405 return []
407 if isinstance(value, list):
408 return value
410 if isinstance(value, DocumentNote):
411 return [value]
413 raise TypeError(f"Invalid type for notes: {type(value)}")
415 @field_validator("is_shared_by_requester", mode="before")
416 @classmethod
417 def validate_is_shared_by_requester(cls, value: Any) -> bool:
418 """
419 Validate and return the is_shared_by_requester flag.
421 Args:
422 value: The flag to validate.
424 Returns:
425 The validated flag.
427 """
428 if value is None:
429 return False
431 if isinstance(value, bool):
432 return value
434 raise TypeError(f"Invalid type for is_shared_by_requester: {type(value)}")
436 @property
437 def custom_field_ids(self) -> list[int]:
438 """
439 Get the IDs of the custom fields for this document.
440 """
441 return [element.field for element in self.custom_field_dicts]
443 @property
444 def custom_field_values(self) -> list[Any]:
445 """
446 Get the values of the custom fields for this document.
447 """
448 return [element.value for element in self.custom_field_dicts]
450 @property
451 def tag_names(self) -> list[str]:
452 """
453 Get the names of the tags for this document.
454 """
455 return [tag.name for tag in self.tags if tag.name]
457 @property
458 def tags(self) -> TagQuerySet:
459 """
460 Get the tags for this document.
462 Returns:
463 List of tags associated with this document.
465 Examples:
466 >>> document = client.documents().get(pk=1)
467 >>> for tag in document.tags:
468 ... print(f'{tag.name} # {tag.id}')
469 'Tag 1 # 1'
470 'Tag 2 # 2'
471 'Tag 3 # 3'
473 >>> if 5 in document.tags:
474 ... print('Tag ID #5 is associated with this document')
476 >>> tag = client.tags().get(pk=1)
477 >>> if tag in document.tags:
478 ... print('Tag ID #1 is associated with this document')
480 >>> filtered_tags = document.tags.filter(name__icontains='example')
481 >>> for tag in filtered_tags:
482 ... print(f'{tag.name} # {tag.id}')
484 """
485 if not self.tag_ids:
486 return self._client.tags().none()
488 # Use the API's filtering capability to get only the tags with specific IDs
489 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID
490 return self._client.tags().id(self.tag_ids)
492 @tags.setter
493 def tags(self, value: "Iterable[Tag | int] | None") -> None:
494 """
495 Set the tags for this document.
497 Args:
498 value: The tags to set.
500 """
501 if value is None:
502 self.tag_ids = []
503 return
505 if isinstance(value, Iterable):
506 for tag in value:
507 if isinstance(tag, int):
508 self.tag_ids.append(tag)
509 continue
511 # Check against StandardModel to avoid circular imports
512 # If it is another type of standard model, pydantic validators will complain
513 if isinstance(tag, StandardModel):
514 self.tag_ids.append(tag.id)
515 continue
517 raise TypeError(f"Invalid type for tags: {type(tag)}")
518 return
520 raise TypeError(f"Invalid type for tags: {type(value)}")
522 @property
523 def correspondent(self) -> "Correspondent | None":
524 """
525 Get the correspondent for this document.
527 Returns:
528 The correspondent or None if not set.
530 Examples:
531 >>> document = client.documents().get(pk=1)
532 >>> document.correspondent.name
533 'Example Correspondent'
535 """
536 # Return cache
537 if self._correspondent is not None:
538 pk, value = self._correspondent
539 if pk == self.correspondent_id:
540 return value
542 # None set to retrieve
543 if not self.correspondent_id:
544 return None
546 # Retrieve it
547 correspondent = self._client.correspondents().get(self.correspondent_id)
548 self._correspondent = (self.correspondent_id, correspondent)
549 return correspondent
551 @correspondent.setter
552 def correspondent(self, value: "Correspondent | int | None") -> None:
553 """
554 Set the correspondent for this document.
556 Args:
557 value: The correspondent to set.
559 """
560 if value is None:
561 # Leave cache in place in case it changes again
562 self.correspondent_id = None
563 return
565 if isinstance(value, int):
566 # Leave cache in place in case id is the same, or id changes again
567 self.correspondent_id = value
568 return
570 # Check against StandardModel to avoid circular imports
571 # If it is another type of standard model, pydantic validators will complain
572 if isinstance(value, StandardModel):
573 self.correspondent_id = value.id
574 # Pre-populate the cache
575 self._correspondent = (value.id, value)
576 return
578 raise TypeError(f"Invalid type for correspondent: {type(value)}")
580 @property
581 def document_type(self) -> "DocumentType | None":
582 """
583 Get the document type for this document.
585 Returns:
586 The document type or None if not set.
588 Examples:
589 >>> document = client.documents().get(pk=1)
590 >>> document.document_type.name
591 'Example Document Type
593 """
594 # Return cache
595 if self._document_type is not None:
596 pk, value = self._document_type
597 if pk == self.document_type_id:
598 return value
600 # None set to retrieve
601 if not self.document_type_id:
602 return None
604 # Retrieve it
605 document_type = self._client.document_types().get(self.document_type_id)
606 self._document_type = (self.document_type_id, document_type)
607 return document_type
609 @document_type.setter
610 def document_type(self, value: "DocumentType | int | None") -> None:
611 """
612 Set the document type for this document.
614 Args:
615 value: The document type to set.
617 """
618 if value is None:
619 # Leave cache in place in case it changes again
620 self.document_type_id = None
621 return
623 if isinstance(value, int):
624 # Leave cache in place in case id is the same, or id changes again
625 self.document_type_id = value
626 return
628 # Check against StandardModel to avoid circular imports
629 # If it is another type of standard model, pydantic validators will complain
630 if isinstance(value, StandardModel):
631 self.document_type_id = value.id
632 # Pre-populate the cache
633 self._document_type = (value.id, value)
634 return
636 raise TypeError(f"Invalid type for document_type: {type(value)}")
638 @property
639 def storage_path(self) -> "StoragePath | None":
640 """
641 Get the storage path for this document.
643 Returns:
644 The storage path or None if not set.
646 Examples:
647 >>> document = client.documents().get(pk=1)
648 >>> document.storage_path.name
649 'Example Storage Path'
651 """
652 # Return cache
653 if self._storage_path is not None:
654 pk, value = self._storage_path
655 if pk == self.storage_path_id:
656 return value
658 # None set to retrieve
659 if not self.storage_path_id:
660 return None
662 # Retrieve it
663 storage_path = self._client.storage_paths().get(self.storage_path_id)
664 self._storage_path = (self.storage_path_id, storage_path)
665 return storage_path
667 @storage_path.setter
668 def storage_path(self, value: "StoragePath | int | None") -> None:
669 """
670 Set the storage path for this document.
672 Args:
673 value: The storage path to set.
675 """
676 if value is None:
677 # Leave cache in place in case it changes again
678 self.storage_path_id = None
679 return
681 if isinstance(value, int):
682 # Leave cache in place in case id is the same, or id changes again
683 self.storage_path_id = value
684 return
686 # Check against StandardModel to avoid circular imports
687 # If it is another type of standard model, pydantic validators will complain
688 if isinstance(value, StandardModel):
689 self.storage_path_id = value.id
690 # Pre-populate the cache
691 self._storage_path = (value.id, value)
692 return
694 raise TypeError(f"Invalid type for storage_path: {type(value)}")
696 @property
697 def custom_fields(self) -> "CustomFieldQuerySet":
698 """
699 Get the custom fields for this document.
701 Returns:
702 List of custom fields associated with this document.
704 """
705 if not self.custom_field_dicts:
706 return self._client.custom_fields().none()
708 # Use the API's filtering capability to get only the custom fields with specific IDs
709 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID
710 return self._client.custom_fields().id(self.custom_field_ids)
712 @custom_fields.setter
713 def custom_fields(self, value: "Iterable[CustomField | CustomFieldValues | CustomFieldTypedDict] | None") -> None:
714 """
715 Set the custom fields for this document.
717 Args:
718 value: The custom fields to set.
720 """
721 if value is None:
722 self.custom_field_dicts = []
723 return
725 if isinstance(value, Iterable):
726 new_list: list[CustomFieldValues] = []
727 for field in value:
728 if isinstance(field, CustomFieldValues):
729 new_list.append(field)
730 continue
732 # isinstance(field, CustomField)
733 # Check against StandardModel (instead of CustomField) to avoid circular imports
734 # If it is the wrong type of standard model (e.g. a User), pydantic validators will complain
735 if isinstance(field, StandardModel):
736 new_list.append(CustomFieldValues(field=field.id, value=getattr(field, "value")))
737 continue
739 if isinstance(field, dict):
740 new_list.append(CustomFieldValues(**field))
741 continue
743 raise TypeError(f"Invalid type for custom fields: {type(field)}")
745 self.custom_field_dicts = new_list
746 return
748 raise TypeError(f"Invalid type for custom fields: {type(value)}")
750 @property
751 def has_search_hit(self) -> bool:
752 return self.__search_hit__ is not None
754 @property
755 def search_hit(self) -> Optional[dict[str, Any]]:
756 return self.__search_hit__
758 def custom_field_value(self, field_id: int, default: Any = None, *, raise_errors: bool = False) -> Any:
759 """
760 Get the value of a custom field by ID.
762 Args:
763 field_id: The ID of the custom field.
764 default: The value to return if the field is not found.
765 raise_errors: Whether to raise an error if the field is not found.
767 Returns:
768 The value of the custom field or the default value if not found.
770 """
771 for field in self.custom_field_dicts:
772 if field.field == field_id:
773 return field.value
775 if raise_errors:
776 raise ValueError(f"Custom field {field_id} not found")
777 return default
779 """
780 def __getattr__(self, name: str) -> Any:
781 # Allow easy access to custom fields
782 for custom_field in self.custom_fields:
783 if custom_field['field'] == name:
784 return custom_field['value']
786 raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
787 """
789 @override
790 def update_locally(self, from_db: bool | None = None, **kwargs: Any):
791 """
792 Update the document locally with the provided data.
794 Args:
795 from_db: Whether to update from the database.
796 **kwargs: Additional data to update the document with.
798 Raises:
799 NotImplementedError: If attempting to set notes or tags to None when they are not already None.
801 """
802 # Paperless does not support setting notes or tags to None if not already None
803 if self._meta.original_data["notes"]:
804 if "notes" in kwargs and not kwargs.get("notes"):
805 # TODO: Gracefully delete the notes instead of raising an error.
806 raise NotImplementedError(
807 f"Cannot set notes to None. Notes currently: {self._meta.original_data['notes']}"
808 )
810 if self._meta.original_data["tag_ids"]:
811 if "tag_ids" in kwargs and not kwargs.get("tag_ids"):
812 # TODO: Gracefully delete the tags instead of raising an error.
813 raise NotImplementedError(
814 f"Cannot set tag_ids to None. Tags currently: {self._meta.original_data['tag_ids']}"
815 )
817 return super().update_locally(from_db, **kwargs)