Coverage for src/paperap/models/document/model.py: 76%
303 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -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
27import logging
28from datetime import datetime
29from typing import TYPE_CHECKING, Annotated, Any, Iterable, Iterator, Optional, TypedDict, cast, override
31import pydantic
32from pydantic import Field, field_serializer, field_validator, model_serializer
33from typing_extensions import TypeVar
35from paperap.const import CustomFieldTypedDict, CustomFieldValues
36from paperap.exceptions import ResourceNotFoundError
37from paperap.models.abstract import FilteringStrategies, StandardModel
38from paperap.models.document.queryset import DocumentQuerySet
40if TYPE_CHECKING:
41 from paperap.models.correspondent import Correspondent
42 from paperap.models.custom_field import CustomField, CustomFieldQuerySet
43 from paperap.models.document.download import DownloadedDocument
44 from paperap.models.document.metadata import DocumentMetadata
45 from paperap.models.document.suggestions import DocumentSuggestions
46 from paperap.models.document_type import DocumentType
47 from paperap.models.storage_path import StoragePath
48 from paperap.models.tag import Tag, TagQuerySet
49 from paperap.models.user import User
51logger = logging.getLogger(__name__)
54class DocumentNote(StandardModel):
55 """
56 Represents a note on a Paperless-NgX document.
57 """
59 deleted_at: datetime | None = None
60 restored_at: datetime | None = None
61 transaction_id: int | None = None
62 note: str
63 created: datetime
64 document: int
65 user: int
67 class Meta(StandardModel.Meta):
68 read_only_fields = {"deleted_at", "restored_at", "transaction_id", "created"}
70 @field_serializer("deleted_at", "restored_at", "created")
71 def serialize_datetime(self, value: datetime | None) -> str | None:
72 """
73 Serialize datetime fields to ISO format.
75 Args:
76 value: The datetime value to serialize.
78 Returns:
79 The serialized datetime value or None if the value is None.
81 """
82 return value.isoformat() if value else None
84 def get_document(self) -> "Document":
85 """
86 Get the document associated with this note.
88 Returns:
89 The document associated with this note.
91 """
92 return self._client.documents().get(self.document)
94 def get_user(self) -> "User":
95 """
96 Get the user who created this note.
98 Returns:
99 The user who created this note.
101 """
102 return self._client.users().get(self.user)
105class Document(StandardModel):
106 """
107 Represents a Paperless-NgX document.
109 Attributes:
110 added: The timestamp when the document was added to the system.
111 archive_serial_number: The serial number of the archive.
112 archived_file_name: The name of the archived file.
113 content: The content of the document.
114 correspondent: The correspondent associated with the document.
115 created: The timestamp when the document was created.
116 created_date: The date when the document was created.
117 updated: The timestamp when the document was last updated.
118 custom_fields: Custom fields associated with the document.
119 deleted_at: The timestamp when the document was deleted.
120 document_type: The document type associated with the document.
121 is_shared_by_requester: Whether the document is shared by the requester.
122 notes: Notes associated with the document.
123 original_file_name: The original file name of the document.
124 owner: The owner of the document.
125 page_count: The number of pages in the document.
126 storage_path: The storage path of the document.
127 tags: The tags associated with the document.
128 title: The title of the document.
129 user_can_change: Whether the user can change the document.
130 checksum: The checksum of the document.
132 Examples:
133 >>> document = client.documents().get(pk=1)
134 >>> document.title = 'Example Document'
135 >>> document.save()
136 >>> document.title
137 'Example Document'
139 # Get document metadata
140 >>> metadata = document.get_metadata()
141 >>> print(metadata.original_mime_type)
143 # Download document
144 >>> download = document.download()
145 >>> with open(download.disposition_filename, 'wb') as f:
146 ... f.write(download.content)
148 # Get document suggestions
149 >>> suggestions = document.get_suggestions()
150 >>> print(suggestions.tags)
152 """
154 added: datetime | None = None
155 archive_serial_number: int | None = None
156 archived_file_name: str | None = None
157 content: str = ""
158 is_shared_by_requester: bool = False
159 notes: "list[DocumentNote]" = Field(default_factory=list)
160 original_file_name: str | None = None
161 owner: int | None = None
162 page_count: int | None = None
163 title: str = ""
164 user_can_change: bool | None = None
165 checksum: str | None = None
167 created: datetime | None = Field(description="Creation timestamp", default=None)
168 created_date: str | None = None
169 # where did this come from? It's not in sample data?
170 updated: datetime | None = Field(description="Last update timestamp", default=None)
171 deleted_at: datetime | None = None
173 custom_field_dicts: Annotated[list[CustomFieldValues], Field(default_factory=list)]
174 correspondent_id: int | None = None
175 document_type_id: int | None = None
176 storage_path_id: int | None = None
177 tag_ids: Annotated[list[int], Field(default_factory=list)]
179 _correspondent: tuple[int, Correspondent] | None = None
180 _document_type: tuple[int, DocumentType] | None = None
181 _storage_path: tuple[int, StoragePath] | None = None
182 __search_hit__: Optional[dict[str, Any]] = None
184 class Meta(StandardModel.Meta):
185 # NOTE: Filtering appears to be disabled by paperless on page_count
186 read_only_fields = {"page_count", "deleted_at", "updated", "is_shared_by_requester", "archived_file_name"}
187 filtering_disabled = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}
188 filtering_strategies = {FilteringStrategies.WHITELIST}
189 field_map = {
190 "tags": "tag_ids",
191 "custom_fields": "custom_field_dicts",
192 "document_type": "document_type_id",
193 "correspondent": "correspondent_id",
194 "storage_path": "storage_path_id",
195 }
196 supported_filtering_params = {
197 "id__in",
198 "id",
199 "title__istartswith",
200 "title__iendswith",
201 "title__icontains",
202 "title__iexact",
203 "content__istartswith",
204 "content__iendswith",
205 "content__icontains",
206 "content__iexact",
207 "archive_serial_number",
208 "archive_serial_number__gt",
209 "archive_serial_number__gte",
210 "archive_serial_number__lt",
211 "archive_serial_number__lte",
212 "archive_serial_number__isnull",
213 "content__contains", # maybe?
214 "correspondent__isnull",
215 "correspondent__id__in",
216 "correspondent__id",
217 "correspondent__name__istartswith",
218 "correspondent__name__iendswith",
219 "correspondent__name__icontains",
220 "correspondent__name__iexact",
221 "correspondent__slug__iexact", # maybe?
222 "created__year",
223 "created__month",
224 "created__day",
225 "created__date__gt",
226 "created__gt",
227 "created__date__lt",
228 "created__lt",
229 "added__year",
230 "added__month",
231 "added__day",
232 "added__date__gt",
233 "added__gt",
234 "added__date__lt",
235 "added__lt",
236 "modified__year",
237 "modified__month",
238 "modified__day",
239 "modified__date__gt",
240 "modified__gt",
241 "modified__date__lt",
242 "modified__lt",
243 "original_filename__istartswith",
244 "original_filename__iendswith",
245 "original_filename__icontains",
246 "original_filename__iexact",
247 "checksum__istartswith",
248 "checksum__iendswith",
249 "checksum__icontains",
250 "checksum__iexact",
251 "tags__id__in",
252 "tags__id",
253 "tags__name__istartswith",
254 "tags__name__iendswith",
255 "tags__name__icontains",
256 "tags__name__iexact",
257 "document_type__isnull",
258 "document_type__id__in",
259 "document_type__id",
260 "document_type__name__istartswith",
261 "document_type__name__iendswith",
262 "document_type__name__icontains",
263 "document_type__name__iexact",
264 "storage_path__isnull",
265 "storage_path__id__in",
266 "storage_path__id",
267 "storage_path__name__istartswith",
268 "storage_path__name__iendswith",
269 "storage_path__name__icontains",
270 "storage_path__name__iexact",
271 "owner__isnull",
272 "owner__id__in",
273 "owner__id",
274 "is_tagged",
275 "tags__id__all",
276 "tags__id__none",
277 "correspondent__id__none",
278 "document_type__id__none",
279 "storage_path__id__none",
280 "is_in_inbox",
281 "title_content",
282 "owner__id__none",
283 "custom_fields__icontains",
284 "custom_fields__id__all",
285 "custom_fields__id__none", # ??
286 "custom_fields__id__in",
287 "custom_field_query", # ??
288 "has_custom_fields",
289 "shared_by__id",
290 "shared_by__id__in",
291 }
293 @field_serializer("added", "created", "updated", "deleted_at")
294 def serialize_datetime(self, value: datetime | None) -> str | None:
295 """
296 Serialize datetime fields to ISO format.
298 Args:
299 value: The datetime value to serialize.
301 Returns:
302 The serialized datetime value.
304 """
305 return value.isoformat() if value else None
307 @field_serializer("notes")
308 def serialize_notes(self, value: list[DocumentNote]) -> list[dict[str, Any]]:
309 """
310 Serialize notes to a list of dictionaries.
312 Args:
313 value: The list of DocumentNote objects to serialize.
315 Returns:
316 A list of dictionaries representing the notes.
318 """
319 return [note.to_dict() for note in value] if value else []
321 @field_validator("tag_ids", mode="before")
322 @classmethod
323 def validate_tags(cls, value: Any) -> list[int]:
324 """
325 Validate and convert tag IDs to a list of integers.
327 Args:
328 value: The list of tag IDs to validate.
330 Returns:
331 A list of validated tag IDs.
333 """
334 if value is None:
335 return []
337 if isinstance(value, list):
338 return [int(tag) for tag in value]
340 if isinstance(value, int):
341 return [value]
343 raise TypeError(f"Invalid type for tags: {type(value)}")
345 @field_validator("custom_field_dicts", mode="before")
346 @classmethod
347 def validate_custom_fields(cls, value: Any) -> list[CustomFieldValues]:
348 """
349 Validate and return custom field dictionaries.
351 Args:
352 value: The list of custom field dictionaries to validate.
354 Returns:
355 A list of validated custom field dictionaries.
357 """
358 if value is None:
359 return []
361 if isinstance(value, list):
362 return value
364 raise TypeError(f"Invalid type for custom fields: {type(value)}")
366 @field_validator("content", "title", mode="before")
367 @classmethod
368 def validate_text(cls, value: Any) -> str:
369 """
370 Validate and return a text field.
372 Args:
373 value: The value of the text field to validate.
375 Returns:
376 The validated text value.
378 """
379 if value is None:
380 return ""
382 if isinstance(value, (str, int)):
383 return str(value)
385 raise TypeError(f"Invalid type for text: {type(value)}")
387 @field_validator("notes", mode="before")
388 @classmethod
389 def validate_notes(cls, value: Any) -> list[Any]:
390 """
391 Validate and return the list of notes.
393 Args:
394 value: The list of notes to validate.
396 Returns:
397 The validated list of notes.
399 """
400 if value is None:
401 return []
403 if isinstance(value, list):
404 return value
406 if isinstance(value, DocumentNote):
407 return [value]
409 raise TypeError(f"Invalid type for notes: {type(value)}")
411 @field_validator("is_shared_by_requester", mode="before")
412 @classmethod
413 def validate_is_shared_by_requester(cls, value: Any) -> bool:
414 """
415 Validate and return the is_shared_by_requester flag.
417 Args:
418 value: The flag to validate.
420 Returns:
421 The validated flag.
423 """
424 if value is None:
425 return False
427 if isinstance(value, bool):
428 return value
430 raise TypeError(f"Invalid type for is_shared_by_requester: {type(value)}")
432 @property
433 def custom_field_ids(self) -> list[int]:
434 """
435 Get the IDs of the custom fields for this document.
436 """
437 return [element.field for element in self.custom_field_dicts]
439 @property
440 def custom_field_values(self) -> list[Any]:
441 """
442 Get the values of the custom fields for this document.
443 """
444 return [element.value for element in self.custom_field_dicts]
446 @property
447 def tag_names(self) -> list[str]:
448 """
449 Get the names of the tags for this document.
450 """
451 return [tag.name for tag in self.tags if tag.name]
453 @property
454 def tags(self) -> TagQuerySet:
455 """
456 Get the tags for this document.
458 Returns:
459 List of tags associated with this document.
461 Examples:
462 >>> document = client.documents().get(pk=1)
463 >>> for tag in document.tags:
464 ... print(f'{tag.name} # {tag.id}')
465 'Tag 1 # 1'
466 'Tag 2 # 2'
467 'Tag 3 # 3'
469 >>> if 5 in document.tags:
470 ... print('Tag ID #5 is associated with this document')
472 >>> tag = client.tags().get(pk=1)
473 >>> if tag in document.tags:
474 ... print('Tag ID #1 is associated with this document')
476 >>> filtered_tags = document.tags.filter(name__icontains='example')
477 >>> for tag in filtered_tags:
478 ... print(f'{tag.name} # {tag.id}')
480 """
481 if not self.tag_ids:
482 return self._client.tags().none()
484 # Use the API's filtering capability to get only the tags with specific IDs
485 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID
486 return self._client.tags().id(self.tag_ids)
488 @tags.setter
489 def tags(self, value: "Iterable[Tag | int] | None") -> None:
490 """
491 Set the tags for this document.
493 Args:
494 value: The tags to set.
496 """
497 if value is None:
498 self.tag_ids = []
499 return
501 if isinstance(value, Iterable):
502 # Reset tag_ids to ensure we only have the new values
503 self.tag_ids = []
504 for tag in value:
505 if isinstance(tag, int):
506 self.tag_ids.append(tag)
507 continue
509 # Check against StandardModel to avoid circular imports
510 # If it is another type of standard model, pydantic validators will complain
511 if isinstance(tag, StandardModel):
512 self.tag_ids.append(tag.id)
513 continue
515 raise TypeError(f"Invalid type for tags: {type(tag)}")
516 return
518 raise TypeError(f"Invalid type for tags: {type(value)}")
520 @property
521 def correspondent(self) -> "Correspondent | None":
522 """
523 Get the correspondent for this document.
525 Returns:
526 The correspondent or None if not set.
528 Examples:
529 >>> document = client.documents().get(pk=1)
530 >>> document.correspondent.name
531 'Example Correspondent'
533 """
534 # Return cache
535 if self._correspondent is not None:
536 pk, value = self._correspondent
537 if pk == self.correspondent_id:
538 return value
540 # None set to retrieve
541 if not self.correspondent_id:
542 return None
544 # Retrieve it
545 correspondent = self._client.correspondents().get(self.correspondent_id)
546 self._correspondent = (self.correspondent_id, correspondent)
547 return correspondent
549 @correspondent.setter
550 def correspondent(self, value: "Correspondent | int | None") -> None:
551 """
552 Set the correspondent for this document.
554 Args:
555 value: The correspondent to set.
557 """
558 if value is None:
559 # Leave cache in place in case it changes again
560 self.correspondent_id = None
561 return
563 if isinstance(value, int):
564 # Leave cache in place in case id is the same, or id changes again
565 self.correspondent_id = value
566 return
568 # Check against StandardModel to avoid circular imports
569 # If it is another type of standard model, pydantic validators will complain
570 if isinstance(value, StandardModel):
571 self.correspondent_id = value.id
572 # Pre-populate the cache
573 self._correspondent = (value.id, value)
574 return
576 raise TypeError(f"Invalid type for correspondent: {type(value)}")
578 @property
579 def document_type(self) -> "DocumentType | None":
580 """
581 Get the document type for this document.
583 Returns:
584 The document type or None if not set.
586 Examples:
587 >>> document = client.documents().get(pk=1)
588 >>> document.document_type.name
589 'Example Document Type
591 """
592 # Return cache
593 if self._document_type is not None:
594 pk, value = self._document_type
595 if pk == self.document_type_id:
596 return value
598 # None set to retrieve
599 if not self.document_type_id:
600 return None
602 # Retrieve it
603 document_type = self._client.document_types().get(self.document_type_id)
604 self._document_type = (self.document_type_id, document_type)
605 return document_type
607 @document_type.setter
608 def document_type(self, value: "DocumentType | int | None") -> None:
609 """
610 Set the document type for this document.
612 Args:
613 value: The document type to set.
615 """
616 if value is None:
617 # Leave cache in place in case it changes again
618 self.document_type_id = None
619 return
621 if isinstance(value, int):
622 # Leave cache in place in case id is the same, or id changes again
623 self.document_type_id = value
624 return
626 # Check against StandardModel to avoid circular imports
627 # If it is another type of standard model, pydantic validators will complain
628 if isinstance(value, StandardModel):
629 self.document_type_id = value.id
630 # Pre-populate the cache
631 self._document_type = (value.id, value)
632 return
634 raise TypeError(f"Invalid type for document_type: {type(value)}")
636 @property
637 def storage_path(self) -> "StoragePath | None":
638 """
639 Get the storage path for this document.
641 Returns:
642 The storage path or None if not set.
644 Examples:
645 >>> document = client.documents().get(pk=1)
646 >>> document.storage_path.name
647 'Example Storage Path'
649 """
650 # Return cache
651 if self._storage_path is not None:
652 pk, value = self._storage_path
653 if pk == self.storage_path_id:
654 return value
656 # None set to retrieve
657 if not self.storage_path_id:
658 return None
660 # Retrieve it
661 storage_path = self._client.storage_paths().get(self.storage_path_id)
662 self._storage_path = (self.storage_path_id, storage_path)
663 return storage_path
665 @storage_path.setter
666 def storage_path(self, value: "StoragePath | int | None") -> None:
667 """
668 Set the storage path for this document.
670 Args:
671 value: The storage path to set.
673 """
674 if value is None:
675 # Leave cache in place in case it changes again
676 self.storage_path_id = None
677 return
679 if isinstance(value, int):
680 # Leave cache in place in case id is the same, or id changes again
681 self.storage_path_id = value
682 return
684 # Check against StandardModel to avoid circular imports
685 # If it is another type of standard model, pydantic validators will complain
686 if isinstance(value, StandardModel):
687 self.storage_path_id = value.id
688 # Pre-populate the cache
689 self._storage_path = (value.id, value)
690 return
692 raise TypeError(f"Invalid type for storage_path: {type(value)}")
694 @property
695 def custom_fields(self) -> "CustomFieldQuerySet":
696 """
697 Get the custom fields for this document.
699 Returns:
700 List of custom fields associated with this document.
702 """
703 if not self.custom_field_dicts:
704 return self._client.custom_fields().none()
706 # Use the API's filtering capability to get only the custom fields with specific IDs
707 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID
708 return self._client.custom_fields().id(self.custom_field_ids)
710 @custom_fields.setter
711 def custom_fields(self, value: "Iterable[CustomField | CustomFieldValues | CustomFieldTypedDict] | None") -> None:
712 """
713 Set the custom fields for this document.
715 Args:
716 value: The custom fields to set.
718 """
719 if value is None:
720 self.custom_field_dicts = []
721 return
723 if isinstance(value, Iterable):
724 new_list: list[CustomFieldValues] = []
725 for field in value:
726 if isinstance(field, CustomFieldValues):
727 new_list.append(field)
728 continue
730 # isinstance(field, CustomField)
731 # Check against StandardModel (instead of CustomField) to avoid circular imports
732 # If it is the wrong type of standard model (e.g. a User), pydantic validators will complain
733 if isinstance(field, StandardModel):
734 new_list.append(CustomFieldValues(field=field.id, value=getattr(field, "value")))
735 continue
737 if isinstance(field, dict):
738 new_list.append(CustomFieldValues(**field))
739 continue
741 raise TypeError(f"Invalid type for custom fields: {type(field)}")
743 self.custom_field_dicts = new_list
744 return
746 raise TypeError(f"Invalid type for custom fields: {type(value)}")
748 @property
749 def has_search_hit(self) -> bool:
750 return self.__search_hit__ is not None
752 @property
753 def search_hit(self) -> Optional[dict[str, Any]]:
754 return self.__search_hit__
756 def custom_field_value(self, field_id: int, default: Any = None, *, raise_errors: bool = False) -> Any:
757 """
758 Get the value of a custom field by ID.
760 Args:
761 field_id: The ID of the custom field.
762 default: The value to return if the field is not found.
763 raise_errors: Whether to raise an error if the field is not found.
765 Returns:
766 The value of the custom field or the default value if not found.
768 """
769 for field in self.custom_field_dicts:
770 if field.field == field_id:
771 return field.value
773 if raise_errors:
774 raise ValueError(f"Custom field {field_id} not found")
775 return default
777 """
778 def __getattr__(self, name: str) -> Any:
779 # Allow easy access to custom fields
780 for custom_field in self.custom_fields:
781 if custom_field['field'] == name:
782 return custom_field['value']
784 raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
785 """
787 def add_tag(self, tag: "Tag | int | str") -> None:
788 """
789 Add a tag to the document.
791 Args:
792 tag: The tag to add.
794 """
795 if isinstance(tag, int):
796 self.tag_ids.append(tag)
797 return
799 if isinstance(tag, StandardModel):
800 self.tag_ids.append(tag.id)
801 return
803 if isinstance(tag, str):
804 if not (instance := self._client.tags().filter(name=tag).first()):
805 raise ResourceNotFoundError(f"Tag '{tag}' not found")
806 self.tag_ids.append(instance.id)
807 return
809 raise TypeError(f"Invalid type for tag: {type(tag)}")
811 def remove_tag(self, tag: "Tag | int | str") -> None:
812 """
813 Remove a tag from the document.
815 Args:
816 tag: The tag to remove.
818 """
819 if isinstance(tag, int):
820 # TODO: Handle removal with consideration of "tags can't be empty" rule in paperless
821 self.tag_ids.remove(tag)
822 return
824 if isinstance(tag, StandardModel):
825 # TODO: Handle removal with consideration of "tags can't be empty" rule in paperless
826 self.tag_ids.remove(tag.id)
827 return
829 if isinstance(tag, str):
830 # TODO: Handle removal with consideration of "tags can't be empty" rule in paperless
831 if not (instance := self._client.tags().filter(name=tag).first()):
832 raise ResourceNotFoundError(f"Tag '{tag}' not found")
833 self.tag_ids.remove(instance.id)
834 return
836 raise TypeError(f"Invalid type for tag: {type(tag)}")
838 def get_metadata(self) -> "DocumentMetadata":
839 """
840 Get the metadata for this document.
842 Returns:
843 The document metadata.
845 Examples:
846 >>> metadata = document.get_metadata()
847 >>> print(metadata.original_mime_type)
849 """
850 raise NotImplementedError()
852 def download(self, original: bool = False) -> "DownloadedDocument":
853 """
854 Download the document file.
856 Args:
857 original: Whether to download the original file instead of the archived version.
859 Returns:
860 The downloaded document.
862 Examples:
863 >>> download = document.download()
864 >>> with open(download.disposition_filename, 'wb') as f:
865 ... f.write(download.content)
867 """
868 raise NotImplementedError()
870 def preview(self, original: bool = False) -> "DownloadedDocument":
871 """
872 Get a preview of the document.
874 Args:
875 original: Whether to preview the original file instead of the archived version.
877 Returns:
878 The document preview.
880 """
881 raise NotImplementedError()
883 def thumbnail(self, original: bool = False) -> "DownloadedDocument":
884 """
885 Get the document thumbnail.
887 Args:
888 original: Whether to get the thumbnail of the original file.
890 Returns:
891 The document thumbnail.
893 """
894 raise NotImplementedError()
896 def get_suggestions(self) -> "DocumentSuggestions":
897 """
898 Get suggestions for this document.
900 Returns:
901 The document suggestions.
903 Examples:
904 >>> suggestions = document.get_suggestions()
905 >>> print(suggestions.tags)
907 """
908 raise NotImplementedError()
910 def append_content(self, value: str):
911 """
912 Append content to the document.
914 Args:
915 value: The content to append.
917 """
918 self.content = f"{self.content}\n{value}"
920 @override
921 def update_locally(self, from_db: bool | None = None, **kwargs: Any):
922 """
923 Update the document locally with the provided data.
925 Args:
926 from_db: Whether to update from the database.
927 **kwargs: Additional data to update the document with.
929 Raises:
930 NotImplementedError: If attempting to set notes or tags to None when they are not already None.
932 """
933 if not from_db:
934 # Paperless does not support setting notes or tags to None if not already None
935 fields = ["notes", "tag_ids"]
936 for field in fields:
937 original = self._original_data[field]
938 if original and field in kwargs and not kwargs.get(field):
939 raise NotImplementedError(f"Cannot set {field} to None. {field} currently: {original}")
941 # Handle aliases
942 if self._original_data["tag_ids"] and "tags" in kwargs and not kwargs.get("tags"):
943 raise NotImplementedError(f"Cannot set tags to None. Tags currently: {self._original_data['tag_ids']}")
945 return super().update_locally(from_db=from_db, **kwargs)