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