Coverage for src/paperap/models/document/model.py: 76%

307 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-22 16:02 -0400

1""" 

2 

3 

4 

5---------------------------------------------------------------------------- 

6 

7METADATA: 

8 

9File: model.py 

10 Project: paperap 

11Created: 2025-03-09 

12 Version: 0.0.10 

13Author: Jess Mann 

14Email: jess@jmann.me 

15 Copyright (c) 2025 Jess Mann 

16 

17---------------------------------------------------------------------------- 

18 

19LAST MODIFIED: 

20 

212025-03-09 By Jess Mann 

22 

23""" 

24 

25from __future__ import annotations 

26 

27import logging 

28from datetime import datetime 

29from typing import TYPE_CHECKING, Annotated, Any, Iterable, Iterator, TypedDict, cast, override 

30 

31import pydantic 

32from pydantic import Field, field_serializer, field_validator, model_serializer 

33from typing_extensions import TypeVar 

34 

35from paperap.const import ( 

36 CustomFieldTypedDict, 

37 CustomFieldTypes, 

38 CustomFieldValues, 

39 DocumentStorageType, 

40 FilteringStrategies, 

41) 

42from paperap.exceptions import ResourceNotFoundError 

43from paperap.models.abstract.model import StandardModel 

44from paperap.models.document.meta import SUPPORTED_FILTERING_PARAMS 

45from paperap.models.document.queryset import DocumentQuerySet 

46 

47if TYPE_CHECKING: 

48 from paperap.models.correspondent.model import Correspondent 

49 from paperap.models.custom_field import CustomField, CustomFieldQuerySet 

50 from paperap.models.document.download.model import DownloadedDocument 

51 from paperap.models.document.metadata.model import DocumentMetadata 

52 from paperap.models.document.suggestions.model import DocumentSuggestions 

53 from paperap.models.document_type.model import DocumentType 

54 from paperap.models.storage_path.model import StoragePath 

55 from paperap.models.tag import Tag, TagQuerySet 

56 from paperap.models.user.model import User 

57 

58logger = logging.getLogger(__name__) 

59 

60 

61class DocumentNote(StandardModel): 

62 """ 

63 Represents a note on a Paperless-NgX document. 

64 """ 

65 

66 deleted_at: datetime | None = None 

67 restored_at: datetime | None = None 

68 transaction_id: int | None = None 

69 note: str 

70 created: datetime 

71 document: int 

72 user: int 

73 

74 class Meta(StandardModel.Meta): 

75 read_only_fields = {"deleted_at", "restored_at", "transaction_id", "created"} 

76 

77 @field_serializer("deleted_at", "restored_at", "created") 

78 def serialize_datetime(self, value: datetime | None) -> str | None: 

79 """ 

80 Serialize datetime fields to ISO format. 

81 

82 Args: 

83 value: The datetime value to serialize. 

84 

85 Returns: 

86 The serialized datetime value or None if the value is None. 

87 

88 """ 

89 return value.isoformat() if value else None 

90 

91 def get_document(self) -> "Document": 

92 """ 

93 Get the document associated with this note. 

94 

95 Returns: 

96 The document associated with this note. 

97 

98 """ 

99 return self._client.documents().get(self.document) 

100 

101 def get_user(self) -> "User": 

102 """ 

103 Get the user who created this note. 

104 

105 Returns: 

106 The user who created this note. 

107 

108 """ 

109 return self._client.users().get(self.user) 

110 

111 

112class Document(StandardModel): 

113 """ 

114 Represents a Paperless-NgX document. 

115 

116 Attributes: 

117 added: The timestamp when the document was added to the system. 

118 archive_serial_number: The serial number of the archive. 

119 archived_file_name: The name of the archived file. 

120 content: The content of the document. 

121 correspondent: The correspondent associated with the document. 

122 created: The timestamp when the document was created. 

123 created_date: The date when the document was created. 

124 updated: The timestamp when the document was last updated. 

125 custom_fields: Custom fields associated with the document. 

126 deleted_at: The timestamp when the document was deleted. 

127 document_type: The document type associated with the document. 

128 is_shared_by_requester: Whether the document is shared by the requester. 

129 notes: Notes associated with the document. 

130 original_filename: The original file name of the document. 

131 owner: The owner of the document. 

132 page_count: The number of pages in the document. 

133 storage_path: The storage path of the document. 

134 tags: The tags associated with the document. 

135 title: The title of the document. 

136 user_can_change: Whether the user can change the document. 

137 checksum: The checksum of the document. 

138 

139 Examples: 

140 >>> document = client.documents().get(pk=1) 

141 >>> document.title = 'Example Document' 

142 >>> document.save() 

143 >>> document.title 

144 'Example Document' 

145 

146 # Get document metadata 

147 >>> metadata = document.get_metadata() 

148 >>> print(metadata.original_mime_type) 

149 

150 # Download document 

151 >>> download = document.download() 

152 >>> with open(download.disposition_filename, 'wb') as f: 

153 ... f.write(download.content) 

154 

155 # Get document suggestions 

156 >>> suggestions = document.get_suggestions() 

157 >>> print(suggestions.tags) 

158 

159 """ 

