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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
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
14----------------------------------------------------------------------------
16 LAST MODIFIED:
18 2025-03-04 By Jess Mann
20"""
22from __future__ import annotations
24import logging
25from datetime import datetime
26from functools import singledispatchmethod
27from typing import TYPE_CHECKING, Any, NamedTuple, Optional, Self, Union, overload
29from paperap.models.abstract.queryset import BaseQuerySet, StandardQuerySet
30from paperap.models.mixins.queryset import HasOwner
32if TYPE_CHECKING:
33 from paperap.models.correspondent import Correspondent
34 from paperap.models.document.model import Document
36logger = logging.getLogger(__name__)
38_OperationType = Union[str, "_QueryParam"]
39_QueryParam = Union["CustomFieldQuery", tuple[str, _OperationType, Any]]
42class CustomFieldQuery(NamedTuple):
43 field: str
44 operation: _OperationType
45 value: Any
48class DocumentQuerySet(StandardQuerySet["Document"], HasOwner):
49 """
50 QuerySet for Paperless-ngx documents with specialized filtering methods.
51 """
53 def tag_id(self, tag_id: int | list[int]) -> Self:
54 """
55 Filter documents that have the specified tag ID(s).
57 Args:
58 tag_id: A single tag ID or list of tag IDs
60 Returns:
61 Filtered DocumentQuerySet
63 """
64 if isinstance(tag_id, list):
65 return self.filter(tags__id__in=tag_id)
66 return self.filter(tags__id=tag_id)
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.
72 Args:
73 tag_name: The name of the tag
74 exact: If True, match the exact tag name, otherwise use contains
76 Returns:
77 Filtered DocumentQuerySet
79 """
80 return self.filter_field_by_str("tags__name", tag_name, exact=exact, case_insensitive=case_insensitive)
82 def title(self, title: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
83 """
84 Filter documents by title.
86 Args:
87 title: The document title to filter by
88 exact: If True, match the exact title, otherwise use contains
90 Returns:
91 Filtered DocumentQuerySet
93 """
94 return self.filter_field_by_str("title", title, exact=exact, case_insensitive=case_insensitive)
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.
102 Any number of filter arguments can be provided, but at least one must be specified.
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)
109 Returns:
110 Filtered DocumentQuerySet
112 Raises:
113 ValueError: If no valid filters are provided
115 Examples:
116 # Filter by ID
117 client.documents().all().correspondent(1)
118 client.documents().all().correspondent(id=1)
120 # Filter by name
121 client.documents().all().correspondent("John Doe")
122 client.documents().all().correspondent(name="John Doe")
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)
128 # Filter by slug
129 client.documents().all().correspondent(slug="john-doe")
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)
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")
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)
153 # If no filters have been applied, raise an error
154 if qs is self:
155 raise ValueError("No valid filters provided for correspondent")
157 return qs
159 def correspondent_id(self, correspondent_id: int) -> Self:
160 """
161 Filter documents by correspondent ID.
163 Args:
164 correspondent_id: The correspondent ID to filter by
166 Returns:
167 Filtered DocumentQuerySet
169 """
170 return self.filter(correspondent__id=correspondent_id)
172 def correspondent_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
173 """
174 Filter documents by correspondent name.
176 Args:
177 name: The correspondent name to filter by
178 exact: If True, match the exact name, otherwise use contains
180 Returns:
181 Filtered DocumentQuerySet
183 """
184 return self.filter_field_by_str("correspondent__name", name, exact=exact, case_insensitive=case_insensitive)
186 def correspondent_slug(self, slug: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
187 """
188 Filter documents by correspondent slug.
190 Args:
191 slug: The correspondent slug to filter by
192 exact: If True, match the exact slug, otherwise use contains
194 Returns:
195 Filtered DocumentQuerySet
197 """
198 return self.filter_field_by_str("correspondent__slug", slug, exact=exact, case_insensitive=case_insensitive)
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.
206 Any number of filter arguments can be provided, but at least one must be specified.
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)
213 Returns:
214 Filtered DocumentQuerySet
216 Raises:
217 ValueError: If no valid filters are provided
219 Examples:
220 # Filter by ID
221 client.documents().all().document_type(1)
222 client.documents().all().document_type(id=1)
224 # Filter by name
225 client.documents().all().document_type("Invoice")
226 client.documents().all().document_type(name="Invoice")
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)
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)
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")
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)
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")
256 return qs
258 def document_type_id(self, document_type_id: int) -> Self:
259 """
260 Filter documents by document type ID.
262 Args:
263 document_type_id: The document type ID to filter by
265 Returns:
266 Filtered DocumentQuerySet
268 """
269 return self.filter(document_type__id=document_type_id)
271 def document_type_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
272 """
273 Filter documents by document type name.
275 Args:
276 name: The document type name to filter by
277 exact: If True, match the exact name, otherwise use contains
279 Returns:
280 Filtered DocumentQuerySet
282 """
283 return self.filter_field_by_str("document_type__name", name, exact=exact, case_insensitive=case_insensitive)
285 def storage_path(self, value: int | str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
286 """
287 Filter documents by storage path.
289 Any number of filter arguments can be provided, but at least one must be specified.
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)
296 Returns:
297 Filtered DocumentQuerySet
299 Raises:
300 ValueError: If no valid filters are provided
302 Examples:
303 # Filter by ID
304 client.documents().all().storage_path(1)
305 client.documents().all().storage_path(id=1)
307 # Filter by name
308 client.documents().all().storage_path("Invoices")
309 client.documents().all().storage_path(name="Invoices")
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)
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)
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)
325 def storage_path_id(self, storage_path_id: int) -> Self:
326 """
327 Filter documents by storage path ID.
329 Args:
330 storage_path_id: The storage path ID to filter by
332 Returns:
333 Filtered DocumentQuerySet
335 """
336 return self.filter(storage_path__id=storage_path_id)
338 def storage_path_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
339 """
340 Filter documents by storage path name.
342 Args:
343 name: The storage path name to filter by
344 exact: If True, match the exact name, otherwise use contains
346 Returns:
347 Filtered DocumentQuerySet
349 """
350 return self.filter_field_by_str("storage_path__name", name, exact=exact, case_insensitive=case_insensitive)
352 def content(self, text: str) -> Self:
353 """
354 Filter documents whose content contains the specified text.
356 Args:
357 text: The text to search for in document content
359 Returns:
360 Filtered DocumentQuerySet
362 """
363 return self.filter(content__contains=text)
365 def added_after(self, date_str: str) -> Self:
366 """
367 Filter documents added after the specified date.
369 Args:
370 date_str: ISO format date string (YYYY-MM-DD)
372 Returns:
373 Filtered DocumentQuerySet
375 """
376 return self.filter(added__gt=date_str)
378 def added_before(self, date_str: str) -> Self:
379 """
380 Filter documents added before the specified date.
382 Args:
383 date_str: ISO format date string (YYYY-MM-DD)
385 Returns:
386 Filtered DocumentQuerySet
388 """
389 return self.filter(added__lt=date_str)
391 def asn(self, value: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
392 """
393 Filter documents by archive serial number.
395 Args:
396 value: The archive serial number to filter by
397 exact: If True, match the exact value, otherwise use contains
399 Returns:
400 Filtered DocumentQuerySet
402 """
403 return self.filter_field_by_str("asn", value, exact=exact, case_insensitive=case_insensitive)
405 def original_file_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
406 """
407 Filter documents by original file name.
409 Args:
410 name: The original file name to filter by
411 exact: If True, match the exact name, otherwise use contains
413 Returns:
414 Filtered DocumentQuerySet
416 """
417 return self.filter_field_by_str("original_file_name", name, exact=exact, case_insensitive=case_insensitive)
419 def user_can_change(self, value: bool) -> Self:
420 """
421 Filter documents by user change permission.
423 Args:
424 value: True to filter documents the user can change
426 Returns:
427 Filtered DocumentQuerySet
429 """
430 return self.filter(user_can_change=value)
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.
436 Args:
437 value: The search string
439 Returns:
440 Filtered DocumentQuerySet
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")
447 def custom_field(self, field: str, value: Any, *, exact: bool = False, case_insensitive: bool = True) -> Self:
448 """
449 Filter documents by custom field.
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
456 Returns:
457 Filtered DocumentQuerySet
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)
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).
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
476 Returns:
477 Filtered DocumentQuerySet
479 """
480 if exact:
481 return self.filter(custom_fields__id__all=pk)
482 return self.filter(custom_fields__id__in=pk)
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
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()
502 return str(value)
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
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}]'
516 @overload
517 def custom_field_query(self, query: _QueryParam) -> Self:
518 """
519 Filter documents by custom field query.
521 Args:
522 query: A list representing a custom field query
524 Returns:
525 Filtered DocumentQuerySet
527 """
528 ...
530 @overload
531 def custom_field_query(self, field: str, operation: _OperationType, value: Any) -> Self:
532 """
533 Filter documents by custom field query.
535 Args:
536 field: The name of the custom field
537 operation: The operation to perform
538 value: The value to filter by
540 Returns:
541 Filtered DocumentQuerySet
543 """
544 ...
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")
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)
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)
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.
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
573 Returns:
574 Filtered DocumentQuerySet
576 """
577 return self.custom_field_query(field, "range", [start, end])
579 def custom_field_exact(self, field: str, value: Any) -> Self:
580 """
581 Filter documents with a custom field value that matches exactly.
583 Args:
584 field: The name of the custom field
585 value: The exact value to match
587 Returns:
588 Filtered DocumentQuerySet
590 """
591 return self.custom_field_query(field, "exact", value)
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.
597 Args:
598 field: The name of the custom field
599 values: The list of values to match
601 Returns:
602 Filtered DocumentQuerySet
604 """
605 return self.custom_field_query(field, "in", values)
607 def custom_field_isnull(self, field: str) -> Self:
608 """
609 Filter documents with a custom field that is null or empty.
611 Args:
612 field: The name of the custom field
614 Returns:
615 Filtered DocumentQuerySet
617 """
618 return self.custom_field_query("OR", (field, "isnull", True), [field, "exact", ""])
620 def custom_field_exists(self, field: str, exists: bool = True) -> Self:
621 """
622 Filter documents based on the existence of a custom field.
624 Args:
625 field: The name of the custom field
626 exists: True to filter documents where the field exists, False otherwise
628 Returns:
629 Filtered DocumentQuerySet
631 """
632 return self.custom_field_query(field, "exists", exists)
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.
638 Args:
639 field: The name of the custom field
640 values: The list of values that the field should contain
642 Returns:
643 Filtered DocumentQuerySet
645 """
646 return self.custom_field_query(field, "contains", values)
648 def has_custom_fields(self) -> Self:
649 """
650 Filter documents that have custom fields.
651 """
652 return self.filter(has_custom_fields=True)
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)
660 def notes(self, text: str) -> Self:
661 """
662 Filter documents whose notes contain the specified text.
664 Args:
665 text: The text to search for in document notes
667 Returns:
668 Filtered DocumentQuerySet
670 """
671 return self.filter(notes__contains=text)
673 def created_before(self, date: datetime | str) -> Self:
674 """
675 Filter models created before a given date.
677 Args:
678 date: The date to filter by
680 Returns:
681 Filtered QuerySet
683 """
684 if isinstance(date, datetime):
685 return self.filter(created__lt=date.strftime("%Y-%m-%d"))
686 return self.filter(created__lt=date)
688 def created_after(self, date: datetime | str) -> Self:
689 """
690 Filter models created after a given date.
692 Args:
693 date: The date to filter by
695 Returns:
696 Filtered QuerySet
698 """
699 if isinstance(date, datetime):
700 return self.filter(created__gt=date.strftime("%Y-%m-%d"))
701 return self.filter(created__gt=date)
703 def created_between(self, start: datetime | str, end: datetime | str) -> Self:
704 """
705 Filter models created between two dates.
707 Args:
708 start: The start date to filter by
709 end: The end date to filter by
711 Returns:
712 Filtered QuerySet
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")
720 return self.filter(created__range=(start, end))