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

1"""

2

3

4

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

6

7 METADATA:

8

9 File: model.py

10 Project: paperap

11 Created: 2025-03-09

12 Version: 0.0.7

13 Author: Jess Mann

14 Email: jess@jmann.me

15 Copyright (c) 2025 Jess Mann

16

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

18

19 LAST MODIFIED:

20

21 2025-03-09 By Jess Mann

22

23"""

24 from __future__ import annotations

25

26 from datetime import datetime

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

28

29 import pydantic

30 from pydantic import ConfigDict, Field, conlist, field_serializer, field_validator, model_serializer

31 from typing_extensions import TypeVar

32 from yarl import URL

33

34 from paperap.models.abstract import FilteringStrategies, StandardModel

35 from paperap.models.document.queryset import DocumentQuerySet

36

37 if TYPE_CHECKING:

38 from paperap.models.correspondent import Correspondent

39 from paperap.models.custom_field import CustomField, CustomFieldQuerySet

40 from paperap.models.document_type import DocumentType

41 from paperap.models.storage_path import StoragePath

42 from paperap.models.tag import Tag, TagQuerySet

43 from paperap.models.user import User

44

45

46 class CustomFieldTypedDict(TypedDict):

47 field: int

48 value: Any

49

50

51 class CustomFieldValues(pydantic.BaseModel):

52 field: int

53 value: Any

54

55 model_config = ConfigDict({

56 "extra": "forbid",

57 "use_enum_values": True,

58 })

59

60 @override

61 def __eq__(self, other: Any) -> bool:

62 if isinstance(other, dict):

63 if other.keys() != {"field", "value"}:

64 return False

65 return self.field == other.get("field") and self.value == other.get("value")

66

67 if isinstance(other, CustomFieldValues):

68 return self.field == other.field and self.value == other.value

69

70 return super().__eq__(other)

71

72 class DocumentNote(StandardModel):

73 """

74 Represents a note on a Paperless-NgX document.

75 """

76

77 deleted_at: datetime | None = None

78 restored_at: datetime | None = None

79 transaction_id: int | None = None

80 note: str

81 created: datetime

82 document: int

83 user: int

84

85 class Meta(StandardModel.Meta):

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

87

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

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

90 """

91 Serialize datetime fields to ISO format.

92

93 Args:

94 value: The datetime value to serialize.

95

96 Returns:

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

98

99 """

100 return value.isoformat() if value else None

101

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

103 """

104 Get the document associated with this note.

105

106 Returns:

107 The document associated with this note.

108

109 """

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

111

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

113 """

114 Get the user who created this note.

115

116 Returns:

117 The user who created this note.

118

119 """

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

121

122

123 class Document(StandardModel):

124 """

125 Represents a Paperless-NgX document.

126

127 Attributes:

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

129 archive_serial_number: The serial number of the archive.

130 archived_file_name: The name of the archived file.

131 content: The content of the document.

132 correspondent: The correspondent associated with the document.

133 created: The timestamp when the document was created.

134 created_date: The date when the document was created.

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

136 custom_fields: Custom fields associated with the document.

137 deleted_at: The timestamp when the document was deleted.

138 document_type: The document type associated with the document.

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

140 notes: Notes associated with the document.

141 original_file_name: The original file name of the document.

142 owner: The owner of the document.

143 page_count: The number of pages in the document.

144 storage_path: The storage path of the document.

145 tags: The tags associated with the document.

146 title: The title of the document.

147 user_can_change: Whether the user can change the document.

148

149 Examples:

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

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

152 >>> document.save()

153 >>> document.title

154 'Example Document'

155

156 """

157

158 added: datetime | None = None

159 archive_serial_number: int | None = None

160 archived_file_name: str | None = None

161 content: str = ""

162 is_shared_by_requester: bool = False

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

164 original_file_name: str | None = None

165 owner: int | None = None

166 page_count: int | None = None

167 title: str = ""

168 user_can_change: bool | None = None

169

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

171 created_date: str | None = None

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

173 updated: datetime | None = Field(description="Last update timestamp", default=None)

174 deleted_at: datetime | None = None

175

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

177 correspondent_id: int | None = None