160 

161 # where did this come from? It's not in sample data? 

162 added: datetime | None = None 

163 archive_checksum: str | None = None 

164 archive_filename: str | None = None 

165 archive_serial_number: int | None = None 

166 archived_file_name: str | None = None 

167 checksum: str | None = None 

168 content: str = "" 

169 correspondent_id: int | None = None 

170 created: datetime | None = Field(description="Creation timestamp", default=None) 

171 created_date: str | None = None 

172 custom_field_dicts: Annotated[list[CustomFieldValues], Field(default_factory=list)] 

173 deleted_at: datetime | None = None 

174 document_type_id: int | None = None 

175 filename: str | None = None 

176 is_shared_by_requester: bool = False 

177 notes: "list[DocumentNote]" = Field(default_factory=list) 

178 original_filename: str | None = None 

179 owner: int | None = None 

180 page_count: int | None = None 

181 storage_path_id: int | None = None 

182 storage_type: DocumentStorageType | None = None 

183 tag_ids: Annotated[list[int], Field(default_factory=list)] 

184 title: str = "" 

185 user_can_change: bool | None = None 

186 

187 _correspondent: tuple[int, Correspondent] | None = None 

188 _document_type: tuple[int, DocumentType] | None = None 

189 _storage_path: tuple[int, StoragePath] | None = None 

190 __search_hit__: dict[str, Any] | None = None 

191 

192 class Meta(StandardModel.Meta): 

193 # NOTE: Filtering appears to be disabled by paperless on page_count 

194 read_only_fields = {"page_count", "deleted_at", "is_shared_by_requester", "archived_file_name"} 

195 filtering_disabled = {"page_count", "deleted_at", "is_shared_by_requester"} 

196 filtering_strategies = {FilteringStrategies.WHITELIST} 

197 field_map = { 

198 "tags": "tag_ids", 

199 "custom_fields": "custom_field_dicts", 

200 "document_type": "document_type_id", 

201 "correspondent": "correspondent_id", 

202 "storage_path": "storage_path_id", 

203 } 

204 supported_filtering_params = SUPPORTED_FILTERING_PARAMS 

205 

206 @field_serializer("added", "created", "deleted_at") 

207 def serialize_datetime(self, value: datetime | None) -> str | None: 

208 """ 

209 Serialize datetime fields to ISO format. 

210 

211 Args: 

212 value: The datetime value to serialize. 

213 

214 Returns: 

215 The serialized datetime value. 

216 

217 """ 

218 return value.isoformat() if value else None 

219 

220 @field_serializer("notes") 

221 def serialize_notes(self, value: list[DocumentNote]) -> list[dict[str, Any]]: 

222 """ 

223 Serialize notes to a list of dictionaries. 

224 

225 Args: 

226 value: The list of DocumentNote objects to serialize. 

227 

228 Returns: 

229 A list of dictionaries representing the notes. 

230 

231 """ 

232 return [note.to_dict() for note in value] if value else [] 

233 

234 @field_validator("tag_ids", mode="before") 

235 @classmethod 

236 def validate_tags(cls, value: Any) -> list[int]: 

237 """ 

238 Validate and convert tag IDs to a list of integers. 

239 

240 Args: 

241 value: The list of tag IDs to validate. 

242 

243 Returns: 

244 A list of validated tag IDs. 

245 

246 """ 

247 if value is None: 

248 return [] 

249 

250 if isinstance(value, list): 

251 return [int(tag) for tag in value] 

252 

253 if isinstance(value, int): 

254 return [value] 

255 

256 raise TypeError(f"Invalid type for tags: {type(value)}") 

257 

258 @field_validator("custom_field_dicts", mode="before") 

259 @classmethod 

260 def validate_custom_fields(cls, value: Any) -> list[CustomFieldValues]: 

