Features: 1) Introduced search fields with transliteration support across filters and Elasticsearch queries; 2) Simplified name filters in product, category, and brand filters by replacing custom logic with standard icontains; 3) Added process_system_query function with enhanced query capabilities.

Fixes: 1) Corrected inconsistent `QuerySet` type hints in vendors module; 2) Resolved string concatenation issue in `get_uuid_as_path` by prepending the path with "users/".

Extra: Updated Elasticsearch weighting factors and SMART_FIELDS configuration for better search relevance; removed unused methods and redundant comments in filters and documents; cleaned up migrations and adjusted query building logic.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-09-27 15:56:52 +03:00
parent cf268c8af3
commit bb5911abe6
6 changed files with 217 additions and 140 deletions

View file

@ -13,27 +13,32 @@ from rest_framework.request import Request
from core.models import Brand, Category, Product from core.models import Brand, Category, Product
SMART_FIELDS = [ SMART_FIELDS = [
"name^8", "name^6",
"name.ngram^8", "name.ngram^6",
"name.phonetic^6", "name.phonetic^4",
"title^5", "name.translit^5",
"title.ngram^4", "title^4",
"title.phonetic^2", "title.ngram^5",
"title.phonetic^3",
"title.translit^4",
"description^2", "description^2",
"description.ngram", "description.ngram^3",
"description.phonetic", "description.phonetic^2",
"brand_name^5", "description.translit^3",
"brand_name^4",
"brand_name.ngram^3", "brand_name.ngram^3",
"brand_name.auto^4", "brand_name.auto^4",
"brand_name.translit^4",
"category_name^3", "category_name^3",
"category_name.ngram^2", "category_name.ngram^3",
"category_name.auto^2", "category_name.auto^3",
"sku^9", "category_name.translit^3",
"sku.ngram^6", "sku^7",
"sku.auto^8", "sku.ngram^5",
"partnumber^10", "sku.auto^6",
"partnumber.ngram^7", "partnumber^8",
"partnumber.auto^9", "partnumber.ngram^6",
"partnumber.auto^7",
] ]
functions = [ functions = [
@ -42,75 +47,80 @@ functions = [
"field_value_factor": { "field_value_factor": {
"field": "brand_priority", "field": "brand_priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 0.2, "factor": 0.15,
"missing": 0, "missing": 0,
}, },
"weight": 0.6, "weight": 0.35,
}, },
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "rating", "field": "rating",
"modifier": "log1p", "modifier": "log1p",
"factor": 0.15, "factor": 0.10,
"missing": 0, "missing": 0,
}, },
"weight": 0.5, "weight": 0.3,
}, },
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "total_orders", "field": "total_orders",
"modifier": "log1p", "modifier": "log1p",
"factor": 0.25, "factor": 0.18,
"missing": 0, "missing": 0,
}, },
"weight": 0.7, "weight": 0.4,
}, },
{ {
"filter": Q("term", **{"_index": "products"}), "filter": Q("term", **{"_index": "products"}),
"field_value_factor": { "field_value_factor": {
"field": "category_priority", "field": "category_priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 0.2, "factor": 0.15,
"missing": 0, "missing": 0,
}, },
"weight": 0.6, "weight": 0.35,
}, },
{ {
"filter": Q("term", **{"_index": "categories"}), "filter": Q("term", **{"_index": "categories"}),
"field_value_factor": { "field_value_factor": {
"field": "priority", "field": "priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 0.25, "factor": 0.18,
"missing": 0, "missing": 0,
}, },
"weight": 0.8, "weight": 0.45,
}, },
{ {
"filter": Q("term", **{"_index": "brands"}), "filter": Q("term", **{"_index": "brands"}),
"field_value_factor": { "field_value_factor": {
"field": "priority", "field": "priority",
"modifier": "log1p", "modifier": "log1p",
"factor": 0.25, "factor": 0.18,
"missing": 0, "missing": 0,
}, },
"weight": 0.8, "weight": 0.45,
}, },
] ]
def process_query(query: str = "", request: Request | None = None) -> dict[str, list[dict]] | None: def process_query(
query: str = "",
request: Request | None = None,
indexes: tuple[str, ...] = ("categories", "brands", "products"),
use_transliteration: bool = True,
) -> dict[str, list[dict]] | None:
if not query: if not query:
raise ValueError(_("no search term provided.")) raise ValueError(_("no search term provided."))
query = query.strip() query = query.strip()
try: try:
exact_shoulds = [ exact_shoulds = [
Q("term", **{"name.raw": {"value": query, "boost": 3.0}}), Q("term", **{"name.raw": {"value": query, "boost": 2.0}}),
Q("term", **{"slug": {"value": slugify(query), "boost": 2.0}}), Q("term", **{"slug": {"value": slugify(query), "boost": 1.5}}),
Q("term", **{"sku.raw": {"value": query.lower(), "boost": 8.0}}), Q("term", **{"sku.raw": {"value": query.lower(), "boost": 6.0}}),
Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 9.0}}), Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 7.0}}),
] ]
lang = "" lang = ""
@ -122,10 +132,16 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
is_rtl_or_indic = base in {"ar", "hi"} is_rtl_or_indic = base in {"ar", "hi"}
fields_all = SMART_FIELDS[:] fields_all = SMART_FIELDS[:]
if not use_transliteration:
fields_all = [f for f in fields_all if ".translit" not in f]
if is_cjk or is_rtl_or_indic: if is_cjk or is_rtl_or_indic:
fields_all = [f for f in fields_all if ".phonetic" not in f] fields_all = [f for f in fields_all if ".phonetic" not in f]
fields_all = [ fields_all = [
f.replace("name.ngram^8", "name.ngram^10").replace("title.ngram^4", "title.ngram^6") for f in fields_all f.replace("name.ngram^6", "name.ngram^8")
.replace("title.ngram^5", "title.ngram^7")
.replace("description.ngram^3", "description.ngram^4")
for f in fields_all
] ]
fuzzy = None if (is_cjk or is_rtl_or_indic) else "AUTO:5,8" fuzzy = None if (is_cjk or is_rtl_or_indic) else "AUTO:5,8"
@ -138,6 +154,8 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
query=query, query=query,
fields=fields_all, fields=fields_all,
operator="and", operator="and",
type="most_fields",
tie_breaker=0.2,
**({"fuzziness": fuzzy} if fuzzy else {}), **({"fuzziness": fuzzy} if fuzzy else {}),
), ),
Q( Q(
@ -151,10 +169,10 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
if is_code_like: if is_code_like:
text_shoulds.extend( text_shoulds.extend(
[ [
Q("term", **{"sku.raw": {"value": query.lower(), "boost": 12.0}}), Q("term", **{"sku.raw": {"value": query.lower(), "boost": 10.0}}),
Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 14.0}}), Q("term", **{"partnumber.raw": {"value": query.lower(), "boost": 12.0}}),
Q("prefix", **{"sku.raw": {"value": query.lower(), "boost": 6.0}}), Q("prefix", **{"sku.raw": {"value": query.lower(), "boost": 5.0}}),
Q("prefix", **{"partnumber.raw": {"value": query.lower(), "boost": 7.0}}), Q("prefix", **{"partnumber.raw": {"value": query.lower(), "boost": 6.0}}),
] ]
) )
@ -164,9 +182,9 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
minimum_should_match=1, minimum_should_match=1,
) )
def build_search(indexes, size): def build_search(idxs, size):
return ( return (
Search(index=indexes) Search(index=idxs)
.query(query_base) .query(query_base)
.extra( .extra(
rescore={ rescore={
@ -178,29 +196,40 @@ def process_query(query: str = "", request: Request | None = None) -> dict[str,
functions=functions, functions=functions,
boost_mode="sum", boost_mode="sum",
score_mode="sum", score_mode="sum",
max_boost=2.0, max_boost=1.2,
).to_dict(), ).to_dict(),
"query_weight": 1.0, "query_weight": 1.0,
"rescore_query_weight": 1.0, "rescore_query_weight": 0.6,
}, },
} }
) )
.extra(size=size, track_total_hits=True) .extra(size=size, track_total_hits=True)
) )
search_cats = build_search(["categories"], size=22) resp_cats = None
search_brands = build_search(["brands"], size=22) if "categories" in indexes:
search_products = build_search(["products"], size=44) search_cats = build_search(["categories"], size=22)
resp_cats = search_cats.execute()
resp_cats = search_cats.execute() resp_brands = None
resp_brands = search_brands.execute() if "brands" in indexes:
resp_products = search_products.execute() search_brands = build_search(["brands"], size=22)
resp_brands = search_brands.execute()
resp_products = None
if "products" in indexes:
search_products = build_search(["products"], size=44)
resp_products = search_products.execute()
results: dict = {"products": [], "categories": [], "brands": [], "posts": []} results: dict = {"products": [], "categories": [], "brands": [], "posts": []}
uuids_by_index: dict[str, list] = {"products": [], "categories": [], "brands": []} uuids_by_index: dict[str, list] = {"products": [], "categories": [], "brands": []}
hit_cache: list = [] hit_cache: list = []
for h in list(resp_cats.hits[:12]) + list(resp_brands.hits[:12]) + list(resp_products.hits[:26]): for h in (
list(resp_cats.hits[:12] if resp_cats else [])
+ list(resp_brands.hits[:12] if resp_brands else [])
+ list(resp_products.hits[:26] if resp_products else [])
):
hit_cache.append(h) hit_cache.append(h)
if getattr(h, "uuid", None): if getattr(h, "uuid", None):
uuids_by_index.setdefault(h.meta.index, []).append(str(h.uuid)) uuids_by_index.setdefault(h.meta.index, []).append(str(h.uuid))
@ -296,8 +325,6 @@ def _lang_analyzer(lang_code: str) -> str:
class ActiveOnlyMixin: class ActiveOnlyMixin:
"""QuerySet & indexing helpers, so only *active* objects are indexed."""
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(is_active=True) return super().get_queryset().filter(is_active=True)
@ -317,6 +344,9 @@ COMMON_ANALYSIS = {
"double_metaphone": {"type": "phonetic", "encoder": "double_metaphone", "replace": False}, "double_metaphone": {"type": "phonetic", "encoder": "double_metaphone", "replace": False},
"arabic_norm": {"type": "arabic_normalization"}, "arabic_norm": {"type": "arabic_normalization"},
"indic_norm": {"type": "indic_normalization"}, "indic_norm": {"type": "indic_normalization"},
"icu_any_latin": {"type": "icu_transform", "id": "Any-Latin"},
"icu_latin_ascii": {"type": "icu_transform", "id": "Latin-ASCII"},
"icu_ru_latin_bgn": {"type": "icu_transform", "id": "Russian-Latin/BGN"},
}, },
"analyzer": { "analyzer": {
"icu_query": { "icu_query": {
@ -367,6 +397,32 @@ COMMON_ANALYSIS = {
"tokenizer": "icu_tokenizer", "tokenizer": "icu_tokenizer",
"filter": ["lowercase", "icu_folding", "indic_norm"], "filter": ["lowercase", "icu_folding", "indic_norm"],
}, },
"translit_index": {
"type": "custom",
"char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": [
"icu_any_latin",
"icu_ru_latin_bgn",
"icu_latin_ascii",
"lowercase",
"icu_folding",
"double_metaphone",
],
},
"translit_query": {
"type": "custom",
"char_filter": ["icu_nfkc_cf"],
"tokenizer": "icu_tokenizer",
"filter": [
"icu_any_latin",
"icu_ru_latin_bgn",
"icu_latin_ascii",
"lowercase",
"icu_folding",
"double_metaphone",
],
},
}, },
"normalizer": { "normalizer": {
"lc_norm": { "lc_norm": {
@ -378,12 +434,8 @@ COMMON_ANALYSIS = {
def add_multilang_fields(cls): def add_multilang_fields(cls):
"""
Dynamically add multilingual name/description fields and prepare methods to guard against None.
"""
for code, _lang in settings.LANGUAGES: for code, _lang in settings.LANGUAGES:
lc = code.replace("-", "_").lower() lc = code.replace("-", "_").lower()
# name_{lc}
name_field = f"name_{lc}" name_field = f"name_{lc}"
setattr( setattr(
cls, cls,
@ -396,17 +448,16 @@ def add_multilang_fields(cls):
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"),
}, },
), ),
) )
# prepare_name_{lc} to ensure no None values
def make_prepare(attr): def make_prepare(attr):
return lambda self, instance: getattr(instance, attr, "") or "" return lambda self, instance: getattr(instance, attr, "") or ""
setattr(cls, f"prepare_{name_field}", make_prepare(name_field)) setattr(cls, f"prepare_{name_field}", make_prepare(name_field))
# description_{lc}
desc_field = f"description_{lc}" desc_field = f"description_{lc}"
setattr( setattr(
cls, cls,
@ -419,6 +470,7 @@ def add_multilang_fields(cls):
"raw": fields.KeywordField(ignore_above=256), "raw": fields.KeywordField(ignore_above=256),
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"),
}, },
), ),
) )
@ -429,3 +481,62 @@ def populate_index():
for doc in registry.get_documents(set(registry.get_models())): for doc in registry.get_documents(set(registry.get_models())):
qs = doc().get_indexing_queryset() qs = doc().get_indexing_queryset()
doc().update(qs, parallel=True, refresh=True) doc().update(qs, parallel=True, refresh=True)
def process_system_query(
query: str,
*,
indexes: tuple[str, ...] = ("categories", "brands", "products"),
size_per_index: int = 25,
language_code: str | None = None,
use_transliteration: bool = True,
) -> dict[str, list[dict]]:
if not query:
raise ValueError(_("no search term provided."))
q = query.strip()
base = (language_code or "").split("-")[0].lower() if language_code else ""
is_cjk = base in {"ja", "zh"}
is_rtl_or_indic = base in {"ar", "hi"}
fields_all = [f for f in SMART_FIELDS if not f.startswith(("sku", "partnumber"))]
if not use_transliteration:
fields_all = [f for f in fields_all if ".translit" not in f]
if is_cjk or is_rtl_or_indic:
fields_all = [f for f in fields_all if ".phonetic" not in f]
fields_all = [
f.replace("ngram^6", "ngram^8").replace("ngram^5", "ngram^7").replace("ngram^3", "ngram^4")
for f in fields_all
]
fuzzy = None if (is_cjk or is_rtl_or_indic) else "AUTO:5,8"
mm = Q(
"multi_match",
query=q,
fields=fields_all,
operator="and",
type="most_fields",
tie_breaker=0.2,
**({"fuzziness": fuzzy} if fuzzy else {}),
)
results: dict[str, list[dict]] = {idx: [] for idx in indexes}
for idx in indexes:
s = Search(index=[idx]).query(mm).extra(size=size_per_index, track_total_hits=False)
resp = s.execute()
for h in resp.hits:
name = getattr(h, "name", None) or getattr(h, "title", None) or "N/A"
results[idx].append(
{
"id": getattr(h, "uuid", None) or h.meta.id,
"name": name,
"slug": getattr(h, "slug", ""),
"score": getattr(h.meta, "score", None),
}
)
return results

