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:
parent
b6297aefa1
commit
eef774c3a3
25 changed files with 702 additions and 146 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
from django.contrib.admin import register
|
from django.contrib.admin import register
|
||||||
from django.db.models import TextField
|
from django.db.models import TextField
|
||||||
from unfold.admin import ModelAdmin
|
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.blog.models import Post, PostTag
|
||||||
from engine.core.admin import ActivationActionsMixin, FieldsetsMixin
|
from engine.core.admin import ActivationActionsMixin, FieldsetsMixin
|
||||||
|
|
@ -15,7 +15,7 @@ class PostAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
|
||||||
filter_horizontal = ("tags",)
|
filter_horizontal = ("tags",)
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
autocomplete_fields = ("tags",)
|
autocomplete_fields = ("tags",)
|
||||||
formfield_overrides = {TextField: {"widget": WysiwygWidget}}
|
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"uuid",
|
"uuid",
|
||||||
"slug",
|
"slug",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from graphene import List, String, relay
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
|
|
||||||
from engine.blog.models import Post, PostTag
|
from engine.blog.models import Post, PostTag
|
||||||
|
from engine.core.utils.markdown import render_markdown
|
||||||
|
|
||||||
|
|
||||||
class PostType(DjangoObjectType):
|
class PostType(DjangoObjectType):
|
||||||
|
|
@ -15,7 +16,7 @@ class PostType(DjangoObjectType):
|
||||||
interfaces = (relay.Node,)
|
interfaces = (relay.Node,)
|
||||||
|
|
||||||
def resolve_content(self: Post, _info: HttpRequest) -> str:
|
def resolve_content(self: Post, _info: HttpRequest) -> str:
|
||||||
return self.content or ""
|
return render_markdown(self.content or "")
|
||||||
|
|
||||||
|
|
||||||
class PostTagType(DjangoObjectType):
|
class PostTagType(DjangoObjectType):
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ from django.db.models import (
|
||||||
ManyToManyField,
|
ManyToManyField,
|
||||||
TextField,
|
TextField,
|
||||||
)
|
)
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from engine.core.abstract import NiceModel
|
from engine.core.abstract import NiceModel
|
||||||
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
|
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
|
||||||
|
from engine.core.utils.markdown import strip_markdown
|
||||||
|
|
||||||
|
|
||||||
class Post(NiceModel):
|
class Post(NiceModel):
|
||||||
|
|
@ -70,6 +72,10 @@ class Post(NiceModel):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.title} | {self.author.first_name} {self.author.last_name}"
|
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:
|
class Meta:
|
||||||
verbose_name = _("post")
|
verbose_name = _("post")
|
||||||
verbose_name_plural = _("posts")
|
verbose_name_plural = _("posts")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from rest_framework.fields import SerializerMethodField
|
||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
|
|
||||||
from engine.blog.models import Post, PostTag
|
from engine.blog.models import Post, PostTag
|
||||||
|
from engine.core.utils.markdown import render_markdown
|
||||||
|
|
||||||
|
|
||||||
class PostTagSerializer(ModelSerializer):
|
class PostTagSerializer(ModelSerializer):
|
||||||
|
|
@ -19,4 +20,4 @@ class PostSerializer(ModelSerializer):
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
def get_content(self, obj: Post) -> str:
|
def get_content(self, obj: Post) -> str:
|
||||||
return obj.content or ""
|
return render_markdown(obj.content or "")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -5,12 +5,14 @@ from constance.admin import Config
|
||||||
from constance.admin import ConstanceAdmin as BaseConstanceAdmin
|
from constance.admin import ConstanceAdmin as BaseConstanceAdmin
|
||||||
from django.apps import AppConfig, apps
|
from django.apps import AppConfig, apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
from django.contrib.admin import register, site
|
from django.contrib.admin import register, site
|
||||||
from django.contrib.gis.admin import GISModelAdmin
|
from django.contrib.gis.admin import GISModelAdmin
|
||||||
from django.contrib.messages import constants as messages
|
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.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.utils.html import format_html
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.admin import ClockedScheduleAdmin as BaseClockedScheduleAdmin
|
from django_celery_beat.admin import ClockedScheduleAdmin as BaseClockedScheduleAdmin
|
||||||
from django_celery_beat.admin import CrontabScheduleAdmin as BaseCrontabScheduleAdmin
|
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.decorators import action
|
||||||
from unfold.typing import FieldsetsType
|
from unfold.typing import FieldsetsType
|
||||||
from unfold.widgets import UnfoldAdminSelectWidget, UnfoldAdminTextInputWidget
|
from unfold.widgets import UnfoldAdminSelectWidget, UnfoldAdminTextInputWidget
|
||||||
|
from unfold_markdown import MarkdownWidget
|
||||||
|
|
||||||
from engine.core.forms import (
|
from engine.core.forms import (
|
||||||
CRMForm,
|
CRMForm,
|
||||||
|
|
@ -54,6 +57,7 @@ from engine.core.models import (
|
||||||
Order,
|
Order,
|
||||||
OrderCrmLink,
|
OrderCrmLink,
|
||||||
OrderProduct,
|
OrderProduct,
|
||||||
|
PastedImage,
|
||||||
Product,
|
Product,
|
||||||
ProductImage,
|
ProductImage,
|
||||||
ProductTag,
|
ProductTag,
|
||||||
|
|
@ -365,6 +369,7 @@ class CategoryAdmin(
|
||||||
):
|
):
|
||||||
# noinspection PyClassVar
|
# noinspection PyClassVar
|
||||||
model = Category
|
model = Category
|
||||||
|
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||||
list_display = (
|
list_display = (
|
||||||
"indented_title",
|
"indented_title",
|
||||||
"parent",
|
"parent",
|
||||||
|
|
@ -416,6 +421,7 @@ class BrandAdmin(
|
||||||
):
|
):
|
||||||
# noinspection PyClassVar
|
# noinspection PyClassVar
|
||||||
model = Brand
|
model = Brand
|
||||||
|
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||||
list_display = (
|
list_display = (
|
||||||
"name",
|
"name",
|
||||||
"priority",
|
"priority",
|
||||||
|
|
@ -451,6 +457,7 @@ class ProductAdmin(
|
||||||
):
|
):
|
||||||
# noinspection PyClassVar
|
# noinspection PyClassVar
|
||||||
model = Product
|
model = Product
|
||||||
|
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||||
actions = ActivationActionsMixin.actions + [
|
actions = ActivationActionsMixin.actions + [
|
||||||
"export_to_marketplaces",
|
"export_to_marketplaces",
|
||||||
"ban_from_marketplaces",
|
"ban_from_marketplaces",
|
||||||
|
|
@ -856,6 +863,7 @@ class PromotionAdmin(
|
||||||
):
|
):
|
||||||
# noinspection PyClassVar
|
# noinspection PyClassVar
|
||||||
model = Promotion
|
model = Promotion
|
||||||
|
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||||
list_display = (
|
list_display = (
|
||||||
"name",
|
"name",
|
||||||
"discount_percent",
|
"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)
|
@register(Address)
|
||||||
class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
|
class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
|
||||||
# noinspection PyClassVar
|
# noinspection PyClassVar
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ from engine.core.models import (
|
||||||
Wishlist,
|
Wishlist,
|
||||||
)
|
)
|
||||||
from engine.core.utils import graphene_abs, graphene_current_lang
|
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 (
|
from engine.core.utils.seo_builders import (
|
||||||
brand_schema,
|
brand_schema,
|
||||||
breadcrumb_schema,
|
breadcrumb_schema,
|
||||||
|
|
@ -132,6 +133,9 @@ class BrandType(DjangoObjectType):
|
||||||
filter_fields = ["uuid", "name"]
|
filter_fields = ["uuid", "name"]
|
||||||
description = _("brands")
|
description = _("brands")
|
||||||
|
|
||||||
|
def resolve_description(self: Brand, _info) -> str:
|
||||||
|
return render_markdown(self.description or "")
|
||||||
|
|
||||||
def resolve_categories(self: Brand, info) -> QuerySet[Category]:
|
def resolve_categories(self: Brand, info) -> QuerySet[Category]:
|
||||||
if info.context.user.has_perm("core.view_category"):
|
if info.context.user.has_perm("core.view_category"):
|
||||||
return self.categories.all()
|
return self.categories.all()
|
||||||
|
|
@ -156,7 +160,7 @@ class BrandType(DjangoObjectType):
|
||||||
base = f"https://{settings.BASE_DOMAIN}"
|
base = f"https://{settings.BASE_DOMAIN}"
|
||||||
canonical = f"{base}/{lang}/brand/{self.slug}"
|
canonical = f"{base}/{lang}/brand/{self.slug}"
|
||||||
title = f"{self.name} | {settings.PROJECT_NAME}"
|
title = f"{self.name} | {settings.PROJECT_NAME}"
|
||||||
description = (self.description or "")[:180]
|
description = self.seo_description
|
||||||
|
|
||||||
logo_url = None
|
logo_url = None
|
||||||
if getattr(self, "big_logo", None):
|
if getattr(self, "big_logo", None):
|
||||||
|
|
@ -255,6 +259,9 @@ class CategoryType(DjangoObjectType):
|
||||||
filter_fields = ["uuid"]
|
filter_fields = ["uuid"]
|
||||||
description = _("categories")
|
description = _("categories")
|
||||||
|
|
||||||
|
def resolve_description(self: Category, _info) -> str:
|
||||||
|
return render_markdown(self.description or "")
|
||||||
|
|
||||||
def resolve_children(self, info) -> TreeQuerySet | list[Category]:
|
def resolve_children(self, info) -> TreeQuerySet | list[Category]:
|
||||||
categories = Category.objects.filter(parent=self)
|
categories = Category.objects.filter(parent=self)
|
||||||
if not info.context.user.has_perm("core.view_category"):
|
if not info.context.user.has_perm("core.view_category"):
|
||||||
|
|
@ -292,7 +299,7 @@ class CategoryType(DjangoObjectType):
|
||||||
base = f"https://{settings.BASE_DOMAIN}"
|
base = f"https://{settings.BASE_DOMAIN}"
|
||||||
canonical = f"{base}/{lang}/catalog/{self.slug}"
|
canonical = f"{base}/{lang}/catalog/{self.slug}"
|
||||||
title = f"{self.name} | {settings.PROJECT_NAME}"
|
title = f"{self.name} | {settings.PROJECT_NAME}"
|
||||||
description = (self.description or "")[:180]
|
description = self.seo_description
|
||||||
|
|
||||||
og_image = (
|
og_image = (
|
||||||
graphene_abs(info.context, self.image.url)
|
graphene_abs(info.context, self.image.url)
|
||||||
|
|
@ -560,6 +567,9 @@ class ProductType(DjangoObjectType):
|
||||||
filter_fields = ["uuid", "name"]
|
filter_fields = ["uuid", "name"]
|
||||||
description = _("products")
|
description = _("products")
|
||||||
|
|
||||||
|
def resolve_description(self: Product, _info) -> str:
|
||||||
|
return render_markdown(self.description or "")
|
||||||
|
|
||||||
def resolve_price(self: Product, _info) -> float:
|
def resolve_price(self: Product, _info) -> float:
|
||||||
return self.price or 0.0
|
return self.price or 0.0
|
||||||
|
|
||||||
|
|
@ -592,7 +602,7 @@ class ProductType(DjangoObjectType):
|
||||||
base = f"https://{settings.BASE_DOMAIN}"
|
base = f"https://{settings.BASE_DOMAIN}"
|
||||||
canonical = f"{base}/{lang}/product/{self.slug}"
|
canonical = f"{base}/{lang}/product/{self.slug}"
|
||||||
title = f"{self.name} | {settings.PROJECT_NAME}"
|
title = f"{self.name} | {settings.PROJECT_NAME}"
|
||||||
description = (self.description or "")[:180]
|
description = self.seo_description
|
||||||
|
|
||||||
first_img = self.images.order_by("priority").first()
|
first_img = self.images.order_by("priority").first()
|
||||||
og_image = graphene_abs(info.context, first_img.image.url) if first_img else ""
|
og_image = graphene_abs(info.context, first_img.image.url) if first_img else ""
|
||||||
|
|
@ -689,10 +699,19 @@ class PromotionType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Promotion
|
model = Promotion
|
||||||
interfaces = (relay.Node,)
|
interfaces = (relay.Node,)
|
||||||
fields = ("uuid", "name", "discount_percent", "products")
|
fields = (
|
||||||
|
"uuid",
|
||||||
|
"name",
|
||||||
|
"discount_percent",
|
||||||
|
"description",
|
||||||
|
"products",
|
||||||
|
)
|
||||||
filter_fields = ["uuid"]
|
filter_fields = ["uuid"]
|
||||||
description = _("promotions")
|
description = _("promotions")
|
||||||
|
|
||||||
|
def resolve_description(self: Promotion, _info) -> str:
|
||||||
|
return render_markdown(self.description or "")
|
||||||
|
|
||||||
|
|
||||||
class StockType(DjangoObjectType):
|
class StockType(DjangoObjectType):
|
||||||
vendor = Field(VendorType, description=_("vendor"))
|
vendor = Field(VendorType, description=_("vendor"))
|
||||||
|
|
|
||||||
|
|
@ -715,11 +715,11 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
if created:
|
if created:
|
||||||
if "title_ru" in post_data:
|
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:
|
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:
|
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()
|
post.save()
|
||||||
|
|
||||||
for tag_name in post_data.get("tags", []):
|
for tag_name in post_data.get("tags", []):
|
||||||
|
|
|
||||||
88
engine/core/migrations/0056_pastedimage.py
Normal file
88
engine/core/migrations/0056_pastedimage.py
Normal 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",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -69,6 +69,7 @@ from engine.core.utils import (
|
||||||
)
|
)
|
||||||
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
|
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
|
||||||
from engine.core.utils.lists import FAILED_STATUSES
|
from engine.core.utils.lists import FAILED_STATUSES
|
||||||
|
from engine.core.utils.markdown import strip_markdown
|
||||||
from engine.core.validators import validate_category_image_dimensions
|
from engine.core.validators import validate_category_image_dimensions
|
||||||
from engine.payments.models import Transaction
|
from engine.payments.models import Transaction
|
||||||
from schon.utils.misc import create_object
|
from schon.utils.misc import create_object
|
||||||
|
|
@ -479,6 +480,10 @@ class Category(NiceModel, MPTTModel):
|
||||||
or 0.0
|
or 0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def seo_description(self) -> str:
|
||||||
|
return strip_markdown(self.description or "")[:180]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("category")
|
verbose_name = _("category")
|
||||||
verbose_name_plural = _("categories")
|
verbose_name_plural = _("categories")
|
||||||
|
|
@ -550,6 +555,10 @@ class Brand(NiceModel):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def seo_description(self) -> str:
|
||||||
|
return strip_markdown(self.description or "")[:180]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("brand")
|
verbose_name = _("brand")
|
||||||
verbose_name_plural = _("brands")
|
verbose_name_plural = _("brands")
|
||||||
|
|
@ -800,6 +809,10 @@ class Product(NiceModel):
|
||||||
def has_images(self):
|
def has_images(self):
|
||||||
return self.images.exists()
|
return self.images.exists()
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def seo_description(self) -> str:
|
||||||
|
return strip_markdown(self.description or "")[:180]
|
||||||
|
|
||||||
|
|
||||||
class Attribute(NiceModel):
|
class Attribute(NiceModel):
|
||||||
__doc__ = _(
|
__doc__ = _(
|
||||||
|
|
@ -990,6 +1003,10 @@ class Promotion(NiceModel):
|
||||||
verbose_name=_("included products"),
|
verbose_name=_("included products"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def seo_description(self) -> str:
|
||||||
|
return strip_markdown(self.description or "")[:180]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("promotion")
|
verbose_name = _("promotion")
|
||||||
verbose_name_plural = _("promotions")
|
verbose_name_plural = _("promotions")
|
||||||
|
|
@ -2177,3 +2194,35 @@ class DigitalAssetDownload(NiceModel):
|
||||||
@property
|
@property
|
||||||
def url(self):
|
def url(self):
|
||||||
return f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}"
|
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
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from engine.core.serializers.simple import (
|
||||||
)
|
)
|
||||||
from engine.core.serializers.utility import AddressSerializer
|
from engine.core.serializers.utility import AddressSerializer
|
||||||
from engine.core.typing import FilterableAttribute
|
from engine.core.typing import FilterableAttribute
|
||||||
|
from engine.core.utils.markdown import render_markdown
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -60,6 +61,7 @@ class CategoryDetailListSerializer(ListSerializer):
|
||||||
|
|
||||||
class CategoryDetailSerializer(ModelSerializer):
|
class CategoryDetailSerializer(ModelSerializer):
|
||||||
children = SerializerMethodField()
|
children = SerializerMethodField()
|
||||||
|
description = SerializerMethodField()
|
||||||
filterable_attributes = SerializerMethodField()
|
filterable_attributes = SerializerMethodField()
|
||||||
brands = BrandSimpleSerializer(many=True, read_only=True)
|
brands = BrandSimpleSerializer(many=True, read_only=True)
|
||||||
min_price = SerializerMethodField()
|
min_price = SerializerMethodField()
|
||||||
|
|
@ -82,6 +84,9 @@ class CategoryDetailSerializer(ModelSerializer):
|
||||||
"modified",
|
"modified",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_description(self, obj: Category) -> str:
|
||||||
|
return render_markdown(obj.description or "")
|
||||||
|
|
||||||
def get_filterable_attributes(self, obj: Category) -> list[FilterableAttribute]:
|
def get_filterable_attributes(self, obj: Category) -> list[FilterableAttribute]:
|
||||||
return obj.filterable_attributes
|
return obj.filterable_attributes
|
||||||
|
|
||||||
|
|
@ -109,12 +114,14 @@ class CategoryDetailSerializer(ModelSerializer):
|
||||||
|
|
||||||
class BrandDetailSerializer(ModelSerializer):
|
class BrandDetailSerializer(ModelSerializer):
|
||||||
categories = CategorySimpleSerializer(many=True)
|
categories = CategorySimpleSerializer(many=True)
|
||||||
|
description = SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Brand
|
model = Brand
|
||||||
fields = [
|
fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
"name",
|
"name",
|
||||||
|
"description",
|
||||||
"categories",
|
"categories",
|
||||||
"created",
|
"created",
|
||||||
"modified",
|
"modified",
|
||||||
|
|
@ -122,6 +129,9 @@ class BrandDetailSerializer(ModelSerializer):
|
||||||
"small_logo",
|
"small_logo",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_description(self, obj: Brand) -> str:
|
||||||
|
return render_markdown(obj.description or "")
|
||||||
|
|
||||||
|
|
||||||
class BrandProductDetailSerializer(ModelSerializer):
|
class BrandProductDetailSerializer(ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
@ -267,6 +277,7 @@ class ProductDetailSerializer(ModelSerializer):
|
||||||
many=True,
|
many=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
description = SerializerMethodField()
|
||||||
rating = SerializerMethodField()
|
rating = SerializerMethodField()
|
||||||
price = SerializerMethodField()
|
price = SerializerMethodField()
|
||||||
quantity = SerializerMethodField()
|
quantity = SerializerMethodField()
|
||||||
|
|
@ -299,6 +310,9 @@ class ProductDetailSerializer(ModelSerializer):
|
||||||
"personal_orders_only",
|
"personal_orders_only",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_description(self, obj: Product) -> str:
|
||||||
|
return render_markdown(obj.description or "")
|
||||||
|
|
||||||
def get_rating(self, obj: Product) -> float:
|
def get_rating(self, obj: Product) -> float:
|
||||||
return obj.rating
|
return obj.rating
|
||||||
|
|
||||||
|
|
@ -322,6 +336,7 @@ class PromotionDetailSerializer(ModelSerializer):
|
||||||
products = ProductDetailSerializer(
|
products = ProductDetailSerializer(
|
||||||
many=True,
|
many=True,
|
||||||
)
|
)
|
||||||
|
description = SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Promotion
|
model = Promotion
|
||||||
|
|
@ -335,6 +350,9 @@ class PromotionDetailSerializer(ModelSerializer):
|
||||||
"modified",
|
"modified",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_description(self, obj: Promotion) -> str:
|
||||||
|
return render_markdown(obj.description or "")
|
||||||
|
|
||||||
|
|
||||||
class WishlistDetailSerializer(ModelSerializer):
|
class WishlistDetailSerializer(ModelSerializer):
|
||||||
products = ProductSimpleSerializer(
|
products = ProductSimpleSerializer(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from engine.core.models import (
|
||||||
Wishlist,
|
Wishlist,
|
||||||
)
|
)
|
||||||
from engine.core.serializers.utility import AddressSerializer
|
from engine.core.serializers.utility import AddressSerializer
|
||||||
|
from engine.core.utils.markdown import render_markdown
|
||||||
|
|
||||||
|
|
||||||
class AttributeGroupSimpleSerializer(ModelSerializer):
|
class AttributeGroupSimpleSerializer(ModelSerializer):
|
||||||
|
|
@ -137,6 +138,7 @@ class ProductSimpleSerializer(ModelSerializer):
|
||||||
|
|
||||||
attributes = AttributeValueSimpleSerializer(many=True, read_only=True)
|
attributes = AttributeValueSimpleSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
description = SerializerMethodField()
|
||||||
rating = SerializerMethodField()
|
rating = SerializerMethodField()
|
||||||
price = SerializerMethodField()
|
price = SerializerMethodField()
|
||||||
quantity = SerializerMethodField()
|
quantity = SerializerMethodField()
|
||||||
|
|
@ -167,6 +169,9 @@ class ProductSimpleSerializer(ModelSerializer):
|
||||||
"discount_price",
|
"discount_price",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_description(self, obj: Product) -> str:
|
||||||
|
return render_markdown(obj.description or "")
|
||||||
|
|
||||||
def get_rating(self, obj: Product) -> float:
|
def get_rating(self, obj: Product) -> float:
|
||||||
return obj.rating
|
return obj.rating
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from engine.core.views import (
|
||||||
ContactUsView,
|
ContactUsView,
|
||||||
DownloadDigitalAssetView,
|
DownloadDigitalAssetView,
|
||||||
GlobalSearchView,
|
GlobalSearchView,
|
||||||
|
PastedImageUploadView,
|
||||||
RequestCursedURLView,
|
RequestCursedURLView,
|
||||||
SupportedLanguagesView,
|
SupportedLanguagesView,
|
||||||
WebsiteParametersView,
|
WebsiteParametersView,
|
||||||
|
|
@ -182,4 +183,9 @@ urlpatterns = [
|
||||||
RequestCursedURLView.as_view(),
|
RequestCursedURLView.as_view(),
|
||||||
name="request_cursed_url",
|
name="request_cursed_url",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"app/pasted_images/upload/",
|
||||||
|
PastedImageUploadView.as_view(),
|
||||||
|
name="pasted_image_upload",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
21
engine/core/utils/markdown.py
Normal file
21
engine/core/utils/markdown.py
Normal 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
|
||||||
|
|
@ -36,7 +36,8 @@ from drf_spectacular.utils import extend_schema_view
|
||||||
from drf_spectacular.views import SpectacularAPIView
|
from drf_spectacular.views import SpectacularAPIView
|
||||||
from graphene_file_upload.django import FileUploadGraphQLView
|
from graphene_file_upload.django import FileUploadGraphQLView
|
||||||
from rest_framework import status
|
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.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
@ -58,6 +59,7 @@ from engine.core.models import (
|
||||||
DigitalAssetDownload,
|
DigitalAssetDownload,
|
||||||
Order,
|
Order,
|
||||||
OrderProduct,
|
OrderProduct,
|
||||||
|
PastedImage,
|
||||||
Product,
|
Product,
|
||||||
Wishlist,
|
Wishlist,
|
||||||
)
|
)
|
||||||
|
|
@ -436,6 +438,27 @@ def version(request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
version.__doc__ = str(_("Returns current version of the Schon. "))
|
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:
|
def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
||||||
tf_map: dict[str, int] = {"7": 7, "30": 30, "90": 90, "360": 360}
|
tf_map: dict[str, int] = {"7": 7, "30": 30, "90": 90, "360": 360}
|
||||||
tf_param = str(request.GET.get("tf", "30") or "30")
|
tf_param = str(request.GET.get("tf", "30") or "30")
|
||||||
|
|
|
||||||
|
|
@ -274,7 +274,7 @@ class CategoryViewSet(SchonViewSet):
|
||||||
category = self.get_object()
|
category = self.get_object()
|
||||||
|
|
||||||
title = f"{category.name} | {settings.PROJECT_NAME}"
|
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}"
|
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{category.slug}"
|
||||||
og_image = (
|
og_image = (
|
||||||
request.build_absolute_uri(category.image.url)
|
request.build_absolute_uri(category.image.url)
|
||||||
|
|
@ -409,7 +409,7 @@ class BrandViewSet(SchonViewSet):
|
||||||
brand = self.get_object()
|
brand = self.get_object()
|
||||||
|
|
||||||
title = f"{brand.name} | {settings.PROJECT_NAME}"
|
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}"
|
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/brand/{brand.slug}"
|
||||||
|
|
||||||
logo_url = (
|
logo_url = (
|
||||||
|
|
@ -555,7 +555,7 @@ class ProductViewSet(SchonViewSet):
|
||||||
images = list(p.images.all()[:6])
|
images = list(p.images.all()[:6])
|
||||||
rating = {"value": p.rating, "count": p.feedbacks_count}
|
rating = {"value": p.rating, "count": p.feedbacks_count}
|
||||||
title = f"{p.name} | {settings.PROJECT_NAME}"
|
title = f"{p.name} | {settings.PROJECT_NAME}"
|
||||||
description = (p.description or "")[:180]
|
description = p.seo_description
|
||||||
canonical = (
|
canonical = (
|
||||||
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}"
|
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import Prefetch, QuerySet
|
from django.db.models import Prefetch, QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework_simplejwt.token_blacklist.admin import (
|
from rest_framework_simplejwt.token_blacklist.admin import (
|
||||||
BlacklistedTokenAdmin as BaseBlacklistedTokenAdmin,
|
BlacklistedTokenAdmin as BaseBlacklistedTokenAdmin,
|
||||||
|
|
@ -29,8 +28,8 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
||||||
OutstandingToken as BaseOutstandingToken,
|
OutstandingToken as BaseOutstandingToken,
|
||||||
)
|
)
|
||||||
from unfold.admin import ModelAdmin, TabularInline
|
from unfold.admin import ModelAdmin, TabularInline
|
||||||
from unfold.contrib.forms.widgets import WysiwygWidget
|
|
||||||
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
|
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
|
||||||
|
from unfold_markdown import MarkdownWidget
|
||||||
|
|
||||||
from engine.core.admin import ActivationActionsMixin
|
from engine.core.admin import ActivationActionsMixin
|
||||||
from engine.core.models import Order
|
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 (
|
from engine.vibes_auth.emailing.models import (
|
||||||
CampaignRecipient,
|
CampaignRecipient,
|
||||||
EmailCampaign,
|
EmailCampaign,
|
||||||
EmailImage,
|
|
||||||
EmailTemplate,
|
EmailTemplate,
|
||||||
)
|
)
|
||||||
from engine.vibes_auth.emailing.tasks import (
|
from engine.vibes_auth.emailing.tasks import (
|
||||||
|
|
@ -196,38 +194,6 @@ class ChatMessageAdmin(admin.ModelAdmin):
|
||||||
readonly_fields = ("created", "modified")
|
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)
|
@register(EmailTemplate)
|
||||||
class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
||||||
list_display = ("name", "slug", "subject", "is_active", "modified")
|
list_display = ("name", "slug", "subject", "is_active", "modified")
|
||||||
|
|
@ -238,7 +204,7 @@ class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("name", "slug", "subject")}),
|
(None, {"fields": ("name", "slug", "subject")}),
|
||||||
(_("Content"), {"fields": ("html_content", "plain_content")}),
|
(_("Content"), {"fields": ("content", "plain_content")}),
|
||||||
(
|
(
|
||||||
_("Documentation"),
|
_("Documentation"),
|
||||||
{
|
{
|
||||||
|
|
@ -250,8 +216,8 @@ class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
||||||
)
|
)
|
||||||
|
|
||||||
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
||||||
if db_field.name == "html_content":
|
if db_field.name == "content":
|
||||||
kwargs["widget"] = WysiwygWidget
|
kwargs["widget"] = MarkdownWidget
|
||||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
from engine.vibes_auth.emailing.models import (
|
from engine.vibes_auth.emailing.models import (
|
||||||
CampaignRecipient,
|
CampaignRecipient,
|
||||||
EmailCampaign,
|
EmailCampaign,
|
||||||
EmailImage,
|
|
||||||
EmailTemplate,
|
EmailTemplate,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EmailImage",
|
|
||||||
"EmailTemplate",
|
"EmailTemplate",
|
||||||
"EmailCampaign",
|
"EmailCampaign",
|
||||||
"CampaignRecipient",
|
"CampaignRecipient",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from django.db.models import (
|
||||||
CharField,
|
CharField,
|
||||||
DateTimeField,
|
DateTimeField,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
ImageField,
|
|
||||||
Index,
|
Index,
|
||||||
PositiveIntegerField,
|
PositiveIntegerField,
|
||||||
SlugField,
|
SlugField,
|
||||||
|
|
@ -21,42 +20,10 @@ from engine.vibes_auth.emailing.choices import CampaignStatus, RecipientStatus
|
||||||
|
|
||||||
|
|
||||||
def get_email_image_path(instance, filename: str) -> str:
|
def get_email_image_path(instance, filename: str) -> str:
|
||||||
|
"""Kept for historic migrations that reference this callable."""
|
||||||
return f"email_images/{instance.uuid}/{filename}"
|
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):
|
class EmailTemplate(NiceModel):
|
||||||
"""
|
"""
|
||||||
Customizable email template stored in the database.
|
Customizable email template stored in the database.
|
||||||
|
|
@ -81,10 +48,10 @@ class EmailTemplate(NiceModel):
|
||||||
verbose_name=_("subject"),
|
verbose_name=_("subject"),
|
||||||
help_text=_("email subject line - supports {{ variables }}"),
|
help_text=_("email subject line - supports {{ variables }}"),
|
||||||
)
|
)
|
||||||
html_content = TextField(
|
content = TextField(
|
||||||
verbose_name=_("HTML content"),
|
verbose_name=_("content"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"email body content - supports {{ user.first_name }}, "
|
"email body in markdown - supports {{ user.first_name }}, "
|
||||||
"{{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}"
|
"{{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from django.utils.html import strip_tags
|
||||||
from django.utils.translation import activate
|
from django.utils.translation import activate
|
||||||
|
|
||||||
from engine.core.utils import get_dynamic_email_connection
|
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
|
from engine.vibes_auth.emailing.choices import CampaignStatus, RecipientStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -230,7 +231,8 @@ def send_single_campaign_email(campaign, recipient, connection) -> None:
|
||||||
|
|
||||||
# Render subject and content
|
# Render subject and content
|
||||||
subject = render_template_string(template.subject, context)
|
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
|
# Add unsubscribe footer to HTML
|
||||||
html_content = add_unsubscribe_footer(html_content, unsubscribe_url)
|
html_content = add_unsubscribe_footer(html_content, unsubscribe_url)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -6,4 +6,4 @@ from engine.vibes_auth.emailing import EmailTemplate
|
||||||
|
|
||||||
@register(EmailTemplate)
|
@register(EmailTemplate)
|
||||||
class EmailTemplateOptions(TranslationOptions):
|
class EmailTemplateOptions(TranslationOptions):
|
||||||
fields = ("subject", "html_content", "plain_content")
|
fields = ("subject", "content", "plain_content")
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ dependencies = [
|
||||||
"graphene-django==3.2.3",
|
"graphene-django==3.2.3",
|
||||||
"graphene-file-upload==1.3.0",
|
"graphene-file-upload==1.3.0",
|
||||||
"httpx==0.28.1",
|
"httpx==0.28.1",
|
||||||
|
"markdown==3.10.2",
|
||||||
"opentelemetry-instrumentation-django==0.60b1",
|
"opentelemetry-instrumentation-django==0.60b1",
|
||||||
"paramiko==4.0.0",
|
"paramiko==4.0.0",
|
||||||
"pillow==12.1.1",
|
"pillow==12.1.1",
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ INSTALLED_APPS: list[str] = [
|
||||||
"unfold.contrib.inlines",
|
"unfold.contrib.inlines",
|
||||||
"unfold.contrib.constance",
|
"unfold.contrib.constance",
|
||||||
"unfold.contrib.import_export",
|
"unfold.contrib.import_export",
|
||||||
|
"unfold_markdown",
|
||||||
"constance",
|
"constance",
|
||||||
"modeltranslation",
|
"modeltranslation",
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
|
|
|
||||||
2
uv.lock
2
uv.lock
|
|
@ -3382,6 +3382,7 @@ dependencies = [
|
||||||
{ name = "graphene-django" },
|
{ name = "graphene-django" },
|
||||||
{ name = "graphene-file-upload" },
|
{ name = "graphene-file-upload" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "markdown" },
|
||||||
{ name = "opentelemetry-instrumentation-django" },
|
{ name = "opentelemetry-instrumentation-django" },
|
||||||
{ name = "paramiko" },
|
{ name = "paramiko" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
|
|
@ -3486,6 +3487,7 @@ requires-dist = [
|
||||||
{ name = "graphene-file-upload", specifier = "==1.3.0" },
|
{ name = "graphene-file-upload", specifier = "==1.3.0" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
{ name = "jupyter", marker = "extra == 'jupyter'", specifier = "==1.1.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 = "openai", marker = "extra == 'openai'", specifier = "==2.24.0" },
|
||||||
{ name = "opentelemetry-instrumentation-django", specifier = "==0.60b1" },
|
{ name = "opentelemetry-instrumentation-django", specifier = "==0.60b1" },
|
||||||
{ name = "paramiko", specifier = "==4.0.0" },
|
{ name = "paramiko", specifier = "==4.0.0" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue