feat(core/blog): add support for product videos and blog post images

This commit introduces support for uploading optional video files to products and image files to blog posts. Enhanced admin interfaces were added to preview these files directly. Also includes adjustments to GraphQL types and serializers to expose the new fields.
This commit is contained in:
Egor Pavlovich Gorbunov 2026-03-02 01:57:57 +03:00
parent d97e9a973b
commit dc94841f40
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",
),
),
]