178 document_type_id: int | None = None

179 storage_path_id: int | None = None

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

181

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

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

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

185 __search_hit__: Optional[dict[str, Any]] = None

186

187 class Meta(StandardModel.Meta):

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

189 queryset = DocumentQuerySet

190 read_only_fields = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}

191 filtering_disabled = {"page_count", "deleted_at", "updated", "is_shared_by_requester"}

192 filtering_strategies = {FilteringStrategies.WHITELIST}

193 field_map = {

194 "tags": "tag_ids",

195 "custom_fields": "custom_field_dicts",

196 "document_type": "document_type_id",

197 "correspondent": "correspondent_id",

198 "storage_path": "storage_path_id",

199 }

200 supported_filtering_params = {

201 "id__in",

202 "id",

203 "title__istartswith",

204 "title__iendswith",

205 "title__icontains",

206 "title__iexact",

207 "content__istartswith",

208 "content__iendswith",

209 "content__icontains",

210 "content__iexact",

211 "archive_serial_number",

212 "archive_serial_number__gt",

213 "archive_serial_number__gte",

214 "archive_serial_number__lt",

215 "archive_serial_number__lte",

216 "archive_serial_number__isnull",

217 "content__contains", # maybe?

218 "correspondent__isnull",

219 "correspondent__id__in",

220 "correspondent__id",

221 "correspondent__name__istartswith",

222 "correspondent__name__iendswith",

223 "correspondent__name__icontains",

224 "correspondent__name__iexact",

225 "correspondent__slug__iexact", # maybe?

226 "created__year",

227 "created__month",

228 "created__day",

229 "created__date__gt",

230 "created__gt",

231 "created__date__lt",

232 "created__lt",

233 "added__year",

234 "added__month",

235 "added__day",

236 "added__date__gt",

237 "added__gt",

238 "added__date__lt",

239 "added__lt",

240 "modified__year",

241 "modified__month",

242 "modified__day",

243 "modified__date__gt",

244 "modified__gt",

245 "modified__date__lt",

246 "modified__lt",

247 "original_filename__istartswith",

248 "original_filename__iendswith",

249 "original_filename__icontains",

250 "original_filename__iexact",

251 "checksum__istartswith",

252 "checksum__iendswith",

253 "checksum__icontains",

254 "checksum__iexact",

255 "tags__id__in",

256 "tags__id",

257 "tags__name__istartswith",

258 "tags__name__iendswith",

259 "tags__name__icontains",

260 "tags__name__iexact",

261 "document_type__isnull",

262 "document_type__id__in",

263 "document_type__id",

264 "document_type__name__istartswith",

265 "document_type__name__iendswith",

266 "document_type__name__icontains",

267 "document_type__name__iexact",

268 "storage_path__isnull",

269 "storage_path__id__in",

270 "storage_path__id",

271 "storage_path__name__istartswith",

272 "storage_path__name__iendswith",

273 "storage_path__name__icontains",

274 "storage_path__name__iexact",

275 "owner__isnull",

276 "owner__id__in",

277 "owner__id",

278 "is_tagged",

279 "tags__id__all",

280 "tags__id__none",

281 "correspondent__id__none",

282 "document_type__id__none",

283 "storage_path__id__none",

284 "is_in_inbox",

285 "title_content",

286 "owner__id__none",

287 "custom_fields__icontains",

288 "custom_fields__id__all",

289 "custom_fields__id__none", # ??

290 "custom_fields__id__in",

291 "custom_field_query", # ??

292 "has_custom_fields",

293 "shared_by__id",

294 "shared_by__id__in",

295 }

296

297 @field_serializer("added", "created", "updated", "deleted_at")

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

299 """

300 Serialize datetime fields to ISO format.

301

302 Args:

303 value: The datetime value to serialize.

304

305 Returns:

306 The serialized datetime value.

307

308 """

309 return value.isoformat() if value else None

310

311 @field_serializer("notes")

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

313 """

314 Serialize notes to a list of dictionaries.

315

316 Args:

317 value: The list of DocumentNote objects to serialize.

318

319 Returns:

320 A list of dictionaries representing the notes.

321

322 """

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

324

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

326 @classmethod

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

