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 %}
-
-
-
+{% 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" %}
+
+
+
+
+
+
+{% 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",
+ ),
+ ),
+ ]