261 """ 

262 Validate and return custom field dictionaries. 

263 

264 Args: 

265 value: The list of custom field dictionaries to validate. 

266 

267 Returns: 

268 A list of validated custom field dictionaries. 

269 

270 """ 

271 if value is None: 

272 return [] 

273 

274 if isinstance(value, list): 

275 return value 

276 

277 raise TypeError(f"Invalid type for custom fields: {type(value)}") 

278 

279 @field_validator("content", "title", mode="before") 

280 @classmethod 

281 def validate_text(cls, value: Any) -> str: 

282 """ 

283 Validate and return a text field. 

284 

285 Args: 

286 value: The value of the text field to validate. 

287 

288 Returns: 

289 The validated text value. 

290 

291 """ 

292 if value is None: 

293 return "" 

294 

295 if isinstance(value, (str, int)): 

296 return str(value) 

297 

298 raise TypeError(f"Invalid type for text: {type(value)}") 

299 

300 @field_validator("notes", mode="before") 

301 @classmethod 

302 def validate_notes(cls, value: Any) -> list[Any]: 

303 """ 

304 Validate and return the list of notes. 

305 

306 Args: 

307 value: The list of notes to validate. 

308 

309 Returns: 

310 The validated list of notes. 

311 

312 """ 

313 if value is None: 

314 return [] 

315 

316 if isinstance(value, list): 

317 return value 

318 

319 if isinstance(value, DocumentNote): 

320 return [value] 

321 

322 raise TypeError(f"Invalid type for notes: {type(value)}") 

323 

324 @field_validator("is_shared_by_requester", mode="before") 

325 @classmethod 

326 def validate_is_shared_by_requester(cls, value: Any) -> bool: 

327 """ 

328 Validate and return the is_shared_by_requester flag. 

329 

330 Args: 

331 value: The flag to validate. 

332 

333 Returns: 

334 The validated flag. 

335 

336 """ 

337 if value is None: 

338 return False 

339 

340 if isinstance(value, bool): 

341 return value 

342 

343 raise TypeError(f"Invalid type for is_shared_by_requester: {type(value)}") 

344 

345 @property 

346 def custom_field_ids(self) -> list[int]: 

347 """ 

348 Get the IDs of the custom fields for this document. 

349 """ 

350 return [element.field for element in self.custom_field_dicts] 

351 

352 @property 

353 def custom_field_values(self) -> list[Any]: 

354 """ 

355 Get the values of the custom fields for this document. 

356 """ 

357 return [element.value for element in self.custom_field_dicts] 

358 

359 @property 

360 def tag_names(self) -> list[str]: 

361 """ 

362 Get the names of the tags for this document. 

363 """ 

364 return [tag.name for tag in self.tags if tag.name] 

365 

366 @property 

367 def tags(self) -> TagQuerySet: 

368 """ 

369 Get the tags for this document. 

370 

371 Returns: 

372 List of tags associated with this document. 

373 

374 Examples: 

375 >>> document = client.documents().get(pk=1) 

376 >>> for tag in document.tags: 

377 ... print(f'{tag.name} # {tag.id}') 

378 'Tag 1 # 1' 

379 'Tag 2 # 2' 

380 'Tag 3 # 3' 

381 

382 >>> if 5 in document.tags: 

383 ... print('Tag ID #5 is associated with this document') 

384 

385 >>> tag = client.tags().get(pk=1) 

386 >>> if tag in document.tags: 

387 ... print('Tag ID #1 is associated with this document') 

388 

389 >>> filtered_tags = document.tags.filter(name__icontains='example') 

390 >>> for tag in filtered_tags: 

391 ... print(f'{tag.name} # {tag.id}') 

392 

393 """ 

394 if not self.tag_ids: 

395 return self._client.tags().none() 

396 

397 # Use the API's filtering capability to get only the tags with specific IDs 

398 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID 

399 return self._client.tags().id(self.tag_ids) 

400 

401 @tags.setter 

402 def tags(self, value: "Iterable[Tag | int] | None") -> None: 

403 """ 

404 Set the tags for this document. 

405 

406 Args: 

407 value: The tags to set. 

408 

409 """ 

410 if value is None: 

411 self.tag_ids = [] 

412 return 

413 

414 if isinstance(value, Iterable): 

415 # Reset tag_ids to ensure we only have the new values 

416 self.tag_ids = [] 

417 for tag in value: 

418 if isinstance(tag, int): 

419 self.tag_ids.append(tag) 

420 continue 

