Merge branch 'master' into storefront-nuxt
This commit is contained in:
commit
faea55c257
25 changed files with 775 additions and 185 deletions
|
|
@ -1,7 +1,7 @@
|
|||
from django.contrib.admin import register
|
||||
from django.db.models import TextField
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.contrib.forms.widgets import WysiwygWidget
|
||||
from unfold_markdown import MarkdownWidget
|
||||
|
||||
from engine.blog.models import Post, PostTag
|
||||
from engine.core.admin import ActivationActionsMixin, FieldsetsMixin
|
||||
|
|
@ -15,7 +15,7 @@ class PostAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
|
|||
filter_horizontal = ("tags",)
|
||||
date_hierarchy = "created"
|
||||
autocomplete_fields = ("tags",)
|
||||
formfield_overrides = {TextField: {"widget": WysiwygWidget}}
|
||||
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||
readonly_fields = (
|
||||
"uuid",
|
||||
"slug",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from graphene import List, String, relay
|
|||
from graphene_django import DjangoObjectType
|
||||
|
||||
from engine.blog.models import Post, PostTag
|
||||
from engine.core.utils.markdown import render_markdown
|
||||
|
||||
|
||||
class PostType(DjangoObjectType):
|
||||
|
|
@ -15,7 +16,7 @@ class PostType(DjangoObjectType):
|
|||
interfaces = (relay.Node,)
|
||||
|
||||
def resolve_content(self: Post, _info: HttpRequest) -> str:
|
||||
return self.content or ""
|
||||
return render_markdown(self.content or "")
|
||||
|
||||
|
||||
class PostTagType(DjangoObjectType):
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ from django.db.models import (
|
|||
ManyToManyField,
|
||||
TextField,
|
||||
)
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from engine.core.abstract import NiceModel
|
||||
from engine.core.utils.db import TweakedAutoSlugField, unicode_slugify_function
|
||||
from engine.core.utils.markdown import strip_markdown
|
||||
|
||||
|
||||
class Post(NiceModel):
|
||||
|
|
@ -70,6 +72,10 @@ class Post(NiceModel):
|
|||
def __str__(self):
|
||||
return f"{self.title} | {self.author.first_name} {self.author.last_name}"
|
||||
|
||||
@cached_property
|
||||
def seo_description(self) -> str:
|
||||
return strip_markdown(self.content or "")[:180]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("post")
|
||||
verbose_name_plural = _("posts")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from rest_framework.fields import SerializerMethodField
|
|||
from rest_framework.serializers import ModelSerializer
|
||||
|
||||
from engine.blog.models import Post, PostTag
|
||||
from engine.core.utils.markdown import render_markdown
|
||||
|
||||
|
||||
class PostTagSerializer(ModelSerializer):
|
||||
|
|
@ -19,4 +20,4 @@ class PostSerializer(ModelSerializer):
|
|||
fields = "__all__"
|
||||
|
||||
def get_content(self, obj: Post) -> str:
|
||||
return obj.content or ""
|
||||
return render_markdown(obj.content or "")
|
||||
|
|
|
|||
|
|
@ -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 django.apps import AppConfig, apps
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import register, site
|
||||
from django.contrib.gis.admin import GISModelAdmin
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.db.models import Model
|
||||
from django.db.models import Model, TextField
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.admin import ClockedScheduleAdmin as BaseClockedScheduleAdmin
|
||||
from django_celery_beat.admin import CrontabScheduleAdmin as BaseCrontabScheduleAdmin
|
||||
|
|
@ -33,6 +35,7 @@ from unfold.contrib.import_export.forms import ExportForm, ImportForm
|
|||
from unfold.decorators import action
|
||||
from unfold.typing import FieldsetsType
|
||||
from unfold.widgets import UnfoldAdminSelectWidget, UnfoldAdminTextInputWidget
|
||||
from unfold_markdown import MarkdownWidget
|
||||
|
||||
from engine.core.forms import (
|
||||
CRMForm,
|
||||
|
|
@ -54,6 +57,7 @@ from engine.core.models import (
|
|||
Order,
|
||||
OrderCrmLink,
|
||||
OrderProduct,
|
||||
PastedImage,
|
||||
Product,
|
||||
ProductImage,
|
||||
ProductTag,
|
||||
|
|
@ -365,6 +369,7 @@ class CategoryAdmin(
|
|||
):
|
||||
# noinspection PyClassVar
|
||||
model = Category
|
||||
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||
list_display = (
|
||||
"indented_title",
|
||||
"parent",
|
||||
|
|
@ -416,6 +421,7 @@ class BrandAdmin(
|
|||
):
|
||||
# noinspection PyClassVar
|
||||
model = Brand
|
||||
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||
list_display = (
|
||||
"name",
|
||||
"priority",
|
||||
|
|
@ -451,6 +457,7 @@ class ProductAdmin(
|
|||
):
|
||||
# noinspection PyClassVar
|
||||
model = Product
|
||||
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||
actions = ActivationActionsMixin.actions + [
|
||||
"export_to_marketplaces",
|
||||
"ban_from_marketplaces",
|
||||
|
|
@ -856,6 +863,7 @@ class PromotionAdmin(
|
|||
):
|
||||
# noinspection PyClassVar
|
||||
model = Promotion
|
||||
formfield_overrides = {TextField: {"widget": MarkdownWidget}}
|
||||
list_display = (
|
||||
"name",
|
||||
"discount_percent",
|
||||
|
|
@ -995,6 +1003,38 @@ class ProductImageAdmin(
|
|||
]
|
||||
|
||||
|
||||
@register(PastedImage)
|
||||
class PastedImageAdmin(ActivationActionsMixin, ModelAdmin):
|
||||
list_display = ("name", "image_preview", "alt_text", "is_active", "created")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "alt_text")
|
||||
readonly_fields = ("uuid", "created", "modified", "image_preview_large")
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "image", "alt_text")}),
|
||||
(_("Preview"), {"fields": ("image_preview_large",)}),
|
||||
(_("Metadata"), {"fields": ("uuid", "is_active", "created", "modified")}),
|
||||
)
|
||||
|
||||
@admin.display(description=_("preview"))
|
||||
def image_preview(self, obj: PastedImage) -> str:
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<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)
|
||||
class AddressAdmin(DjangoQLSearchMixin, FieldsetsMixin, GISModelAdmin):
|
||||
# noinspection PyClassVar
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ from engine.core.models import (
|
|||
Wishlist,
|
||||
)
|
||||
from engine.core.utils import graphene_abs, graphene_current_lang
|
||||
from engine.core.utils.markdown import render_markdown
|
||||
from engine.core.utils.seo_builders import (
|
||||
brand_schema,
|
||||
breadcrumb_schema,
|
||||
|
|
@ -132,6 +133,9 @@ class BrandType(DjangoObjectType):
|
|||
filter_fields = ["uuid", "name"]
|
||||
description = _("brands")
|
||||
|
||||
def resolve_description(self: Brand, _info) -> str:
|
||||
return render_markdown(self.description or "")
|
||||
|
||||
def resolve_categories(self: Brand, info) -> QuerySet[Category]:
|
||||
if info.context.user.has_perm("core.view_category"):
|
||||
return self.categories.all()
|
||||
|
|
@ -156,7 +160,7 @@ class BrandType(DjangoObjectType):
|
|||
base = f"https://{settings.BASE_DOMAIN}"
|
||||
canonical = f"{base}/{lang}/brand/{self.slug}"
|
||||
title = f"{self.name} | {settings.PROJECT_NAME}"
|
||||
description = (self.description or "")[:180]
|
||||
description = self.seo_description
|
||||
|
||||
logo_url = None
|
||||
if getattr(self, "big_logo", None):
|
||||
|
|
@ -255,6 +259,9 @@ class CategoryType(DjangoObjectType):
|
|||
filter_fields = ["uuid"]
|
||||
description = _("categories")
|
||||
|
||||
def resolve_description(self: Category, _info) -> str:
|
||||
return render_markdown(self.description or "")
|
||||
|
||||
def resolve_children(self, info) -> TreeQuerySet | list[Category]:
|
||||
categories = Category.objects.filter(parent=self)
|
||||
if not info.context.user.has_perm("core.view_category"):
|
||||
|
|
@ -292,7 +299,7 @@ class CategoryType(DjangoObjectType):
|
|||
base = f"https://{settings.BASE_DOMAIN}"
|
||||
canonical = f"{base}/{lang}/catalog/{self.slug}"
|
||||
title = f"{self.name} | {settings.PROJECT_NAME}"
|
||||
description = (self.description or "")[:180]
|
||||
description = self.seo_description
|
||||
|
||||
og_image = (
|
||||
graphene_abs(info.context, self.image.url)
|
||||
|
|
@ -560,6 +567,9 @@ class ProductType(DjangoObjectType):
|
|||
filter_fields = ["uuid", "name"]
|
||||
description = _("products")
|
||||
|
||||
def resolve_description(self: Product, _info) -> str:
|
||||
return render_markdown(self.description or "")
|
||||
|
||||
def resolve_price(self: Product, _info) -> float:
|
||||
return self.price or 0.0
|
||||
|
||||
|
|
@ -592,7 +602,7 @@ class ProductType(DjangoObjectType):
|
|||
base = f"https://{settings.BASE_DOMAIN}"
|
||||
canonical = f"{base}/{lang}/product/{self.slug}"
|
||||
title = f"{self.name} | {settings.PROJECT_NAME}"
|
||||
description = (self.description or "")[:180]
|
||||
description = self.seo_description
|
||||
|
||||
first_img = self.images.order_by("priority").first()
|
||||
og_image = graphene_abs(info.context, first_img.image.url) if first_img else ""
|
||||
|
|
@ -689,10 +699,19 @@ class PromotionType(DjangoObjectType):
|
|||
class Meta:
|
||||
model = Promotion
|
||||
interfaces = (relay.Node,)
|
||||
fields = ("uuid", "name", "discount_percent", "products")
|
||||
fields = (
|
||||
"uuid",
|
||||
"name",
|
||||
"discount_percent",
|
||||
"description",
|
||||
"products",
|
||||
)
|
||||
filter_fields = ["uuid"]
|
||||
description = _("promotions")
|
||||
|
||||
def resolve_description(self: Promotion, _info) -> str:
|
||||
return render_markdown(self.description or "")
|
||||
|
||||
|
||||
class StockType(DjangoObjectType):
|
||||
vendor = Field(VendorType, description=_("vendor"))
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class Command(BaseCommand):
|
|||
|
||||
self._load_demo_data()
|
||||
|
||||
with override("en"):
|
||||
with override("en-gb"):
|
||||
if action == "install":
|
||||
self._install(options)
|
||||
elif action == "remove":
|
||||
|
|
@ -257,8 +257,8 @@ class Command(BaseCommand):
|
|||
Attribute.objects.filter(group__name__in=group_names).delete()
|
||||
AttributeGroup.objects.filter(name__in=group_names).delete()
|
||||
|
||||
self.staff_user.delete()
|
||||
self.super_user.delete()
|
||||
User.objects.filter(email=f"staff@{DEMO_EMAIL_DOMAIN}").delete()
|
||||
User.objects.filter(email=f"super@{DEMO_EMAIL_DOMAIN}").delete()
|
||||
|
||||
self.stdout.write("")
|
||||
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.save()
|
||||
|
||||
Stock.objects.create(
|
||||
Stock.objects.get_or_create(
|
||||
vendor=vendor,
|
||||
product=product,
|
||||
sku=f"GS-{prod_data['partnumber']}",
|
||||
price=prod_data["price"],
|
||||
purchase_price=prod_data["purchase_price"],
|
||||
quantity=prod_data["quantity"],
|
||||
defaults={
|
||||
"sku": f"GS-{prod_data['partnumber']}",
|
||||
"price": prod_data["price"],
|
||||
"purchase_price": prod_data["purchase_price"],
|
||||
"quantity": prod_data["quantity"],
|
||||
},
|
||||
)
|
||||
|
||||
# Add product image
|
||||
|
|
@ -436,8 +438,11 @@ class Command(BaseCommand):
|
|||
attribute=attr,
|
||||
defaults={"value": value},
|
||||
)
|
||||
if created and value_ru:
|
||||
if created:
|
||||
if value_ru:
|
||||
av.value_ru_ru = value_ru # ty:ignore[invalid-assignment]
|
||||
else:
|
||||
av.value_ru_ru = value # ty:ignore[invalid-assignment]
|
||||
av.save()
|
||||
|
||||
def _find_image(self, partnumber: str, suffix: str = "") -> Path | None:
|
||||
|
|
@ -472,6 +477,9 @@ class Command(BaseCommand):
|
|||
def _save_product_image(
|
||||
self, product: Product, image_path: Path, priority: int
|
||||
) -> None:
|
||||
if product.images.filter(priority=priority).exists():
|
||||
return
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
image_content = f.read()
|
||||
|
||||
|
|
@ -510,14 +518,16 @@ class Command(BaseCommand):
|
|||
|
||||
existing_emails.add(email)
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
user, created = User.objects.get_or_create(
|
||||
email=email,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
defaults={
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"is_active": True,
|
||||
"is_verified": True,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
|
|
@ -591,12 +601,14 @@ class Command(BaseCommand):
|
|||
|
||||
address = Address.objects.filter(user=user).first()
|
||||
|
||||
order = Order.objects.create(
|
||||
order, _ = Order.objects.get_or_create(
|
||||
user=user,
|
||||
status=status,
|
||||
buy_time=order_date,
|
||||
billing_address=address,
|
||||
shipping_address=address,
|
||||
defaults={
|
||||
"billing_address": address,
|
||||
"shipping_address": address,
|
||||
},
|
||||
)
|
||||
|
||||
Order.objects.filter(pk=order.pk).update(created=order_date)
|
||||
|
|
@ -617,12 +629,14 @@ class Command(BaseCommand):
|
|||
else:
|
||||
op_status = random.choice(["ACCEPTED", "PENDING"])
|
||||
|
||||
OrderProduct.objects.create(
|
||||
OrderProduct.objects.get_or_create(
|
||||
order=order,
|
||||
product=product,
|
||||
quantity=quantity,
|
||||
buy_price=round(price, 2),
|
||||
status=op_status,
|
||||
defaults={
|
||||
"quantity": quantity,
|
||||
"buy_price": round(price, 2),
|
||||
"status": op_status,
|
||||
},
|
||||
)
|
||||
|
||||
orders.append(order)
|
||||
|
|
@ -679,9 +693,6 @@ class Command(BaseCommand):
|
|||
tag.save()
|
||||
|
||||
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_ru = self._load_blog_content(post_data["content_file"], "ru")
|
||||
|
||||
|
|
@ -693,19 +704,22 @@ class Command(BaseCommand):
|
|||
)
|
||||
continue
|
||||
|
||||
post = Post(
|
||||
author=author,
|
||||
post, created = Post.objects.get_or_create(
|
||||
title=post_data["title"],
|
||||
content=content_en,
|
||||
meta_description=post_data.get("meta_description", ""),
|
||||
is_static_page=post_data.get("is_static_page", False),
|
||||
defaults={
|
||||
"author": author,
|
||||
"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:
|
||||
post.title_ru_ru = post_data["title_ru"] # ty:ignore[unresolved-attribute]
|
||||
post.title_ru_ru = post_data["title_ru"]
|
||||
if content_ru:
|
||||
post.content_ru_ru = content_ru # ty:ignore[unresolved-attribute]
|
||||
post.content_ru_ru = content_ru
|
||||
if "meta_description_ru" in post_data:
|
||||
post.meta_description_ru_ru = post_data["meta_description_ru"] # ty:ignore[unresolved-attribute]
|
||||
post.meta_description_ru_ru = post_data["meta_description_ru"]
|
||||
post.save()
|
||||
|
||||
for tag_name in post_data.get("tags", []):
|
||||
|
|
|
|||
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.lists import FAILED_STATUSES
|
||||
from engine.core.utils.markdown import strip_markdown
|
||||
from engine.core.validators import validate_category_image_dimensions
|
||||
from engine.payments.models import Transaction
|
||||
from schon.utils.misc import create_object
|
||||
|
|
@ -464,17 +465,25 @@ class Category(NiceModel, MPTTModel):
|
|||
@cached_property
|
||||
def min_price(self) -> float:
|
||||
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
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def max_price(self) -> float:
|
||||
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
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def seo_description(self) -> str:
|
||||
return strip_markdown(self.description or "")[:180]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("category")
|
||||
verbose_name_plural = _("categories")
|
||||
|
|
@ -546,6 +555,10 @@ class Brand(NiceModel):
|
|||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@cached_property
|
||||
def seo_description(self) -> str:
|
||||
return strip_markdown(self.description or "")[:180]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("brand")
|
||||
verbose_name_plural = _("brands")
|
||||
|
|
@ -796,6 +809,10 @@ class Product(NiceModel):
|
|||
def has_images(self):
|
||||
return self.images.exists()
|
||||
|
||||
@cached_property
|
||||
def seo_description(self) -> str:
|
||||
return strip_markdown(self.description or "")[:180]
|
||||
|
||||
|
||||
class Attribute(NiceModel):
|
||||
__doc__ = _(
|
||||
|
|
@ -986,6 +1003,10 @@ class Promotion(NiceModel):
|
|||
verbose_name=_("included products"),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def seo_description(self) -> str:
|
||||
return strip_markdown(self.description or "")[:180]
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("promotion")
|
||||
verbose_name_plural = _("promotions")
|
||||
|
|
@ -2173,3 +2194,35 @@ class DigitalAssetDownload(NiceModel):
|
|||
@property
|
||||
def url(self):
|
||||
return f"https://api.{settings.BASE_DOMAIN}/download/{urlsafe_base64_encode(force_bytes(self.order_product.uuid))}"
|
||||
|
||||
|
||||
def get_pasted_image_path(instance, filename: str) -> str:
|
||||
return f"pasted_images/{instance.uuid}/{filename}"
|
||||
|
||||
|
||||
class PastedImage(NiceModel):
|
||||
name = CharField(
|
||||
max_length=100,
|
||||
verbose_name=_("name"),
|
||||
help_text=_("descriptive name for the image"),
|
||||
)
|
||||
image = ImageField(
|
||||
upload_to=get_pasted_image_path,
|
||||
verbose_name=_("image"),
|
||||
help_text=_("image file pasted in the markdown editor"),
|
||||
)
|
||||
alt_text = CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("alt text"),
|
||||
help_text=_("alternative text for accessibility"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("pasted image")
|
||||
verbose_name_plural = _("pasted images")
|
||||
ordering = ("-created",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from engine.core.serializers.simple import (
|
|||
)
|
||||
from engine.core.serializers.utility import AddressSerializer
|
||||
from engine.core.typing import FilterableAttribute
|
||||
from engine.core.utils.markdown import render_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ class CategoryDetailListSerializer(ListSerializer):
|
|||
|
||||
class CategoryDetailSerializer(ModelSerializer):
|
||||
children = SerializerMethodField()
|
||||
description = SerializerMethodField()
|
||||
filterable_attributes = SerializerMethodField()
|
||||
brands = BrandSimpleSerializer(many=True, read_only=True)
|
||||
min_price = SerializerMethodField()
|
||||
|
|
@ -82,6 +84,9 @@ class CategoryDetailSerializer(ModelSerializer):
|
|||
"modified",
|
||||
]
|
||||
|
||||
def get_description(self, obj: Category) -> str:
|
||||
return render_markdown(obj.description or "")
|
||||
|
||||
def get_filterable_attributes(self, obj: Category) -> list[FilterableAttribute]:
|
||||
return obj.filterable_attributes
|
||||
|
||||
|
|
@ -109,12 +114,14 @@ class CategoryDetailSerializer(ModelSerializer):
|
|||
|
||||
class BrandDetailSerializer(ModelSerializer):
|
||||
categories = CategorySimpleSerializer(many=True)
|
||||
description = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Brand
|
||||
fields = [
|
||||
"uuid",
|
||||
"name",
|
||||
"description",
|
||||
"categories",
|
||||
"created",
|
||||
"modified",
|
||||
|
|
@ -122,6 +129,9 @@ class BrandDetailSerializer(ModelSerializer):
|
|||
"small_logo",
|
||||
]
|
||||
|
||||
def get_description(self, obj: Brand) -> str:
|
||||
return render_markdown(obj.description or "")
|
||||
|
||||
|
||||
class BrandProductDetailSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
|
|
@ -267,6 +277,7 @@ class ProductDetailSerializer(ModelSerializer):
|
|||
many=True,
|
||||
)
|
||||
|
||||
description = SerializerMethodField()
|
||||
rating = SerializerMethodField()
|
||||
price = SerializerMethodField()
|
||||
quantity = SerializerMethodField()
|
||||
|
|
@ -299,6 +310,9 @@ class ProductDetailSerializer(ModelSerializer):
|
|||
"personal_orders_only",
|
||||
]
|
||||
|
||||
def get_description(self, obj: Product) -> str:
|
||||
return render_markdown(obj.description or "")
|
||||
|
||||
def get_rating(self, obj: Product) -> float:
|
||||
return obj.rating
|
||||
|
||||
|
|
@ -322,6 +336,7 @@ class PromotionDetailSerializer(ModelSerializer):
|
|||
products = ProductDetailSerializer(
|
||||
many=True,
|
||||
)
|
||||
description = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Promotion
|
||||
|
|
@ -335,6 +350,9 @@ class PromotionDetailSerializer(ModelSerializer):
|
|||
"modified",
|
||||
]
|
||||
|
||||
def get_description(self, obj: Promotion) -> str:
|
||||
return render_markdown(obj.description or "")
|
||||
|
||||
|
||||
class WishlistDetailSerializer(ModelSerializer):
|
||||
products = ProductSimpleSerializer(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from engine.core.models import (
|
|||
Wishlist,
|
||||
)
|
||||
from engine.core.serializers.utility import AddressSerializer
|
||||
from engine.core.utils.markdown import render_markdown
|
||||
|
||||
|
||||
class AttributeGroupSimpleSerializer(ModelSerializer):
|
||||
|
|
@ -137,6 +138,7 @@ class ProductSimpleSerializer(ModelSerializer):
|
|||
|
||||
attributes = AttributeValueSimpleSerializer(many=True, read_only=True)
|
||||
|
||||
description = SerializerMethodField()
|
||||
rating = SerializerMethodField()
|
||||
price = SerializerMethodField()
|
||||
quantity = SerializerMethodField()
|
||||
|
|
@ -167,6 +169,9 @@ class ProductSimpleSerializer(ModelSerializer):
|
|||
"discount_price",
|
||||
]
|
||||
|
||||
def get_description(self, obj: Product) -> str:
|
||||
return render_markdown(obj.description or "")
|
||||
|
||||
def get_rating(self, obj: Product) -> float:
|
||||
return obj.rating
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from engine.core.views import (
|
|||
ContactUsView,
|
||||
DownloadDigitalAssetView,
|
||||
GlobalSearchView,
|
||||
PastedImageUploadView,
|
||||
RequestCursedURLView,
|
||||
SupportedLanguagesView,
|
||||
WebsiteParametersView,
|
||||
|
|
@ -182,4 +183,9 @@ urlpatterns = [
|
|||
RequestCursedURLView.as_view(),
|
||||
name="request_cursed_url",
|
||||
),
|
||||
path(
|
||||
"app/pasted_images/upload/",
|
||||
PastedImageUploadView.as_view(),
|
||||
name="pasted_image_upload",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
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 graphene_file_upload.django import FileUploadGraphQLView
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
|
@ -58,6 +59,7 @@ from engine.core.models import (
|
|||
DigitalAssetDownload,
|
||||
Order,
|
||||
OrderProduct,
|
||||
PastedImage,
|
||||
Product,
|
||||
Wishlist,
|
||||
)
|
||||
|
|
@ -436,6 +438,27 @@ def version(request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
|||
version.__doc__ = str(_("Returns current version of the Schon. "))
|
||||
|
||||
|
||||
class PastedImageUploadView(APIView):
|
||||
permission_classes = [IsAdminUser]
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def post(self, request: Request, *args, **kwargs) -> Response:
|
||||
image = request.FILES.get("image")
|
||||
if not image:
|
||||
return Response(
|
||||
{"error": _("no image file provided")},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
obj = PastedImage.objects.create(
|
||||
name=image.name or "pasted",
|
||||
image=image,
|
||||
)
|
||||
return Response(
|
||||
{"data": {"filePath": request.build_absolute_uri(obj.image.url)}},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
def dashboard_callback(request: HttpRequest, context: Context) -> Context:
|
||||
tf_map: dict[str, int] = {"7": 7, "30": 30, "90": 90, "360": 360}
|
||||
tf_param = str(request.GET.get("tf", "30") or "30")
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ class CategoryViewSet(SchonViewSet):
|
|||
category = self.get_object()
|
||||
|
||||
title = f"{category.name} | {settings.PROJECT_NAME}"
|
||||
description = (category.description or "")[:180]
|
||||
description = category.seo_description
|
||||
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/catalog/{category.slug}"
|
||||
og_image = (
|
||||
request.build_absolute_uri(category.image.url)
|
||||
|
|
@ -409,7 +409,7 @@ class BrandViewSet(SchonViewSet):
|
|||
brand = self.get_object()
|
||||
|
||||
title = f"{brand.name} | {settings.PROJECT_NAME}"
|
||||
description = (brand.description or "")[:180]
|
||||
description = brand.seo_description
|
||||
canonical = f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/brand/{brand.slug}"
|
||||
|
||||
logo_url = (
|
||||
|
|
@ -555,7 +555,7 @@ class ProductViewSet(SchonViewSet):
|
|||
images = list(p.images.all()[:6])
|
||||
rating = {"value": p.rating, "count": p.feedbacks_count}
|
||||
title = f"{p.name} | {settings.PROJECT_NAME}"
|
||||
description = (p.description or "")[:180]
|
||||
description = p.seo_description
|
||||
canonical = (
|
||||
f"https://{settings.BASE_DOMAIN}/{settings.LANGUAGE_CODE}/product/{p.slug}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from django.core.exceptions import PermissionDenied
|
|||
from django.db.models import Prefetch, QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework_simplejwt.token_blacklist.admin import (
|
||||
BlacklistedTokenAdmin as BaseBlacklistedTokenAdmin,
|
||||
|
|
@ -29,8 +28,8 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
|||
OutstandingToken as BaseOutstandingToken,
|
||||
)
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from unfold.contrib.forms.widgets import WysiwygWidget
|
||||
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
|
||||
from unfold_markdown import MarkdownWidget
|
||||
|
||||
from engine.core.admin import ActivationActionsMixin
|
||||
from engine.core.models import Order
|
||||
|
|
@ -39,7 +38,6 @@ from engine.vibes_auth.emailing.choices import CampaignStatus
|
|||
from engine.vibes_auth.emailing.models import (
|
||||
CampaignRecipient,
|
||||
EmailCampaign,
|
||||
EmailImage,
|
||||
EmailTemplate,
|
||||
)
|
||||
from engine.vibes_auth.emailing.tasks import (
|
||||
|
|
@ -196,38 +194,6 @@ class ChatMessageAdmin(admin.ModelAdmin):
|
|||
readonly_fields = ("created", "modified")
|
||||
|
||||
|
||||
@register(EmailImage)
|
||||
class EmailImageAdmin(ActivationActionsMixin, ModelAdmin):
|
||||
list_display = ("name", "image_preview", "alt_text", "is_active", "created")
|
||||
list_filter = ("is_active",)
|
||||
search_fields = ("name", "alt_text")
|
||||
readonly_fields = ("uuid", "created", "modified", "image_preview_large")
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "image", "alt_text")}),
|
||||
(_("Preview"), {"fields": ("image_preview_large",)}),
|
||||
(_("Metadata"), {"fields": ("uuid", "is_active", "created", "modified")}),
|
||||
)
|
||||
|
||||
@admin.display(description=_("preview"))
|
||||
def image_preview(self, obj: EmailImage) -> str:
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<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)
|
||||
class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
||||
list_display = ("name", "slug", "subject", "is_active", "modified")
|
||||
|
|
@ -238,7 +204,7 @@ class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
|||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("name", "slug", "subject")}),
|
||||
(_("Content"), {"fields": ("html_content", "plain_content")}),
|
||||
(_("Content"), {"fields": ("content", "plain_content")}),
|
||||
(
|
||||
_("Documentation"),
|
||||
{
|
||||
|
|
@ -250,8 +216,8 @@ class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
|||
)
|
||||
|
||||
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
||||
if db_field.name == "html_content":
|
||||
kwargs["widget"] = WysiwygWidget
|
||||
if db_field.name == "content":
|
||||
kwargs["widget"] = MarkdownWidget
|
||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
from engine.vibes_auth.emailing.models import (
|
||||
CampaignRecipient,
|
||||
EmailCampaign,
|
||||
EmailImage,
|
||||
EmailTemplate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EmailImage",
|
||||
"EmailTemplate",
|
||||
"EmailCampaign",
|
||||
"CampaignRecipient",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from django.db.models import (
|
|||
CharField,
|
||||
DateTimeField,
|
||||
ForeignKey,
|
||||
ImageField,
|
||||
Index,
|
||||
PositiveIntegerField,
|
||||
SlugField,
|
||||
|
|
@ -21,42 +20,10 @@ from engine.vibes_auth.emailing.choices import CampaignStatus, RecipientStatus
|
|||
|
||||
|
||||
def get_email_image_path(instance, filename: str) -> str:
|
||||
"""Kept for historic migrations that reference this callable."""
|
||||
return f"email_images/{instance.uuid}/{filename}"
|
||||
|
||||
|
||||
class EmailImage(NiceModel):
|
||||
"""
|
||||
Stores images that can be used in email templates.
|
||||
Images are uploaded to filesystem and referenced by URL in templates.
|
||||
"""
|
||||
|
||||
name = CharField(
|
||||
max_length=100,
|
||||
verbose_name=_("name"),
|
||||
help_text=_("descriptive name for the image"),
|
||||
)
|
||||
image = ImageField(
|
||||
upload_to=get_email_image_path,
|
||||
verbose_name=_("image"),
|
||||
help_text=_("image file to use in email templates"),
|
||||
)
|
||||
alt_text = CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("alt text"),
|
||||
help_text=_("alternative text for accessibility"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("email image")
|
||||
verbose_name_plural = _("email images")
|
||||
ordering = ("-created",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class EmailTemplate(NiceModel):
|
||||
"""
|
||||
Customizable email template stored in the database.
|
||||
|
|
@ -81,10 +48,10 @@ class EmailTemplate(NiceModel):
|
|||
verbose_name=_("subject"),
|
||||
help_text=_("email subject line - supports {{ variables }}"),
|
||||
)
|
||||
html_content = TextField(
|
||||
verbose_name=_("HTML content"),
|
||||
content = TextField(
|
||||
verbose_name=_("content"),
|
||||
help_text=_(
|
||||
"email body content - supports {{ user.first_name }}, "
|
||||
"email body in markdown - supports {{ user.first_name }}, "
|
||||
"{{ user.email }}, {{ project_name }}, {{ unsubscribe_url }}"
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from django.utils.html import strip_tags
|
|||
from django.utils.translation import activate
|
||||
|
||||
from engine.core.utils import get_dynamic_email_connection
|
||||
from engine.core.utils.markdown import render_markdown
|
||||
from engine.vibes_auth.emailing.choices import CampaignStatus, RecipientStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -230,7 +231,8 @@ def send_single_campaign_email(campaign, recipient, connection) -> None:
|
|||
|
||||
# Render subject and content
|
||||
subject = render_template_string(template.subject, context)
|
||||
html_content = render_template_string(template.html_content, context)
|
||||
rendered_content = render_template_string(template.content, context)
|
||||
html_content = render_markdown(rendered_content)
|
||||
|
||||
# Add unsubscribe footer to HTML
|
||||
html_content = add_unsubscribe_footer(html_content, unsubscribe_url)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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-storages==1.14.6",
|
||||
"django-unfold==0.81.0",
|
||||
"django-unfold-markdown==0.1.2",
|
||||
"django-debug-toolbar==6.2.0",
|
||||
"django-widget-tweaks==1.5.1",
|
||||
"djangorestframework==3.16.1",
|
||||
|
|
@ -51,6 +52,7 @@ dependencies = [
|
|||
"graphene-django==3.2.3",
|
||||
"graphene-file-upload==1.3.0",
|
||||
"httpx==0.28.1",
|
||||
"markdown==3.10.2",
|
||||
"opentelemetry-instrumentation-django==0.60b1",
|
||||
"paramiko==4.0.0",
|
||||
"pillow==12.1.1",
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ INSTALLED_APPS: list[str] = [
|
|||
"unfold.contrib.inlines",
|
||||
"unfold.contrib.constance",
|
||||
"unfold.contrib.import_export",
|
||||
"unfold_markdown",
|
||||
"constance",
|
||||
"modeltranslation",
|
||||
"django.contrib.admin",
|
||||
|
|
|
|||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "django-widget-tweaks"
|
||||
version = "1.5.1"
|
||||
|
|
@ -3351,6 +3364,7 @@ dependencies = [
|
|||
{ name = "django-redis" },
|
||||
{ name = "django-storages" },
|
||||
{ name = "django-unfold" },
|
||||
{ name = "django-unfold-markdown" },
|
||||
{ name = "django-widget-tweaks" },
|
||||
{ name = "djangoql" },
|
||||
{ name = "djangorestframework" },
|
||||
|
|
@ -3368,6 +3382,7 @@ dependencies = [
|
|||
{ name = "graphene-django" },
|
||||
{ name = "graphene-file-upload" },
|
||||
{ name = "httpx" },
|
||||
{ name = "markdown" },
|
||||
{ name = "opentelemetry-instrumentation-django" },
|
||||
{ name = "paramiko" },
|
||||
{ name = "pillow" },
|
||||
|
|
@ -3452,6 +3467,7 @@ requires-dist = [
|
|||
{ name = "django-storages", specifier = "==1.14.6" },
|
||||
{ name = "django-stubs", marker = "extra == 'linting'", specifier = "==5.2.9" },
|
||||
{ name = "django-unfold", specifier = "==0.81.0" },
|
||||
{ name = "django-unfold-markdown", specifier = "==0.1.2" },
|
||||
{ name = "django-widget-tweaks", specifier = "==1.5.1" },
|
||||
{ name = "djangoql", specifier = "==0.19.1" },
|
||||
{ name = "djangorestframework", specifier = "==3.16.1" },
|
||||
|
|
@ -3471,6 +3487,7 @@ requires-dist = [
|
|||
{ name = "graphene-file-upload", specifier = "==1.3.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "jupyter", marker = "extra == 'jupyter'", specifier = "==1.1.1" },
|
||||
{ name = "markdown", specifier = "==3.10.2" },
|
||||
{ name = "openai", marker = "extra == 'openai'", specifier = "==2.24.0" },
|
||||
{ name = "opentelemetry-instrumentation-django", specifier = "==0.60b1" },
|
||||
{ name = "paramiko", specifier = "==4.0.0" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue