Skip to content

Commit 17c67c8

Browse files
committed
add searching and ordering
1 parent 0c8bb86 commit 17c67c8

File tree

4 files changed

+221
-1
lines changed

4 files changed

+221
-1
lines changed

ninja_extra/conf/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ def __init__(self, data: dict) -> None:
2121
"ninja_extra.throttling.UserRateThrottle",
2222
],
2323
THROTTLE_RATES={"user": None, "anon": None},
24+
ORDERING_CLASS="ninja_extra.ordering.Ordering",
25+
SEARCHING_CLASS="ninja_extra.searching.Search"
2426
)
2527

2628
USER_SETTINGS = UserDefinedSettingsMapper(
@@ -43,6 +45,12 @@ class Config:
4345
THROTTLE_CLASSES: List[Any] = []
4446
NUM_PROXIES: Optional[int] = None
4547
INJECTOR_MODULES: List[Any] = []
48+
ORDERING_CLASS: Any = Field(
49+
"ninja_extra.ordering.Ordering",
50+
)
51+
SEARCHING_CLASS: Any = Field(
52+
"ninja_extra.searching.Search",
53+
)
4654

4755
@validator("INJECTOR_MODULES", pre=True)
4856
def pre_injector_module_validate(cls, value: Any) -> Any:

ninja_extra/ordering.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
from functools import wraps
2+
from inspect import isfunction as is_func
3+
from typing import (
4+
TYPE_CHECKING,
5+
Any,
6+
Callable,
7+
List,
8+
Optional,
9+
Tuple,
10+
Type,
11+
Union,
12+
)
13+
14+
from django.db.models import QuerySet
15+
from ninja import Field, Query, Schema
16+
from ninja.constants import NOT_SET
17+
18+
from ninja_extra.conf import settings
19+
20+
if TYPE_CHECKING: # pragma: no cover
21+
from ninja_extra.controllers import ControllerBase
22+
23+
24+
class Ordering:
25+
26+
class Input(Schema):
27+
ordering: Optional[str] = Field(None)
28+
29+
input_source = Query(...)
30+
31+
def __init__(self, ordering_fields: Optional[Union[str, List[str]]] = None, pass_parameter: Optional[str] = None) -> None:
32+
self.ordering_fields = ordering_fields or '__all__'
33+
self.pass_parameter = pass_parameter
34+
35+
def ordering_queryset(self, queryset: QuerySet, ordering: Input):
36+
ordering = self.get_ordering(queryset, ordering.ordering)
37+
if ordering:
38+
return queryset.order_by(*ordering)
39+
return queryset
40+
41+
def get_ordering(self, queryset: QuerySet, value: Optional[str]) -> Optional[List[str]]:
42+
if value:
43+
fields = [param.strip() for param in value.split(',')]
44+
return self.remove_invalid_fields(queryset, fields)
45+
return []
46+
47+
def remove_invalid_fields(self, queryset, fields):
48+
valid_fields = [item[0] for item in self.get_valid_fields(queryset)]
49+
50+
def term_valid(term):
51+
if term.startswith("-"):
52+
term = term[1:]
53+
return term in valid_fields
54+
55+
return [term for term in fields if term_valid(term)]
56+
57+
def get_valid_fields(self, queryset):
58+
valid_fields = self.ordering_fields
59+
if valid_fields == '__all__':
60+
valid_fields = [(field.name, field.verbose_name) for field in queryset.model._meta.fields]
61+
valid_fields += [(key, key.title().split('__')) for key in queryset.query.annotations]
62+
else:
63+
valid_fields = [(item, item) if isinstance(item, str) else item for item in valid_fields]
64+
return valid_fields
65+
66+
67+
def ordering(func_or_ordering_class: Type[Ordering] = NOT_SET, **ordering_params: Any) -> Callable:
68+
isfunction = is_func(func_or_ordering_class)
69+
isnotset = func_or_ordering_class == NOT_SET
70+
ordering_class: Type[Ordering] = settings.ORDERING_CLASS
71+
if isfunction:
72+
return _inject_orderator(func_or_ordering_class, ordering_class)
73+
if not isnotset:
74+
ordering_class = func_or_ordering_class
75+
76+
def wrapper(func: Callable[..., Any]) -> Any:
77+
return _inject_orderator(func, ordering_class, **ordering_params)
78+
79+
return wrapper
80+
81+
82+
def _inject_orderator(
83+
func: Callable[..., Any],
84+
ordering_class: Type[Ordering],
85+
**ordering_params: Any,
86+
) -> Callable[..., Any]:
87+
orderator: Ordering = ordering_class(**ordering_params)
88+
orderator_kwargs_name = "ordering"
89+
_ninja_contribute_args: List[Tuple] = getattr(func, "_ninja_contribute_args", [])
90+
91+
@wraps(func)
92+
def view_with_ordering(controller: "ControllerBase", *args: Any, **kw: Any) -> Any:
93+
func_kwargs = dict(**kw)
94+
ordering = func_kwargs.pop(orderator_kwargs_name)
95+
if orderator.pass_parameter:
96+
func_kwargs[orderator.pass_parameter] = ordering_params
97+
items = func(controller, *args, **func_kwargs)
98+
return orderator.ordering_queryset(items, ordering)
99+
100+
_ninja_contribute_args.append((
101+
orderator_kwargs_name,
102+
orderator.Input,
103+
orderator.input_source,
104+
))
105+
view_with_ordering._ninja_contribute_args = _ninja_contribute_args
106+
return view_with_ordering

ninja_extra/pagination.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
from collections import OrderedDict
44
from functools import wraps
5-
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union, cast, overload
5+
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union, cast, overload, List, Tuple
66

77
from asgiref.sync import sync_to_async
88
from django.core.paginator import InvalidPage, Page, Paginator
@@ -180,6 +180,8 @@ def __init__(
180180
self.view_func = view_func
181181

182182
paginator_view = self.get_view_function()
183+
_ninja_contribute_args: List[Tuple] = getattr(self.view_func, "_ninja_contribute_args", [])
184+
paginator_view._ninja_contribute_args = _ninja_contribute_args
183185
add_ninja_contribute_args(
184186
paginator_view,
185187
(

ninja_extra/searching.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import inspect
2+
import operator
3+
from functools import reduce, wraps
4+
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, Type
5+
6+
from django.db.models import Q, QuerySet
7+
from django.db.models.constants import LOOKUP_SEP
8+
from ninja import Field, Query, Schema
9+
from ninja.constants import NOT_SET
10+
11+
from ninja_extra.conf import settings
12+
13+
if TYPE_CHECKING: # pragma: no cover
14+
from ninja_extra.controllers import ControllerBase
15+
16+
17+
class Search:
18+
19+
class Input(Schema):
20+
search: Optional[str] = Field(None)
21+
22+
lookup_prefixes = {
23+
'^': 'istartswith',
24+
'=': 'iexact',
25+
'@': 'search',
26+
'$': 'iregex',
27+
}
28+
input_source = Query(...)
29+
30+
def __init__(self, search_fields: List[str], pass_parameter: Optional[str] = None) -> None:
31+
self.search_fields = search_fields
32+
self.pass_parameter = pass_parameter
33+
34+
def search_queryset(self, queryset: QuerySet, searching: Input):
35+
if searching.search:
36+
search_terms = self.get_search_terms(searching.search)
37+
38+
if not self.search_fields or not search_terms:
39+
return queryset
40+
41+
orm_lookups = [self.construct_search(str(search_field)) for search_field in self.search_fields]
42+
conditions = []
43+
for search_term in search_terms:
44+
queries = [Q(**{orm_lookup: search_term}) for orm_lookup in orm_lookups]
45+
conditions.append(reduce(operator.or_, queries))
46+
queryset = queryset.filter(reduce(operator.and_, conditions))
47+
return queryset
48+
49+
def get_search_terms(self, value: str):
50+
if value:
51+
value = value.replace('\x00', '') # strip null characters
52+
value = value.replace(',', ' ')
53+
return value.split()
54+
return ''
55+
56+
def construct_search(self, field_name):
57+
lookup = self.lookup_prefixes.get(field_name[0])
58+
if lookup:
59+
field_name = field_name[1:]
60+
else:
61+
lookup = 'icontains'
62+
return LOOKUP_SEP.join([field_name, lookup])
63+
64+
65+
def searching(func_or_searching_class: Type[Search] = NOT_SET, **searching_params: Any) -> Callable:
66+
isfunction = inspect.isfunction(func_or_searching_class)
67+
isnotset = func_or_searching_class == NOT_SET
68+
searching_class: Type[Search] = settings.SEARCHING_CLASS
69+
if isfunction:
70+
return _inject_searcherator(func_or_searching_class, searching_class)
71+
if not isnotset:
72+
searching_class = func_or_searching_class
73+
74+
def wrapper(func: Callable[..., Any]) -> Any:
75+
return _inject_searcherator(func, searching_class, **searching_params)
76+
77+
return wrapper
78+
79+
80+
def _inject_searcherator(
81+
func: Callable[..., Any],
82+
searching_class: Type[Search],
83+
**searching_params: Any,
84+
) -> Callable[..., Any]:
85+
searcherator: Search = searching_class(**searching_params)
86+
searcherator_kwargs_name = "searching"
87+
_ninja_contribute_args: List[Tuple] = getattr(func, "_ninja_contribute_args", [])
88+
89+
@wraps(func)
90+
def view_with_searching(controller: "ControllerBase", *args: Any, **kw: Any) -> Any:
91+
func_kwargs = dict(**kw)
92+
searching = func_kwargs.pop(searcherator_kwargs_name)
93+
if searcherator.pass_parameter:
94+
func_kwargs[searcherator.pass_parameter] = searching_params
95+
items = func(controller, *args, **func_kwargs)
96+
return searcherator.search_queryset(items, searching)
97+
98+
_ninja_contribute_args.append((
99+
searcherator_kwargs_name,
100+
searcherator.Input,
101+
searcherator.input_source,
102+
))
103+
view_with_searching._ninja_contribute_args = _ninja_contribute_args
104+
return view_with_searching

0 commit comments

Comments
 (0)