Compare commits
7 commits
1e1d0ef397
...
faea55c257
| Author | SHA1 | Date | |
|---|---|---|---|
| faea55c257 | |||
| eef774c3a3 | |||
| b6297aefa1 | |||
| 09610d98a2 | |||
| df0d503c13 | |||
| 0603fe320c | |||
| 7bb05d4987 |
25 changed files with 775 additions and 185 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"))
|
||||||
|
|
|
||||||
|
|
@ -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", []):
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
17
uv.lock
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue