Compare commits

...

7 commits

Author SHA1 Message Date
faea55c257 Merge branch 'master' into storefront-nuxt 2026-02-27 23:44:57 +03:00
eef774c3a3 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.
2026-02-27 23:36:51 +03:00
b6297aefa1 feat(dependencies): add django-unfold-markdown to project
add django-unfold-markdown v0.1.2 to dependencies for enhanced markdown functionality in the application. Updated `uv.lock` and `pyproject.toml` accordingly.
2026-02-27 22:35:36 +03:00
09610d98a2 feat(demo_data): enhance data generation with get_or_create
- Replaced `create` operations with `get_or_create` to ensure idempotency during data generation.
- Avoided redundant user, product image, and post creation when duplicates exist.
- Updated user and stock handling to leverage defaults for improved clarity.
- Prevented overwriting existing blog post and product image content.
2026-02-27 22:27:39 +03:00
df0d503c13 fix(demo_data): update locale override and handle missing value_ru gracefully
Adjust override to use "en-gb" for consistency with regional settings. Improve fallback behavior by assigning default value to `value_ru_ru` when `value_ru` is missing, ensuring data integrity during demo data loading.
2026-02-27 22:21:46 +03:00
0603fe320c style(models): reformat price aggregation queries for readability
Improves code readability by restructuring the `min_price` and `max_price` methods into a more concise and consistent format. No functional changes introduced.
2026-02-27 21:58:56 +03:00
7bb05d4987 feat(models): refine price aggregation to include active stocks
update `min_price` and `max_price` methods to consider only active stocks in price aggregation. This ensures more accurate price calculations by filtering out inactive stock entries.
2026-02-27 21:58:41 +03:00
25 changed files with 775 additions and 185 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,51 +0,0 @@
from typing import Any
from django import forms
from django.forms.renderers import BaseRenderer
from django.utils.safestring import mark_safe
class MarkdownEditorWidget(forms.Textarea):
class Media:
css = {
"all": (
"https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.css",
)
}
js = ("https://cdnjs.cloudflare.com/ajax/libs/easymde/2.14.0/easymde.min.js",)
def render(
self,
name: str,
value: str,
attrs: dict[Any, Any] | None = None,
renderer: BaseRenderer | None = None,
):
if not attrs:
attrs = {}
attrs["class"] = "markdown-editor"
textarea_html = super().render(name, value, attrs, renderer)
textarea_id = attrs.get("id", f"id_{name}")
init_js = f"""
<script>
document.addEventListener('DOMContentLoaded', function() {{
var el = document.getElementById("{textarea_id}");
if (!el || !window.EasyMDE) return;
new EasyMDE({{
element: el,
spellChecker: false,
renderingConfig: {{ singleLineBreaks: false }},
autoDownloadFontAwesome: false,
toolbar: [
"bold","italic","heading","|",
"quote","unordered-list","ordered-list","|",
"link","image","|",
"preview","side-by-side","fullscreen","|",
"guide"
]
}});
}});
</script>
"""
# noinspection DjangoSafeString
return mark_safe(textarea_html + init_js)

View file

@ -5,12 +5,14 @@ from constance.admin import Config
from constance.admin import ConstanceAdmin as BaseConstanceAdmin from 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

View file

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

View file

@ -87,7 +87,7 @@ class Command(BaseCommand):
self._load_demo_data() self._load_demo_data()
with override("en"): with override("en-gb"):
if action == "install": if action == "install":
self._install(options) self._install(options)
elif action == "remove": elif action == "remove":
@ -257,8 +257,8 @@ class Command(BaseCommand):
Attribute.objects.filter(group__name__in=group_names).delete() Attribute.objects.filter(group__name__in=group_names).delete()
AttributeGroup.objects.filter(name__in=group_names).delete() AttributeGroup.objects.filter(name__in=group_names).delete()
self.staff_user.delete() User.objects.filter(email=f"staff@{DEMO_EMAIL_DOMAIN}").delete()
self.super_user.delete() User.objects.filter(email=f"super@{DEMO_EMAIL_DOMAIN}").delete()
self.stdout.write("") self.stdout.write("")
self.stdout.write(self.style.SUCCESS("=" * 50)) self.stdout.write(self.style.SUCCESS("=" * 50))
@ -409,13 +409,15 @@ class Command(BaseCommand):
product.description_ru_ru = prod_data["description_ru"] # ty: ignore[invalid-assignment] product.description_ru_ru = prod_data["description_ru"] # ty: ignore[invalid-assignment]
product.save() product.save()
Stock.objects.create( Stock.objects.get_or_create(
vendor=vendor, vendor=vendor,
product=product, product=product,
sku=f"GS-{prod_data['partnumber']}", defaults={
price=prod_data["price"], "sku": f"GS-{prod_data['partnumber']}",
purchase_price=prod_data["purchase_price"], "price": prod_data["price"],
quantity=prod_data["quantity"], "purchase_price": prod_data["purchase_price"],
"quantity": prod_data["quantity"],
},
) )
# Add product image # Add product image
@ -436,8 +438,11 @@ class Command(BaseCommand):
attribute=attr, attribute=attr,
defaults={"value": value}, defaults={"value": value},
) )
if created and value_ru: if created:
if value_ru:
av.value_ru_ru = value_ru # ty:ignore[invalid-assignment] av.value_ru_ru = value_ru # ty:ignore[invalid-assignment]
else:
av.value_ru_ru = value # ty:ignore[invalid-assignment]
av.save() av.save()
def _find_image(self, partnumber: str, suffix: str = "") -> Path | None: def _find_image(self, partnumber: str, suffix: str = "") -> Path | None:
@ -472,6 +477,9 @@ class Command(BaseCommand):
def _save_product_image( def _save_product_image(
self, product: Product, image_path: Path, priority: int self, product: Product, image_path: Path, priority: int
) -> None: ) -> None:
if product.images.filter(priority=priority).exists():
return
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
image_content = f.read() image_content = f.read()
@ -510,14 +518,16 @@ class Command(BaseCommand):
existing_emails.add(email) existing_emails.add(email)
# Create user user, created = User.objects.get_or_create(
user = User(
email=email, email=email,
first_name=first_name, defaults={
last_name=last_name, "first_name": first_name,
is_active=True, "last_name": last_name,
is_verified=True, "is_active": True,
"is_verified": True,
},
) )
if created:
user.set_password(password) user.set_password(password)
user.save() user.save()
@ -591,12 +601,14 @@ class Command(BaseCommand):
address = Address.objects.filter(user=user).first() address = Address.objects.filter(user=user).first()
order = Order.objects.create( order, _ = Order.objects.get_or_create(
user=user, user=user,
status=status, status=status,
buy_time=order_date, buy_time=order_date,
billing_address=address, defaults={
shipping_address=address, "billing_address": address,
"shipping_address": address,
},
) )
Order.objects.filter(pk=order.pk).update(created=order_date) Order.objects.filter(pk=order.pk).update(created=order_date)
@ -617,12 +629,14 @@ class Command(BaseCommand):
else: else:
op_status = random.choice(["ACCEPTED", "PENDING"]) op_status = random.choice(["ACCEPTED", "PENDING"])
OrderProduct.objects.create( OrderProduct.objects.get_or_create(
order=order, order=order,
product=product, product=product,
quantity=quantity, defaults={
buy_price=round(price, 2), "quantity": quantity,
status=op_status, "buy_price": round(price, 2),
"status": op_status,
},
) )
orders.append(order) orders.append(order)
@ -679,9 +693,6 @@ class Command(BaseCommand):
tag.save() tag.save()
for post_data in data.get("blog_posts", []): for post_data in data.get("blog_posts", []):
if Post.objects.filter(title=post_data["title"]).exists():
continue
content_en = self._load_blog_content(post_data["content_file"], "en") content_en = self._load_blog_content(post_data["content_file"], "en")
content_ru = self._load_blog_content(post_data["content_file"], "ru") content_ru = self._load_blog_content(post_data["content_file"], "ru")
@ -693,19 +704,22 @@ class Command(BaseCommand):
) )
continue continue
post = Post( post, created = Post.objects.get_or_create(
author=author,
title=post_data["title"], title=post_data["title"],
content=content_en, defaults={
meta_description=post_data.get("meta_description", ""), "author": author,
is_static_page=post_data.get("is_static_page", False), "content": content_en,
"meta_description": post_data.get("meta_description", ""),
"is_static_page": post_data.get("is_static_page", False),
},
) )
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", []):

View file

