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

1""" 

2 

3 

4 

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

6 

7METADATA: 

8 

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 

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, Optional, 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 CustomFieldTypedDict, CustomFieldValues 

36from paperap.exceptions import ResourceNotFoundError 

37from paperap.models.abstract import FilteringStrategies, StandardModel 

38from paperap.models.document.queryset import DocumentQuerySet 

39 

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 

50 

51logger = logging.getLogger(__name__) 

52 

53 

54class DocumentNote(StandardModel): 

55 """ 

56 Represents a note on a Paperless-NgX document. 

57 """ 

58 

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 

66 

67 class Meta(StandardModel.Meta): 

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

69 

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. 

74 

75 Args: 

76 value: The datetime value to serialize. 

77 

78 Returns: 

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

80 

81 """ 

82 return value.isoformat() if value else None 

83 

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

85 """ 

86 Get the document associated with this note. 

87 

88 Returns: 

89 The document associated with this note. 

90 

91 """ 

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

93 

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

95 """ 

96 Get the user who created this note. 

97 

98 Returns: 

99 The user who created this note. 

100 

101 """ 

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

103 

104 

105class Document(StandardModel): 

106 """ 

107 Represents a Paperless-NgX document. 

108 

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. 

131 

132 Examples: 

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

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

135 >>> document.save() 

136 >>> document.title 

137 'Example Document' 

138 

139 # Get document metadata 

140 >>> metadata = document.get_metadata() 

141 >>> print(metadata.original_mime_type) 

142 

143 # Download document 

144 >>> download = document.download() 

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

146 ... f.write(download.content) 

147 

148 # Get document suggestions 

149 >>> suggestions = document.get_suggestions() 

150 >>> print(suggestions.tags) 

151 

152 """ 

153 

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 

166 

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 

172 

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)] 

178 

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 

183 

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 } 

292 

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. 

297 

298 Args: 

299 value: The datetime value to serialize. 

300 

301 Returns: 

302 The serialized datetime value. 

303 

304 """ 

305 return value.isoformat() if value else None 

306 

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. 

311 

312 Args: 

313 value: The list of DocumentNote objects to serialize. 

314 

315 Returns: 

316 A list of dictionaries representing the notes. 

317 

318 """ 

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

320 

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. 

326 

327 Args: 

328 value: The list of tag IDs to validate. 

329 

330 Returns: 

331 A list of validated tag IDs. 

332 

333 """ 

334 if value is None: 

335 return [] 

336 

337 if isinstance(value, list): 

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

339 

340 if isinstance(value, int): 

341 return [value] 

342 

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

344 

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. 

350 

351 Args: 

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

353 

354 Returns: 

355 A list of validated custom field dictionaries. 

356 

357 """ 

358 if value is None: 

359 return [] 

360 

361 if isinstance(value, list): 

362 return value 

363 

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

365 

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. 

371 

372 Args: 

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

374 

375 Returns: 

376 The validated text value. 

377 

378 """ 

379 if value is None: 

380 return "" 

381 

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

383 return str(value) 

384 

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

386 

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. 

392 

393 Args: 

394 value: The list of notes to validate. 

395 

396 Returns: 

397 The validated list of notes. 

398 

399 """ 

400 if value is None: 

401 return [] 

402 

403 if isinstance(value, list): 

404 return value 

405 

406 if isinstance(value, DocumentNote): 

407 return [value] 

408 

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

410 

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. 

416 

417 Args: 

418 value: The flag to validate. 

419 

420 Returns: 

421 The validated flag. 

422 

423 """ 

424 if value is None: 

425 return False 

426 

427 if isinstance(value, bool): 

428 return value 

429 

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

431 

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] 

438 

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] 

445 

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] 

452 

453 @property 

454 def tags(self) -> TagQuerySet: 

455 """ 

456 Get the tags for this document. 

457 

458 Returns: 

459 List of tags associated with this document. 

460 

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' 

468 

469 >>> if 5 in document.tags: 

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

471 

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

473 >>> if tag in document.tags: 

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

475 

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

477 >>> for tag in filtered_tags: 

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

479 

480 """ 

481 if not self.tag_ids: 

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

483 

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) 

487 

488 @tags.setter 

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

490 """ 

491 Set the tags for this document. 

492 

493 Args: 

494 value: The tags to set. 

495 

496 """ 

497 if value is None: 

498 self.tag_ids = [] 

499 return 

500 

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 

508 

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 

514 

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

516 return 

517 

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

519 

520 @property 

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

522 """ 

523 Get the correspondent for this document. 

524 

525 Returns: 

526 The correspondent or None if not set. 

527 

528 Examples: 

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

530 >>> document.correspondent.name 

531 'Example Correspondent' 

532 

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 

539 

540 # None set to retrieve 

541 if not self.correspondent_id: 

542 return None 

543 

544 # Retrieve it 

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

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

547 return correspondent 

548 

549 @correspondent.setter 

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

551 """ 

552 Set the correspondent for this document. 

553 

554 Args: 

555 value: The correspondent to set. 

556 

557 """ 