328 """

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

330

331 Args:

332 value: The list of tag IDs to validate.

333

334 Returns:

335 A list of validated tag IDs.

336

337 """

338 if value is None:

339 return []

340

341 if isinstance(value, list):

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

343

344 if isinstance(value, int):

345 return [value]

346

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

348

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

350 @classmethod

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

352 """

353 Validate and return custom field dictionaries.

354

355 Args:

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

357

358 Returns:

359 A list of validated custom field dictionaries.

360

361 """

362 if value is None:

363 return []

364

365 if isinstance(value, list):

366 return value

367

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

369

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

371 @classmethod

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

373 """

374 Validate and return a text field.

375

376 Args:

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

378

379 Returns:

380 The validated text value.

381

382 """

383 if value is None:

384 return ""

385

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

387 return str(value)

388

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

390

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

392 @classmethod

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

394 """

395 Validate and return the list of notes.

396

397 Args:

398 value: The list of notes to validate.

399

400 Returns:

401 The validated list of notes.

402

403 """

404 if value is None:

405 return []

406

407 if isinstance(value, list):

408 return value

409

410 if isinstance(value, DocumentNote):

411 return [value]

412

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

414

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

416 @classmethod

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

418 """

419 Validate and return the is_shared_by_requester flag.

420

421 Args:

422 value: The flag to validate.

423

424 Returns:

425 The validated flag.

426

427 """

428 if value is None:

429 return False

430

431 if isinstance(value, bool):

432 return value

433

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

435

436 @property

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

438 """

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

440 """

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

442

443 @property

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

445 """

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

447 """

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

449

450 @property

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

452 """

453 Get the names of the tags for this document.

454 """

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

456

457 @property

458 def tags(self) -> TagQuerySet:

459 """

460 Get the tags for this document.

461

462 Returns:

463 List of tags associated with this document.

464

465 Examples:

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

467 >>> for tag in document.tags:

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

469 'Tag 1 # 1'

470 'Tag 2 # 2'

471 'Tag 3 # 3'

472

473 >>> if 5 in document.tags:

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

475

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

477 >>> if tag in document.tags:

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

479

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

481 >>> for tag in filtered_tags:

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

483

484 """

485 if not self.tag_ids:

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

487

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

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

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

491

492 @tags.setter

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

494 """

495 Set the tags for this document.

496

497 Args:

498 value: The tags to set.

499

500 """

501 if value is None:

502 self.tag_ids = []

503 return

504

505 if isinstance(value, Iterable):

506 for tag in value:

507 if isinstance(tag, int):

508 self.tag_ids.append(tag)

509 continue

510

511 # Check against StandardModel to avoid circular imports

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

513 if isinstance(tag, StandardModel):

514 self.tag_ids.append(tag.id)

515 continue

516

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

518 return

519

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

521

522 @property

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

524 """

525 Get the correspondent for this document.

526

527 Returns:

528 The correspondent or None if not set.

529

530 Examples:

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

532 >>> document.correspondent.name

533 'Example Correspondent'

534

535 """

536 # Return cache

537 if self._correspondent is not None:

538 pk, value = self._correspondent

539 if pk == self.correspondent_id:

540 return value

541

542 # None set to retrieve

543 if not self.correspondent_id:

544 return None

545

546 # Retrieve it

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

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

549 return correspondent

550

551 @correspondent.setter

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

553 """

554 Set the correspondent for this document.

555

556 Args:

557 value: The correspondent to set.

558

559 """

560 if value is None:

561 # Leave cache in place in case it changes again

562 self.correspondent_id = None

563 return

564

565 if isinstance(value, int):

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

567 self.correspondent_id = value

568 return

569

570 # Check against StandardModel to avoid circular imports

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

572 if isinstance(value, StandardModel):

573 self.correspondent_id = value.id

574 # Pre-populate the cache

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

576 return

577

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

579

580 @property

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

582 """

583 Get the document type for this document.

584

585 Returns:

586 The document type or None if not set.

587

588 Examples:

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

590 >>> document.document_type.name

591 'Example Document Type

592

593 """

594 # Return cache

595 if self._document_type is not None:

596 pk, value = self._document_type