@ -0,0 +1,88 @@
# Generated by Django 5.2.11 on 2026-02-27 20:25
import uuid
import django_extensions.db.fields
from django.db import migrations, models
import engine.core.models
class Migration(migrations.Migration):
dependencies = [
("core", "0055_alter_brand_categories_alter_product_slug"),
]
operations = [
migrations.CreateModel(
name="PastedImage",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="unique id is used to surely identify any database object",
primary_key=True,
serialize=False,
verbose_name="unique id",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="if set to false, this object can't be seen by users without needed permission",
verbose_name="is active",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True,
help_text="when the object first appeared on the database",
verbose_name="created",
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
(
"name",
models.CharField(
help_text="descriptive name for the image",
max_length=100,
verbose_name="name",
),
),
(
"image",
models.ImageField(
help_text="image file pasted in the markdown editor",
upload_to=engine.core.models.get_pasted_image_path,
verbose_name="image",
),
),
(
"alt_text",
models.CharField(
blank=True,
default="",
help_text="alternative text for accessibility",
max_length=255,
verbose_name="alt text",
),
),
],
options={
"verbose_name": "pasted image",
"verbose_name_plural": "pasted images",
"ordering": ("-created",),
},
),
]

View file

@ -69,6 +69,7 @@ from engine.core.utils import (
) )
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function from engine.core.utils.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
@ -464,17 +465,25 @@ class Category(NiceModel, MPTTModel):
@cached_property @cached_property
def min_price(self) -> float: def min_price(self) -> float:
return ( return (
self.products.filter(is_active=True).aggregate(Min("price"))["price__min"] self.products.filter(is_active=True, stocks__is_active=True).aggregate(
Min("stocks__price")
)["stocks__price__min"]
or 0.0 or 0.0
) )
@cached_property @cached_property
def max_price(self) -> float: def max_price(self) -> float:
return ( return (
self.products.filter(is_active=True).aggregate(Max("price"))["price__max"] self.products.filter(is_active=True, stocks__is_active=True).aggregate(
Max("stocks__price")
)["stocks__price__max"]
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")
@ -546,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")
@ -796,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__ = _(
@ -986,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")
@ -2173,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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,21 @@
import re
from contextlib import suppress
import markdown
from django.utils.html import strip_tags
def render_markdown(text: str) -> str:
"""Render a markdown string to HTML."""
with suppress(Exception):
return markdown.markdown(text, extensions=["tables", "fenced_code", "nl2br"])
return text
def strip_markdown(text: str) -> str:
"""Render markdown to HTML then strip all tags, collapsing whitespace."""
with suppress(Exception):
html = render_markdown(text)
plain = strip_tags(html)
return re.sub(r"\s+", " ", plain).strip()
return text

View file

@ -36,7 +36,8 @@ from drf_spectacular.utils import extend_schema_view
from drf_spectacular.views import SpectacularAPIView from 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")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,393 @@
# Generated by Django 5.2.11 on 2026-02-27 20:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("vibes_auth", "0008_emailtemplate_html_content_ar_ar_and_more"),
]
operations = [
migrations.DeleteModel(
name="EmailImage",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_ar_ar",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_cs_cz",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_da_dk",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_de_de",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_en_gb",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_en_us",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_es_es",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_fa_ir",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_fr_fr",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_he_il",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_hi_in",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_hr_hr",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_id_id",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_it_it",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_ja_jp",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_kk_kz",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_ko_kr",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_nl_nl",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_no_no",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_pl_pl",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_pt_br",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_ro_ro",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_ru_ru",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_sv_se",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_th_th",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_tr_tr",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_vi_vn",
),
migrations.RemoveField(
model_name="emailtemplate",
name="html_content_zh_hans",
),
migrations.AddField(
model_name="emailtemplate",
name="content",
field=models.TextField(
default="<p>null</p>",
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
verbose_name="content",
),
preserve_default=False,
),
migrations.AddField(
model_name="emailtemplate",
name="content_ar_ar",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_cs_cz",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_da_dk",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_de_de",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_en_gb",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_en_us",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_es_es",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_fa_ir",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_fr_fr",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_he_il",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_hi_in",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_hr_hr",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_id_id",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_it_it",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_ja_jp",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_kk_kz",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_ko_kr",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_nl_nl",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_no_no",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_pl_pl",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_pt_br",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_ro_ro",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_ru_ru",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_sv_se",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_th_th",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_tr_tr",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_vi_vn",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
migrations.AddField(
model_name="emailtemplate",
name="content_zh_hans",
field=models.TextField(
help_text="email body in markdown - supports {{ user.first_name }}, {{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}",
null=True,
verbose_name="content",
),
),
]

View file

@ -6,4 +6,4 @@ from engine.vibes_auth.emailing import EmailTemplate
@register(EmailTemplate) @register(EmailTemplate)
class EmailTemplateOptions(TranslationOptions): class EmailTemplateOptions(TranslationOptions):
fields = ("subject", "html_content", "plain_content") fields = ("subject", "content", "plain_content")

View file

@ -33,6 +33,7 @@ dependencies = [
"django-ratelimit==4.1.0", "django-ratelimit==4.1.0",
"django-storages==1.14.6", "django-storages==1.14.6",
"django-unfold==0.81.0", "django-unfold==0.81.0",
"django-unfold-markdown==0.1.2",
"django-debug-toolbar==6.2.0", "django-debug-toolbar==6.2.0",
"django-widget-tweaks==1.5.1", "django-widget-tweaks==1.5.1",
"djangorestframework==3.16.1", "djangorestframework==3.16.1",
@ -51,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",

View file

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

17
uv.lock
View file

@ -1066,6 +1066,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/03/f3b11452636bcb8f8fb4b2daa3301781eca7ea1b2ee5781fdc888e315b43/django_unfold-0.81.0-py3-none-any.whl", hash = "sha256:7a800fcf7ac438ae473ffa51cdfbb22ef0e6e8455dad84ce1e1846ddd21deac9", size = 1226399, upload-time = "2026-02-25T09:48:40.646Z" }, { url = "https://files.pythonhosted.org/packages/63/03/f3b11452636bcb8f8fb4b2daa3301781eca7ea1b2ee5781fdc888e315b43/django_unfold-0.81.0-py3-none-any.whl", hash = "sha256:7a800fcf7ac438ae473ffa51cdfbb22ef0e6e8455dad84ce1e1846ddd21deac9", size = 1226399, upload-time = "2026-02-25T09:48:40.646Z" },
] ]
[[package]]
name = "django-unfold-markdown"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-unfold" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/c5/3d1e9d43fce9a0b646bf4f40fdce056af45cda39ede35a2d1cdebfb25ddc/django_unfold_markdown-0.1.2.tar.gz", hash = "sha256:6b4d8c627c08901fc6088a4aa85c357510fa4c9e55fcb2e81f2bdf10628076ec", size = 117597, upload-time = "2025-11-30T19:40:02.159Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/6d/c4ded7b49db7e4c8daddafb28b6ec377a5e435879e370e048537342e65c3/django_unfold_markdown-0.1.2-py3-none-any.whl", hash = "sha256:a9614d7eac13c0b6be1288dfe7750987cbb99810372803a880b90b07ee014ce0", size = 119709, upload-time = "2025-11-30T19:40:00.864Z" },
]
[[package]] [[package]]
name = "django-widget-tweaks" name = "django-widget-tweaks"
version = "1.5.1" version = "1.5.1"
@ -3351,6 +3364,7 @@ dependencies = [
{ name = "django-redis" }, { name = "django-redis" },
{ name = "django-storages" }, { name = "django-storages" },
{ name = "django-unfold" }, { name = "django-unfold" },
{ name = "django-unfold-markdown" },
{ name = "django-widget-tweaks" }, { name = "django-widget-tweaks" },
{ name = "djangoql" }, { name = "djangoql" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
@ -3368,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" },
@ -3452,6 +3467,7 @@ requires-dist = [
{ name = "django-storages", specifier = "==1.14.6" }, { name = "django-storages", specifier = "==1.14.6" },
{ name = "django-stubs", marker = "extra == 'linting'", specifier = "==5.2.9" }, { name = "django-stubs", marker = "extra == 'linting'", specifier = "==5.2.9" },
{ name = "django-unfold", specifier = "==0.81.0" }, { name = "django-unfold", specifier = "==0.81.0" },
{ name = "django-unfold-markdown", specifier = "==0.1.2" },
{ name = "django-widget-tweaks", specifier = "==1.5.1" }, { name = "django-widget-tweaks", specifier = "==1.5.1" },
{ name = "djangoql", specifier = "==0.19.1" }, { name = "djangoql", specifier = "==0.19.1" },
{ name = "djangorestframework", specifier = "==3.16.1" }, { name = "djangorestframework", specifier = "==3.16.1" },
@ -3471,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" },