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.
This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-09 18:13:53 +03:00
parent af69abf8e3
commit 7e9c0f6dc2
5 changed files with 107 additions and 30 deletions

View file

@ -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)}"

View file

@ -1,18 +1,5 @@
{% 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 %}
{% include "admin/core/submit_line_with_storefront_link.html" %}
{% endblock %}

View file

@ -0,0 +1,79 @@
{% load i18n admin_urls unfold %}
<div {% if not is_popup %}id="submit-row"{% endif %} class="relative lg:sticky lg:bottom-0 z-40">
<div class="backdrop-blur-xs bg-white/80 rounded-b-default pb-4 px-4 dark:bg-base-900/80 lg:border-t lg:border-base-200 relative lg:scrollable-top lg:py-0 dark:border-base-800">
<div class="flex flex-col-reverse gap-3 items-center mx-auto lg:flex-row-reverse container lg:h-[64px]">
{% 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 %}
<span class="material-symbols-outlined">
{{ action.icon }}
</span>
{% 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 %}
<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>
{% endif %}
<div class="flex flex-col gap-3 mr-auto w-full lg:flex-row lg:w-auto">
{% 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 %}
</div>
{% endblock %}
</div>
</div>
</div>

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:
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":

View file

@ -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")