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(`
`); - timeline_item.find('.timeline-content').append(item.content); + let timeline_content = timeline_item.find('.timeline-content'); + timeline_content.append(item.content); if (!item.hide_timestamp && !item.is_card) { - timeline_item.find('.timeline-content').append(` - ${comment_when(item.creation)}`); + timeline_content.append(` - ${comment_when(item.creation)}`); + } + if (item.id) { + timeline_content.attr("id", item.id); } return timeline_item; } diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index b3feae3ee8..128bd355ad 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -96,6 +96,7 @@ class FormTimeline extends BaseTimeline { render_timeline_items() { super.render_timeline_items(); this.set_document_info(); + frappe.utils.bind_actions_with_object(this.timeline_items_wrapper, this); } set_document_info() { @@ -179,6 +180,7 @@ class FormTimeline extends BaseTimeline { is_card: true, content: this.get_communication_timeline_content(communication), doctype: "Communication", + id: `communication-${communication.name}`, name: communication.name }); }); @@ -246,6 +248,7 @@ class FormTimeline extends BaseTimeline { creation: comment.creation, is_card: true, doctype: "Comment", + id: `comment-${comment.name}`, name: comment.name, content: this.get_comment_timeline_content(comment), }; @@ -394,7 +397,7 @@ class FormTimeline extends BaseTimeline { } setup_reply(communication_box, communication_doc) { - let actions = communication_box.find('.actions'); + let actions = communication_box.find('.custom-actions'); let reply = $(`${frappe.utils.icon('reply', 'md')}`).click(() => { this.compose_mail(communication_doc); }); @@ -446,14 +449,16 @@ class FormTimeline extends BaseTimeline { let edit_wrapper = $(`
`).hide(); let edit_box = this.make_editable(edit_wrapper); let content_wrapper = comment_wrapper.find('.content'); - - let delete_button = $(); + let more_actions_wrapper = comment_wrapper.find('.more-actions'); if (frappe.model.can_delete("Comment")) { - delete_button = $(` - + const delete_option = $(` +
  • + + ${__("Delete")} + +
  • `).click(() => this.delete_comment(doc.name)); + more_actions_wrapper.find('.dropdown-menu').append(delete_option); } let dismiss_button = $(` @@ -493,15 +498,14 @@ class FormTimeline extends BaseTimeline { edit_button.toggle_edit_mode = () => { edit_button.edit_mode = !edit_button.edit_mode; edit_button.text(edit_button.edit_mode ? __('Save') : __('Edit')); - delete_button.toggle(!edit_button.edit_mode); + more_actions_wrapper.toggle(!edit_button.edit_mode); dismiss_button.toggle(edit_button.edit_mode); edit_wrapper.toggle(edit_button.edit_mode); content_wrapper.toggle(!edit_button.edit_mode); }; - - comment_wrapper.find('.actions').append(edit_button); - comment_wrapper.find('.actions').append(dismiss_button); - comment_wrapper.find('.actions').append(delete_button); + let actions_wrapper = comment_wrapper.find('.custom-actions'); + actions_wrapper.append(edit_button); + actions_wrapper.append(dismiss_button); } make_editable(container) { @@ -559,6 +563,14 @@ class FormTimeline extends BaseTimeline { }); }); } + + copy_link(ev) { + let doc_link = frappe.urllib.get_full_url( + frappe.utils.get_form_link(this.frm.doctype, this.frm.docname) + ); + let element_id = $(ev.currentTarget).closest(".timeline-content").attr("id"); + frappe.utils.copy_to_clipboard(`${doc_link}#${element_id}`); + } } export default FormTimeline; diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 97473c3069..a095956dfe 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -480,7 +480,11 @@ frappe.ui.form.Form = class FrappeForm { this.layout.show_empty_form_message(); } - this.scroll_to_element(); + frappe.after_ajax(() => { + $(document).ready(() => { + this.scroll_to_element(); + }); + }); } set_first_tab_as_active() { @@ -598,6 +602,8 @@ frappe.ui.form.Form = class FrappeForm { this.validate_form_action(save_action, resolve); var after_save = function(r) { + // to remove hash from URL to avoid scroll after save + history.replaceState(null, null, ' '); if(!r.exc) { if (["Save", "Update", "Amend"].indexOf(save_action)!==-1) { frappe.utils.play_sound("click"); @@ -1195,6 +1201,8 @@ frappe.ui.form.Form = class FrappeForm { if (selector.length) { frappe.utils.scroll_to(selector); } + } else if (window.location.hash && $(window.location.hash).length) { + frappe.utils.scroll_to(window.location.hash, true, 200, null, null, true); } } diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 574543c3a6..1d302f5e1f 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -773,16 +773,18 @@ export default class Grid { } setup_user_defined_columns() { - let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView'); - if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) { - this.user_defined_columns = user_settings[this.doctype].map(row => { - let column = frappe.meta.get_docfield(this.doctype, row.fieldname); - if (column) { - column.in_list_view = 1; - column.columns = row.columns; - return column; - } - }); + if (this.frm) { + let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView'); + if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) { + this.user_defined_columns = user_settings[this.doctype].map(row => { + let column = frappe.meta.get_docfield(this.doctype, row.fieldname); + if (column) { + column.in_list_view = 1; + column.columns = row.columns; + return column; + } + }); + } } } diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 20a04ee206..de174cf37f 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -497,7 +497,7 @@ export default class GridRow { } update_user_settings_for_grid() { - if (!this.selected_columns_for_grid) { + if (!this.selected_columns_for_grid || !this.frm) { return; } diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index ba522a4085..37b7e08a80 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -70,6 +70,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.dialog = new frappe.ui.Dialog({ title: title, fields: this.fields, + size: this.size, primary_action_label: this.primary_action_label || __("Get Items"), secondary_action_label: __("Make {0}", [__(this.doctype)]), primary_action: () => { @@ -135,7 +136,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.get_child_result().then(r => { this.child_results = r.message || []; this.render_child_datatable(); - + this.$wrapper.addClass('hidden'); this.$child_wrapper.removeClass('hidden'); this.dialog.fields_dict.more_btn.$wrapper.hide(); diff --git a/frappe/public/js/frappe/form/script_manager.js b/frappe/public/js/frappe/form/script_manager.js index f960f4f767..f877a7cf8b 100644 --- a/frappe/public/js/frappe/form/script_manager.js +++ b/frappe/public/js/frappe/form/script_manager.js @@ -193,7 +193,7 @@ frappe.ui.form.ScriptManager = class ScriptManager { function setup_add_fetch(df) { if ((['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check', - 'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select'].includes(df.fieldtype) || df.read_only==1) + 'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) || df.read_only==1) && df.fetch_from && df.fetch_from.indexOf(".")!=-1) { var parts = df.fetch_from.split("."); me.frm.add_fetch(parts[0], parts[1], df.fieldname); diff --git a/frappe/public/js/frappe/form/templates/timeline_message_box.html b/frappe/public/js/frappe/form/templates/timeline_message_box.html index 3884918165..a38e36c7b5 100644 --- a/frappe/public/js/frappe/form/templates/timeline_message_box.html +++ b/frappe/public/js/frappe/form/templates/timeline_message_box.html @@ -63,6 +63,20 @@ {% } %} +
    +
    diff --git a/frappe/public/js/frappe/list/list_settings.js b/frappe/public/js/frappe/list/list_settings.js index 4877d6fbb7..782a077a78 100644 --- a/frappe/public/js/frappe/list/list_settings.js +++ b/frappe/public/js/frappe/list/list_settings.js @@ -114,14 +114,14 @@ export default class ListSettings {
    - + ${frappe.utils.icon("drag", "xs", "", "", "sortable-handle " + show_sortable_handle)}
    ${me.fields[idx].label}
    diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 1cf4b4c6ac..09072e106e 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -907,7 +907,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return this.settings.get_form_link(doc); } - const docname = doc.name.match(/[%'"\s]/) + const docname = doc.name.match(/[%'"#\s]/) ? encodeURIComponent(doc.name) : doc.name; diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index 484f1ac911..349eadffb0 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -56,10 +56,6 @@ $('body').on('click', 'a', function(e) { return override(e.currentTarget.hash); } - if (frappe.router.is_app_route(e.currentTarget.pathname)) { - // target has "/app, this is a v2 style route. - return override(e.currentTarget.pathname + e.currentTarget.hash); - } }); frappe.router = { @@ -263,7 +259,9 @@ frappe.router = { return new Promise(resolve => { route = this.get_route_from_arguments(route); route = this.convert_from_standard_route(route); - const sub_path = this.make_url(route); + let sub_path = this.make_url(route); + // replace each # occurrences in the URL with encoded character except for last + // sub_path = sub_path.replace(/[#](?=.*[#])/g, "%23"); this.push_state(sub_path); setTimeout(() => { @@ -347,7 +345,7 @@ frappe.router = { return null; } else { a = String(a); - if (a && a.match(/[%'"\s\t]/)) { + if (a && a.match(/[%'"#\s\t]/)) { // if special chars, then encode a = encodeURIComponent(a); } @@ -374,7 +372,7 @@ frappe.router = { // return clean sub_path from hash or url // supports both v1 and v2 routing if (!route) { - route = window.location.pathname + window.location.hash + window.location.search; + route = window.location.pathname; if (route.includes('app#')) { // to support v1 route = window.location.hash; diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index 6471fb47a5..2151e66236 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -536,8 +536,8 @@ frappe.ui.filter_utils = { if (condition === 'is') { df.fieldtype = 'Select'; df.options = [ - { label: __('Set'), value: 'set' }, - { label: __('Not Set'), value: 'not set' }, + { label: __('Set', null, 'Field value is set'), value: 'set' }, + { label: __('Not Set', null, 'Field value is not set'), value: 'not set' }, ]; } return; diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index 0c6fea2986..99d47a6deb 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -242,18 +242,21 @@ Object.defineProperties(window, { get: function() { console.warn('Please use `frappe.datetime` instead of `dateutil`. It will be deprecated soon.'); return frappe.datetime; - } + }, + configurable: true }, 'date': { get: function() { console.warn('Please use `frappe.datetime` instead of `date`. It will be deprecated soon.'); return frappe.datetime; - } + }, + configurable: true }, 'get_today': { get: function() { console.warn('Please use `frappe.datetime.get_today` instead of `get_today`. It will be deprecated soon.'); return frappe.datetime.get_today; - } + }, + configurable: true } }); diff --git a/frappe/public/js/frappe/utils/pretty_date.js b/frappe/public/js/frappe/utils/pretty_date.js index 84fd276068..3ebe2c1ae2 100644 --- a/frappe/public/js/frappe/utils/pretty_date.js +++ b/frappe/public/js/frappe/utils/pretty_date.js @@ -25,11 +25,11 @@ function prettyDate(date, mini) { if (day_diff < 7) { return __("{0} d", [day_diff]); } else if (day_diff < 31) { - return __("{0} w", [Math.ceil(day_diff / 7)]); + return __("{0} w", [Math.floor(day_diff / 7)]); } else if (day_diff < 365) { - return __("{0} M", [Math.ceil(day_diff / 30)]); + return __("{0} M", [Math.floor(day_diff / 30)]); } else { - return __("{0} y", [Math.ceil(day_diff / 365)]); + return __("{0} y", [Math.floor(day_diff / 365)]); } } } else { @@ -54,15 +54,15 @@ function prettyDate(date, mini) { } else if (day_diff < 14) { return __("1 week ago"); } else if (day_diff < 31) { - return __("{0} weeks ago", [Math.ceil(day_diff / 7)]); + return __("{0} weeks ago", [Math.floor(day_diff / 7)]); } else if (day_diff < 62) { return __("1 month ago"); } else if (day_diff < 365) { - return __("{0} months ago", [Math.ceil(day_diff / 30)]); + return __("{0} months ago", [Math.floor(day_diff / 30)]); } else if (day_diff < 730) { return __("1 year ago"); } else { - return __("{0} years ago", [Math.ceil(day_diff / 365)]); + return __("{0} years ago", [Math.floor(day_diff / 365)]); } } } diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index b36111a3de..831538d255 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -268,7 +268,8 @@ Object.assign(frappe.utils, {

    '); return content.html(); }, - scroll_to: function(element, animate=true, additional_offset, element_to_be_scrolled, callback) { + scroll_to: function(element, animate=true, additional_offset, + element_to_be_scrolled, callback, highlight_element=false) { if (frappe.flags.disable_auto_scroll) return; element_to_be_scrolled = element_to_be_scrolled || $("html, body"); @@ -291,11 +292,20 @@ Object.assign(frappe.utils, { } if (animate) { - element_to_be_scrolled.animate({ scrollTop: scroll_top }).promise().then(callback); + element_to_be_scrolled.animate({ + scrollTop: scroll_top + }).promise().then(() => { + if (highlight_element) { + $(element).addClass('highlight'); + document.addEventListener("click", function() { + $(element).removeClass('highlight'); + }, {once: true}); + } + callback && callback(); + }); } else { element_to_be_scrolled.scrollTop(scroll_top); } - }, get_scroll_position: function(element, additional_offset) { let header_offset = $(".navbar").height() + $(".page-head:visible").height(); @@ -1123,7 +1133,7 @@ Object.assign(frappe.utils, { } }, - icon(icon_name, size="sm", icon_class="", icon_style="") { + icon(icon_name, size="sm", icon_class="", icon_style="", svg_class="") { let size_class = ""; if (typeof size == "object") { @@ -1131,7 +1141,7 @@ Object.assign(frappe.utils, { } else { size_class = `icon-${size}`; } - return ` + return ` `; }, diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index 112238bfe5..ede3dd8ba9 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -209,6 +209,8 @@ --highlight-color: var(--gray-50); --yellow-highlight-color: var(--yellow-50); + --highlight-shadow: 1px 1px 10px var(--blue-50), 0px 0px 4px var(--blue-600); + // Border Sizes --border-radius-sm: 4px; --border-radius: 6px; diff --git a/frappe/public/scss/desk/dark.scss b/frappe/public/scss/desk/dark.scss index 7f0dfe73b8..35cdffc91a 100644 --- a/frappe/public/scss/desk/dark.scss +++ b/frappe/public/scss/desk/dark.scss @@ -75,6 +75,8 @@ --highlight-color: var(--gray-700); --yellow-highlight-color: var(--yellow-700); + --highlight-shadow: 1px 1px 10px var(--blue-900), 0px 0px 4px var(--blue-500); + // input --input-disabled-bg: none; diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index 2ab6d98e20..6ab01a744c 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -164,12 +164,11 @@ body { .drag-handle { cursor: all-scroll; - cursor: -webkit-grabbing; + cursor: grabbing; &:active { + cursor: all-scroll; cursor: grabbing; - cursor: -moz-grabbing; - cursor: -webkit-grabbing; } } @@ -813,7 +812,7 @@ body { .drag-handle { cursor: all-scroll; - cursor: -webkit-grabbing; + cursor: grabbing; display: none; } @@ -966,7 +965,7 @@ body { .drag-handle { cursor: all-scroll; - cursor: -webkit-grabbing; + cursor: grabbing; } } } diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss index 8c646395e9..d157a43bc3 100644 --- a/frappe/public/scss/desk/global.scss +++ b/frappe/public/scss/desk/global.scss @@ -561,6 +561,19 @@ details > summary:focus { display: none; } +.highlight { + transition: 0.5s ease background-color; + box-shadow: var(--highlight-shadow) !important; +} + +.dropdown-menu.small { + font-size: var(--text-sm); + min-width: 140px; + .dropdown-item { + padding: var(--padding-xs); + } +} + // REDESIGN TODO: Handling of broken images? // img.no-image:before { // .img-background(); diff --git a/frappe/public/scss/desk/list.scss b/frappe/public/scss/desk/list.scss index 1818f6d8b3..4456acabb3 100644 --- a/frappe/public/scss/desk/list.scss +++ b/frappe/public/scss/desk/list.scss @@ -228,6 +228,11 @@ input.list-check-all, input.list-row-checkbox { z-index: 500; top: 0; } + + .sortable-handle { + cursor: all-scroll; + cursor: grabbing; + } } .list-items { diff --git a/frappe/public/scss/desk/timeline.scss b/frappe/public/scss/desk/timeline.scss index a7e5d3dd9c..1861ee018b 100644 --- a/frappe/public/scss/desk/timeline.scss +++ b/frappe/public/scss/desk/timeline.scss @@ -117,7 +117,7 @@ $threshold: 34; .actions { display: flex; - > * { + > *:not(.indicator-pill) { color: var(--text-muted); } } diff --git a/frappe/query_builder/builder.py b/frappe/query_builder/builder.py index 1d9b9f0e40..0c78a7e89d 100644 --- a/frappe/query_builder/builder.py +++ b/frappe/query_builder/builder.py @@ -1,14 +1,17 @@ from pypika import MySQLQuery, Order, PostgreSQLQuery, terms from pypika.queries import Schema, Table from frappe.utils import get_table_name - - +from pypika.terms import Function class Base: terms = terms desc = Order.desc Schema = Schema Table = Table + @staticmethod + def functions(name: str, *args, **kwargs) -> Function: + return Function(name, *args, **kwargs) + @staticmethod def DocType(table_name: str, *args, **kwargs) -> Table: table_name = get_table_name(table_name) diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index c217d0975e..d915b85897 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, Callable, Dict, get_type_hints +from typing import Any, Callable, Dict, Union, get_type_hints from importlib import import_module from pypika import Query @@ -26,7 +26,7 @@ class BuilderIdentificationFailed(Exception): def __init__(self): super().__init__("Couldn't guess builder") -def get_query_builder(type_of_db: str) -> Query: +def get_query_builder(type_of_db: str) -> Union[Postgres, MariaDB]: """[return the query builder object] Args: diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 72bec78db7..0d32a72756 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -22,6 +22,8 @@ class TestDB(unittest.TestCase): self.assertNotEqual(frappe.db.get_value("User", {"name": ["!=", "Guest"]}), "Guest") self.assertEqual(frappe.db.get_value("User", {"name": ["<", "Adn"]}), "Administrator") self.assertEqual(frappe.db.get_value("User", {"name": ["<=", "Administrator"]}), "Administrator") + self.assertEqual(frappe.db.get_value("User", {}, ["Max(name)"]), frappe.db.sql("SELECT Max(name) FROM tabUser")[0][0]) + self.assertEqual(frappe.db.get_value("User", {}, "Min(name)"), frappe.db.sql("SELECT Min(name) FROM tabUser")[0][0]) self.assertEqual(frappe.db.sql("""SELECT name FROM `tabUser` WHERE name > 's' ORDER BY MODIFIED DESC""")[0][0], frappe.db.get_value("User", {"name": [">", "s"]})) @@ -46,12 +48,6 @@ class TestDB(unittest.TestCase): def test_escape(self): frappe.db.escape("香港濟生堂製藥有限公司 - IT".encode("utf-8")) - def test_aggregation(self): - self.assertTrue(type(frappe.db.sum('DocField', 'permlevel', dict(parent=('like', 'doc')))) in (int, float)) - self.assertTrue(type(frappe.db.avg('DocField', 'permlevel')) in (int, float)) - self.assertTrue(type(frappe.db.min('DocField', 'permlevel')) in (int, float)) - self.assertTrue(type(frappe.db.max('DocField', 'permlevel')) in (int, float)) - def test_get_single_value(self): #setup values_dict = { diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index dbf450180e..1751d41b66 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -147,13 +147,14 @@ def get_safe_globals(): set_value = frappe.db.set_value, get_single_value = frappe.db.get_single_value, get_default = frappe.db.get_default, - escape = frappe.db.escape, - sql = read_sql, - sum = frappe.db.sum, - avg = frappe.db.avg, + exists = frappe.db.exists, count = frappe.db.count, min = frappe.db.min, - max = frappe.db.max + max = frappe.db.max, + avg = frappe.db.avg, + sum = frappe.db.sum, + escape = frappe.db.escape, + sql = read_sql ) if frappe.response: diff --git a/package.json b/package.json index 01b17fb904..7472a6b28b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "build": "node esbuild", "production": "node esbuild --production", "watch": "node esbuild --watch", - "snyk-protect": "snyk protect" + "snyk-protect": "snyk protect", + "coverage:report": "npx nyc report --reporter=clover" }, "repository": { "type": "git", @@ -74,5 +75,8 @@ "rtlcss": "^3.2.1", "yargs": "^16.2.0" }, - "snyk": true + "snyk": true, + "nyc": { + "report-dir": ".cypress-coverage" + } }