feat(markdown): integrate markdown rendering and editor support

Replace WYSIWYG editor with Markdown editor across all relevant models and admin fields. Add utilities for rendering and stripping markdown. Adjust serializers, views, and templates to support markdown content. Introduce `PastedImage` model and upload endpoint for handling inline image uploads in markdown.

This change simplifies content formatting while enhancing flexibility with markdown support.
This commit is contained in:
Egor Pavlovich Gorbunov 2026-02-27 23:36:51 +03:00
parent b6297aefa1
commit eef774c3a3
25 changed files with 702 additions and 146 deletions

View file

@ -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",

View file

@ -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):

View file

@ -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")

View file

@ -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 "")

View file

@ -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"""
<script>
document.addEventListener('DOMContentLoaded', function() {{
var el = document.getElementById("{textarea_id}");
if (!el || !window.EasyMDE) return;
new EasyMDE({{
element: el,
spellChecker: false,
renderingConfig: {{ singleLineBreaks: false }},
autoDownloadFontAwesome: false,
toolbar: [
"bold","italic","heading","|",
"quote","unordered-list","ordered-list","|",
"link","image","|",
"preview","side-by-side","fullscreen","|",
"guide"
]
}});
}});
</script>
"""
# noinspection DjangoSafeString
return mark_safe(textarea_html + init_js)

View file

@ -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(
'<img src="{}" style="max-height: 50px; max-width: 100px;" />',
obj.image.url,
)
return "-"
@admin.display(description=_("image preview"))
def image_preview_large(self, obj: PastedImage) -> str:
if obj.image:
return format_html(
'<img src="{}" style="max-height: 300px; max-width: 500px;" />',
obj.image.url,
)
return "-"
@register(Address)
class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
# noinspection PyClassVar

View file

@ -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"))

View file

@ -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", []):

View file

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

View file

@ -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

View file

@ -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(

View file

@ -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

View file

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

View file

@ -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

View file

@ -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")

View file

@ -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}"
)

View file

@ -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(
'<img src="{}" style="max-height: 50px; max-width: 100px;" />',
obj.image.url,
)
return "-"
@admin.display(description=_("image preview"))
def image_preview_large(self, obj: EmailImage) -> str:
if obj.image:
return format_html(
'<img src="{}" style="max-height: 300px; max-width: 500px;" />',
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)

View file

@ -1,12 +1,10 @@
from engine.vibes_auth.emailing.models import (
CampaignRecipient,
EmailCampaign,
EmailImage,
EmailTemplate,
)
__all__ = [
"EmailImage",
"EmailTemplate",
"EmailCampaign",
"CampaignRecipient",

View file

@ -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 }}"
),
)

View file

@ -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)

View file

@ -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="<p>null</p>",
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",
),
),
]

View file

@ -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")

View file

@ -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",

View file

@ -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",

View file

@ -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" },