Coverage for src/paperap/models/document/queryset.py: 97%
209 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-20 13:17 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: queryset.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.8
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, DocumentNote
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 DocumentNoteQuerySet(StandardQuerySet["DocumentNote"]):
49 pass
52class DocumentQuerySet(StandardQuerySet["Document"], HasOwner):
53 """
54 QuerySet for Paperless-ngx documents with specialized filtering methods.
56 Examples:
57 >>> # Search for documents
58 >>> docs = client.documents().search("invoice")
59 >>> for doc in docs:
60 ... print(doc.title)
62 >>> # Find documents similar to a specific document
63 >>> similar_docs = client.documents().more_like(42)
64 >>> for doc in similar_docs:
65 ... print(doc.title)
67 """
69 def tag_id(self, tag_id: int | list[int]) -> Self:
70 """
71 Filter documents that have the specified tag ID(s).
73 Args:
74 tag_id: A single tag ID or list of tag IDs
76 Returns:
77 Filtered DocumentQuerySet
79 """
80 if isinstance(tag_id, list):
81 return self.filter(tags__id__in=tag_id)
82 return self.filter(tags__id=tag_id)
84 def tag_name(self, tag_name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
85 """
86 Filter documents that have a tag with the specified name.
88 Args:
89 tag_name: The name of the tag
90 exact: If True, match the exact tag name, otherwise use contains
92 Returns:
93 Filtered DocumentQuerySet
95 """
96 return self.filter_field_by_str("tags__name", tag_name, exact=exact, case_insensitive=case_insensitive)
98 def title(self, title: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
99 """
100 Filter documents by title.
102 Args:
103 title: The document title to filter by
104 exact: If True, match the exact title, otherwise use contains
106 Returns:
107 Filtered DocumentQuerySet
109 """
110 return self.filter_field_by_str("title", title, exact=exact, case_insensitive=case_insensitive)
112 def search(self, query: str) -> "DocumentQuerySet":
113 """
114 Search for documents using a query string.
116 Args:
117 query: The search query.
119 Returns:
120 A queryset with the search results.
122 Examples:
123 >>> docs = client.documents().search("invoice")
124 >>> for doc in docs:
125 ... print(doc.title)
127 """
128 return self.filter(query=query)
130 def more_like(self, document_id: int) -> "DocumentQuerySet":
131 """
132 Find documents similar to the specified document.
134 Args:
135 document_id: The ID of the document to find similar documents for.
137 Returns:
138 A queryset with similar documents.
140 Examples:
141 >>> similar_docs = client.documents().more_like(42)
142 >>> for doc in similar_docs:
143 ... print(doc.title)
145 """
146 return self.filter(more_like_id=document_id)
148 def correspondent(
149 self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs
150 ) -> Self:
151 """
152 Filter documents by correspondent.
154 Any number of filter arguments can be provided, but at least one must be specified.
156 Args:
157 value: The correspondent ID or name to filter by
158 exact: If True, match the exact value, otherwise use contains
159 **kwargs: Additional filters (slug, id, name)
161 Returns:
162 Filtered DocumentQuerySet
164 Raises:
165 ValueError: If no valid filters are provided
167 Examples:
168 # Filter by ID
169 client.documents().all().correspondent(1)
170 client.documents().all().correspondent(id=1)
172 # Filter by name
173 client.documents().all().correspondent("John Doe")
174 client.documents().all().correspondent(name="John Doe")
176 # Filter by name (exact match)
177 client.documents().all().correspondent("John Doe", exact=True)
178 client.documents().all().correspondent(name="John Doe", exact=True)
180 # Filter by slug
181 client.documents().all().correspondent(slug="john-doe")
183 # Filter by ID and name
184 client.documents().all().correspondent(1, name="John Doe")
185 client.documents().all().correspondent(id=1, name="John Doe")
186 client.documents().all().correspondent("John Doe", id=1)
188 """
189 # Track if any filters were applied
190 filters_applied = False
191 result = self
193 if value is not None:
194 if isinstance(value, int):
195 result = self.correspondent_id(value)
196 filters_applied = True
197 elif isinstance(value, str):
198 result = self.correspondent_name(value, exact=exact, case_insensitive=case_insensitive)
199 filters_applied = True
200 else:
201 raise TypeError("Invalid value type for correspondent filter")
203 if (slug := kwargs.get("slug")) is not None:
204 result = result.correspondent_slug(slug, exact=exact, case_insensitive=case_insensitive)
205 filters_applied = True
206 if (pk := kwargs.get("id")) is not None:
207 result = result.correspondent_id(pk)
208 filters_applied = True
209 if (name := kwargs.get("name")) is not None:
210 result = result.correspondent_name(name, exact=exact, case_insensitive=case_insensitive)
211 filters_applied = True
213 # If no filters have been applied, raise an error
214 if not filters_applied:
215 raise ValueError("No valid filters provided for correspondent")
217 return result
219 def correspondent_id(self, correspondent_id: int) -> Self:
220 """
221 Filter documents by correspondent ID.
223 Args:
224 correspondent_id: The correspondent ID to filter by
226 Returns:
227 Filtered DocumentQuerySet
229 """
230 return self.filter(correspondent__id=correspondent_id)
232 def correspondent_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
233 """
234 Filter documents by correspondent name.
236 Args:
237 name: The correspondent name to filter by
238 exact: If True, match the exact name, otherwise use contains
240 Returns:
241 Filtered DocumentQuerySet
243 """
244 return self.filter_field_by_str("correspondent__name", name, exact=exact, case_insensitive=case_insensitive)
246 def correspondent_slug(self, slug: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
247 """
248 Filter documents by correspondent slug.
250 Args:
251 slug: The correspondent slug to filter by
252 exact: If True, match the exact slug, otherwise use contains
254 Returns:
255 Filtered DocumentQuerySet
257 """
258 return self.filter_field_by_str("correspondent__slug", slug, exact=exact, case_insensitive=case_insensitive)
260 def document_type(
261 self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs
262 ) -> Self:
263 """
264 Filter documents by document type.
266 Any number of filter arguments can be provided, but at least one must be specified.
268 Args:
269 value: The document type ID or name to filter by
270 exact: If True, match the exact value, otherwise use contains
271 **kwargs: Additional filters (id, name)
273 Returns:
274 Filtered DocumentQuerySet
276 Raises:
277 ValueError: If no valid filters are provided
279 Examples:
280 # Filter by ID
281 client.documents().all().document_type(1)
282 client.documents().all().document_type(id=1)
284 # Filter by name
285 client.documents().all().document_type("Invoice")
286 client.documents().all().document_type(name="Invoice")
288 # Filter by name (exact match)
289 client.documents().all().document_type("Invoice", exact=True)
290 client.documents().all().document_type(name="Invoice", exact=True)
292 # Filter by ID and name
293 client.documents().all().document_type(1, name="Invoice")
294 client.documents().all().document_type(id=1, name="Invoice")
295 client.documents().all().document_type("Invoice", id=1)
297 """
298 # Track if any filters were applied
299 filters_applied = False
300 result = self
302 if value is not None:
303 if isinstance(value, int):
304 result = self.document_type_id(value)
305 filters_applied = True
306 elif isinstance(value, str):
307 result = self.document_type_name(value, exact=exact, case_insensitive=case_insensitive)
308 filters_applied = True
309 else:
310 raise TypeError("Invalid value type for document type filter")
312 if (pk := kwargs.get("id")) is not None:
313 result = result.document_type_id(pk)
314 filters_applied = True
315 if (name := kwargs.get("name")) is not None:
316 result = result.document_type_name(name, exact=exact, case_insensitive=case_insensitive)
317 filters_applied = True
319 # If no filters have been applied, raise an error
320 if not filters_applied:
321 raise ValueError("No valid filters provided for document type")
323 return result
325 def document_type_id(self, document_type_id: int) -> Self:
326 """
327 Filter documents by document type ID.
329 Args:
330 document_type_id: The document type ID to filter by
332 Returns:
333 Filtered DocumentQuerySet
335 """
336 return self.filter(document_type__id=document_type_id)
338 def document_type_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
339 """
340 Filter documents by document type name.
342 Args:
343 name: The document type 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("document_type__name", name, exact=exact, case_insensitive=case_insensitive)
352 def storage_path(
353 self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs
354 ) -> Self:
355 """
356 Filter documents by storage path.
358 Any number of filter arguments can be provided, but at least one must be specified.
360 Args:
361 value: The storage path ID or name to filter by
362 exact: If True, match the exact value, otherwise use contains
363 **kwargs: Additional filters (id, name)
365 Returns:
366 Filtered DocumentQuerySet
368 Raises:
369 ValueError: If no valid filters are provided
371 Examples:
372 # Filter by ID
373 client.documents().all().storage_path(1)
374 client.documents().all().storage_path(id=1)
376 # Filter by name
377 client.documents().all().storage_path("Invoices")
378 client.documents().all().storage_path(name="Invoices")
380 # Filter by name (exact match)
381 client.documents().all().storage_path("Invoices", exact=True)
382 client.documents().all().storage_path(name="Invoices", exact=True)
384 # Filter by ID and name
385 client.documents().all().storage_path(1, name="Invoices")
386 client.documents().all().storage_path(id=1, name="Invoices")
387 client.documents().all().storage_path("Invoices", id=1)
389 """
390 # Track if any filters were applied
391 filters_applied = False
392 result = self
394 if value is not None:
395 if isinstance(value, int):
396 result = self.storage_path_id(value)
397 filters_applied = True
398 elif isinstance(value, str):
399 result = self.storage_path_name(value, exact=exact, case_insensitive=case_insensitive)
400 filters_applied = True
401 else:
402 raise TypeError("Invalid value type for storage path filter")
404 if (pk := kwargs.get("id")) is not None:
405 result = result.storage_path_id(pk)
406 filters_applied = True
407 if (name := kwargs.get("name")) is not None:
408 result = result.storage_path_name(name, exact=exact, case_insensitive=case_insensitive)
409 filters_applied = True
411 # If no filters have been applied, raise an error
412 if not filters_applied:
413 raise ValueError("No valid filters provided for storage path")
415 return result
417 def storage_path_id(self, storage_path_id: int) -> Self:
418 """
419 Filter documents by storage path ID.
421 Args:
422 storage_path_id: The storage path ID to filter by
424 Returns:
425 Filtered DocumentQuerySet
427 """
428 return self.filter(storage_path__id=storage_path_id)
430 def storage_path_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
431 """
432 Filter documents by storage path name.
434 Args:
435 name: The storage path name to filter by
436 exact: If True, match the exact name, otherwise use contains
438 Returns:
439 Filtered DocumentQuerySet
441 """
442 return self.filter_field_by_str("storage_path__name", name, exact=exact, case_insensitive=case_insensitive)
444 def content(self, text: str) -> Self:
445 """
446 Filter documents whose content contains the specified text.
448 Args:
449 text: The text to search for in document content
451 Returns:
452 Filtered DocumentQuerySet
454 """
455 return self.filter(content__contains=text)
457 def added_after(self, date_str: str) -> Self:
458 """
459 Filter documents added after the specified date.
461 Args:
462 date_str: ISO format date string (YYYY-MM-DD)
464 Returns:
465 Filtered DocumentQuerySet
467 """
468 return self.filter(added__gt=date_str)
470 def added_before(self, date_str: str) -> Self:
471 """
472 Filter documents added before the specified date.
474 Args:
475 date_str: ISO format date string (YYYY-MM-DD)
477 Returns:
478 Filtered DocumentQuerySet
480 """
481 return self.filter(added__lt=date_str)
483 def asn(self, value: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
484 """
485 Filter documents by archive serial number.
487 Args:
488 value: The archive serial number to filter by
489 exact: If True, match the exact value, otherwise use contains
491 Returns:
492 Filtered DocumentQuerySet
494 """
495 return self.filter_field_by_str("asn", value, exact=exact, case_insensitive=case_insensitive)
497 def original_file_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
498 """
499 Filter documents by original file name.
501 Args:
502 name: The original file name to filter by
503 exact: If True, match the exact name, otherwise use contains
505 Returns:
506 Filtered DocumentQuerySet
508 """
509 return self.filter_field_by_str("original_file_name", name, exact=exact, case_insensitive=case_insensitive)
511 def user_can_change(self, value: bool) -> Self:
512 """
513 Filter documents by user change permission.
515 Args:
516 value: True to filter documents the user can change
518 Returns:
519 Filtered DocumentQuerySet
521 """
522 return self.filter(user_can_change=value)
524 def custom_field_fullsearch(self, value: str, *, case_insensitive: bool = True) -> Self:
525 """
526 Filter documents by searching through both custom field name and value.
528 Args:
529 value: The search string
531 Returns:
532 Filtered DocumentQuerySet
534 """
535 if case_insensitive:
536 return self.filter(custom_fields__icontains=value)
537 raise NotImplementedError("Case-sensitive custom field search is not supported by Paperless NGX")
539 def custom_field(self, field: str, value: Any, *, exact: bool = False, case_insensitive: bool = True) -> Self:
540 """
541 Filter documents by custom field.
543 Args:
544 field: The name of the custom field
545 value: The value to filter by
546 exact: If True, match the exact value, otherwise use contains
548 Returns:
549 Filtered DocumentQuerySet
551 """
552 if exact:
553 if case_insensitive:
554 return self.custom_field_query(field, "iexact", value)
555 return self.custom_field_query(field, "exact", value)
556 if case_insensitive:
557 return self.custom_field_query(field, "icontains", value)
558 return self.custom_field_query(field, "contains", value)
560 def has_custom_field_id(self, pk: int | list[int], *, exact: bool = False) -> Self:
561 """
562 Filter documents that have a custom field with the specified ID(s).
564 Args:
565 pk: A single custom field ID or list of custom field IDs
566 exact: If True, return results that have exactly these ids and no others
568 Returns:
569 Filtered DocumentQuerySet
571 """
572 if exact:
573 return self.filter(custom_fields__id__all=pk)
574 return self.filter(custom_fields__id__in=pk)
576 def _normalize_custom_field_query_item(self, value: Any) -> str:
577 if isinstance(value, tuple):
578 # Check if it's a CustomFieldQuery
579 try:
580 converted_value = CustomFieldQuery(*value)
581 return self._normalize_custom_field_query(converted_value)
582 except TypeError:
583 # It's a tuple, not a CustomFieldQuery
584 pass
586 if isinstance(value, str):
587 return f'"{value}"'
588 if isinstance(value, (list, tuple)):
589 values = [str(self._normalize_custom_field_query_item(v)) for v in value]
590 return f"[{', '.join(values)}]"
591 if isinstance(value, bool):
592 return str(value).lower()
594 return str(value)
596 def _normalize_custom_field_query(self, query: _QueryParam) -> str:
597 try:
598 if not isinstance(query, CustomFieldQuery):
599 query = CustomFieldQuery(*query)
600 except TypeError as te:
601 raise TypeError("Invalid custom field query format") from te
603 field, operation, value = query
604 operation = self._normalize_custom_field_query_item(operation)
605 value = self._normalize_custom_field_query_item(value)
606 return f'["{field}", {operation}, {value}]'
608 @overload
609 def custom_field_query(self, query: _QueryParam) -> Self:
610 """
611 Filter documents by custom field query.
613 Args:
614 query: A list representing a custom field query
616 Returns:
617 Filtered DocumentQuerySet
619 """
620 ...
622 @overload
623 def custom_field_query(self, field: str, operation: _OperationType, value: Any) -> Self:
624 """
625 Filter documents by custom field query.
627 Args:
628 field: The name of the custom field
629 operation: The operation to perform
630 value: The value to filter by
632 Returns:
633 Filtered DocumentQuerySet
635 """
636 ...
638 @singledispatchmethod # type: ignore # mypy does not handle singledispatchmethod with overloads correctly
639 def custom_field_query(self, *args, **kwargs: Any) -> Self:
640 """
641 Filter documents by custom field query.
642 """
643 raise TypeError("Invalid custom field query format")
645 @custom_field_query.register # type: ignore # mypy does not handle singledispatchmethod with overloads correctly
646 def _(self, query: CustomFieldQuery) -> Self:
647 query_str = self._normalize_custom_field_query(query)
648 return self.filter(custom_field_query=query_str)
650 @custom_field_query.register # type: ignore # mypy does not handle singledispatchmethod with overloads correctly
651 def _(self, field: str, operation: str | CustomFieldQuery | tuple[str, Any, Any], value: Any) -> Self:
652 query = CustomFieldQuery(field, operation, value)
653 query_str = self._normalize_custom_field_query(query)
654 return self.filter(custom_field_query=query_str)
656 def custom_field_range(self, field: str, start: str, end: str) -> Self:
657 """
658 Filter documents with a custom field value within a specified range.
660 Args:
661 field: The name of the custom field
662 start: The start value of the range
663 end: The end value of the range
665 Returns:
666 Filtered DocumentQuerySet
668 """
669 return self.custom_field_query(field, "range", [start, end])
671 def custom_field_exact(self, field: str, value: Any) -> Self:
672 """
673 Filter documents with a custom field value that matches exactly.
675 Args:
676 field: The name of the custom field
677 value: The exact value to match
679 Returns:
680 Filtered DocumentQuerySet
682 """
683 return self.custom_field_query(field, "exact", value)
685 def custom_field_in(self, field: str, values: list[Any]) -> Self:
686 """
687 Filter documents with a custom field value in a list of values.
689 Args:
690 field: The name of the custom field
691 values: The list of values to match
693 Returns:
694 Filtered DocumentQuerySet
696 """
697 return self.custom_field_query(field, "in", values)
699 def custom_field_isnull(self, field: str) -> Self:
700 """
701 Filter documents with a custom field that is null or empty.
703 Args:
704 field: The name of the custom field
706 Returns:
707 Filtered DocumentQuerySet
709 """
710 return self.custom_field_query("OR", (field, "isnull", True), [field, "exact", ""])
712 def custom_field_exists(self, field: str, exists: bool = True) -> Self:
713 """
714 Filter documents based on the existence of a custom field.
716 Args:
717 field: The name of the custom field
718 exists: True to filter documents where the field exists, False otherwise
720 Returns:
721 Filtered DocumentQuerySet
723 """
724 return self.custom_field_query(field, "exists", exists)
726 def custom_field_contains(self, field: str, values: list[Any]) -> Self:
727 """
728 Filter documents with a custom field that contains all specified values.
730 Args:
731 field: The name of the custom field
732 values: The list of values that the field should contain
734 Returns:
735 Filtered DocumentQuerySet
737 """
738 return self.custom_field_query(field, "contains", values)
740 def has_custom_fields(self) -> Self:
741 """
742 Filter documents that have custom fields.
743 """
744 return self.filter(has_custom_fields=True)
746 def no_custom_fields(self) -> Self:
747 """
748 Filter documents that do not have custom fields.
749 """
750 return self.filter(has_custom_fields=False)
752 def notes(self, text: str) -> Self:
753 """
754 Filter documents whose notes contain the specified text.
756 Args:
757 text: The text to search for in document notes
759 Returns:
760 Filtered DocumentQuerySet
762 """
763 return self.filter(notes__contains=text)
765 def created_before(self, date: datetime | str) -> Self:
766 """
767 Filter models created before a given date.
769 Args:
770 date: The date to filter by
772 Returns:
773 Filtered QuerySet
775 """
776 if isinstance(date, datetime):
777 return self.filter(created__lt=date.strftime("%Y-%m-%d"))
778 return self.filter(created__lt=date)
780 def created_after(self, date: datetime | str) -> Self:
781 """
782 Filter models created after a given date.
784 Args:
785 date: The date to filter by
787 Returns:
788 Filtered QuerySet
790 """
791 if isinstance(date, datetime):
792 return self.filter(created__gt=date.strftime("%Y-%m-%d"))
793 return self.filter(created__gt=date)
795 def created_between(self, start: datetime | str, end: datetime | str) -> Self:
796 """
797 Filter models created between two dates.
799 Args:
800 start: The start date to filter by
801 end: The end date to filter by
803 Returns:
804 Filtered QuerySet
806 """
807 if isinstance(start, datetime):
808 start = start.strftime("%Y-%m-%d")
809 if isinstance(end, datetime):
810 end = end.strftime("%Y-%m-%d")
812 return self.filter(created__range=(start, end))