Coverage for src/paperap/models/document/model.py: 84%
261 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -0400
1"""
5----------------------------------------------------------------------------
7METADATA:
9File: model.py
10 Project: paperap
11Created: 2025-03-09
12 Version: 0.0.8
13Author: Jess Mann
14Email: jess@jmann.me
15 Copyright (c) 2025 Jess Mann
17----------------------------------------------------------------------------
19LAST MODIFIED:
212025-03-09 By Jess Mann
23"""
25from __future__ import annotations
27from datetime import datetime
28from typing import TYPE_CHECKING, Annotated, Any, Iterable, Iterator, Optional, TypedDict, cast, override
30import pydantic
31from pydantic import Field, field_serializer, field_validator, model_serializer
32from typing_extensions import TypeVar
33from yarl import URL
35from paperap.const import CustomFieldTypedDict, CustomFieldValues
36from paperap.models.abstract import FilteringStrategies, StandardModel
37from paperap.models.document.queryset import DocumentQuerySet
39if TYPE_CHECKING:
40 from paperap.models.correspondent import Correspondent
41 from paperap.models.custom_field import CustomField, CustomFieldQuerySet
42 from paperap.models.document_type import DocumentType
43 from paperap.models.storage_path import StoragePath
44 from paperap.models.tag import Tag, TagQuerySet
45 from paperap.models.user import User
48class DocumentNote(StandardModel):
49 """
50 Represents a note on a Paperless-NgX document.
51 """
53 deleted_at: datetime | None = None
54 restored_at: datetime | None = None
55 transaction_id: int | None = None
56 note: str
57 created: datetime
58 document: int
59 user: int
61 class Meta(StandardModel.Meta):
62 read_only_fields = {"deleted_at", "restored_at", "transaction_id", "created"}
64 @field_serializer("deleted_at", "restored_at", "created")
65 def serialize_datetime(self, value: datetime | None):
66 """
67 Serialize datetime fields to ISO format.
69 Args:
70 value: The datetime value to serialize.
72 Returns:
73 The serialized datetime value or None if the value is None.
75 """
76 return value.isoformat() if value else None
78 def get_document(self) -> "Document":
79 """
80 Get the document associated with this note.
82 Returns:
83 The document associated with this note.
85 """
86 return self._client.documents().get(self.document)
88 def get_user(self) -> "User":
89 """
90 Get the user who created this note.
92 Returns:
93 The user who created this note.
95 """
96 return self._client.users().get(self.user)
99class Document(StandardModel):
100 """
101 Represents a Paperless-NgX document.
103 Attributes:
104 added: The timestamp when the document was added to the system.
105 archive_serial_number: The serial number of the archive.
106 archived_file_name: The name of the archived file.
107 content: The content of the document.
108 correspondent: The correspondent associated with the document.
109 created: The timestamp when the document was created.
110 created_date: The date when the document was created.
111 updated: The timestamp when the document was last updated.
112 custom_fields: Custom fields associated with the document.
113 deleted_at: The timestamp when the document was deleted.
114 document_type: The document type associated with the document.
115 is_shared_by_requester: Whether the document is shared by the requester.
116 notes: Notes associated with the document.
117 original_file_name: The original file name of the document.
118 owner: The owner of the document.
119 page_count: The number of pages in the document.
120 storage_path: The storage path of the document.
121 tags: The tags associated with the document.
122 title: The title of the document.
123 user_can_change: Whether the user can change the document.
125 Examples:
126 >>> document = client.documents().get(pk=1)
127 >>> document.title = 'Example Document'
128 >>> document.save()
129 >>> document.title
130 'Example Document'
132 """
134 added: datetime | None = None
135 archive_serial_number: int | None = None
136 archived_file_name: str | None = None
137 content: str = ""
138 is_shared_by_requester: bool = False
139 notes: "list[DocumentNote]" = Field(default_factory=list)
140 original_file_name: str | None = None
141 owner: int | None = None
142 page_count: int | None = None
143 title: str = ""
144 user_can_change: bool | None = None
146 created: datetime | None = Field(description="Creation timestamp", default=None)
147 created_date: str | None = None
148 # where did this come from? It's not in sample data?
149 updated: datetime | None = Field(description="Last update timestamp", default=None)
150 deleted_at: datetime | None = None
152 custom_field_dicts: Annotated[list[CustomFieldValues], Field(default_factory=list)]
153 correspondent_id: int | None = None
154 document_type_id: int | None = None
155 storage_path_id: int | None = None
156 tag_ids: Annotated[list[int], Field(default_factory=list)]
158 _correspondent: tuple[int, Correspondent] | None = None
159 _document_type: tuple[int, DocumentType] | None = None
160 _storage_path: tuple[int, StoragePath] | None = None
161 __search_hit__: Optional[dict[str, Any]] = None
163 class Meta(StandardModel.Meta):
164 # NOTE: Filtering appears to be disabled by paperless on page_count
165 queryset = DocumentQuerySet
166 read_only_fields = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}
167 filtering_disabled = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}
168 filtering_strategies = {FilteringStrategies.WHITELIST}
169 field_map = {
170 "tags": "tag_ids",
171 "custom_fields": "custom_field_dicts",
172 "document_type": "document_type_id",
173 "correspondent": "correspondent_id",
174 "storage_path": "storage_path_id",
175 }
176 supported_filtering_params = {
177 "id__in",
178 "id",
179 "title__istartswith",
180 "title__iendswith",
181 "title__icontains",
182 "title__iexact",
183 "content__istartswith",
184 "content__iendswith",
185 "content__icontains",
186 "content__iexact",
187 "archive_serial_number",
188 "archive_serial_number__gt",
189 "archive_serial_number__gte",
190 "archive_serial_number__lt",
191 "archive_serial_number__lte",
192 "archive_serial_number__isnull",
193 "content__contains", # maybe?
194 "correspondent__isnull",
195 "correspondent__id__in",
196 "correspondent__id",
197 "correspondent__name__istartswith",
198 "correspondent__name__iendswith",
199 "correspondent__name__icontains",
200 "correspondent__name__iexact",
201 "correspondent__slug__iexact", # maybe?
202 "created__year",
203 "created__month",
204 "created__day",
205 "created__date__gt",
206 "created__gt",
207 "created__date__lt",
208 "created__lt",
209 "added__year",
210 "added__month",
211 "added__day",
212 "added__date__gt",
213 "added__gt",
214 "added__date__lt",
215 "added__lt",
216 "modified__year",
217 "modified__month",
218 "modified__day",
219 "modified__date__gt",
220 "modified__gt",
221 "modified__date__lt",
222 "modified__lt",
223 "original_filename__istartswith",
224 "original_filename__iendswith",
225 "original_filename__icontains",
226 "original_filename__iexact",
227 "checksum__istartswith",
228 "checksum__iendswith",
229 "checksum__icontains",
230 "checksum__iexact",
231 "tags__id__in",
232 "tags__id",
233 "tags__name__istartswith",
234 "tags__name__iendswith",
235 "tags__name__icontains",
236 "tags__name__iexact",
237 "document_type__isnull",
238 "document_type__id__in",
239 "document_type__id",
240 "document_type__name__istartswith",
241 "document_type__name__iendswith",
242 "document_type__name__icontains",
243 "document_type__name__iexact",
244 "storage_path__isnull",
245 "storage_path__id__in",
246 "storage_path__id",
247 "storage_path__name__istartswith",
248 "storage_path__name__iendswith",
249 "storage_path__name__icontains",
250 "storage_path__name__iexact",
251 "owner__isnull",
252 "owner__id__in",
253 "owner__id",
254 "is_tagged",
255 "tags__id__all",
256 "tags__id__none",
257 "correspondent__id__none",
258 "document_type__id__none",
259 "storage_path__id__none",
260 "is_in_inbox",
261 "title_content",
262 "owner__id__none",
263 "custom_fields__icontains",
264 "custom_fields__id__all",
265 "custom_fields__id__none", # ??
266 "custom_fields__id__in",
267 "custom_field_query", # ??
268 "has_custom_fields",
269 "shared_by__id",
270 "shared_by__id__in",
271 }
273 @field_serializer("added", "created", "updated", "deleted_at")
274 def serialize_datetime(self, value: datetime | None) -> str | None:
275 """
276 Serialize datetime fields to ISO format.
278 Args:
279 value: The datetime value to serialize.
281 Returns:
282 The serialized datetime value.
284 """
285 return value.isoformat() if value else None
287 @field_serializer("notes")
288 def serialize_notes(self, value: list[DocumentNote]):
289 """
290 Serialize notes to a list of dictionaries.
292 Args:
293 value: The list of DocumentNote objects to serialize.
295 Returns:
296 A list of dictionaries representing the notes.
298 """
299 return [note.to_dict() for note in value] if value else []
301 @field_validator("tag_ids", mode="before")
302 @classmethod
303 def validate_tags(cls, value: Any) -> list[int]:
304 """
305 Validate and convert tag IDs to a list of integers.
307 Args:
308 value: The list of tag IDs to validate.
310 Returns:
311 A list of validated tag IDs.
313 """
314 if value is None:
315 return []
317 if isinstance(value, list):
318 return [int(tag) for tag in value]
320 if isinstance(value, int):
321 return [value]
323 raise TypeError(f"Invalid type for tags: {type(value)}")
325 @field_validator("custom_field_dicts", mode="before")
326 @classmethod
327 def validate_custom_fields(cls, value: Any) -> list[CustomFieldValues]:
328 """
329 Validate and return custom field dictionaries.
331 Args:
332 value: The list of custom field dictionaries to validate.
334 Returns:
335 A list of validated custom field dictionaries.
337 """
338 if value is None:
339 return []
341 if isinstance(value, list):
342 return value
344 raise TypeError(f"Invalid type for custom fields: {type(value)}")
346 @field_validator("content", "title", mode="before")
347 @classmethod
348 def validate_text(cls, value: Any) -> str:
349 """
350 Validate and return a text field.
352 Args:
353 value: The value of the text field to validate.
355 Returns:
356 The validated text value.
358 """
359 if value is None:
360 return ""
362 if isinstance(value, (str, int)):
363 return str(value)
365 raise TypeError(f"Invalid type for text: {type(value)}")
367 @field_validator("notes", mode="before")
368 @classmethod
369 def validate_notes(cls, value: Any) -> list[Any]:
370 """
371 Validate and return the list of notes.
373 Args:
374 value: The list of notes to validate.
376 Returns:
377 The validated list of notes.
379 """
380 if value is None:
381 return []
383 if isinstance(value, list):
384 return value
386 if isinstance(value, DocumentNote):
387 return [value]
389 raise TypeError(f"Invalid type for notes: {type(value)}")
391 @field_validator("is_shared_by_requester", mode="before")
392 @classmethod
393 def validate_is_shared_by_requester(cls, value: Any) -> bool:
394 """
395 Validate and return the is_shared_by_requester flag.
397 Args:
398 value: The flag to validate.
400 Returns:
401 The validated flag.
403 """
404 if value is None:
405 return False
407 if isinstance(value, bool):
408 return value
410 raise TypeError(f"Invalid type for is_shared_by_requester: {type(value)}")
412 @property
413 def custom_field_ids(self) -> list[int]:
414 """
415 Get the IDs of the custom fields for this document.
416 """
417 return [element.field for element in self.custom_field_dicts]
419 @property
420 def custom_field_values(self) -> list[Any]:
421 """
422 Get the values of the custom fields for this document.
423 """
424 return [element.value for element in self.custom_field_dicts]
426 @property
427 def tag_names(self) -> list[str]:
428 """
429 Get the names of the tags for this document.
430 """
431 return [tag.name for tag in self.tags if tag.name]
433 @property
434 def tags(self) -> TagQuerySet:
435 """
436 Get the tags for this document.
438 Returns:
439 List of tags associated with this document.
441 Examples:
442 >>> document = client.documents().get(pk=1)
443 >>> for tag in document.tags:
444 ... print(f'{tag.name} # {tag.id}')
445 'Tag 1 # 1'
446 'Tag 2 # 2'
447 'Tag 3 # 3'
449 >>> if 5 in document.tags:
450 ... print('Tag ID #5 is associated with this document')
452 >>> tag = client.tags().get(pk=1)
453 >>> if tag in document.tags:
454 ... print('Tag ID #1 is associated with this document')
456 >>> filtered_tags = document.tags.filter(name__icontains='example')
457 >>> for tag in filtered_tags:
458 ... print(f'{tag.name} # {tag.id}')
460 """
461 if not self.tag_ids:
462 return self._client.tags().none()
464 # Use the API's filtering capability to get only the tags with specific IDs
465 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID
466 return self._client.tags().id(self.tag_ids)
468 @tags.setter
469 def tags(self, value: "Iterable[Tag | int] | None") -> None:
470 """
471 Set the tags for this document.
473 Args:
474 value: The tags to set.
476 """
477 if value is None:
478 self.tag_ids = []
479 return
481 if isinstance(value, Iterable):
482 # Reset tag_ids to ensure we only have the new values
483 self.tag_ids = []
484 for tag in value:
485 if isinstance(tag, int):
486 self.tag_ids.append(tag)
487 continue
489 # Check against StandardModel to avoid circular imports
490 # If it is another type of standard model, pydantic validators will complain
491 if isinstance(tag, StandardModel):
492 self.tag_ids.append(tag.id)
493 continue
495 raise TypeError(f"Invalid type for tags: {type(tag)}")
496 return
498 raise TypeError(f"Invalid type for tags: {type(value)}")
500 @property
501 def correspondent(self) -> "Correspondent | None":
502 """
503 Get the correspondent for this document.
505 Returns:
506 The correspondent or None if not set.
508 Examples:
509 >>> document = client.documents().get(pk=1)
510 >>> document.correspondent.name
511 'Example Correspondent'
513 """
514 # Return cache
515 if self._correspondent is not None:
516 pk, value = self._correspondent
517 if pk == self.correspondent_id:
518 return value
520 # None set to retrieve
521 if not self.correspondent_id:
522 return None
524 # Retrieve it
525 correspondent = self._client.correspondents().get(self.correspondent_id)
526 self._correspondent = (self.correspondent_id, correspondent)
527 return correspondent
529 @correspondent.setter
530 def correspondent(self, value: "Correspondent | int | None") -> None:
531 """
532 Set the correspondent for this document.
534 Args:
535 value: The correspondent to set.
537 """
538 if value is None:
539 # Leave cache in place in case it changes again
540 self.correspondent_id = None
541 return
543 if isinstance(value, int):
544 # Leave cache in place in case id is the same, or id changes again
545 self.correspondent_id = value
546 return
548 # Check against StandardModel to avoid circular imports
549 # If it is another type of standard model, pydantic validators will complain
550 if isinstance(value, StandardModel):
551 self.correspondent_id = value.id
552 # Pre-populate the cache
553 self._correspondent = (value.id, value)
554 return
556 raise TypeError(f"Invalid type for correspondent: {type(value)}")
558 @property
559 def document_type(self) -> "DocumentType | None":
560 """
561 Get the document type for this document.
563 Returns:
564 The document type or None if not set.
566 Examples:
567 >>> document = client.documents().get(pk=1)
568 >>> document.document_type.name
569 'Example Document Type
571 """
572 # Return cache
573 if self._document_type is not None:
574 pk, value = self._document_type
575 if pk == self.document_type_id:
576 return value
578 # None set to retrieve
579 if not self.document_type_id:
580 return None
582 # Retrieve it
583 document_type = self._client.document_types().get(self.document_type_id)
584 self._document_type = (self.document_type_id, document_type)
585 return document_type
587 @document_type.setter
588 def document_type(self, value: "DocumentType | int | None") -> None:
589 """
590 Set the document type for this document.
592 Args:
593 value: The document type to set.
595 """
596 if value is None:
597 # Leave cache in place in case it changes again
598 self.document_type_id = None
599 return
601 if isinstance(value, int):
602 # Leave cache in place in case id is the same, or id changes again
603 self.document_type_id = value
604 return
606 # Check against StandardModel to avoid circular imports
607 # If it is another type of standard model, pydantic validators will complain
608 if isinstance(value, StandardModel):
609 self.document_type_id = value.id
610 # Pre-populate the cache
611 self._document_type = (value.id, value)
612 return
614 raise TypeError(f"Invalid type for document_type: {type(value)}")
616 @property
617 def storage_path(self) -> "StoragePath | None":
618 """
619 Get the storage path for this document.
621 Returns:
622 The storage path or None if not set.
624 Examples:
625 >>> document = client.documents().get(pk=1)
626 >>> document.storage_path.name
627 'Example Storage Path'
629 """
630 # Return cache
631 if self._storage_path is not None:
632 pk, value = self._storage_path
633 if pk == self.storage_path_id:
634 return value
636 # None set to retrieve
637 if not self.storage_path_id:
638 return None
640 # Retrieve it
641 storage_path = self._client.storage_paths().get(self.storage_path_id)
642 self._storage_path = (self.storage_path_id, storage_path)
643 return storage_path
645 @storage_path.setter
646 def storage_path(self, value: "StoragePath | int | None") -> None:
647 """
648 Set the storage path for this document.
650 Args:
651 value: The storage path to set.
653 """
654 if value is None:
655 # Leave cache in place in case it changes again
656 self.storage_path_id = None
657 return
659 if isinstance(value, int):
660 # Leave cache in place in case id is the same, or id changes again
661 self.storage_path_id = value
662 return
664 # Check against StandardModel to avoid circular imports
665 # If it is another type of standard model, pydantic validators will complain
666 if isinstance(value, StandardModel):
667 self.storage_path_id = value.id
668 # Pre-populate the cache
669 self._storage_path = (value.id, value)
670 return
672 raise TypeError(f"Invalid type for storage_path: {type(value)}")
674 @property
675 def custom_fields(self) -> "CustomFieldQuerySet":
676 """
677 Get the custom fields for this document.
679 Returns:
680 List of custom fields associated with this document.
682 """
683 if not self.custom_field_dicts:
684 return self._client.custom_fields().none()
686 # Use the API's filtering capability to get only the custom fields with specific IDs
687 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID
688 return self._client.custom_fields().id(self.custom_field_ids)
690 @custom_fields.setter
691 def custom_fields(self, value: "Iterable[CustomField | CustomFieldValues | CustomFieldTypedDict] | None") -> None:
692 """
693 Set the custom fields for this document.
695 Args:
696 value: The custom fields to set.
698 """
699 if value is None:
700 self.custom_field_dicts = []
701 return
703 if isinstance(value, Iterable):
704 new_list: list[CustomFieldValues] = []
705 for field in value:
706 if isinstance(field, CustomFieldValues):
707 new_list.append(field)
708 continue
710 # isinstance(field, CustomField)
711 # Check against StandardModel (instead of CustomField) to avoid circular imports
712 # If it is the wrong type of standard model (e.g. a User), pydantic validators will complain
713 if isinstance(field, StandardModel):
714 new_list.append(CustomFieldValues(field=field.id, value=getattr(field, "value")))
715 continue
717 if isinstance(field, dict):
718 new_list.append(CustomFieldValues(**field))
719 continue
721 raise TypeError(f"Invalid type for custom fields: {type(field)}")
723 self.custom_field_dicts = new_list
724 return
726 raise TypeError(f"Invalid type for custom fields: {type(value)}")
728 @property
729 def has_search_hit(self) -> bool:
730 return self.__search_hit__ is not None
732 @property
733 def search_hit(self) -> Optional[dict[str, Any]]:
734 return self.__search_hit__
736 def custom_field_value(self, field_id: int, default: Any = None, *, raise_errors: bool = False) -> Any:
737 """
738 Get the value of a custom field by ID.
740 Args:
741 field_id: The ID of the custom field.
742 default: The value to return if the field is not found.
743 raise_errors: Whether to raise an error if the field is not found.
745 Returns:
746 The value of the custom field or the default value if not found.
748 """
749 for field in self.custom_field_dicts:
750 if field.field == field_id:
751 return field.value
753 if raise_errors:
754 raise ValueError(f"Custom field {field_id} not found")
755 return default
757 """
758 def __getattr__(self, name: str) -> Any:
759 # Allow easy access to custom fields
760 for custom_field in self.custom_fields:
761 if custom_field['field'] == name:
762 return custom_field['value']
764 raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
765 """
767 @override
768 def update_locally(self, from_db: bool | None = None, **kwargs: Any):
769 """
770 Update the document locally with the provided data.
772 Args:
773 from_db: Whether to update from the database.
774 **kwargs: Additional data to update the document with.
776 Raises:
777 NotImplementedError: If attempting to set notes or tags to None when they are not already None.
779 """
780 # Paperless does not support setting notes or tags to None if not already None
781 if self._original_data["notes"]:
782 if "notes" in kwargs and not kwargs.get("notes"):
783 # TODO: Gracefully delete the notes instead of raising an error.
784 raise NotImplementedError(f"Cannot set notes to None. Notes currently: {self._original_data['notes']}")
786 if self._original_data["tag_ids"]:
787 if "tag_ids" in kwargs and not kwargs.get("tag_ids"):
788 # TODO: Gracefully delete the tags instead of raising an error.
789 raise NotImplementedError(
790 f"Cannot set tag_ids to None. Tags currently: {self._original_data['tag_ids']}"
791 )
793 return super().update_locally(from_db=from_db, **kwargs)