Merge branch 'develop' into atom
This commit is contained in:
commit
7fa9c42711
36 changed files with 1246 additions and 872 deletions
|
|
@ -25,7 +25,7 @@ import importlib
|
|||
import inspect
|
||||
import json
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Dict, List, Union
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Union
|
||||
|
||||
import click
|
||||
from werkzeug.local import Local, release_local
|
||||
|
|
@ -435,7 +435,7 @@ def msgprint(
|
|||
if as_table and type(msg) in (list, tuple):
|
||||
out.as_table = 1
|
||||
|
||||
if as_list and type(msg) in (list, tuple) and len(msg) > 1:
|
||||
if as_list and type(msg) in (list, tuple):
|
||||
out.as_list = 1
|
||||
|
||||
if flags.print_messages and out.message:
|
||||
|
|
@ -973,7 +973,7 @@ def get_precision(doctype, fieldname, currency=None, doc=None):
|
|||
return get_field_precision(get_meta(doctype).get_field(fieldname), doc, currency)
|
||||
|
||||
|
||||
def generate_hash(txt=None, length=None):
|
||||
def generate_hash(txt: Optional[str] = None, length: Optional[int] = None) -> str:
|
||||
"""Generates random hash for given text + current timestamp + random string."""
|
||||
import hashlib
|
||||
import time
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def load_address_and_contact(doc, key=None):
|
|||
["Dynamic Link", "link_name", "=", doc.name],
|
||||
["Dynamic Link", "parenttype", "=", "Address"],
|
||||
]
|
||||
address_list = frappe.get_list("Address", filters=filters, fields=["*"])
|
||||
address_list = frappe.get_list("Address", filters=filters, fields=["*"], order_by="creation asc")
|
||||
|
||||
address_list = [a.update({"display": get_address_display(a)}) for a in address_list]
|
||||
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@
|
|||
"column_break_64",
|
||||
"max_auto_email_report_per_user",
|
||||
"system_updates_section",
|
||||
"disable_system_update_notification"
|
||||
"disable_system_update_notification",
|
||||
"disable_change_log_notification"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
|
|
@ -488,12 +489,18 @@
|
|||
"fieldname": "max_auto_email_report_per_user",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max auto email report per user"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disable_change_log_notification",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable Change Log Notification"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2022-05-02 18:53:35.218721",
|
||||
"modified": "2022-05-09 18:53:35.218721",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -1019,21 +1019,17 @@ class Database(object):
|
|||
|
||||
return self.get_value(dt, dn, ignore=True, cache=cache)
|
||||
|
||||
def count(self, dt, filters=None, debug=False, cache=False):
|
||||
def count(self, dt, filters=None, debug=False, cache=False, distinct: bool = True):
|
||||
"""Returns `COUNT(*)` for given DocType and filters."""
|
||||
if cache and not filters:
|
||||
cache_count = frappe.cache().get_value("doctype:count:{}".format(dt))
|
||||
if cache_count is not None:
|
||||
return cache_count
|
||||
query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"))
|
||||
if filters:
|
||||
count = self.sql(query, debug=debug)[0][0]
|
||||
return count
|
||||
else:
|
||||
count = self.sql(query, debug=debug)[0][0]
|
||||
if cache:
|
||||
frappe.cache().set_value("doctype:count:{}".format(dt), count, expires_in_sec=86400)
|
||||
return count
|
||||
query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"), distinct=distinct)
|
||||
count = query.run(debug=debug)[0][0]
|
||||
if not filters and cache:
|
||||
frappe.cache().set_value("doctype:count:{}".format(dt), count, expires_in_sec=86400)
|
||||
return count
|
||||
|
||||
@staticmethod
|
||||
def format_date(date):
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ from typing import Any, Dict, List, Tuple, Union
|
|||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import Criterion, Field, Order
|
||||
from frappe.query_builder import Criterion, Field, Order, Table
|
||||
|
||||
|
||||
def like(key: str, value: str) -> frappe.qb:
|
||||
def like(key: Field, value: str) -> frappe.qb:
|
||||
"""Wrapper method for `LIKE`
|
||||
|
||||
Args:
|
||||
|
|
@ -17,10 +17,10 @@ def like(key: str, value: str) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `LIKE`
|
||||
"""
|
||||
return Field(key).like(value)
|
||||
return key.like(value)
|
||||
|
||||
|
||||
def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
||||
def func_in(key: Field, value: Union[List, Tuple]) -> frappe.qb:
|
||||
"""Wrapper method for `IN`
|
||||
|
||||
Args:
|
||||
|
|
@ -30,10 +30,10 @@ def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `IN`
|
||||
"""
|
||||
return Field(key).isin(value)
|
||||
return key.isin(value)
|
||||
|
||||
|
||||
def not_like(key: str, value: str) -> frappe.qb:
|
||||
def not_like(key: Field, value: str) -> frappe.qb:
|
||||
"""Wrapper method for `NOT LIKE`
|
||||
|
||||
Args:
|
||||
|
|
@ -43,10 +43,10 @@ def not_like(key: str, value: str) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `NOT LIKE`
|
||||
"""
|
||||
return Field(key).not_like(value)
|
||||
return key.not_like(value)
|
||||
|
||||
|
||||
def func_not_in(key: str, value: Union[List, Tuple]):
|
||||
def func_not_in(key: Field, value: Union[List, Tuple]):
|
||||
"""Wrapper method for `NOT IN`
|
||||
|
||||
Args:
|
||||
|
|
@ -56,10 +56,10 @@ def func_not_in(key: str, value: Union[List, Tuple]):
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `NOT IN`
|
||||
"""
|
||||
return Field(key).notin(value)
|
||||
return key.notin(value)
|
||||
|
||||
|
||||
def func_regex(key: str, value: str) -> frappe.qb:
|
||||
def func_regex(key: Field, value: str) -> frappe.qb:
|
||||
"""Wrapper method for `REGEX`
|
||||
|
||||
Args:
|
||||
|
|
@ -69,10 +69,10 @@ def func_regex(key: str, value: str) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `REGEX`
|
||||
"""
|
||||
return Field(key).regex(value)
|
||||
return key.regex(value)
|
||||
|
||||
|
||||
def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
||||
def func_between(key: Field, value: Union[List, Tuple]) -> frappe.qb:
|
||||
"""Wrapper method for `BETWEEN`
|
||||
|
||||
Args:
|
||||
|
|
@ -82,7 +82,7 @@ def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb:
|
|||
Returns:
|
||||
frappe.qb: `frappe.qb object with `BETWEEN`
|
||||
"""
|
||||
return Field(key)[slice(*value)]
|
||||
return key[slice(*value)]
|
||||
|
||||
|
||||
def make_function(key: Any, value: Union[int, str]):
|
||||
|
|
@ -139,7 +139,9 @@ OPERATOR_MAP = {
|
|||
|
||||
|
||||
class Query:
|
||||
def get_condition(self, table: str, **kwargs) -> frappe.qb:
|
||||
tables: dict = {}
|
||||
|
||||
def get_condition(self, table: Union[str, Table], **kwargs) -> frappe.qb:
|
||||
"""Get initial table object
|
||||
|
||||
Args:
|
||||
|
|
@ -148,11 +150,20 @@ class Query:
|
|||
Returns:
|
||||
frappe.qb: DocType with initial condition
|
||||
"""
|
||||
table_object = self.get_table(table)
|
||||
if kwargs.get("update"):
|
||||
return frappe.qb.update(table)
|
||||
return frappe.qb.update(table_object)
|
||||
if kwargs.get("into"):
|
||||
return frappe.qb.into(table)
|
||||
return frappe.qb.from_(table)
|
||||
return frappe.qb.into(table_object)
|
||||
return frappe.qb.from_(table_object)
|
||||
|
||||
def get_table(self, table_name: Union[str, Table]) -> Table:
|
||||
if isinstance(table_name, Table):
|
||||
return table_name
|
||||
table_name = table_name.strip('"').strip("'")
|
||||
if table_name not in self.tables:
|
||||
self.tables[table_name] = frappe.qb.DocType(table_name)
|
||||
return self.tables[table_name]
|
||||
|
||||
def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb:
|
||||
"""Generate filters from Criterion objects
|
||||
|
|
@ -217,8 +228,13 @@ class Query:
|
|||
conditions = conditions.where(_operator(Field(filters[0]), filters[2]))
|
||||
break
|
||||
else:
|
||||
_operator = OPERATOR_MAP[f[1]]
|
||||
conditions = conditions.where(_operator(Field(f[0]), f[2]))
|
||||
_operator = OPERATOR_MAP[f[-2]]
|
||||
if len(f) == 4:
|
||||
table_object = self.get_table(f[0])
|
||||
_field = table_object[f[1]]
|
||||
else:
|
||||
_field = Field(f[0])
|
||||
conditions = conditions.where(_operator(_field, f[-1]))
|
||||
|
||||
return self.add_conditions(conditions, **kwargs)
|
||||
|
||||
|
|
@ -249,7 +265,7 @@ class Query:
|
|||
if isinstance(value, (list, tuple)):
|
||||
if isinstance(value[1], (list, tuple)) or value[0] in list(OPERATOR_MAP.keys())[-4:]:
|
||||
_operator = OPERATOR_MAP[value[0]]
|
||||
conditions = conditions.where(_operator(key, value[1]))
|
||||
conditions = conditions.where(_operator(Field(key), value[1]))
|
||||
else:
|
||||
_operator = OPERATOR_MAP[value[0]]
|
||||
conditions = conditions.where(_operator(Field(key), value[1]))
|
||||
|
|
@ -293,10 +309,19 @@ class Query:
|
|||
self,
|
||||
table: str,
|
||||
fields: Union[List, Tuple],
|
||||
filters: Union[Dict[str, Union[str, int]], str, int] = None,
|
||||
filters: Union[Dict[str, Union[str, int]], str, int, List[Union[List, str, int]]] = None,
|
||||
**kwargs,
|
||||
):
|
||||
# Clean up state before each query
|
||||
self.tables = {}
|
||||
criterion = self.build_conditions(table, filters, **kwargs)
|
||||
|
||||
if len(self.tables) > 1:
|
||||
primary_table = self.tables[table]
|
||||
del self.tables[table]
|
||||
for table_object in self.tables.values():
|
||||
criterion = criterion.left_join(table_object).on(table_object.parent == primary_table.name)
|
||||
|
||||
if isinstance(fields, (list, tuple)):
|
||||
query = criterion.select(*kwargs.get("field_objects", fields))
|
||||
|
||||
|
|
|
|||
|
|
@ -249,9 +249,9 @@ def get_open_count(doctype, name, items=None):
|
|||
if frappe.flags.in_migrate or frappe.flags.in_install:
|
||||
return {"count": []}
|
||||
|
||||
frappe.has_permission(doc=frappe.get_doc(doctype, name), throw=True)
|
||||
|
||||
meta = frappe.get_meta(doctype)
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.check_permission()
|
||||
meta = doc.meta
|
||||
links = meta.get_dashboard_data()
|
||||
|
||||
# compile all items in a list
|
||||
|
|
@ -266,7 +266,6 @@ def get_open_count(doctype, name, items=None):
|
|||
out = []
|
||||
for d in items:
|
||||
if d in links.get("internal_links", {}):
|
||||
# internal link
|
||||
continue
|
||||
|
||||
filters = get_filters_for(d)
|
||||
|
|
|
|||
|
|
@ -48,15 +48,12 @@ def get_list():
|
|||
@frappe.read_only()
|
||||
def get_count():
|
||||
args = get_form_params()
|
||||
|
||||
if is_virtual_doctype(args.doctype):
|
||||
controller = get_controller(args.doctype)
|
||||
data = controller(args.doctype).get_count(args)
|
||||
else:
|
||||
distinct = "distinct " if args.distinct == "true" else ""
|
||||
args.fields = [f"count({distinct}`tab{args.doctype}`.name) as total_count"]
|
||||
data = execute(**args)[0].get("total_count")
|
||||
|
||||
distinct = args["distinct"] == "true"
|
||||
data = frappe.db.count(args["doctype"], args["filters"], distinct=distinct)
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ app_name = "frappe"
|
|||
app_title = "Frappe Framework"
|
||||
app_publisher = "Frappe Technologies"
|
||||
app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node"
|
||||
app_icon = "octicon octicon-circuit-board"
|
||||
app_color = "orange"
|
||||
source_link = "https://github.com/frappe/frappe"
|
||||
app_license = "MIT"
|
||||
app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg"
|
||||
|
|
|
|||
|
|
@ -511,7 +511,8 @@ frappe.Application = class Application {
|
|||
// "version": "12.2.0"
|
||||
// }];
|
||||
|
||||
if (!Array.isArray(change_log) || !change_log.length || window.Cypress) {
|
||||
if (!Array.isArray(change_log) || !change_log.length ||
|
||||
window.Cypress || cint(frappe.boot.sysdefaults.disable_change_log_notification)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,13 +137,13 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat
|
|||
});
|
||||
}
|
||||
parse(value) {
|
||||
if(value) {
|
||||
return frappe.datetime.user_to_str(value);
|
||||
if (value) {
|
||||
return frappe.datetime.user_to_str(value, false, true);
|
||||
}
|
||||
}
|
||||
format_for_input(value) {
|
||||
if(value) {
|
||||
return frappe.datetime.str_to_user(value);
|
||||
if (value) {
|
||||
return frappe.datetime.str_to_user(value, false, true);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co
|
|||
}
|
||||
format_for_input(value) {
|
||||
if (!value) return "";
|
||||
|
||||
|
||||
return frappe.datetime.str_to_user(value, false);
|
||||
}
|
||||
set_description() {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ export default class ListSettings {
|
|||
if (field_count < 4) {
|
||||
field_count = 4;
|
||||
} else if (field_count > 10) {
|
||||
field_count = 4;
|
||||
field_count = 10;
|
||||
}
|
||||
|
||||
me.dialog.set_value("total_fields", field_count);
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
if (window.innerWidth <= 1366) {
|
||||
total_fields = 4;
|
||||
} else if (window.innerWidth >= 1920) {
|
||||
total_fields = 8;
|
||||
total_fields = 10;
|
||||
}
|
||||
|
||||
this.columns = this.columns.slice(0, this.list_view_settings.total_fields || total_fields);
|
||||
|
|
@ -1973,22 +1973,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
|
|||
|
||||
return filters;
|
||||
}
|
||||
|
||||
static trigger_list_update(data) {
|
||||
const doctype = data.doctype;
|
||||
if (!doctype) return;
|
||||
frappe.provide("frappe.views.trees");
|
||||
|
||||
// refresh list view
|
||||
const page_name = frappe.get_route_str();
|
||||
const list_view = frappe.views.list_view[page_name];
|
||||
list_view && list_view.on_update(data);
|
||||
}
|
||||
};
|
||||
|
||||
$(document).on("save", (event, doc) => {
|
||||
frappe.views.ListView.trigger_list_update(doc);
|
||||
});
|
||||
|
||||
frappe.get_list_view = (doctype) => {
|
||||
let route = `List/${doctype}/List`;
|
||||
|
|
|
|||
|
|
@ -47,8 +47,6 @@ $.extend(frappe.model, {
|
|||
init: function() {
|
||||
// setup refresh if the document is updated somewhere else
|
||||
frappe.realtime.on("doc_update", function(data) {
|
||||
// set list dirty
|
||||
frappe.views.ListView.trigger_list_update(data);
|
||||
var doc = locals[data.doctype] && locals[data.doctype][data.name];
|
||||
|
||||
if(doc) {
|
||||
|
|
@ -69,11 +67,6 @@ $.extend(frappe.model, {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
frappe.realtime.on("list_update", function(data) {
|
||||
frappe.views.ListView.trigger_list_update(data);
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
is_value_type: function(fieldtype) {
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ $.extend(frappe.datetime, {
|
|||
return frappe.sys_defaults && frappe.sys_defaults.date_format || "yyyy-mm-dd";
|
||||
},
|
||||
|
||||
str_to_user: function(val, only_time = false) {
|
||||
str_to_user: function(val, only_time=false, only_date=false) {
|
||||
if (!val) return "";
|
||||
const user_date_fmt = frappe.datetime.get_user_date_fmt().toUpperCase();
|
||||
const user_time_fmt = frappe.datetime.get_user_time_fmt();
|
||||
|
|
@ -142,6 +142,9 @@ $.extend(frappe.datetime, {
|
|||
if (only_time) {
|
||||
let date_obj = moment(val, frappe.defaultTimeFormat);
|
||||
return date_obj.format(user_format);
|
||||
} else if (only_date) {
|
||||
let date_obj = moment(val, frappe.defaultDateFormat);
|
||||
return date_obj.format(user_date_fmt);
|
||||
} else {
|
||||
let date_obj = moment.tz(val, frappe.boot.time_zone.system);
|
||||
if (typeof val !== "string" || val.indexOf(" ") === -1) {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,14 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
|
|||
setup_view() {
|
||||
this.setup_columns();
|
||||
super.setup_new_doc_event();
|
||||
this.setup_events()
|
||||
this.page.main.addClass('report-view');
|
||||
}
|
||||
|
||||
setup_events() {
|
||||
frappe.realtime.on("list_update", (data) => this.on_update(data));
|
||||
}
|
||||
|
||||
setup_page() {
|
||||
this.menu_items = this.report_menu_items();
|
||||
super.setup_page();
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ frappe.ready(function() {
|
|||
window.location.replace('/login?redirect-to=' + window.location.pathname);
|
||||
}
|
||||
});
|
||||
login_required.set_message(__("You are not permitted to access this page."));
|
||||
login_required.show();
|
||||
login_required.set_message(__("You are not permitted to access this page without login."));
|
||||
}
|
||||
|
||||
function show_grid() {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default class ChartWidget extends Widget {
|
|||
|
||||
this.empty = $(
|
||||
`<div class="chart-loading-state text-muted" style="height: ${this.height}px;">${__(
|
||||
"No Data..."
|
||||
"No Data"
|
||||
)}</div>`
|
||||
);
|
||||
this.empty.hide().appendTo(this.body);
|
||||
|
|
@ -529,7 +529,26 @@ export default class ChartWidget extends Widget {
|
|||
return frappe.xcall(method, args);
|
||||
}
|
||||
|
||||
render() {
|
||||
async get_source_doctype() {
|
||||
if (this.chart_doc.document_type) {
|
||||
return this.chart_doc.document_type;
|
||||
}
|
||||
if (this.chart_doc.chart_type == "Report" && this.chart_doc.report_name) {
|
||||
return await frappe.db.get_value("Report", this.chart_doc.report_name, "ref_doctype").then(r => r.message.ref_doctype);
|
||||
}
|
||||
}
|
||||
|
||||
async render() {
|
||||
let setup_dashboard_chart = () => {
|
||||
const chart_args = this.get_chart_args();
|
||||
|
||||
if (!this.dashboard_chart) {
|
||||
this.dashboard_chart = frappe.utils.make_chart(this.chart_wrapper[0], chart_args);
|
||||
} else {
|
||||
this.dashboard_chart.update(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.data || !this.data.labels || !Object.keys(this.data).length) {
|
||||
this.chart_wrapper.hide();
|
||||
this.loading.hide();
|
||||
|
|
@ -539,13 +558,12 @@ export default class ChartWidget extends Widget {
|
|||
this.loading.hide();
|
||||
this.empty.hide();
|
||||
this.chart_wrapper.show();
|
||||
this.chart_doc.document_type = await this.get_source_doctype();
|
||||
|
||||
const chart_args = this.get_chart_args();
|
||||
|
||||
if (!this.dashboard_chart) {
|
||||
this.dashboard_chart = frappe.utils.make_chart(this.chart_wrapper[0], chart_args);
|
||||
if (this.chart_doc.document_type) {
|
||||
frappe.model.with_doctype(this.chart_doc.document_type, setup_dashboard_chart);
|
||||
} else {
|
||||
this.dashboard_chart.update(this.data);
|
||||
setup_dashboard_chart();
|
||||
}
|
||||
|
||||
this.width == "Full" && this.summary && this.set_summary();
|
||||
|
|
@ -555,6 +573,7 @@ export default class ChartWidget extends Widget {
|
|||
|
||||
get_chart_args() {
|
||||
let colors = this.get_chart_colors();
|
||||
let fieldtype, options;
|
||||
|
||||
const chart_type_map = {
|
||||
Line: "line",
|
||||
|
|
@ -577,16 +596,22 @@ export default class ChartWidget extends Widget {
|
|||
},
|
||||
};
|
||||
|
||||
if (this.report_result && this.report_result.chart) {
|
||||
chart_args.tooltipOptions = {
|
||||
formatTooltipY: value =>
|
||||
frappe.format(value, {
|
||||
fieldtype: this.report_result.chart.fieldtype,
|
||||
options: this.report_result.chart.options
|
||||
}, { always_show_decimals: true, inline: true })
|
||||
};
|
||||
if (this.chart_doc.document_type) {
|
||||
let doctype_meta = frappe.get_meta(this.chart_doc.document_type);
|
||||
let field = doctype_meta.fields.find(x => x.fieldname == this.chart_doc.value_based_on);
|
||||
fieldtype = field.fieldtype;
|
||||
options = field.options;
|
||||
}
|
||||
|
||||
if (this.chart_doc.chart_type == "Report" && this.report_result?.chart?.fieldtype) {
|
||||
fieldtype = this.report_result.chart.fieldtype;
|
||||
options = this.report_result.chart.options;
|
||||
}
|
||||
|
||||
chart_args.tooltipOptions = {
|
||||
formatTooltipY: value => frappe.format(value, { fieldtype, options }, { always_show_decimals: true, inline: true })
|
||||
};
|
||||
|
||||
if (this.chart_doc.type == "Heatmap") {
|
||||
const heatmap_year = parseInt(this.selected_heatmap_year || this.chart_settings.heatmap_year || this.chart_doc.heatmap_year);
|
||||
chart_args.data.start = new Date(`${heatmap_year}-01-01`);
|
||||
|
|
|
|||
|
|
@ -255,18 +255,21 @@ export default class NumberCardWidget extends Widget {
|
|||
};
|
||||
const stats_qualifier = stats_qualifier_map[this.card_doc.stats_time_interval];
|
||||
|
||||
let get_stat = () => {
|
||||
let stat = (() => {
|
||||
if (this.percentage_stat == undefined) return NaN;
|
||||
const parts = this.percentage_stat.split(' ');
|
||||
const symbol = parts[1] || '';
|
||||
return Math.abs(parts[0]) + ' ' + symbol;
|
||||
};
|
||||
})();
|
||||
|
||||
// don't show stats if not valid number - skip showing `NaN %` in card
|
||||
if (isNaN(stat)) return;
|
||||
|
||||
$(this.body).find('.widget-content').append(`<div class="card-stats ${color_class}">
|
||||
<span class="percentage-stat-area">
|
||||
${caret_html}
|
||||
<span class="percentage-stat">
|
||||
${get_stat()} %
|
||||
${stat} %
|
||||
</span>
|
||||
</span>
|
||||
<span class="stat-period text-muted">
|
||||
|
|
|
|||
16
frappe/public/js/lib/clusterize.min.js
vendored
16
frappe/public/js/lib/clusterize.min.js
vendored
|
|
@ -1,16 +0,0 @@
|
|||
/*! Clusterize.js - v0.17.6 - 2017-03-05
|
||||
* http://NeXTs.github.com/Clusterize.js/
|
||||
* Copyright (c) 2015 Denis Lukov; Licensed GPLv3 */
|
||||
|
||||
;(function(q,n){"undefined"!=typeof module?module.exports=n():"function"==typeof define&&"object"==typeof define.amd?define(n):this[q]=n()})("Clusterize",function(){function q(b,a,c){return a.addEventListener?a.addEventListener(b,c,!1):a.attachEvent("on"+b,c)}function n(b,a,c){return a.removeEventListener?a.removeEventListener(b,c,!1):a.detachEvent("on"+b,c)}function r(b){return"[object Array]"===Object.prototype.toString.call(b)}function m(b,a){return window.getComputedStyle?window.getComputedStyle(a)[b]:
|
||||
a.currentStyle[b]}var l=function(){for(var b=3,a=document.createElement("b"),c=a.all||[];a.innerHTML="\x3c!--[if gt IE "+ ++b+"]><i><![endif]--\x3e",c[0];);return 4<b?b:document.documentMode}(),x=navigator.platform.toLowerCase().indexOf("mac")+1,p=function(b){if(!(this instanceof p))return new p(b);var a=this,c={rows_in_block:50,blocks_in_cluster:4,tag:null,show_no_data_row:!0,no_data_class:"clusterize-no-data",no_data_text:"No data",keep_parity:!0,callbacks:{}};a.options={};for(var d="rows_in_block blocks_in_cluster show_no_data_row no_data_class no_data_text keep_parity tag callbacks".split(" "),
|
||||
f=0,h;h=d[f];f++)a.options[h]="undefined"!=typeof b[h]&&null!=b[h]?b[h]:c[h];c=["scroll","content"];for(f=0;d=c[f];f++)if(a[d+"_elem"]=b[d+"Id"]?document.getElementById(b[d+"Id"]):b[d+"Elem"],!a[d+"_elem"])throw Error("Error! Could not find "+d+" element");a.content_elem.hasAttribute("tabindex")||a.content_elem.setAttribute("tabindex",0);var e=r(b.rows)?b.rows:a.fetchMarkup(),g={};b=a.scroll_elem.scrollTop;a.insertToDOM(e,g);a.scroll_elem.scrollTop=b;var k=!1,m=0,l=!1,t=function(){x&&(l||(a.content_elem.style.pointerEvents=
|
||||
"none"),l=!0,clearTimeout(m),m=setTimeout(function(){a.content_elem.style.pointerEvents="auto";l=!1},50));k!=(k=a.getClusterNum())&&a.insertToDOM(e,g);a.options.callbacks.scrollingProgress&&a.options.callbacks.scrollingProgress(a.getScrollProgress())},u=0,v=function(){clearTimeout(u);u=setTimeout(a.refresh,100)};q("scroll",a.scroll_elem,t);q("resize",window,v);a.destroy=function(b){n("scroll",a.scroll_elem,t);n("resize",window,v);a.html((b?a.generateEmptyRow():e).join(""))};a.refresh=function(b){(a.getRowsHeight(e)||
|
||||
b)&&a.update(e)};a.update=function(b){e=r(b)?b:[];b=a.scroll_elem.scrollTop;e.length*a.options.item_height<b&&(k=a.scroll_elem.scrollTop=0);a.insertToDOM(e,g);a.scroll_elem.scrollTop=b};a.clear=function(){a.update([])};a.getRowsAmount=function(){return e.length};a.getScrollProgress=function(){return this.options.scroll_top/(e.length*this.options.item_height)*100||0};var w=function(b,c){var d=r(c)?c:[];d.length&&(e="append"==b?e.concat(d):d.concat(e),a.insertToDOM(e,g))};a.append=function(a){w("append",
|
||||
a)};a.prepend=function(a){w("prepend",a)}};p.prototype={constructor:p,fetchMarkup:function(){for(var b=[],a=this.getChildNodes(this.content_elem);a.length;)b.push(a.shift().outerHTML);return b},exploreEnvironment:function(b,a){var c=this.options;c.content_tag=this.content_elem.tagName.toLowerCase();b.length&&(l&&9>=l&&!c.tag&&(c.tag=b[0].match(/<([^>\s/]*)/)[1].toLowerCase()),1>=this.content_elem.children.length&&(a.data=this.html(b[0]+b[0]+b[0])),c.tag||(c.tag=this.content_elem.children[0].tagName.toLowerCase()),
|
||||
this.getRowsHeight(b))},getRowsHeight:function(b){var a=this.options,c=a.item_height;a.cluster_height=0;if(b.length){b=this.content_elem.children;var d=b[Math.floor(b.length/2)];a.item_height=d.offsetHeight;"tr"==a.tag&&"collapse"!=m("borderCollapse",this.content_elem)&&(a.item_height+=parseInt(m("borderSpacing",this.content_elem),10)||0);"tr"!=a.tag&&(b=parseInt(m("marginTop",d),10)||0,d=parseInt(m("marginBottom",d),10)||0,a.item_height+=Math.max(b,d));a.block_height=a.item_height*a.rows_in_block;
|
||||
a.rows_in_cluster=a.blocks_in_cluster*a.rows_in_block;a.cluster_height=a.blocks_in_cluster*a.block_height;return c!=a.item_height}},getClusterNum:function(){this.options.scroll_top=this.scroll_elem.scrollTop;return Math.floor(this.options.scroll_top/(this.options.cluster_height-this.options.block_height))||0},generateEmptyRow:function(){var b=this.options;if(!b.tag||!b.show_no_data_row)return[];var a=document.createElement(b.tag),c=document.createTextNode(b.no_data_text),d;a.className=b.no_data_class;
|
||||
"tr"==b.tag&&(d=document.createElement("td"),d.colSpan=100,d.appendChild(c));a.appendChild(d||c);return[a.outerHTML]},generate:function(b,a){var c=this.options,d=b.length;if(d<c.rows_in_block)return{top_offset:0,bottom_offset:0,rows_above:0,rows:d?b:this.generateEmptyRow()};var f=Math.max((c.rows_in_cluster-c.rows_in_block)*a,0),h=f+c.rows_in_cluster,e=Math.max(f*c.item_height,0),c=Math.max((d-h)*c.item_height,0),d=[],g=f;for(1>e&&g++;f<h;f++)b[f]&&d.push(b[f]);return{top_offset:e,bottom_offset:c,
|
||||
rows_above:g,rows:d}},renderExtraTag:function(b,a){var c=document.createElement(this.options.tag);c.className=["clusterize-extra-row","clusterize-"+b].join(" ");a&&(c.style.height=a+"px");return c.outerHTML},insertToDOM:function(b,a){this.options.cluster_height||this.exploreEnvironment(b,a);var c=this.generate(b,this.getClusterNum()),d=c.rows.join(""),f=this.checkChanges("data",d,a),h=this.checkChanges("top",c.top_offset,a),e=this.checkChanges("bottom",c.bottom_offset,a),g=this.options.callbacks,
|
||||
k=[];f||h?(c.top_offset&&(this.options.keep_parity&&k.push(this.renderExtraTag("keep-parity")),k.push(this.renderExtraTag("top-space",c.top_offset))),k.push(d),c.bottom_offset&&k.push(this.renderExtraTag("bottom-space",c.bottom_offset)),g.clusterWillChange&&g.clusterWillChange(),this.html(k.join("")),"ol"==this.options.content_tag&&this.content_elem.setAttribute("start",c.rows_above),g.clusterChanged&&g.clusterChanged()):e&&(this.content_elem.lastChild.style.height=c.bottom_offset+"px")},html:function(b){var a=
|
||||
this.content_elem;if(l&&9>=l&&"tr"==this.options.tag){var c=document.createElement("div");for(c.innerHTML="<table><tbody>"+b+"</tbody></table>";b=a.lastChild;)a.removeChild(b);for(c=this.getChildNodes(c.firstChild.firstChild);c.length;)a.appendChild(c.shift())}else a.innerHTML=b},getChildNodes:function(b){b=b.children;for(var a=[],c=0,d=b.length;c<d;c++)a.push(b[c]);return a},checkChanges:function(b,a,c){var d=a!=c[b];c[b]=a;return d}};return p});
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import "./lib/clusterize.min.js";
|
||||
import "./frappe/views/reports/report_factory.js";
|
||||
import "./frappe/views/reports/report_view.js";
|
||||
import "./frappe/views/reports/query_report.js";
|
||||
|
|
|
|||
|
|
@ -1,38 +1,52 @@
|
|||
import ast
|
||||
import copy
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
import frappe
|
||||
from frappe.utils.boilerplate import make_boilerplate
|
||||
from frappe.utils.boilerplate import (
|
||||
_create_app_boilerplate,
|
||||
_get_user_inputs,
|
||||
github_workflow_template,
|
||||
)
|
||||
|
||||
|
||||
class TestBoilerPlate(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
title = "Test App"
|
||||
description = "This app's description contains 'single quotes' and \"double quotes\"."
|
||||
publisher = "Test Publisher"
|
||||
email = "example@example.org"
|
||||
icon = "" # empty -> default
|
||||
color = ""
|
||||
app_license = "MIT"
|
||||
cls.default_hooks = frappe._dict(
|
||||
{
|
||||
"app_name": "test_app",
|
||||
"app_title": "Test App",
|
||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"app_publisher": "Test Publisher",
|
||||
"app_email": "example@example.org",
|
||||
"app_license": "MIT",
|
||||
"create_github_workflow": False,
|
||||
}
|
||||
)
|
||||
|
||||
cls.user_input = [
|
||||
title,
|
||||
description,
|
||||
publisher,
|
||||
email,
|
||||
icon,
|
||||
color,
|
||||
app_license,
|
||||
]
|
||||
cls.default_user_input = frappe._dict(
|
||||
{
|
||||
"title": "Test App",
|
||||
"description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"publisher": "Test Publisher",
|
||||
"email": "example@example.org",
|
||||
"icon": "", # empty -> default
|
||||
"color": "",
|
||||
"app_license": "MIT",
|
||||
"github_workflow": "n",
|
||||
}
|
||||
)
|
||||
|
||||
cls.bench_path = frappe.utils.get_bench_path()
|
||||
cls.apps_dir = os.path.join(cls.bench_path, "apps")
|
||||
cls.app_names = ("test_app", "test_app_no_git")
|
||||
cls.gitignore_file = ".gitignore"
|
||||
cls.git_folder = ".git"
|
||||
|
||||
|
|
@ -55,39 +69,90 @@ class TestBoilerPlate(unittest.TestCase):
|
|||
"public",
|
||||
]
|
||||
|
||||
def create_app(self, hooks, no_git=False):
|
||||
self.addCleanup(self.delete_test_app, hooks.app_name)
|
||||
_create_app_boilerplate(self.apps_dir, hooks, no_git)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
test_app_dirs = (os.path.join(cls.bench_path, "apps", app_name) for app_name in cls.app_names)
|
||||
for test_app_dir in test_app_dirs:
|
||||
if os.path.exists(test_app_dir):
|
||||
shutil.rmtree(test_app_dir)
|
||||
def delete_test_app(cls, app_name):
|
||||
test_app_dir = os.path.join(cls.bench_path, "apps", app_name)
|
||||
if os.path.exists(test_app_dir):
|
||||
shutil.rmtree(test_app_dir)
|
||||
|
||||
@staticmethod
|
||||
def get_user_input_stream(inputs):
|
||||
user_inputs = []
|
||||
for value in inputs.values():
|
||||
if isinstance(value, list):
|
||||
user_inputs.extend(value)
|
||||
else:
|
||||
user_inputs.append(value)
|
||||
return StringIO("\n".join(user_inputs))
|
||||
|
||||
def test_simple_input_to_boilerplate(self):
|
||||
with patch("sys.stdin", self.get_user_input_stream(self.default_user_input)):
|
||||
hooks = _get_user_inputs(self.default_hooks.app_name)
|
||||
self.assertDictEqual(hooks, self.default_hooks)
|
||||
|
||||
def test_invalid_inputs(self):
|
||||
invalid_inputs = copy.copy(self.default_user_input).update(
|
||||
{
|
||||
"title": ["1nvalid Title", "valid title"],
|
||||
}
|
||||
)
|
||||
with patch("sys.stdin", self.get_user_input_stream(invalid_inputs)):
|
||||
hooks = _get_user_inputs(self.default_hooks.app_name)
|
||||
self.assertEqual(hooks.app_title, "valid title")
|
||||
|
||||
def test_valid_ci_yaml(self):
|
||||
yaml.safe_load(github_workflow_template.format(**self.default_hooks))
|
||||
|
||||
def test_create_app(self):
|
||||
with patch("builtins.input", side_effect=self.user_input):
|
||||
make_boilerplate(self.apps_dir, self.app_names[0])
|
||||
app_name = "test_app"
|
||||
|
||||
new_app_dir = os.path.join(self.bench_path, self.apps_dir, self.app_names[0])
|
||||
hooks = frappe._dict(
|
||||
{
|
||||
"app_name": app_name,
|
||||
"app_title": "Test App",
|
||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"app_publisher": "Test Publisher",
|
||||
"app_email": "example@example.org",
|
||||
"app_license": "MIT",
|
||||
}
|
||||
)
|
||||
|
||||
paths = self.get_paths(new_app_dir, self.app_names[0])
|
||||
self.create_app(hooks)
|
||||
new_app_dir = os.path.join(self.bench_path, self.apps_dir, app_name)
|
||||
|
||||
paths = self.get_paths(new_app_dir, app_name)
|
||||
for path in paths:
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {self.app_names[0]} app")
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {app_name} app")
|
||||
|
||||
self.check_parsable_python_files(new_app_dir)
|
||||
|
||||
def test_create_app_without_git_init(self):
|
||||
with patch("builtins.input", side_effect=self.user_input):
|
||||
make_boilerplate(self.apps_dir, self.app_names[1], no_git=True)
|
||||
app_name = "test_app_no_git"
|
||||
|
||||
new_app_dir = os.path.join(self.apps_dir, self.app_names[1])
|
||||
hooks = frappe._dict(
|
||||
{
|
||||
"app_name": app_name,
|
||||
"app_title": "Test App",
|
||||
"app_description": "This app's description contains 'single quotes' and \"double quotes\".",
|
||||
"app_publisher": "Test Publisher",
|
||||
"app_email": "example@example.org",
|
||||
"app_license": "MIT",
|
||||
}
|
||||
)
|
||||
self.create_app(hooks, no_git=True)
|
||||
|
||||
paths = self.get_paths(new_app_dir, self.app_names[1])
|
||||
new_app_dir = os.path.join(self.apps_dir, app_name)
|
||||
|
||||
paths = self.get_paths(new_app_dir, app_name)
|
||||
for path in paths:
|
||||
if os.path.basename(path) in (self.git_folder, self.gitignore_file):
|
||||
self.assertFalse(
|
||||
os.path.exists(path), msg=f"{path} shouldn't exist in {self.app_names[1]} app"
|
||||
)
|
||||
self.assertFalse(os.path.exists(path), msg=f"{path} shouldn't exist in {app_name} app")
|
||||
else:
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {self.app_names[1]} app")
|
||||
self.assertTrue(os.path.exists(path), msg=f"{path} should exist in {app_name} app")
|
||||
|
||||
self.check_parsable_python_files(new_app_dir)
|
||||
|
||||
|
|
|
|||
|
|
@ -413,26 +413,6 @@ class TestCommands(BaseTestCommands):
|
|||
self.assertEqual(self.returncode, 0)
|
||||
self.assertEqual(check_password("Administrator", "test2"), "Administrator")
|
||||
|
||||
def test_make_app(self):
|
||||
user_input = [
|
||||
b"Test App", # title
|
||||
b"This app's description contains 'single quotes' and \"double quotes\".", # description
|
||||
b"Test Publisher", # publisher
|
||||
b"example@example.org", # email
|
||||
b"", # icon
|
||||
b"", # color
|
||||
b"MIT", # app_license
|
||||
]
|
||||
app_name = "testapp0"
|
||||
apps_path = os.path.join(get_bench_path(), "apps")
|
||||
test_app_path = os.path.join(apps_path, app_name)
|
||||
self.execute(f"bench make-app {apps_path} {app_name}", {"cmd_input": b"\n".join(user_input)})
|
||||
self.assertEqual(self.returncode, 0)
|
||||
self.assertTrue(os.path.exists(test_app_path))
|
||||
|
||||
# cleanup
|
||||
shutil.rmtree(test_app_path)
|
||||
|
||||
@skipIf(
|
||||
not (
|
||||
frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type == "mariadb"
|
||||
|
|
|
|||
|
|
@ -482,6 +482,33 @@ class TestDB(unittest.TestCase):
|
|||
|
||||
frappe.db.delete("ToDo", {"description": test_body})
|
||||
|
||||
def test_count(self):
|
||||
frappe.db.delete("Note")
|
||||
|
||||
frappe.get_doc(doctype="Note", title="note1", content="something").insert()
|
||||
frappe.get_doc(doctype="Note", title="note2", content="someting else").insert()
|
||||
|
||||
# Count with no filtes
|
||||
self.assertEquals((frappe.db.count("Note")), 2)
|
||||
|
||||
# simple filters
|
||||
self.assertEquals((frappe.db.count("Note", ["title", "=", "note1"])), 1)
|
||||
|
||||
frappe.get_doc(doctype="Note", title="note3", content="something other").insert()
|
||||
|
||||
# List of list filters with tables
|
||||
self.assertEquals(
|
||||
(
|
||||
frappe.db.count(
|
||||
"Note",
|
||||
[["Note", "title", "like", "note%"], ["Note", "content", "like", "some%"]],
|
||||
)
|
||||
),
|
||||
3,
|
||||
)
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestDDLCommandsMaria(unittest.TestCase):
|
||||
|
|
|
|||
20
frappe/tests/test_query.py
Normal file
20
frappe/tests/test_query.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.tests.test_query_builder import db_type_is, run_only_if
|
||||
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestQuery(unittest.TestCase):
|
||||
def test_multiple_tables_in_filters(self):
|
||||
self.assertEqual(
|
||||
frappe.db.query.get_sql(
|
||||
"DocType",
|
||||
["*"],
|
||||
[
|
||||
["BOM Update Log", "name", "like", "f%"],
|
||||
["DocType", "parent", "=", "something"],
|
||||
],
|
||||
).get_sql(),
|
||||
"SELECT * FROM `tabDocType` LEFT JOIN `tabBOM Update Log` ON `tabBOM Update Log`.`parent`=`tabDocType`.`name` WHERE `tabBOM Update Log`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'",
|
||||
)
|
||||
|
|
@ -1,10 +1,27 @@
|
|||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import set_request
|
||||
from frappe.website.serve import get_response
|
||||
from frappe.www.list import get_list_context
|
||||
|
||||
|
||||
class TestWebsite(unittest.TestCase):
|
||||
class TestWebform(unittest.TestCase):
|
||||
def test_webform_publish_functionality(self):
|
||||
edit_profile = frappe.get_doc("Web Form", "edit-profile")
|
||||
# publish webform
|
||||
edit_profile.published = True
|
||||
edit_profile.save()
|
||||
set_request(method="GET", path="update-profile")
|
||||
response = get_response()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# un-publish webform
|
||||
edit_profile.published = False
|
||||
edit_profile.save()
|
||||
response = get_response()
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_get_context_hook_of_webform(self):
|
||||
create_custom_doctype()
|
||||
create_webform()
|
||||
|
|
|
|||
|
|
@ -312,6 +312,11 @@ class TestWebsite(unittest.TestCase):
|
|||
self.assertIn("test.__test", content)
|
||||
self.assertNotIn("frappe.exceptions.ValidationError: Illegal template", content)
|
||||
|
||||
def test_metatags(self):
|
||||
content = get_response_content("/_test/_test_metatags")
|
||||
self.assertIn('<meta name="title" content="Test Title Metatag">', content)
|
||||
self.assertIn('<meta name="description" content="Test Description for Metatag">', content)
|
||||
|
||||
|
||||
def set_home_page_hook(key, value):
|
||||
from frappe import hooks
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,12 +2,14 @@
|
|||
# License: MIT. See LICENSE
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import click
|
||||
import git
|
||||
|
||||
import frappe
|
||||
from frappe.utils import cstr, touch_file
|
||||
from frappe.utils import touch_file
|
||||
|
||||
|
||||
def make_boilerplate(dest, app_name, no_git=False):
|
||||
|
|
@ -17,47 +19,63 @@ def make_boilerplate(dest, app_name, no_git=False):
|
|||
|
||||
# app_name should be in snake_case
|
||||
app_name = frappe.scrub(app_name)
|
||||
hooks = _get_user_inputs(app_name)
|
||||
_create_app_boilerplate(dest, hooks, no_git=no_git)
|
||||
|
||||
|
||||
def _get_user_inputs(app_name):
|
||||
"""Prompt user for various inputs related to new app and return config."""
|
||||
app_name = frappe.scrub(app_name)
|
||||
|
||||
hooks = frappe._dict()
|
||||
hooks.app_name = app_name
|
||||
app_title = hooks.app_name.replace("_", " ").title()
|
||||
for key in (
|
||||
"App Title (default: {0})".format(app_title),
|
||||
"App Description",
|
||||
"App Publisher",
|
||||
"App Email",
|
||||
"App Icon (default 'octicon octicon-file-directory')",
|
||||
"App Color (default 'grey')",
|
||||
"App License (default 'MIT')",
|
||||
):
|
||||
hook_key = key.split(" (")[0].lower().replace(" ", "_")
|
||||
hook_val = None
|
||||
while not hook_val:
|
||||
hook_val = cstr(input(key + ": "))
|
||||
|
||||
if not hook_val:
|
||||
defaults = {
|
||||
"app_title": app_title,
|
||||
"app_icon": "octicon octicon-file-directory",
|
||||
"app_color": "grey",
|
||||
"app_license": "MIT",
|
||||
}
|
||||
if hook_key in defaults:
|
||||
hook_val = defaults[hook_key]
|
||||
new_app_config = {
|
||||
"app_title": {
|
||||
"prompt": "App Title",
|
||||
"default": app_title,
|
||||
"validator": is_valid_title,
|
||||
},
|
||||
"app_description": {"prompt": "App Description"},
|
||||
"app_publisher": {"prompt": "App Publisher"},
|
||||
"app_email": {"prompt": "App Email"},
|
||||
"app_license": {"prompt": "App License", "default": "MIT"},
|
||||
"create_github_workflow": {
|
||||
"prompt": "Create GitHub Workflow action for unittests",
|
||||
"default": False,
|
||||
"type": bool,
|
||||
},
|
||||
}
|
||||
|
||||
if hook_key == "app_name" and hook_val.lower().replace(" ", "_") != hook_val:
|
||||
print("App Name must be all lowercase and without spaces")
|
||||
hook_val = ""
|
||||
elif hook_key == "app_title" and not re.match(
|
||||
r"^(?![\W])[^\d_\s][\w -]+$", hook_val, re.UNICODE
|
||||
):
|
||||
print(
|
||||
"App Title should start with a letter and it can only consist of letters, numbers, spaces and underscores"
|
||||
)
|
||||
hook_val = ""
|
||||
for property, config in new_app_config.items():
|
||||
value = None
|
||||
input_type = config.get("type", str)
|
||||
|
||||
hooks[hook_key] = hook_val
|
||||
while value is None:
|
||||
if input_type == bool:
|
||||
value = click.confirm(config["prompt"], default=config.get("default"))
|
||||
else:
|
||||
value = click.prompt(config["prompt"], default=config.get("default"), type=input_type)
|
||||
|
||||
if validator_function := config.get("validator"):
|
||||
if not validator_function(value):
|
||||
value = None
|
||||
hooks[property] = value
|
||||
|
||||
return hooks
|
||||
|
||||
|
||||
def is_valid_title(title) -> bool:
|
||||
if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", title, re.UNICODE):
|
||||
print(
|
||||
"App Title should start with a letter and it can only consist of letters, numbers, spaces and underscores"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _create_app_boilerplate(dest, hooks, no_git=False):
|
||||
frappe.create_folder(
|
||||
os.path.join(dest, hooks.app_name, hooks.app_name, frappe.scrub(hooks.app_title)), with_init=True
|
||||
)
|
||||
|
|
@ -123,6 +141,9 @@ def make_boilerplate(dest, app_name, no_git=False):
|
|||
|
||||
app_directory = os.path.join(dest, hooks.app_name)
|
||||
|
||||
if hooks.create_github_workflow:
|
||||
_create_github_workflow_files(dest, hooks)
|
||||
|
||||
if not no_git:
|
||||
with open(os.path.join(dest, hooks.app_name, ".gitignore"), "w") as f:
|
||||
f.write(frappe.as_unicode(gitignore_template.format(app_name=hooks.app_name)))
|
||||
|
|
@ -132,7 +153,16 @@ def make_boilerplate(dest, app_name, no_git=False):
|
|||
app_repo.git.add(A=True)
|
||||
app_repo.index.commit("feat: Initialize App")
|
||||
|
||||
print("'{app}' created at {path}".format(app=app_name, path=app_directory))
|
||||
print(f"'{hooks.app_name}' created at {app_directory}")
|
||||
|
||||
|
||||
def _create_github_workflow_files(dest, hooks):
|
||||
workflows_path = pathlib.Path(dest) / hooks.app_name / ".github" / "workflows"
|
||||
workflows_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ci_workflow = workflows_path / "ci.yml"
|
||||
with open(ci_workflow, "w") as f:
|
||||
f.write(github_workflow_template.format(**hooks))
|
||||
|
||||
|
||||
manifest_template = """include MANIFEST.in
|
||||
|
|
@ -165,8 +195,6 @@ app_name = "{app_name}"
|
|||
app_title = "{app_title}"
|
||||
app_publisher = "{app_publisher}"
|
||||
app_description = "{app_description}"
|
||||
app_icon = "{app_icon}"
|
||||
app_color = "{app_color}"
|
||||
app_email = "{app_email}"
|
||||
app_license = "{app_license}"
|
||||
|
||||
|
|
@ -364,8 +392,6 @@ def get_data():
|
|||
return [
|
||||
{{
|
||||
"module_name": "{app_title}",
|
||||
"color": "{app_color}",
|
||||
"icon": "{app_icon}",
|
||||
"type": "module",
|
||||
"label": _("{app_title}")
|
||||
}}
|
||||
|
|
@ -398,7 +424,8 @@ gitignore_template = """.DS_Store
|
|||
*.egg-info
|
||||
*.swp
|
||||
tags
|
||||
{app_name}/docs/current"""
|
||||
{app_name}/docs/current
|
||||
node_modules/"""
|
||||
|
||||
docs_template = '''"""
|
||||
Configuration for docs
|
||||
|
|
@ -411,3 +438,96 @@ Configuration for docs
|
|||
def get_context(context):
|
||||
context.brand_html = "{app_title}"
|
||||
'''
|
||||
|
||||
|
||||
github_workflow_template = """
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: develop-{app_name}-${{{{ github.event.number }}}}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
name: Server
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:10.3
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14
|
||||
check-latest: true
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{{{ runner.os }}}}-pip-${{{{ hashFiles('**/*requirements.txt') }}}}
|
||||
restore-keys: |
|
||||
${{{{ runner.os }}}}-pip-
|
||||
${{{{ runner.os }}}}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: 'echo "::set-output name=dir::$(yarn cache dir)"'
|
||||
|
||||
- uses: actions/cache@v2
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{{{ steps.yarn-cache-dir-path.outputs.dir }}}}
|
||||
key: ${{{{ runner.os }}}}-yarn-${{{{ hashFiles('**/yarn.lock') }}}}
|
||||
restore-keys: |
|
||||
${{{{ runner.os }}}}-yarn-
|
||||
|
||||
- name: Setup
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench
|
||||
mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
- name: Install
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench get-app {app_name} $GITHUB_WORKSPACE
|
||||
bench setup requirements --dev
|
||||
bench new-site --db-root-password root --admin-password admin test_site
|
||||
bench --site test_site install-app {app_name}
|
||||
bench build
|
||||
env:
|
||||
CI: 'Yes'
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench --site test_site set-config allow_tests true
|
||||
bench --site test_site run-tests --app {app_name}
|
||||
env:
|
||||
TYPE: server
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import math
|
|||
import operator
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from code import compile_command
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple, TypeVar, Union
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
from click import secho
|
||||
|
|
@ -18,6 +19,14 @@ from click import secho
|
|||
import frappe
|
||||
from frappe.desk.utils import slug
|
||||
|
||||
DateTimeLikeObject = Union[str, datetime.date, datetime.datetime]
|
||||
NumericType = Union[int, float]
|
||||
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
DATE_FORMAT = "%Y-%m-%d"
|
||||
TIME_FORMAT = "%H:%M:%S.%f"
|
||||
DATETIME_FORMAT = DATE_FORMAT + " " + TIME_FORMAT
|
||||
|
|
@ -33,23 +42,22 @@ class Weekday(Enum):
|
|||
Saturday = 6
|
||||
|
||||
|
||||
def get_first_day_of_the_week():
|
||||
def get_first_day_of_the_week() -> str:
|
||||
return frappe.get_system_settings("first_day_of_the_week") or "Sunday"
|
||||
|
||||
|
||||
def get_start_of_week_index():
|
||||
def get_start_of_week_index() -> int:
|
||||
return Weekday[get_first_day_of_the_week()].value
|
||||
|
||||
|
||||
def is_invalid_date_string(date_string):
|
||||
def is_invalid_date_string(date_string: str) -> bool:
|
||||
# dateutil parser does not agree with dates like "0001-01-01" or "0000-00-00"
|
||||
return not isinstance(date_string, str) or (
|
||||
(not date_string) or (date_string or "").startswith(("0001-01-01", "0000-00-00"))
|
||||
)
|
||||
|
||||
|
||||
# datetime functions
|
||||
def getdate(string_date: Optional[str] = None):
|
||||
def getdate(string_date: Optional["DateTimeLikeObject"] = None) -> Optional[datetime.date]:
|
||||
"""
|
||||
Converts string date (yyyy-mm-dd) to datetime.date object.
|
||||
If no input is provided, current date is returned.
|
||||
|
|
@ -76,7 +84,9 @@ def getdate(string_date: Optional[str] = None):
|
|||
)
|
||||
|
||||
|
||||
def get_datetime(datetime_str=None):
|
||||
def get_datetime(
|
||||
datetime_str: Optional["DateTimeLikeObject"] = None,
|
||||
) -> Optional[datetime.datetime]:
|
||||
from dateutil import parser
|
||||
|
||||
if datetime_str is None:
|
||||
|
|
@ -132,7 +142,7 @@ def get_timedelta(time: Optional[str] = None) -> Optional[datetime.timedelta]:
|
|||
return None
|
||||
|
||||
|
||||
def to_timedelta(time_str):
|
||||
def to_timedelta(time_str: Union[str, datetime.time]) -> datetime.timedelta:
|
||||
from dateutil import parser
|
||||
|
||||
if isinstance(time_str, datetime.time):
|
||||
|
|
@ -148,6 +158,7 @@ def to_timedelta(time_str):
|
|||
return time_str
|
||||
|
||||
|
||||
@typing.overload
|
||||
def add_to_date(
|
||||
date,
|
||||
years=0,
|
||||
|
|
@ -157,9 +168,56 @@ def add_to_date(
|
|||
hours=0,
|
||||
minutes=0,
|
||||
seconds=0,
|
||||
as_string: Literal[False] = False,
|
||||
as_datetime: Literal[False] = False,
|
||||
) -> datetime.date:
|
||||
...
|
||||
|
||||
|
||||
@typing.overload
|
||||
def add_to_date(
|
||||
date,
|
||||
years=0,
|
||||
months=0,
|
||||
weeks=0,
|
||||
days=0,
|
||||
hours=0,
|
||||
minutes=0,
|
||||
seconds=0,
|
||||
as_string: Literal[False] = False,
|
||||
as_datetime: Literal[True] = True,
|
||||
) -> datetime.datetime:
|
||||
...
|
||||
|
||||
|
||||
@typing.overload
|
||||
def add_to_date(
|
||||
date,
|
||||
years=0,
|
||||
months=0,
|
||||
weeks=0,
|
||||
days=0,
|
||||
hours=0,
|
||||
minutes=0,
|
||||
seconds=0,
|
||||
as_string: Literal[True] = True,
|
||||
as_datetime: bool = False,
|
||||
) -> str:
|
||||
...
|
||||
|
||||
|
||||
def add_to_date(
|
||||
date: DateTimeLikeObject,
|
||||
years=0,
|
||||
months=0,
|
||||
weeks=0,
|
||||
days=0,
|
||||
hours=0,
|
||||
minutes=0,
|
||||
seconds=0,
|
||||
as_string=False,
|
||||
as_datetime=False,
|
||||
):
|
||||
) -> DateTimeLikeObject:
|
||||
"""Adds `days` to the given date"""
|
||||
from dateutil import parser
|
||||
from dateutil.parser._parser import ParserError
|
||||
|
|
@ -272,7 +330,7 @@ def convert_utc_to_user_timezone(utc_timestamp):
|
|||
return convert_utc_to_timezone(utc_timestamp, time_zone)
|
||||
|
||||
|
||||
def now():
|
||||
def now() -> str:
|
||||
"""return current datetime as yyyy-mm-dd hh:mm:ss"""
|
||||
if frappe.flags.current_date:
|
||||
return (
|
||||
|
|
@ -284,16 +342,16 @@ def now():
|
|||
return now_datetime().strftime(DATETIME_FORMAT)
|
||||
|
||||
|
||||
def nowdate():
|
||||
def nowdate() -> str:
|
||||
"""return current date as yyyy-mm-dd"""
|
||||
return now_datetime().strftime(DATE_FORMAT)
|
||||
|
||||
|
||||
def today():
|
||||
def today() -> str:
|
||||
return nowdate()
|
||||
|
||||
|
||||
def get_abbr(string, max_len=2):
|
||||
def get_abbr(string: str, max_len: int = 2) -> str:
|
||||
abbr = ""
|
||||
for part in string.split(" "):
|
||||
if len(abbr) < max_len and part:
|
||||
|
|
@ -302,12 +360,25 @@ def get_abbr(string, max_len=2):
|
|||
return abbr or "?"
|
||||
|
||||
|
||||
def nowtime():
|
||||
def nowtime() -> str:
|
||||
"""return current time in hh:mm"""
|
||||
return now_datetime().strftime(TIME_FORMAT)
|
||||
|
||||
|
||||
def get_first_day(dt, d_years=0, d_months=0, as_str=False):
|
||||
@typing.overload
|
||||
def get_first_day(dt, d_years=0, d_months=0, as_str: Literal[False] = False) -> datetime.date:
|
||||
...
|
||||
|
||||
|
||||
@typing.overload
|
||||
def get_first_day(dt, d_years=0, d_months=0, as_str: Literal[True] = False) -> str:
|
||||
...
|
||||
|
||||
|
||||
# TODO: first arg
|
||||
def get_first_day(
|
||||
dt, d_years: int = 0, d_months: int = 0, as_str: bool = False
|
||||
) -> Union[str, datetime.date]:
|
||||
"""
|
||||
Returns the first day of the month for the date specified by date object
|
||||
Also adds `d_years` and `d_months` if specified
|
||||
|
|
@ -325,7 +396,17 @@ def get_first_day(dt, d_years=0, d_months=0, as_str=False):
|
|||
)
|
||||
|
||||
|
||||
def get_quarter_start(dt, as_str=False):
|
||||
@typing.overload
|
||||
def get_quarter_start(dt, as_str: Literal[False] = False) -> datetime.date:
|
||||
...
|
||||
|
||||
|
||||
@typing.overload
|
||||
def get_quarter_start(dt, as_str: Literal[True] = False) -> str:
|
||||
...
|
||||
|
||||
|
||||
def get_quarter_start(dt, as_str: bool = False) -> Union[str, datetime.date]:
|
||||
date = getdate(dt)
|
||||
quarter = (date.month - 1) // 3 + 1
|
||||
first_date_of_quarter = datetime.date(date.year, ((quarter - 1) * 3) + 1, 1)
|
||||
|
|
@ -414,19 +495,19 @@ def get_time(time_str: str) -> datetime.time:
|
|||
raise e
|
||||
|
||||
|
||||
def get_datetime_str(datetime_obj):
|
||||
def get_datetime_str(datetime_obj) -> str:
|
||||
if isinstance(datetime_obj, str):
|
||||
datetime_obj = get_datetime(datetime_obj)
|
||||
return datetime_obj.strftime(DATETIME_FORMAT)
|
||||
|
||||
|
||||
def get_date_str(date_obj):
|
||||
def get_date_str(date_obj) -> str:
|
||||
if isinstance(date_obj, str):
|
||||
date_obj = get_datetime(date_obj)
|
||||
return date_obj.strftime(DATE_FORMAT)
|
||||
|
||||
|
||||
def get_time_str(timedelta_obj):
|
||||
def get_time_str(timedelta_obj) -> str:
|
||||
if isinstance(timedelta_obj, str):
|
||||
timedelta_obj = to_timedelta(timedelta_obj)
|
||||
|
||||
|
|
@ -435,7 +516,7 @@ def get_time_str(timedelta_obj):
|
|||
return "{0}:{1}:{2}".format(hours, minutes, seconds)
|
||||
|
||||
|
||||
def get_user_date_format():
|
||||
def get_user_date_format() -> str:
|
||||
"""Get the current user date format. The result will be cached."""
|
||||
if getattr(frappe.local, "user_date_format", None) is None:
|
||||
frappe.local.user_date_format = frappe.db.get_default("date_format")
|
||||
|
|
@ -446,7 +527,7 @@ def get_user_date_format():
|
|||
get_user_format = get_user_date_format # for backwards compatibility
|
||||
|
||||
|
||||
def get_user_time_format():
|
||||
def get_user_time_format() -> str:
|
||||
"""Get the current user time format. The result will be cached."""
|
||||
if getattr(frappe.local, "user_time_format", None) is None:
|
||||
frappe.local.user_time_format = frappe.db.get_default("time_format")
|
||||
|
|
@ -454,7 +535,7 @@ def get_user_time_format():
|
|||
return frappe.local.user_time_format or "HH:mm:ss"
|
||||
|
||||
|
||||
def format_date(string_date=None, format_string=None):
|
||||
def format_date(string_date=None, format_string: Optional[str] = None) -> str:
|
||||
"""Converts the given string date to :data:`user_date_format`
|
||||
User format specified in defaults
|
||||
|
||||
|
|
@ -487,7 +568,7 @@ def format_date(string_date=None, format_string=None):
|
|||
formatdate = format_date # For backwards compatibility
|
||||
|
||||
|
||||
def format_time(time_string=None, format_string=None):
|
||||
def format_time(time_string=None, format_string: Optional[str] = None) -> str:
|
||||
"""Converts the given string time to :data:`user_time_format`
|
||||
User format specified in defaults
|
||||
|
||||
|
|
@ -514,7 +595,9 @@ def format_time(time_string=None, format_string=None):
|
|||
return formatted_time
|
||||
|
||||
|
||||
def format_datetime(datetime_string, format_string=None):
|
||||
def format_datetime(
|
||||
datetime_string: DateTimeLikeObject, format_string: Optional[str] = None
|
||||
) -> str:
|
||||
"""Converts the given string time to :data:`user_datetime_format`
|
||||
User format specified in defaults
|
||||
|
||||
|
|
@ -624,7 +707,7 @@ def get_weekdays():
|
|||
return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
def get_weekday(datetime=None):
|
||||
def get_weekday(datetime: Optional[datetime.datetime] = None) -> str:
|
||||
if not datetime:
|
||||
datetime = now_datetime()
|
||||
weekdays = get_weekdays()
|
||||
|
|
@ -698,7 +781,7 @@ def global_date_format(date, format="long"):
|
|||
return formatted_date
|
||||
|
||||
|
||||
def has_common(l1, l2):
|
||||
def has_common(l1: typing.Hashable, l2: typing.Hashable) -> bool:
|
||||
"""Returns truthy value if there are common elements in lists l1 and l2"""
|
||||
return set(l1) & set(l2)
|
||||
|
||||
|
|
@ -790,7 +873,17 @@ def cast(fieldtype, value=None):
|
|||
return value
|
||||
|
||||
|
||||
def flt(s, precision=None):
|
||||
@typing.overload
|
||||
def flt(s: Union[NumericType, str], precision: Literal[0] = None) -> int:
|
||||
...
|
||||
|
||||
|
||||
@typing.overload
|
||||
def flt(s: Union[NumericType, str], precision: Optional[int] = None) -> float:
|
||||
...
|
||||
|
||||
|
||||
def flt(s: Union[NumericType, str], precision: Optional[int] = None) -> float:
|
||||
"""Convert to float (ignoring commas in string)
|
||||
|
||||
:param s: Number in string or other numeric format.
|
||||
|
|
@ -823,7 +916,7 @@ def flt(s, precision=None):
|
|||
return num
|
||||
|
||||
|
||||
def cint(s, default=0):
|
||||
def cint(s: Union[NumericType, str], default: int = 0) -> int:
|
||||
"""Convert to integer
|
||||
|
||||
:param s: Number in string or other numeric format.
|
||||
|
|
@ -938,7 +1031,7 @@ def rounded(num, precision=0):
|
|||
return (num / multiplier) if precision else num
|
||||
|
||||
|
||||
def remainder(numerator, denominator, precision=2):
|
||||
def remainder(numerator: NumericType, denominator: NumericType, precision: int = 2) -> NumericType:
|
||||
precision = cint(precision)
|
||||
multiplier = 10**precision
|
||||
|
||||
|
|
@ -950,7 +1043,7 @@ def remainder(numerator, denominator, precision=2):
|
|||
return flt(_remainder, precision)
|
||||
|
||||
|
||||
def safe_div(numerator, denominator, precision=2):
|
||||
def safe_div(numerator: NumericType, denominator: NumericType, precision: int = 2) -> float:
|
||||
"""
|
||||
SafeMath division that returns zero when divided by zero.
|
||||
"""
|
||||
|
|
@ -1007,7 +1100,12 @@ def parse_val(v):
|
|||
return v
|
||||
|
||||
|
||||
def fmt_money(amount, precision=None, currency=None, format=None):
|
||||
def fmt_money(
|
||||
amount: Union[str, float, int],
|
||||
precision: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
format: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Convert to string with commas for thousands, millions etc
|
||||
"""
|
||||
|
|
@ -1104,7 +1202,9 @@ def get_number_format_info(format: str) -> Tuple[str, str, int]:
|
|||
# convert currency to words
|
||||
#
|
||||
def money_in_words(
|
||||
number: str, main_currency: Optional[str] = None, fraction_currency: Optional[str] = None
|
||||
number: Union[str, float, int],
|
||||
main_currency: Optional[str] = None,
|
||||
fraction_currency: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Returns string in words with currency and fraction currency.
|
||||
|
|
@ -1177,7 +1277,7 @@ def money_in_words(
|
|||
#
|
||||
# convert number to words
|
||||
#
|
||||
def in_words(integer, in_million=True):
|
||||
def in_words(integer: int, in_million=True) -> str:
|
||||
"""
|
||||
Returns string in words for the given integer.
|
||||
"""
|
||||
|
|
@ -1194,13 +1294,13 @@ def in_words(integer, in_million=True):
|
|||
return ret.replace("-", " ")
|
||||
|
||||
|
||||
def is_html(text):
|
||||
def is_html(text: str) -> bool:
|
||||
if not isinstance(text, str):
|
||||
return False
|
||||
return re.search("<[^>]+>", text)
|
||||
|
||||
|
||||
def is_image(filepath):
|
||||
def is_image(filepath: str) -> bool:
|
||||
from mimetypes import guess_type
|
||||
|
||||
# filepath can be https://example.com/bed.jpg?v=129
|
||||
|
|
@ -1249,7 +1349,7 @@ def get_thumbnail_base64_for_image(src):
|
|||
return cache().hget("thumbnail_base64", src, generator=_get_base64)
|
||||
|
||||
|
||||
def image_to_base64(image, extn):
|
||||
def image_to_base64(image, extn: str) -> bytes:
|
||||
from io import BytesIO
|
||||
|
||||
buffered = BytesIO()
|
||||
|
|
@ -1260,7 +1360,7 @@ def image_to_base64(image, extn):
|
|||
return img_str
|
||||
|
||||
|
||||
def pdf_to_base64(filename):
|
||||
def pdf_to_base64(filename: str) -> Optional[bytes]:
|
||||
from frappe.utils.file_manager import get_file_path
|
||||
|
||||
if "../" in filename or filename.rsplit(".")[-1] not in ["pdf", "PDF"]:
|
||||
|
|
@ -1280,12 +1380,12 @@ def pdf_to_base64(filename):
|
|||
_striptags_re = re.compile(r"(<!--.*?-->|<[^>]*>)")
|
||||
|
||||
|
||||
def strip_html(text):
|
||||
def strip_html(text: str) -> str:
|
||||
"""removes anything enclosed in and including <>"""
|
||||
return _striptags_re.sub("", text)
|
||||
|
||||
|
||||
def escape_html(text):
|
||||
def escape_html(text: str) -> str:
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
|
||||
|
|
@ -1300,7 +1400,7 @@ def escape_html(text):
|
|||
return "".join(html_escape_table.get(c, c) for c in text)
|
||||
|
||||
|
||||
def pretty_date(iso_datetime):
|
||||
def pretty_date(iso_datetime: Union[datetime.datetime, str]) -> str:
|
||||
"""
|
||||
Takes an ISO time and returns a string representing how
|
||||
long ago the date represents.
|
||||
|
|
@ -1391,12 +1491,12 @@ def new_line_sep(some_list):
|
|||
return some_list
|
||||
|
||||
|
||||
def filter_strip_join(some_list, sep):
|
||||
def filter_strip_join(some_list: List[str], sep: str) -> List[str]:
|
||||
"""given a list, filter None values, strip spaces and join"""
|
||||
return (cstr(sep)).join((cstr(a).strip() for a in filter(None, some_list)))
|
||||
|
||||
|
||||
def get_url(uri=None, full_address=False):
|
||||
def get_url(uri: Optional[str] = None, full_address: bool = False) -> str:
|
||||
"""get app url from request"""
|
||||
host_name = frappe.local.conf.host_name or frappe.local.conf.hostname
|
||||
|
||||
|
|
@ -1453,7 +1553,7 @@ def get_url(uri=None, full_address=False):
|
|||
return url
|
||||
|
||||
|
||||
def get_host_name_from_request():
|
||||
def get_host_name_from_request() -> str:
|
||||
if hasattr(frappe.local, "request") and frappe.local.request and frappe.local.request.host:
|
||||
protocol = (
|
||||
"https://" if "https" == frappe.get_request_header("X-Forwarded-Proto", "") else "http://"
|
||||
|
|
@ -1461,23 +1561,29 @@ def get_host_name_from_request():
|
|||
return protocol + frappe.local.request.host
|
||||
|
||||
|
||||
def url_contains_port(url):
|
||||
def url_contains_port(url: str) -> bool:
|
||||
parts = url.split(":")
|
||||
return len(parts) > 2
|
||||
|
||||
|
||||
def get_host_name():
|
||||
def get_host_name() -> str:
|
||||
return get_url().rsplit("//", 1)[-1]
|
||||
|
||||
|
||||
def get_link_to_form(doctype, name, label=None):
|
||||
def get_link_to_form(doctype: str, name: str, label: Optional[str] = None) -> str:
|
||||
if not label:
|
||||
label = name
|
||||
|
||||
return """<a href="{0}">{1}</a>""".format(get_url_to_form(doctype, name), label)
|
||||
|
||||
|
||||
def get_link_to_report(name, label=None, report_type=None, doctype=None, filters=None):
|
||||
def get_link_to_report(
|
||||
name: str,
|
||||
label: Optional[str] = None,
|
||||
report_type: Optional[str] = None,
|
||||
doctype: Optional[str] = None,
|
||||
filters: Optional[Dict] = None,
|
||||
) -> str:
|
||||
if not label:
|
||||
label = name
|
||||
|
||||
|
|
@ -1501,19 +1607,21 @@ def get_link_to_report(name, label=None, report_type=None, doctype=None, filters
|
|||
return """<a href='{0}'>{1}</a>""".format(get_url_to_report(name, report_type, doctype), label)
|
||||
|
||||
|
||||
def get_absolute_url(doctype, name):
|
||||
def get_absolute_url(doctype: str, name: str) -> str:
|
||||
return "/app/{0}/{1}".format(quoted(slug(doctype)), quoted(name))
|
||||
|
||||
|
||||
def get_url_to_form(doctype, name):
|
||||
def get_url_to_form(doctype: str, name: str) -> str:
|
||||
return get_url(uri="/app/{0}/{1}".format(quoted(slug(doctype)), quoted(name)))
|
||||
|
||||
|
||||
def get_url_to_list(doctype):
|
||||
def get_url_to_list(doctype: str) -> str:
|
||||
return get_url(uri="/app/{0}".format(quoted(slug(doctype))))
|
||||
|
||||
|
||||
def get_url_to_report(name, report_type=None, doctype=None):
|
||||
def get_url_to_report(
|
||||
name, report_type: Optional[str] = None, doctype: Optional[str] = None
|
||||
) -> str:
|
||||
if report_type == "Report Builder":
|
||||
return get_url(uri="/app/{0}/view/report/{1}".format(quoted(slug(doctype)), quoted(name)))
|
||||
else:
|
||||
|
|
@ -1680,7 +1788,7 @@ def make_filter_dict(filters):
|
|||
return _filter
|
||||
|
||||
|
||||
def sanitize_column(column_name):
|
||||
def sanitize_column(column_name: str) -> None:
|
||||
import sqlparse
|
||||
|
||||
from frappe import _
|
||||
|
|
@ -1716,14 +1824,14 @@ def sanitize_column(column_name):
|
|||
_raise_exception()
|
||||
|
||||
|
||||
def scrub_urls(html):
|
||||
def scrub_urls(html: str) -> str:
|
||||
html = expand_relative_urls(html)
|
||||
# encoding should be responsibility of the composer
|
||||
# html = quote_urls(html)
|
||||
return html
|
||||
|
||||
|
||||
def expand_relative_urls(html):
|
||||
def expand_relative_urls(html: str) -> str:
|
||||
# expand relative urls
|
||||
url = get_url()
|
||||
if url.endswith("/"):
|
||||
|
|
@ -1752,11 +1860,11 @@ def expand_relative_urls(html):
|
|||
return html
|
||||
|
||||
|
||||
def quoted(url):
|
||||
def quoted(url: str) -> str:
|
||||
return cstr(quote(encode(cstr(url)), safe=b"~@#$&()*!+=:;,.?/'"))
|
||||
|
||||
|
||||
def quote_urls(html):
|
||||
def quote_urls(html: str) -> str:
|
||||
def _quote_url(match):
|
||||
groups = list(match.groups())
|
||||
groups[2] = quoted(groups[2])
|
||||
|
|
@ -1765,7 +1873,7 @@ def quote_urls(html):
|
|||
return re.sub(r'(href|src){1}([\s]*=[\s]*[\'"]?)((?:http)[^\'">]+)([\'"]?)', _quote_url, html)
|
||||
|
||||
|
||||
def unique(seq):
|
||||
def unique(seq: typing.Sequence["T"]) -> List["T"]:
|
||||
"""use this instead of list(set()) to preserve order of the original list.
|
||||
Thanks to Stackoverflow: http://stackoverflow.com/questions/480214/how-do-you-remove-duplicates-from-a-list-in-python-whilst-preserving-order"""
|
||||
|
||||
|
|
@ -1774,26 +1882,23 @@ def unique(seq):
|
|||
return [x for x in seq if not (x in seen or seen_add(x))]
|
||||
|
||||
|
||||
def strip(val, chars=None):
|
||||
def strip(val: str, chars: Optional[str] = None) -> str:
|
||||
# \ufeff is no-width-break, \u200b is no-width-space
|
||||
return (val or "").replace("\ufeff", "").replace("\u200b", "").strip(chars)
|
||||
|
||||
|
||||
def to_markdown(html):
|
||||
def to_markdown(html: str) -> str:
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from html2text import html2text
|
||||
|
||||
text = None
|
||||
try:
|
||||
text = html2text(html or "")
|
||||
return html2text(html or "")
|
||||
except HTMLParser.HTMLParseError:
|
||||
pass
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def md_to_html(markdown_text):
|
||||
def md_to_html(markdown_text: str) -> Optional["UnicodeWithAttrs"]:
|
||||
from markdown2 import MarkdownError
|
||||
from markdown2 import markdown as _markdown
|
||||
|
||||
|
|
@ -1806,14 +1911,11 @@ def md_to_html(markdown_text):
|
|||
"html-classes": {"table": "table table-bordered", "img": "screenshot"},
|
||||
}
|
||||
|
||||
html = None
|
||||
try:
|
||||
html = UnicodeWithAttrs(_markdown(markdown_text or "", extras=extras))
|
||||
return UnicodeWithAttrs(_markdown(markdown_text or "", extras=extras))
|
||||
except MarkdownError:
|
||||
pass
|
||||
|
||||
return html
|
||||
|
||||
|
||||
def markdown(markdown_text):
|
||||
return md_to_html(markdown_text)
|
||||
|
|
@ -1911,7 +2013,13 @@ def validate_json_string(string: str) -> None:
|
|||
raise frappe.ValidationError
|
||||
|
||||
|
||||
def get_user_info_for_avatar(user_id: str) -> Dict:
|
||||
class _UserInfo(typing.TypedDict):
|
||||
email: str
|
||||
image: Optional[str]
|
||||
name: str
|
||||
|
||||
|
||||
def get_user_info_for_avatar(user_id: str) -> _UserInfo:
|
||||
try:
|
||||
user = frappe.get_cached_doc("User", user_id)
|
||||
return {"email": user.email, "image": user.user_image, "name": user.full_name}
|
||||
|
|
@ -1921,7 +2029,9 @@ def get_user_info_for_avatar(user_id: str) -> Dict:
|
|||
return {"email": user_id, "image": "", "name": user_id}
|
||||
|
||||
|
||||
def validate_python_code(string: str, fieldname=None, is_expression: bool = True) -> None:
|
||||
def validate_python_code(
|
||||
string: str, fieldname: Optional[str] = None, is_expression: bool = True
|
||||
) -> None:
|
||||
"""Validate python code fields by using compile_command to ensure that expression is valid python.
|
||||
|
||||
args:
|
||||
|
|
|
|||
|
|
@ -103,11 +103,11 @@ def enqueue_events(site):
|
|||
|
||||
def is_scheduler_inactive():
|
||||
if frappe.local.conf.maintenance_mode:
|
||||
cprint("Maintenance mode is ON")
|
||||
cprint(f"{frappe.local.site}: Maintenance mode is ON")
|
||||
return True
|
||||
|
||||
if frappe.local.conf.pause_scheduler:
|
||||
cprint("frappe.conf.pause_scheduler is SET")
|
||||
cprint(f"{frappe.local.site}: frappe.conf.pause_scheduler is SET")
|
||||
return True
|
||||
|
||||
if is_scheduler_disabled():
|
||||
|
|
@ -118,14 +118,14 @@ def is_scheduler_inactive():
|
|||
|
||||
def is_scheduler_disabled():
|
||||
if frappe.conf.disable_scheduler:
|
||||
cprint("frappe.conf.disable_scheduler is SET")
|
||||
cprint(f"{frappe.local.site}: frappe.conf.disable_scheduler is SET")
|
||||
return True
|
||||
|
||||
scheduler_disabled = not frappe.utils.cint(
|
||||
frappe.db.get_single_value("System Settings", "enable_scheduler")
|
||||
)
|
||||
if scheduler_disabled:
|
||||
cprint("SystemSettings.enable_scheduler is UNSET")
|
||||
cprint(f"{frappe.local.site}: SystemSettings.enable_scheduler is UNSET")
|
||||
return scheduler_disabled
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from frappe.website.page_renderers.document_page import DocumentPage
|
|||
|
||||
class WebFormPage(DocumentPage):
|
||||
def can_render(self):
|
||||
webform_name = frappe.db.exists("Web Form", {"route": self.path}, cache=True)
|
||||
webform_name = frappe.db.exists("Web Form", {"route": self.path, "published": 1}, cache=True)
|
||||
if webform_name:
|
||||
self.doctype = "Web Form"
|
||||
self.docname = webform_name
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class MetaTags:
|
|||
|
||||
def init_metatags_from_context(self):
|
||||
for key in METATAGS:
|
||||
if key not in self.tags and self.context.get(key):
|
||||
if not self.tags.get(key) and self.context.get(key):
|
||||
self.tags[key] = self.context[key]
|
||||
|
||||
if not self.tags.get("title"):
|
||||
|
|
|
|||
5
frappe/www/_test/_test_metatags.html
Normal file
5
frappe/www/_test/_test_metatags.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
base_template: frappe/templates/web.html
|
||||
---
|
||||
|
||||
<h1>Test Metatags</h1>
|
||||
8
frappe/www/_test/_test_metatags.py
Normal file
8
frappe/www/_test/_test_metatags.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
def get_context():
|
||||
return {"title": "Test Title Metatag", "description": "Test Description for Metatag"}
|
||||
2
hooks.md
2
hooks.md
|
|
@ -7,8 +7,6 @@
|
|||
1. `app_publisher`
|
||||
1. `app_description`
|
||||
1. `app_version`
|
||||
1. `app_icon` - font-awesome icon or image url
|
||||
1. `app_color` - hex colour background of the app icon
|
||||
|
||||
#### Install
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue