From dc94841f40fcb1d6299636a8e41ba1e5f9a18d88 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Mon, 2 Mar 2026 01:57:57 +0300 Subject: [PATCH] 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. --- engine/blog/admin.py | 15 ++ engine/blog/graphene/object_types.py | 6 +- engine/blog/migrations/0010_post_blogpic.py | 17 ++ engine/blog/models.py | 2 + engine/core/admin.py | 17 +- engine/core/graphene/object_types.py | 5 + ...deo_alter_address_api_response_and_more.py | 45 +++++ engine/core/models.py | 13 +- engine/core/serializers/detail.py | 1 + engine/core/serializers/simple.py | 1 + engine/core/templates/json_table_widget.html | 171 +++++++++++------- engine/core/validators.py | 22 +++ engine/core/widgets.py | 7 +- ...user_attributes_alter_user_phone_number.py | 35 ++++ 14 files changed, 286 insertions(+), 71 deletions(-) create mode 100644 engine/blog/migrations/0010_post_blogpic.py create mode 100644 engine/core/migrations/0058_product_video_alter_address_api_response_and_more.py create mode 100644 engine/vibes_auth/migrations/0011_alter_user_attributes_alter_user_phone_number.py diff --git a/engine/blog/admin.py b/engine/blog/admin.py index 07b745d6..0c9d9c42 100644 --- a/engine/blog/admin.py +++ b/engine/blog/admin.py @@ -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( + '', + obj.blogpic.url, + ) + return "—" + + blogpic_preview.short_description = _("picture preview") # ty:ignore[unresolved-attribute] + @register(PostTag) class PostTagAdmin(ModelAdmin): diff --git a/engine/blog/graphene/object_types.py b/engine/blog/graphene/object_types.py index cc2fd94f..19090a6c 100644 --- a/engine/blog/graphene/object_types.py +++ b/engine/blog/graphene/object_types.py @@ -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: diff --git a/engine/blog/migrations/0010_post_blogpic.py b/engine/blog/migrations/0010_post_blogpic.py new file mode 100644 index 00000000..fa2b8681 --- /dev/null +++ b/engine/blog/migrations/0010_post_blogpic.py @@ -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/"), + ), + ] diff --git a/engine/blog/models.py b/engine/blog/models.py index d9cdf42f..582e12d5 100644 --- a/engine/blog/models.py +++ b/engine/blog/models.py @@ -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, diff --git a/engine/core/admin.py b/engine/core/admin.py index b6b0ee2c..93be8b04 100644 --- a/engine/core/admin.py +++ b/engine/core/admin.py @@ -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( + '", + 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 = [ diff --git a/engine/core/graphene/object_types.py b/engine/core/graphene/object_types.py index 238ce081..dd4a0b1d 100644 --- a/engine/core/graphene/object_types.py +++ b/engine/core/graphene/object_types.py @@ -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 diff --git a/engine/core/migrations/0058_product_video_alter_address_api_response_and_more.py b/engine/core/migrations/0058_product_video_alter_address_api_response_and_more.py new file mode 100644 index 00000000..7c8ac263 --- /dev/null +++ b/engine/core/migrations/0058_product_video_alter_address_api_response_and_more.py @@ -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, + ), + ), + ] diff --git a/engine/core/models.py b/engine/core/models.py index efc0b830..ff62c4d4 100644 --- a/engine/core/models.py +++ b/engine/core/models.py @@ -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, diff --git a/engine/core/serializers/detail.py b/engine/core/serializers/detail.py index e8d978f3..cf698755 100644 --- a/engine/core/serializers/detail.py +++ b/engine/core/serializers/detail.py @@ -294,6 +294,7 @@ class ProductDetailSerializer(ModelSerializer): "sku", "name", "description", + "video", "partnumber", "is_digital", "brand", diff --git a/engine/core/serializers/simple.py b/engine/core/serializers/simple.py index 3a9ba1e8..6be4064b 100644 --- a/engine/core/serializers/simple.py +++ b/engine/core/serializers/simple.py @@ -155,6 +155,7 @@ class ProductSimpleSerializer(ModelSerializer): "is_digital", "slug", "description", + "video", "partnumber", "brand", "feedbacks_count", diff --git a/engine/core/templates/json_table_widget.html b/engine/core/templates/json_table_widget.html index 0c72cdc9..509eee45 100644 --- a/engine/core/templates/json_table_widget.html +++ b/engine/core/templates/json_table_widget.html @@ -1,71 +1,112 @@ -{% load static i18n %} - - - - - - - - - {% for key, value in widget.value.items %} - - - - - {% endfor %} - - - - - -
{% blocktrans %}key{% endblocktrans %}{% blocktrans %}value{% endblocktrans %}
- {% if value is list %} - - {% else %} - - {% endif %} -
- - +{% 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" %} +
+ + + + + + + + + + {% for key, val in widget.value.items %} + + + + + + {% endfor %} + +
{% trans "Key" %}{% trans "Value" %}
+ + + + + +
+
+ +
+
+{% endwith %} diff --git a/engine/core/validators.py b/engine/core/validators.py index fa76ea5c..d21c0a74 100644 --- a/engine/core/validators.py +++ b/engine/core/validators.py @@ -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 diff --git a/engine/core/widgets.py b/engine/core/widgets.py index ffeb61a5..d5b83631 100644 --- a/engine/core/widgets.py +++ b/engine/core/widgets.py @@ -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) diff --git a/engine/vibes_auth/migrations/0011_alter_user_attributes_alter_user_phone_number.py b/engine/vibes_auth/migrations/0011_alter_user_attributes_alter_user_phone_number.py new file mode 100644 index 00000000..c5da03f3 --- /dev/null +++ b/engine/vibes_auth/migrations/0011_alter_user_attributes_alter_user_phone_number.py @@ -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", + ), + ), + ]