Merge pull request #34801 from alexleach/html_emails
feat: Raw Html Emails
This commit is contained in:
commit
93e2b64c0b
9 changed files with 191 additions and 56 deletions
|
|
@ -50,6 +50,8 @@ def make(
|
|||
send_after=None,
|
||||
print_language=None,
|
||||
now=False,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
**kwargs,
|
||||
) -> dict[str, str]:
|
||||
"""Make a new communication. Checks for email permissions for specified Document.
|
||||
|
|
@ -69,10 +71,12 @@ def make(
|
|||
:param send_me_a_copy: Send a copy to the sender (default **False**).
|
||||
:param email_template: Template which is used to compose mail .
|
||||
:param send_after: Send after the given datetime.
|
||||
:param raw_html: Whether to use html version of email template
|
||||
:param add_css: Add default CSS from hooks/email_css to the email template (default **True**)
|
||||
"""
|
||||
if kwargs:
|
||||
from frappe.utils.commands import warn
|
||||
from frappe.utils.commands import warn
|
||||
|
||||
if kwargs:
|
||||
warn(
|
||||
f"Options {kwargs} used in frappe.core.doctype.communication.email.make "
|
||||
"are deprecated or unsupported",
|
||||
|
|
@ -82,6 +86,20 @@ def make(
|
|||
if doctype and name:
|
||||
frappe.has_permission(doctype, doc=name, ptype="email", throw=True)
|
||||
|
||||
if (
|
||||
raw_html
|
||||
and email_template
|
||||
and not frappe.get_cached_value("Email Template", email_template, "use_html")
|
||||
):
|
||||
warn(
|
||||
_(
|
||||
"Raw HTML can be used only with Email Templates having 'Use HTML' checked. "
|
||||
"Proceeding with plain text email."
|
||||
),
|
||||
category=UserWarning,
|
||||
)
|
||||
raw_html = False
|
||||
|
||||
return _make(
|
||||
doctype=doctype,
|
||||
name=name,
|
||||
|
|
@ -107,6 +125,8 @@ def make(
|
|||
send_after=send_after,
|
||||
print_language=print_language,
|
||||
now=now,
|
||||
raw_html=raw_html,
|
||||
add_css=add_css,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -135,6 +155,8 @@ def _make(
|
|||
send_after=None,
|
||||
print_language=None,
|
||||
now=False,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
) -> dict[str, str]:
|
||||
"""Internal method to make a new communication that ignores Permission checks."""
|
||||
|
||||
|
|
@ -165,7 +187,9 @@ def _make(
|
|||
"send_after": send_after,
|
||||
}
|
||||
)
|
||||
comm.flags.skip_add_signature = not add_signature
|
||||
comm.flags.skip_add_signature = not add_signature or (
|
||||
raw_html and frappe.get_cached_value("Email Template", email_template, "use_html")
|
||||
)
|
||||
comm.insert(ignore_permissions=True)
|
||||
|
||||
# if not committed, delayed task doesn't find the communication
|
||||
|
|
@ -190,6 +214,8 @@ def _make(
|
|||
print_letterhead=print_letterhead,
|
||||
print_language=print_language,
|
||||
now=now,
|
||||
raw_html=raw_html,
|
||||
add_css=add_css,
|
||||
)
|
||||
|
||||
emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy)
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ class CommunicationEmailMixin:
|
|||
print_letterhead=None,
|
||||
is_inbound_mail_communcation=None,
|
||||
print_language=None,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
) -> dict:
|
||||
outgoing_email_account = self.get_outgoing_email_account()
|
||||
if not outgoing_email_account:
|
||||
|
|
@ -307,6 +309,8 @@ class CommunicationEmailMixin:
|
|||
"is_notification": (self.sent_or_received == "Received"),
|
||||
"print_letterhead": print_letterhead,
|
||||
"send_after": self.send_after,
|
||||
"raw_html": raw_html,
|
||||
"add_css": add_css,
|
||||
}
|
||||
|
||||
def send_email(
|
||||
|
|
@ -318,6 +322,8 @@ class CommunicationEmailMixin:
|
|||
is_inbound_mail_communcation=None,
|
||||
print_language=None,
|
||||
now=False,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
):
|
||||
if input_dict := self.sendmail_input_dict(
|
||||
print_html=print_html,
|
||||
|
|
@ -326,5 +332,7 @@ class CommunicationEmailMixin:
|
|||
print_letterhead=print_letterhead,
|
||||
is_inbound_mail_communcation=is_inbound_mail_communcation,
|
||||
print_language=print_language,
|
||||
raw_html=raw_html,
|
||||
add_css=add_css,
|
||||
):
|
||||
frappe.sendmail(now=now, **input_dict)
|
||||
|
|
|
|||
|
|
@ -557,7 +557,7 @@ class User(Document):
|
|||
if custom_template:
|
||||
from frappe.email.doctype.email_template.email_template import get_email_template
|
||||
|
||||
email_template = get_email_template(custom_template, args)
|
||||
email_template = get_email_template(custom_template, args, sender=sender)
|
||||
subject = email_template.get("subject")
|
||||
content = email_template.get("message")
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ def sendmail(
|
|||
email_read_tracker_url=None,
|
||||
x_priority: Literal[1, 3, 5] = 3,
|
||||
email_headers=None,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
) -> EmailQueue | None:
|
||||
"""Send email using user's default **Email Account** or global default **Email Account**.
|
||||
|
||||
|
|
@ -179,6 +181,8 @@ def sendmail(
|
|||
:param with_container: Wraps email inside a styled container
|
||||
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
|
||||
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
|
||||
:param raw_html: Whether to treat email template as a complete HTML file
|
||||
:param add_css: Whether to add CSS from hooks/email_css to the email template
|
||||
"""
|
||||
|
||||
from frappe.utils.jinja import get_email_from_template
|
||||
|
|
@ -238,6 +242,8 @@ def sendmail(
|
|||
email_read_tracker_url=email_read_tracker_url,
|
||||
x_priority=x_priority,
|
||||
email_headers=email_headers,
|
||||
raw_html=raw_html,
|
||||
add_css=add_css,
|
||||
)
|
||||
|
||||
# build email queue and send the email if send_now is True.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
"expose_recipients",
|
||||
"attachments",
|
||||
"retry",
|
||||
"email_account"
|
||||
"email_account",
|
||||
"raw_html"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -148,13 +149,21 @@
|
|||
"fieldtype": "Code",
|
||||
"label": "Unsubscribe Params",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Raw HTML emails are rendered as complete Jinja templates. Otherwise, emails are wrapped in the standard.html email template, which inserts brand_logo, header and footer.",
|
||||
"fieldname": "raw_html",
|
||||
"fieldtype": "Check",
|
||||
"label": "Send As Raw HTML",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-envelope",
|
||||
"idx": 1,
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-07 15:56:13.341958",
|
||||
"modified": "2026-01-06 05:45:35.503215",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Email Queue",
|
||||
|
|
@ -175,4 +184,4 @@
|
|||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class EmailQueue(Document):
|
|||
message: DF.Code | None
|
||||
message_id: DF.SmallText | None
|
||||
priority: DF.Int
|
||||
raw_html: DF.Check
|
||||
recipients: DF.Table[EmailQueueRecipient]
|
||||
reference_doctype: DF.Link | None
|
||||
reference_name: DF.Data | None
|
||||
|
|
@ -518,6 +519,8 @@ class QueueBuilder:
|
|||
email_read_tracker_url=None,
|
||||
x_priority: Literal[1, 3, 5] = 3,
|
||||
email_headers=None,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
):
|
||||
"""Add email to sending queue (Email Queue)
|
||||
|
||||
|
|
@ -545,6 +548,8 @@ class QueueBuilder:
|
|||
:param email_read_tracker_url: A URL for tracking whether an email is read by the recipient.
|
||||
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
|
||||
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
|
||||
:param raw_html: Whether to treat email template as a complete HTML file
|
||||
:param add_css: Add default CSS from hooks/email_css to the email template (default True)
|
||||
"""
|
||||
|
||||
self._unsubscribe_method = unsubscribe_method
|
||||
|
|
@ -582,6 +587,8 @@ class QueueBuilder:
|
|||
self.print_letterhead = print_letterhead
|
||||
self.email_read_tracker_url = email_read_tracker_url
|
||||
self.email_headers = email_headers
|
||||
self.raw_html = raw_html
|
||||
self.add_css = add_css
|
||||
|
||||
@property
|
||||
def unsubscribe_method(self):
|
||||
|
|
@ -638,6 +645,8 @@ class QueueBuilder:
|
|||
email_account=email_account,
|
||||
unsubscribe_link=self.unsubscribe_message(),
|
||||
with_container=self.with_container,
|
||||
raw_html=self.raw_html,
|
||||
add_css=self.add_css,
|
||||
)
|
||||
|
||||
def should_include_unsubscribe_link(self):
|
||||
|
|
@ -843,6 +852,8 @@ class QueueBuilder:
|
|||
"show_as_bcc": ",".join(self.final_bcc()),
|
||||
"email_account": email_account_name or None,
|
||||
"email_read_tracker_url": self.email_read_tracker_url,
|
||||
"raw_html": self.raw_html,
|
||||
"add_css": self.add_css,
|
||||
}
|
||||
|
||||
if include_recipients:
|
||||
|
|
|
|||
|
|
@ -37,19 +37,37 @@ class EmailTemplate(Document):
|
|||
def get_formatted_response(self, doc):
|
||||
return frappe.render_template(self.response_, doc)
|
||||
|
||||
def get_formatted_email(self, doc):
|
||||
def get_formatted_email(self, doc, sender=None):
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
if self.use_html:
|
||||
doc = self.inject_email_account(doc, sender)
|
||||
|
||||
return {
|
||||
"subject": self.get_formatted_subject(doc),
|
||||
"message": self.get_formatted_response(doc),
|
||||
}
|
||||
|
||||
def inject_email_account(self, doc, sender=None):
|
||||
from frappe.email.doctype.email_account.email_account import EmailAccount
|
||||
from frappe.email.email_body import get_footer, get_signature
|
||||
|
||||
if sender:
|
||||
kwargs = {"match_by_email": sender}
|
||||
else:
|
||||
kwargs = {"match_by_doctype": doc.get("doctype")}
|
||||
|
||||
if email_account := EmailAccount.find_outgoing(**kwargs):
|
||||
doc.update(
|
||||
{"email_signature": get_signature(email_account), "email_footer": get_footer(email_account)}
|
||||
)
|
||||
return doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_email_template(template_name, doc):
|
||||
def get_email_template(template_name, doc, sender=None):
|
||||
"""Return the processed HTML of a email template with the given doc"""
|
||||
|
||||
email_template = frappe.get_doc("Email Template", template_name)
|
||||
return email_template.get_formatted_email(doc)
|
||||
return email_template.get_formatted_email(doc, sender=sender)
|
||||
|
|
|
|||
|
|
@ -381,29 +381,38 @@ def get_formatted_html(
|
|||
unsubscribe_link: frappe._dict | None = None,
|
||||
sender=None,
|
||||
with_container=False,
|
||||
raw_html=False,
|
||||
add_css=True,
|
||||
):
|
||||
email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)
|
||||
|
||||
rendered_email = frappe.get_template("templates/emails/standard.html").render(
|
||||
{
|
||||
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
|
||||
"with_container": with_container,
|
||||
"site_url": get_url(),
|
||||
"header": get_header(header),
|
||||
"content": message,
|
||||
"footer": get_footer(email_account, footer),
|
||||
"title": subject,
|
||||
"print_html": print_html,
|
||||
"subject": subject,
|
||||
}
|
||||
)
|
||||
params = {
|
||||
"site_url": get_url(),
|
||||
"title": subject,
|
||||
"print_html": print_html,
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
if raw_html:
|
||||
rendered_email = frappe.render_template(message, params)
|
||||
else:
|
||||
params.update(
|
||||
{
|
||||
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
|
||||
"with_container": with_container,
|
||||
"header": get_header(header),
|
||||
"content": message,
|
||||
"footer": get_footer(email_account, footer),
|
||||
}
|
||||
)
|
||||
rendered_email = frappe.get_template("templates/emails/standard.html").render(params)
|
||||
|
||||
html = scrub_urls(rendered_email)
|
||||
|
||||
if unsubscribe_link:
|
||||
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)
|
||||
|
||||
return inline_style_in_html(html)
|
||||
return inline_style_in_html(html, add_css=add_css)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -418,17 +427,20 @@ def get_email_html(template, args, subject, header=None, with_container=False):
|
|||
return get_formatted_html(subject, email[0], header=header, with_container=with_container)
|
||||
|
||||
|
||||
def inline_style_in_html(html):
|
||||
def inline_style_in_html(html, add_css=True):
|
||||
"""Convert email.css and html to inline-styled html."""
|
||||
from premailer import Premailer
|
||||
|
||||
from frappe.utils.jinja_globals import bundled_asset
|
||||
|
||||
# get email css files from hooks
|
||||
css_files = frappe.get_hooks("email_css")
|
||||
css_files = [bundled_asset(path) for path in css_files]
|
||||
css_files = [path.lstrip("/") for path in css_files]
|
||||
css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))]
|
||||
if add_css:
|
||||
# get email css files from hooks
|
||||
css_files = frappe.get_hooks("email_css")
|
||||
css_files = [bundled_asset(path) for path in css_files]
|
||||
css_files = [path.lstrip("/") for path in css_files]
|
||||
css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))]
|
||||
else:
|
||||
css_files = None
|
||||
|
||||
p = Premailer(
|
||||
html=html, external_styles=css_files, strip_important=False, allow_loading_external_files=True
|
||||
|
|
|
|||
|
|
@ -89,15 +89,6 @@ frappe.views.CommunicationComposer = class {
|
|||
fieldtype: "Datetime",
|
||||
fieldname: "send_after",
|
||||
},
|
||||
{
|
||||
label: __("Use HTML"),
|
||||
fieldtype: "Check",
|
||||
fieldname: "use_html",
|
||||
default: 0,
|
||||
onchange: () => {
|
||||
me.on_use_html_toggle();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "Section Break",
|
||||
fieldname: "email_template_section_break",
|
||||
|
|
@ -108,12 +99,30 @@ frappe.views.CommunicationComposer = class {
|
|||
fieldtype: "Link",
|
||||
options: "Email Template",
|
||||
fieldname: "email_template",
|
||||
onchange: async function () {
|
||||
const email_template = this.value;
|
||||
if (!email_template) {
|
||||
return me.hide_use_html_field();
|
||||
}
|
||||
await me.check_email_template_html(email_template);
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldtype: "HTML",
|
||||
label: __("Clear & Add template"),
|
||||
fieldname: "clear_and_add_template",
|
||||
},
|
||||
{
|
||||
label: __("Use HTML"),
|
||||
fieldtype: "Check",
|
||||
fieldname: "use_html",
|
||||
default: 0,
|
||||
hidden: 1,
|
||||
description: "Use Raw HTML email editor.",
|
||||
onchange: (event) => {
|
||||
me.on_use_html_toggle(event);
|
||||
},
|
||||
},
|
||||
{ fieldtype: "Section Break" },
|
||||
{
|
||||
label: __("Subject"),
|
||||
|
|
@ -127,13 +136,15 @@ frappe.views.CommunicationComposer = class {
|
|||
fieldtype: "Text Editor",
|
||||
fieldname: "content",
|
||||
onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300),
|
||||
depends_on: "eval:!doc.use_html",
|
||||
},
|
||||
{
|
||||
label: __("Message"),
|
||||
fieldtype: "HTML Editor",
|
||||
fieldname: "content_html",
|
||||
hidden: 1,
|
||||
label: __("HTML Message"),
|
||||
fieldtype: "Code",
|
||||
fieldname: "html_content",
|
||||
onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300),
|
||||
depends_on: "eval:doc.use_html",
|
||||
options: "HTML",
|
||||
},
|
||||
{
|
||||
fieldtype: "Button",
|
||||
|
|
@ -180,6 +191,13 @@ frappe.views.CommunicationComposer = class {
|
|||
depends_on: "attach_document_print",
|
||||
},
|
||||
{ fieldtype: "Column Break" },
|
||||
{
|
||||
label: __("Add CSS"),
|
||||
fieldtype: "Check",
|
||||
fieldname: "add_css",
|
||||
default: 1,
|
||||
depends_on: "eval:doc.use_html",
|
||||
},
|
||||
{
|
||||
label: __("Select Attachments"),
|
||||
fieldtype: "HTML",
|
||||
|
|
@ -221,6 +239,14 @@ frappe.views.CommunicationComposer = class {
|
|||
return fields;
|
||||
}
|
||||
|
||||
get_content_field() {
|
||||
if (this.dialog.fields_dict.use_html.value) {
|
||||
return this.dialog.fields_dict.html_content;
|
||||
} else {
|
||||
return this.dialog.fields_dict.content;
|
||||
}
|
||||
}
|
||||
|
||||
get_default_recipients(fieldname) {
|
||||
if (this.frm?.events.get_email_recipients) {
|
||||
return (this.frm.events.get_email_recipients(this.frm, fieldname) || []).join(", ");
|
||||
|
|
@ -254,6 +280,22 @@ frappe.views.CommunicationComposer = class {
|
|||
this.dialog.set_value("print_language", lang);
|
||||
}
|
||||
|
||||
async check_email_template_html(email_template) {
|
||||
const r = await frappe.db.get_value("Email Template", email_template, "use_html");
|
||||
// Show or hide "Use HTML" based on the Email Template's use_html value
|
||||
if (r.message?.use_html === 1) {
|
||||
// Show the field.
|
||||
this.dialog.fields_dict.use_html.toggle(true);
|
||||
} else {
|
||||
this.hide_use_html_field();
|
||||
}
|
||||
}
|
||||
|
||||
hide_use_html_field() {
|
||||
this.dialog.fields_dict.use_html.set_input(false); // reset the value
|
||||
this.dialog.fields_dict.use_html.toggle(false); // hide the field
|
||||
}
|
||||
|
||||
toggle_more_options(show_options) {
|
||||
show_options = show_options || this.dialog.fields_dict.more_options.df.hidden;
|
||||
this.dialog.set_df_property("more_options", "hidden", !show_options);
|
||||
|
|
@ -419,7 +461,7 @@ frappe.views.CommunicationComposer = class {
|
|||
|
||||
let content = content_field.get_value() || "";
|
||||
|
||||
content_field.set_value(`${reply.message}<br>${content}`);
|
||||
content_field.set_value(reply.message + content);
|
||||
subject_field.set_value(reply.subject);
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +470,7 @@ frappe.views.CommunicationComposer = class {
|
|||
args: {
|
||||
template_name: email_template,
|
||||
doc: me.doc,
|
||||
sender: me.dialog.get_value("sender") || "",
|
||||
},
|
||||
callback(r) {
|
||||
prepend_reply(r.message);
|
||||
|
|
@ -452,6 +495,7 @@ frappe.views.CommunicationComposer = class {
|
|||
];
|
||||
|
||||
frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions);
|
||||
$(fields.use_html.wrapper).addClass("mt-2 text-center").appendTo(clear_and_add_template);
|
||||
}
|
||||
|
||||
setup_last_edited_communication() {
|
||||
|
|
@ -520,12 +564,13 @@ frappe.views.CommunicationComposer = class {
|
|||
if (this.message) return;
|
||||
|
||||
const last_edited = this.get_last_edited_communication();
|
||||
if (!last_edited.content && !last_edited.content_html) return;
|
||||
if (!last_edited.content && !last_edited.html_content) return;
|
||||
|
||||
// prevent re-triggering of email template
|
||||
if (last_edited.email_template) {
|
||||
const template_field = this.dialog.fields_dict.email_template;
|
||||
await template_field.set_model_value(last_edited.email_template);
|
||||
await this.check_email_template_html(last_edited.email_template);
|
||||
delete last_edited.email_template;
|
||||
}
|
||||
|
||||
|
|
@ -823,6 +868,8 @@ frappe.views.CommunicationComposer = class {
|
|||
print_letterhead: me.is_print_letterhead_checked(),
|
||||
send_after: form_values.send_after ? form_values.send_after : null,
|
||||
print_language: form_values.print_language,
|
||||
raw_html: form_values.use_html,
|
||||
add_css: form_values.add_css,
|
||||
},
|
||||
btn,
|
||||
callback(r) {
|
||||
|
|
@ -1002,11 +1049,6 @@ frappe.views.CommunicationComposer = class {
|
|||
return text.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
get_content_field() {
|
||||
const use_html = this.dialog.get_value("use_html");
|
||||
return use_html ? this.dialog.fields_dict.content_html : this.dialog.fields_dict.content;
|
||||
}
|
||||
|
||||
get_email_content() {
|
||||
return this.get_content_field().get_value() || "";
|
||||
}
|
||||
|
|
@ -1015,13 +1057,16 @@ frappe.views.CommunicationComposer = class {
|
|||
return this.get_content_field().set_value(value);
|
||||
}
|
||||
|
||||
on_use_html_toggle() {
|
||||
on_use_html_toggle(event) {
|
||||
if (!event) return;
|
||||
|
||||
this.save_as_draft();
|
||||
const use_html = this.dialog.get_value("use_html");
|
||||
const use_html = event.target.checked;
|
||||
|
||||
this.dialog.set_df_property("content", "hidden", use_html);
|
||||
this.dialog.set_df_property("content_html", "hidden", !use_html);
|
||||
|
||||
this.dialog.set_value("email_template", "");
|
||||
if (use_html) {
|
||||
this.dialog.set_value("html_content", this.dialog.get_value("content"));
|
||||
} else {
|
||||
this.dialog.set_value("content", this.dialog.get_value("html_content"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue