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