From af69abf8e3ceed778f4110409212e9a700f5cb52 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 9 Mar 2026 17:38:28 +0300 Subject: [PATCH 1/5] feat(admin): add "See on site" link in change forms for better navigation Integrates a "See on site" button in the admin change forms for `Category`, `Brand`, and `Product` models. This allows users to navigate directly to the corresponding storefront page of the object being edited, improving efficiency and user experience. - Introduced `StorefrontLinkMixin` for reusable storefront URL logic. - Added custom template `change_form_with_storefront_link.html` for rendering the button. --- engine/core/admin.py | 29 ++++++++++++++++++- .../change_form_with_storefront_link.html | 18 ++++++++++++ .../unfold/helpers/language_switch.html | 21 ++++++++++++++ engine/vibes_auth/graphene/mutations.py | 6 ++-- schon/settings/unfold.py | 20 +++++++++---- 5 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 engine/core/templates/admin/core/change_form_with_storefront_link.html create mode 100644 engine/core/templates/unfold/helpers/language_switch.html diff --git a/engine/core/admin.py b/engine/core/admin.py index 93be8b04..9fbf6ebc 100644 --- a/engine/core/admin.py +++ b/engine/core/admin.py @@ -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}} diff --git a/engine/core/templates/admin/core/change_form_with_storefront_link.html b/engine/core/templates/admin/core/change_form_with_storefront_link.html new file mode 100644 index 00000000..c89434e6 --- /dev/null +++ b/engine/core/templates/admin/core/change_form_with_storefront_link.html @@ -0,0 +1,18 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_modify %} + +{% block submit_buttons_bottom %} + {% submit_row %} + + {% if original and storefront_url %} +
+ + open_in_new + {% trans "See on site" %} + +
+ {% endif %} +{% endblock %} diff --git a/engine/core/templates/unfold/helpers/language_switch.html b/engine/core/templates/unfold/helpers/language_switch.html new file mode 100644 index 00000000..05f12b43 --- /dev/null +++ b/engine/core/templates/unfold/helpers/language_switch.html @@ -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 %} +
+ {% 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 %} +
+ +
+{% endif %} diff --git a/engine/vibes_auth/graphene/mutations.py b/engine/vibes_auth/graphene/mutations.py index 4563b5c9..6ab3841a 100644 --- a/engine/vibes_auth/graphene/mutations.py +++ b/engine/vibes_auth/graphene/mutations.py @@ -156,12 +156,12 @@ class UpdateUser(Mutation): attribute_pairs = kwargs.pop("attributes", "") if attribute_pairs: + if not isinstance(user.attributes, dict): + user.attributes = {} 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}) + user.attributes[attr] = value else: raise BadRequest( _(f"Invalid attribute format: {attribute_pair}") diff --git a/schon/settings/unfold.py b/schon/settings/unfold.py index 69d4d1a0..02beb794 100644 --- a/schon/settings/unfold.py +++ b/schon/settings/unfold.py @@ -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", From 7e9c0f6dc230df74d2d5594bde28f1eb2edabd6c Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 9 Mar 2026 18:13:53 +0300 Subject: [PATCH 2/5] feat(admin): add reusable submit row with storefront link component refactor submit row logic into a dedicated reusable template (`submit_line_with_storefront_link.html`) to improve maintainability and reduce duplication across admin templates. - Updated `change_form_with_storefront_link.html` to use the new template. - Added missing `formfield` implementation for `EncryptedJSONField` to ensure proper form compatibility. - Enhanced attributes handling in user-related mutation and signal functions for better flexibility and error handling. --- engine/core/signals.py | 13 +-- .../change_form_with_storefront_link.html | 15 +--- .../submit_line_with_storefront_link.html | 79 +++++++++++++++++++ engine/vibes_auth/graphene/mutations.py | 26 +++--- schon/fields.py | 4 + 5 files changed, 107 insertions(+), 30 deletions(-) create mode 100644 engine/core/templates/admin/core/submit_line_with_storefront_link.html diff --git a/engine/core/signals.py b/engine/core/signals.py index 43fe5e65..6e71c485 100644 --- a/engine/core/signals.py +++ b/engine/core/signals.py @@ -70,14 +70,15 @@ 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", ""): + try: + attrs = instance.attributes if isinstance(instance.attributes, dict) else {} + + if attrs.get("referrer", ""): referrer_uuid = urlsafe_base64_decode( - instance.attributes.get("referrer", "") + attrs.get("referrer", "") ).decode() referrer = User.objects.get(uuid=referrer_uuid) code = f"WELCOME-{get_random_string(6)}" diff --git a/engine/core/templates/admin/core/change_form_with_storefront_link.html b/engine/core/templates/admin/core/change_form_with_storefront_link.html index c89434e6..71995314 100644 --- a/engine/core/templates/admin/core/change_form_with_storefront_link.html +++ b/engine/core/templates/admin/core/change_form_with_storefront_link.html @@ -1,18 +1,5 @@ {% extends "admin/change_form.html" %} -{% load i18n admin_modify %} {% block submit_buttons_bottom %} - {% submit_row %} - - {% if original and storefront_url %} - - {% endif %} + {% include "admin/core/submit_line_with_storefront_link.html" %} {% endblock %} diff --git a/engine/core/templates/admin/core/submit_line_with_storefront_link.html b/engine/core/templates/admin/core/submit_line_with_storefront_link.html new file mode 100644 index 00000000..b9918324 --- /dev/null +++ b/engine/core/templates/admin/core/submit_line_with_storefront_link.html @@ -0,0 +1,79 @@ +{% load i18n admin_urls unfold %} + +
+
+
+ {% block submit-row %} + {% if show_save %} + {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_save" class="w-full lg:w-auto" %} + {% trans "Save" %} + {% endcomponent %} + {% endif %} + + {% for action in actions_submit_line %} + {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name=action.attrs.name|default:action.action_name variant="default" class="w-full lg:w-auto" attrs=action.attrs %} + {% if action.icon %} + + {{ action.icon }} + + {% endif %} + + {{ action.description }} + {% endcomponent %} + {% endfor %} + + {% if show_save_and_continue %} + {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_continue" variant="default" class="w-full lg:w-auto" %} + {% if can_change %} + {% trans "Save and continue editing" %} + {% else %} + {% trans "Save and view" %} + {% endif %} + {% endcomponent %} + {% endif %} + + {% if show_save_and_add_another %} + {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_addanother" variant="default" class="w-full lg:w-auto" %} + {% trans "Save and add another" %} + {% endcomponent %} + {% endif %} + + {% if show_save_as_new %} + {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_saveasnew" variant="default" class="w-full lg:w-auto" %} + {% trans "Save as new" %} + {% endcomponent %} + {% endif %} + + {% if original and storefront_url %} + + open_in_new + {% trans "See on site" %} + + {% endif %} + +
+ {% if show_close or adminform.model_admin.change_form_show_cancel_button %} + {% url opts|admin_urlname:'changelist' as changelist_url %} + {% add_preserved_filters changelist_url as link %} + + {% component "unfold/components/button.html" with href=link variant="default" class="w-full lg:w-auto"%} + {% trans "Close" %} + {% endcomponent %} + {% endif %} + + {% if show_delete_link and original %} + {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %} + {% add_preserved_filters delete_url as link %} + + {% component "unfold/components/button.html" with href=delete_url variant="danger" class="mr-auto w-full lg:w-auto" %} + {% trans "Delete" %} {{ opts.verbose_name }} + {% endcomponent %} + {% endif %} +
+ {% endblock %} +
+
+
diff --git a/engine/vibes_auth/graphene/mutations.py b/engine/vibes_auth/graphene/mutations.py index 6ab3841a..b51e42ef 100644 --- a/engine/vibes_auth/graphene/mutations.py +++ b/engine/vibes_auth/graphene/mutations.py @@ -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: + if new_attributes is not None: if not isinstance(user.attributes, dict): user.attributes = {} - for attribute_pair in attribute_pairs.split(";"): - if "-" in attribute_pair: - attr, value = attribute_pair.split("-", 1) - user.attributes[attr] = value - else: - raise BadRequest( - _(f"Invalid attribute format: {attribute_pair}") - ) + + 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": diff --git a/schon/fields.py b/schon/fields.py index 33dfa3cd..c98e9d2d 100644 --- a/schon/fields.py +++ b/schon/fields.py @@ -1,5 +1,6 @@ import orjson from cryptography.fernet import InvalidToken +from django import forms from encrypted_fields.fields import EncryptedTextField @@ -16,6 +17,9 @@ 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): value = orjson.dumps(value, default=str).decode("utf-8") From f1d74cada0fbc08585c0af9fa3aa4c269f48bf67 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 9 Mar 2026 18:14:01 +0300 Subject: [PATCH 3/5] refactor(signals): simplify referrer_uuid decoding logic Simplifies the decoding logic for `referrer_uuid` to make it more concise and readable. No change in functionality was introduced. --- engine/core/signals.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/engine/core/signals.py b/engine/core/signals.py index 6e71c485..9fce91da 100644 --- a/engine/core/signals.py +++ b/engine/core/signals.py @@ -77,9 +77,7 @@ def create_promocode_on_user_referring( attrs = instance.attributes if isinstance(instance.attributes, dict) else {} if attrs.get("referrer", ""): - referrer_uuid = urlsafe_base64_decode( - attrs.get("referrer", "") - ).decode() + 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( From f9d479c37cb48334575d18e9e0039a677d63d542 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 9 Mar 2026 18:41:30 +0300 Subject: [PATCH 4/5] feat(fields): enhance encryption in JSONField with direct processing Replace parent method call in `get_prep_value` with direct encryption logic to prevent errors caused by incorrect JSON parsing. This ensures the proper handling of JSON strings and avoids reliance on parent class behavior. --- schon/fields.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/schon/fields.py b/schon/fields.py index c98e9d2d..2dd7a482 100644 --- a/schon/fields.py +++ b/schon/fields.py @@ -21,9 +21,17 @@ class EncryptedJSONTextField(EncryptedTextField): 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: From dc1d97478e000298bb1144a9c9e7a047e51429a8 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 9 Mar 2026 19:24:09 +0300 Subject: [PATCH 5/5] refactor(admin/templates): remove unused template and inline storefront link logic Removed `submit_line_with_storefront_link.html` as it is no longer needed. Moved storefront link rendering logic directly into `change_form_with_storefront_link.html` for better maintainability and reduced template complexity. --- .../change_form_with_storefront_link.html | 28 ++++++- .../submit_line_with_storefront_link.html | 79 ------------------- 2 files changed, 27 insertions(+), 80 deletions(-) delete mode 100644 engine/core/templates/admin/core/submit_line_with_storefront_link.html diff --git a/engine/core/templates/admin/core/change_form_with_storefront_link.html b/engine/core/templates/admin/core/change_form_with_storefront_link.html index 71995314..83cb0272 100644 --- a/engine/core/templates/admin/core/change_form_with_storefront_link.html +++ b/engine/core/templates/admin/core/change_form_with_storefront_link.html @@ -1,5 +1,31 @@ {% extends "admin/change_form.html" %} +{% load i18n %} {% block submit_buttons_bottom %} - {% include "admin/core/submit_line_with_storefront_link.html" %} + {{ block.super }} + + {% if storefront_url %} + + + {% endif %} {% endblock %} diff --git a/engine/core/templates/admin/core/submit_line_with_storefront_link.html b/engine/core/templates/admin/core/submit_line_with_storefront_link.html deleted file mode 100644 index b9918324..00000000 --- a/engine/core/templates/admin/core/submit_line_with_storefront_link.html +++ /dev/null @@ -1,79 +0,0 @@ -{% load i18n admin_urls unfold %} - -
-
-
- {% block submit-row %} - {% if show_save %} - {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_save" class="w-full lg:w-auto" %} - {% trans "Save" %} - {% endcomponent %} - {% endif %} - - {% for action in actions_submit_line %} - {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name=action.attrs.name|default:action.action_name variant="default" class="w-full lg:w-auto" attrs=action.attrs %} - {% if action.icon %} - - {{ action.icon }} - - {% endif %} - - {{ action.description }} - {% endcomponent %} - {% endfor %} - - {% if show_save_and_continue %} - {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_continue" variant="default" class="w-full lg:w-auto" %} - {% if can_change %} - {% trans "Save and continue editing" %} - {% else %} - {% trans "Save and view" %} - {% endif %} - {% endcomponent %} - {% endif %} - - {% if show_save_and_add_another %} - {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_addanother" variant="default" class="w-full lg:w-auto" %} - {% trans "Save and add another" %} - {% endcomponent %} - {% endif %} - - {% if show_save_as_new %} - {% component "unfold/components/button.html" with submit=1 form=opts.model_name|add:"_form" name="_saveasnew" variant="default" class="w-full lg:w-auto" %} - {% trans "Save as new" %} - {% endcomponent %} - {% endif %} - - {% if original and storefront_url %} - - open_in_new - {% trans "See on site" %} - - {% endif %} - -
- {% if show_close or adminform.model_admin.change_form_show_cancel_button %} - {% url opts|admin_urlname:'changelist' as changelist_url %} - {% add_preserved_filters changelist_url as link %} - - {% component "unfold/components/button.html" with href=link variant="default" class="w-full lg:w-auto"%} - {% trans "Close" %} - {% endcomponent %} - {% endif %} - - {% if show_delete_link and original %} - {% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %} - {% add_preserved_filters delete_url as link %} - - {% component "unfold/components/button.html" with href=delete_url variant="danger" class="mr-auto w-full lg:w-auto" %} - {% trans "Delete" %} {{ opts.verbose_name }} - {% endcomponent %} - {% endif %} -
- {% endblock %} -
-
-