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

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 

27from datetime import datetime 

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

29 

30import pydantic 

31from pydantic import Field, field_serializer, field_validator, model_serializer 

32from typing_extensions import TypeVar 

33from yarl import URL 

34 

35from paperap.const import CustomFieldTypedDict, CustomFieldValues 

36from paperap.models.abstract import FilteringStrategies, StandardModel 

37from paperap.models.document.queryset import DocumentQuerySet 

38 

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 

46 

47 

48class DocumentNote(StandardModel): 

49 """ 

50 Represents a note on a Paperless-NgX document. 

51 """ 

52 

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 

60 

61 class Meta(StandardModel.Meta): 

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

63 

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

65 def serialize_datetime(self, value: datetime | None): 

66 """ 

67 Serialize datetime fields to ISO format. 

68 

69 Args: 

70 value: The datetime value to serialize. 

71 

72 Returns: 

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

74 

75 """ 

76 return value.isoformat() if value else None 

77 

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

79 """ 

80 Get the document associated with this note. 

81 

82 Returns: 

83 The document associated with this note. 

84 

85 """ 

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

87 

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

89 """ 

90 Get the user who created this note. 

91 

92 Returns: 

93 The user who created this note. 

94 

95 """ 

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

97 

98 

99class Document(StandardModel): 

100 """ 

101 Represents a Paperless-NgX document. 

102 

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. 

124 

125 Examples: 

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

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

128 >>> document.save() 

129 >>> document.title 

130 'Example Document' 

131 

132 """ 

133 

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 

145 

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 

151 

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

157 

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 

162 

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 } 

272 

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. 

277 

278 Args: 

279 value: The datetime value to serialize. 

280 

281 Returns: 

282 The serialized datetime value. 

283 

284 """ 

285 return value.isoformat() if value else None 

286 

287 @field_serializer("notes") 

288 def serialize_notes(self, value: list[DocumentNote]): 

289 """ 

290 Serialize notes to a list of dictionaries. 

291 

292 Args: 

293 value: The list of DocumentNote objects to serialize. 

294 

295 Returns: 

296 A list of dictionaries representing the notes. 

297 

298 """ 

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

300 

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. 

306 

307 Args: 

308 value: The list of tag IDs to validate. 

309 

310 Returns: 

311 A list of validated tag IDs. 

312 

313 """ 

314 if value is None: 

315 return [] 

316 

317 if isinstance(value, list): 

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

319 

320 if isinstance(value, int): 

321 return [value] 

322 

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

324 

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. 

330 

331 Args: 

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

333 

334 Returns: 

335 A list of validated custom field dictionaries. 

336 

337 """ 

338 if value is None: 

339 return [] 

340 

341 if isinstance(value, list): 

342 return value 

343 

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

345 

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. 

351 

352 Args: 

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

354 

355 Returns: 

356 The validated text value. 

357 

358 """ 

359 if value is None: 

360 return "" 

361 

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

363 return str(value) 

364 

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

366 

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. 

372 

373 Args: 

374 value: The list of notes to validate. 

375 

376 Returns: 

377 The validated list of notes. 

378 

379 """ 

380 if value is None: 

381 return [] 

382 

383 if isinstance(value, list): 

384 return value 

385 

386 if isinstance(value, DocumentNote): 

387 return [value] 

388 

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

390 

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. 

396 

397 Args: 

398 value: The flag to validate. 

399 

400 Returns: 

401 The validated flag. 

402 

403 """ 

404 if value is None: 

405 return False 

406 

407 if isinstance(value, bool): 

408 return value 

409 

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

411 

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] 

418 

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] 

425 

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] 

432 

433 @property 

434 def tags(self) -> TagQuerySet: 

435 """ 

436 Get the tags for this document. 

437 

438 Returns: 

439 List of tags associated with this document. 

440 

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' 

448 

449 >>> if 5 in document.tags: 

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

451 

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

453 >>> if tag in document.tags: 

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

455 

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

457 >>> for tag in filtered_tags: 

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

459 

460 """ 

