Coverage for src/paperap/models/document/queryset.py: 97%
209 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-22 16:02 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-22 16:02 -0400
1"""
2----------------------------------------------------------------------------
4 METADATA:
6 File: queryset.py
7 Project: paperap
8 Created: 2025-03-04
9 Version: 0.0.9
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, 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.model 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(self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs: Any) -> Self:
149 """
150 Filter documents by correspondent.
152 Any number of filter arguments can be provided, but at least one must be specified.
154 Args:
155 value: The correspondent ID or name to filter by
156 exact: If True, match the exact value, otherwise use contains
157 **kwargs: Additional filters (slug, id, name)
159 Returns:
160 Filtered DocumentQuerySet
162 Raises:
163 ValueError: If no valid filters are provided
165 Examples:
166 # Filter by ID
167 client.documents().all().correspondent(1)
168 client.documents().all().correspondent(id=1)
170 # Filter by name
171 client.documents().all().correspondent("John Doe")
172 client.documents().all().correspondent(name="John Doe")
174 # Filter by name (exact match)
175 client.documents().all().correspondent("John Doe", exact=True)
176 client.documents().all().correspondent(name="John Doe", exact=True)
178 # Filter by slug
179 client.documents().all().correspondent(slug="john-doe")
181 # Filter by ID and name
182 client.documents().all().correspondent(1, name="John Doe")
183 client.documents().all().correspondent(id=1, name="John Doe")
184 client.documents().all().correspondent("John Doe", id=1)
186 """
187 # Track if any filters were applied
188 filters_applied = False
189 result = self
191 if value is not None:
192 if isinstance(value, int):
193 result = self.correspondent_id(value)
194 filters_applied = True
195 elif isinstance(value, str):
196 result = self.correspondent_name(value, exact=exact, case_insensitive=case_insensitive)
197 filters_applied = True
198 else:
199 raise TypeError("Invalid value type for correspondent filter")
201 if (slug := kwargs.get("slug")) is not None:
202 result = result.correspondent_slug(slug, exact=exact, case_insensitive=case_insensitive)
203 filters_applied = True
204 if (pk := kwargs.get("id")) is not None:
205 result = result.correspondent_id(pk)
206 filters_applied = True
207 if (name := kwargs.get("name")) is not None:
208 result = result.correspondent_name(name, exact=exact, case_insensitive=case_insensitive)
209 filters_applied = True
211 # If no filters have been applied, raise an error
212 if not filters_applied:
213 raise ValueError("No valid filters provided for correspondent")
215 return result
217 def correspondent_id(self, correspondent_id: int) -> Self:
218 """
219 Filter documents by correspondent ID.
221 Args:
222 correspondent_id: The correspondent ID to filter by
224 Returns:
225 Filtered DocumentQuerySet
227 """
228 return self.filter(correspondent__id=correspondent_id)
230 def correspondent_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
231 """
232 Filter documents by correspondent name.
234 Args:
235 name: The correspondent name to filter by
236 exact: If True, match the exact name, otherwise use contains
238 Returns:
239 Filtered DocumentQuerySet
241 """
242 return self.filter_field_by_str("correspondent__name", name, exact=exact, case_insensitive=case_insensitive)
244 def correspondent_slug(self, slug: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
245 """
246 Filter documents by correspondent slug.
248 Args:
249 slug: The correspondent slug to filter by
250 exact: If True, match the exact slug, otherwise use contains
252 Returns:
253 Filtered DocumentQuerySet
255 """
256 return self.filter_field_by_str("correspondent__slug", slug, exact=exact, case_insensitive=case_insensitive)
258 def document_type(self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs: Any) -> Self:
259 """
260 Filter documents by document type.
262 Any number of filter arguments can be provided, but at least one must be specified.
264 Args:
265 value: The document type ID or name to filter by
266 exact: If True, match the exact value, otherwise use contains
267 **kwargs: Additional filters (id, name)
269 Returns:
270 Filtered DocumentQuerySet
272 Raises:
273 ValueError: If no valid filters are provided
275 Examples:
276 # Filter by ID
277 client.documents().all().document_type(1)
278 client.documents().all().document_type(id=1)
280 # Filter by name
281 client.documents().all().document_type("Invoice")
282 client.documents().all().document_type(name="Invoice")
284 # Filter by name (exact match)
285 client.documents().all().document_type("Invoice", exact=True)
286 client.documents().all().document_type(name="Invoice", exact=True)
288 # Filter by ID and name
289 client.documents().all().document_type(1, name="Invoice")
290 client.documents().all().document_type(id=1, name="Invoice")
291 client.documents().all().document_type("Invoice", id=1)
293 """
294 # Track if any filters were applied
295 filters_applied = False
296 result = self
298 if value is not None:
299 if isinstance(value, int):
300 result = self.document_type_id(value)
301 filters_applied = True
302 elif isinstance(value, str):
303 result = self.document_type_name(value, exact=exact, case_insensitive=case_insensitive)
304 filters_applied = True
305 else:
306 raise TypeError("Invalid value type for document type filter")
308 if (pk := kwargs.get("id")) is not None:
309 result = result.document_type_id(pk)
310 filters_applied = True
311 if (name := kwargs.get("name")) is not None:
312 result = result.document_type_name(name, exact=exact, case_insensitive=case_insensitive)
313 filters_applied = True
315 # If no filters have been applied, raise an error
316 if not filters_applied:
317 raise ValueError("No valid filters provided for document type")
319 return result
321 def document_type_id(self, document_type_id: int) -> Self:
322 """
323 Filter documents by document type ID.
325 Args:
326 document_type_id: The document type ID to filter by
328 Returns:
329 Filtered DocumentQuerySet
331 """
332 return self.filter(document_type__id=document_type_id)
334 def document_type_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
335 """
336 Filter documents by document type name.
338 Args:
339 name: The document type name to filter by
340 exact: If True, match the exact name, otherwise use contains
342 Returns:
343 Filtered DocumentQuerySet
345 """
346 return self.filter_field_by_str("document_type__name", name, exact=exact, case_insensitive=case_insensitive)
348 def storage_path(self, value: int | str | None = None, *, exact: bool = True, case_insensitive: bool = True, **kwargs: Any) -> Self:
349 """
350 Filter documents by storage path.
352 Any number of filter arguments can be provided, but at least one must be specified.
354 Args:
355 value: The storage path ID or name to filter by
356 exact: If True, match the exact value, otherwise use contains
357 **kwargs: Additional filters (id, name)
359 Returns:
360 Filtered DocumentQuerySet
362 Raises:
363 ValueError: If no valid filters are provided
365 Examples:
366 # Filter by ID
367 client.documents().all().storage_path(1)
368 client.documents().all().storage_path(id=1)
370 # Filter by name
371 client.documents().all().storage_path("Invoices")
372 client.documents().all().storage_path(name="Invoices")
374 # Filter by name (exact match)
375 client.documents().all().storage_path("Invoices", exact=True)
376 client.documents().all().storage_path(name="Invoices", exact=True)
378 # Filter by ID and name
379 client.documents().all().storage_path(1, name="Invoices")
380 client.documents().all().storage_path(id=1, name="Invoices")
381 client.documents().all().storage_path("Invoices", id=1)
383 """
384 # Track if any filters were applied
385 filters_applied = False
386 result = self
388 if value is not None:
389 if isinstance(value, int):
390 result = self.storage_path_id(value)
391 filters_applied = True
392 elif isinstance(value, str):
393 result = self.storage_path_name(value, exact=exact, case_insensitive=case_insensitive)
394 filters_applied = True
395 else:
396 raise TypeError("Invalid value type for storage path filter")
398 if (pk := kwargs.get("id")) is not None:
399 result = result.storage_path_id(pk)
400 filters_applied = True
401 if (name := kwargs.get("name")) is not None:
402 result = result.storage_path_name(name, exact=exact, case_insensitive=case_insensitive)
403 filters_applied = True
405 # If no filters have been applied, raise an error
406 if not filters_applied:
407 raise ValueError("No valid filters provided for storage path")
409 return result
411 def storage_path_id(self, storage_path_id: int) -> Self:
412 """
413 Filter documents by storage path ID.
415 Args:
416 storage_path_id: The storage path ID to filter by
418 Returns:
419 Filtered DocumentQuerySet
421 """
422 return self.filter(storage_path__id=storage_path_id)
424 def storage_path_name(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
425 """
426 Filter documents by storage path name.
428 Args:
429 name: The storage path name to filter by
430 exact: If True, match the exact name, otherwise use contains
432 Returns:
433 Filtered DocumentQuerySet
435 """
436 return self.filter_field_by_str("storage_path__name", name, exact=exact, case_insensitive=case_insensitive)
438 def content(self, text: str) -> Self:
439 """
440 Filter documents whose content contains the specified text.
442 Args:
443 text: The text to search for in document content
445 Returns:
446 Filtered DocumentQuerySet
448 """
449 return self.filter(content__contains=text)
451 def added_after(self, date_str: str) -> Self:
452 """
453 Filter documents added after the specified date.
455 Args:
456 date_str: ISO format date string (YYYY-MM-DD)
458 Returns:
459 Filtered DocumentQuerySet
461 """
462 return self.filter(added__gt=date_str)
464 def added_before(self, date_str: str) -> Self:
465 """
466 Filter documents added before the specified date.
468 Args:
469 date_str: ISO format date string (YYYY-MM-DD)
471 Returns:
472 Filtered DocumentQuerySet
474 """
475 return self.filter(added__lt=date_str)
477 def asn(self, value: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
478 """
479 Filter documents by archive serial number.
481 Args:
482 value: The archive serial number to filter by
483 exact: If True, match the exact value, otherwise use contains
485 Returns:
486 Filtered DocumentQuerySet
488 """
489 return self.filter_field_by_str("asn", value, exact=exact, case_insensitive=case_insensitive)
491 def original_filename(self, name: str, *, exact: bool = True, case_insensitive: bool = True) -> Self:
492 """
493 Filter documents by original file name.
495 Args:
496 name: The original file name to filter by
497 exact: If True, match the exact name, otherwise use contains
499 Returns:
500 Filtered DocumentQuerySet
502 """
503 return self.filter_field_by_str("original_filename", name, exact=exact, case_insensitive=case_insensitive)
505 def user_can_change(self, value: bool) -> Self:
506 """
507 Filter documents by user change permission.
509 Args:
510 value: True to filter documents the user can change
512 Returns:
513 Filtered DocumentQuerySet
515 """
516 return self.filter(user_can_change=value)
518 def custom_field_fullsearch(self, value: str, *, case_insensitive: bool = True) -> Self:
519 """
520 Filter documents by searching through both custom field name and value.
522 Args:
523 value: The search string
525 Returns:
526 Filtered DocumentQuerySet
528 """
529 if case_insensitive:
530 return self.filter(custom_fields__icontains=value)
531 raise NotImplementedError("Case-sensitive custom field search is not supported by Paperless NGX")
533 def custom_field(self, field: str, value: Any, *, exact: bool = False, case_insensitive: bool = True) -> Self:
534 """
535 Filter documents by custom field.
537 Args:
538 field: The name of the custom field
539 value: The value to filter by
540 exact: If True, match the exact value, otherwise use contains
542 Returns:
543 Filtered DocumentQuerySet
545 """
546 if exact:
547 if case_insensitive:
548 return self.custom_field_query(field, "iexact", value)
549 return self.custom_field_query(field, "exact", value)
550 if case_insensitive:
551 return self.custom_field_query(field, "icontains", value)
552 return self.custom_field_query(field, "contains", value)
554 def has_custom_field_id(self, pk: int | list[int], *, exact: bool = False) -> Self:
555 """
556 Filter documents that have a custom field with the specified ID(s).
558 Args:
559 pk: A single custom field ID or list of custom field IDs
560 exact: If True, return results that have exactly these ids and no others
562 Returns:
563 Filtered DocumentQuerySet
565 """
566 if exact:
567 return self.filter(custom_fields__id__all=pk)
568 return self.filter(custom_fields__id__in=pk)
570 def _normalize_custom_field_query_item(self, value: Any) -> str:
571 if isinstance(value, tuple):
572 # Check if it's a CustomFieldQuery
573 try:
574 converted_value = CustomFieldQuery(*value)
575 return self._normalize_custom_field_query(converted_value)
576 except TypeError:
577 # It's a tuple, not a CustomFieldQuery
578 pass
580 if isinstance(value, str):
581 return f'"{value}"'
582 if isinstance(value, (list, tuple)):
583 values = [str(self._normalize_custom_field_query_item(v)) for v in value]
584 return f"[{', '.join(values)}]"
585 if isinstance(value, bool):
586 return str(value).lower()
588 return str(value)
590 def _normalize_custom_field_query(self, query: _QueryParam) -> str:
591 try:
592 if not isinstance(query, CustomFieldQuery):
593 query = CustomFieldQuery(*query)
594 except TypeError as te:
595 raise TypeError("Invalid custom field query format") from te
597 field, operation, value = query
598 operation = self._normalize_custom_field_query_item(operation)
599 value = self._normalize_custom_field_query_item(value)
600 return f'["{field}", {operation}, {value}]'
602 @overload
603 def custom_field_query(self, query: _QueryParam) -> Self:
604 """
605 Filter documents by custom field query.
607 Args:
608 query: A list representing a custom field query
610 Returns:
611 Filtered DocumentQuerySet
613 """
614 ...
616 @overload
617 def custom_field_query(self, field: str, operation: _OperationType, value: Any) -> Self:
618 """
619 Filter documents by custom field query.
621 Args:
622 field: The name of the custom field
623 operation: The operation to perform
624 value: The value to filter by
626 Returns:
627 Filtered DocumentQuerySet
629 """
630 ...
632 @singledispatchmethod # type: ignore # mypy does not handle singledispatchmethod with overloads correctly
633 def custom_field_query(self, *args: Any, **kwargs: Any) -> Self:
634 """
635 Filter documents by custom field query.
636 """
637 raise TypeError("Invalid custom field query format")
639 @custom_field_query.register # type: ignore # mypy does not handle singledispatchmethod with overloads correctly
640 def _(self, query: CustomFieldQuery) -> Self:
641 query_str = self._normalize_custom_field_query(query)
642 return self.filter(custom_field_query=query_str)
644 @custom_field_query.register # type: ignore # mypy does not handle singledispatchmethod with overloads correctly
645 def _(self, field: str, operation: str | CustomFieldQuery | tuple[str, Any, Any], value: Any) -> Self:
646 query = CustomFieldQuery(field, operation, value)
647 query_str = self._normalize_custom_field_query(query)
648 return self.filter(custom_field_query=query_str)
650 def custom_field_range(self, field: str, start: str, end: str) -> Self:
651 """
652 Filter documents with a custom field value within a specified range.
654 Args:
655 field: The name of the custom field
656 start: The start value of the range
657 end: The end value of the range
659 Returns:
660 Filtered DocumentQuerySet
662 """
663 return self.custom_field_query(field, "range", [start, end])
665 def custom_field_exact(self, field: str, value: Any) -> Self:
666 """
667 Filter documents with a custom field value that matches exactly.
669 Args:
670 field: The name of the custom field
671 value: The exact value to match
673 Returns:
674 Filtered DocumentQuerySet
676 """
677 return self.custom_field_query(field, "exact", value)
679 def custom_field_in(self, field: str, values: list[Any]) -> Self:
680 """
681 Filter documents with a custom field value in a list of values.
683 Args:
684 field: The name of the custom field
685 values: The list of values to match
687 Returns:
688 Filtered DocumentQuerySet
690 """
691 return self.custom_field_query(field, "in", values)
693 def custom_field_isnull(self, field: str) -> Self:
694 """
695 Filter documents with a custom field that is null or empty.
697 Args:
698 field: The name of the custom field
700 Returns:
701 Filtered DocumentQuerySet
703 """
704 return self.custom_field_query("OR", (field, "isnull", True), [field, "exact", ""])
706 def custom_field_exists(self, field: str, exists: bool = True) -> Self:
707 """
708 Filter documents based on the existence of a custom field.
710 Args:
711 field: The name of the custom field
712 exists: True to filter documents where the field exists, False otherwise
714 Returns:
715 Filtered DocumentQuerySet
717 """
718 return self.custom_field_query(field, "exists", exists)
720 def custom_field_contains(self, field: str, values: list[Any]) -> Self:
721 """
722 Filter documents with a custom field that contains all specified values.
724 Args:
725 field: The name of the custom field
726 values: The list of values that the field should contain
728 Returns:
729 Filtered DocumentQuerySet
731 """
732 return self.custom_field_query(field, "contains", values)
734 def has_custom_fields(self) -> Self:
735 """
736 Filter documents that have custom fields.
737 """
738 return self.filter(has_custom_fields=True)
740 def no_custom_fields(self) -> Self:
741 """
742 Filter documents that do not have custom fields.
743 """
744 return self.filter(has_custom_fields=False)
746 def notes(self, text: str) -> Self:
747 """
748 Filter documents whose notes contain the specified text.
750 Args:
751 text: The text to search for in document notes
753 Returns:
754 Filtered DocumentQuerySet
756 """
757 return self.filter(notes__contains=text)
759 def created_before(self, date: datetime | str) -> Self:
760 """
761 Filter models created before a given date.
763 Args:
764 date: The date to filter by
766 Returns:
767 Filtered QuerySet
769 """
770 if isinstance(date, datetime):
771 return self.filter(created__lt=date.strftime("%Y-%m-%d"))
772 return self.filter(created__lt=date)
774 def created_after(self, date: datetime | str) -> Self:
775 """
776 Filter models created after a given date.
778 Args:
779 date: The date to filter by
781 Returns:
782 Filtered QuerySet
784 """
785 if isinstance(date, datetime):
786 return self.filter(created__gt=date.strftime("%Y-%m-%d"))
787 return self.filter(created__gt=date)
789 def created_between(self, start: datetime | str, end: datetime | str) -> Self:
790 """
791 Filter models created between two dates.
793 Args:
794 start: The start date to filter by
795 end: The end date to filter by
797 Returns:
798 Filtered QuerySet
800 """
801 if isinstance(start, datetime):
802 start = start.strftime("%Y-%m-%d")
803 if isinstance(end, datetime):
804 end = end.strftime("%Y-%m-%d")
806 return self.filter(created__range=(start, end))