Compare commits
2 commits
783fca1f28
...
7e31a80290
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e31a80290 | |||
| dc94841f40 |
14 changed files with 286 additions and 71 deletions
|
|
@ -1,5 +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 django.utils.html import format_html
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
from unfold_markdown import MarkdownWidget
|
from unfold_markdown import MarkdownWidget
|
||||||
|
|
||||||
|
|
@ -21,18 +23,31 @@ class PostAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin):
|
||||||
"slug",
|
"slug",
|
||||||
"modified",
|
"modified",
|
||||||
"created",
|
"created",
|
||||||
|
"blogpic_preview",
|
||||||
)
|
)
|
||||||
|
|
||||||
general_fields = [
|
general_fields = [
|
||||||
"title",
|
"title",
|
||||||
"content",
|
"content",
|
||||||
"file",
|
"file",
|
||||||
|
"blogpic",
|
||||||
|
"blogpic_preview",
|
||||||
]
|
]
|
||||||
relation_fields = [
|
relation_fields = [
|
||||||
"author",
|
"author",
|
||||||
"tags",
|
"tags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def blogpic_preview(self, obj: Post):
|
||||||
|
if obj.blogpic:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="max-width:400px;max-height:300px;object-fit:contain">',
|
||||||
|
obj.blogpic.url,
|
||||||
|
)
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
blogpic_preview.short_description = _("picture preview") # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
|
|
||||||
@register(PostTag)
|
@register(PostTag)
|
||||||
class PostTagAdmin(ModelAdmin):
|
class PostTagAdmin(ModelAdmin):
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,19 @@ from engine.core.utils.markdown import render_markdown
|
||||||
class PostType(DjangoObjectType):
|
class PostType(DjangoObjectType):
|
||||||
tags = List(lambda: PostTagType)
|
tags = List(lambda: PostTagType)
|
||||||
content = String()
|
content = String()
|
||||||
|
blogpic = String()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = ["tags", "content", "title", "slug"]
|
fields = ["tags", "content", "title", "slug", "blogpic"]
|
||||||
interfaces = (relay.Node,)
|
interfaces = (relay.Node,)
|
||||||
|
|
||||||
def resolve_content(self: Post, _info: HttpRequest) -> str:
|
def resolve_content(self: Post, _info: HttpRequest) -> str:
|
||||||
return render_markdown(self.content or "")
|
return render_markdown(self.content or "")
|
||||||
|
|
||||||
|
def resolve_blogpic(self: Post, info: HttpRequest) -> str:
|
||||||
|
return info.build_absolute_uri(self.blogpic.url) if self.blogpic else ""
|
||||||
|
|
||||||
|
|
||||||
class PostTagType(DjangoObjectType):
|
class PostTagType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
17
engine/blog/migrations/0010_post_blogpic.py
Normal file
17
engine/blog/migrations/0010_post_blogpic.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 5.2.11 on 2026-03-01 22:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("blog", "0009_alter_post_slug"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="post",
|
||||||
|
name="blogpic",
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to="posts/images/"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -5,6 +5,7 @@ from django.db.models import (
|
||||||
CharField,
|
CharField,
|
||||||
FileField,
|
FileField,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
|
ImageField,
|
||||||
ManyToManyField,
|
ManyToManyField,
|
||||||
TextField,
|
TextField,
|
||||||
)
|
)
|
||||||
|
|
@ -49,6 +50,7 @@ class Post(NiceModel):
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
file = FileField(upload_to="posts/", blank=True, null=True)
|
file = FileField(upload_to="posts/", blank=True, null=True)
|
||||||
|
blogpic = ImageField(upload_to="posts/images/", blank=True, null=True)
|
||||||
slug = TweakedAutoSlugField(
|
slug = TweakedAutoSlugField(
|
||||||
populate_from="title",
|
populate_from="title",
|
||||||
slugify_function=unicode_slugify_function,
|
slugify_function=unicode_slugify_function,
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,7 @@ class ProductAdmin(
|
||||||
"uuid",
|
"uuid",
|
||||||
"modified",
|
"modified",
|
||||||
"created",
|
"created",
|
||||||
|
"video_preview",
|
||||||
)
|
)
|
||||||
autocomplete_fields = (
|
autocomplete_fields = (
|
||||||
"category",
|
"category",
|
||||||
|
|
@ -527,8 +528,22 @@ class ProductAdmin(
|
||||||
]
|
]
|
||||||
additional_fields = [
|
additional_fields = [
|
||||||
"is_updatable",
|
"is_updatable",
|
||||||
|
"video",
|
||||||
|
"video_preview",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def video_preview(self, obj: Product):
|
||||||
|
if obj.video:
|
||||||
|
return format_html(
|
||||||
|
'<video controls style="max-width:100%;max-height:360px">'
|
||||||
|
'<source src="{}">'
|
||||||
|
"</video>",
|
||||||
|
obj.video.url,
|
||||||
|
)
|
||||||
|
return "—"
|
||||||
|
|
||||||
|
video_preview.short_description = _("video preview") # ty:ignore[unresolved-attribute]
|
||||||
|
|
||||||
def has_images(self, obj: Product) -> bool:
|
def has_images(self, obj: Product) -> bool:
|
||||||
return obj.has_images
|
return obj.has_images
|
||||||
|
|
||||||
|
|
@ -924,8 +939,8 @@ class StockAdmin(
|
||||||
"is_active",
|
"is_active",
|
||||||
"sku",
|
"sku",
|
||||||
"quantity",
|
"quantity",
|
||||||
"price",
|
|
||||||
"purchase_price",
|
"purchase_price",
|
||||||
|
"price",
|
||||||
"digital_asset",
|
"digital_asset",
|
||||||
]
|
]
|
||||||
additional_fields = [
|
additional_fields = [
|
||||||
|
|
|
||||||
|
|
@ -530,6 +530,7 @@ class ProductType(DjangoObjectType):
|
||||||
images = DjangoFilterConnectionField(ProductImageType, description=_("images"))
|
images = DjangoFilterConnectionField(ProductImageType, description=_("images"))
|
||||||
feedbacks = DjangoFilterConnectionField(FeedbackType, description=_("feedbacks"))
|
feedbacks = DjangoFilterConnectionField(FeedbackType, description=_("feedbacks"))
|
||||||
brand = Field(BrandType, description=_("brand"))
|
brand = Field(BrandType, description=_("brand"))
|
||||||
|
video = String(description=_("video url"))
|
||||||
attribute_groups = DjangoFilterConnectionField(
|
attribute_groups = DjangoFilterConnectionField(
|
||||||
AttributeGroupType, description=_("attribute groups")
|
AttributeGroupType, description=_("attribute groups")
|
||||||
)
|
)
|
||||||
|
|
@ -555,6 +556,7 @@ class ProductType(DjangoObjectType):
|
||||||
"name",
|
"name",
|
||||||
"slug",
|
"slug",
|
||||||
"description",
|
"description",
|
||||||
|
"video",
|
||||||
"feedbacks",
|
"feedbacks",
|
||||||
"feedbacks_count",
|
"feedbacks_count",
|
||||||
"personal_orders_only",
|
"personal_orders_only",
|
||||||
|
|
@ -648,6 +650,9 @@ class ProductType(DjangoObjectType):
|
||||||
"hreflang": info.context.LANGUAGE_CODE,
|
"hreflang": info.context.LANGUAGE_CODE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def resolve_video(self: Product, info) -> str:
|
||||||
|
return info.context.build_absolute_uri(self.video.url) if self.video else ""
|
||||||
|
|
||||||
def resolve_discount_price(self: Product, _info) -> float | None:
|
def resolve_discount_price(self: Product, _info) -> float | None:
|
||||||
return self.discount_price
|
return self.discount_price
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Generated by Django 5.2.11 on 2026-03-01 22:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import engine.core.validators
|
||||||
|
import schon.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0057_encrypt_address_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="product",
|
||||||
|
name="video",
|
||||||
|
field=models.FileField(
|
||||||
|
blank=True,
|
||||||
|
help_text="optional video file for this product",
|
||||||
|
null=True,
|
||||||
|
upload_to="products/videos/",
|
||||||
|
validators=[engine.core.validators.validate_browser_video],
|
||||||
|
verbose_name="product video",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="api_response",
|
||||||
|
field=schon.fields.EncryptedJSONTextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="stored JSON response from the geocoding service",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="address",
|
||||||
|
name="raw_data",
|
||||||
|
field=schon.fields.EncryptedJSONTextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="full JSON response from geocoder for this address",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -71,7 +71,10 @@ 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.utils.markdown import strip_markdown
|
||||||
from engine.core.validators import validate_category_image_dimensions
|
from engine.core.validators import (
|
||||||
|
validate_browser_video,
|
||||||
|
validate_category_image_dimensions,
|
||||||
|
)
|
||||||
from engine.payments.models import Transaction
|
from engine.payments.models import Transaction
|
||||||
from schon.fields import EncryptedJSONTextField
|
from schon.fields import EncryptedJSONTextField
|
||||||
from schon.utils.misc import create_object
|
from schon.utils.misc import create_object
|
||||||
|
|
@ -701,6 +704,14 @@ class Product(NiceModel):
|
||||||
help_text=_("add a detailed description of the product"),
|
help_text=_("add a detailed description of the product"),
|
||||||
verbose_name=_("product description"),
|
verbose_name=_("product description"),
|
||||||
)
|
)
|
||||||
|
video = FileField(
|
||||||
|
upload_to="products/videos/",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
help_text=_("optional video file for this product"),
|
||||||
|
verbose_name=_("product video"),
|
||||||
|
validators=[validate_browser_video],
|
||||||
|
)
|
||||||
partnumber = CharField(
|
partnumber = CharField(
|
||||||
unique=True,
|
unique=True,
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,7 @@ class ProductDetailSerializer(ModelSerializer):
|
||||||
"sku",
|
"sku",
|
||||||
"name",
|
"name",
|
||||||
"description",
|
"description",
|
||||||
|
"video",
|
||||||
"partnumber",
|
"partnumber",
|
||||||
"is_digital",
|
"is_digital",
|
||||||
"brand",
|
"brand",
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ class ProductSimpleSerializer(ModelSerializer):
|
||||||
"is_digital",
|
"is_digital",
|
||||||
"slug",
|
"slug",
|
||||||
"description",
|
"description",
|
||||||
|
"video",
|
||||||
"partnumber",
|
"partnumber",
|
||||||
"brand",
|
"brand",
|
||||||
"feedbacks_count",
|
"feedbacks_count",
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,112 @@
|
||||||
{% load static i18n %}
|
{% load i18n %}
|
||||||
<table id="table-{{ widget.attrs.id }}">
|
{% with input_cls="border border-base-200 bg-white font-medium placeholder-base-400 rounded-default shadow-xs text-font-default-light text-sm focus:outline-2 focus:-outline-offset-2 focus:outline-primary-600 dark:bg-base-900 dark:border-base-700 dark:text-font-default-dark dark:scheme-dark px-3 py-2 w-full" %}
|
||||||
<thead>
|
<div class="max-w-4xl">
|
||||||
<tr>
|
<table class="w-full" id="table-{{ widget.attrs.id }}">
|
||||||
<th>{% blocktrans %}key{% endblocktrans %}</th>
|
<thead>
|
||||||
<th>{% blocktrans %}value{% endblocktrans %}</th>
|
<tr class="border-b border-base-200 dark:border-base-700">
|
||||||
</tr>
|
<th class="text-left text-xs font-semibold uppercase tracking-wide text-font-important-light dark:text-font-important-dark pb-2 pr-4 w-5/12">{% trans "Key" %}</th>
|
||||||
</thead>
|
<th class="text-left text-xs font-semibold uppercase tracking-wide text-font-important-light dark:text-font-important-dark pb-2 pr-4 w-5/12">{% trans "Value" %}</th>
|
||||||
<tbody id="json-fields-{{ widget.attrs.id }}">
|
<th class="pb-2 w-2/12"></th>
|
||||||
{% for key, value in widget.value.items %}
|
</tr>
|
||||||
<tr data-row-index="{{ forloop.counter }}">
|
</thead>
|
||||||
<td><label>
|
<tbody id="json-fields-{{ widget.attrs.id }}">
|
||||||
<input type="text" name="{{ widget.name }}_key" value="{{ key }}">
|
{% for key, val in widget.value.items %}
|
||||||
</label></td>
|
<tr class="border-b border-base-200 dark:border-base-700 group/row">
|
||||||
<td>
|
<td class="py-2 pr-3">
|
||||||
{% if value is list %}
|
<input type="text"
|
||||||
<label>
|
name="{{ widget.name }}_key"
|
||||||
<input type="text" name="{{ widget.name }}_value" value="{{ value|join:', ' }}">
|
value="{{ key }}"
|
||||||
</label>
|
placeholder="{% trans "key" %}"
|
||||||
{% else %}
|
class="{{ input_cls }}">
|
||||||
<label>
|
</td>
|
||||||
<input type="text" name="{{ widget.name }}_value" value="{{ value }}">
|
<td class="py-2 pr-3">
|
||||||
</label>
|
<input type="text"
|
||||||
{% endif %}
|
name="{{ widget.name }}_value"
|
||||||
</td>
|
value="{% if val is list %}{{ val|join:', ' }}{% else %}{{ val }}{% endif %}"
|
||||||
</tr>
|
placeholder="{% trans "value" %}"
|
||||||
{% endfor %}
|
class="{{ input_cls }}">
|
||||||
<tr data-row-index="{{ widget.value.items|length|default:0|add:1 }}">
|
</td>
|
||||||
<td><label>
|
<td class="py-2">
|
||||||
<input type="text" name="{{ widget.name }}_key">
|
<button type="button"
|
||||||
</label></td>
|
class="delete-row-btn border border-transparent cursor-pointer font-medium px-2 py-2 rounded-default text-center bg-red-600 text-white text-sm leading-none opacity-60 group-hover/row:opacity-100 transition-opacity"
|
||||||
<td><label>
|
title="{% trans "Delete row" %}">✕</button>
|
||||||
<input type="text" name="{{ widget.name }}_value">
|
</td>
|
||||||
</label></td>
|
</tr>
|
||||||
</tr>
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="mt-3">
|
||||||
<button type="button" class="add-row-button" data-table-id="json-fields-{{ widget.attrs.id }}">
|
<button type="button"
|
||||||
{% blocktrans %}Add Row{% endblocktrans %}
|
class="add-row-btn border border-transparent cursor-pointer font-medium px-3 py-2 rounded-default text-center whitespace-nowrap bg-primary-600 text-white text-sm"
|
||||||
</button>
|
data-tbody="json-fields-{{ widget.attrs.id }}"
|
||||||
|
data-name="{{ widget.name }}"
|
||||||
|
data-input-cls="{{ input_cls }}">
|
||||||
|
+ {% trans "Add Row" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
(function () {
|
||||||
|
function wireDeleteBtn(btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
btn.closest("tr").remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(name, inputCls) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "border-b border-base-200 dark:border-base-700 group/row";
|
||||||
|
|
||||||
|
const mkTd = (paddingCls) => {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.className = paddingCls;
|
||||||
|
return td;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mkInput = (fieldName, placeholder) => {
|
||||||
|
const inp = document.createElement("input");
|
||||||
|
inp.type = "text";
|
||||||
|
inp.name = fieldName;
|
||||||
|
inp.placeholder = placeholder;
|
||||||
|
inp.className = inputCls;
|
||||||
|
return inp;
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyTd = mkTd("py-2 pr-3");
|
||||||
|
keyTd.appendChild(mkInput(name + "_key", "key"));
|
||||||
|
|
||||||
|
const valTd = mkTd("py-2 pr-3");
|
||||||
|
valTd.appendChild(mkInput(name + "_value", "value"));
|
||||||
|
|
||||||
|
const delTd = mkTd("py-2");
|
||||||
|
const delBtn = document.createElement("button");
|
||||||
|
delBtn.type = "button";
|
||||||
|
delBtn.className = "delete-row-btn border border-transparent cursor-pointer font-medium px-2 py-2 rounded-default text-center bg-red-600 text-white text-sm leading-none";
|
||||||
|
delBtn.title = "Delete row";
|
||||||
|
delBtn.textContent = "\u2715";
|
||||||
|
wireDeleteBtn(delBtn);
|
||||||
|
delTd.appendChild(delBtn);
|
||||||
|
|
||||||
|
tr.appendChild(keyTd);
|
||||||
|
tr.appendChild(valTd);
|
||||||
|
tr.appendChild(delTd);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
document.querySelectorAll(".add-row-button").forEach(function (button) {
|
// Wire existing delete buttons
|
||||||
button.removeEventListener("click", addRow);
|
document.querySelectorAll(".delete-row-btn").forEach(wireDeleteBtn);
|
||||||
button.addEventListener("click", addRow);
|
|
||||||
|
// Wire add-row buttons
|
||||||
|
document.querySelectorAll(".add-row-btn").forEach(function (btn) {
|
||||||
|
const tbody = document.getElementById(btn.dataset.tbody);
|
||||||
|
if (!tbody) return;
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
tbody.appendChild(buildRow(btn.dataset.name, btn.dataset.inputCls));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}());
|
||||||
function addRow(event) {
|
|
||||||
let tableId = event.target.getAttribute("data-table-id");
|
|
||||||
let table = document.getElementById(tableId);
|
|
||||||
|
|
||||||
if (table) {
|
|
||||||
let lastRow = table.querySelector("tr:last-child");
|
|
||||||
let rowIndex = (parseInt(lastRow.getAttribute("data-row-index"), 10) + 1).toString();
|
|
||||||
|
|
||||||
let row = table.insertRow();
|
|
||||||
row.setAttribute("data-row-index", rowIndex);
|
|
||||||
|
|
||||||
let keyCell = row.insertCell(0);
|
|
||||||
let valueCell = row.insertCell(1);
|
|
||||||
|
|
||||||
let namePrefix = tableId.replace("json-fields-", "");
|
|
||||||
|
|
||||||
keyCell.innerHTML = `<input type="text" name="${namePrefix}_key_${rowIndex}">`;
|
|
||||||
valueCell.innerHTML = `<input type="text" name="${namePrefix}_value_${rowIndex}">`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,29 @@
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
import filetype
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.images import ImageFile, get_image_dimensions
|
from django.core.files.images import ImageFile, get_image_dimensions
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
_BROWSER_VIDEO_MIMES = {"video/mp4", "video/webm", "video/ogg"}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_browser_video(file) -> None:
|
||||||
|
if not file:
|
||||||
|
return
|
||||||
|
kind = None
|
||||||
|
with suppress(Exception):
|
||||||
|
kind = filetype.guess(file)
|
||||||
|
if not kind:
|
||||||
|
raise ValidationError(_("could not determine file type"))
|
||||||
|
file.seek(0)
|
||||||
|
|
||||||
|
if kind is None or kind.mime not in _BROWSER_VIDEO_MIMES:
|
||||||
|
supported = ", ".join(sorted(_BROWSER_VIDEO_MIMES))
|
||||||
|
raise ValidationError(
|
||||||
|
_(f"unsupported video format. supported formats: {supported}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_category_image_dimensions(
|
def validate_category_image_dimensions(
|
||||||
image: ImageFile, max_width: int | None = None, max_height: int | None = None
|
image: ImageFile, max_width: int | None = None, max_height: int | None = None
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,14 @@ class JSONTableWidget(forms.Widget):
|
||||||
try:
|
try:
|
||||||
keys = data.getlist(f"{name}_key") # ty: ignore[unresolved-attribute]
|
keys = data.getlist(f"{name}_key") # ty: ignore[unresolved-attribute]
|
||||||
values = data.getlist(f"{name}_value") # ty: ignore[unresolved-attribute]
|
values = data.getlist(f"{name}_value") # ty: ignore[unresolved-attribute]
|
||||||
for key, value in zip(keys, values, strict=True):
|
for key, value in zip(keys, values, strict=False):
|
||||||
if key.strip():
|
key = key.strip()
|
||||||
|
if key:
|
||||||
try:
|
try:
|
||||||
json_data[key] = json.loads(value)
|
json_data[key] = json.loads(value)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
json_data[key] = value
|
json_data[key] = value
|
||||||
except TypeError:
|
except (TypeError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None if not json_data else json.dumps(json_data)
|
return None if not json_data else json.dumps(json_data)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 5.2.11 on 2026-03-01 22:48
|
||||||
|
|
||||||
|
import encrypted_fields.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
import engine.vibes_auth.validators
|
||||||
|
import schon.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("vibes_auth", "0010_encrypt_user_pii_and_token_expiry"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="attributes",
|
||||||
|
field=schon.fields.EncryptedJSONTextField(
|
||||||
|
blank=True, default=dict, null=True, verbose_name="attributes"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="phone_number",
|
||||||
|
field=encrypted_fields.fields.EncryptedCharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="user phone number",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
validators=[engine.vibes_auth.validators.validate_phone_number],
|
||||||
|
verbose_name="phone_number",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
Loading…
Reference in a new issue