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:
parent
d97e9a973b
commit
dc94841f40
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 %}
|
||||
<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