421 

422 # Check against StandardModel to avoid circular imports 

423 # If it is another type of standard model, pydantic validators will complain 

424 if isinstance(tag, StandardModel): 

425 self.tag_ids.append(tag.id) 

426 continue 

427 

428 raise TypeError(f"Invalid type for tags: {type(tag)}") 

429 return 

430 

431 raise TypeError(f"Invalid type for tags: {type(value)}") 

432 

433 @property 

434 def correspondent(self) -> "Correspondent | None": 

435 """ 

436 Get the correspondent for this document. 

437 

438 Returns: 

439 The correspondent or None if not set. 

440 

441 Examples: 

442 >>> document = client.documents().get(pk=1) 

443 >>> document.correspondent.name 

444 'Example Correspondent' 

445 

446 """ 

447 # Return cache 

448 if self._correspondent is not None: 

449 pk, value = self._correspondent 

450 if pk == self.correspondent_id: 

451 return value 

452 

453 # None set to retrieve 

454 if not self.correspondent_id: 

455 return None 

456 

457 # Retrieve it 

458 correspondent = self._client.correspondents().get(self.correspondent_id) 

459 self._correspondent = (self.correspondent_id, correspondent) 

460 return correspondent 

461 

462 @correspondent.setter 

463 def correspondent(self, value: "Correspondent | int | None") -> None: 

464 """ 

465 Set the correspondent for this document. 

466 

467 Args: 

468 value: The correspondent to set. 

469 

470 """ 

471 if value is None: 

472 # Leave cache in place in case it changes again 

473 self.correspondent_id = None 

474 return 

475 

476 if isinstance(value, int): 

477 # Leave cache in place in case id is the same, or id changes again 

478 self.correspondent_id = value 

479 return 

480 

481 # Check against StandardModel to avoid circular imports 

482 # If it is another type of standard model, pydantic validators will complain 

483 if isinstance(value, StandardModel): 

484 self.correspondent_id = value.id 

485 # Pre-populate the cache 

486 self._correspondent = (value.id, value) 

487 return 

488 

489 raise TypeError(f"Invalid type for correspondent: {type(value)}") 

490 

491 @property 

492 def document_type(self) -> "DocumentType | None": 

493 """ 

494 Get the document type for this document. 

495 

496 Returns: 

497 The document type or None if not set. 

498 

499 Examples: 

500 >>> document = client.documents().get(pk=1) 

501 >>> document.document_type.name 

502 'Example Document Type 

503 

504 """ 

505 # Return cache 

506 if self._document_type is not None: 

507 pk, value = self._document_type 

508 if pk == self.document_type_id: 

509 return value 

510 

511 # None set to retrieve 

512 if not self.document_type_id: 

513 return None 

514 

515 # Retrieve it 

516 document_type = self._client.document_types().get(self.document_type_id) 

517 self._document_type = (self.document_type_id, document_type) 

518 return document_type 

519 

520 @document_type.setter 

521 def document_type(self, value: "DocumentType | int | None") -> None: 

522 """ 

523 Set the document type for this document. 

524 

525 Args: 

526 value: The document type to set. 

527 

528 """ 

529 if value is None: 

530 # Leave cache in place in case it changes again 

531 self.document_type_id = None 

532 return 

533 

534 if isinstance(value, int): 

535 # Leave cache in place in case id is the same, or id changes again 

536 self.document_type_id = value 

537 return 

538 

539 # Check against StandardModel to avoid circular imports 

540 # If it is another type of standard model, pydantic validators will complain 

541 if isinstance(value, StandardModel): 

542 self.document_type_id = value.id 

543 # Pre-populate the cache 

544 self._document_type = (value.id, value) 

545 return 

546 

547 raise TypeError(f"Invalid type for document_type: {type(value)}") 

548 

549 @property 

550 def storage_path(self) -> "StoragePath | None": 

551 """ 

552 Get the storage path for this document. 

553 

554 Returns: 

555 The storage path or None if not set. 

556 

557 Examples: 

558 >>> document = client.documents().get(pk=1) 

559 >>> document.storage_path.name 

560 'Example Storage Path' 

561 

562 """ 

563 # Return cache 

564 if self._storage_path is not None: 

565 pk, value = self._storage_path 

566 if pk == self.storage_path_id: 

567 return value 

568 

569 # None set to retrieve 

570 if not self.storage_path_id: 

571 return None 

572 

573 # Retrieve it 

574 storage_path = self._client.storage_paths().get(self.storage_path_id) 