View file

@ -15,6 +15,7 @@ class BaseDocument(Document):
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"),
}, },
) )
description = fields.TextField( description = fields.TextField(
@ -25,6 +26,7 @@ class BaseDocument(Document):
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"),
}, },
) )
slug = fields.KeywordField(attr="slug", index=False) slug = fields.KeywordField(attr="slug", index=False)
@ -66,6 +68,7 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument):
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"),
}, },
) )
category_name = fields.TextField( category_name = fields.TextField(
@ -76,6 +79,7 @@ class ProductDocument(ActiveOnlyMixin, BaseDocument):
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"), "ngram": fields.TextField(analyzer="name_ngram", search_analyzer="icu_query"),
"phonetic": fields.TextField(analyzer="name_phonetic"), "phonetic": fields.TextField(analyzer="name_phonetic"),
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"), "auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
"translit": fields.TextField(analyzer="translit_index", search_analyzer="translit_query"),
}, },
) )

View file

@ -5,18 +5,19 @@ import uuid
from django.core.exceptions import BadRequest from django.core.exceptions import BadRequest
from django.db.models import ( from django.db.models import (
Avg, Avg,
BooleanField,
Case, Case,
Exists, Exists,
FloatField, FloatField,
IntegerField, IntegerField,
Max,
OuterRef, OuterRef,
Prefetch,
Q, Q,
QuerySet,
Subquery, Subquery,
Value, Value,
When, When,
Max,
Prefetch,
BooleanField,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
@ -61,8 +62,9 @@ class CaseInsensitiveListFilter(BaseInFilter, CharFilter):
class ProductFilter(FilterSet): class ProductFilter(FilterSet):
search = CharFilter(field_name="name", method="search_products", label=_("Search"))
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID")) uuid = UUIDFilter(field_name="uuid", lookup_expr="exact", label=_("UUID"))
name = CharFilter(method="filter_name", label=_("Name")) name = CharFilter(lookup_expr="icontains", label=_("Name"))
categories = CaseInsensitiveListFilter(field_name="category__name", label=_("Categories")) categories = CaseInsensitiveListFilter(field_name="category__name", label=_("Categories"))
category_uuid = CharFilter(method="filter_category", label="Category (UUID)") category_uuid = CharFilter(method="filter_category", label="Category (UUID)")
categories_slugs = CaseInsensitiveListFilter(field_name="category__slug", label=_("Categories Slugs")) categories_slugs = CaseInsensitiveListFilter(field_name="category__slug", label=_("Categories Slugs"))
@ -120,58 +122,25 @@ class ProductFilter(FilterSet):
"order_by", "order_by",
] ]
def __init__(self, data=None, queryset=None, *, request=None, prefix=None): def search_products(self, queryset: QuerySet[Product], name, value):
super().__init__(data=data, queryset=queryset, request=request, prefix=prefix) if not value:
ordering_param = self.data.get("order_by", "") return queryset
if ordering_param:
order_fields = [field.strip("-") for field in ordering_param.split(",")]
if "rating" in order_fields:
feedback_qs = (
Feedback.objects.filter(order_product__product_id=OuterRef("pk"))
.values("order_product__product_id")
.annotate(avg_rating=Avg("rating"))
.values("avg_rating")
)
self.queryset = self.queryset.annotate(
rating=Coalesce(
Subquery(feedback_qs, output_field=FloatField()),
Value(0, output_field=FloatField()),
)
)
if "price" in order_fields:
self.queryset = self.queryset.annotate(
price_order=Coalesce(
Max("stocks__price"),
Value(0.0),
output_field=FloatField(),
)
)
def filter_name(self, queryset, _name, value): uuids = [product.get("uuid") for product in process_query(query=value, indexes=("products",))["products"]]
search_results = process_query(query=value, request=self.request)["products"]
uuids = [res["uuid"] for res in search_results if res.get("uuid")]
if not uuids: return queryset.filter(uuid__in=uuids)
return queryset.none()
ordering = Case( def filter_include_flag(self, queryset, name, value):
*[When(uuid=uuid_, then=pos) for pos, uuid_ in enumerate(uuids)],
output_field=IntegerField(),
)
return queryset.filter(uuid__in=uuids).annotate(_order=ordering).order_by("_order")
def filter_include_flag(self, queryset, **_kwargs):
if not self.data.get("category_uuid"): if not self.data.get("category_uuid"):
raise BadRequest(_("there must be a category_uuid to use include_subcategories flag")) raise BadRequest(_("there must be a category_uuid to use include_subcategories flag"))
return queryset return queryset
def filter_include_personal_ordered(self, queryset, **_kwargs): def filter_include_personal_ordered(self, queryset, name, value):
if self.data.get("include_personal_ordered", False): if self.data.get("include_personal_ordered", False):
queryset = queryset.filter(stocks__isnull=False, stocks__quantity__gt=0, stocks__price__gt=0) queryset = queryset.filter(stocks__isnull=False, stocks__quantity__gt=0, stocks__price__gt=0)
return queryset return queryset
def filter_attributes(self, queryset, _name, value): def filter_attributes(self, queryset, name, value):
if not value: if not value:
return queryset return queryset
@ -233,7 +202,7 @@ class ProductFilter(FilterSet):
return queryset return queryset
def filter_category(self, queryset, _name, value): def filter_category(self, queryset, name, value):
if not value: if not value:
return queryset return queryset
@ -425,8 +394,9 @@ class WishlistFilter(FilterSet):
class CategoryFilter(FilterSet): class CategoryFilter(FilterSet):
search = CharFilter(field_name="name", method="search_categories", label=_("Search"))
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
name = CharFilter(method="filter_name", label=_("Name")) name = CharFilter(lookup_expr="icontains", label=_("Name"))
parent_uuid = CharFilter(method="filter_parent_uuid", label=_("Parent")) parent_uuid = CharFilter(method="filter_parent_uuid", label=_("Parent"))
slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug")) slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug"))
whole = BooleanFilter( whole = BooleanFilter(
@ -452,6 +422,14 @@ class CategoryFilter(FilterSet):
"whole", "whole",
] ]
def search_categories(self, queryset: QuerySet[Product], name, value):
if not value:
return queryset
uuids = [category.get("uuid") for category in process_query(query=value, indexes=("categories",))["categories"]]
return queryset.filter(uuid__in=uuids)
def filter_order_by(self, queryset, _name, value): def filter_order_by(self, queryset, _name, value):
if not value: if not value:
return queryset return queryset
@ -514,20 +492,6 @@ class CategoryFilter(FilterSet):
return qs return qs
def filter_name(self, queryset, _name, value):
search_results = process_query(query=value, request=self.request)["categories"]
uuids = [res["uuid"] for res in search_results if res.get("uuid")]
if not uuids:
return queryset.none()
ordering = Case(
*[When(uuid=uuid_, then=pos) for pos, uuid_ in enumerate(uuids)],
output_field=IntegerField(),
)
return queryset.filter(uuid__in=uuids).annotate(_order=ordering).order_by("_order")
def filter_whole_categories(self, queryset, _name, value): def filter_whole_categories(self, queryset, _name, value):
has_own_products = Exists(Product.objects.filter(category=OuterRef("pk"))) has_own_products = Exists(Product.objects.filter(category=OuterRef("pk")))
has_desc_products = Exists( has_desc_products = Exists(
@ -556,8 +520,9 @@ class CategoryFilter(FilterSet):
class BrandFilter(FilterSet): class BrandFilter(FilterSet):
search = CharFilter(field_name="name", method="search_brands", label=_("Search"))
uuid = UUIDFilter(field_name="uuid", lookup_expr="exact") uuid = UUIDFilter(field_name="uuid", lookup_expr="exact")
name = CharFilter(method="filter_name", label=_("Name")) name = CharFilter(lookup_expr="icontains", label=_("Name"))
slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug")) slug = CharFilter(field_name="slug", lookup_expr="exact", label=_("Slug"))
categories = CaseInsensitiveListFilter(field_name="categories__uuid", lookup_expr="exact", label=_("Categories")) categories = CaseInsensitiveListFilter(field_name="categories__uuid", lookup_expr="exact", label=_("Categories"))
@ -575,19 +540,13 @@ class BrandFilter(FilterSet):
model = Brand model = Brand
fields = ["uuid", "name", "slug", "priority"] fields = ["uuid", "name", "slug", "priority"]
def filter_name(self, queryset, _name, value): def search_brands(self, queryset: QuerySet[Product], name, value):
search_results = process_query(query=value, request=self.request)["brands"] if not value:
uuids = [res["uuid"] for res in search_results if res.get("uuid")] return queryset
if not uuids: uuids = [brand.get("uuid") for brand in process_query(query=value, indexes=("brands",))["brands"]]
return queryset.none()
ordering = Case( return queryset.filter(uuid__in=uuids)
*[When(uuid=uuid_, then=pos) for pos, uuid_ in enumerate(uuids)],
output_field=IntegerField(),
)
return queryset.filter(uuid__in=uuids).annotate(_order=ordering).order_by("_order")
class FeedbackFilter(FilterSet): class FeedbackFilter(FilterSet):

View file

@ -4,7 +4,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("core", "0042_attribute_name_fa_ir_attribute_name_he_il_and_more"), ("core", "0042_attribute_name_fa_ir_attribute_name_he_il_and_more"),
] ]

