Merge branch 'develop' into aks-test-fixtures
This commit is contained in:
commit
84f366bbfc
19 changed files with 154 additions and 67 deletions
|
|
@ -740,17 +740,26 @@ def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=Fals
|
|||
:param doc: [optional] Checks User permissions for given doc.
|
||||
:param user: [optional] Check for given user. Default: current user.
|
||||
:param parent_doctype: Required when checking permission for a child DocType (unless doc is specified)."""
|
||||
import frappe.permissions
|
||||
|
||||
if not doctype and doc:
|
||||
doctype = doc.doctype
|
||||
|
||||
import frappe.permissions
|
||||
out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user,
|
||||
raise_exception=throw, parent_doctype=parent_doctype)
|
||||
|
||||
if throw and not out:
|
||||
if doc:
|
||||
frappe.throw(_("No permission for {0}").format(doc.doctype + " " + doc.name))
|
||||
else:
|
||||
frappe.throw(_("No permission for {0}").format(doctype))
|
||||
# mimics frappe.throw
|
||||
document_label = f"{doc.doctype} {doc.name}" if doc else doctype
|
||||
msgprint(
|
||||
_("No permission for {0}").format(document_label),
|
||||
raise_exception=ValidationError,
|
||||
title=None,
|
||||
indicator='red',
|
||||
is_minimizable=None,
|
||||
wide=None,
|
||||
as_list=False
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ def get_list(doctype, fields=None, filters=None, order_by=None,
|
|||
|
||||
args = frappe._dict(
|
||||
doctype=doctype,
|
||||
parent_doctype=parent,
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from frappe import _, conf, safe_decode
|
|||
from frappe.model.document import Document
|
||||
from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip
|
||||
from frappe.utils.image import strip_exif_data, optimize_image
|
||||
from frappe.utils.file_manager import safe_b64decode
|
||||
|
||||
class MaxFileSizeReachedError(frappe.ValidationError):
|
||||
pass
|
||||
|
|
@ -436,7 +437,7 @@ class File(Document):
|
|||
|
||||
if b"," in self.content:
|
||||
self.content = self.content.split(b",")[1]
|
||||
self.content = base64.b64decode(self.content)
|
||||
self.content = safe_b64decode(self.content)
|
||||
|
||||
if not self.is_private:
|
||||
self.is_private = 0
|
||||
|
|
@ -852,7 +853,7 @@ def extract_images_from_html(doc, content, is_private=False):
|
|||
content = content.encode("utf-8")
|
||||
if b"," in content:
|
||||
content = content.split(b",")[1]
|
||||
content = base64.b64decode(content)
|
||||
content = safe_b64decode(content)
|
||||
|
||||
content = optimize_image(content, mtype)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright (c) 2016, {app_publisher} and contributors
|
||||
// Copyright (c) {year}, {app_publisher} and contributors
|
||||
// For license information, please see license.txt
|
||||
/* eslint-disable */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# Copyright (c) 2013, {app_publisher} and contributors
|
||||
# License: MIT. See LICENSE
|
||||
# Copyright (c) {year}, {app_publisher} and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
|
||||
|
|
|
|||
|
|
@ -135,9 +135,10 @@ class MariaDBDatabase(Database):
|
|||
table_name = get_table_name(doctype)
|
||||
return self.sql(f"DESC `{table_name}`")
|
||||
|
||||
def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]:
|
||||
def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]:
|
||||
table_name = get_table_name(doctype)
|
||||
return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} NOT NULL")
|
||||
null_constraint = "NOT NULL" if not nullable else ""
|
||||
return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}")
|
||||
|
||||
# exception types
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -183,9 +183,12 @@ class PostgresDatabase(Database):
|
|||
table_name = get_table_name(doctype)
|
||||
return self.sql(f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'")
|
||||
|
||||
def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]:
|
||||
def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]:
|
||||
table_name = get_table_name(doctype)
|
||||
return self.sql(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column}" TYPE {type}')
|
||||
null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL"
|
||||
return self.sql(f"""ALTER TABLE "{table_name}"
|
||||
ALTER COLUMN "{column}" TYPE {type},
|
||||
ALTER COLUMN "{column}" {null_constraint}""")
|
||||
|
||||
def create_auth_table(self):
|
||||
self.sql_ddl("""create table if not exists "__Auth" (
|
||||
|
|
|
|||
|
|
@ -382,10 +382,10 @@ class Leaderboard {
|
|||
let timespan = this.options.selected_timespan.toLowerCase();
|
||||
let current_date = frappe.datetime.now_date();
|
||||
let date_range_map = {
|
||||
"this week": [frappe.datetime.week_start(), current_date],
|
||||
"this month": [frappe.datetime.month_start(), current_date],
|
||||
"this quarter": [frappe.datetime.quarter_start(), current_date],
|
||||
"this year": [frappe.datetime.year_start(), current_date],
|
||||
"this week": [frappe.datetime.week_start(), frappe.datetime.week_end()],
|
||||
"this month": [frappe.datetime.month_start(), frappe.datetime.month_end()],
|
||||
"this quarter": [frappe.datetime.quarter_start(), frappe.datetime.quarter_end()],
|
||||
"this year": [frappe.datetime.year_start(), frappe.datetime.year_end()],
|
||||
"last week": [frappe.datetime.add_days(current_date, -7), current_date],
|
||||
"last month": [frappe.datetime.add_months(current_date, -1), current_date],
|
||||
"last quarter": [frappe.datetime.add_months(current_date, -3), current_date],
|
||||
|
|
|
|||
|
|
@ -36,10 +36,12 @@ class DatabaseQuery(object):
|
|||
ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False,
|
||||
update=None, add_total_row=None, user_settings=None, reference_doctype=None,
|
||||
run=True, strict=True, pluck=None, ignore_ddl=False, parent_doctype=None) -> List:
|
||||
if not ignore_permissions and \
|
||||
not frappe.has_permission(self.doctype, "select", user=user, parent_doctype=parent_doctype) and \
|
||||
not frappe.has_permission(self.doctype, "read", user=user, parent_doctype=parent_doctype):
|
||||
|
||||
if (
|
||||
not ignore_permissions
|
||||
and not frappe.has_permission(self.doctype, "select", user=user, parent_doctype=parent_doctype)
|
||||
and not frappe.has_permission(self.doctype, "read", user=user, parent_doctype=parent_doctype)
|
||||
):
|
||||
frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype))
|
||||
raise frappe.PermissionError(self.doctype)
|
||||
|
||||
|
|
@ -787,12 +789,15 @@ class DatabaseQuery(object):
|
|||
def check_parent_permission(parent, child_doctype):
|
||||
if parent:
|
||||
# User may pass fake parent and get the information from the child table
|
||||
if child_doctype and not frappe.db.exists('DocField',
|
||||
{'parent': parent, 'options': child_doctype}):
|
||||
if child_doctype and not (
|
||||
frappe.db.exists('DocField', {'parent': parent, 'options': child_doctype})
|
||||
or frappe.db.exists('Custom Field', {'dt': parent, 'options': child_doctype})
|
||||
):
|
||||
raise frappe.PermissionError
|
||||
|
||||
if frappe.permissions.has_permission(parent):
|
||||
return
|
||||
|
||||
# Either parent not passed or the user doesn't have permission on parent doctype of child table!
|
||||
raise frappe.PermissionError
|
||||
|
||||
|
|
|
|||
|
|
@ -188,5 +188,5 @@ frappe.patches.v14_0.copy_mail_data #08.03.21
|
|||
frappe.patches.v14_0.update_workspace2 # 20.09.2021
|
||||
frappe.patches.v14_0.update_github_endpoints #08-11-2021
|
||||
frappe.patches.v14_0.remove_db_aggregation
|
||||
frappe.patches.v14_0.save_ratings_in_fraction
|
||||
frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021
|
||||
frappe.patches.v14_0.update_color_names_in_kanban_board_column
|
||||
|
|
|
|||
|
|
@ -1,12 +1,39 @@
|
|||
import frappe
|
||||
from frappe.query_builder import DocType
|
||||
|
||||
|
||||
def execute():
|
||||
rating_fields = frappe.get_all("DocField", fields=["parent", "fieldname"], filters={"fieldtype": "Rating"})
|
||||
RATING_FIELD_TYPE = "decimal(3,2)"
|
||||
rating_fields = frappe.get_all(
|
||||
"DocField", fields=["parent", "fieldname"], filters={"fieldtype": "Rating"}
|
||||
)
|
||||
|
||||
custom_rating_fields = frappe.get_all("Custom Field", fields=["dt", "fieldname"], filters={"fieldtype": "Rating"})
|
||||
custom_rating_fields = frappe.get_all(
|
||||
"Custom Field", fields=["dt", "fieldname"], filters={"fieldtype": "Rating"}
|
||||
)
|
||||
|
||||
for field in rating_fields + custom_rating_fields:
|
||||
doctype_name = field.get("parent") or field.get("dt")
|
||||
doctype = frappe.qb.DocType(doctype_name)
|
||||
field = field.fieldname
|
||||
(frappe.qb.update(doctype_name).set(doctype[field], doctype[field]/5)).run()
|
||||
for _field in rating_fields + custom_rating_fields:
|
||||
doctype_name = _field.get("parent") or _field.get("dt")
|
||||
doctype = DocType(doctype_name)
|
||||
field = _field.fieldname
|
||||
|
||||
# TODO: Add postgres support (for the check)
|
||||
if (
|
||||
frappe.conf.db_type == "mariadb"
|
||||
and frappe.db.get_column_type(doctype_name, field) == RATING_FIELD_TYPE
|
||||
):
|
||||
continue
|
||||
|
||||
# commit any changes so far for upcoming DDL
|
||||
frappe.db.commit()
|
||||
|
||||
# alter column types for rating fieldtype
|
||||
frappe.db.change_column_type(doctype_name, column=field, type=RATING_FIELD_TYPE, nullable=True)
|
||||
|
||||
# update data: int => decimal
|
||||
frappe.qb.update(doctype).set(
|
||||
doctype[field], doctype[field] / 5
|
||||
).run()
|
||||
|
||||
# commit to flush updated rows
|
||||
frappe.db.commit()
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
|
||||
setup_std_layout() {
|
||||
this.form_wrapper = $('<div></div>').appendTo(this.layout_main);
|
||||
this.body = $('<div></div>').appendTo(this.form_wrapper);
|
||||
this.body = $('<div class="std-form-layout"></div>').appendTo(this.form_wrapper);
|
||||
|
||||
// only tray
|
||||
this.meta.section_style='Simple'; // always simple!
|
||||
|
|
@ -211,12 +211,24 @@ frappe.ui.form.Form = class FrappeForm {
|
|||
this.fields = this.layout.fields_list;
|
||||
|
||||
let dashboard_parent = $('<div class="form-dashboard">');
|
||||
let dashboard_added = false;
|
||||
|
||||
if (this.layout.tabs.length) {
|
||||
this.layout.tabs[0].wrapper.prepend(dashboard_parent);
|
||||
this.layout.tabs.every(tab => {
|
||||
if (tab.df.options === 'Dashboard') {
|
||||
tab.wrapper.prepend(dashboard_parent);
|
||||
dashboard_added = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!dashboard_added) {
|
||||
this.layout.tabs[0].wrapper.prepend(dashboard_parent);
|
||||
}
|
||||
} else {
|
||||
dashboard_parent.insertAfter(this.layout.wrapper.find('.form-message'));
|
||||
this.layout.wrapper.find('.form-page').prepend(dashboard_parent);
|
||||
}
|
||||
|
||||
this.dashboard = new frappe.ui.form.Dashboard(dashboard_parent, this);
|
||||
|
||||
this.tour = new frappe.ui.form.FormTour({
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ $.extend(frappe.contacts, {
|
|||
}
|
||||
},
|
||||
get_last_doc: function(frm) {
|
||||
const reverse_routes = frappe.route_history.reverse();
|
||||
const reverse_routes = frappe.route_history.slice().reverse();
|
||||
const last_route = reverse_routes.find(route => {
|
||||
return route[0] === 'Form' && route[1] !== frm.doctype
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
.form-control {
|
||||
height: inherit;
|
||||
border: none;
|
||||
font-size: var(--text-md);
|
||||
position: relative;
|
||||
|
|
@ -13,10 +14,9 @@
|
|||
font-weight: normal;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
min-height: var(--input-height);
|
||||
border-radius: $border-radius;
|
||||
font-weight: 400;
|
||||
padding: 8px 12px;
|
||||
padding: 6px 12px;
|
||||
cursor: default;
|
||||
color: var(--disabled-text-color);
|
||||
background-color: var(--disabled-control-bg);
|
||||
|
|
|
|||
|
|
@ -79,10 +79,9 @@
|
|||
|
||||
.grid-static-col,
|
||||
.row-index {
|
||||
height: 39px;
|
||||
padding: var(--padding-sm) var(--padding-md);
|
||||
height: 34px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
// border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.grid-row-check {
|
||||
|
|
@ -108,6 +107,7 @@
|
|||
.grid-row > .row {
|
||||
.col:last-child {
|
||||
margin-right: calc(-1 * var(--margin-sm));
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.col {
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
}
|
||||
|
||||
textarea {
|
||||
height: 40px !important;
|
||||
height: 37px !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
|
|
@ -157,7 +157,7 @@
|
|||
border: 0px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 9px;
|
||||
height: 40px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
|
|
@ -196,6 +196,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.grid-static-col[data-fieldtype="Check"] .static-area {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.grid-static-col[data-fieldtype="Rating"] .field-area {
|
||||
margin-top: 1rem;
|
||||
margin-left: 1rem;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
@import "../common/form.scss";
|
||||
@import '~cropperjs/dist/cropper.min';
|
||||
|
||||
.std-form-layout > .form-layout > .form-page {
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--card-shadow);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.form-section, .form-dashboard-section {
|
||||
margin: 0px;
|
||||
|
||||
|
|
@ -12,6 +18,7 @@
|
|||
|
||||
.section-head {
|
||||
@extend .head-title;
|
||||
font-size: var(--text-base);
|
||||
width: 100%;
|
||||
padding: var(--padding-sm) var(--padding-md);
|
||||
margin: 0;
|
||||
|
|
@ -47,8 +54,12 @@
|
|||
|
||||
.form-section.card-section,
|
||||
.form-dashboard-section {
|
||||
margin-bottom: var(--margin-lg);
|
||||
@extend .frappe-card;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
padding: var(--padding-xs);
|
||||
}
|
||||
|
||||
.row.form-section.card-section.visible-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.form-dashboard-section {
|
||||
|
|
@ -57,9 +68,8 @@
|
|||
}
|
||||
.section-body {
|
||||
display: block;
|
||||
padding-left: var(--padding-md);
|
||||
padding-right: var(--padding-md);
|
||||
padding-bottom: var(--padding-md);
|
||||
padding: var(--padding-md);
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +95,8 @@
|
|||
|
||||
.comment-box {
|
||||
@include card();
|
||||
padding: 25px var(--padding-xl);
|
||||
margin-top: var(--margin-lg);
|
||||
padding: var(--padding-lg);
|
||||
.comment-input-header {
|
||||
@extend .head-title;
|
||||
margin-bottom: var(--margin-sm);
|
||||
|
|
@ -304,19 +315,18 @@
|
|||
}
|
||||
|
||||
.form-tabs-list {
|
||||
margin-bottom: var(--margin-lg);
|
||||
padding-left: var(--padding-xs);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
|
||||
.form-tabs {
|
||||
.nav-item {
|
||||
.nav-link {
|
||||
padding-bottom: var(--padding-md);
|
||||
color: var(--gray-700);
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin-right: var(--margin-xl);
|
||||
padding: var(--padding-md) 0;
|
||||
margin: 0 var(--margin-md);
|
||||
|
||||
&.active {
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--primary);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -507,10 +507,10 @@ def get_timespan_date_range(timespan):
|
|||
"yesterday": lambda: (add_to_date(today, days=-1),) * 2,
|
||||
"today": lambda: (today, today),
|
||||
"tomorrow": lambda: (add_to_date(today, days=1),) * 2,
|
||||
"this week": lambda: (get_first_day_of_week(today), today),
|
||||
"this month": lambda: (get_first_day(today), today),
|
||||
"this quarter": lambda: (get_quarter_start(today), today),
|
||||
"this year": lambda: (get_year_start(today), today),
|
||||
"this week": lambda: (get_first_day_of_week(today), get_last_day_of_week(today)),
|
||||
"this month": lambda: (get_first_day(today), get_last_day(today)),
|
||||
"this quarter": lambda: (get_quarter_start(today), get_quarter_ending(today)),
|
||||
"this year": lambda: (get_year_start(today), get_year_ending(today)),
|
||||
"next week": lambda: (get_first_day_of_week(add_to_date(today, days=7)), get_last_day_of_week(add_to_date(today, days=7))),
|
||||
"next month": lambda: (get_first_day(add_to_date(today, months=1)), get_last_day(add_to_date(today, months=1))),
|
||||
"next quarter": lambda: (get_quarter_start(add_to_date(today, months=3)), get_quarter_ending(add_to_date(today, months=3))),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import frappe
|
||||
|
|
@ -17,6 +17,20 @@ class MaxFileSizeReachedError(frappe.ValidationError):
|
|||
pass
|
||||
|
||||
|
||||
def safe_b64decode(binary: bytes) -> bytes:
|
||||
"""Adds padding if doesn't already exist before decoding.
|
||||
|
||||
This attempts to avoid the `binascii.Error: Incorrect padding` error raised
|
||||
when the number of trailing = is simply not enough :crie:. Although, it may
|
||||
be an indication of corrupted data.
|
||||
|
||||
Refs:
|
||||
* https://en.wikipedia.org/wiki/Base64
|
||||
* https://stackoverflow.com/questions/2941995/python-ignore-incorrect-padding-error-when-base64-decoding
|
||||
"""
|
||||
return base64.b64decode(binary + b"===")
|
||||
|
||||
|
||||
def get_file_url(file_data_name):
|
||||
data = frappe.db.get_value("File", file_data_name, ["file_name", "file_url"], as_dict=True)
|
||||
return data.file_url or data.file_name
|
||||
|
|
@ -112,7 +126,7 @@ def get_uploaded_content():
|
|||
if 'filedata' in frappe.form_dict:
|
||||
if "," in frappe.form_dict.filedata:
|
||||
frappe.form_dict.filedata = frappe.form_dict.filedata.rsplit(",", 1)[1]
|
||||
frappe.uploaded_content = base64.b64decode(frappe.form_dict.filedata)
|
||||
frappe.uploaded_content = safe_b64decode(frappe.form_dict.filedata)
|
||||
frappe.uploaded_filename = frappe.form_dict.filename
|
||||
return frappe.uploaded_filename, frappe.uploaded_content
|
||||
else:
|
||||
|
|
@ -126,7 +140,7 @@ def save_file(fname, content, dt, dn, folder=None, decode=False, is_private=0, d
|
|||
|
||||
if b"," in content:
|
||||
content = content.split(b",")[1]
|
||||
content = base64.b64decode(content)
|
||||
content = safe_b64decode(content)
|
||||
|
||||
file_size = check_max_file_size(content)
|
||||
content_hash = get_content_hash(content)
|
||||
|
|
|
|||
|
|
@ -69,15 +69,15 @@ frappe.ready(function() {
|
|||
const confirm_password = $('#confirm_password').val()
|
||||
if (!args.old_password && !args.key) {
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Message') }}",
|
||||
message: "{{ _('Old Password Required.') }}",
|
||||
title: "{{ _('Missing Value') }}",
|
||||
message: "{{ _('Please enter your old password.') }}",
|
||||
clear: true
|
||||
});
|
||||
}
|
||||
if (!args.new_password) {
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Message') }}",
|
||||
message: "{{ _('New Password Required.') }}",
|
||||
title: "{{ _('Missing Value') }}",
|
||||
message: "{{ _('Please enter your new password.') }}",
|
||||
clear: true
|
||||
});
|
||||
}
|
||||
|
|
@ -110,8 +110,8 @@ frappe.ready(function() {
|
|||
.html("{{ _('Status Updated') }}");
|
||||
if(r.message) {
|
||||
frappe.msgprint({
|
||||
title: "{{ _('Message') }}",
|
||||
message: "{{ _('Password Updated') }}",
|
||||
title: "{{ _('Password set') }}",
|
||||
message: "{{ _('Your new password has been set successfully.') }}",
|
||||
// password is updated successfully
|
||||
// clear any server message
|
||||
clear: true
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue