Coverage for src/paperap/models/document/queryset.py: 98%

203 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-18 12:26 -0400

1""" 

2---------------------------------------------------------------------------- 

3 

4 METADATA: 

5 

6 File: queryset.py 

7 Project: paperap 

8 Created: 2025-03-04 

9 Version: 0.0.5 

10 Author: Jess Mann 

11 Email: jess@jmann.me 

12 Copyright (c) 2025 Jess Mann 

13 

14---------------------------------------------------------------------------- 

15 

16 LAST MODIFIED: 

17 

18 2025-03-04 By Jess Mann 

19 

20""" 

21 

22from __future__ import annotations 

23 

24import logging 

25from datetime import datetime 

26from functools import singledispatchmethod 

27from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Self, Union, overload 

28 

29from paperap.models.abstract.queryset import BaseQuerySet, StandardQuerySet 

30from paperap.models.mixins.queryset import HasOwner 

31 

32if TYPE_CHECKING: 

33 from paperap.models.correspondent import Correspondent 

34 from paperap.models.document.model import Document 

35 

36logger = logging.getLogger(__name__) 

37 

38_OperationType = Union[str, "_QueryParam"] 

39_QueryParam = Union["CustomFieldQuery", tuple[str, _OperationType, Any]] 

40 

41 

42class CustomFieldQuery(NamedTuple): 

43 field: str 

44 operation: _OperationType 

45 value: Any 

46 

47 

48class DocumentQuerySet(StandardQuerySet["Document"], HasOwner): 

49 """ 

50 QuerySet for Paperless-ngx documents with specialized filtering methods. 

51 """ 

52 

53 def tag_id(self, tag_id: int | list[int]) -> Self: 

54 """ 

55 Filter documents that have the specified tag ID(s). 

56 

57 Args: 

58 tag_id: A single tag ID or list of tag IDs 

59 

60 Returns: 

61 Filtered DocumentQuerySet 

62 

63 """ 

64 if isinstance(tag_id, list): 

65 return self.filter(tags__id__in=tag_id) 

66 return self.filter(tags__id=tag_id) 

67 

