Merge branch 'master' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-02 01:58:36 +03:00
commit 7e31a80290
14 changed files with 286 additions and 71 deletions

View file

@ -1,5 +1,7 @@
from django.contrib.admin import register
from django.db.models import TextField
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold_markdown import MarkdownWidget
@ -21,18 +23,31 @@ class PostAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
"slug",
"modified",
"created",
"blogpic_preview",
)
general_fields = [
"title",
"content",
"file",
"blogpic",
"blogpic_preview",
]
relation_fields = [
"author",
"tags",
]
def blogpic_preview(self, obj: Post):
if obj.blogpic:
return format_html(
'<img src="{}" style="max-width:400px;max-height:300px;object-fit:contain">',
obj.blogpic.url,
)
return ""
blogpic_preview.short_description = _("picture preview") # ty:ignore[unresolved-attribute]
@register(PostTag)
class PostTagAdmin(ModelAdmin):

View file

@ -9,15 +9,19 @@ from engine.core.utils.markdown import render_markdown
class PostType(DjangoObjectType):
tags = List(lambda: PostTagType)
content = String()
blogpic = String()
class Meta:
model = Post
fields = ["tags", "content", "title", "slug"]
fields = ["tags", "content", "title", "slug", "blogpic"]
interfaces = (relay.Node,)
def resolve_content(self: Post, _info: HttpRequest) -> str:
return render_markdown(self.content or "")
def resolve_blogpic(self: Post, info: HttpRequest) -> str:
return info.build_absolute_uri(self.blogpic.url) if self.blogpic else ""
class PostTagType(DjangoObjectType):
class Meta:

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.11 on 2026-03-01 22:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("blog", "0009_alter_post_slug"),
]
operations = [
migrations.AddField(
model_name="post",
name="blogpic",
field=models.ImageField(blank=True, null=True, upload_to="posts/images/"),
),
]

View file