558 if value is None: 

559 # Leave cache in place in case it changes again 

560 self.correspondent_id = None 

561 return 

562 

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 

567 

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 

575 

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

577 

578 @property 

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

580 """ 

581 Get the document type for this document. 

582 

583 Returns: 

584 The document type or None if not set. 

585 

586 Examples: 

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

588 >>> document.document_type.name 

589 'Example Document Type 

590 

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 

597 

598 # None set to retrieve 

599 if not self.document_type_id: 

600 return None 

601 

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 

606 

607 @document_type.setter 

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

609 """ 

610 Set the document type for this document. 

611 

612 Args: 

613 value: The document type to set. 

614 

615 """ 

616 if value is None: 

617 # Leave cache in place in case it changes again 

618 self.document_type_id = None 

619 return 

620 

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 

625 

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 

633 

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

635 

636 @property 

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

638 """ 

639 Get the storage path for this document. 

640 

641 Returns: 

642 The storage path or None if not set. 

643 

644 Examples: 

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

646 >>> document.storage_path.name 

647 'Example Storage Path' 

648 

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 

655 

656 # None set to retrieve 

657 if not self.storage_path_id: 

658 return None 

659 

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 

664 

665 @storage_path.setter 

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

667 """ 

668 Set the storage path for this document. 

669 

670 Args: 

671 value: The storage path to set. 

672 

673 """ 

674 if value is None: 

675 # Leave cache in place in case it changes again 

676 self.storage_path_id = None 

677 return 

678 

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 

683 

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 

691 

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

693 

694 @property 

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

696 """ 

697 Get the custom fields for this document. 

698 

699 Returns: 

700 List of custom fields associated with this document. 

701 

702 """ 

703 if not self.custom_field_dicts: 

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

705 

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) 

709 

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. 

714 

715 Args: 

716 value: The custom fields to set. 

717 

718 """ 

719 if value is None: 

720 self.custom_field_dicts = [] 

721 return 

722 

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 

729 

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 

736 

737 if isinstance(field, dict): 

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

739 continue 

740 

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

742 

743 self.custom_field_dicts = new_list 

744 return 

745 

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

747 

748 @property 

749 def has_search_hit(self) -> bool: 

750 return self.__search_hit__ is not None 

751 

752 @property 

753 def search_hit(self) -> Optional[dict[str, Any]]: 

754 return self.__search_hit__ 

755 

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. 

759 

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. 

764 

765 Returns: 

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

767 

768 """ 

769 for field in self.custom_field_dicts: 

770 if field.field == field_id: 

771 return field.value 

772 

773 if raise_errors: 

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

775 return default 

776 

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'] 

783 

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

785 """ 

786 

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

788 """ 

789 Add a tag to the document. 

790 

791 Args: 

792 tag: The tag to add. 

793 

794 """ 

795 if isinstance(tag, int): 

796 self.tag_ids.append(tag) 

797 return 

798 

799 if isinstance(tag, StandardModel): 

800 self.tag_ids.append(tag.id) 

801 return 

802 

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 

808 

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

810 

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

812 """ 

813 Remove a tag from the document. 

814 

815 Args: 

816 tag: The tag to remove. 

817 

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 

823 

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 

828 

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 

835 

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

837 

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

839 """ 

840 Get the metadata for this document. 

841 

842 Returns: 

843 The document metadata. 

844 

845 Examples: 

846 >>> metadata = document.get_metadata() 

847 >>> print(metadata.original_mime_type) 

848 

849 """ 

850 raise NotImplementedError() 

851 

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

853 """ 

854 Download the document file. 

855 

856 Args: 

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

858 

859 Returns: 

860 The downloaded document. 

861 

862 Examples: 

863 >>> download = document.download() 

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

865 ... f.write(download.content) 

866 

867 """ 

868 raise NotImplementedError() 

869 

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

871 """ 

872 Get a preview of the document. 

873 

874 Args: 

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

876 

877 Returns: 

878 The document preview. 

879 

880 """ 

881 raise NotImplementedError() 

882 

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

884 """ 

885 Get the document thumbnail. 

886 

887 Args: 

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

889 

890 Returns: 

891 The document thumbnail. 

892 

893 """ 

894 raise NotImplementedError() 

895 

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

897 """ 

898 Get suggestions for this document. 

899 

900 Returns: 

901 The document suggestions. 

902 

903 Examples: 

904 >>> suggestions = document.get_suggestions() 

905 >>> print(suggestions.tags) 

906 

907 """ 

908 raise NotImplementedError() 

909 

910 def append_content(self, value: str): 

911 """ 

912 Append content to the document. 

913 

914 Args: 

915 value: The content to append. 

916 

917 """ 

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

919 

920 @override 

921 def update_locally(self, from_db: bool | None = None, **kwargs: Any): 

922 """ 

923 Update the document locally with the provided data. 

924 

925 Args: 

926 from_db: Whether to update from the database. 

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

928 

929 Raises: 

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

931 

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}") 

940 

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']}") 

944 

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