View file

@ -5,9 +5,8 @@ from math import ceil, log10
from typing import Any from typing import Any
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import QuerySet
from core.elasticsearch import process_query from core.elasticsearch import process_system_query
from core.models import ( from core.models import (
Attribute, Attribute,
AttributeGroup, AttributeGroup,
@ -174,7 +173,7 @@ class AbstractVendor:
def auto_resolve_category(self, category_name: str = "") -> Category | None: def auto_resolve_category(self, category_name: str = "") -> Category | None:
if category_name: if category_name:
try: try:
search = process_query(category_name) search = process_system_query(query=category_name, indexes=("categories",))
uuid = search["categories"][0]["uuid"] if search else None uuid = search["categories"][0]["uuid"] if search else None
if uuid: if uuid:
return Category.objects.get(uuid=uuid) return Category.objects.get(uuid=uuid)
@ -192,7 +191,7 @@ class AbstractVendor:
def auto_resolve_brand(self, brand_name: str = "") -> Brand | None: def auto_resolve_brand(self, brand_name: str = "") -> Brand | None:
if brand_name: if brand_name:
try: try:
search = process_query(brand_name) search = process_system_query(query=brand_name, indexes=("brands",))
uuid = search["brands"][0]["uuid"] if search else None uuid = search["brands"][0]["uuid"] if search else None
if uuid: if uuid:
return Brand.objects.get(uuid=uuid) return Brand.objects.get(uuid=uuid)
@ -283,13 +282,13 @@ class AbstractVendor:
def get_products(self) -> None: def get_products(self) -> None:
pass pass
def get_products_queryset(self) -> QuerySet[Product] | None: def get_products_queryset(self):
return Product.objects.filter(stocks__vendor=self.get_vendor_instance(), orderproduct__isnull=True) return Product.objects.filter(stocks__vendor=self.get_vendor_instance(), orderproduct__isnull=True)
def get_stocks_queryset(self) -> QuerySet[Stock] | None: def get_stocks_queryset(self):
return Stock.objects.filter(product__in=self.get_products_queryset(), product__orderproduct__isnull=True) return Stock.objects.filter(product__in=self.get_products_queryset(), product__orderproduct__isnull=True)
def get_attribute_values_queryset(self) -> QuerySet[AttributeValue] | None: def get_attribute_values_queryset(self):
return AttributeValue.objects.filter( return AttributeValue.objects.filter(
product__in=self.get_products_queryset(), product__orderproduct__isnull=True product__in=self.get_products_queryset(), product__orderproduct__isnull=True
) )
@ -311,6 +310,8 @@ class AbstractVendor:
raise ValueError(f"Invalid method {method!r} for products update...") raise ValueError(f"Invalid method {method!r} for products update...")
def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000) -> None: def delete_inactives(self, inactivation_method: str = "deactivate", size: int = 5000) -> None:
filter_kwargs: dict[str, Any] = dict()
match inactivation_method: match inactivation_method:
case "deactivate": case "deactivate":
filter_kwargs: dict[str, Any] = {"is_active": False} filter_kwargs: dict[str, Any] = {"is_active": False}
@ -319,6 +320,9 @@ class AbstractVendor:
case _: case _:
raise ValueError(f"Invalid method {inactivation_method!r} for products cleaner...") raise ValueError(f"Invalid method {inactivation_method!r} for products cleaner...")
if filter_kwargs == {}:
raise ValueError("Invalid filter kwargs...")
while True: while True:
products = self.get_products_queryset() products = self.get_products_queryset()

View file

@ -80,7 +80,7 @@ class User(AbstractUser, NiceModel): # type: ignore [django-manager-missing]
""" """
def get_uuid_as_path(self, *args): def get_uuid_as_path(self, *args):
return str(self.uuid) + "/" + args[0] return "users/" + str(self.uuid) + "/" + args[0]
email = EmailField(_("email"), unique=True, help_text=_("user email address")) email = EmailField(_("email"), unique=True, help_text=_("user email address"))
phone_number = CharField( phone_number = CharField(