Merge branch 'develop' into atom

This commit is contained in:
Ritwik Puri 2022-05-11 22:35:09 +05:30 committed by GitHub
commit 7fa9c42711
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1246 additions and 872 deletions

View file

@ -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

View file

@ -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]

View file

@ -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",

View file

@ -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):

View file

@ -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))

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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;
}

View file

@ -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 "";
}

View file

@ -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() {

View file

@ -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);

View file

@ -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`;

View file

@ -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) {

View file

@ -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) {

View file

@ -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();

View file

@ -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() {

View file

@ -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`);

View file

@ -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">

View file

@ -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});

View file

@ -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";

View file

@ -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)

View file

@ -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"

View file

@ -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):

View 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'",
)

View file

@ -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()

View file

@ -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

View file

@ -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
"""

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"):

View file

@ -0,0 +1,5 @@
---
base_template: frappe/templates/web.html
---
<h1>Test Metatags</h1>

View 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"}

View file

@ -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