461 if not self.tag_ids: 

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

463 

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) 

467 

468 @tags.setter 

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

470 """ 

471 Set the tags for this document. 

472 

473 Args: 

474 value: The tags to set. 

475 

476 """ 

477 if value is None: 

478 self.tag_ids = [] 

479 return 

480 

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 

488 

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 

494 

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

496 return 

497 

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

499 

500 @property 

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

502 """ 

503 Get the correspondent for this document. 

504 

505 Returns: 

506 The correspondent or None if not set. 

507 

508 Examples: 

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

510 >>> document.correspondent.name 

511 'Example Correspondent' 

512 

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 

519 

520 # None set to retrieve 

521 if not self.correspondent_id: 

522 return None 

523 

524 # Retrieve it 

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

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

527 return correspondent 

528 

529 @correspondent.setter 

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

531 """ 

532 Set the correspondent for this document. 

533 

534 Args: 

535 value: The correspondent to set. 

536 

537 """ 

538 if value is None: 

539 # Leave cache in place in case it changes again 

540 self.correspondent_id = None 

541 return 

542 

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 

547 

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 

555 

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

557 

558 @property 

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

560 """ 

561 Get the document type for this document. 

562 

563 Returns: 

564 The document type or None if not set. 

565 

566 Examples: 

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

568 >>> document.document_type.name 

569 'Example Document Type 

570 

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 

577 

578 # None set to retrieve 

579 if not self.document_type_id: 

580 return None 

581 

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 

586 

587 @document_type.setter 

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

589 """ 

590 Set the document type for this document. 

591 

592 Args: 

593 value: The document type to set. 

594 

595 """ 

596 if value is None: 

597 # Leave cache in place in case it changes again 

598 self.document_type_id = None 

599 return 

600 

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 

605 

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 

613 

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

615 

616 @property 

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

618 """ 

619 Get the storage path for this document. 

620 

621 Returns: 

622 The storage path or None if not set. 

623 

624 Examples: 

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

626 >>> document.storage_path.name 

627 'Example Storage Path' 

628 

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 

635 

636 # None set to retrieve 

637 if not self.storage_path_id: 

638 return None 

639 

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 

644 

645 @storage_path.setter 

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

647 """ 

648 Set the storage path for this document. 

649 

650 Args: 

651 value: The storage path to set. 

652 

653 """ 

654 if value is None: 

655 # Leave cache in place in case it changes again 

656 self.storage_path_id = None 

657 return 

658 

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 

663 

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 

671 

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

673 

674 @property 

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

676 """ 

677 Get the custom fields for this document. 

678 

679 Returns: 

680 List of custom fields associated with this document. 

681 

682 """ 

683 if not self.custom_field_dicts: 

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

685 

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) 

689 

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. 

694 

695 Args: 

696 value: The custom fields to set. 

697 

698 """ 

699 if value is None: 

700 self.custom_field_dicts = [] 

701 return 

702 

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 

709 

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 

716 

717 if isinstance(field, dict): 

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

719 continue 

720 

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

722 

723 self.custom_field_dicts = new_list 

724 return 

725 

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

727 

728 @property 

729 def has_search_hit(self) -> bool: 

730 return self.__search_hit__ is not None 

731 

732 @property 

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

734 return self.__search_hit__ 

735 

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. 

739 

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. 

744 

745 Returns: 

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

747 

748 """ 

749 for field in self.custom_field_dicts: 

750 if field.field == field_id: 

751 return field.value 

752 

753 if raise_errors: 

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

755 return default 

756 

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

763 

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

765 """ 

766 

767 @override 

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

769 """ 

770 Update the document locally with the provided data. 

771 

772 Args: 

773 from_db: Whether to update from the database. 

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

775 

776 Raises: 

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

778 

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

785 

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 ) 

792 

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