575 self._storage_path = (self.storage_path_id, storage_path) 

576 return storage_path 

577 

578 @storage_path.setter 

579 def storage_path(self, value: "StoragePath | int | None") -> None: 

580 """ 

581 Set the storage path for this document. 

582 

583 Args: 

584 value: The storage path to set. 

585 

586 """ 

587 if value is None: 

588 # Leave cache in place in case it changes again 

589 self.storage_path_id = None 

590 return 

591 

592 if isinstance(value, int): 

593 # Leave cache in place in case id is the same, or id changes again 

594 self.storage_path_id = value 

595 return 

596 

597 # Check against StandardModel to avoid circular imports 

598 # If it is another type of standard model, pydantic validators will complain 

599 if isinstance(value, StandardModel): 

600 self.storage_path_id = value.id 

601 # Pre-populate the cache 

602 self._storage_path = (value.id, value) 

603 return 

604 

605 raise TypeError(f"Invalid type for storage_path: {type(value)}") 

606 

607 @property 

608 def custom_fields(self) -> "CustomFieldQuerySet": 

609 """ 

610 Get the custom fields for this document. 

611 

612 Returns: 

613 List of custom fields associated with this document. 

614 

615 """ 

616 if not self.custom_field_dicts: 

617 return self._client.custom_fields().none() 

618 

619 # Use the API's filtering capability to get only the custom fields with specific IDs 

620 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID 

621 return self._client.custom_fields().id(self.custom_field_ids) 

622 

623 @custom_fields.setter 

624 def custom_fields(self, value: "Iterable[CustomField | CustomFieldValues | CustomFieldTypedDict] | None") -> None: 

625 """ 

626 Set the custom fields for this document. 

627 

628 Args: 

629 value: The custom fields to set. 

630 

631 """ 

632 if value is None: 

633 self.custom_field_dicts = [] 

634 return 

635 

636 if isinstance(value, Iterable): 

637 new_list: list[CustomFieldValues] = [] 

638 for field in value: 

639 if isinstance(field, CustomFieldValues): 

640 new_list.append(field) 

641 continue 

642 

643 # isinstance(field, CustomField) 

644 # Check against StandardModel (instead of CustomField) to avoid circular imports 

645 # If it is the wrong type of standard model (e.g. a User), pydantic validators will complain 

646 if isinstance(field, StandardModel): 

647 new_list.append(CustomFieldValues(field=field.id, value=getattr(field, "value"))) 

648 continue 

649 

650 if isinstance(field, dict): 

651 new_list.append(CustomFieldValues(**field)) 

652 continue 

653 

654 raise TypeError(f"Invalid type for custom fields: {type(field)}") 

655 

656 self.custom_field_dicts = new_list 

657 return 

658 

659 raise TypeError(f"Invalid type for custom fields: {type(value)}") 

660 

661 @property 

662 def has_search_hit(self) -> bool: 

663 return self.__search_hit__ is not None 

664 

665 @property 

666 def search_hit(self) -> dict[str, Any] | None: 

667 return self.__search_hit__ 

668 

669 def custom_field_value(self, field_id: int, default: Any = None, *, raise_errors: bool = False) -> Any: 

670 """ 

671 Get the value of a custom field by ID. 

672 

673 Args: 

674 field_id: The ID of the custom field. 

675 default: The value to return if the field is not found. 

676 raise_errors: Whether to raise an error if the field is not found. 

677 

678 Returns: 

679 The value of the custom field or the default value if not found. 

680 

681 """ 

682 for field in self.custom_field_dicts: 

683 if field.field == field_id: 

684 return field.value 

685 

686 if raise_errors: 

687 raise ValueError(f"Custom field {field_id} not found") 

688 return default 

689 

690 """ 

691 def __getattr__(self, name: str) -> Any: 

692 # Allow easy access to custom fields 

693 for custom_field in self.custom_fields: 

694 if custom_field['field'] == name: 

695 return custom_field['value'] 

696 

697 raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") 

698 """ 

699 

700 def add_tag(self, tag: "Tag | int | str") -> None: 

701 """ 

702 Add a tag to the document. 

703 

704 Args: 

705 tag: The tag to add. 

706 

707 """ 

708 if isinstance(tag, int): 

709 self.tag_ids.append(tag) 

710 return 

711 

712 if isinstance(tag, StandardModel): 

713 self.tag_ids.append(tag.id) 

714 return 

715 

716 if isinstance(tag, str): 

