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
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-18 12:26 -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 # Track if any filters were applied
138 filters_applied = False
139 result = self
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")
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
161 # If no filters have been applied, raise an error
162 if not filters_applied:
163 raise ValueError("No valid filters provided for correspondent")
165 return result
167 def correspondent_id(self, correspondent_id: int) -> Self:
168 """
169 Filter documents by correspondent ID.
171 Args:
172 correspondent_id: The correspondent ID to filter by
174 Returns:
175 Filtered DocumentQuerySet
177 """
178 return self.filter(correspondent__id=correspondent_id)
180 def correspondent_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
181 """
182 Filter documents by correspondent name.
184 Args:
185 name: The correspondent name to filter by
186 exact: If True, match the exact name, otherwise use contains
188 Returns:
189 Filtered DocumentQuerySet
191 """
192 return self.filter_field_by_str("correspondent__name", name, exact=exact, case_insensitive=case_insensitive)
194 def correspondent_slug(self, slug: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
195 """
196 Filter documents by correspondent slug.
198 Args:
199 slug: The correspondent slug to filter by
200 exact: If True, match the exact slug, otherwise use contains
202 Returns:
203 Filtered DocumentQuerySet
205 """
206 return self.filter_field_by_str("correspondent__slug", slug, exact=exact, case_insensitive=case_insensitive)
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.
214 Any number of filter arguments can be provided, but at least one must be specified.
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)
221 Returns:
222 Filtered DocumentQuerySet
224 Raises:
225 ValueError: If no valid filters are provided
227 Examples:
228 # Filter by ID
229 client.documents().all().document_type(1)
230 client.documents().all().document_type(id=1)
232 # Filter by name
233 client.documents().all().document_type("Invoice")
234 client.documents().all().document_type(name="Invoice")
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)
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)
245 """
246 # Track if any filters were applied
247 filters_applied = False
248 result = self
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")
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
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")
271 return result
273 def document_type_id(self, document_type_id: int) -> Self:
274 """
275 Filter documents by document type ID.
277 Args:
278 document_type_id: The document type ID to filter by
280 Returns:
281 Filtered DocumentQuerySet
283 """
284 return self.filter(document_type__id=document_type_id)
286 def document_type_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
287 """
288 Filter documents by document type name.
290 Args:
291 name: The document type name to filter by
292 exact: If True, match the exact name, otherwise use contains
294 Returns:
295 Filtered DocumentQuerySet
297 """
298 return self.filter_field_by_str("document_type__name", name, exact=exact, case_insensitive=case_insensitive)
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.
306 Any number of filter arguments can be provided, but at least one must be specified.
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)
313 Returns:
314 Filtered DocumentQuerySet
316 Raises:
317 ValueError: If no valid filters are provided
319 Examples:
320 # Filter by ID
321 client.documents().all().storage_path(1)
322 client.documents().all().storage_path(id=1)
324 # Filter by name
325 client.documents().all().storage_path("Invoices")
326 client.documents().all().storage_path(name="Invoices")
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)
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)
337 """
338 # Track if any filters were applied
339 filters_applied = False
340 result = self
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")
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
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")
363 return result
365 def storage_path_id(self, storage_path_id: int) -> Self:
366 """
367 Filter documents by storage path ID.
369 Args:
370 storage_path_id: The storage path ID to filter by
372 Returns:
373 Filtered DocumentQuerySet
375 """
376 return self.filter(storage_path__id=storage_path_id)
378 def storage_path_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
379 """
380 Filter documents by storage path name.
382 Args:
383 name: The storage path name to filter by
384 exact: If True, match the exact name, otherwise use contains
386 Returns:
387 Filtered DocumentQuerySet
389 """
390 return self.filter_field_by_str("storage_path__name", name, exact=exact, case_insensitive=case_insensitive)
392 def content(self, text: str) -> Self:
393 """
394 Filter documents whose content contains the specified text.
396 Args:
397 text: The text to search for in document content
399 Returns:
400 Filtered DocumentQuerySet
402 """
403 return self.filter(content__contains=text)
405 def added_after(self, date_str: str) -> Self:
406 """
407 Filter documents added after the specified date.
409 Args:
410 date_str: ISO format date string (YYYY-MM-DD)
412 Returns:
413 Filtered DocumentQuerySet
415 """
416 return self.filter(added__gt=date_str)
418 def added_before(self, date_str: str) -> Self:
419 """
420 Filter documents added before the specified date.
422 Args:
423 date_str: ISO format date string (YYYY-MM-DD)
425 Returns:
426 Filtered DocumentQuerySet
428 """
429 return self.filter(added__lt=date_str)
431 def asn(self, value: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
432 """
433 Filter documents by archive serial number.
435 Args:
436 value: The archive serial number to filter by
437 exact: If True, match the exact value, otherwise use contains
439 Returns:
440 Filtered DocumentQuerySet
442 """
443 return self.filter_field_by_str("asn", value, exact=exact, case_insensitive=case_insensitive)
445 def original_file_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
446 """
447 Filter documents by original file name.
449 Args:
450 name: The original file name to filter by
451 exact: If True, match the exact name, otherwise use contains
453 Returns:
454 Filtered DocumentQuerySet
456 """
457 return self.filter_field_by_str("original_file_name", name, exact=exact, case_insensitive=case_insensitive)
459 def user_can_change(self, value: bool) -> Self:
460 """
461 Filter documents by user change permission.
463 Args:
464 value: True to filter documents the user can change
466 Returns:
467 Filtered DocumentQuerySet
469 """
470 return self.filter(user_can_change=value)
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.
476 Args:
477 value: The search string
479 Returns:
480 Filtered DocumentQuerySet
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")
487 def custom_field(self, field: str, value: Any, *, exact: bool = False, case_insensitive: bool = True) -> Self:
488 """
489 Filter documents by custom field.
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
496 Returns:
497 Filtered DocumentQuerySet
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)
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).
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
516 Returns:
517 Filtered DocumentQuerySet
519 """
520 if exact:
521 return self.filter(custom_fields__id__all=pk)
522 return self.filter(custom_fields__id__in=pk)
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
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()
542 return str(value)
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
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}]'
556 @overload
557 def custom_field_query(self, query: _QueryParam) -> Self:
558 """
559 Filter documents by custom field query.
561 Args:
562 query: A list representing a custom field query
564 Returns:
565 Filtered DocumentQuerySet
567 """
568 ...
570 @overload
571 def custom_field_query(self, field: str, operation: _OperationType, value: Any) -> Self:
572 """
573 Filter documents by custom field query.
575 Args:
576 field: The name of the custom field
577 operation: The operation to perform
578 value: The value to filter by
580 Returns:
581 Filtered DocumentQuerySet
583 """
584 ...
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")
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)
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)
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.
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
613 Returns:
614 Filtered DocumentQuerySet
616 """
617 return self.custom_field_query(field, "range", [start, end])
619 def custom_field_exact(self, field: str, value: Any) -> Self:
620 """
621 Filter documents with a custom field value that matches exactly.
623 Args:
624 field: The name of the custom field
625 value: The exact value to match
627 Returns:
628 Filtered DocumentQuerySet
630 """
631 return self.custom_field_query(field, "exact", value)
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.
637 Args:
638 field: The name of the custom field
639 values: The list of values to match
641 Returns:
642 Filtered DocumentQuerySet
644 """
645 return self.custom_field_query(field, "in", values)
647 def custom_field_isnull(self, field: str) -> Self:
648 """
649 Filter documents with a custom field that is null or empty.
651 Args:
652 field: The name of the custom field
654 Returns:
655 Filtered DocumentQuerySet
657 """
658 return self.custom_field_query("OR", (field, "isnull", True), [field, "exact", ""])
660 def custom_field_exists(self, field: str, exists: bool = True) -> Self:
661 """
662 Filter documents based on the existence of a custom field.
664 Args:
665 field: The name of the custom field
666 exists: True to filter documents where the field exists, False otherwise
668 Returns:
669 Filtered DocumentQuerySet
671 """
672 return self.custom_field_query(field, "exists", exists)
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.
678 Args:
679 field: The name of the custom field
680 values: The list of values that the field should contain
682 Returns:
683 Filtered DocumentQuerySet
685 """
686 return self.custom_field_query(field, "contains", values)
688 def has_custom_fields(self) -> Self:
689 """
690 Filter documents that have custom fields.
691 """
692 return self.filter(has_custom_fields=True)
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)
700 def notes(self, text: str) -> Self:
701 """
702 Filter documents whose notes contain the specified text.
704 Args:
705 text: The text to search for in document notes
707 Returns:
708 Filtered DocumentQuerySet
710 """
711 return self.filter(notes__contains=text)
713 def created_before(self, date: datetime | str) -> Self:
714 """
715 Filter models created before a given date.
717 Args:
718 date: The date to filter by
720 Returns:
721 Filtered QuerySet
723 """
724 if isinstance(date, datetime):
725 return self.filter(created__lt=date.strftime("%Y-%m-%d"))
726 return self.filter(created__lt=date)
728 def created_after(self, date: datetime | str) -> Self:
729 """
730 Filter models created after a given date.
732 Args:
733 date: The date to filter by
735 Returns:
736 Filtered QuerySet
738 """
739 if isinstance(date, datetime):
740 return self.filter(created__gt=date.strftime("%Y-%m-%d"))
741 return self.filter(created__gt=date)
743 def created_between(self, start: datetime | str, end: datetime | str) -> Self:
744 """
745 Filter models created between two dates.
747 Args:
748 start: The start date to filter by
749 end: The end date to filter by
751 Returns:
752 Filtered QuerySet
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")
760 return self.filter(created__range=(start, end))