diff --git a/core/graphene/object_types.py b/core/graphene/object_types.py index eb0e87c1..8cd928e6 100644 --- a/core/graphene/object_types.py +++ b/core/graphene/object_types.py @@ -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")) diff --git a/core/utils/__init__.py b/core/utils/__init__.py index 2e199dd6..53d1f447 100644 --- a/core/utils/__init__.py +++ b/core/utils/__init__.py @@ -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)])