717 if not (instance := self._client.tags().filter(name=tag).first()): 

718 raise ResourceNotFoundError(f"Tag '{tag}' not found") 

719 self.tag_ids.append(instance.id) 

720 return 

721 

722 raise TypeError(f"Invalid type for tag: {type(tag)}") 

723 

724 def remove_tag(self, tag: "Tag | int | str") -> None: 

725 """ 

726 Remove a tag from the document. 

727 

728 Args: 

729 tag: The tag to remove. 

730 

731 """ 

732 if isinstance(tag, int): 

733 # TODO: Handle removal with consideration of "tags can't be empty" rule in paperless 

734 self.tag_ids.remove(tag) 

735 return 

736 

737 if isinstance(tag, StandardModel): 

738 # TODO: Handle removal with consideration of "tags can't be empty" rule in paperless 

739 self.tag_ids.remove(tag.id) 

740 return 

741 

742 if isinstance(tag, str): 

743 # TODO: Handle removal with consideration of "tags can't be empty" rule in paperless 

744 if not (instance := self._client.tags().filter(name=tag).first()): 

745 raise ResourceNotFoundError(f"Tag '{tag}' not found") 

746 self.tag_ids.remove(instance.id) 

747 return 

748 

749 raise TypeError(f"Invalid type for tag: {type(tag)}") 

750 

751 def get_metadata(self) -> "DocumentMetadata": 

752 """ 

753 Get the metadata for this document. 

754 

755 Returns: 

756 The document metadata. 

757 

758 Examples: 

759 >>> metadata = document.get_metadata() 

760 >>> print(metadata.original_mime_type) 

761 

762 """ 

763 raise NotImplementedError() 

764 

765 def download(self, original: bool = False) -> "DownloadedDocument": 

766 """ 

767 Download the document file. 

768 

769 Args: 

770 original: Whether to download the original file instead of the archived version. 

771 

772 Returns: 

773 The downloaded document. 

774 

775 Examples: 

776 >>> download = document.download() 

777 >>> with open(download.disposition_filename, 'wb') as f: 

778 ... f.write(download.content) 

779 

780 """ 

781 raise NotImplementedError() 

782 

783 def preview(self, original: bool = False) -> "DownloadedDocument": 

784 """ 

785 Get a preview of the document. 

786 

787 Args: 

788 original: Whether to preview the original file instead of the archived version. 

789 

790 Returns: 

791 The document preview. 

792 

793 """ 

794 raise NotImplementedError() 

795 

796 def thumbnail(self, original: bool = False) -> "DownloadedDocument": 

797 """ 

798 Get the document thumbnail. 

799 

800 Args: 

801 original: Whether to get the thumbnail of the original file. 

802 

803 Returns: 

804 The document thumbnail. 

805 

806 """ 

807 raise NotImplementedError() 

808 

809 def get_suggestions(self) -> "DocumentSuggestions": 

810 """ 

811 Get suggestions for this document. 

812 

813 Returns: 

814 The document suggestions. 

815 

816 Examples: 

817 >>> suggestions = document.get_suggestions() 

818 >>> print(suggestions.tags) 

819 

820 """ 

821 raise NotImplementedError() 

822 

823 def append_content(self, value: str) -> None: 

824 """ 

825 Append content to the document. 

826 

827 Args: 

828 value: The content to append. 

829 

830 """ 

831 self.content = f"{self.content}\n{value}" 

832 

833 @override 

834 def update_locally(self, from_db: bool | None = None, **kwargs: Any) -> None: 

835 """ 

836 Update the document locally with the provided data. 

837 

838 Args: 

839 from_db: Whether to update from the database. 

840 **kwargs: Additional data to update the document with. 

841 

842 Raises: 

843 NotImplementedError: If attempting to set notes or tags to None when they are not already None. 

844 

845 """ 

846 if not from_db: 

847 # Paperless does not support setting notes or tags to None if not already None 

848 fields = ["notes", "tag_ids"] 

849 for field in fields: 

850 original = self._original_data[field] 

851 if original and field in kwargs and not kwargs.get(field): 

852 raise NotImplementedError(f"Cannot set {field} to None. {field} currently: {original}") 

853 

854 # Handle aliases 

855 if self._original_data["tag_ids"] and "tags" in kwargs and not kwargs.get("tags"): 

856 raise NotImplementedError(f"Cannot set tags to None. Tags currently: {self._original_data['tag_ids']}") 

857 

858 return super().update_locally(from_db=from_db, **kwargs)