Features: 1) Add support for auto-generating slugs for Brand, Category, and Product models; 2) Extend Elasticsearch documents with slug indexing and response processing; 3) Introduce image fetching in search results.
Fixes: 1) Update slug population logic in management commands. Extra: Refactor renderer class formatting, query processing, and formatting for readability across multiple files.
This commit is contained in:
parent
201c66069b
commit
8c906a2880
7 changed files with 269 additions and 77 deletions
|
|
@ -1,11 +1,15 @@
|
|||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_elasticsearch_dsl import fields
|
||||
from django_elasticsearch_dsl.registries import registry
|
||||
from elasticsearch import NotFoundError
|
||||
from elasticsearch.dsl import Q, Search
|
||||
from rest_framework.request import Request
|
||||
|
||||
from core.models import Brand, Category, Product
|
||||
|
||||
SMART_FIELDS = [
|
||||
"name^4",
|
||||
|
|
@ -29,7 +33,7 @@ SMART_FIELDS = [
|
|||
]
|
||||
|
||||
|
||||
def process_query(query: str = ""):
|
||||
def process_query(query: str = "", request: Request | None = None):
|
||||
"""
|
||||
Perform a lenient, typo‑tolerant, multi‑index search.
|
||||
|
||||
|
|
@ -41,7 +45,6 @@ def process_query(query: str = ""):
|
|||
|
||||
query = query.strip()
|
||||
try:
|
||||
# Build the boolean query
|
||||
q = Q(
|
||||
"bool",
|
||||
should=[
|
||||
|
|
@ -62,27 +65,53 @@ def process_query(query: str = ""):
|
|||
minimum_should_match=1,
|
||||
)
|
||||
|
||||
# Execute search across multiple indices
|
||||
search = Search(index=["products", "categories", "brands", "posts"]).query(q).extra(size=100)
|
||||
search = (
|
||||
Search(index=["products", "categories", "brands", "posts"])
|
||||
.query(q)
|
||||
.extra(size=100)
|
||||
)
|
||||
response = search.execute()
|
||||
|
||||
# Collect results, guard against None values
|
||||
results: dict = {"products": [], "categories": [], "brands": [], "posts": []}
|
||||
for hit in response.hits:
|
||||
obj_uuid = getattr(hit, "uuid", None) or hit.meta.id
|
||||
obj_name = getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A"
|
||||
# Safely generate a slug
|
||||
obj_slug = getattr(hit, "slug", None) or slugify(f"{obj_uuid}{obj_name}")
|
||||
obj_name = (
|
||||
getattr(hit, "name", None) or getattr(hit, "title", None) or "N/A"
|
||||
)
|
||||
|
||||
raw_slug = getattr(hit, "slug", None)
|
||||
if raw_slug:
|
||||
obj_slug = raw_slug
|
||||
elif hit.meta.index == "brands":
|
||||
obj_slug = slugify(obj_name)
|
||||
elif hit.meta.index == "categories":
|
||||
obj_slug = slugify(f"{obj_name}")
|
||||
|
||||
image_url = None
|
||||
idx = hit.meta.index
|
||||
if idx in results:
|
||||
if idx == "products" and request:
|
||||
prod = get_object_or_404(Product, uuid=obj_uuid)
|
||||
first = prod.images.order_by("priority").first()
|
||||
if first and first.image:
|
||||
image_url = request.build_absolute_uri(first.image.url)
|
||||
elif idx == "brands" and request:
|
||||
brand = get_object_or_404(Brand, uuid=obj_uuid)
|
||||
if brand.small_logo:
|
||||
image_url = request.build_absolute_uri(brand.small_logo.url)
|
||||
elif idx == "categories" and request:
|
||||
cat = get_object_or_404(Category, uuid=obj_uuid)
|
||||
if cat.image:
|
||||
image_url = request.build_absolute_uri(cat.image.url)
|
||||
|
||||
results[idx].append(
|
||||
{
|
||||
"uuid": str(obj_uuid),
|
||||
"name": obj_name,
|
||||
"slug": obj_slug,
|
||||
"image": image_url,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
except NotFoundError:
|
||||
raise Http404
|
||||
|
|
@ -129,13 +158,29 @@ COMMON_ANALYSIS = {
|
|||
"filter": {
|
||||
"edge_ngram_filter": {"type": "edge_ngram", "min_gram": 1, "max_gram": 20},
|
||||
"ngram_filter": {"type": "ngram", "min_gram": 2, "max_gram": 20},
|
||||
"double_metaphone": {"type": "phonetic", "encoder": "double_metaphone", "replace": False},
|
||||
"double_metaphone": {
|
||||
"type": "phonetic",
|
||||
"encoder": "double_metaphone",
|
||||
"replace": False,
|
||||
},
|
||||
},
|
||||
"analyzer": {
|
||||
"autocomplete": {"tokenizer": "standard", "filter": ["lowercase", "asciifolding", "edge_ngram_filter"]},
|
||||
"autocomplete_search": {"tokenizer": "standard", "filter": ["lowercase", "asciifolding"]},
|
||||
"name_ngram": {"tokenizer": "standard", "filter": ["lowercase", "asciifolding", "ngram_filter"]},
|
||||
"name_phonetic": {"tokenizer": "standard", "filter": ["lowercase", "asciifolding", "double_metaphone"]},
|
||||
"autocomplete": {
|
||||
"tokenizer": "standard",
|
||||
"filter": ["lowercase", "asciifolding", "edge_ngram_filter"],
|
||||
},
|
||||
"autocomplete_search": {
|
||||
"tokenizer": "standard",
|
||||
"filter": ["lowercase", "asciifolding"],
|
||||
},
|
||||
"name_ngram": {
|
||||
"tokenizer": "standard",
|
||||
"filter": ["lowercase", "asciifolding", "ngram_filter"],
|
||||
},
|
||||
"name_phonetic": {
|
||||
"tokenizer": "standard",
|
||||
"filter": ["lowercase", "asciifolding", "double_metaphone"],
|
||||
},
|
||||
"query_lc": {"tokenizer": "standard", "filter": ["lowercase", "asciifolding"]},
|
||||
},
|
||||
}
|
||||
|
|
@ -158,7 +203,9 @@ def _add_multilang_fields(cls):
|
|||
copy_to="name",
|
||||
fields={
|
||||
"raw": fields.KeywordField(ignore_above=256),
|
||||
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"),
|
||||
"ngram": fields.TextField(
|
||||
analyzer="name_ngram", search_analyzer="query_lc"
|
||||
),
|
||||
"phonetic": fields.TextField(analyzer="name_phonetic"),
|
||||
},
|
||||
),
|
||||
|
|
@ -181,7 +228,9 @@ def _add_multilang_fields(cls):
|
|||
copy_to="description",
|
||||
fields={
|
||||
"raw": fields.KeywordField(ignore_above=256),
|
||||
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"),
|
||||
"ngram": fields.TextField(
|
||||
analyzer="name_ngram", search_analyzer="query_lc"
|
||||
),
|
||||
"phonetic": fields.TextField(analyzer="name_phonetic"),
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,9 +11,13 @@ class _BaseDoc(ActiveOnlyMixin, Document):
|
|||
analyzer="standard",
|
||||
fields={
|
||||
"raw": fields.KeywordField(ignore_above=256),
|
||||
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"),
|
||||
"ngram": fields.TextField(
|
||||
analyzer="name_ngram", search_analyzer="query_lc"
|
||||
),
|
||||
"phonetic": fields.TextField(analyzer="name_phonetic"),
|
||||
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
|
||||
"auto": fields.TextField(
|
||||
analyzer="autocomplete", search_analyzer="autocomplete_search"
|
||||
),
|
||||
},
|
||||
)
|
||||
description = fields.TextField(
|
||||
|
|
@ -21,11 +25,16 @@ class _BaseDoc(ActiveOnlyMixin, Document):
|
|||
analyzer="standard",
|
||||
fields={
|
||||
"raw": fields.KeywordField(ignore_above=256),
|
||||
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"),
|
||||
"ngram": fields.TextField(
|
||||
analyzer="name_ngram", search_analyzer="query_lc"
|
||||
),
|
||||
"phonetic": fields.TextField(analyzer="name_phonetic"),
|
||||
"auto": fields.TextField(analyzer="autocomplete", search_analyzer="autocomplete_search"),
|
||||
"auto": fields.TextField(
|
||||
analyzer="autocomplete", search_analyzer="autocomplete_search"
|
||||
),
|
||||
},
|
||||
)
|
||||
slug = fields.KeywordField(attr="slug", index=False)
|
||||
|
||||
class Index:
|
||||
settings = {
|
||||
|
|
@ -76,7 +85,9 @@ class BrandDocument(ActiveOnlyMixin, Document):
|
|||
analyzer="standard",
|
||||
fields={
|
||||
"raw": fields.KeywordField(ignore_above=256),
|
||||
"ngram": fields.TextField(analyzer="name_ngram", search_analyzer="query_lc"),
|
||||
"ngram": fields.TextField(
|
||||
analyzer="name_ngram", search_analyzer="query_lc"
|
||||
),
|
||||
"phonetic": fields.TextField(analyzer="name_phonetic"),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from core.models import Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Populate slug field for all Product instances"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
qs = Product.objects.filter(slug__isnull=True)
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Starting slug population for {total} products")
|
||||
|
||||
for idx, product in enumerate(qs.iterator(), start=1):
|
||||
try:
|
||||
product.slug = None
|
||||
with transaction.atomic():
|
||||
product.save(update_fields=["slug"])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"[{idx}/{total}] (Product ID: {product.pk}) slug set to '{product.slug}'")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Product {product.pk}: slug population failed")
|
||||
self.stderr.write(self.style.ERROR(f"[{idx}/{total}] (Product ID: {product.pk}) ERROR: {e}"))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Slug population complete."))
|
||||
50
core/management/commands/rebuild_slugs.py
Normal file
50
core/management/commands/rebuild_slugs.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils.crypto import get_random_string
|
||||
|
||||
from core.models import Brand, Category, Product
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Rebuild slug field for all slugified instances"
|
||||
|
||||
def reset_em(self, queryset):
|
||||
total = queryset.count()
|
||||
self.stdout.write(
|
||||
f"Starting slug rebuilding for {total} {queryset.model._meta.verbose_name_plural}"
|
||||
)
|
||||
for idx, instance in enumerate(queryset.iterator(), start=1):
|
||||
try:
|
||||
if (
|
||||
queryset.filter(name=instance.name).exclude(pk=instance.pk).count()
|
||||
>= 1
|
||||
):
|
||||
instance.name = f"{instance.name} - {get_random_string(length=3, allowed_chars='0123456789')}"
|
||||
instance.save()
|
||||
instance.slug = None
|
||||
with transaction.atomic():
|
||||
instance.save(update_fields=["slug"])
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"[{idx}/{total}] ({queryset.model._meta.verbose_name_plural} UUID:"
|
||||
f" {instance.pk}) slug set to '{instance.slug}'"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(
|
||||
f"[{idx}/{total}] ({queryset.model._meta.verbose_name_plural}: {instance.pk}) ERROR: {e}"
|
||||
)
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
||||
for queryset in [
|
||||
Brand.objects.all(),
|
||||
Category.objects.all(),
|
||||
Product.objects.all(),
|
||||
]:
|
||||
self.reset_em(queryset)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Slug rebuild complete."))
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Generated by Django 5.2 on 2025-06-18 19:21
|
||||
|
||||
import django_extensions.db.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0025_alter_product_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="slug",
|
||||
field=django_extensions.db.fields.AutoSlugField(
|
||||
allow_unicode=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
populate_from=("name",),
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="category",
|
||||
name="slug",
|
||||
field=django_extensions.db.fields.AutoSlugField(
|
||||
allow_unicode=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
populate_from=("name",),
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="product",
|
||||
name="slug",
|
||||
field=django_extensions.db.fields.AutoSlugField(
|
||||
allow_unicode=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
populate_from=("category__slug", "brand__slug", "name", "uuid"),
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -214,7 +214,7 @@ class Category(ExportModelOperationsMixin("category"), NiceModel, MPTTModel):
|
|||
)
|
||||
|
||||
slug: str = AutoSlugField( # type: ignore
|
||||
populate_from=("uuid", "name"),
|
||||
populate_from=("name",),
|
||||
allow_unicode=True,
|
||||
unique=True,
|
||||
editable=False,
|
||||
|
|
@ -281,6 +281,13 @@ class Brand(ExportModelOperationsMixin("brand"), NiceModel):
|
|||
help_text=_("optional categories that this brand is associated with"),
|
||||
verbose_name=_("associated categories"),
|
||||
)
|
||||
slug: str = AutoSlugField( # type: ignore
|
||||
populate_from=("name",),
|
||||
allow_unicode=True,
|
||||
unique=True,
|
||||
editable=False,
|
||||
null=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
|
@ -341,7 +348,7 @@ class Product(ExportModelOperationsMixin("product"), NiceModel):
|
|||
verbose_name=_("part number"),
|
||||
)
|
||||
slug: str | None = AutoSlugField( # type: ignore
|
||||
populate_from=("uuid", "category__name", "name"),
|
||||
populate_from=("category__slug", "brand__slug", "name", "uuid"),
|
||||
allow_unicode=True,
|
||||
unique=True,
|
||||
editable=False,
|
||||
|
|
|
|||
|
|
@ -91,7 +91,12 @@ class SupportedLanguagesView(APIView):
|
|||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer]
|
||||
renderer_classes = [
|
||||
CamelCaseJSONRenderer,
|
||||
MultiPartRenderer,
|
||||
XMLRenderer,
|
||||
YAMLRenderer,
|
||||
]
|
||||
|
||||
def get(self, request):
|
||||
return Response(
|
||||
|
|
@ -116,10 +121,17 @@ class WebsiteParametersView(APIView):
|
|||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer]
|
||||
renderer_classes = [
|
||||
CamelCaseJSONRenderer,
|
||||
MultiPartRenderer,
|
||||
XMLRenderer,
|
||||
YAMLRenderer,
|
||||
]
|
||||
|
||||
def get(self, request):
|
||||
return Response(data=camelize(get_project_parameters()), status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
data=camelize(get_project_parameters()), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(**CACHE_SCHEMA)
|
||||
|
|
@ -128,7 +140,12 @@ class CacheOperatorView(APIView):
|
|||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer]
|
||||
renderer_classes = [
|
||||
CamelCaseJSONRenderer,
|
||||
MultiPartRenderer,
|
||||
XMLRenderer,
|
||||
YAMLRenderer,
|
||||
]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return Response(
|
||||
|
|
@ -145,7 +162,12 @@ class CacheOperatorView(APIView):
|
|||
@extend_schema_view(**CONTACT_US_SCHEMA)
|
||||
class ContactUsView(APIView):
|
||||
serializer_class = ContactUsSerializer
|
||||
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer]
|
||||
renderer_classes = [
|
||||
CamelCaseJSONRenderer,
|
||||
MultiPartRenderer,
|
||||
XMLRenderer,
|
||||
YAMLRenderer,
|
||||
]
|
||||
|
||||
@ratelimit(key="ip", rate="2/h")
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -161,7 +183,12 @@ class RequestCursedURLView(APIView):
|
|||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer]
|
||||
renderer_classes = [
|
||||
CamelCaseJSONRenderer,
|
||||
MultiPartRenderer,
|
||||
XMLRenderer,
|
||||
YAMLRenderer,
|
||||
]
|
||||
|
||||
@ratelimit(key="ip", rate="10/h")
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
|
@ -174,7 +201,9 @@ class RequestCursedURLView(APIView):
|
|||
try:
|
||||
data = cache.get(url, None)
|
||||
if not data:
|
||||
response = requests.get(url, headers={"content-type": "application/json"})
|
||||
response = requests.get(
|
||||
url, headers={"content-type": "application/json"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = camelize(response.json())
|
||||
cache.set(url, data, 86400)
|
||||
|
|
@ -196,10 +225,23 @@ class GlobalSearchView(APIView):
|
|||
It returns a response grouping matched items by index.
|
||||
"""
|
||||
|
||||
renderer_classes = [CamelCaseJSONRenderer, MultiPartRenderer, XMLRenderer, YAMLRenderer]
|
||||
renderer_classes = [
|
||||
CamelCaseJSONRenderer,
|
||||
MultiPartRenderer,
|
||||
XMLRenderer,
|
||||
YAMLRenderer,
|
||||
]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return Response(camelize({"results": process_query(request.GET.get("q", "").strip())}))
|
||||
return Response(
|
||||
camelize(
|
||||
{
|
||||
"results": process_query(
|
||||
query=request.GET.get("q", "").strip(), request=request
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(**BUY_AS_BUSINESS_SCHEMA)
|
||||
|
|
@ -209,19 +251,29 @@ class BuyAsBusinessView(APIView):
|
|||
serializer = BuyAsBusinessOrderSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
order = Order.objects.create(status="MOMENTAL")
|
||||
products = [product.get("product_uuid") for product in serializer.validated_data.get("products")]
|
||||
products = [
|
||||
product.get("product_uuid")
|
||||
for product in serializer.validated_data.get("products")
|
||||
]
|
||||
transaction = order.buy_without_registration(
|
||||
products=products,
|
||||
promocode_uuid=serializer.validated_data.get("promocode_uuid"),
|
||||
customer_name=serializer.validated_data.get("customer_name"),
|
||||
customer_email=serializer.validated_data.get("customer_email"),
|
||||
customer_phone=serializer.validated_data.get("customer_phone"),
|
||||
customer_billing_address=serializer.validated_data.get("customer_billing_address_uuid"),
|
||||
customer_shipping_address=serializer.validated_data.get("customer_shipping_address_uuid"),
|
||||
customer_billing_address=serializer.validated_data.get(
|
||||
"customer_billing_address_uuid"
|
||||
),
|
||||
customer_shipping_address=serializer.validated_data.get(
|
||||
"customer_shipping_address_uuid"
|
||||
),
|
||||
payment_method=serializer.validated_data.get("payment_method"),
|
||||
is_business=True,
|
||||
)
|
||||
return Response(status=status.HTTP_202_ACCEPTED, data=TransactionProcessSerializer(transaction).data)
|
||||
return Response(
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
data=TransactionProcessSerializer(transaction).data,
|
||||
)
|
||||
|
||||
|
||||
def download_digital_asset_view(request, *args, **kwargs):
|
||||
|
|
@ -235,7 +287,9 @@ def download_digital_asset_view(request, *args, **kwargs):
|
|||
download.num_downloads += 1
|
||||
download.save()
|
||||
|
||||
file_path = download.order_product.product.stocks.first().digital_asset.file.path
|
||||
file_path = (
|
||||
download.order_product.product.stocks.first().digital_asset.file.path
|
||||
)
|
||||
|
||||
content_type, encoding = mimetypes.guess_type(file_path)
|
||||
if not content_type:
|
||||
|
|
@ -255,7 +309,10 @@ def download_digital_asset_view(request, *args, **kwargs):
|
|||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return JsonResponse({"error": "An error occurred while trying to download the digital asset"}, status=500)
|
||||
return JsonResponse(
|
||||
{"error": "An error occurred while trying to download the digital asset"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
def favicon_view(request, *args, **kwargs):
|
||||
|
|
|
|||
Loading…
Reference in a new issue