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.
This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-09 17:38:28 +03:00
parent f0b92bb475
commit af69abf8e3
5 changed files with 84 additions and 10 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: class FieldsetsMixin:
general_fields: list[str] | None = [] general_fields: list[str] | None = []
relation_fields: list[str] | None = [] relation_fields: list[str] | None = []
@ -361,12 +379,14 @@ class AttributeValueAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
@register(Category) @register(Category)
class CategoryAdmin( class CategoryAdmin(
StorefrontLinkMixin,
DjangoQLSearchMixin, DjangoQLSearchMixin,
FieldsetsMixin, FieldsetsMixin,
ActivationActionsMixin, ActivationActionsMixin,
DraggableMPTTAdmin, DraggableMPTTAdmin,
ModelAdmin, ModelAdmin,
): ):
storefront_path_prefix = "catalog"
# noinspection PyClassVar # noinspection PyClassVar
model = Category model = Category
formfield_overrides = {TextField: {"widget": MarkdownWidget}} formfield_overrides = {TextField: {"widget": MarkdownWidget}}
@ -417,8 +437,13 @@ class CategoryAdmin(
@register(Brand) @register(Brand)
class BrandAdmin( class BrandAdmin(
DjangoQLSearchMixin, FieldsetsMixin, ActivationActionsMixin, ModelAdmin StorefrontLinkMixin,
DjangoQLSearchMixin,
FieldsetsMixin,
ActivationActionsMixin,
ModelAdmin,
): ):
storefront_path_prefix = "brand"
# noinspection PyClassVar # noinspection PyClassVar
model = Brand model = Brand
formfield_overrides = {TextField: {"widget": MarkdownWidget}} formfield_overrides = {TextField: {"widget": MarkdownWidget}}
@ -449,12 +474,14 @@ class BrandAdmin(
@register(Product) @register(Product)
class ProductAdmin( class ProductAdmin(
StorefrontLinkMixin,
DjangoQLSearchMixin, DjangoQLSearchMixin,
FieldsetsMixin, FieldsetsMixin,
ActivationActionsMixin, ActivationActionsMixin,
ModelAdmin, ModelAdmin,
ImportExportModelAdmin, ImportExportModelAdmin,
): ):
storefront_path_prefix = "product"
# noinspection PyClassVar # noinspection PyClassVar
model = Product model = Product
formfield_overrides = {TextField: {"widget": MarkdownWidget}} formfield_overrides = {TextField: {"widget": MarkdownWidget}}

View file

@ -0,0 +1,18 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_modify %}
{% block submit_buttons_bottom %}
{% submit_row %}
{% if original and storefront_url %}
<div class="flex justify-end px-4 -mt-2 pb-2">
<a href="{{ storefront_url }}"
target="_blank"
rel="noopener"
class="font-medium inline-flex group items-center gap-2 px-3 py-2 rounded-default justify-center whitespace-nowrap cursor-pointer border border-base-200 bg-white shadow-xs text-important dark:border-base-700 dark:bg-transparent hover:bg-base-100/80 dark:hover:bg-base-800/80 w-full lg:w-auto">
<span class="material-symbols-outlined text-base">open_in_new</span>
{% trans "See on site" %}
</a>
</div>
{% 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

@ -156,12 +156,12 @@ class UpdateUser(Mutation):
attribute_pairs = kwargs.pop("attributes", "") attribute_pairs = kwargs.pop("attributes", "")
if attribute_pairs: if attribute_pairs:
if not isinstance(user.attributes, dict):
user.attributes = {}
for attribute_pair in attribute_pairs.split(";"): for attribute_pair in attribute_pairs.split(";"):
if "-" in attribute_pair: if "-" in attribute_pair:
attr, value = attribute_pair.split("-", 1) attr, value = attribute_pair.split("-", 1)
if not user.attributes: user.attributes[attr] = value
user.attributes = {}
user.attributes.update({attr: value})
else: else:
raise BadRequest( raise BadRequest(
_(f"Invalid attribute format: {attribute_pair}") _(f"Invalid attribute format: {attribute_pair}")

View file

@ -1,3 +1,4 @@
from os import getenv
from typing import Any from typing import Any
from django.templatetags.static import static from django.templatetags.static import static
@ -31,7 +32,7 @@ UNFOLD: dict[str, Any] = {
"950": "#22282d", "950": "#22282d",
}, },
}, },
"SITE_URL": STOREFRONT_DOMAIN, "SITE_URL": f"https://{STOREFRONT_DOMAIN}",
"SITE_TITLE": f"{PROJECT_NAME} Dashboard", "SITE_TITLE": f"{PROJECT_NAME} Dashboard",
"SITE_HEADER": PROJECT_NAME, "SITE_HEADER": PROJECT_NAME,
"SITE_LOGO": lambda request: static("favicon.png"), "SITE_LOGO": lambda request: static("favicon.png"),
@ -120,11 +121,18 @@ UNFOLD: dict[str, Any] = {
"icon": "api", "icon": "api",
"link": reverse_lazy("rapidoc-platform"), "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"), "title": _("Taskboard"),
"icon": "view_kanban", "icon": "view_kanban",