diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 93189d2b1f..6c81d6298a 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -59,4 +59,4 @@ cd ../..
bench start &
bench --site test_site reinstall --yes
if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi
-bench build --app frappe
+CI=Yes bench build --app frappe
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
index 56137d0bea..8758c4e273 100644
--- a/.github/workflows/patch-mariadb-tests.yml
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -29,7 +29,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
- python-version: 3.7
+ python-version: '3.9'
- name: Check if build should be run
id: check-build
diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml
index 85f3f7c3b0..f56d1460b5 100644
--- a/.github/workflows/publish-assets-develop.yml
+++ b/.github/workflows/publish-assets-develop.yml
@@ -18,7 +18,7 @@ jobs:
node-version: 14
- uses: actions/setup-python@v2
with:
- python-version: '3.7'
+ python-version: '3.9'
- name: Set up bench and build assets
run: |
npm install -g yarn
diff --git a/.github/workflows/publish-assets-releases.yml b/.github/workflows/publish-assets-releases.yml
index a5cc1f8872..2582632fa0 100644
--- a/.github/workflows/publish-assets-releases.yml
+++ b/.github/workflows/publish-assets-releases.yml
@@ -21,7 +21,7 @@ jobs:
python-version: '12.x'
- uses: actions/setup-python@v2
with:
- python-version: '3.7'
+ python-version: '3.9'
- name: Set up bench and build assets
run: |
npm install -g yarn
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
index 0b187fc44c..588f357f26 100644
--- a/.github/workflows/server-mariadb-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -38,7 +38,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
- python-version: 3.7
+ python-version: '3.9'
- name: Check if build should be run
id: check-build
@@ -127,4 +127,5 @@ jobs:
name: MariaDB
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
- verbose: true
\ No newline at end of file
+ verbose: true
+ flags: server
\ No newline at end of file
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
index a5630121a4..78f379837b 100644
--- a/.github/workflows/server-postgres-tests.yml
+++ b/.github/workflows/server-postgres-tests.yml
@@ -41,7 +41,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
- python-version: 3.7
+ python-version: '3.9'
- name: Check if build should be run
id: check-build
@@ -131,3 +131,4 @@ jobs:
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
+ flags: server
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index 0727b06043..fcc53ba59c 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -37,7 +37,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
- python-version: 3.7
+ python-version: '3.9'
- name: Check if build should be run
id: check-build
@@ -122,12 +122,36 @@ jobs:
DB: mariadb
TYPE: ui
+ - name: Instrument Source Code
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe
+
+ - name: Build
+ if: ${{ steps.check-build.outputs.build == 'strawberry' }}
+ run: cd ~/frappe-bench/ && bench build --apps frappe
+
- name: Site Setup
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard
- name: UI Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
- run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --headless --parallel --ci-build-id $GITHUB_RUN_ID
+ run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID
env:
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
+
+ - name: Check If Coverage Report Exists
+ id: check_coverage
+ uses: andstor/file-existence-action@v1
+ with:
+ files: "/home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml"
+
+ - name: Upload Coverage Data
+ if: ${{ steps.check-build.outputs.build == 'strawberry' && steps.check_coverage.outputs.files_exists == 'true' }}
+ uses: codecov/codecov-action@v2
+ with:
+ name: Cypress
+ fail_ci_if_error: true
+ directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/
+ verbose: true
+ flags: ui-tests
diff --git a/.gitignore b/.gitignore
index 1ff3122d70..c9dd8f38f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,6 +67,7 @@ coverage.xml
*.cover
.hypothesis/
.pytest_cache/
+.cypress-coverage
# Translations
*.mo
diff --git a/README.md b/README.md
index 6c2804d843..f8a1907da2 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@
-
+
@@ -35,25 +35,29 @@
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com)
-### Table of Contents
-* [Installation](https://frappeframework.com/docs/user/en/installation)
-* [Documentation](https://frappeframework.com/docs)
+## Table of Contents
+* [Installation](#installation)
+* [Contributing](#contributing)
+* [Resources](#resources)
* [License](#license)
-### Installation
+## Installation
* [Install via Docker](https://github.com/frappe/frappe_docker)
* [Install via Frappe Bench](https://github.com/frappe/bench)
+* [Offical Documentation](https://frappeframework.com/docs/user/en/installation)
## Contributing
+1. [Code of Conduct](CODE_OF_CONDUCT.md)
1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
+1. [Security Policy](SECURITY.md)
1. [Translations](https://translate.erpnext.com)
-### Website
+## Resources
-For details and documentation, see the website
-[https://frappeframework.com](https://frappeframework.com)
+1. [frappeframework.com](https://frappeframework.com) - Official documentation of the Frappe Framework.
+1. [frappe.school](https://frappe.school) - Pick from the various courses by the maintainers or from the community.
-### License
+## License
This repository has been released under the [MIT License](LICENSE).
diff --git a/codecov.yml b/codecov.yml
index 41b22001a5..eeba1ff381 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -4,10 +4,28 @@ codecov:
coverage:
status:
project:
- default:
+ default: false
+ server:
target: auto
threshold: 0.5%
+ flags:
+ - server
+ ui-tests:
+ target: auto
+ threshold: 0.5%
+ flags:
+ - ui-tests
comment:
- layout: "diff"
+ layout: "diff, flags"
require_changes: true
+
+flags:
+ server:
+ paths:
+ - ".*\\.py"
+ carryforward: true
+ ui-tests:
+ paths:
+ - ".*\\.js"
+ carryforward: true
\ No newline at end of file
diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js
index 973694c84b..16ffd41cf4 100644
--- a/cypress/integration/dashboard_links.js
+++ b/cypress/integration/dashboard_links.js
@@ -54,13 +54,12 @@ context('Dashboard links', () => {
cur_frm.dashboard.data.reports = [
{
'label': 'Reports',
- 'items': ['Permitted Documents For User']
+ 'items': ['Website Analytics']
}
];
cur_frm.dashboard.render_report_links();
- cy.get('[data-report="Permitted Documents For User"]').contains('Permitted Documents For User').click();
- cy.findByText('Permitted Documents For User');
- cy.findByPlaceholderText('User').should("have.value", "Administrator");
+ cy.get('[data-report="Website Analytics"]').contains('Website Analytics').click();
+ cy.findByText('Website Analytics');
});
});
});
diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js
index 66ba0761c4..3071330b61 100644
--- a/cypress/integration/timeline.js
+++ b/cypress/integration/timeline.js
@@ -44,13 +44,14 @@ context('Timeline', () => {
cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
//Deleting the added comment
- cy.get('.actions > .btn > .icon').first().click();
+ cy.get('.more-actions > .action-btn').click();
+ cy.get('.more-actions .dropdown-item').contains('Delete').click();
cy.findByRole('button', {name: 'Yes'}).click();
cy.click_modal_primary_button('Yes');
//Deleting the added ToDo
- cy.get('.menu-btn-group button').eq(1).click();
- cy.get('.menu-btn-group [data-label="Delete"]').click();
+ cy.get('.menu-btn-group [data-original-title="Menu"]').click();
+ cy.get('.menu-btn-group .dropdown-item').contains('Delete').click();
cy.findByRole('button', {name: 'Yes'}).click();
});
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
index 07d9804a73..9720faa666 100644
--- a/cypress/plugins/index.js
+++ b/cypress/plugins/index.js
@@ -11,7 +11,7 @@
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
-module.exports = () => {
- // `on` is used to hook into various events Cypress emits
- // `config` is the resolved Cypress config
-};
+module.exports = (on, config) => {
+ require('@cypress/code-coverage/task')(on, config);
+ return config;
+};
\ No newline at end of file
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 47c37a56a0..6484370946 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -353,5 +353,5 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
});
Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
- cy.get('.timeline-content > .timeline-message-box > .justify-between > .actions > .btn').contains(btn_name).click();
+ cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click();
});
\ No newline at end of file
diff --git a/cypress/support/index.js b/cypress/support/index.js
index 1bee72d2ca..9cd770a31e 100644
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -15,6 +15,7 @@
// Import commands.js using ES2015 syntax:
import './commands';
+import '@cypress/code-coverage/support';
// Alternatively you can use CommonJS syntax:
diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js
index 9074beae06..bf4436358e 100644
--- a/esbuild/esbuild.js
+++ b/esbuild/esbuild.js
@@ -104,6 +104,9 @@ async function execute() {
log_error("There were some problems during build");
log();
log(chalk.dim(e.stack));
+ if (process.env.CI) {
+ process.kill(process.pid);
+ }
return;
}
@@ -528,4 +531,4 @@ function log_rebuilt_assets(prev_assets, new_assets) {
log(" " + filename);
}
log();
-}
\ No newline at end of file
+}
diff --git a/frappe/build.py b/frappe/build.py
index 879d5ec432..05fa213018 100644
--- a/frappe/build.py
+++ b/frappe/build.py
@@ -246,7 +246,7 @@ def bundle(mode, apps=None, hard_link=False, make_copy=False, restore=False, ver
check_node_executable()
frappe_app_path = frappe.get_app_path("frappe", "..")
- frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
+ frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True)
def watch(apps=None):
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 90cd60c6ec..74af28e3ff 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -679,9 +679,10 @@ def run_parallel_tests(context, app, build_number, total_builds, with_coverage=F
@click.argument('app')
@click.option('--headless', is_flag=True, help="Run UI Test in headless mode")
@click.option('--parallel', is_flag=True, help="Run UI Test in parallel mode")
+@click.option('--with-coverage', is_flag=True, help="Generate coverage report")
@click.option('--ci-build-id')
@pass_context
-def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
+def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=False, ci_build_id=None):
"Run UI tests"
site = get_site(context)
app_base_path = os.path.abspath(os.path.join(frappe.get_app_path(app), '..'))
@@ -691,6 +692,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
# override baseUrl using env variable
site_env = f'CYPRESS_baseUrl={site_url}'
password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else ''
+ coverage_env = f'CYPRESS_coverage={str(with_coverage).lower()}'
os.chdir(app_base_path)
@@ -698,22 +700,23 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
cypress_path = f"{node_bin}/cypress"
plugin_path = f"{node_bin}/../cypress-file-upload"
testing_library_path = f"{node_bin}/../@testing-library"
+ coverage_plugin_path = f"{node_bin}/../@cypress/code-coverage"
# check if cypress in path...if not, install it.
if not (
os.path.exists(cypress_path)
and os.path.exists(plugin_path)
and os.path.exists(testing_library_path)
+ and os.path.exists(coverage_plugin_path)
and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6
):
# install cypress
click.secho("Installing Cypress...", fg="yellow")
- frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 --no-lockfile")
+ frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile")
# run for headless mode
run_or_open = 'run --browser firefox --record' if headless else 'open'
- command = '{site_env} {password_env} {cypress} {run_or_open}'
- formatted_command = command.format(site_env=site_env, password_env=password_env, cypress=cypress_path, run_or_open=run_or_open)
+ formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}'
if parallel:
formatted_command += ' --parallel'
diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py
index d9ecd85533..4df9ef3132 100755
--- a/frappe/core/doctype/file/file.py
+++ b/frappe/core/doctype/file/file.py
@@ -813,7 +813,7 @@ def extract_images_from_doc(doc, fieldname):
doc.set(fieldname, content)
-def extract_images_from_html(doc, content):
+def extract_images_from_html(doc, content, is_private=False):
frappe.flags.has_dataurl = False
def _save_file(match):
@@ -846,7 +846,8 @@ def extract_images_from_html(doc, content):
"attached_to_doctype": doctype,
"attached_to_name": name,
"content": content,
- "decode": False
+ "decode": False,
+ "is_private": is_private
})
_file.save(ignore_permissions=True)
file_url = _file.file_url
diff --git a/frappe/core/doctype/package_release/package_release.py b/frappe/core/doctype/package_release/package_release.py
index 1fb8796882..d23ae917c4 100644
--- a/frappe/core/doctype/package_release/package_release.py
+++ b/frappe/core/doctype/package_release/package_release.py
@@ -6,16 +6,27 @@ from frappe.model.document import Document
from frappe.modules.export_file import export_doc
import os
import subprocess
+from frappe.query_builder.functions import Max
+
class PackageRelease(Document):
def set_version(self):
# set the next patch release by default
+ doctype = frappe.qb.DocType("Package Release")
if not self.major:
- self.major = frappe.db.max('Package Release', 'major', dict(package=self.package))
+ self.major = frappe.qb.from_(doctype) \
+ .where(doctype.package == self.package) \
+ .select(Max(doctype.minor)).run()[0][0] or 0
+
if not self.minor:
- self.minor = frappe.db.max('Package Release', 'minor', dict(package=self.package))
+ self.minor = frappe.qb.from_(doctype) \
+ .where(doctype.package == self.package) \
+ .select(Max("minor")).run()[0][0] or 0
if not self.patch:
- self.patch = frappe.db.max('Package Release', 'patch', dict(package=self.package)) + 1
+ value = frappe.qb.from_(doctype) \
+ .where(doctype.package == self.package) \
+ .select(Max("patch")).run()[0][0] or 0
+ self.patch = value + 1
def autoname(self):
self.set_version()
diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js
index 41cc900a97..6b427fdebf 100644
--- a/frappe/core/page/permission_manager/permission_manager.js
+++ b/frappe/core/page/permission_manager/permission_manager.js
@@ -325,15 +325,15 @@ frappe.PermissionEngine = class PermissionEngine {
.attr("data-doctype", d.parent)
.attr("data-role", d.role)
.attr("data-permlevel", d.permlevel)
- .click(function () {
+ .on("click", () => {
return frappe.call({
module: "frappe.core",
page: "permission_manager",
method: "remove",
args: {
- doctype: $(this).attr("data-doctype"),
- role: $(this).attr("data-role"),
- permlevel: $(this).attr("data-permlevel")
+ doctype: d.parent,
+ role: d.role,
+ permlevel: d.permlevel
},
callback: (r) => {
if (r.exc) {
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 0ee11ea075..f07e0c38e3 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -14,8 +14,13 @@ import frappe.model.meta
from frappe import _
from time import time
-from frappe.utils import now, getdate, cast, get_datetime, get_table_name
+from frappe.utils import now, getdate, cast, get_datetime
from frappe.model.utils.link_count import flush_local_link_count
+from frappe.query_builder.functions import Count
+from frappe.query_builder.functions import Min, Max, Avg, Sum
+from frappe.query_builder.utils import Column
+from .query import Query
+from pypika.terms import PseudoColumn
class Database(object):
@@ -55,6 +60,7 @@ class Database(object):
self.password = password or frappe.conf.db_password
self.value_cache = {}
+ self.query = Query()
def setup_type_map(self):
pass
@@ -77,7 +83,7 @@ class Database(object):
pass
def sql(self, query, values=(), as_dict = 0, as_list = 0, formatted = 0,
- debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, explain=False):
+ debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, explain=False, run=True):
"""Execute a SQL query and fetch all rows.
:param query: SQL query.
@@ -90,7 +96,7 @@ class Database(object):
:param as_utf8: Encode values as UTF 8.
:param auto_commit: Commit after executing the query.
:param update: Update this dict to all rows (if returned `as_dict`).
-
+ :param run: Returns query without executing it if False.
Examples:
# return customer names as dicts
@@ -105,6 +111,8 @@ class Database(object):
"""
query = str(query)
+ if not run:
+ return query
if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE)
@@ -310,59 +318,6 @@ class Database(object):
nres.append(nr)
return nres
- def build_conditions(self, filters):
- """Convert filters sent as dict, lists to SQL conditions. filter's key
- is passed by map function, build conditions like:
-
- * ifnull(`fieldname`, default_value) = %(fieldname)s
- * `fieldname` [=, !=, >, >=, <, <=] %(fieldname)s
- """
- conditions = []
- values = {}
- def _build_condition(key):
- """
- filter's key is passed by map function
- build conditions like:
- * ifnull(`fieldname`, default_value) = %(fieldname)s
- * `fieldname` [=, !=, >, >=, <, <=] %(fieldname)s
- """
- _operator = "="
- _rhs = " %(" + key + ")s"
- value = filters.get(key)
- values[key] = value
- if isinstance(value, (list, tuple)):
- # value is a tuple like ("!=", 0)
- _operator = value[0].lower()
- values[key] = value[1]
- if isinstance(value[1], (tuple, list)):
- # value is a list in tuple ("in", ("A", "B"))
- _rhs = " ({0})".format(", ".join(self.escape(v) for v in value[1]))
- del values[key]
-
- if _operator not in ["=", "!=", ">", ">=", "<", "<=", "like", "in", "not in", "not like"]:
- _operator = "="
-
- if "[" in key:
- split_key = key.split("[")
- condition = "coalesce(`" + split_key[0] + "`, " + split_key[1][:-1] + ") " \
- + _operator + _rhs
- else:
- condition = "`" + key + "` " + _operator + _rhs
-
- conditions.append(condition)
-
- if isinstance(filters, int):
- # docname is a number, convert to string
- filters = str(filters)
-
- if isinstance(filters, str):
- filters = { "name": filters }
-
- for f in filters:
- _build_condition(f)
-
- return " and ".join(conditions), values
-
def get(self, doctype, filters=None, as_dict=True, cache=False):
"""Returns `get_value` with fieldname='*'"""
return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache)
@@ -424,9 +379,8 @@ class Database(object):
(doctype, filters, fieldname) in self.value_cache:
return self.value_cache[(doctype, filters, fieldname)]
- if not order_by: order_by = 'modified desc'
-
if isinstance(filters, list):
+ order_by = order_by or "modified_desc"
out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug)
else:
@@ -439,6 +393,7 @@ class Database(object):
if (filters is not None) and (filters!=doctype or doctype=="DocType"):
try:
+ order_by = order_by or "modified"
out = self._get_values_from_table(fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update)
except Exception as e:
if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)):
@@ -567,32 +522,23 @@ class Database(object):
return self.get_single_value(*args, **kwargs)
def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, update=None, for_update=False):
- fl = []
+ field_objects = []
+
+ for field in fields:
+ if "(" in field or " as " in field:
+ field_objects.append(PseudoColumn(field))
+ else:
+ field_objects.append(field)
+
+ criterion = self.query.build_conditions(table=doctype, filters=filters, orderby=order_by, for_update=for_update)
+
if isinstance(fields, (list, tuple)):
- for f in fields:
- if "(" in f or " as " in f: # function
- fl.append(f)
- else:
- fl.append("`" + f + "`")
- fl = ", ".join(fl)
+ query = criterion.select(*field_objects)
else:
- fl = fields
if fields=="*":
+ query = criterion.select(fields)
as_dict = True
-
- conditions, values = self.build_conditions(filters)
-
- order_by = ("order by " + order_by) if order_by else ""
-
- r = self.sql("select {fields} from `tab{doctype}` {where} {conditions} {order_by} {for_update}"
- .format(
- for_update = 'for update' if for_update else '',
- fields = fl,
- doctype = doctype,
- where = "where" if conditions else "",
- conditions = conditions,
- order_by = order_by),
- values, as_dict=as_dict, debug=debug, update=update)
+ r = self.sql(query, as_dict=as_dict, debug=debug, update=update)
return r
@@ -819,50 +765,34 @@ class Database(object):
except Exception:
return None
+ def min(self, dt, fieldname, filters=None, **kwargs):
+ return self.query.build_conditions(dt, filters=filters).select(Min(Column(fieldname))).run(**kwargs)[0][0] or 0
+
+ def max(self, dt, fieldname, filters=None, **kwargs):
+ return self.query.build_conditions(dt, filters=filters).select(Max(Column(fieldname))).run(**kwargs)[0][0] or 0
+
+ def avg(self, dt, fieldname, filters=None, **kwargs):
+ return self.query.build_conditions(dt, filters=filters).select(Avg(Column(fieldname))).run(**kwargs)[0][0] or 0
+
+ def sum(self, dt, fieldname, filters=None, **kwargs):
+ return self.query.build_conditions(dt, filters=filters).select(Sum(Column(fieldname))).run(**kwargs)[0][0] or 0
+
def count(self, dt, filters=None, debug=False, cache=False):
"""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.build_conditions(table=dt, filters=filters).select(Count("*"))
if filters:
- conditions, filters = self.build_conditions(filters)
- count = self.sql("""select count(*)
- from `tab%s` where %s""" % (dt, conditions), filters, debug=debug)[0][0]
+ count = self.sql(query, debug=debug)[0][0]
return count
else:
- count = self.sql("""select count(*)
- from `tab%s`""" % (dt,))[0][0]
-
+ 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
- def sum(self, dt, fieldname, filters=None):
- return self._get_aggregation('SUM', dt, fieldname, filters)
-
- def avg(self, dt, fieldname, filters=None):
- return self._get_aggregation('AVG', dt, fieldname, filters)
-
- def min(self, dt, fieldname, filters=None):
- return self._get_aggregation('MIN', dt, fieldname, filters)
-
- def max(self, dt, fieldname, filters=None):
- return self._get_aggregation('MAX', dt, fieldname, filters)
-
- def _get_aggregation(self, function, dt, fieldname, filters=None):
- if not self.has_column(dt, fieldname):
- frappe.throw(frappe._('Invalid column'), self.InvalidColumnName)
-
- query = f'SELECT {function}({fieldname}) AS value FROM `tab{dt}`'
- values = ()
- if filters:
- conditions, values = self.build_conditions(filters)
- query = f"{query} WHERE {conditions}"
-
- return self.sql(query, values)[0][0] or 0
-
@staticmethod
def format_date(date):
return getdate(date).strftime("%Y-%m-%d")
@@ -984,16 +914,9 @@ class Database(object):
"""
values = ()
filters = filters or kwargs.get("conditions")
- table = get_table_name(doctype)
- query = f"DELETE FROM `{table}`"
-
+ query = self.query.build_conditions(table=doctype, filters=filters).delete()
if "debug" not in kwargs:
kwargs["debug"] = debug
-
- if filters:
- conditions, values = self.build_conditions(filters)
- query = f"{query} WHERE {conditions}"
-
return self.sql(query, values, **kwargs)
def truncate(self, doctype: str):
diff --git a/frappe/database/query.py b/frappe/database/query.py
new file mode 100644
index 0000000000..7d7de85646
--- /dev/null
+++ b/frappe/database/query.py
@@ -0,0 +1,267 @@
+import operator
+from typing import Any, Dict, List, Tuple, Union
+
+import frappe
+from frappe.query_builder import Criterion, Order, Field
+
+
+def like(key: str, value: str) -> frappe.qb:
+ """Wrapper method for `LIKE`
+
+ Args:
+ key (str): field
+ value (str): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `LIKE`
+ """
+ return Field(key).like(value)
+
+
+def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb:
+ """Wrapper method for `IN`
+
+ Args:
+ key (str): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `IN`
+ """
+ return Field(key).isin(value)
+
+
+def not_like(key: str, value: str) -> frappe.qb:
+ """Wrapper method for `NOT LIKE`
+
+ Args:
+ key (str): field
+ value (str): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `NOT LIKE`
+ """
+ return Field(key).not_like(value)
+
+
+def func_not_in(key: str, value: Union[List, Tuple]):
+ """Wrapper method for `NOT IN`
+
+ Args:
+ key (str): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `NOT IN`
+ """
+ return Field(key).notin(value)
+
+
+def func_regex(key: str, value: str) -> frappe.qb:
+ """Wrapper method for `REGEX`
+
+ Args:
+ key (str): field
+ value (str): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `REGEX`
+ """
+ return Field(key).regex(value)
+
+
+def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb:
+ """Wrapper method for `BETWEEN`
+
+ Args:
+ key (str): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: `frappe.qb object with `BETWEEN`
+ """
+ return Field(key)[slice(*value)]
+
+def make_function(key: Any, value: Union[int, str]):
+ """returns fucntion query
+
+ Args:
+ key (Any): field
+ value (Union[int, str]): criterion
+
+ Returns:
+ frappe.qb: frappe.qb object
+ """
+ return OPERATOR_MAP[value[0]](key, value[1])
+
+
+def change_orderby(order: str):
+ """Convert orderby to standart Order object
+
+ Args:
+ order (str): Field, order
+
+ Returns:
+ tuple: field, order
+ """
+ order = order.split()
+ if order[1].lower() == "asc":
+ orderby, order = order[0], Order.asc
+ return orderby, order
+ orderby, order = order[0], Order.desc
+ return orderby, order
+
+
+OPERATOR_MAP = {
+ "+": operator.add,
+ "=": operator.eq,
+ "-": operator.sub,
+ "!=": operator.ne,
+ "<": operator.lt,
+ ">": operator.gt,
+ "<=": operator.le,
+ ">=": operator.ge,
+ "in": func_in,
+ "not in": func_not_in,
+ "like": like,
+ "not like": not_like,
+ "regex": func_regex,
+ "between": func_between
+ }
+
+
+class Query:
+ def get_condition(self, table: str, **kwargs) -> frappe.qb:
+ """Get initial table object
+
+ Args:
+ table (str): DocType
+
+ Returns:
+ frappe.qb: DocType with initial condition
+ """
+ if kwargs.get("update"):
+ return frappe.qb.update(table)
+ if kwargs.get("into"):
+ return frappe.qb.into(table)
+ return frappe.qb.from_(table)
+
+ def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb:
+ """Generate filters from Criterion objects
+
+ Args:
+ table (str): DocType
+ criterion (Criterion): Filters
+
+ Returns:
+ frappe.qb: condition object
+ """
+ condition = self.get_condition(table, **kwargs)
+ return condition.where(criterion)
+
+ def add_conditions(self, conditions: frappe.qb, **kwargs):
+ """Adding additional conditions
+
+ Args:
+ conditions (frappe.qb): built conditions
+
+ Returns:
+ conditions (frappe.qb): frappe.qb object
+ """
+ if kwargs.get("orderby"):
+ orderby = kwargs.get("orderby")
+ order = kwargs.get("order") if kwargs.get("order") else Order.desc
+ if isinstance(orderby, str) and len(orderby.split()) > 1:
+ orderby, order = change_orderby(orderby)
+ conditions = conditions.orderby(orderby, order=order)
+
+ if kwargs.get("limit"):
+ conditions = conditions.limit(kwargs.get("limit"))
+
+ if kwargs.get("distinct"):
+ conditions = conditions.distinct()
+
+ if kwargs.get("for_update"):
+ conditions = conditions.for_update()
+
+ return conditions
+
+ def misc_query(self, table: str, filters: Union[List, Tuple] = None, **kwargs):
+ """Build conditions using the given Lists or Tuple filters
+
+ Args:
+ table (str): DocType
+ filters (Union[List, Tuple], optional): Filters. Defaults to None.
+ """
+ conditions = self.get_condition(table, **kwargs)
+ if not filters:
+ return conditions
+ if isinstance(filters, list):
+ for f in filters:
+ if not isinstance(f, (list, tuple)):
+ _operator = OPERATOR_MAP[filters[1]]
+ if not isinstance(filters[0], str):
+ conditions = make_function(filters[0], filters[2])
+ break
+ 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]))
+
+ conditions = self.add_conditions(conditions, **kwargs)
+ return conditions
+
+ def dict_query(self, table: str, filters: Dict[str, Union[str, int]] = None, **kwargs) -> frappe.qb:
+ """Build conditions using the given dictionary filters
+
+ Args:
+ table (str): DocType
+ filters (Dict[str, Union[str, int]], optional): Filters. Defaults to None.
+
+ Returns:
+ frappe.qb: conditions object
+ """
+ conditions = self.get_condition(table, **kwargs)
+ if not filters:
+ return conditions
+
+ for key in filters:
+ value = filters.get(key)
+ _operator = OPERATOR_MAP["="]
+
+ if not isinstance(key, str):
+ conditions = conditions.where(make_function(key, value))
+ continue
+ 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]))
+ else:
+ _operator = OPERATOR_MAP[value[0]]
+ conditions = conditions.where(_operator(Field(key), value[1]))
+ else:
+ conditions = conditions.where(_operator(Field(key), value))
+ conditions = self.add_conditions(conditions, **kwargs)
+ return conditions
+
+ def build_conditions(self, table: str, filters: Union[Dict[str, Union[str, int]], str, int] = None, **kwargs) -> frappe.qb:
+ """Build conditions for sql query
+
+ Args:
+ filters (Union[Dict[str, Union[str, int]], str, int]): conditions in Dict
+ table (str): DocType
+
+ Returns:
+ frappe.qb: frappe.qb conditions object
+ """
+ if isinstance(filters, Criterion):
+ return self.criterion_query(table, filters, **kwargs)
+
+ if isinstance(filters, int) or isinstance(filters, str):
+ filters = {"name": str(filters)}
+
+ if isinstance(filters, (list, tuple)):
+ return self.misc_query(table, filters, **kwargs)
+
+ return self.dict_query(filters=filters, table=table, **kwargs)
diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py
index a4dcee4ab3..bd04e1915c 100644
--- a/frappe/desk/form/utils.py
+++ b/frappe/desk/form/utils.py
@@ -66,7 +66,7 @@ def add_comment(reference_doctype, reference_name, content, comment_email, comme
comment_type='Comment',
comment_by=comment_by
))
- doc.content = extract_images_from_html(doc, content)
+ doc.content = extract_images_from_html(doc, content, is_private=True)
doc.insert(ignore_permissions=True)
follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user)
diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py
index a086ded3fb..f05d35be3e 100644
--- a/frappe/email/doctype/notification/test_notification.py
+++ b/frappe/email/doctype/notification/test_notification.py
@@ -274,4 +274,7 @@ class TestNotification(unittest.TestCase):
self.assertTrue('test2@example.com' in recipients)
self.assertTrue('test1@example.com' in recipients)
-
+ @classmethod
+ def tearDownClass(cls):
+ frappe.delete_doc_if_exists("Notification", "ToDo Status Update")
+ frappe.delete_doc_if_exists("Notification", "Contact Status Update")
\ No newline at end of file
diff --git a/frappe/installer.py b/frappe/installer.py
index f0bf0cb51c..8b840ede46 100755
--- a/frappe/installer.py
+++ b/frappe/installer.py
@@ -29,6 +29,10 @@ def _new_site(
):
"""Install a new Frappe site"""
+ from frappe.commands.scheduler import _is_scheduler_enabled
+ from frappe.utils import get_site_path, scheduler, touch_file
+
+
if not force and os.path.exists(site):
print("Site {0} already exists".format(site))
sys.exit(1)
@@ -37,14 +41,11 @@ def _new_site(
print("--no-mariadb-socket requires db_type to be set to mariadb.")
sys.exit(1)
- if not db_name:
- import hashlib
- db_name = "_" + hashlib.sha1(site.encode()).hexdigest()[:16]
-
frappe.init(site=site)
- from frappe.commands.scheduler import _is_scheduler_enabled
- from frappe.utils import get_site_path, scheduler, touch_file
+ if not db_name:
+ import hashlib
+ db_name = "_" + hashlib.sha1(os.path.realpath(frappe.get_site_path()).encode()).hexdigest()[:16]
try:
# enable scheduler post install?
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 5605ac61ed..1826cca9a3 100644
--- a/frappe/model/base_document.py
+++ b/frappe/model/base_document.py
@@ -267,7 +267,12 @@ class BaseDocument(object):
if isinstance(d[fieldname], list) and df.fieldtype not in table_fields:
frappe.throw(_('Value for {0} cannot be a list').format(_(df.label)))
- if convert_dates_to_str and isinstance(d[fieldname], (datetime.datetime, datetime.time, datetime.timedelta)):
+ if convert_dates_to_str and isinstance(d[fieldname], (
+ datetime.datetime,
+ datetime.date,
+ datetime.time,
+ datetime.timedelta
+ )):
d[fieldname] = str(d[fieldname])
if d[fieldname] == None and ignore_nulls:
diff --git a/frappe/printing/doctype/network_printer_settings/network_printer_settings.json b/frappe/printing/doctype/network_printer_settings/network_printer_settings.json
index cbef4b8ba4..11f1382225 100644
--- a/frappe/printing/doctype/network_printer_settings/network_printer_settings.json
+++ b/frappe/printing/doctype/network_printer_settings/network_printer_settings.json
@@ -41,10 +41,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-09-17 11:30:16.781655",
+ "modified": "2021-10-07 11:23:13.799402",
"modified_by": "Administrator",
"module": "Printing",
"name": "Network Printer Settings",
+ "naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@@ -58,6 +59,15 @@
"role": "System Manager",
"share": 1,
"write": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "All",
+ "share": 1
}
],
"sort_field": "modified",
diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js
index d3b1c69815..c1ab56b181 100644
--- a/frappe/printing/page/print/print.js
+++ b/frappe/printing/page/print/print.js
@@ -177,7 +177,7 @@ frappe.ui.form.PrintView = class {
);
}
- if (this.print_settings.enable_print_server) {
+ if (cint(this.print_settings.enable_print_server)) {
this.page.add_menu_item(__('Select Network Printer'), () =>
this.network_printer_setting_dialog()
);
@@ -464,7 +464,7 @@ frappe.ui.form.PrintView = class {
printit() {
let me = this;
- if (me.print_settings.enable_print_server) {
+ if (cint(me.print_settings.enable_print_server)) {
if (localStorage.getItem('network_printer')) {
me.print_by_server();
} else {
diff --git a/frappe/public/js/frappe/form/footer/base_timeline.js b/frappe/public/js/frappe/form/footer/base_timeline.js
index 702d964442..beeba16459 100644
--- a/frappe/public/js/frappe/form/footer/base_timeline.js
+++ b/frappe/public/js/frappe/form/footer/base_timeline.js
@@ -97,9 +97,13 @@ class BaseTimeline {
}
timeline_item.append(`
+-
+ {{ __('Copy Link') }}
+
+
+