diff --git a/engine/blog/admin.py b/engine/blog/admin.py
index c858a761..07b745d6 100644
--- a/engine/blog/admin.py
+++ b/engine/blog/admin.py
@@ -1,7 +1,7 @@
from django.contrib.admin import register
from django.db.models import TextField
from unfold.admin import ModelAdmin
-from unfold.contrib.forms.widgets import WysiwygWidget
+from unfold_markdown import MarkdownWidget
from engine.blog.models import Post, PostTag
from engine.core.admin import ActivationActionsMixin, FieldsetsMixin
@@ -15,7 +15,7 @@ class PostAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
filter_horizontal = ("tags",)
date_hierarchy = "created"
autocomplete_fields = ("tags",)
- formfield_overrides = {TextField: {"widget": WysiwygWidget}}
+ formfield_overrides = {TextField: {"widget": MarkdownWidget}}
readonly_fields = (
"uuid",
"slug",
diff --git a/engine/blog/graphene/object_types.py b/engine/blog/graphene/object_types.py
index 54d71afc..cc2fd94f 100644
--- a/engine/blog/graphene/object_types.py
+++ b/engine/blog/graphene/object_types.py
@@ -3,6 +3,7 @@ from graphene import List, String, relay
from graphene_django import DjangoObjectType
from engine.blog.models import Post, PostTag
+from engine.core.utils.markdown import render_markdown
class PostType(DjangoObjectType):
@@ -15,7 +16,7 @@ class PostType(DjangoObjectType):
interfaces = (relay.Node,)
def resolve_content(self: Post, _info: HttpRequest) -> str:
- return self.content or ""
+ return render_markdown(self.content or "")
class PostTagType(DjangoObjectType):
diff --git a/engine/blog/models.py b/engine/blog/models.py
index 5706d3a8..d9cdf42f 100644
--- a/engine/blog/models.py
+++ b/engine/blog/models.py
@@ -8,10 +8,12 @@ from django.db.models import (
ManyToManyField,
TextField,
)
+from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from engine.core.abstract import NiceModel
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
+from engine.core.utils.markdown import strip_markdown
class Post(NiceModel):
@@ -70,6 +72,10 @@ class Post(NiceModel):
def __str__(self):
return f"{self.title} | {self.author.first_name} {self.author.last_name}"
+ @cached_property
+ def seo_description(self) -> str:
+ return strip_markdown(self.content or "")[:180]
+
class Meta:
verbose_name = _("post")
verbose_name_plural = _("posts")
diff --git a/engine/blog/serializers.py b/engine/blog/serializers.py
index 9415d0ea..84635860 100644
--- a/engine/blog/serializers.py
+++ b/engine/blog/serializers.py
@@ -2,6 +2,7 @@ from rest_framework.fields import SerializerMethodField
from rest_framework.serializers import ModelSerializer
from engine.blog.models import Post, PostTag
+from engine.core.utils.markdown import render_markdown
class PostTagSerializer(ModelSerializer):
@@ -19,4 +20,4 @@ class PostSerializer(ModelSerializer):
fields = "__all__"
def get_content(self, obj: Post) -> str:
- return obj.content or ""
+ return render_markdown(obj.content or "")
diff --git a/engine/blog/widgets.py b/engine/blog/widgets.py
deleted file mode 100644
index 5c9e8604..00000000
--- a/engine/blog/widgets.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from typing import Any
-
-from django import forms
-from django.forms.renderers import BaseRenderer
-from django.utils.safestring import mark_safe
-
-
-class MarkdownEditorWidget(forms.Textarea):
- class Media:
- css = {
- "all": (
- "https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.css",
- )
- }
- js = ("https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.js",)
-
- def render(
- self,
- name: str,
- value: str,
- attrs: dict[Any, Any] | None = None,
- renderer: BaseRenderer | None = None,
- ):
- if not attrs:
- attrs = {}
- attrs["class"] = "markdown-editor"
- textarea_html = super().render(name, value, attrs, renderer)
- textarea_id = attrs.get("id", f"id_{name}")
- init_js = f"""
-
- """
- # noinspection DjangoSafeString
- return mark_safe(textarea_html + init_js)
diff --git a/engine/core/admin.py b/engine/core/admin.py
index d6f06de4..684fc11f 100644
--- a/engine/core/admin.py
+++ b/engine/core/admin.py
@@ -5,12 +5,14 @@ from constance.admin import Config
from constance.admin import ConstanceAdmin as BaseConstanceAdmin
from django.apps import AppConfig, apps
from django.conf import settings
+from django.contrib import admin
from django.contrib.admin import register, site
from django.contrib.gis.admin import GISModelAdmin
from django.contrib.messages import constants as messages
-from django.db.models import Model
+from django.db.models import Model, TextField
from django.db.models.query import QuerySet
from django.http import HttpRequest
+from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_celery_beat.admin import ClockedScheduleAdmin as BaseClockedScheduleAdmin
from django_celery_beat.admin import CrontabScheduleAdmin as BaseCrontabScheduleAdmin
@@ -33,6 +35,7 @@ from unfold.contrib.import_export.forms import ExportForm, ImportForm
from unfold.decorators import action
from unfold.typing import FieldsetsType
from unfold.widgets import UnfoldAdminSelectWidget, UnfoldAdminTextInputWidget
+from unfold_markdown import MarkdownWidget
from engine.core.forms import (
CRMForm,
@@ -54,6 +57,7 @@ from engine.core.models import (
Order,
OrderCrmLink,
OrderProduct,
+ PastedImage,
Product,
ProductImage,
ProductTag,
@@ -365,6 +369,7 @@ class CategoryAdmin(
):
# noinspection PyClassVar
model = Category
+ formfield_overrides = {TextField: {"widget": MarkdownWidget}}
list_display = (
"indented_title",
"parent",
@@ -416,6 +421,7 @@ class BrandAdmin(
):
# noinspection PyClassVar
model = Brand
+ formfield_overrides = {TextField: {"widget": MarkdownWidget}}
list_display = (
"name",
"priority",
@@ -451,6 +457,7 @@ class ProductAdmin(
):
# noinspection PyClassVar
model = Product
+ formfield_overrides = {TextField: {"widget": MarkdownWidget}}
actions = ActivationActionsMixin.actions + [
"export_to_marketplaces",
"ban_from_marketplaces",
@@ -856,6 +863,7 @@ class PromotionAdmin(
):
# noinspection PyClassVar
model = Promotion
+ formfield_overrides = {TextField: {"widget": MarkdownWidget}}
list_display = (
"name",
"discount_percent",
@@ -995,6 +1003,38 @@ class ProductImageAdmin(
]
+@register(PastedImage)
+class PastedImageAdmin(ActivationActionsMixin, ModelAdmin):
+ list_display = ("name", "image_preview", "alt_text", "is_active", "created")
+ list_filter = ("is_active",)
+ search_fields = ("name", "alt_text")
+ readonly_fields = ("uuid", "created", "modified", "image_preview_large")
+
+ fieldsets = (
+ (None, {"fields": ("name", "image", "alt_text")}),
+ (_("Preview"), {"fields": ("image_preview_large",)}),
+ (_("Metadata"), {"fields": ("uuid", "is_active", "created", "modified")}),
+ )
+
+ @admin.display(description=_("preview"))
+ def image_preview(self, obj: PastedImage) -> str:
+ if obj.image:
+ return format_html(
+ '',
+ obj.image.url,
+ )
+ return "-"
+
+ @admin.display(description=_("image preview"))
+ def image_preview_large(self, obj: PastedImage) -> str:
+ if obj.image:
+ return format_html(
+ '
',
+ obj.image.url,
+ )
+ return "-"
+
+
@register(Address)
class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
# noinspection PyClassVar
diff --git a/engine/core/graphene/object_types.py b/engine/core/graphene/object_types.py
index cd0c9e0a..238ce081 100644
--- a/engine/core/graphene/object_types.py
+++ b/engine/core/graphene/object_types.py
@@ -46,6 +46,7 @@ from engine.core.models import (
Wishlist,
)
from engine.core.utils import graphene_abs, graphene_current_lang
+from engine.core.utils.markdown import render_markdown
from engine.core.utils.seo_builders import (
brand_schema,
breadcrumb_schema,
@@ -132,6 +133,9 @@ class BrandType(DjangoObjectType):
filter_fields = ["uuid", "name"]
description = _("brands")
+ def resolve_description(self: Brand, _info) -> str:
+ return render_markdown(self.description or "")
+
def resolve_categories(self: Brand, info) -> QuerySet[Category]:
if info.context.user.has_perm("core.view_category"):
return self.categories.all()
@@ -156,7 +160,7 @@ class BrandType(DjangoObjectType):
base = f"https://{settings.BASE_DOMAIN}"
canonical = f"{base}/{lang}/brand/{self.slug}"
title = f"{self.name} | {settings.PROJECT_NAME}"
- description = (self.description or "")[:180]
+ description = self.seo_description
logo_url = None
if getattr(self, "big_logo", None):
@@ -255,6 +259,9 @@ class CategoryType(DjangoObjectType):
filter_fields = ["uuid"]
description = _("categories")
+ def resolve_description(self: Category, _info) -> str:
+ return render_markdown(self.description or "")
+
def resolve_children(self, info) -> TreeQuerySet | list[Category]:
categories = Category.objects.filter(parent=self)
if not info.context.user.has_perm("core.view_category"):
@@ -292,7 +299,7 @@ class CategoryType(DjangoObjectType):
base = f"https://{settings.BASE_DOMAIN}"
canonical = f"{base}/{lang}/catalog/{self.slug}"
title = f"{self.name} | {settings.PROJECT_NAME}"
- description = (self.description or "")[:180]
+ description = self.seo_description
og_image = (
graphene_abs(info.context, self.image.url)
@@ -560,6 +567,9 @@ class ProductType(DjangoObjectType):
filter_fields = ["uuid", "name"]
description = _("products")
+ def resolve_description(self: Product, _info) -> str:
+ return render_markdown(self.description or "")
+
def resolve_price(self: Product, _info) -> float:
return self.price or 0.0
@@ -592,7 +602,7 @@ class ProductType(DjangoObjectType):
base = f"https://{settings.BASE_DOMAIN}"
canonical = f"{base}/{lang}/product/{self.slug}"
title = f"{self.name} | {settings.PROJECT_NAME}"
- description = (self.description or "")[:180]
+ description = self.seo_description
first_img = self.images.order_by("priority").first()
og_image = graphene_abs(info.context, first_img.image.url) if first_img else ""
@@ -689,10 +699,19 @@ class PromotionType(DjangoObjectType):
class Meta:
model = Promotion
interfaces = (relay.Node,)
- fields = ("uuid", "name", "discount_percent", "products")
+ fields = (
+ "uuid",
+ "name",
+ "discount_percent",
+ "description",
+ "products",
+ )
filter_fields = ["uuid"]
description = _("promotions")
+ def resolve_description(self: Promotion, _info) -> str:
+ return render_markdown(self.description or "")
+
class StockType(DjangoObjectType):
vendor = Field(VendorType, description=_("vendor"))
diff --git a/engine/core/management/commands/demo_data.py b/engine/core/management/commands/demo_data.py
index 7ebffbe2..9ddccca2 100644
--- a/engine/core/management/commands/demo_data.py
+++ b/engine/core/management/commands/demo_data.py
@@ -715,11 +715,11 @@ class Command(BaseCommand):
)
if created:
if "title_ru" in post_data:
- post.title_ru_ru = post_data["title_ru"] # ty:ignore[unresolved-attribute]
+ post.title_ru_ru = post_data["title_ru"]
if content_ru:
- post.content_ru_ru = content_ru # ty:ignore[unresolved-attribute]
+ post.content_ru_ru = content_ru
if "meta_description_ru" in post_data:
- post.meta_description_ru_ru = post_data["meta_description_ru"] # ty:ignore[unresolved-attribute]
+ post.meta_description_ru_ru = post_data["meta_description_ru"]
post.save()
for tag_name in post_data.get("tags", []):
diff --git a/engine/core/migrations/0056_pastedimage.py b/engine/core/migrations/0056_pastedimage.py
new file mode 100644
index 00000000..2b4d58ac
--- /dev/null
+++ b/engine/core/migrations/0056_pastedimage.py
@@ -0,0 +1,88 @@
+# Generated by Django 5.2.11 on 2026-02-27 20:25
+
+import uuid
+
+import django_extensions.db.fields
+from django.db import migrations, models
+
+import engine.core.models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0055_alter_brand_categories_alter_product_slug"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PastedImage",
+ fields=[
+ (
+ "uuid",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ help_text="unique id is used to surely identify any database object",
+ primary_key=True,
+ serialize=False,
+ verbose_name="unique id",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ default=True,
+ help_text="if set to false, this object can't be seen by users without needed permission",
+ verbose_name="is active",
+ ),
+ ),
+ (
+ "created",
+ django_extensions.db.fields.CreationDateTimeField(
+ auto_now_add=True,
+ help_text="when the object first appeared on the database",
+ verbose_name="created",
+ ),
+ ),
+ (
+ "modified",
+ django_extensions.db.fields.ModificationDateTimeField(
+ auto_now=True,
+ help_text="when the object was last modified",
+ verbose_name="modified",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ help_text="descriptive name for the image",
+ max_length=100,
+ verbose_name="name",
+ ),
+ ),
+ (
+ "image",
+ models.ImageField(
+ help_text="image file pasted in the markdown editor",
+ upload_to=engine.core.models.get_pasted_image_path,
+ verbose_name="image",
+ ),
+ ),
+ (
+ "alt_text",
+ models.CharField(
+ blank=True,
+ default="",
+ help_text="alternative text for accessibility",
+ max_length=255,
+ verbose_name="alt text",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "pasted image",
+ "verbose_name_plural": "pasted images",
+ "ordering": ("-created",),
+ },
+ ),
+ ]
diff --git a/engine/core/models.py b/engine/core/models.py
index 714f060d..267ca857 100644
--- a/engine/core/models.py
+++ b/engine/core/models.py
@@ -69,6 +69,7 @@ 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.payments.models import Transaction
from schon.utils.misc import create_object
@@ -479,6 +480,10 @@ class Category(NiceModel, MPTTModel):
or 0.0
)
+ @cached_property
+ def seo_description(self) -> str:
+ return strip_markdown(self.description or "")[:180]
+
class Meta:
verbose_name = _("category")
verbose_name_plural = _("categories")
@@ -550,6 +555,10 @@ class Brand(NiceModel):
def __str__(self):
return self.name
+ @cached_property
+ def seo_description(self) -> str:
+ return strip_markdown(self.description or "")[:180]
+
class Meta:
verbose_name = _("brand")
verbose_name_plural = _("brands")
@@ -800,6 +809,10 @@ class Product(NiceModel):
def has_images(self):
return self.images.exists()
+ @cached_property
+ def seo_description(self) -> str:
+ return strip_markdown(self.description or "")[:180]
+
class Attribute(NiceModel):
__doc__ = _(
@@ -990,6 +1003,10 @@ class Promotion(NiceModel):
verbose_name=_("included products"),
)
+ @cached_property
+ def seo_description(self) -> str:
+ return strip_markdown(self.description or "")[:180]
+
class Meta:
verbose_name = _("promotion")
verbose_name_plural = _("promotions")
@@ -2177,3 +2194,35 @@ class DigitalAssetDownload(NiceModel):
@property
def url(self):
return f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}"
+
+
+def get_pasted_image_path(instance, filename: str) -> str:
+ return f"pasted_images/{instance.uuid}/{filename}"
+
+
+class PastedImage(NiceModel):
+ name = CharField(
+ max_length=100,
+ verbose_name=_("name"),
+ help_text=_("descriptive name for the image"),
+ )
+ image = ImageField(
+ upload_to=get_pasted_image_path,
+ verbose_name=_("image"),
+ help_text=_("image file pasted in the markdown editor"),
+ )
+ alt_text = CharField(
+ max_length=255,
+ blank=True,
+ default="",
+ verbose_name=_("alt text"),
+ help_text=_("alternative text for accessibility"),
+ )
+
+ class Meta:
+ verbose_name = _("pasted image")
+ verbose_name_plural = _("pasted images")
+ ordering = ("-created",)
+
+ def __str__(self) -> str:
+ return self.name
diff --git a/engine/core/serializers/detail.py b/engine/core/serializers/detail.py
index 14c97487..8ff9170e 100644
--- a/engine/core/serializers/detail.py
+++ b/engine/core/serializers/detail.py
@@ -32,6 +32,7 @@ from engine.core.serializers.simple import (
)
from engine.core.serializers.utility import AddressSerializer
from engine.core.typing import FilterableAttribute
+from engine.core.utils.markdown import render_markdown
logger = logging.getLogger(__name__)
@@ -60,6 +61,7 @@ class CategoryDetailListSerializer(ListSerializer):
class CategoryDetailSerializer(ModelSerializer):
children = SerializerMethodField()
+ description = SerializerMethodField()
filterable_attributes = SerializerMethodField()
brands = BrandSimpleSerializer(many=True, read_only=True)
min_price = SerializerMethodField()
@@ -82,6 +84,9 @@ class CategoryDetailSerializer(ModelSerializer):
"modified",
]
+ def get_description(self, obj: Category) -> str:
+ return render_markdown(obj.description or "")
+
def get_filterable_attributes(self, obj: Category) -> list[FilterableAttribute]:
return obj.filterable_attributes
@@ -109,12 +114,14 @@ class CategoryDetailSerializer(ModelSerializer):
class BrandDetailSerializer(ModelSerializer):
categories = CategorySimpleSerializer(many=True)
+ description = SerializerMethodField()
class Meta:
model = Brand
fields = [
"uuid",
"name",
+ "description",
"categories",
"created",
"modified",
@@ -122,6 +129,9 @@ class BrandDetailSerializer(ModelSerializer):
"small_logo",
]
+ def get_description(self, obj: Brand) -> str:
+ return render_markdown(obj.description or "")
+
class BrandProductDetailSerializer(ModelSerializer):
class Meta:
@@ -267,6 +277,7 @@ class ProductDetailSerializer(ModelSerializer):
many=True,
)
+ description = SerializerMethodField()
rating = SerializerMethodField()
price = SerializerMethodField()
quantity = SerializerMethodField()
@@ -299,6 +310,9 @@ class ProductDetailSerializer(ModelSerializer):
"personal_orders_only",
]
+ def get_description(self, obj: Product) -> str:
+ return render_markdown(obj.description or "")
+
def get_rating(self, obj: Product) -> float:
return obj.rating
@@ -322,6 +336,7 @@ class PromotionDetailSerializer(ModelSerializer):
products = ProductDetailSerializer(
many=True,
)
+ description = SerializerMethodField()
class Meta:
model = Promotion
@@ -335,6 +350,9 @@ class PromotionDetailSerializer(ModelSerializer):
"modified",
]
+ def get_description(self, obj: Promotion) -> str:
+ return render_markdown(obj.description or "")
+
class WishlistDetailSerializer(ModelSerializer):
products = ProductSimpleSerializer(
diff --git a/engine/core/serializers/simple.py b/engine/core/serializers/simple.py
index a2fec54c..3a9ba1e8 100644
--- a/engine/core/serializers/simple.py
+++ b/engine/core/serializers/simple.py
@@ -23,6 +23,7 @@ from engine.core.models import (
Wishlist,
)
from engine.core.serializers.utility import AddressSerializer
+from engine.core.utils.markdown import render_markdown
class AttributeGroupSimpleSerializer(ModelSerializer):
@@ -137,6 +138,7 @@ class ProductSimpleSerializer(ModelSerializer):
attributes = AttributeValueSimpleSerializer(many=True, read_only=True)
+ description = SerializerMethodField()
rating = SerializerMethodField()
price = SerializerMethodField()
quantity = SerializerMethodField()
@@ -167,6 +169,9 @@ class ProductSimpleSerializer(ModelSerializer):
"discount_price",
]
+ def get_description(self, obj: Product) -> str:
+ return render_markdown(obj.description or "")
+
def get_rating(self, obj: Product) -> float:
return obj.rating
diff --git a/engine/core/urls.py b/engine/core/urls.py
index 697d1150..11f296b0 100644
--- a/engine/core/urls.py
+++ b/engine/core/urls.py
@@ -12,6 +12,7 @@ from engine.core.views import (
ContactUsView,
DownloadDigitalAssetView,
GlobalSearchView,
+ PastedImageUploadView,
RequestCursedURLView,
SupportedLanguagesView,
WebsiteParametersView,
@@ -182,4 +183,9 @@ urlpatterns = [
RequestCursedURLView.as_view(),
name="request_cursed_url",
),
+ path(
+ "app/pasted_images/upload/",
+ PastedImageUploadView.as_view(),
+ name="pasted_image_upload",
+ ),
]
diff --git a/engine/core/utils/markdown.py b/engine/core/utils/markdown.py
new file mode 100644
index 00000000..68468a8c
--- /dev/null
+++ b/engine/core/utils/markdown.py
@@ -0,0 +1,21 @@
+import re
+from contextlib import suppress
+
+import markdown
+from django.utils.html import strip_tags
+
+
+def render_markdown(text: str) -> str:
+ """Render a markdown string to HTML."""
+ with suppress(Exception):
+ return markdown.markdown(text, extensions=["tables", "fenced_code", "nl2br"])
+ return text
+
+
+def strip_markdown(text: str) -> str:
+ """Render markdown to HTML then strip all tags, collapsing whitespace."""
+ with suppress(Exception):
+ html = render_markdown(text)
+ plain = strip_tags(html)
+ return re.sub(r"\s+", " ", plain).strip()
+ return text
diff --git a/engine/core/views.py b/engine/core/views.py
index 73fa36f9..da435ed9 100644
--- a/engine/core/views.py
+++ b/engine/core/views.py
@@ -36,7 +36,8 @@ from drf_spectacular.utils import extend_schema_view
from drf_spectacular.views import SpectacularAPIView
from graphene_file_upload.django import FileUploadGraphQLView
from rest_framework import status
-from rest_framework.permissions import AllowAny
+from rest_framework.parsers import MultiPartParser
+from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -58,6 +59,7 @@ from engine.core.models import (
DigitalAssetDownload,
Order,
OrderProduct,
+ PastedImage,
Product,
Wishlist,
)
@@ -436,6 +438,27 @@ def version(request: HttpRequest, *args, **kwargs) -> HttpResponse:
version.__doc__ = str(_("Returns current version of the Schon. "))
+class PastedImageUploadView(APIView):
+ permission_classes = [IsAdminUser]
+ parser_classes = [MultiPartParser]
+
+ def post(self, request: Request, *args, **kwargs) -> Response:
+ image = request.FILES.get("image")
+ if not image:
+ return Response(
+ {"error": _("no image file provided")},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ obj = PastedImage.objects.create(
+ name=image.name or "pasted",
+ image=image,
+ )
+ return Response(
+ {"data": {"filePath": request.build_absolute_uri(obj.image.url)}},
+ status=status.HTTP_201_CREATED,
+ )
+
+
def dashboard_callback(request: HttpRequest, context: Context) -> Context:
tf_map: dict[str, int] = {"7": 7, "30": 30, "90": 90, "360": 360}
tf_param = str(request.GET.get("tf", "30") or "30")
diff --git a/engine/core/viewsets.py b/engine/core/viewsets.py
index c783dd0c..4b43373e 100644
--- a/engine/core/viewsets.py
+++ b/engine/core/viewsets.py
@@ -274,7 +274,7 @@ class CategoryViewSet(SchonViewSet):
category = self.get_object()
title = f"{category.name} | {settings.PROJECT_NAME}"
- description = (category.description or "")[:180]
+ description = category.seo_description
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{category.slug}"
og_image = (
request.build_absolute_uri(category.image.url)
@@ -409,7 +409,7 @@ class BrandViewSet(SchonViewSet):
brand = self.get_object()
title = f"{brand.name} | {settings.PROJECT_NAME}"
- description = (brand.description or "")[:180]
+ description = brand.seo_description
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/brand/{brand.slug}"
logo_url = (
@@ -555,7 +555,7 @@ class ProductViewSet(SchonViewSet):
images = list(p.images.all()[:6])
rating = {"value": p.rating, "count": p.feedbacks_count}
title = f"{p.name} | {settings.PROJECT_NAME}"
- description = (p.description or "")[:180]
+ description = p.seo_description
canonical = (
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}"
)
diff --git a/engine/vibes_auth/admin.py b/engine/vibes_auth/admin.py
index ae88e25b..9d03f2dd 100644
--- a/engine/vibes_auth/admin.py
+++ b/engine/vibes_auth/admin.py
@@ -14,7 +14,6 @@ from django.core.exceptions import PermissionDenied
from django.db.models import Prefetch, QuerySet
from django.http import HttpRequest
from django.utils import timezone
-from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from rest_framework_simplejwt.token_blacklist.admin import (
BlacklistedTokenAdmin as BaseBlacklistedTokenAdmin,
@@ -29,8 +28,8 @@ from rest_framework_simplejwt.token_blacklist.models import (
OutstandingToken as BaseOutstandingToken,
)
from unfold.admin import ModelAdmin, TabularInline
-from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
+from unfold_markdown import MarkdownWidget
from engine.core.admin import ActivationActionsMixin
from engine.core.models import Order
@@ -39,7 +38,6 @@ from engine.vibes_auth.emailing.choices import CampaignStatus
from engine.vibes_auth.emailing.models import (
CampaignRecipient,
EmailCampaign,
- EmailImage,
EmailTemplate,
)
from engine.vibes_auth.emailing.tasks import (
@@ -196,38 +194,6 @@ class ChatMessageAdmin(admin.ModelAdmin):
readonly_fields = ("created", "modified")
-@register(EmailImage)
-class EmailImageAdmin(ActivationActionsMixin, ModelAdmin):
- list_display = ("name", "image_preview", "alt_text", "is_active", "created")
- list_filter = ("is_active",)
- search_fields = ("name", "alt_text")
- readonly_fields = ("uuid", "created", "modified", "image_preview_large")
-
- fieldsets = (
- (None, {"fields": ("name", "image", "alt_text")}),
- (_("Preview"), {"fields": ("image_preview_large",)}),
- (_("Metadata"), {"fields": ("uuid", "is_active", "created", "modified")}),
- )
-
- @admin.display(description=_("preview"))
- def image_preview(self, obj: EmailImage) -> str:
- if obj.image:
- return format_html(
- '
',
- obj.image.url,
- )
- return "-"
-
- @admin.display(description=_("image preview"))
- def image_preview_large(self, obj: EmailImage) -> str:
- if obj.image:
- return format_html(
- '
',
- obj.image.url,
- )
- return "-"
-
-
@register(EmailTemplate)
class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
list_display = ("name", "slug", "subject", "is_active", "modified")
@@ -238,7 +204,7 @@ class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
fieldsets = (
(None, {"fields": ("name", "slug", "subject")}),
- (_("Content"), {"fields": ("html_content", "plain_content")}),
+ (_("Content"), {"fields": ("content", "plain_content")}),
(
_("Documentation"),
{
@@ -250,8 +216,8 @@ class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
)
def formfield_for_dbfield(self, db_field, request, **kwargs):
- if db_field.name == "html_content":
- kwargs["widget"] = WysiwygWidget
+ if db_field.name == "content":
+ kwargs["widget"] = MarkdownWidget
return super().formfield_for_dbfield(db_field, request, **kwargs)
diff --git a/engine/vibes_auth/emailing/__init__.py b/engine/vibes_auth/emailing/__init__.py
index c8ec8f4f..3f238986 100644
--- a/engine/vibes_auth/emailing/__init__.py
+++ b/engine/vibes_auth/emailing/__init__.py
@@ -1,12 +1,10 @@
from engine.vibes_auth.emailing.models import (
CampaignRecipient,
EmailCampaign,
- EmailImage,
EmailTemplate,
)
__all__ = [
- "EmailImage",
"EmailTemplate",
"EmailCampaign",
"CampaignRecipient",
diff --git a/engine/vibes_auth/emailing/models.py b/engine/vibes_auth/emailing/models.py
index e160d4f8..e6946ebe 100644
--- a/engine/vibes_auth/emailing/models.py
+++ b/engine/vibes_auth/emailing/models.py
@@ -7,7 +7,6 @@ from django.db.models import (
CharField,
DateTimeField,
ForeignKey,
- ImageField,
Index,
PositiveIntegerField,
SlugField,
@@ -21,42 +20,10 @@ from engine.vibes_auth.emailing.choices import CampaignStatus, RecipientStatus
def get_email_image_path(instance, filename: str) -> str:
+ """Kept for historic migrations that reference this callable."""
return f"email_images/{instance.uuid}/{filename}"
-class EmailImage(NiceModel):
- """
- Stores images that can be used in email templates.
- Images are uploaded to filesystem and referenced by URL in templates.
- """
-
- name = CharField(
- max_length=100,
- verbose_name=_("name"),
- help_text=_("descriptive name for the image"),
- )
- image = ImageField(
- upload_to=get_email_image_path,
- verbose_name=_("image"),
- help_text=_("image file to use in email templates"),
- )
- alt_text = CharField(
- max_length=255,
- blank=True,
- default="",
- verbose_name=_("alt text"),
- help_text=_("alternative text for accessibility"),
- )
-
- class Meta:
- verbose_name = _("email image")
- verbose_name_plural = _("email images")
- ordering = ("-created",)
-
- def __str__(self) -> str:
- return self.name
-
-
class EmailTemplate(NiceModel):
"""
Customizable email template stored in the database.
@@ -81,10 +48,10 @@ class EmailTemplate(NiceModel):
verbose_name=_("subject"),
help_text=_("email subject line - supports {{ variables }}"),
)
- html_content = TextField(
- verbose_name=_("HTML content"),
+ content = TextField(
+ verbose_name=_("content"),
help_text=_(
- "email body content - supports {{ user.first_name }}, "
+ "email body in markdown - supports {{ user.first_name }}, "
"{{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}"
),
)
diff --git a/engine/vibes_auth/emailing/tasks.py b/engine/vibes_auth/emailing/tasks.py
index 52c222a0..ecc68ecb 100644
--- a/engine/vibes_auth/emailing/tasks.py
+++ b/engine/vibes_auth/emailing/tasks.py
@@ -12,6 +12,7 @@ from django.utils.html import strip_tags
from django.utils.translation import activate
from engine.core.utils import get_dynamic_email_connection
+from engine.core.utils.markdown import render_markdown
from engine.vibes_auth.emailing.choices import CampaignStatus, RecipientStatus
logger = logging.getLogger(__name__)
@@ -230,7 +231,8 @@ def send_single_campaign_email(campaign, recipient, connection) -> None:
# Render subject and content
subject = render_template_string(template.subject, context)
- html_content = render_template_string(template.html_content, context)
+ rendered_content = render_template_string(template.content, context)
+ html_content = render_markdown(rendered_content)
# Add unsubscribe footer to HTML
html_content = add_unsubscribe_footer(html_content, unsubscribe_url)
diff --git a/engine/vibes_auth/migrations/0009_delete_emailimage_remove_emailtemplate_html_content_and_more.py b/engine/vibes_auth/migrations/0009_delete_emailimage_remove_emailtemplate_html_content_and_more.py
new file mode 100644
index 00000000..27a39312
--- /dev/null
+++ b/engine/vibes_auth/migrations/0009_delete_emailimage_remove_emailtemplate_html_content_and_more.py
@@ -0,0 +1,393 @@
+# Generated by Django 5.2.11 on 2026-02-27 20:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("vibes_auth", "0008_emailtemplate_html_content_ar_ar_and_more"),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name="EmailImage",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_ar_ar",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_cs_cz",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_da_dk",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_de_de",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_en_gb",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_en_us",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_es_es",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_fa_ir",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_fr_fr",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_he_il",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_hi_in",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_hr_hr",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_id_id",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_it_it",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_ja_jp",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_kk_kz",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_ko_kr",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_nl_nl",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_no_no",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_pl_pl",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_pt_br",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_ro_ro",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_ru_ru",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_sv_se",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_th_th",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_tr_tr",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_vi_vn",
+ ),
+ migrations.RemoveField(
+ model_name="emailtemplate",
+ name="html_content_zh_hans",
+ ),
+ migrations.AddField(
+ model_name="emailtemplate",
+ name="content",
+ field=models.TextField(
+ default="
null
", + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + verbose_name="content", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="emailtemplate", + name="content_ar_ar", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_cs_cz", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_da_dk", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_de_de", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_en_gb", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_en_us", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_es_es", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_fa_ir", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_fr_fr", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_he_il", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_hi_in", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_hr_hr", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_id_id", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_it_it", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_ja_jp", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_kk_kz", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_ko_kr", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_nl_nl", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_no_no", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_pl_pl", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_pt_br", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_ro_ro", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_ru_ru", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_sv_se", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_th_th", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_tr_tr", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_vi_vn", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + migrations.AddField( + model_name="emailtemplate", + name="content_zh_hans", + field=models.TextField( + help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}", + null=True, + verbose_name="content", + ), + ), + ] diff --git a/engine/vibes_auth/translation.py b/engine/vibes_auth/translation.py index 04626ad0..c1ff8c98 100644 --- a/engine/vibes_auth/translation.py +++ b/engine/vibes_auth/translation.py @@ -6,4 +6,4 @@ from engine.vibes_auth.emailing import EmailTemplate @register(EmailTemplate) class EmailTemplateOptions(TranslationOptions): - fields = ("subject", "html_content", "plain_content") + fields = ("subject", "content", "plain_content") diff --git a/pyproject.toml b/pyproject.toml index b0d51be9..dac42eff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dependencies = [ "graphene-django==3.2.3", "graphene-file-upload==1.3.0", "httpx==0.28.1", + "markdown==3.10.2", "opentelemetry-instrumentation-django==0.60b1", "paramiko==4.0.0", "pillow==12.1.1", diff --git a/schon/settings/base.py b/schon/settings/base.py index 56fb8fcb..31682fbc 100644 --- a/schon/settings/base.py +++ b/schon/settings/base.py @@ -114,6 +114,7 @@ INSTALLED_APPS: list[str] = [ "unfold.contrib.inlines", "unfold.contrib.constance", "unfold.contrib.import_export", + "unfold_markdown", "constance", "modeltranslation", "django.contrib.admin", diff --git a/uv.lock b/uv.lock index 9419772d..61acb3af 100644 --- a/uv.lock +++ b/uv.lock @@ -3382,6 +3382,7 @@ dependencies = [ { name = "graphene-django" }, { name = "graphene-file-upload" }, { name = "httpx" }, + { name = "markdown" }, { name = "opentelemetry-instrumentation-django" }, { name = "paramiko" }, { name = "pillow" }, @@ -3486,6 +3487,7 @@ requires-dist = [ { name = "graphene-file-upload", specifier = "==1.3.0" }, { name = "httpx", specifier = "==0.28.1" }, { name = "jupyter", marker = "extra == 'jupyter'", specifier = "==1.1.1" }, + { name = "markdown", specifier = "==3.10.2" }, { name = "openai", marker = "extra == 'openai'", specifier = "==2.24.0" }, { name = "opentelemetry-instrumentation-django", specifier = "==0.60b1" }, { name = "paramiko", specifier = "==4.0.0" },