597 if pk == self.document_type_id:

598 return value

599

600 # None set to retrieve

601 if not self.document_type_id:

602 return None

603

604 # Retrieve it

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

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

607 return document_type

608

609 @document_type.setter

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

611 """

612 Set the document type for this document.

613

614 Args:

615 value: The document type to set.

616

617 """

618 if value is None:

619 # Leave cache in place in case it changes again

620 self.document_type_id = None

621 return

622

623 if isinstance(value, int):

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

625 self.document_type_id = value

626 return

627

628 # Check against StandardModel to avoid circular imports

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

630 if isinstance(value, StandardModel):

631 self.document_type_id = value.id

632 # Pre-populate the cache

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

634 return

635

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

637

638 @property

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

640 """

641 Get the storage path for this document.

642

643 Returns:

644 The storage path or None if not set.

645

646 Examples:

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

648 >>> document.storage_path.name

649 'Example Storage Path'

650

651 """

652 # Return cache

653 if self._storage_path is not None:

654 pk, value = self._storage_path

655 if pk == self.storage_path_id:

656 return value

657

658 # None set to retrieve

659 if not self.storage_path_id:

660 return None

661

662 # Retrieve it

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

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

665 return storage_path

666

667 @storage_path.setter

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

669 """

670 Set the storage path for this document.

671

672 Args:

673 value: The storage path to set.

674

675 """

676 if value is None:

677 # Leave cache in place in case it changes again

678 self.storage_path_id = None

679 return

680

681 if isinstance(value, int):

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

683 self.storage_path_id = value

684 return

685

686 # Check against StandardModel to avoid circular imports

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

688 if isinstance(value, StandardModel):

689 self.storage_path_id = value.id

690 # Pre-populate the cache

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

692 return

693

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

695

696 @property

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

698 """

699 Get the custom fields for this document.

700

701 Returns:

702 List of custom fields associated with this document.

703

704 """

705 if not self.custom_field_dicts:

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

707

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

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

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

711

712 @custom_fields.setter

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

714 """

715 Set the custom fields for this document.

716

717 Args:

718 value: The custom fields to set.

719

720 """

721 if value is None:

722 self.custom_field_dicts = []

723 return

724

725 if isinstance(value, Iterable):

726 new_list: list[CustomFieldValues] = []

727 for field in value:

728 if isinstance(field, CustomFieldValues):

729 new_list.append(field)

730 continue

731

732 # isinstance(field, CustomField)

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

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

735 if isinstance(field, StandardModel):

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

737 continue

738

739 if isinstance(field, dict):

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

741 continue

742

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

744

745 self.custom_field_dicts = new_list

746 return

747

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

749

750 @property

751 def has_search_hit(self) -> bool:

752 return self.__search_hit__ is not None

753

754 @property

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

756 return self.__search_hit__

757

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

759 """

760 Get the value of a custom field by ID.

761

762 Args:

763 field_id: The ID of the custom field.

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

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

766

767 Returns:

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

769

770 """

771 for field in self.custom_field_dicts:

772 if field.field == field_id:

773 return field.value

774

775 if raise_errors:

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

777 return default

778

779 """

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

781 # Allow easy access to custom fields

782 for custom_field in self.custom_fields:

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

784 return custom_field['value']

785

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

787 """

788

789 @override

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

791 """

792 Update the document locally with the provided data.

793

794 Args:

795 from_db: Whether to update from the database.

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

797

798 Raises:

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

800

801 """

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

803 if self._meta.original_data["notes"]:

804 if "notes" in kwargs and not kwargs.get("notes"):

805 # TODO: Gracefully delete the notes instead of raising an error.

806 raise NotImplementedError(

807 f"Cannot set notes to None. Notes currently: {self._meta.original_data['notes']}"

808 )

809

810 if self._meta.original_data["tag_ids"]:

811 if "tag_ids" in kwargs and not kwargs.get("tag_ids"):

812 # TODO: Gracefully delete the tags instead of raising an error.

813 raise NotImplementedError(

814 f"Cannot set tag_ids to None. Tags currently: {self._meta.original_data['tag_ids']}"

815 )

816

817 return super().update_locally(from_db, **kwargs)