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

176 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-11 21:37 -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 qs = self 

138 if value is not None: 

139 if isinstance(value, int): 

140 qs = qs.correspondent_id(value) 

141 elif isinstance(value, str): 

142 qs = qs.correspondent_name(value, exact=exact, case_insensitive=case_insensitive) 

143 else: 

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

145 

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

147 qs = qs.correspondent_slug(slug, exact=exact, case_insensitive=case_insensitive) 

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

149 qs = qs.correspondent_id(pk) 

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

151 qs = qs.correspondent_name(name, exact=exact, case_insensitive=case_insensitive) 

152 

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

154 if qs is self: 

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

156 

157 return qs 

158 

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

160 """ 

161 Filter documents by correspondent ID. 

162 

163 Args: 

164 correspondent_id: The correspondent ID to filter by 

165 

166 Returns: 

167 Filtered DocumentQuerySet 

168 

169 """ 

170 return self.filter(correspondent__id=correspondent_id) 

171 

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

173 """ 

174 Filter documents by correspondent name. 

175 

176 Args: 

177 name: The correspondent name to filter by 

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

179 

180 Returns: 

181 Filtered DocumentQuerySet 

182 

183 """ 

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

185 

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

187 """ 

188 Filter documents by correspondent slug. 

189 

190 Args: 

191 slug: The correspondent slug to filter by 

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

193 

194 Returns: 

195 Filtered DocumentQuerySet 

196 

197 """ 

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

199 

200 def document_type( 

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

202 ) -> Self: 

203 """ 

204 Filter documents by document type. 

205 

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

207 

208 Args: 

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

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

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

212 

213 Returns: 

214 Filtered DocumentQuerySet 

215 

216 Raises: 

217 ValueError: If no valid filters are provided 

218 

219 Examples: 

220 # Filter by ID 

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

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

223 

224 # Filter by name 

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

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

227 

228 # Filter by name (exact match) 

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

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

231 

232 # Filter by ID and name 

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

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

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

236 

237 """ 

238 qs = self 

239 if value is not None: 

240 if isinstance(value, int): 

241 qs = qs.document_type_id(value) 

242 elif isinstance(value, str): 

243 qs = qs.document_type_name(value, exact=exact, case_insensitive=case_insensitive) 

244 else: 

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

246 

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

248 qs = qs.document_type_id(pk) 

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

250 qs = qs.document_type_name(name, exact=exact, case_insensitive=case_insensitive) 

251 

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

253 if qs is self: 

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

255 

256 return qs 

257 

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

259 """ 

260 Filter documents by document type ID. 

261 

262 Args: 

263 document_type_id: The document type ID to filter by 

264 

265 Returns: 

266 Filtered DocumentQuerySet 

267 

268 """ 

269 return self.filter(document_type__id=document_type_id) 

270 

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

272 """ 

273 Filter documents by document type name. 

274 

275 Args: 

276 name: The document type name to filter by 

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

278 

279 Returns: 

280 Filtered DocumentQuerySet 

281 

282 """ 

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

284 

