Merge branch 'master' into storefront-nuxt
This commit is contained in:
commit
7e31a80290
14 changed files with 286 additions and 71 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
17
engine/blog/migrations/0010_post_blogpic.py
Normal file
17
engine/blog/migrations/0010_post_blogpic.py
Normal 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/"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ class ProductDetailSerializer(ModelSerializer):
|
|||
"sku",
|
||||
"name",
|
||||
"description",
|
||||
"video",
|
||||
"partnumber",
|
||||
"is_digital",
|
||||
"brand",
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ class ProductSimpleSerializer(ModelSerializer):
|
|||
"is_digital",
|
||||
"slug",
|
||||
"description",
|
||||
"video",
|
||||
"partnumber",
|
||||
"brand",
|
||||
"feedbacks_count",
|
||||
|
|
|
|||
|
|
@ -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" %}">✕</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 %}
|
||||
</button>
|
||||
</table>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
Loading…
Reference in a new issue