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

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

22 from __future__ import annotations

23

24 import logging

25 from datetime import datetime

26 from functools import singledispatchmethod

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

28

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

30 from paperap.models.mixins.queryset import HasOwner

31

32 if TYPE_CHECKING:

33 from paperap.models.correspondent import Correspondent

34 from paperap.models.document.model import Document

35

36 logger = logging.getLogger(__name__)

37

38 _OperationType = Union[str, "_QueryParam"]

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

40

41

42 class CustomFieldQuery(NamedTuple):

43 field: str

44 operation: _OperationType

45 value: Any

46

47

48 class 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))