Features: 1) Add SEOMetaType to support structured SEO metadata for brands, products, and categories; 2) Introduce resolve_seo_meta for dynamic SEO data generation on key models; 3) Add helper functions graphene_current_lang and graphene_abs for language-based URLs and absolute URI handling.

Fixes: None;

Extra: 1) Refactor utility docstrings for clarity and detail; 2) Modernize utility function logic and improve inline comments; 3) Adjust logging initialization in `object_types.py`.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-08-18 14:36:48 +03:00
parent ab10a7a0b7
commit 91ebaece07
2 changed files with 310 additions and 42 deletions

View file

@ -1,3 +1,6 @@
import logging
from constance import config
from django.core.cache import cache
from django.db.models import Max, Min, QuerySet
from django.db.models.functions import Length
@ -41,10 +44,31 @@ from core.models import (
Vendor,
Wishlist,
)
from core.utils import graphene_current_lang, graphene_abs
from core.utils.seo_builders import (
org_schema,
breadcrumb_schema,
brand_schema,
website_schema,
category_schema,
item_list_schema,
product_schema,
)
from payments.graphene.object_types import TransactionType
from payments.models import Transaction
logger = __import__("logging").getLogger(__name__)
logger = logging.getLogger("django")
class SEOMetaType(ObjectType):
title = String()
description = String()
canonical = String()
robots = String()
open_graph = GenericScalar()
twitter = GenericScalar()
json_ld = List(GenericScalar)
hreflang = List(GenericScalar)
class AttributeType(DjangoObjectType):
@ -90,6 +114,7 @@ class AttributeGroupType(DjangoObjectType):
class BrandType(DjangoObjectType):
categories = List(lambda: CategoryType, description=_("categories"))
seo_meta = Field(SEOMetaType, description=_("SEO meta snapshot"))
class Meta:
model = Brand
@ -109,6 +134,48 @@ class BrandType(DjangoObjectType):
def resolve_small_logo(self: Brand, info):
return info.context.build_absolute_uri(self.small_logo.url) if self.small_logo else ""
def resolve_seo_meta(self: Brand, info):
lang = graphene_current_lang()
base = f"https://{config.BASE_DOMAIN}"
canonical = f"{base}/{lang}/brand/{self.slug}"
title = f"{self.name} | {config.PROJECT_NAME}"
description = (self.description or "")[:180]
logo_url = None
if getattr(self, "big_logo", None):
logo_url = graphene_abs(info.context, self.big_logo.url)
elif getattr(self, "small_logo", None):
logo_url = graphene_abs(info.context, self.small_logo.url)
og = {
"title": title,
"description": description,
"type": "website",
"url": canonical,
"image": logo_url or "",
}
tw = {"card": "summary_large_image", "title": title, "description": description, "image": logo_url or ""}
crumbs = [("Home", f"{base}/"), (self.name, canonical)]
json_ld = [
org_schema(),
website_schema(),
breadcrumb_schema(crumbs),
brand_schema(self, canonical, logo_url=logo_url),
]
return {
"title": title,
"description": description,
"canonical": canonical,
"robots": "index,follow",
"open_graph": og,
"twitter": tw,
"json_ld": json_ld,
"hreflang": [],
}
class FilterableAttributeType(ObjectType):
attribute_name = String(required=True)
@ -137,6 +204,7 @@ class CategoryType(DjangoObjectType):
)
tags = DjangoFilterConnectionField(lambda: CategoryTagType, description=_("tags for this category"))
products = DjangoFilterConnectionField(lambda: ProductType, description=_("products in this category"))
seo_meta = Field(SEOMetaType, description=_("SEO meta snapshot"))
class Meta:
model = Category
@ -219,6 +287,58 @@ class CategoryType(DjangoObjectType):
"max_price": min_max_prices["max_price"],
}
def resolve_seo_meta(self: Category, info):
lang = graphene_current_lang()
base = f"https://{config.BASE_DOMAIN}"
canonical = f"{base}/{lang}/catalog/{self.slug}"
title = f"{self.name} | {config.PROJECT_NAME}"
description = (self.description or "")[:180]
og_image = graphene_abs(info.context, self.image.url) if getattr(self, "image", None) else ""
og = {
"title": title,
"description": description,
"type": "website",
"url": canonical,
"image": og_image,
}
tw = {"card": "summary_large_image", "title": title, "description": description, "image": og_image}
crumbs = [("Home", f"{base}/")]
for c in self.get_ancestors():
crumbs.append((c.name, f"{base}/{lang}/catalog/{c.slug}"))
crumbs.append((self.name, canonical))
json_ld = [org_schema(), website_schema(), breadcrumb_schema(crumbs), category_schema(self, canonical)]
product_urls = []
qs = (
Product.objects.filter(
is_active=True,
category=self,
brand__is_active=True,
stocks__vendor__is_active=True,
)
.only("slug")
.distinct()[:24]
)
for p in qs:
product_urls.append(f"{base}/{lang}/product/{p.slug}")
if product_urls:
json_ld.append(item_list_schema(product_urls))
return {
"title": title,
"description": description,
"canonical": canonical,
"robots": "index,follow",
"open_graph": og,
"twitter": tw,
"json_ld": json_ld,
"hreflang": [],
}
class VendorType(DjangoObjectType):
markup_percent = Float(description=_("markup percentage"))
@ -380,6 +500,7 @@ class ProductType(DjangoObjectType):
quantity = Float(description=_("quantity"))
feedbacks_count = Int(description=_("number of feedbacks"))
personal_orders_only = Boolean(description=_("only available for personal orders"))
seo_meta = Field(SEOMetaType, description=_("SEO meta snapshot"))
class Meta:
model = Product
@ -425,6 +546,52 @@ class ProductType(DjangoObjectType):
def resolve_personal_orders_only(self: Product, _info) -> bool:
return False or self.personal_orders_only
def resolve_seo_meta(self: Product, info):
lang = graphene_current_lang()
base = f"https://{config.BASE_DOMAIN}"
canonical = f"{base}/{lang}/product/{self.slug}"
title = f"{self.name} | {config.PROJECT_NAME}"
description = (self.description or "")[:180]
first_img = self.images.order_by("priority").first()
og_image = _abs(info.context, first_img.image.url) if first_img else ""
og = {
"title": title,
"description": description,
"type": "product",
"url": canonical,
"image": og_image,
}
tw = {"card": "summary_large_image", "title": title, "description": description, "image": og_image}
crumbs = [("Home", f"{base}/")]
if self.category:
for c in self.category.get_ancestors(include_self=True):
crumbs.append((c.name, f"{base}/{lang}/catalog/{c.slug}"))
crumbs.append((self.name, canonical))
images = list(self.images.all()[:6])
rating = {"value": self.rating, "count": self.feedbacks_count}
json_ld = [
org_schema(),
website_schema(),
breadcrumb_schema(crumbs),
product_schema(self, images, rating=rating),
]
return {
"title": title,
"description": description,
"canonical": canonical,
"robots": "index,follow",
"open_graph": og,
"twitter": tw,
"json_ld": json_ld,
"hreflang": [],
}
class AttributeValueType(DjangoObjectType):
value = String(description=_("attribute value"))

