From eef774c3a3759f942a36c144eaecf926ed09c030 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Fri, 27 Feb 2026 23:36:51 +0300 Subject: [PATCH] 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. --- engine/blog/admin.py | 4 +- engine/blog/graphene/object_types.py | 3 +- engine/blog/models.py | 6 + engine/blog/serializers.py | 3 +- engine/blog/widgets.py | 51 --- engine/core/admin.py | 42 +- engine/core/graphene/object_types.py | 27 +- engine/core/management/commands/demo_data.py | 6 +- engine/core/migrations/0056_pastedimage.py | 88 ++++ engine/core/models.py | 49 +++ engine/core/serializers/detail.py | 18 + engine/core/serializers/simple.py | 5 + engine/core/urls.py | 6 + engine/core/utils/markdown.py | 21 + engine/core/views.py | 25 +- engine/core/viewsets.py | 6 +- engine/vibes_auth/admin.py | 42 +- engine/vibes_auth/emailing/__init__.py | 2 - engine/vibes_auth/emailing/models.py | 41 +- engine/vibes_auth/emailing/tasks.py | 4 +- ...ove_emailtemplate_html_content_and_more.py | 393 ++++++++++++++++++ engine/vibes_auth/translation.py | 2 +- pyproject.toml | 1 + schon/settings/base.py | 1 + uv.lock | 2 + 25 files changed, 702 insertions(+), 146 deletions(-) delete mode 100644 engine/blog/widgets.py create mode 100644 engine/core/migrations/0056_pastedimage.py create mode 100644 engine/core/utils/markdown.py create mode 100644 engine/vibes_auth/migrations/0009_delete_emailimage_remove_emailtemplate_html_content_and_more.py 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" },