@ -5,6 +5,7 @@ from django.db.models import (
CharField,
FileField,
ForeignKey,
ImageField,
ManyToManyField,
TextField,
)
@ -49,6 +50,7 @@ class Post(NiceModel):
null=True,
)
file = FileField(upload_to="posts/", blank=True, null=True)
blogpic = ImageField(upload_to="posts/images/", blank=True, null=True)
slug = TweakedAutoSlugField(
populate_from="title",
slugify_function=unicode_slugify_function,

View file

@ -499,6 +499,7 @@ class ProductAdmin(
"uuid",
"modified",
"created",
"video_preview",
)
autocomplete_fields = (
"category",
@ -527,8 +528,22 @@ class ProductAdmin(
]
additional_fields = [
"is_updatable",
"video",
"video_preview",
]
def video_preview(self, obj: Product):
if obj.video:
return format_html(
'<video controls style="max-width:100%;max-height:360px">'
'<source src="{}">'
"</video>",
obj.video.url,
)
return ""
video_preview.short_description = _("video preview") # ty:ignore[unresolved-attribute]
def has_images(self, obj: Product) -> bool:
return obj.has_images
@ -924,8 +939,8 @@ class StockAdmin(
"is_active",
"sku",
"quantity",
"price",
"purchase_price",
"price",
"digital_asset",
]
additional_fields = [

View file

@ -530,6 +530,7 @@ class ProductType(DjangoObjectType):
images = DjangoFilterConnectionField(ProductImageType, description=_("images"))
feedbacks = DjangoFilterConnectionField(FeedbackType, description=_("feedbacks"))
brand = Field(BrandType, description=_("brand"))
video = String(description=_("video url"))
attribute_groups = DjangoFilterConnectionField(
AttributeGroupType, description=_("attribute groups")
)
@ -555,6 +556,7 @@ class ProductType(DjangoObjectType):
"name",
"slug",
"description",
"video",
"feedbacks",
"feedbacks_count",
"personal_orders_only",
@ -648,6 +650,9 @@ class ProductType(DjangoObjectType):
"hreflang": info.context.LANGUAGE_CODE,
}
def resolve_video(self: Product, info) -> str:
return info.context.build_absolute_uri(self.video.url) if self.video else ""
def resolve_discount_price(self: Product, _info) -> float | None:
return self.discount_price

View file

@ -0,0 +1,45 @@
# Generated by Django 5.2.11 on 2026-03-01 22:48
from django.db import migrations, models
import engine.core.validators
import schon.fields
class Migration(migrations.Migration):
dependencies = [
("core", "0057_encrypt_address_fields"),
]
operations = [
migrations.AddField(
model_name="product",
name="video",
field=models.FileField(
blank=True,
help_text="optional video file for this product",
null=True,
upload_to="products/videos/",
validators=[engine.core.validators.validate_browser_video],
verbose_name="product video",
),
),
migrations.AlterField(
model_name="address",
name="api_response",
field=schon.fields.EncryptedJSONTextField(
blank=True,
help_text="stored JSON response from the geocoding service",
null=True,
),
),
migrations.AlterField(
model_name="address",
name="raw_data",
field=schon.fields.EncryptedJSONTextField(
blank=True,
help_text="full JSON response from geocoder for this address",
null=True,
),
),
]

View file

@ -71,7 +71,10 @@ from engine.core.utils import (
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
from engine.core.utils.lists import FAILED_STATUSES
from engine.core.utils.markdown import strip_markdown
from engine.core.validators import validate_category_image_dimensions
from engine.core.validators import (
validate_browser_video,
validate_category_image_dimensions,
)
from engine.payments.models import Transaction
from schon.fields import EncryptedJSONTextField
from schon.utils.misc import create_object
@ -701,6 +704,14 @@ class Product(NiceModel):
help_text=_("add a detailed description of the product"),
verbose_name=_("product description"),
)
video = FileField(
upload_to="products/videos/",
blank=True,
null=True,
help_text=_("optional video file for this product"),
verbose_name=_("product video"),
validators=[validate_browser_video],
)
partnumber = CharField(
unique=True,
default=None,

View file

@ -294,6 +294,7 @@ class ProductDetailSerializer(ModelSerializer):
"sku",
"name",
"description",
"video",
"partnumber",
"is_digital",
"brand",

View file

@ -155,6 +155,7 @@ class ProductSimpleSerializer(ModelSerializer):
"is_digital",
"slug",
"description",
"video",
"partnumber",
"brand",
"feedbacks_count",

View file

@ -1,71 +1,112 @@
{% load static i18n %}
<table id="table-{{ widget.attrs.id }}">
{% load i18n %}
{% with input_cls="border border-base-200 bg-white font-medium placeholder-base-400 rounded-default shadow-xs text-font-default-light text-sm focus:outline-2 focus:-outline-offset-2 focus:outline-primary-600 dark:bg-base-900 dark:border-base-700 dark:text-font-default-dark dark:scheme-dark px-3 py-2 w-full" %}
<div class="max-w-4xl">
<table class="w-full" id="table-{{ widget.attrs.id }}">
<thead>
<tr>
<th>{% blocktrans %}key{% endblocktrans %}</th>
<th>{% blocktrans %}value{% endblocktrans %}</th>
<tr class="border-b border-base-200 dark:border-base-700">
<th class="text-left text-xs font-semibold uppercase tracking-wide text-font-important-light dark:text-font-important-dark pb-2 pr-4 w-5/12">{% trans "Key" %}</th>
<th class="text-left text-xs font-semibold uppercase tracking-wide text-font-important-light dark:text-font-important-dark pb-2 pr-4 w-5/12">{% trans "Value" %}</th>
<th class="pb-2 w-2/12"></th>
</tr>
</thead>
<tbody id="json-fields-{{ widget.attrs.id }}">
{% for key, value in widget.value.items %}
<tr data-row-index="{{ forloop.counter }}">
<td><label>
<input type="text" name="{{ widget.name }}_key" value="{{ key }}">
</label></td>
<td>
{% if value is list %}
<label>
<input type="text" name="{{ widget.name }}_value" value="{{ value|join:', ' }}">
</label>
{% else %}
<label>
<input type="text" name="{{ widget.name }}_value" value="{{ value }}">
</label>
{% endif %}
{% for key, val in widget.value.items %}
<tr class="border-b border-base-200 dark:border-base-700 group/row">
<td class="py-2 pr-3">
<input type="text"
name="{{ widget.name }}_key"
value="{{ key }}"
placeholder="{% trans "key" %}"
class="{{ input_cls }}">
</td>
<td class="py-2 pr-3">
<input type="text"
name="{{ widget.name }}_value"
value="{% if val is list %}{{ val|join:', ' }}{% else %}{{ val }}{% endif %}"
placeholder="{% trans "value" %}"
class="{{ input_cls }}">
</td>
<td class="py-2">
<button type="button"
class="delete-row-btn border border-transparent cursor-pointer font-medium px-2 py-2 rounded-default text-center bg-red-600 text-white text-sm leading-none opacity-60 group-hover/row:opacity-100 transition-opacity"
title="{% trans "Delete row" %}">&#x2715;</button>
</td>
</tr>
{% endfor %}
<tr data-row-index="{{ widget.value.items|length|default:0|add:1 }}">
<td><label>
<input type="text" name="{{ widget.name }}_key">
</label></td>
<td><label>
<input type="text" name="{{ widget.name }}_value">
</label></td>
</tr>
</tbody>
</table>
<button type="button" class="add-row-button" data-table-id="json-fields-{{ widget.attrs.id }}">
{% blocktrans %}Add Row{% endblocktrans %}
<div class="mt-3">
<button type="button"
class="add-row-btn border border-transparent cursor-pointer font-medium px-3 py-2 rounded-default text-center whitespace-nowrap bg-primary-600 text-white text-sm"
data-tbody="json-fields-{{ widget.attrs.id }}"
data-name="{{ widget.name }}"
data-input-cls="{{ input_cls }}">
+ {% trans "Add Row" %}
</button>
</div>
</div>
{% endwith %}
<script>
(function () {
function wireDeleteBtn(btn) {
btn.addEventListener("click", function () {
btn.closest("tr").remove();
});
}
function buildRow(name, inputCls) {
const tr = document.createElement("tr");
tr.className = "border-b border-base-200 dark:border-base-700 group/row";
const mkTd = (paddingCls) => {
const td = document.createElement("td");
td.className = paddingCls;
return td;
};
const mkInput = (fieldName, placeholder) => {
const inp = document.createElement("input");
inp.type = "text";
inp.name = fieldName;
inp.placeholder = placeholder;
inp.className = inputCls;
return inp;
};
const keyTd = mkTd("py-2 pr-3");
keyTd.appendChild(mkInput(name + "_key", "key"));
const valTd = mkTd("py-2 pr-3");
valTd.appendChild(mkInput(name + "_value", "value"));
const delTd = mkTd("py-2");
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "delete-row-btn border border-transparent cursor-pointer font-medium px-2 py-2 rounded-default text-center bg-red-600 text-white text-sm leading-none";
delBtn.title = "Delete row";
delBtn.textContent = "\u2715";
wireDeleteBtn(delBtn);
delTd.appendChild(delBtn);
tr.appendChild(keyTd);
tr.appendChild(valTd);
tr.appendChild(delTd);
return tr;
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".add-row-button").forEach(function (button) {
button.removeEventListener("click", addRow);
button.addEventListener("click", addRow);
// Wire existing delete buttons
document.querySelectorAll(".delete-row-btn").forEach(wireDeleteBtn);
// Wire add-row buttons
document.querySelectorAll(".add-row-btn").forEach(function (btn) {
const tbody = document.getElementById(btn.dataset.tbody);
if (!tbody) return;
btn.addEventListener("click", function () {
tbody.appendChild(buildRow(btn.dataset.name, btn.dataset.inputCls));
});
});
function addRow(event) {
let tableId = event.target.getAttribute("data-table-id");
let table = document.getElementById(tableId);
if (table) {
let lastRow = table.querySelector("tr:last-child");
let rowIndex = (parseInt(lastRow.getAttribute("data-row-index"), 10) + 1).toString();
let row = table.insertRow();
row.setAttribute("data-row-index", rowIndex);
let keyCell = row.insertCell(0);
let valueCell = row.insertCell(1);
let namePrefix = tableId.replace("json-fields-", "");
keyCell.innerHTML = `<input type="text" name="${namePrefix}_key_${rowIndex}">`;
valueCell.innerHTML = `<input type="text" name="${namePrefix}_value_${rowIndex}">`;
}
}
});
}());
</script>

View file

@ -1,7 +1,29 @@
from contextlib import suppress
import filetype
from django.core.exceptions import ValidationError
from django.core.files.images import ImageFile, get_image_dimensions
from django.utils.translation import gettext_lazy as _
_BROWSER_VIDEO_MIMES = {"video/mp4", "video/webm", "video/ogg"}
def validate_browser_video(file) -> None:
if not file:
return
kind = None
with suppress(Exception):
kind = filetype.guess(file)
if not kind:
raise ValidationError(_("could not determine file type"))
file.seek(0)
if kind is None or kind.mime not in _BROWSER_VIDEO_MIMES:
supported = ", ".join(sorted(_BROWSER_VIDEO_MIMES))
raise ValidationError(
_(f"unsupported video format. supported formats: {supported}")
)
def validate_category_image_dimensions(
image: ImageFile, max_width: int | None = None, max_height: int | None = None

View file

@ -50,13 +50,14 @@ class JSONTableWidget(forms.Widget):
try:
keys = data.getlist(f"{name}_key") # ty: ignore[unresolved-attribute]
values = data.getlist(f"{name}_value") # ty: ignore[unresolved-attribute]
for key, value in zip(keys, values, strict=True):
if key.strip():
for key, value in zip(keys, values, strict=False):
key = key.strip()
if key:
try:
json_data[key] = json.loads(value)
except (json.JSONDecodeError, ValueError):
json_data[key] = value
except TypeError:
except (TypeError, AttributeError):
pass
return None if not json_data else json.dumps(json_data)

View file

@ -0,0 +1,35 @@
# Generated by Django 5.2.11 on 2026-03-01 22:48
import encrypted_fields.fields
from django.db import migrations
import engine.vibes_auth.validators
import schon.fields
class Migration(migrations.Migration):
dependencies = [
("vibes_auth", "0010_encrypt_user_pii_and_token_expiry"),
]
operations = [
migrations.AlterField(
model_name="user",
name="attributes",
field=schon.fields.EncryptedJSONTextField(
blank=True, default=dict, null=True, verbose_name="attributes"
),
),
migrations.AlterField(
model_name="user",
name="phone_number",
field=encrypted_fields.fields.EncryptedCharField(
blank=True,
help_text="user phone number",
max_length=20,
null=True,
validators=[engine.vibes_auth.validators.validate_phone_number],
verbose_name="phone_number",
),
),
]