68 def tag_name(self, tag_name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

69 """ 

70 Filter documents that have a tag with the specified name. 

71 

72 Args: 

73 tag_name: The name of the tag 

74 exact: If True, match the exact tag name, otherwise use contains 

75 

76 Returns: 

77 Filtered DocumentQuerySet 

78 

79 """ 

80 return self.filter_field_by_str("tags__name", tag_name, exact=exact, case_insensitive=case_insensitive) 

81 

82 def title(self, title: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

83 """ 

84 Filter documents by title. 

85 

86 Args: 

87 title: The document title to filter by 

88 exact: If True, match the exact title, otherwise use contains 

89 

90 Returns: 

91 Filtered DocumentQuerySet 

92 

93 """ 

94 return self.filter_field_by_str("title", title, exact=exact, case_insensitive=case_insensitive) 

95 

96 def correspondent( 

97 self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs 

98 ) -> Self: 

99 """ 

100 Filter documents by correspondent. 

101 

102 Any number of filter arguments can be provided, but at least one must be specified. 

103 

104 Args: 

105 value: The correspondent ID or name to filter by 

106 exact: If True, match the exact value, otherwise use contains 

107 **kwargs: Additional filters (slug, id, name) 

108 

109 Returns: 

110 Filtered DocumentQuerySet 

111 

112 Raises: 

113 ValueError: If no valid filters are provided 

114 

115 Examples: 

116 # Filter by ID 

117 client.documents().all().correspondent(1) 

118 client.documents().all().correspondent(id=1) 

119 

120 # Filter by name 

121 client.documents().all().correspondent("John Doe") 

122 client.documents().all().correspondent(name="John Doe") 

123 

124 # Filter by name (exact match) 

125 client.documents().all().correspondent("John Doe", exact=True) 

126 client.documents().all().correspondent(name="John Doe", exact=True) 

127 

128 # Filter by slug 

129 client.documents().all().correspondent(slug="john-doe") 

130 

131 # Filter by ID and name 

132 client.documents().all().correspondent(1, name="John Doe") 

133 client.documents().all().correspondent(id=1, name="John Doe") 

134 client.documents().all().correspondent("John Doe", id=1) 

135 

136 """ 

137 # Track if any filters were applied 

138 filters_applied = False 

139 result = self 

140 

141 if value is not None: 

142 if isinstance(value, int): 

143 result = self.correspondent_id(value) 

144 filters_applied = True 

145 elif isinstance(value, str): 

146 result = self.correspondent_name(value, exact=exact, case_insensitive=case_insensitive) 

147 filters_applied = True 

148 else: 

149 raise TypeError("Invalid value type for correspondent filter") 

150 

151 if (slug := kwargs.get("slug")) is not None: 

152 result = result.correspondent_slug(slug, exact=exact, case_insensitive=case_insensitive) 

153 filters_applied = True 

154 if (pk := kwargs.get("id")) is not None: 

155 result = result.correspondent_id(pk) 

156 filters_applied = True 

157 if (name := kwargs.get("name")) is not None: 

158 result = result.correspondent_name(name, exact=exact, case_insensitive=case_insensitive) 

159 filters_applied = True 

160 

161 # If no filters have been applied, raise an error 

162 if not filters_applied: 

163 raise ValueError("No valid filters provided for correspondent") 

164 

165 return result 

166 

167 def correspondent_id(self, correspondent_id: int) -> Self: 

168 """ 

169 Filter documents by correspondent ID. 

170 

171 Args: 

172 correspondent_id: The correspondent ID to filter by 

173 

174 Returns: 

175 Filtered DocumentQuerySet 

176 

177 """ 

178 return self.filter(correspondent__id=correspondent_id) 

179 

180 def correspondent_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

181 """ 

182 Filter documents by correspondent name. 

183 

184 Args: 

185 name: The correspondent name to filter by 

186 exact: If True, match the exact name, otherwise use contains 

187 

188 Returns: 

189 Filtered DocumentQuerySet 

190 

191 """ 

192 return self.filter_field_by_str("correspondent__name", name, exact=exact, case_insensitive=case_insensitive) 

193 

194 def correspondent_slug(self, slug: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

195 """ 

196 Filter documents by correspondent slug. 

197 

198 Args: 

199 slug: The correspondent slug to filter by 

200 exact: If True, match the exact slug, otherwise use contains 

201 

202 Returns: 

203 Filtered DocumentQuerySet 

204 

205 """ 

206 return self.filter_field_by_str("correspondent__slug", slug, exact=exact, case_insensitive=case_insensitive) 

207 

208 def document_type( 

209 self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs 

210 ) -> Self: 

211 """ 

212 Filter documents by document type. 

213 

214 Any number of filter arguments can be provided, but at least one must be specified. 

215 

216 Args: 

217 value: The document type ID or name to filter by 

218 exact: If True, match the exact value, otherwise use contains 

219 **kwargs: Additional filters (id, name) 

220 

221 Returns: 

222 Filtered DocumentQuerySet 

223 

224 Raises: 

225 ValueError: If no valid filters are provided 

226 

227 Examples: 

228 # Filter by ID 

229 client.documents().all().document_type(1) 

230 client.documents().all().document_type(id=1) 

231 

232 # Filter by name 

233 client.documents().all().document_type("Invoice") 

234 client.documents().all().document_type(name="Invoice") 

235 

236 # Filter by name (exact match) 

237 client.documents().all().document_type("Invoice", exact=True) 

238 client.documents().all().document_type(name="Invoice", exact=True) 

239 

240 # Filter by ID and name 

241 client.documents().all().document_type(1, name="Invoice") 

242 client.documents().all().document_type(id=1, name="Invoice") 

243 client.documents().all().document_type("Invoice", id=1) 

244 

245 """ 

246 # Track if any filters were applied 

247 filters_applied = False 

248 result = self 

249 

250 if value is not None: 

251 if isinstance(value, int): 

252 result = self.document_type_id(value) 

253 filters_applied = True 

254 elif isinstance(value, str): 

255 result = self.document_type_name(value, exact=exact, case_insensitive=case_insensitive) 

256 filters_applied = True 

257 else: 

258 raise TypeError("Invalid value type for document type filter") 

259 

260 if (pk := kwargs.get("id")) is not None: 

261 result = result.document_type_id(pk) 

262 filters_applied = True 

263 if (name := kwargs.get("name")) is not None: 

264 result = result.document_type_name(name, exact=exact, case_insensitive=case_insensitive) 

265 filters_applied = True 

266 

267 # If no filters have been applied, raise an error 

268 if not filters_applied: 

269 raise ValueError("No valid filters provided for document type") 

270 

271 return result 

272 

273 def document_type_id(self, document_type_id: int) -> Self: 

274 """ 

275 Filter documents by document type ID. 

276 

277 Args: 

278 document_type_id: The document type ID to filter by 

279 

280 Returns: 

281 Filtered DocumentQuerySet 

282 

283 """ 

284 return self.filter(document_type__id=document_type_id) 

285 

286 def document_type_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

287 """ 

288 Filter documents by document type name. 

289 

290 Args: 

291 name: The document type name to filter by 

292 exact: If True, match the exact name, otherwise use contains 

293 

294 Returns: 

295 Filtered DocumentQuerySet 

296 

297 """ 

298 return self.filter_field_by_str("document_type__name", name, exact=exact, case_insensitive=case_insensitive) 

299 

300 def storage_path( 

301 self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs 

302 ) -> Self: 

303 """ 

304 Filter documents by storage path. 

305 

306 Any number of filter arguments can be provided, but at least one must be specified. 

307 

308 Args: 

309 value: The storage path ID or name to filter by 

310 exact: If True, match the exact value, otherwise use contains 

311 **kwargs: Additional filters (id, name) 

312 

313 Returns: 

314 Filtered DocumentQuerySet 

315 

316 Raises: 

317 ValueError: If no valid filters are provided 

318 

319 Examples: 

320 # Filter by ID 

321 client.documents().all().storage_path(1) 

322 client.documents().all().storage_path(id=1) 

323 

324 # Filter by name 

325 client.documents().all().storage_path("Invoices") 

326 client.documents().all().storage_path(name="Invoices") 

327 

328 # Filter by name (exact match) 

329 client.documents().all().storage_path("Invoices", exact=True) 

330 client.documents().all().storage_path(name="Invoices", exact=True) 

331 

332 # Filter by ID and name 

333 client.documents().all().storage_path(1, name="Invoices") 

334 client.documents().all().storage_path(id=1, name="Invoices") 

335 client.documents().all().storage_path("Invoices", id=1) 

336 

337 """ 

338 # Track if any filters were applied 

339 filters_applied = False 

340 result = self 

341 

342 if value is not None: 

343 if isinstance(value, int): 

344 result = self.storage_path_id(value) 

345 filters_applied = True 

346 elif isinstance(value, str): 

347 result = self.storage_path_name(value, exact=exact, case_insensitive=case_insensitive) 

348 filters_applied = True 

349 else: 

350 raise TypeError("Invalid value type for storage path filter") 

351 

352 if (pk := kwargs.get("id")) is not None: 

353 result = result.storage_path_id(pk) 

354 filters_applied = True 

355 if (name := kwargs.get("name")) is not None: 

356 result = result.storage_path_name(name, exact=exact, case_insensitive=case_insensitive) 

357 filters_applied = True 

358 

359 # If no filters have been applied, raise an error 

360 if not filters_applied: 

361 raise ValueError("No valid filters provided for storage path") 

362 

363 return result 

364 

365 def storage_path_id(self, storage_path_id: int) -> Self: 

366 """ 

367 Filter documents by storage path ID. 

368 

369 Args: 

370 storage_path_id: The storage path ID to filter by 

371 

372 Returns: 

373 Filtered DocumentQuerySet 

374 

375 """ 

376 return self.filter(storage_path__id=storage_path_id) 

377 

378 def storage_path_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

379 """ 

380 Filter documents by storage path name. 

381 

382 Args: 

383 name: The storage path name to filter by 

384 exact: If True, match the exact name, otherwise use contains 

385 

386 Returns: 

387 Filtered DocumentQuerySet 

388 

389 """ 

390 return self.filter_field_by_str("storage_path__name", name, exact=exact, case_insensitive=case_insensitive) 

391 

392 def content(self, text: str) -> Self: 

393 """ 

394 Filter documents whose content contains the specified text. 

395 

396 Args: 

397 text: The text to search for in document content 

398 

399 Returns: 

400 Filtered DocumentQuerySet 

401 

402 """ 

403 return self.filter(content__contains=text) 

404 

405 def added_after(self, date_str: str) -> Self: 

406 """ 

407 Filter documents added after the specified date. 

408 

409 Args: 

410 date_str: ISO format date string (YYYY-MM-DD) 

411 

412 Returns: 

413 Filtered DocumentQuerySet 

414 

415 """ 

416 return self.filter(added__gt=date_str) 

417 

418 def added_before(self, date_str: str) -> Self: 

419 """ 

420 Filter documents added before the specified date. 

421 

422 Args: 

423 date_str: ISO format date string (YYYY-MM-DD) 

424 

425 Returns: 

426 Filtered DocumentQuerySet 

427 

428 """ 

429 return self.filter(added__lt=date_str) 

430 

431 def asn(self, value: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

432 """ 

433 Filter documents by archive serial number. 

434 

435 Args: 

436 value: The archive serial number to filter by 

437 exact: If True, match the exact value, otherwise use contains 

438 

439 Returns: 

440 Filtered DocumentQuerySet 

441 

442 """ 

443 return self.filter_field_by_str("asn", value, exact=exact, case_insensitive=case_insensitive) 

444 

445 def original_file_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

446 """ 

447 Filter documents by original file name. 

448 

449 Args: 

450 name: The original file name to filter by 

451 exact: If True, match the exact name, otherwise use contains 

452 

453 Returns: 

454 Filtered DocumentQuerySet 

455 

456 """ 

457 return self.filter_field_by_str("original_file_name", name, exact=exact, case_insensitive=case_insensitive) 

458 

459 def user_can_change(self, value: bool) -> Self: 

460 """ 

461 Filter documents by user change permission. 

462 

463 Args: 

464 value: True to filter documents the user can change 

465 

466 Returns: 

467 Filtered DocumentQuerySet 

468 

469 """ 

470 return self.filter(user_can_change=value) 

471 

472 def custom_field_fullsearch(self, value: str, *, case_insensitive: bool = True) -> Self: 

473 """ 

474 Filter documents by searching through both custom field name and value. 

475 

476 Args: 

477 value: The search string 

478 

479 Returns: 

480 Filtered DocumentQuerySet 

481 

482 """ 

483 if case_insensitive: 

484 return self.filter(custom_fields__icontains=value) 

485 raise NotImplementedError("Case-sensitive custom field search is not supported by Paperless NGX") 

486 

487 def custom_field(self, field: str, value: Any, *, exact: bool = False, case_insensitive: bool = True) -> Self: 

488 """ 

489 Filter documents by custom field. 

490 

491 Args: 

492 field: The name of the custom field 

493 value: The value to filter by 

494 exact: If True, match the exact value, otherwise use contains 

495 

496 Returns: 

497 Filtered DocumentQuerySet 

498 

499 """ 

500 if exact: 

501 if case_insensitive: 

502 return self.custom_field_query(field, "iexact", value) 

503 return self.custom_field_query(field, "exact", value) 

504 if case_insensitive: 

505 return self.custom_field_query(field, "icontains", value) 

506 return self.custom_field_query(field, "contains", value) 

507 

508 def has_custom_field_id(self, pk: int | list[int], *, exact: bool = False) -> Self: 

509 """ 

510 Filter documents that have a custom field with the specified ID(s). 

511 

512 Args: 

513 pk: A single custom field ID or list of custom field IDs 

514 exact: If True, return results that have exactly these ids and no others 

515 

516 Returns: 

517 Filtered DocumentQuerySet 

518 

519 """ 

520 if exact: 

521 return self.filter(custom_fields__id__all=pk) 

522 return self.filter(custom_fields__id__in=pk) 

523 

524 def _normalize_custom_field_query_item(self, value: Any) -> str: 

525 if isinstance(value, tuple): 

526 # Check if it's a CustomFieldQuery 

527 try: 

528 converted_value = CustomFieldQuery(*value) 

529 return self._normalize_custom_field_query(converted_value) 

530 except TypeError: 

531 # It's a tuple, not a CustomFieldQuery 

532 pass 

533 

534 if isinstance(value, str): 

535 return f'"{value}"' 

536 if isinstance(value, (list, tuple)): 

537 values = [str(self._normalize_custom_field_query_item(v)) for v in value] 

538 return f"[{', '.join(values)}]" 

539 if isinstance(value, bool): 

540 return str(value).lower() 

541 

542 return str(value) 

543 

544 def _normalize_custom_field_query(self, query: _QueryParam) -> str: 

545 try: 

546 if not isinstance(query, CustomFieldQuery): 

547 query = CustomFieldQuery(*query) 

548 except TypeError as te: 

549 raise TypeError("Invalid custom field query format") from te 

550 

551 field, operation, value = query 

552 operation = self._normalize_custom_field_query_item(operation) 

553 value = self._normalize_custom_field_query_item(value) 

554 return f'["{field}", {operation}, {value}]' 

555 

556 @overload 

557 def custom_field_query(self, query: _QueryParam) -> Self: 

558 """ 

559 Filter documents by custom field query. 

560 

561 Args: 

562 query: A list representing a custom field query 

563 

564 Returns: 

565 Filtered DocumentQuerySet 

566 

567 """ 

568 ... 

569 

570 @overload 

571 def custom_field_query(self, field: str, operation: _OperationType, value: Any) -> Self: 

572 """ 

573 Filter documents by custom field query. 

574 

575 Args: 

576 field: The name of the custom field 

577 operation: The operation to perform 

578 value: The value to filter by 

579 

580 Returns: 

581 Filtered DocumentQuerySet 

582 

583 """ 

584 ... 

585 

586 @singledispatchmethod # type: ignore # mypy does not handle singledispatchmethod with overloads correctly 

587 def custom_field_query(self, *args, **kwargs: Any) -> Self: 

588 """ 

589 Filter documents by custom field query. 

590 """ 

591 raise TypeError("Invalid custom field query format") 

592 

593 @custom_field_query.register # type: ignore # mypy does not handle singledispatchmethod with overloads correctly 

594 def _(self, query: CustomFieldQuery) -> Self: 

595 query_str = self._normalize_custom_field_query(query) 

596 return self.filter(custom_field_query=query_str) 

597 

598 @custom_field_query.register # type: ignore # mypy does not handle singledispatchmethod with overloads correctly 

599 def _(self, field: str, operation: str | CustomFieldQuery | tuple[str, Any, Any], value: Any) -> Self: 

600 query = CustomFieldQuery(field, operation, value) 

601 query_str = self._normalize_custom_field_query(query) 

602 return self.filter(custom_field_query=query_str) 

603 

604 def custom_field_range(self, field: str, start: str, end: str) -> Self: 

605 """ 

606 Filter documents with a custom field value within a specified range. 

607 

608 Args: 

609 field: The name of the custom field 

610 start: The start value of the range 

611 end: The end value of the range 

612 

613 Returns: 

614 Filtered DocumentQuerySet 

615 

616 """ 

617 return self.custom_field_query(field, "range", [start, end]) 

618 

619 def custom_field_exact(self, field: str, value: Any) -> Self: 

620 """ 

621 Filter documents with a custom field value that matches exactly. 

622 

623 Args: 

624 field: The name of the custom field 

625 value: The exact value to match 

626 

627 Returns: 

628 Filtered DocumentQuerySet 

629 

630 """ 

631 return self.custom_field_query(field, "exact", value) 

632 

633 def custom_field_in(self, field: str, values: list[Any]) -> Self: 

634 """ 

635 Filter documents with a custom field value in a list of values. 

636 

637 Args: 

638 field: The name of the custom field 

639 values: The list of values to match 

640 

641 Returns: 

642 Filtered DocumentQuerySet 

643 

644 """ 

645 return self.custom_field_query(field, "in", values) 

646 

647 def custom_field_isnull(self, field: str) -> Self: 

648 """ 

649 Filter documents with a custom field that is null or empty. 

650 

651 Args: 

652 field: The name of the custom field 

653 

654 Returns: 

655 Filtered DocumentQuerySet 

656 

657 """ 

658 return self.custom_field_query("OR", (field, "isnull", True), [field, "exact", ""]) 

659 

660 def custom_field_exists(self, field: str, exists: bool = True) -> Self: 

661 """ 

662 Filter documents based on the existence of a custom field. 

663 

664 Args: 

665 field: The name of the custom field 

666 exists: True to filter documents where the field exists, False otherwise 

667 

668 Returns: 

669 Filtered DocumentQuerySet 

670 

671 """ 

672 return self.custom_field_query(field, "exists", exists) 

673 

674 def custom_field_contains(self, field: str, values: list[Any]) -> Self: 

675 """ 

676 Filter documents with a custom field that contains all specified values. 

677 

678 Args: 

679 field: The name of the custom field 

680 values: The list of values that the field should contain 

681 

682 Returns: 

683 Filtered DocumentQuerySet 

684 

685 """ 

686 return self.custom_field_query(field, "contains", values) 

687 

688 def has_custom_fields(self) -> Self: 

689 """ 

690 Filter documents that have custom fields. 

691 """ 

692 return self.filter(has_custom_fields=True) 

693 

694 def no_custom_fields(self) -> Self: 

695 """ 

696 Filter documents that do not have custom fields. 

697 """ 

698 return self.filter(has_custom_fields=False) 

699 

700 def notes(self, text: str) -> Self: 

701 """ 

702 Filter documents whose notes contain the specified text. 

703 

704 Args: 

705 text: The text to search for in document notes 

706 

707 Returns: 

708 Filtered DocumentQuerySet 

709 

710 """ 

711 return self.filter(notes__contains=text) 

712 

713 def created_before(self, date: datetime | str) -> Self: 

714 """ 

715 Filter models created before a given date. 

716 

717 Args: 

718 date: The date to filter by 

719 

720 Returns: 

721 Filtered QuerySet 

722 

723 """ 

724 if isinstance(date, datetime): 

725 return self.filter(created__lt=date.strftime("%Y-%m-%d")) 

726 return self.filter(created__lt=date) 

727 

728 def created_after(self, date: datetime | str) -> Self: 

729 """ 

730 Filter models created after a given date. 

731 

732 Args: 

733 date: The date to filter by 

734 

735 Returns: 

736 Filtered QuerySet 

737 

738 """ 

739 if isinstance(date, datetime): 

740 return self.filter(created__gt=date.strftime("%Y-%m-%d")) 

741 return self.filter(created__gt=date) 

742 

743 def created_between(self, start: datetime | str, end: datetime | str) -> Self: 

744 """ 

745 Filter models created between two dates. 

746 

747 Args: 

748 start: The start date to filter by 

749 end: The end date to filter by 

750 

751 Returns: 

752 Filtered QuerySet 

753 

754 """ 

755 if isinstance(start, datetime): 

756 start = start.strftime("%Y-%m-%d") 

757 if isinstance(end, datetime): 

758 end = end.strftime("%Y-%m-%d") 

759 

760 return self.filter(created__range=(start, end))