Merge branch 'master' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-09 19:24:19 +03:00
commit cb92b7ddaa
7 changed files with 133 additions and 29 deletions

View file

@ -69,6 +69,24 @@ from engine.core.models import (
)
class StorefrontLinkMixin:
"""Adds a 'See on site' link button to the change form submit row."""
change_form_template = "admin/core/change_form_with_storefront_link.html"
storefront_path_prefix: str = ""
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
extra_context = extra_context or {}
if object_id:
obj = self.get_object(request, object_id)
if obj and hasattr(obj, "slug"):
extra_context["storefront_url"] = (
f"https://{settings.STOREFRONT_DOMAIN}"
f"/{self.storefront_path_prefix}/{obj.slug}"
)
return super().changeform_view(request, object_id, form_url, extra_context)
class FieldsetsMixin:
general_fields: list[str] | None = []
relation_fields: list[str] | None = []
@ -361,12 +379,14 @@ class AttributeValueAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
@register(Category)
class CategoryAdmin(
StorefrontLinkMixin,
DjangoQLSearchMixin,
FieldsetsMixin,
ActivationActionsMixin,
DraggableMPTTAdmin,
ModelAdmin,
):
storefront_path_prefix = "catalog"
# noinspection PyClassVar
model = Category
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
@ -417,8 +437,13 @@ class CategoryAdmin(
@register(Brand)
class BrandAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin
StorefrontLinkMixin,
DjangoQLSearchMixin,
FieldsetsMixin,
ActivationActionsMixin,
ModelAdmin,
):
storefront_path_prefix = "brand"
# noinspection PyClassVar
model = Brand
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
@ -449,12 +474,14 @@ class BrandAdmin(
@register(Product)
class ProductAdmin(
StorefrontLinkMixin,
DjangoQLSearchMixin,
FieldsetsMixin,
ActivationActionsMixin,
ModelAdmin,
ImportExportModelAdmin,
):
storefront_path_prefix = "product"
# noinspection PyClassVar
model = Product
formfield_overrides = {TextField: {"widget": MarkdownWidget}}

View file

@ -70,15 +70,14 @@ def create_wishlist_on_user_creation_signal(
def create_promocode_on_user_referring(
instance: User, created: bool, **kwargs: dict[Any, Any]
) -> None:
try:
if type(instance.attributes) is not dict:
instance.attributes = {}
instance.save()
if not created:
return
if created and instance.attributes.get("referrer", ""):
referrer_uuid = urlsafe_base64_decode(
instance.attributes.get("referrer", "")
).decode()
try:
attrs = instance.attributes if isinstance(instance.attributes, dict) else {}
if attrs.get("referrer", ""):
referrer_uuid = urlsafe_base64_decode(attrs.get("referrer", "")).decode()
referrer = User.objects.get(uuid=referrer_uuid)
code = f"WELCOME-{get_random_string(6)}"
PromoCode.objects.create(

View file

@ -0,0 +1,31 @@
{% extends "admin/change_form.html" %}
{% load i18n %}
{% block submit_buttons_bottom %}
{{ block.super }}
{% if storefront_url %}
<template id="storefront-link-tpl">
<a href="{{ storefront_url }}"
target="_blank"
rel="noopener"
class="storefront-link font-medium inline-flex items-center gap-2 px-3 py-2 rounded-default justify-center whitespace-nowrap cursor-pointer text-white text-sm w-full lg:w-auto"
style="background:linear-gradient(135deg,#6366f1,#8b5cf6);box-shadow:0 0 14px rgba(99,102,241,.35);">
<span class="material-symbols-outlined text-base">open_in_new</span>
{% trans "See on site" %}
</a>
</template>
<script>
(function() {
var tpl = document.getElementById("storefront-link-tpl");
if (!tpl) return;
var link = tpl.content.firstElementChild.cloneNode(true);
var save = document.querySelector('#submit-row button[name="_save"]');
if (save) {
save.insertAdjacentElement("afterend", link);
}
tpl.remove();
})();
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,21 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% if show_languages %}
<div class="max-h-[280px] overflow-y-auto">
{% if languages_list %}
{% for language in languages_list %}
{% include "unfold/helpers/language_form.html" with language=language %}
{% endfor %}
{% else %}
{% for language in languages %}
{% include "unfold/helpers/language_form.html" with language=language %}
{% endfor %}
{% endif %}
</div>
<div class="border-t border-base-200 mt-1 pt-1 dark:border-base-700"></div>
{% endif %}

View file

@ -153,19 +153,25 @@ class UpdateUser(Mutation):
user.set_password(password)
user.save()
attribute_pairs = kwargs.pop("attributes", "")
new_attributes = kwargs.pop("attributes", None)
if attribute_pairs:
for attribute_pair in attribute_pairs.split(";"):
if "-" in attribute_pair:
attr, value = attribute_pair.split("-", 1)
if not user.attributes:
user.attributes = {}
user.attributes.update({attr: value})
else:
raise BadRequest(
_(f"Invalid attribute format: {attribute_pair}")
)
if new_attributes is not None:
if not isinstance(user.attributes, dict):
user.attributes = {}
if isinstance(new_attributes, dict):
user.attributes.update(new_attributes)
elif isinstance(new_attributes, str) and new_attributes:
for attribute_pair in new_attributes.split(";"):
if "-" in attribute_pair:
attr, value = attribute_pair.split("-", 1)
user.attributes[attr] = value
else:
raise BadRequest(
_(f"Invalid attribute format: {attribute_pair}")
)
else:
raise BadRequest(_("attributes must be a dict or a string"))
for attr, value in kwargs.items():
if attr == "password" or attr == "confirm_password":

View file

@ -1,5 +1,6 @@
import orjson
from cryptography.fernet import InvalidToken
from django import forms
from encrypted_fields.fields import EncryptedTextField
@ -16,10 +17,21 @@ class EncryptedJSONTextField(EncryptedTextField):
def get_internal_type(self) -> str:
return "TextField"
def formfield(self, **kwargs):
return super().formfield(**{"form_class": forms.JSONField, **kwargs})
def get_prep_value(self, value):
if value is not None and not isinstance(value, str):
# Encrypt directly instead of delegating to super().get_prep_value().
# The parent chain (EncryptedFieldMixin → CharField) calls
# self.to_python() which parses the JSON string back to a dict,
# then str(dict) produces Python repr with single quotes — not JSON.
if value is None:
return None
if not isinstance(value, str):
value = orjson.dumps(value, default=str).decode("utf-8")
return super().get_prep_value(value)
if not value:
return None
return self.f.encrypt(value.encode("utf-8")).decode("utf-8")
def from_db_value(self, value, expression, connection):
if value is None:

View file

@ -1,3 +1,4 @@
from os import getenv
from typing import Any
from django.templatetags.static import static
@ -31,7 +32,7 @@ UNFOLD: dict[str, Any] = {
"950": "#22282d",
},
},
"SITE_URL": STOREFRONT_DOMAIN,
"SITE_URL": f"https://{STOREFRONT_DOMAIN}",
"SITE_TITLE": f"{PROJECT_NAME} Dashboard",
"SITE_HEADER": PROJECT_NAME,
"SITE_LOGO": lambda request: static("favicon.png"),
@ -120,11 +121,18 @@ UNFOLD: dict[str, Any] = {
"icon": "api",
"link": reverse_lazy("rapidoc-platform"),
},
{
"title": "GraphQL",
"icon": "graph_5",
"link": reverse_lazy("graphql-platform"),
},
*(
[
{
"title": "GraphQL",
"icon": "graph_5",
"link": reverse_lazy("graphql-platform"),
},
]
if getenv("GRAPHQL_INTROSPECTION", "").lower()
in ("1", "true", "yes")
else []
),
{
"title": _("Taskboard"),
"icon": "view_kanban",