View file

@ -4,68 +4,116 @@ import secrets
from contextlib import contextmanager
from constance import config
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.translation import get_language
from evibes.settings import DEBUG, EXPOSABLE_KEYS, LANGUAGE_CODE
logger = logging.getLogger("django")
def graphene_current_lang():
"""
Determines the currently active language code.
This function retrieves the current language from the available language
settings. If no language is set, it defaults to the application's default
language code. The language code is returned in lowercase.
Returns:
str: The currently active language code in lowercase.
"""
return (get_language() or settings.LANGUAGE_CODE).lower()
def graphene_abs(request, path_or_url: str) -> str:
"""
Builds and returns an absolute URI for a given path or URL.
Summary:
This function takes a relative path or URL and constructs a fully
qualified absolute URI using the request object.
Args:
request: The request object used to build the absolute URI.
path_or_url: str
The relative path or URL to be converted to an absolute URI.
Returns:
str: The absolute URI corresponding to the provided path or URL.
"""
return request.build_absolute_uri(path_or_url)
def get_random_code() -> str:
"""
Generates a random string of a specified length. This method calls the
get_random_string function to create a random alphanumeric string of
20 characters in length.
Generates a random alphanumeric string of a fixed length.
Returns
-------
str
A 20-character-long alphanumeric string.
This function uses a utility function to generate a random string
consisting of letters and digits. The length of the string is fixed
to 20 characters. The generated string can be used for purposes
such as unique identifiers or tokens.
Returns:
str: Randomly generated alphanumeric string of length 20.
"""
return get_random_string(20)
def get_product_uuid_as_path(instance, filename: str = "") -> str:
"""
Generates a unique file path for a product using its UUID.
Generates a file path for a product using its UUID.
This function constructs a file path that includes the product UUID
in its directory structure. The path format is "products/{product_uuid}/{filename}",
where `product_uuid` is derived from the instance's product attribute, and
`filename` corresponds to the original name of the file being processed.
This function constructs a standardized file path where an uploaded file
is saved for a product. The path includes a `products` directory, followed
by the product's UUID, and concludes with the original filename. It can be
utilized in file storage applications to ensure unique and organized file
storage based on the product's identity.
Parameters:
instance: Object
The model instance containing the product attribute with the desired UUID.
filename: str
The original name of the file for which the path is being generated.
Args:
instance: The object instance that contains a reference to the product.
filename: str, optional. The name of the file being uploaded. Default is an
empty string.
Returns:
str
A string representing the generated unique file path that adheres to the
format "products/{product_uuid}/{filename}".
str: A string that represents the constructed file path.
"""
return "products" + "/" + str(instance.product.uuid) + "/" + filename
def get_brand_name_as_path(instance, filename: str = "") -> str:
"""
Generates a file path for a brand based on its name and the provided filename.
This function constructs a unique file path within the 'brands/' directory using
the name of the given instance and appends the supplied filename.
Parameters:
instance: An object containing a 'name' attribute.
filename: str, optional. The name of the file to be appended to the path.
Returns:
str: A string representing the constructed file path.
"""
return "brands/" + str(instance.name) + "/" + filename
@contextmanager
def atomic_if_not_debug():
"""
A context manager to execute a database operation within an atomic transaction
when the `DEBUG` setting is disabled. If `DEBUG` is enabled, it bypasses
transactional behavior. This allows safe rollback in production and easier
debugging in development.
Context manager to wrap a block of code in an atomic transaction if the DEBUG
setting is not enabled.
Yields
------
None
Yields control to the enclosed block of code.
This context manager ensures that the code block executes within an atomic
database transaction, preventing partial updates to the database in case of
an exception. If the DEBUG setting is enabled, no transaction is enforced,
allowing for easier debugging.
Yields:
None: This context manager does not return any values.
"""
if not DEBUG:
with transaction.atomic():
@ -76,21 +124,37 @@ def atomic_if_not_debug():
def is_url_safe(url: str) -> bool:
"""
Determine if a given URL is safe. This function evaluates whether
the provided URL starts with "https://", making it a potentially
secure resource by evaluating its prefix using a regular expression.
Determines if a given URL starts with "https://" indicating it is a secure URL.
Arguments:
url (str): The URL to evaluate.
This function checks if the provided URL adheres to secure HTTPS protocol.
It uses a regular expression to validate the URL prefix.
Parameters:
url (str): The URL string to validate.
Returns:
bool: True if the URL starts with "https://", indicating it may
be considered safe. False otherwise.
bool: True if the URL starts with "https://", False otherwise.
"""
return bool(re.match(r"^https://", url, re.IGNORECASE))
def format_attributes(attributes: str | None = None) -> dict:
"""
Parses a string of attributes into a dictionary.
This function takes a string input representing attributes and their values,
formatted as `key=value` pairs separated by commas, and converts it into a
dictionary. It returns an empty dictionary if the input is `None` or invalid.
Invalid key-value pairs within the input string are skipped.
Parameters:
attributes (str | None): A comma-separated string of key-value pairs in the
format `key=value`, or None.
Returns:
dict: A dictionary where keys are the attribute names and values are their
corresponding values.
"""
if not attributes:
return {}
@ -111,6 +175,17 @@ def format_attributes(attributes: str | None = None) -> dict:
def get_project_parameters() -> dict:
"""
Fetches project parameters from cache or configuration.
This function retrieves project parameters from a cache if available.
If they are not cached, it collects the parameters from a designated
configuration source, formats their keys to lowercase, and then stores
them in the cache for a limited period.
Returns:
dict: A dictionary containing the project parameters with lowercase keys.
"""
parameters = cache.get("parameters", {})
if not parameters:
@ -123,6 +198,19 @@ def get_project_parameters() -> dict:
def resolve_translations_for_elasticsearch(instance, field_name) -> None:
"""
Resolves translations for a given field in an Elasticsearch-compatible
format. It checks if the localized version of the field contains data,
and if not, sets it to the value of the default field.
Parameters:
instance: The object instance containing the field to resolve.
field_name (str): The base name of the field for which translations
are being resolved.
Returns:
None
"""
field = getattr(instance, f"{field_name}_{LANGUAGE_CODE}", "")
filled_field = getattr(instance, field_name, "")
if not field:
@ -134,12 +222,18 @@ CROCKFORD = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def generate_human_readable_id(length: int = 6) -> str:
"""
Generate a human-readable ID of `length` characters (from the Crockford set),
with a single hyphen inserted:
- 50% chance at the exact middle
- 50% chance at a random position between characters (1 to length-1)
Generates a human-readable identifier using Crockford's Base32 characters.
The final string length will be `length + 1` (including the hyphen).
This function creates a string of a specified length composed of randomly
selected characters from the Crockford Base32 alphabet. A dash is inserted
at a random or mid-point position in the identifier for better readability.
Parameters:
length (int): The length of the identifier excluding the dash. Must be
greater than 0. Default is 6.
Returns:
str: A human-readable identifier with the specified length plus a dash.
"""
chars = [secrets.choice(CROCKFORD) for _ in range(length)]
@ -151,6 +245,13 @@ def generate_human_readable_id(length: int = 6) -> str:
def generate_human_readable_token() -> str:
"""
Generate a human-readable token of 20 characters (from the Crockford set),
Generates a human-readable token.
This function creates a random token using characters from
the CROCKFORD base32 set. The generated token is 20 characters
long and is designed to be human-readable.
Returns:
str: A 20-character random token.
"""
return "".join([secrets.choice(CROCKFORD) for _ in range(20)])