285 def storage_path(self, value: int | str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

286 """ 

287 Filter documents by storage path. 

288 

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

290 

291 Args: 

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

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

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

295 

296 Returns: 

297 Filtered DocumentQuerySet 

298 

299 Raises: 

300 ValueError: If no valid filters are provided 

301 

302 Examples: 

303 # Filter by ID 

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

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

306 

307 # Filter by name 

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

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

310 

311 # Filter by name (exact match) 

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

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

314 

315 # Filter by ID and name 

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

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

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

319 

320 """ 

321 if isinstance(value, int): 

322 return self.storage_path_id(value) 

323 return self.storage_path_name(value, exact=exact, case_insensitive=case_insensitive) 

324 

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

326 """ 

327 Filter documents by storage path ID. 

328 

329 Args: 

330 storage_path_id: The storage path ID to filter by 

331 

332 Returns: 

333 Filtered DocumentQuerySet 

334 

335 """ 

336 return self.filter(storage_path__id=storage_path_id) 

337 

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

339 """ 

340 Filter documents by storage path name. 

341 

342 Args: 

343 name: The storage path name to filter by 

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

345 

346 Returns: 

347 Filtered DocumentQuerySet 

348 

349 """ 

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

351 

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

353 """ 

354 Filter documents whose content contains the specified text. 

355 

356 Args: 

357 text: The text to search for in document content 

358 

359 Returns: 

360 Filtered DocumentQuerySet 

361 

362 """ 

363 return self.filter(content__contains=text) 

364 

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

366 """ 

367 Filter documents added after the specified date. 

368 

369 Args: 

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

371 

372 Returns: 

373 Filtered DocumentQuerySet 

374 

375 """ 

376 return self.filter(added__gt=date_str) 

377 

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

379 """ 

380 Filter documents added before the specified date. 

381 

382 Args: 

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

384 

385 Returns: 

386 Filtered DocumentQuerySet 

387 

388 """ 

389 return self.filter(added__lt=date_str) 

390 

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

392 """ 

393 Filter documents by archive serial number. 

394 

395 Args: 

396 value: The archive serial number to filter by 

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

398 

399 Returns: 

400 Filtered DocumentQuerySet 

401 

402 """ 

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

404 

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

406 """ 

407 Filter documents by original file name. 

408 

409 Args: 

410 name: The original file name to filter by 

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

412 

413 Returns: 

414 Filtered DocumentQuerySet 

415 

416 """ 

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

418 

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

420 """ 

421 Filter documents by user change permission. 

422 

423 Args: 

424 value: True to filter documents the user can change 

425 

426 Returns: 

427 Filtered DocumentQuerySet 

428 

429 """ 

430 return self.filter(user_can_change=value) 

431 

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

433 """ 

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

435 

436 Args: 

437 value: The search string 

438 

439 Returns: 

440 Filtered DocumentQuerySet 

441 

442 """ 

443 if case_insensitive: 

444 return self.filter(custom_fields__icontains=value) 

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

446 

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

448 """ 

449 Filter documents by custom field. 

450 

451 Args: 

452 field: The name of the custom field 

453 value: The value to filter by 

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

455 

456 Returns: 

457 Filtered DocumentQuerySet 

458 

459 """ 

460 if exact: 

461 if case_insensitive: 

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

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

464 if case_insensitive: 

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

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

467 

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

469 """ 

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

471 

472 Args: 

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

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

475 

476 Returns: 

477 Filtered DocumentQuerySet 

478 

479 """ 

480 if exact: 

481 return self.filter(custom_fields__id__all=pk) 

482 return self.filter(custom_fields__id__in=pk) 

483 

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

485 if isinstance(value, tuple): 

486 # Check if it's a CustomFieldQuery 

487 try: 

488 converted_value = CustomFieldQuery(*value) 

489 return self._normalize_custom_field_query(converted_value) 

490 except TypeError: 

491 # It's a tuple, not a CustomFieldQuery 

492 pass 

493 

494 if isinstance(value, str): 

495 return f'"{value}"' 

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

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

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

499 if isinstance(value, bool): 

500 return str(value).lower() 

501 

502 return str(value) 

503 

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

505 try: 

506 if not isinstance(query, CustomFieldQuery): 

507 query = CustomFieldQuery(*query) 

508 except TypeError as te: 

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

510 

511 field, operation, value = query 

512 operation = self._normalize_custom_field_query_item(operation) 

513 value = self._normalize_custom_field_query_item(value) 

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

515 

516 @overload 

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

518 """ 

519 Filter documents by custom field query. 

520 

521 Args: 

522 query: A list representing a custom field query 

523 

524 Returns: 

525 Filtered DocumentQuerySet 

526 

527 """ 

528 ... 

529 

530 @overload 

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

532 """ 

533 Filter documents by custom field query. 

534 

535 Args: 

536 field: The name of the custom field 

537 operation: The operation to perform 

538 value: The value to filter by 

539 

540 Returns: 

541 Filtered DocumentQuerySet 

542 

543 """ 

544 ... 

545 

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

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

548 """ 

549 Filter documents by custom field query. 

550 """ 

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

552 

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

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

555 query_str = self._normalize_custom_field_query(query) 

556 return self.filter(custom_field_query=query_str) 

557 

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

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

560 query = CustomFieldQuery(field, operation, value) 

561 query_str = self._normalize_custom_field_query(query) 

562 return self.filter(custom_field_query=query_str) 

563 

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

565 """ 

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

567 

568 Args: 

569 field: The name of the custom field 

570 start: The start value of the range 

571 end: The end value of the range 

572 

573 Returns: 

574 Filtered DocumentQuerySet 

575 

576 """ 

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

578 

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

580 """ 

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

582 

583 Args: 

584 field: The name of the custom field 

585 value: The exact value to match 

586 

587 Returns: 

588 Filtered DocumentQuerySet 

589 

590 """ 

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

592 

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

594 """ 

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

596 

597 Args: 

598 field: The name of the custom field 

599 values: The list of values to match 

600 

601 Returns: 

602 Filtered DocumentQuerySet 

603 

604 """ 

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

606 

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

608 """ 

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

610 

611 Args: 

612 field: The name of the custom field 

613 

614 Returns: 

615 Filtered DocumentQuerySet 

616 

617 """ 

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

619 

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

621 """ 

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

623 

624 Args: 

625 field: The name of the custom field 

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

627 

628 Returns: 

629 Filtered DocumentQuerySet 

630 

631 """ 

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

633 

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

635 """ 

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

637 

638 Args: 

639 field: The name of the custom field 

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

641 

642 Returns: 

643 Filtered DocumentQuerySet 

644 

645 """ 

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

647 

648 def has_custom_fields(self) -> Self: 

649 """ 

650 Filter documents that have custom fields. 

651 """ 

652 return self.filter(has_custom_fields=True) 

653 

654 def no_custom_fields(self) -> Self: 

655 """ 

656 Filter documents that do not have custom fields. 

657 """ 

658 return self.filter(has_custom_fields=False) 

659 

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

661 """ 

662 Filter documents whose notes contain the specified text. 

663 

664 Args: 

665 text: The text to search for in document notes 

666 

667 Returns: 

668 Filtered DocumentQuerySet 

669 

670 """ 

671 return self.filter(notes__contains=text) 

672 

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

674 """ 

675 Filter models created before a given date. 

676 

677 Args: 

678 date: The date to filter by 

679 

680 Returns: 

681 Filtered QuerySet 

682 

683 """ 

684 if isinstance(date, datetime): 

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

686 return self.filter(created__lt=date) 

687 

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

689 """ 

690 Filter models created after a given date. 

691 

692 Args: 

693 date: The date to filter by 

694 

695 Returns: 

696 Filtered QuerySet 

697 

698 """ 

699 if isinstance(date, datetime): 

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

701 return self.filter(created__gt=date) 

702 

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

704 """ 

705 Filter models created between two dates. 

706 

707 Args: 

708 start: The start date to filter by 

709 end: The end date to filter by 

710 

711 Returns: 

712 Filtered QuerySet 

713 

714 """ 

715 if isinstance(start, datetime): 

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

717 if isinstance(end, datetime): 

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

719 

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