diff --git a/.eslintrc b/.eslintrc index d123023a68..8a509f0df4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -143,6 +143,7 @@ "Cypress": true, "cy": true, "it": true, + "describe": true, "expect": true, "context": true, "before": true, diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index bfe2002f69..d2a00ab05f 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -149,9 +149,10 @@ jobs: run: | cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} cd ${GITHUB_WORKSPACE} - pip install coveralls==2.2.0 - pip install coverage==4.5.4 - coveralls + pip install coveralls==3.0.1 + pip install coverage==5.5 + coveralls --service=github env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + COVERALLS_SERVICE_NAME: github diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 5302ed0964..20ed7a61cd 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -8,7 +8,7 @@ context('Form', () => { }); it('create a new form', () => { cy.visit('/app/todo/new'); - cy.fill_field('description', 'this is a test todo', 'Text Editor').blur(); + cy.fill_field('description', 'this is a test todo', 'Text Editor'); cy.wait(300); cy.get('.page-title').should('contain', 'Not Saved'); cy.intercept({ diff --git a/cypress/integration/relative_time_filters.js b/cypress/integration/relative_time_filters.js index 80e6387d99..cbb0524c24 100644 --- a/cypress/integration/relative_time_filters.js +++ b/cypress/integration/relative_time_filters.js @@ -1,7 +1,4 @@ context('Relative Timeframe', () => { - beforeEach(() => { - cy.login(); - }); before(() => { cy.login(); cy.visit('/app/website'); diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index faa72d63a5..25cab78ba2 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -1,5 +1,5 @@ context('Table MultiSelect', () => { - beforeEach(() => { + before(() => { cy.login(); }); diff --git a/frappe/__init__.py b/frappe/__init__.py index cab9b0da76..1bcf47e986 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -34,7 +34,7 @@ if PY2: reload(sys) sys.setdefaultencoding("utf-8") -__version__ = '13.0.0-dev' +__version__ = '13.1.0' __title__ = "Frappe Framework" @@ -975,7 +975,7 @@ def get_pymodule_path(modulename, *joins): :param *joins: Join additional path elements using `os.path.join`.""" if not "public" in joins: joins = [scrub(part) for part in joins] - return os.path.join(os.path.dirname(get_module(scrub(modulename)).__file__), *joins) + return os.path.join(os.path.dirname(get_module(scrub(modulename)).__file__ or ''), *joins) def get_module_list(app_name): """Get list of modules for given all via `app/modules.txt`.""" diff --git a/frappe/auth.py b/frappe/auth.py index ca97bbc17d..73cb8e8c15 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -120,6 +120,7 @@ class LoginManager: self.make_session() self.set_user_info() + @frappe.whitelist() def login(self): # clear cache frappe.clear_cache(user = frappe.form_dict.get('usr')) diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index bad879d2fa..4e0fe0cf44 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -18,7 +18,7 @@ global_cache_keys = ("app_hooks", "installed_apps", 'all_apps', 'scheduler_events', 'time_zone', 'webhooks', 'active_domains', 'active_modules', 'assignment_rule', 'server_script_map', 'wkhtmltopdf_version', 'domain_restricted_doctypes', 'domain_restricted_pages', 'information_schema:counts', - 'sitemap_routes', 'db_tables') + doctype_map_keys + 'sitemap_routes', 'db_tables', 'server_script_autocompletion_items') + doctype_map_keys user_cache_keys = ("bootinfo", "user_recent", "roles", "user_doc", "lang", "defaults", "user_permissions", "home_page", "linked_with", diff --git a/frappe/change_log/v13/v13_1_0.md b/frappe/change_log/v13/v13_1_0.md new file mode 100644 index 0000000000..87c3bd0906 --- /dev/null +++ b/frappe/change_log/v13/v13_1_0.md @@ -0,0 +1,22 @@ +# Version 13.1.0 Release Notes + +### Features & Enhancements + +- Automated mail notifications will be shown in timeline ([#12693](https://github.com/frappe/frappe/pull/12693)) +- Introduced Client Script for List views ([#12590](https://github.com/frappe/frappe/pull/12590)) +- Introduced language switcher for guest users on website navbar ([#12813](https://github.com/frappe/frappe/pull/12813)) +- Option to give submit permission while sharing a document ([#12799](https://github.com/frappe/frappe/pull/12799)) +- Added option to set `autoname` in Customize Form ([#12413](https://github.com/frappe/frappe/pull/12413)) +- Virtual DocType ([#12121](https://github.com/frappe/frappe/pull/12121)) + +### Fixes + +- Workspace fixes ([#12650](https://github.com/frappe/frappe/pull/12650)) ([#12655](https://github.com/frappe/frappe/pull/12655)) ([#12869](https://github.com/frappe/frappe/pull/12869)) +- Fixed an issue where select options were not getting updated in Grid ([#12839](https://github.com/frappe/frappe/pull/12839)) +- Webform Fixes ([#12630](https://github.com/frappe/frappe/pull/12630)) ([#12756](https://github.com/frappe/frappe/pull/12756)) ([#12819](https://github.com/frappe/frappe/pull/12819)) +- Fixed timespan filter for next and last timespans ([#12509](https://github.com/frappe/frappe/pull/12509)) +- System Notification fixes ([#12719](https://github.com/frappe/frappe/pull/12719)) +- Design Fixes ([#12669](https://github.com/frappe/frappe/pull/12669)) ([#12591](https://github.com/frappe/frappe/pull/12591)) ([#12557](https://github.com/frappe/frappe/pull/12557)) ([#12751](https://github.com/frappe/frappe/pull/12751)) ([#12864](https://github.com/frappe/frappe/pull/12864)) +- Fixed Multi-column paste in grid ([#12861](https://github.com/frappe/frappe/pull/12861)) +- Fixed grid validation ([#12744](https://github.com/frappe/frappe/pull/12744)) +- Fixed currency value formatting in dashboard chart ([#12613](https://github.com/frappe/frappe/pull/12613)) diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py index b9ae02e112..61ee62d352 100644 --- a/frappe/commands/__init__.py +++ b/frappe/commands/__init__.py @@ -62,11 +62,24 @@ def popen(command, *args, **kwargs): if env: env = dict(environ, **env) + def set_low_prio(): + import psutil + if psutil.LINUX: + psutil.Process().nice(19) + psutil.Process().ionice(psutil.IOPRIO_CLASS_IDLE) + elif psutil.WINDOWS: + psutil.Process().nice(psutil.IDLE_PRIORITY_CLASS) + psutil.Process().ionice(psutil.IOPRIO_VERYLOW) + else: + psutil.Process().nice(19) + # ionice not supported + proc = subprocess.Popen(command, stdout=None if output else subprocess.PIPE, stderr=None if output else subprocess.PIPE, shell=shell, cwd=cwd, + preexec_fn=set_low_prio, env=env ) diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index bd9c9d2cb0..e9638800cd 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -18,22 +18,33 @@ def _is_scheduler_enabled(): return enable_scheduler -@click.command('trigger-scheduler-event') -@click.argument('event') + +@click.command("trigger-scheduler-event", help="Trigger a scheduler event") +@click.argument("event") @pass_context def trigger_scheduler_event(context, event): - "Trigger a scheduler event" import frappe.utils.scheduler + + exit_code = 0 + for site in context.sites: try: frappe.init(site=site) frappe.connect() - frappe.utils.scheduler.trigger(site, event, now=True) + try: + frappe.get_doc("Scheduled Job Type", {"method": event}).execute() + except frappe.DoesNotExistError: + click.secho(f"Event {event} does not exist!", fg="red") + exit_code = 1 finally: frappe.destroy() + if not context.sites: raise SiteNotSpecifiedError + sys.exit(exit_code) + + @click.command('enable-scheduler') @pass_context def enable_scheduler(context): diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 0fadf2a294..0102d3ac40 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -676,10 +676,8 @@ def start_ngrok(context): frappe.init(site=site) port = frappe.conf.http_port or frappe.conf.webserver_port - public_url = ngrok.connect(port=port, options={ - 'host_header': site - }) - print(f'Public URL: {public_url}') + tunnel = ngrok.connect(addr=str(port), host_header=site) + print(f'Public URL: {tunnel.public_url}') print('Inspect logs at http://localhost:4040') ngrok_process = ngrok.get_ngrok_process() diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 5ff66171fc..a203c8c6d9 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -11,7 +11,7 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import get_bench_path, update_progress_bar +from frappe.utils import get_bench_path, update_progress_bar, cint @click.command('build') @@ -567,11 +567,14 @@ def run_ui_tests(context, app, headless=False): node_bin = subprocess.getoutput("npm bin") cypress_path = "{0}/cypress".format(node_bin) - plugin_path = "{0}/cypress-file-upload".format(node_bin) + plugin_path = "{0}/../cypress-file-upload".format(node_bin) # check if cypress in path...if not, install it. - if not (os.path.exists(cypress_path) or os.path.exists(plugin_path)) \ - or not subprocess.getoutput("npm view cypress version").startswith("6."): + if not ( + os.path.exists(cypress_path) + and os.path.exists(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 --no-lockfile") diff --git a/frappe/core/doctype/communication/communication_list.js b/frappe/core/doctype/communication/communication_list.js index 454897b865..315b74a39c 100644 --- a/frappe/core/doctype/communication/communication_list.js +++ b/frappe/core/doctype/communication/communication_list.js @@ -20,6 +20,6 @@ frappe.listview_settings['Communication'] = { }, primary_action: function() { - new frappe.views.CommunicationComposer({ doc: {} }); + new frappe.views.CommunicationComposer(); } }; diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 276ce7bee7..fe5038b841 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -56,6 +56,8 @@ "show_preview_popup", "show_name_in_global_search", "email_settings_sb", + "default_email_template", + "column_break_51", "email_append_to", "sender_field", "subject_field", @@ -535,6 +537,16 @@ "fieldname": "is_virtual", "fieldtype": "Check", "label": "Is Virtual" + }, + { + "fieldname": "default_email_template", + "fieldtype": "Link", + "label": "Default Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_51", + "fieldtype": "Column Break" } ], "icon": "fa fa-bolt", @@ -616,7 +628,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2021-02-17 20:18:06.212232", + "modified": "2021-04-16 12:26:41.031135", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index af2c4e5dc2..8a0f9a99f5 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -325,9 +325,8 @@ def get_group_by_field(args, doctype): if args['aggregate_function'] == 'count': group_by_field = 'count(*) as _aggregate_column' else: - group_by_field = '{0}(`tab{1}`.{2}) as _aggregate_column'.format( + group_by_field = '{0}({1}) as _aggregate_column'.format( args.aggregate_function, - doctype, args.aggregate_on ) diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py index 92493a593a..59089d12ad 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -2,14 +2,15 @@ # Copyright (c) 2019, Frappe Technologies and contributors # For license information, please see license.txt -from __future__ import unicode_literals +import json +from datetime import datetime from typing import Dict, List -import frappe, json -from frappe.model.document import Document -from frappe.utils import now_datetime, get_datetime -from datetime import datetime from croniter import croniter + +import frappe +from frappe.model.document import Document +from frappe.utils import get_datetime, now_datetime from frappe.utils.background_jobs import enqueue, get_jobs diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index 95a63780f8..dda39115bf 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -9,6 +9,12 @@ frappe.ui.form.on('Server Script', { if (frm.doc.script_type != 'Scheduler Event') { frm.dashboard.hide(); } + + frm.call('get_autocompletion_items') + .then(r => r.message) + .then(items => { + frm.set_df_property('script', 'autocompletions', items); + }); }, setup_help(frm) { diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 8838d9e954..f80a067cf1 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -5,11 +5,12 @@ from __future__ import unicode_literals import ast +from types import FunctionType, MethodType, ModuleType from typing import Dict, List import frappe from frappe.model.document import Document -from frappe.utils.safe_exec import safe_exec +from frappe.utils.safe_exec import get_safe_globals, safe_exec, NamespaceDict from frappe import _ @@ -122,6 +123,51 @@ class ServerScript(Document): if locals["conditions"]: return locals["conditions"] + @frappe.whitelist() + def get_autocompletion_items(self): + """Generates a list of a autocompletion strings from the context dict + that is used while executing a Server Script. + + Returns: + list: Returns list of autocompletion items. + For e.g., ["frappe.utils.cint", "frappe.db.get_all", ...] + """ + def get_keys(obj): + out = [] + for key in obj: + if key.startswith('_'): + continue + value = obj[key] + if isinstance(value, (NamespaceDict, dict)) and value: + if key == 'form_dict': + out.append(['form_dict', 7]) + continue + for subkey, score in get_keys(value): + fullkey = f'{key}.{subkey}' + out.append([fullkey, score]) + else: + if isinstance(value, type) and issubclass(value, Exception): + score = 0 + elif isinstance(value, ModuleType): + score = 10 + elif isinstance(value, (FunctionType, MethodType)): + score = 9 + elif isinstance(value, type): + score = 8 + elif isinstance(value, dict): + score = 7 + else: + score = 6 + out.append([key, score]) + return out + + items = frappe.cache().get_value('server_script_autocompletion_items') + if not items: + items = get_keys(get_safe_globals()) + items = [{'value': d[0], 'score': d[1]} for d in items] + frappe.cache().set_value('server_script_autocompletion_items', items) + return items + @frappe.whitelist() def setup_scheduler_events(script_name, frequency): diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 77f62b3ec3..442b8dbb31 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -33,6 +33,8 @@ "show_preview_popup", "image_view", "email_settings_section", + "default_email_template", + "column_break_26", "email_append_to", "sender_field", "subject_field", @@ -264,6 +266,16 @@ "label": "Actions", "options": "DocType Action" }, + { + "fieldname": "default_email_template", + "fieldtype": "Link", + "label": "Default Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, { "collapsible": 1, "fieldname": "naming_section", @@ -283,7 +295,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-02-16 15:22:11.108256", + "modified": "2021-03-22 12:27:15.462727", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", @@ -304,4 +316,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 9f6996a660..be0dded99c 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -491,6 +491,7 @@ doctype_properties = { 'allow_auto_repeat': 'Check', 'allow_import': 'Check', 'show_preview_popup': 'Check', + 'default_email_template': 'Data', 'email_append_to': 'Check', 'subject_field': 'Data', 'sender_field': 'Data', diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index f9997d1526..7d1d92408c 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -1,17 +1,13 @@ -from __future__ import unicode_literals - -import frappe import warnings import pymysql -from pymysql.times import TimeDelta -from pymysql.constants import ER, FIELD_TYPE -from pymysql.converters import conversions +from pymysql.constants import ER, FIELD_TYPE +from pymysql.converters import conversions, escape_string -from frappe.utils import get_datetime, cstr, UnicodeWithAttrs +import frappe from frappe.database.database import Database -from six import PY2, binary_type, text_type, string_types from frappe.database.mariadb.schema import MariaDBTable +from frappe.utils import UnicodeWithAttrs, cstr, get_datetime class MariaDBDatabase(Database): @@ -72,22 +68,20 @@ class MariaDBDatabase(Database): conversions.update({ FIELD_TYPE.NEWDECIMAL: float, FIELD_TYPE.DATETIME: get_datetime, - UnicodeWithAttrs: conversions[text_type] + UnicodeWithAttrs: conversions[str] }) - if PY2: - conversions.update({ - TimeDelta: conversions[binary_type] - }) - - if usessl: - conn = pymysql.connect(self.host, self.user or '', self.password or '', - port=self.port, charset='utf8mb4', use_unicode = True, ssl=ssl_params, - conv = conversions, local_infile = frappe.conf.local_infile) - else: - conn = pymysql.connect(self.host, self.user or '', self.password or '', - port=self.port, charset='utf8mb4', use_unicode = True, conv = conversions, - local_infile = frappe.conf.local_infile) + conn = pymysql.connect( + user=self.user or '', + password=self.password or '', + host=self.host, + port=self.port, + charset='utf8mb4', + use_unicode=True, + ssl=ssl_params if usessl else None, + conv=conversions, + local_infile=frappe.conf.local_infile + ) # MYSQL_OPTION_MULTI_STATEMENTS_OFF = 1 # # self._conn.set_server_option(MYSQL_OPTION_MULTI_STATEMENTS_OFF) @@ -111,7 +105,7 @@ class MariaDBDatabase(Database): def escape(s, percent=True): """Excape quotes and percent in given string.""" # pymysql expects unicode argument to escape_string with Python 3 - s = frappe.as_unicode(pymysql.escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`") + s = frappe.as_unicode(escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`") # NOTE separating % escape, because % escape should only be done when using LIKE operator # or when you use python format string to generate query that already has a %s @@ -260,7 +254,7 @@ class MariaDBDatabase(Database): ADD INDEX `%s`(%s)""" % (table_name, index_name, ", ".join(fields))) def add_unique(self, doctype, fields, constraint_name=None): - if isinstance(fields, string_types): + if isinstance(fields, str): fields = [fields] if not constraint_name: constraint_name = "unique_" + "_".join(fields) diff --git a/frappe/desk/doctype/notification_settings/notification_settings.js b/frappe/desk/doctype/notification_settings/notification_settings.js index 88dc145be2..cc2fd95204 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.js +++ b/frappe/desk/doctype/notification_settings/notification_settings.js @@ -19,7 +19,7 @@ frappe.ui.form.on('Notification Settings', { refresh: (frm) => { if (frappe.user.has_role('System Manager')) { - frm.add_custom_button('Go to Notification Settings List', () => { + frm.add_custom_button(__('Go to Notification Settings List'), () => { frappe.set_route('List', 'Notification Settings'); }); } diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 3d04c171a7..86f8ec0aa7 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -126,13 +126,14 @@ def setup_group_by(data): if data.group_by: if data.aggregate_function.lower() not in ('count', 'sum', 'avg'): frappe.throw(_('Invalid aggregate function')) - if '`' in data.aggregate_on: - raise_invalid_field(data.aggregate_on) - data.fields.append('{aggregate_function}(`tab{doctype}`.`{aggregate_on}`) AS _aggregate_column'.format(**data)) - if data.aggregate_on: - data.fields.append(data.aggregate_on) - data.pop('aggregate_on') + if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field): + data.fields.append('{aggregate_function}(`tab{aggregate_on_doctype}`.`{aggregate_on_field}`) AS _aggregate_column'.format(**data)) + else: + raise_invalid_field(data.aggregate_on_field) + + data.pop('aggregate_on_doctype') + data.pop('aggregate_on_field') data.pop('aggregate_function') def raise_invalid_field(fieldname): diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index 12fdb0dadc..6f0d7d3d5f 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -36,7 +36,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters): return out @frappe.whitelist() -def get_children(doctype, parent=''): +def get_children(doctype, parent='', **filters): return _get_children(doctype, parent) def _get_children(doctype, parent='', ignore_permissions=False): @@ -66,7 +66,7 @@ def add_node(): doc.save() def make_tree_args(**kwarg): - del kwarg['cmd'] + kwarg.pop('cmd', None) doctype = kwarg['doctype'] parent_field = 'parent_' + doctype.lower().replace(' ', '_') diff --git a/frappe/email/receive.py b/frappe/email/receive.py index cf6c13ee76..949da4a343 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -1,18 +1,27 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -from __future__ import unicode_literals -import six -from six import iteritems, text_type -from six.moves import range -import time, _socket, poplib, imaplib, email, email.utils, datetime, chardet, re -from email_reply_parser import EmailReplyParser +import datetime +import email +import email.utils +import imaplib +import poplib +import re +import time from email.header import decode_header + +import _socket +import chardet +import six +from email_reply_parser import EmailReplyParser + import frappe from frappe import _, safe_decode, safe_encode -from frappe.utils import (extract_email_id, convert_utc_to_user_timezone, now, - cint, cstr, strip, markdown, parse_addr) -from frappe.core.doctype.file.file import get_random_filename, MaxFileSizeReachedError +from frappe.core.doctype.file.file import (MaxFileSizeReachedError, + get_random_filename) +from frappe.utils import (cint, convert_utc_to_user_timezone, cstr, + extract_email_id, markdown, now, parse_addr, strip) + class EmailSizeExceededError(frappe.ValidationError): pass class EmailTimeoutError(frappe.ValidationError): pass @@ -337,7 +346,7 @@ class EmailServer: return self.imap.select("Inbox") - for uid, operation in iteritems(uid_list): + for uid, operation in uid_list.items(): if not uid: continue op = "+FLAGS" if operation == "Read" else "-FLAGS" @@ -473,7 +482,7 @@ class Email: self.html_content += markdown(text_content) def get_charset(self, part): - """Detect chartset.""" + """Detect charset.""" charset = part.get_content_charset() if not charset: charset = chardet.detect(safe_encode(cstr(part)))['encoding'] @@ -484,7 +493,7 @@ class Email: charset = self.get_charset(part) try: - return text_type(part.get_payload(decode=True), str(charset), "ignore") + return str(part.get_payload(decode=True), str(charset), "ignore") except LookupError: return part.get_payload() diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index 09da1ecc42..53f0935c80 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -2,22 +2,23 @@ # Copyright (c) 2015, Frappe Technologies and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import dropbox import json -import frappe import os -from frappe import _ -from frappe.model.document import Document -from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site -from frappe.integrations.utils import make_post_request -from frappe.utils import (cint, get_request_site_address, - get_files_path, get_backups_path, get_url, encode) -from frappe.utils.backups import new_backup -from frappe.utils.background_jobs import enqueue -from six.moves.urllib.parse import urlparse, parse_qs +from urllib.parse import parse_qs, urlparse + +import dropbox from rq.timeouts import JobTimeoutException -from six import text_type + +import frappe +from frappe import _ +from frappe.integrations.offsite_backup_utils import (get_chunk_site, + get_latest_backup_file, send_email, validate_file_size) +from frappe.integrations.utils import make_post_request +from frappe.model.document import Document +from frappe.utils import (cint, encode, get_backups_path, get_files_path, + get_request_site_address, get_url) +from frappe.utils.background_jobs import enqueue +from frappe.utils.backups import new_backup ignore_list = [".DS_Store"] @@ -91,7 +92,10 @@ def backup_to_dropbox(upload_db_backup=True): dropbox_settings['access_token'] = access_token['oauth2_token'] set_dropbox_access_token(access_token['oauth2_token']) - dropbox_client = dropbox.Dropbox(dropbox_settings['access_token'], timeout=None) + dropbox_client = dropbox.Dropbox( + oauth2_access_token=dropbox_settings['access_token'], + timeout=None + ) if upload_db_backup: if frappe.flags.create_new_backup: @@ -127,7 +131,7 @@ def upload_from_folder(path, is_private, dropbox_folder, dropbox_client, did_not else: response = frappe._dict({"entries": []}) - path = text_type(path) + path = str(path) for f in frappe.get_all("File", filters={"is_folder": 0, "is_private": is_private, "uploaded_to_dropbox": 0}, fields=['file_url', 'name', 'file_name']): @@ -286,11 +290,11 @@ def get_redirect_url(): def get_dropbox_authorize_url(): app_details = get_dropbox_settings(redirect_uri=True) dropbox_oauth_flow = dropbox.DropboxOAuth2Flow( - app_details["app_key"], - app_details["app_secret"], - app_details["redirect_uri"], - {}, - "dropbox-auth-csrf-token" + consumer_key=app_details["app_key"], + redirect_uri=app_details["redirect_uri"], + session={}, + csrf_token_session_key="dropbox-auth-csrf-token", + consumer_secret=app_details["app_secret"] ) auth_url = dropbox_oauth_flow.start() @@ -307,13 +311,13 @@ def dropbox_auth_finish(return_access_token=False): close = '

' + _('Please close this window') + '

' dropbox_oauth_flow = dropbox.DropboxOAuth2Flow( - app_details["app_key"], - app_details["app_secret"], - app_details["redirect_uri"], - { + consumer_key=app_details["app_key"], + redirect_uri=app_details["redirect_uri"], + session={ 'dropbox-auth-csrf-token': callback.state }, - "dropbox-auth-csrf-token" + csrf_token_session_key="dropbox-auth-csrf-token", + consumer_secret=app_details["app_secret"] ) if callback.state or callback.code: diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index fbedd75029..f93be35aa7 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -2,22 +2,23 @@ # Copyright (c) 2019, Frappe Technologies and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import frappe -import requests -import googleapiclient.discovery -import google.oauth2.credentials -from frappe import _ -from frappe.model.document import Document -from frappe.utils import get_request_site_address -from googleapiclient.errors import HttpError -from frappe.utils.password import set_encrypted_password -from frappe.utils import add_days, get_datetime, get_weekdays, now_datetime, add_to_date, get_time_zone -from dateutil import parser from datetime import datetime, timedelta -from six.moves.urllib.parse import quote +from urllib.parse import quote + +import google.oauth2.credentials +import requests +from dateutil import parser +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +import frappe +from frappe import _ from frappe.integrations.doctype.google_settings.google_settings import get_auth_url +from frappe.model.document import Document +from frappe.utils import (add_days, add_to_date, get_datetime, + get_request_site_address, get_time_zone, get_weekdays, now_datetime) +from frappe.utils.password import set_encrypted_password SCOPES = "https://www.googleapis.com/auth/calendar" @@ -171,7 +172,12 @@ def get_google_calendar_object(g_calendar): } credentials = google.oauth2.credentials.Credentials(**credentials_dict) - google_calendar = googleapiclient.discovery.build("calendar", "v3", credentials=credentials) + google_calendar = build( + serviceName="calendar", + version="v3", + credentials=credentials, + static_discovery=False + ) check_google_calendar(account, google_calendar) diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index 4c8c3b67f6..1705f98e91 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -2,17 +2,17 @@ # Copyright (c) 2019, Frappe Technologies and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import frappe -import requests -import googleapiclient.discovery -import google.oauth2.credentials -from frappe.model.document import Document -from frappe import _ +import google.oauth2.credentials +import requests +from googleapiclient.discovery import build from googleapiclient.errors import HttpError -from frappe.utils import get_request_site_address + +import frappe +from frappe import _ from frappe.integrations.doctype.google_settings.google_settings import get_auth_url +from frappe.model.document import Document +from frappe.utils import get_request_site_address SCOPES = "https://www.googleapis.com/auth/contacts" @@ -118,7 +118,12 @@ def get_google_contacts_object(g_contact): } credentials = google.oauth2.credentials.Credentials(**credentials_dict) - google_contacts = googleapiclient.discovery.build("people", "v1", credentials=credentials) + google_contacts = build( + serviceName="people", + version="v1", + credentials=credentials, + static_discovery=False + ) return google_contacts, account diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py index 859c769018..93b6fa3f8d 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.py +++ b/frappe/integrations/doctype/google_drive/google_drive.py @@ -2,27 +2,29 @@ # Copyright (c) 2019, Frappe Technologies and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import frappe -import requests -import googleapiclient.discovery -import google.oauth2.credentials import os +from urllib.parse import quote -from frappe import _ -from googleapiclient.errors import HttpError -from frappe.model.document import Document -from frappe.utils import get_request_site_address -from frappe.utils.background_jobs import enqueue -from six.moves.urllib.parse import quote +import google.oauth2.credentials +import requests from apiclient.http import MediaFileUpload -from frappe.utils import get_backups_path, get_bench_path -from frappe.utils.backups import new_backup +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +import frappe +from frappe import _ from frappe.integrations.doctype.google_settings.google_settings import get_auth_url -from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size +from frappe.integrations.offsite_backup_utils import (get_latest_backup_file, + send_email, validate_file_size) +from frappe.model.document import Document +from frappe.utils import (get_backups_path, get_bench_path, + get_request_site_address) +from frappe.utils.background_jobs import enqueue +from frappe.utils.backups import new_backup SCOPES = "https://www.googleapis.com/auth/drive" + class GoogleDrive(Document): def validate(self): @@ -126,7 +128,12 @@ def get_google_drive_object(): } credentials = google.oauth2.credentials.Credentials(**credentials_dict) - google_drive = googleapiclient.discovery.build("drive", "v3", credentials=credentials) + google_drive = build( + serviceName="drive", + version="v3", + credentials=credentials, + static_discovery=False + ) return google_drive, account diff --git a/frappe/integrations/doctype/token_cache/test_token_cache.py b/frappe/integrations/doctype/token_cache/test_token_cache.py index 73c9f38fce..7aa069647d 100644 --- a/frappe/integrations/doctype/token_cache/test_token_cache.py +++ b/frappe/integrations/doctype/token_cache/test_token_cache.py @@ -13,7 +13,7 @@ class TestTokenCache(unittest.TestCase): def setUp(self): self.token_cache = frappe.get_last_doc('Token Cache') self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name}) - self.token_cache.save() + self.token_cache.save(ignore_permissions=True) def test_get_auth_header(self): self.token_cache.get_auth_header() diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index c444964a16..3ebaaffcff 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -133,7 +133,7 @@ def get_token(*args, **kwargs): } id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header) - out.update({"id_token": str(id_token_encoded)}) + out.update({"id_token": frappe.safe_decode(id_token_encoded)}) frappe.local.response = out diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 9981398b84..b17ce973ec 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -159,9 +159,10 @@ frappe.ui.form.Control = Class.extend({ }, validate_and_set_in_model: function(value, e) { var me = this; - if(this.inside_change_event) { + if (this.inside_change_event || this.get_model_value() === value) { return Promise.resolve(); } + this.inside_change_event = true; var set = function(value) { me.inside_change_event = false; diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js index eec450b390..9600763588 100644 --- a/frappe/public/js/frappe/form/controls/code.js +++ b/frappe/public/js/frappe/form/controls/code.js @@ -31,6 +31,57 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({ const input_value = this.get_input_value(); this.parse_validate_and_set_in_model(input_value); }, 300)); + + // setup autocompletion when it is set the first time + Object.defineProperty(this.df, 'autocompletions', { + get() { + return this._autocompletions || []; + }, + set: (value) => { + this.setup_autocompletion(); + this.df._autocompletions = value; + } + }); + }, + + setup_autocompletion() { + if (this._autocompletion_setup) return; + + const ace = window.ace; + const get_autocompletions = () => this.df.autocompletions; + + ace.config.loadModule("ace/ext/language_tools", langTools => { + this.editor.setOptions({ + enableBasicAutocompletion: true, + enableLiveAutocompletion: true + }); + + langTools.addCompleter({ + getCompletions: function(editor, session, pos, prefix, callback) { + if (prefix.length === 0) { + callback(null, []); + return; + } + let autocompletions = get_autocompletions(); + if (autocompletions.length) { + callback( + null, + autocompletions.map(a => { + if (typeof a === 'string') { + a = { value: a }; + } + return { + name: 'frappe', + value: a.value, + score: a.score + }; + }) + ); + } + } + }); + }); + this._autocompletion_setup = true; }, refresh_height() { diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 1a483c5968..c0ff128088 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -462,9 +462,10 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({ if(this.frm && this.frm.fetch_dict[df.fieldname]) { fetch = this.frm.fetch_dict[df.fieldname].columns.join(', '); } - // if default and no fetch, no need to validate - if (!fetch && df.__default_value && df.__default_value===value) return value; + if (!fetch && df.__default_value && df.__default_value===value) { + resolve(value); + } this.fetch_and_validate_link(resolve, df, doctype, docname, value, fetch); }); diff --git a/frappe/public/js/frappe/form/controls/multiselect.js b/frappe/public/js/frappe/form/controls/multiselect.js index 64ca4fc83d..bbd7aef822 100644 --- a/frappe/public/js/frappe/form/controls/multiselect.js +++ b/frappe/public/js/frappe/form/controls/multiselect.js @@ -68,7 +68,7 @@ frappe.ui.form.ControlMultiSelect = frappe.ui.form.ControlAutocomplete.extend({ let data; if(this.df.get_data) { data = this.df.get_data(); - this.set_data(data); + if (data) this.set_data(data); } else { data = this._super(); } diff --git a/frappe/public/js/frappe/form/controls/table_multiselect.js b/frappe/public/js/frappe/form/controls/table_multiselect.js index c306146f90..eb3f1bce6e 100644 --- a/frappe/public/js/frappe/form/controls/table_multiselect.js +++ b/frappe/public/js/frappe/form/controls/table_multiselect.js @@ -66,6 +66,10 @@ frappe.ui.form.ControlTableMultiSelect = frappe.ui.form.ControlLink.extend({ this._rows_list = this.rows.map(row => row[link_field.fieldname]); return this.rows; }, + get_model_value() { + let value = this._super(); + return value ? value.filter(d => !d.__islocal) : value; + }, validate(value) { const rows = (value || []).slice(); diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js index 9b6d15c1fc..c1c95d94cf 100644 --- a/frappe/public/js/frappe/form/dashboard.js +++ b/frappe/public/js/frappe/form/dashboard.js @@ -290,7 +290,7 @@ frappe.ui.form.Dashboard = class FormDashboard { // bind links transactions_area_body.find(".badge-link").on('click', function() { - me.open_document_list($(this).parent()); + me.open_document_list($(this).closest('.document-link')); }); // bind reports diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index ef728e730e..de9331a726 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -360,6 +360,7 @@ frappe.ui.form.Form = class FrappeForm { grid_obj.grid.grid_pagination.go_to_page(1, true); }); frappe.ui.form.close_grid_form(); + this.viewers && this.viewers.parent.empty(); this.docname = docname; this.setup_docinfo_change_listener(); } diff --git a/frappe/public/js/frappe/form/form_viewers.js b/frappe/public/js/frappe/form/form_viewers.js index 3d488e4729..964576ef8a 100644 --- a/frappe/public/js/frappe/form/form_viewers.js +++ b/frappe/public/js/frappe/form/form_viewers.js @@ -7,6 +7,11 @@ frappe.ui.form.FormViewers = class FormViewers { refresh() { let users = this.frm.get_docinfo()['viewers']; + if (!users || !users.current || !users.current.length) { + this.parent.empty(); + return; + } + let currently_viewing = users.current.filter(user => user != frappe.session.user); let avatar_group = frappe.avatar_group(currently_viewing, 5, {'align': 'left', 'overlap': true}); this.parent.empty().append(avatar_group); diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index b211476e63..86feefed7a 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -194,7 +194,10 @@ export default class Grid { } tasks.push(() => { - if (dirty) this.refresh(); + if (dirty) { + this.refresh(); + this.frm.script_manager.trigger(this.df.fieldname + "_delete", this.doctype); + } }); frappe.run_serially(tasks); @@ -210,6 +213,7 @@ export default class Grid { this.frm.doc[this.df.fieldname] = []; $(this.parent).find('.rows').empty(); this.grid_rows = []; + this.frm.script_manager.trigger(this.df.fieldname + "_delete", this.doctype); this.wrapper.find('.grid-heading-row .grid-row-check:checked:first').prop('checked', 0); this.refresh(); diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 5e3a2b8ccd..4afa251c27 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -6,6 +6,7 @@ export default class GridRow { this.on_grid_fields = []; $.extend(this, opts); if (this.doc && this.parent_df.options) { + frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields); this.docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name); } this.columns = {}; @@ -555,6 +556,15 @@ export default class GridRow { this.grid_form.render(); this.row.toggle(false); // this.form_panel.toggle(true); + + if (this.grid.cannot_add_rows || (this.grid.df && this.grid.df.cannot_add_rows)) { + this.wrapper.find('.grid-insert-row-below, .grid-insert-row, .grid-duplicate-row') + .addClass('hidden'); + } else { + this.wrapper.find('.grid-insert-row-below, .grid-insert-row, .grid-duplicate-row') + .removeClass('hidden'); + } + frappe.dom.freeze("", "dark"); if (cur_frm) cur_frm.cur_grid = this; this.wrapper.addClass("grid-row-open"); diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 145b8d3eed..22787b70c1 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -211,7 +211,6 @@ frappe.ui.form.Toolbar = class Toolbar { make_viewers() { if (this.frm.viewers) { - this.frm.viewers.parent.empty(); return; } this.frm.viewers = new frappe.ui.form.FormViewers({ diff --git a/frappe/public/js/frappe/model/create_new.js b/frappe/public/js/frappe/model/create_new.js index dc6ee56fca..1b09a451eb 100644 --- a/frappe/public/js/frappe/model/create_new.js +++ b/frappe/public/js/frappe/model/create_new.js @@ -177,7 +177,9 @@ $.extend(frappe.model, { // Use User Permission value when only when it has a single value user_default = user_defaults[0]; } - } else if (!user_default) { + } + + if (!user_default) { user_default = frappe.defaults.get_user_default(df.fieldname); } else if ( !user_default && diff --git a/frappe/public/js/frappe/model/meta.js b/frappe/public/js/frappe/model/meta.js index c2fd6b1ae6..6ee9084adc 100644 --- a/frappe/public/js/frappe/model/meta.js +++ b/frappe/public/js/frappe/model/meta.js @@ -38,14 +38,14 @@ $.extend(frappe.meta, { frappe.meta.docfield_list[df.parent].push(df); }, - make_docfield_copy_for: function(doctype, docname) { + make_docfield_copy_for: function(doctype, docname, docfield_list=null) { var c = frappe.meta.docfield_copy; if(!c[doctype]) c[doctype] = {}; if(!c[doctype][docname]) c[doctype][docname] = {}; - var docfield_list = frappe.meta.docfield_list[doctype] || []; + docfield_list = docfield_list || frappe.meta.docfield_list[doctype] || []; for(var i=0, j=docfield_list.length; i { - this.dialog.fields_dict[field].get_data = function() { - const data = me.dialog.fields_dict[field].get_value(); - const txt = data.match(/[^,\s*]*$/)[0] || ''; - let options = []; - - frappe.call({ - method: "frappe.email.get_contact_list", - args: { - txt: txt, - }, - callback: (r) => { - options = r.message; - me.dialog.fields_dict[field].set_data(options); - } - }); - return options; - } - }); - this.prepare(); this.dialog.show(); if (this.frm) { $(document).trigger('form-typing', [this.frm]); } + } - if (this.cc || this.bcc) { - this.toggle_more_options(true); - } - }, - - get_fields: function() { - let contactList = []; - let fields = [ + get_fields() { + const fields = [ { label: __("To"), fieldtype: "MultiSelect", reqd: 0, fieldname: "recipients", - options: contactList }, { fieldtype: "Button", @@ -87,13 +69,11 @@ frappe.views.CommunicationComposer = Class.extend({ label: __("CC"), fieldtype: "MultiSelect", fieldname: "cc", - options: contactList }, { label: __("BCC"), fieldtype: "MultiSelect", fieldname: "bcc", - options: contactList }, { label: __("Email Template"), @@ -163,78 +143,83 @@ frappe.views.CommunicationComposer = Class.extend({ ); }); - if (frappe.boot.email_accounts && email_accounts.length > 1) { - fields = [ - { - label: __("From"), - fieldtype: "Select", - reqd: 1, - fieldname: "sender", - options: email_accounts.map(function(e) { - return e.email_id; - }) - } - ].concat(fields); + if (email_accounts.length > 1) { + fields.unshift({ + label: __("From"), + fieldtype: "Select", + reqd: 1, + fieldname: "sender", + options: email_accounts.map(function(e) { + return e.email_id; + }) + }); } return fields; - }, + } toggle_more_options(show_options) { show_options = show_options || this.dialog.fields_dict.more_options.df.hidden; this.dialog.set_df_property('more_options', 'hidden', !show_options); - let label = frappe.utils.icon(show_options ? 'up-line': 'down'); - this.dialog.get_field('option_toggle_button').set_label(label); - }, - prepare: function() { + const label = frappe.utils.icon(show_options ? 'up-line': 'down'); + this.dialog.get_field('option_toggle_button').set_label(label); + } + + prepare() { + this.setup_multiselect_queries(); this.setup_subject_and_recipients(); this.setup_print_language(); this.setup_print(); this.setup_attach(); this.setup_email(); - this.setup_last_edited_communication(); this.setup_email_template(); + this.setup_last_edited_communication(); + this.set_values(); + } - this.dialog.set_value("recipients", this.recipients || ''); - this.dialog.set_value("cc", this.cc || ''); - this.dialog.set_value("bcc", this.bcc || ''); + setup_multiselect_queries() { + ['recipients', 'cc', 'bcc'].forEach(field => { + this.dialog.fields_dict[field].get_data = () => { + const data = this.dialog.fields_dict[field].get_value(); + const txt = data.match(/[^,\s*]*$/)[0] || ''; - if(this.dialog.fields_dict.sender) { - this.dialog.fields_dict.sender.set_value(this.sender || ''); - } - this.dialog.fields_dict.subject.set_value( - frappe.utils.html2text(this.subject) || '' - ); + frappe.call({ + method: "frappe.email.get_contact_list", + args: {txt}, + callback: (r) => { + this.dialog.fields_dict[field].set_data(r.message); + } + }); + }; + }); + } - this.setup_earlier_reply(); - }, - - setup_subject_and_recipients: function() { + setup_subject_and_recipients() { this.subject = this.subject || ""; - if(!this.forward && !this.recipients && this.last_email) { + if (!this.forward && !this.recipients && this.last_email) { this.recipients = this.last_email.sender; this.cc = this.last_email.cc; this.bcc = this.last_email.bcc; } - if(!this.forward && !this.recipients) { + if (!this.forward && !this.recipients) { this.recipients = this.frm && this.frm.timeline.get_recipient(); } - if(!this.subject && this.frm) { + if (!this.subject && this.frm) { // get subject from last communication - var last = this.frm.timeline.get_last_email(); + const last = this.frm.timeline.get_last_email(); - if(last) { + if (last) { this.subject = last.subject; - if(!this.recipients) { + if (!this.recipients) { this.recipients = last.sender; } // prepend "Re:" - if(strip(this.subject.toLowerCase().split(":")[0])!="re") { + if (strip(this.subject.toLowerCase().split(":")[0])!="re") { this.subject = __("Re: {0}", [this.subject]); } } @@ -251,7 +236,7 @@ frappe.views.CommunicationComposer = Class.extend({ // always add an identifier to catch a reply // some email clients (outlook) may not send the message id to identify // the thread. So as a backup we use the name of the document as identifier - let identifier = `#${this.frm.doc.name}`; + const identifier = `#${this.frm.doc.name}`; if (!this.subject.includes(identifier)) { this.subject = `${this.subject} (${identifier})`; } @@ -260,33 +245,25 @@ frappe.views.CommunicationComposer = Class.extend({ if (this.frm && !this.recipients) { this.recipients = this.frm.doc[this.frm.email_field]; } - }, + } - setup_email_template: function() { - var me = this; + setup_email_template() { + const me = this; this.dialog.fields_dict["email_template"].df.onchange = () => { - var email_template = me.dialog.fields_dict.email_template.get_value(); + const email_template = me.dialog.fields_dict.email_template.get_value(); + if (!email_template) return; - var prepend_reply = function(reply) { - if(me.reply_added===email_template) { - return; - } - var content_field = me.dialog.fields_dict.content; - var subject_field = me.dialog.fields_dict.subject; - var content = content_field.get_value() || ""; - var subject = subject_field.get_value() || ""; + function prepend_reply(reply) { + if (me.reply_added === email_template) return; - var parts = content.split(''); + const content_field = me.dialog.fields_dict.content; + const subject_field = me.dialog.fields_dict.subject; - if(parts.length===2) { - content = [reply.message, "
", parts[1]]; - } else { - content = [reply.message, "
", content]; - } - - content_field.set_value(content.join('')); + let content = content_field.get_value() || ""; + content = content.split('')[1] || content; + content_field.set_value(`${reply.message}
${content}`); subject_field.set_value(reply.subject); me.reply_added = email_template; @@ -296,86 +273,107 @@ frappe.views.CommunicationComposer = Class.extend({ method: 'frappe.email.doctype.email_template.email_template.get_email_template', args: { template_name: email_template, - doc: me.frm.doc, + doc: me.doc, _lang: me.dialog.get_value("language_sel") }, - callback: function(r) { + callback(r) { prepend_reply(r.message); }, }); - } - }, + }; + } - setup_last_edited_communication: function() { - var me = this; - if (!this.doc){ - if (cur_frm){ - this.doc = cur_frm.doctype; - }else{ - this.doc = "Inbox"; - } - } - if (cur_frm && cur_frm.docname) { - this.key = cur_frm.docname; + setup_last_edited_communication() { + if (this.frm) { + this.doctype = this.frm.doctype; + this.key = this.frm.docname; } else { - this.key = "Inbox"; + this.doctype = this.key = "Inbox"; } - if(this.last_email) { + + if (this.last_email) { this.key = this.key + ":" + this.last_email.name; } - if(this.subject){ + + if (this.subject) { this.key = this.key + ":" + this.subject; } - this.dialog.onhide = function() { - var last_edited_communication = me.get_last_edited_communication(); - $.extend(last_edited_communication, { - sender: me.dialog.get_value("sender"), - recipients: me.dialog.get_value("recipients"), - cc: me.dialog.get_value("cc"), - bcc: me.dialog.get_value("bcc"), - subject: me.dialog.get_value("subject"), - content: me.dialog.get_value("content"), - }); - if (me.frm) { - $(document).trigger("form-stopped-typing", [me.frm]); + this.dialog.on_hide = () => { + $.extend( + this.get_last_edited_communication(true), + this.dialog.get_values(true) + ); + + if (this.frm) { + $(document).trigger("form-stopped-typing", [this.frm]); + } + }; + } + + get_last_edited_communication(clear) { + if (!frappe.last_edited_communication[this.doctype]) { + frappe.last_edited_communication[this.doctype] = {}; + } + + if (clear || !frappe.last_edited_communication[this.doctype][this.key]) { + frappe.last_edited_communication[this.doctype][this.key] = {}; + } + + return frappe.last_edited_communication[this.doctype][this.key]; + } + + async set_values() { + for (const fieldname of ["recipients", "cc", "bcc", "sender"]) { + await this.dialog.set_value(fieldname, this[fieldname] || ""); + } + + const subject = frappe.utils.html2text(this.subject) || ''; + await this.dialog.set_value("subject", subject); + + await this.set_values_from_last_edited_communication(); + await this.set_content(); + + // set default email template for the first email in a document + if (this.frm && !this.is_a_reply && !this.content_set) { + const email_template = this.frm.meta.default_email_template || ''; + await this.dialog.set_value("email_template", email_template); + } + + for (const fieldname of ['email_template', 'cc', 'bcc']) { + if (this.dialog.get_value(fieldname)) { + this.toggle_more_options(true); + break; } } + } - this.dialog.on_page_show = function() { - if (!me.txt) { - var last_edited_communication = me.get_last_edited_communication(); - if(last_edited_communication.content) { - me.dialog.set_value("sender", last_edited_communication.sender || ""); - me.dialog.set_value("subject", last_edited_communication.subject || ""); - me.dialog.set_value("recipients", last_edited_communication.recipients || ""); - me.dialog.set_value("cc", last_edited_communication.cc || ""); - me.dialog.set_value("bcc", last_edited_communication.bcc || ""); - me.dialog.set_value("content", last_edited_communication.content || ""); - } - } + async set_values_from_last_edited_communication() { + if (this.txt) return; + const last_edited = this.get_last_edited_communication(); + if (!last_edited.content) return; + + // prevent re-triggering of email template + if (last_edited.email_template) { + const template_field = this.dialog.fields_dict.email_template; + await template_field.set_model_value(last_edited.email_template); + delete last_edited.email_template; } - }, + await this.dialog.set_values(last_edited); + this.content_set = true; + } - get_last_edited_communication: function() { - if (!frappe.last_edited_communication[this.doc]) { - frappe.last_edited_communication[this.doc] = {}; - } + selected_format() { + return ( + this.dialog.fields_dict.select_print_format.input.value + || this.frm && this.frm.meta.default_print_format + || "Standard" + ); + } - if(!frappe.last_edited_communication[this.doc][this.key]) { - frappe.last_edited_communication[this.doc][this.key] = {}; - } - - return frappe.last_edited_communication[this.doc][this.key]; - }, - - selected_format: function() { - return this.dialog.fields_dict.select_print_format.input.value || (this.frm && this.frm.meta.default_print_format) || "Standard"; - }, - - get_print_format: function(format) { + get_print_format(format) { if (!format) { format = this.selected_format(); } @@ -385,21 +383,18 @@ frappe.views.CommunicationComposer = Class.extend({ } else { return {}; } - }, + } - setup_print_language: function() { - var doc = this.doc || cur_frm.doc; - var fields = this.dialog.fields_dict; + setup_print_language() { + const fields = this.dialog.fields_dict; //Load default print language from doctype - this.lang_code = doc.language - - if (!this.lang_code && this.get_print_format().default_print_language) { - this.lang_code = this.get_print_format().default_print_language; - } + this.lang_code = this.doc.language + || this.get_print_format().default_print_language + || frappe.boot.lang; //On selection of language retrieve language code - var me = this; + const me = this; $(fields.language_sel.input).change(function(){ me.lang_code = this.value }) @@ -412,11 +407,11 @@ frappe.views.CommunicationComposer = Class.extend({ if (this.lang_code) { $(fields.language_sel.input).val(this.lang_code); } - }, + } - setup_print: function() { + setup_print() { // print formats - var fields = this.dialog.fields_dict; + const fields = this.dialog.fields_dict; // toggle print format $(fields.attach_document_print.input).click(function() { @@ -426,8 +421,8 @@ frappe.views.CommunicationComposer = Class.extend({ // select print format $(fields.select_print_format.wrapper).toggle(false); - if (cur_frm) { - const print_formats = frappe.meta.get_print_formats(cur_frm.meta.name); + if (this.frm) { + const print_formats = frappe.meta.get_print_formats(this.frm.meta.name); $(fields.select_print_format.input) .empty() .add_options(print_formats) @@ -436,11 +431,11 @@ frappe.views.CommunicationComposer = Class.extend({ $(fields.attach_document_print.wrapper).toggle(false); } - }, + } - setup_attach: function() { - var fields = this.dialog.fields_dict; - var attach = $(fields.select_attachments.wrapper); + setup_attach() { + const fields = this.dialog.fields_dict; + const attach = $(fields.select_attachments.wrapper); if (!this.attachments) { this.attachments = []; @@ -483,9 +478,9 @@ frappe.views.CommunicationComposer = Class.extend({ .find(".add-more-attachments button") .on('click', () => new frappe.ui.FileUploader(args)); this.render_attachment_rows(); - }, + } - render_attachment_rows: function(attachment) { + render_attachment_rows(attachment) { const select_attachments = this.dialog.fields_dict.select_attachments; const attachment_rows = $(select_attachments.wrapper).find(".attach-list"); if (attachment) { @@ -509,7 +504,7 @@ frappe.views.CommunicationComposer = Class.extend({ }); } } - }, + } get_attachment_row(attachment, checked) { return $(`

@@ -526,56 +521,55 @@ frappe.views.CommunicationComposer = Class.extend({ ${frappe.utils.icon('link-url')}

`); - }, + } - setup_email: function() { + setup_email() { // email - var fields = this.dialog.fields_dict; + const fields = this.dialog.fields_dict; - if(this.attach_document_print) { + if (this.attach_document_print) { $(fields.attach_document_print.input).click(); $(fields.select_print_format.wrapper).toggle(true); } $(fields.send_me_a_copy.input).on('click', () => { // update send me a copy (make it sticky) - let val = fields.send_me_a_copy.get_value(); + const val = fields.send_me_a_copy.get_value(); frappe.db.set_value('User', frappe.session.user, 'send_me_a_copy', val); frappe.boot.user.send_me_a_copy = val; }); - }, + } - send_action: function() { - var me = this; - var btn = me.dialog.get_primary_btn(); + send_action() { + const me = this; + const btn = me.dialog.get_primary_btn(); + const form_values = this.get_values(); + if (!form_values) return; - var form_values = this.get_values(); - if(!form_values) return; - - var selected_attachments = + const selected_attachments = $.map($(me.dialog.wrapper).find("[data-file-name]:checked"), function (element) { return $(element).attr("data-file-name"); }); - if(form_values.attach_document_print) { + if (form_values.attach_document_print) { me.send_email(btn, form_values, selected_attachments, null, form_values.select_print_format || ""); } else { me.send_email(btn, form_values, selected_attachments); } - }, + } - get_values: function() { - var form_values = this.dialog.get_values(); + get_values() { + const form_values = this.dialog.get_values(); // cc - for ( var i=0, l=this.dialog.fields.length; i < l; i++ ) { - var df = this.dialog.fields[i]; + for (let i = 0, l = this.dialog.fields.length; i < l; i++) { + const df = this.dialog.fields[i]; - if ( df.is_cc_checkbox ) { + if (df.is_cc_checkbox) { // concat in cc - if ( form_values[df.fieldname] ) { + if (form_values[df.fieldname]) { form_values.cc = ( form_values.cc ? (form_values.cc + ", ") : "" ) + df.fieldname; form_values.bcc = ( form_values.bcc ? (form_values.bcc + ", ") : "" ) + df.fieldname; } @@ -585,22 +579,27 @@ frappe.views.CommunicationComposer = Class.extend({ } return form_values; - }, + } - save_as_draft: function() { + save_as_draft() { if (this.dialog && this.frm) { let message = this.dialog.get_value('content'); - message = message.split(frappe.separator_element)[0]; + message = message.split(separator_element)[0]; localforage.setItem(this.frm.doctype + this.frm.docname, message).catch(e => { if (e) { // silently fail console.log(e); // eslint-disable-line - console.warn('[Communication] localStorage is full. Cannot save message as draft'); // eslint-disable-line + console.warn('[Communication] IndexedDB is full. Cannot save message as draft'); // eslint-disable-line } }); } - }, + } + + clear_cache() { + this.delete_saved_draft(); + this.get_last_edited_communication(true); + } delete_saved_draft() { if (this.dialog && this.frm) { @@ -608,28 +607,28 @@ frappe.views.CommunicationComposer = Class.extend({ if (e) { // silently fail console.log(e); // eslint-disable-line - console.warn('[Communication] localStorage is full. Cannot save message as draft'); // eslint-disable-line + console.warn('[Communication] IndexedDB is full. Cannot save message as draft'); // eslint-disable-line } }); } - }, + } - send_email: function(btn, form_values, selected_attachments, print_html, print_format) { - var me = this; - me.dialog.hide(); + send_email(btn, form_values, selected_attachments, print_html, print_format) { + const me = this; + this.dialog.hide(); - if(!form_values.recipients) { + if (!form_values.recipients) { frappe.msgprint(__("Enter Email Recipient(s)")); return; } - if(!form_values.attach_document_print) { + if (!form_values.attach_document_print) { print_html = null; print_format = null; } - if(cur_frm && !frappe.model.can_email(me.doc.doctype, cur_frm)) { + if (this.frm && !frappe.model.can_email(this.doc.doctype, this.frm)) { frappe.msgprint(__("You are not allowed to send emails related to this document")); return; } @@ -650,28 +649,29 @@ frappe.views.CommunicationComposer = Class.extend({ send_me_a_copy: form_values.send_me_a_copy, print_format: print_format, sender: form_values.sender, - sender_full_name: form_values.sender?frappe.user.full_name():undefined, + sender_full_name: form_values.sender + ? frappe.user.full_name() + : undefined, email_template: form_values.email_template, attachments: selected_attachments, _lang : me.lang_code, read_receipt:form_values.send_read_receipt, print_letterhead: me.is_print_letterhead_checked(), }, - btn: btn, - callback: function(r) { - if(!r.exc) { + btn, + callback(r) { + if (!r.exc) { frappe.utils.play_sound("email"); - if(r.message["emails_not_sent_to"]) { + if (r.message["emails_not_sent_to"]) { frappe.msgprint(__("Email not sent to {0} (unsubscribed / disabled)", [ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) ); } - if ((frappe.last_edited_communication[me.doc] || {})[me.key]) { - delete frappe.last_edited_communication[me.doc][me.key]; - } - if (cur_frm) { - cur_frm.reload_doc(); + me.clear_cache(); + + if (me.frm) { + me.frm.reload_doc(); } // try the success callback if it exists @@ -679,7 +679,7 @@ frappe.views.CommunicationComposer = Class.extend({ try { me.success(r); } catch (e) { - console.log(e); + console.log(e); // eslint-disable-line } } @@ -691,113 +691,115 @@ frappe.views.CommunicationComposer = Class.extend({ try { me.error(r); } catch (e) { - console.log(e); + console.log(e); // eslint-disable-line } } } } }); - }, + } - is_print_letterhead_checked: function() { + is_print_letterhead_checked() { if (this.frm && $(this.frm.wrapper).find('.form-print-wrapper').is(':visible')){ return $(this.frm.wrapper).find('.print-letterhead').prop('checked') ? 1 : 0; } else { return (frappe.model.get_doc(":Print Settings", "Print Settings") || { with_letterhead: 1 }).with_letterhead ? 1 : 0; } - }, + } - get_default_outgoing_email_account_signature: function() { - return frappe.db.get_value('Email Account', { 'default_outgoing': 1, 'add_signature': 1 }, 'signature'); - }, + async set_content() { + if (this.content_set) return; - setup_earlier_reply: async function() { - let fields = this.dialog.fields_dict; - let signature = frappe.boot.user.email_signature || ""; - - if (!signature) { - const res = await this.get_default_outgoing_email_account_signature(); - signature = "" + res.message.signature; + let message = this.txt || ""; + if (!message && this.frm) { + const { doctype, docname } = this.frm; + message = await localforage.getItem(doctype + docname) || ""; } - if (signature && !frappe.utils.is_html(signature)) { - signature = signature.replace(/\n/g, "
"); + if (message) { + this.content_set = true; } - if(this.txt) { - this.message = this.txt + (this.message ? ("

" + this.message) : ""); - } else { - // saved draft in localStorage - const { doctype, docname } = this.frm || {}; - if (doctype && docname) { - this.message = await localforage.getItem(doctype + docname) || ''; - } - } - - if(this.real_name) { - this.message = '

'+__('Dear') +' ' - + this.real_name + ",


" + (this.message || ""); - } - - if(this.message && signature && this.message.includes(signature)) { - signature = ""; - } - - let reply = (this.message || "") + (signature ? ("
" + signature) : ""); - let content = ''; - - if (this.is_a_reply === 'undefined') { - this.is_a_reply = true; + message += await this.get_signature(); + if (this.real_name && !message.includes("")) { + message = `

${__('Dear')} ${this.real_name},

+
${message}`; } if (this.is_a_reply) { - let last_email = this.last_email; - - if (!last_email) { - last_email = this.frm && this.frm.timeline.get_last_email(true); - } - - if (!last_email) return; - - let last_email_content = last_email.original_comment || last_email.content; - - // convert the email context to text as we are enclosing - // this inside
- last_email_content = this.html2text(last_email_content).replace(/\n/g, '
'); - - // clip last email for a maximum of 20k characters - // to prevent the email content from getting too large - if (last_email_content.length > 20 * 1024) { - last_email_content += '
' + __('Message clipped') + '
' + last_email_content; - last_email_content = last_email_content.slice(0, 20 * 1024); - } - - let communication_date = last_email.communication_date || last_email.creation; - content = ` - ${reply} -

- ${frappe.separator_element || ''} -

${__("On {0}, {1} wrote:", [frappe.datetime.global_date_format(communication_date) , last_email.sender])}

-
- ${last_email_content} -
- `; - } else { - content = reply; + message += this.get_earlier_reply(); } - fields.content.set_value(content); - }, - html2text: function(html) { + await this.dialog.set_value("content", message); + } + + async get_signature() { + let signature = frappe.boot.user.email_signature; + + if (!signature) { + const response = await frappe.db.get_value( + 'Email Account', + {'default_outgoing': 1, 'add_signature': 1}, + 'signature' + ); + + signature = response.message.signature; + } + + if (!signature) return ""; + + if (!frappe.utils.is_html(signature)) { + signature = signature.replace(/\n/g, "
"); + } + + return "
" + signature; + } + + get_earlier_reply() { + const last_email = ( + this.last_email + || this.frm && this.frm.timeline.get_last_email(true) + ); + + if (!last_email) return ""; + let last_email_content = last_email.original_comment || last_email.content; + + // convert the email context to text as we are enclosing + // this inside
+ last_email_content = this.html2text(last_email_content).replace(/\n/g, '
'); + + // clip last email for a maximum of 20k characters + // to prevent the email content from getting too large + if (last_email_content.length > 20 * 1024) { + last_email_content += '
' + __('Message clipped') + '
' + last_email_content; + last_email_content = last_email_content.slice(0, 20 * 1024); + } + + const communication_date = frappe.datetime.global_date_format( + last_email.communication_date || last_email.creation + ); + + return ` +

+ ${separator_element || ''} +

+ ${__("On {0}, {1} wrote:", [communication_date, last_email.sender])} +

+
+ ${last_email_content} +
+ `; + } + + html2text(html) { // convert HTML to text and try and preserve whitespace - var d = document.createElement( 'div' ); + const d = document.createElement( 'div' ); d.innerHTML = html.replace(/<\/div>/g, '
') // replace end of blocks .replace(/<\/p>/g, '

') // replace end of paragraphs .replace(/
/g, '\n'); - let text = d.textContent; // replace multiple empty lines with just one - return text.replace(/\n{3,}/g, '\n\n'); + return d.textContent.replace(/\n{3,}/g, '\n\n'); } -}); +}; diff --git a/frappe/public/js/frappe/views/inbox/inbox_view.js b/frappe/public/js/frappe/views/inbox/inbox_view.js index 1085e93e6c..8b53bd49a9 100644 --- a/frappe/public/js/frappe/views/inbox/inbox_view.js +++ b/frappe/public/js/frappe/views/inbox/inbox_view.js @@ -204,9 +204,7 @@ frappe.views.InboxView = class InboxView extends frappe.views.ListView { }; frappe.new_doc('Email Account'); } else { - new frappe.views.CommunicationComposer({ - doc: {} - }); + new frappe.views.CommunicationComposer(); } } }; diff --git a/frappe/public/js/frappe/views/kanban/kanban_board.js b/frappe/public/js/frappe/views/kanban/kanban_board.js index f563f64cb4..bbc2051e4c 100644 --- a/frappe/public/js/frappe/views/kanban/kanban_board.js +++ b/frappe/public/js/frappe/views/kanban/kanban_board.js @@ -306,6 +306,7 @@ frappe.provide("frappe.views"); store.on('change:cur_list', setup_restore_columns); store.on('change:columns', setup_restore_columns); store.on('change:empty_state', show_empty_state); + fluxify.doAction('update_order'); } function prepare() { diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index 701a0d09e9..8f4af36389 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -169,6 +169,9 @@ // Other Colors --sidebar-select-color: var(--gray-200); + --scrollbar-thumb-color: var(--gray-400); + --scrollbar-track-color: var(--gray-200); + --shadow-inset: inset 0px -1px 0px var(--gray-300); --border-color: var(--gray-100); --dark-border-color: var(--gray-300); diff --git a/frappe/public/scss/desk/dark.scss b/frappe/public/scss/desk/dark.scss index 5817e33ca0..4e83f4db47 100644 --- a/frappe/public/scss/desk/dark.scss +++ b/frappe/public/scss/desk/dark.scss @@ -65,6 +65,9 @@ --sidebar-select-color: var(--gray-800); + --scrollbar-thumb-color: var(--gray-600); + --scrollbar-track-color: var(--gray-700); + --shadow-inset: var(--fg-color); --border-color: var(--gray-700); --dark-border-color: var(--gray-600); @@ -75,6 +78,8 @@ // input --input-disabled-bg: none; + color-scheme: dark; + .frappe-card { .btn-default { background-color: var(--bg-color); diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index 1bb91090e6..ac3b1a4f7c 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -754,7 +754,28 @@ body { .layout-side-section, .layout-main-section-wrapper { height: 100%; overflow-y: auto; + padding-right: 25px; + scrollbar-color: var(--gray-200) transparent; + [data-theme="dark"] & { + scrollbar-color: var(--gray-800) transparent; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--gray-200); + [data-theme="dark"] & { + background: var(--gray-800); + } + } } + + .layout-side-section { + margin-right: 20px; + } + .desk-sidebar { margin-bottom: var(--margin-2xl); } diff --git a/frappe/public/scss/desk/index.scss b/frappe/public/scss/desk/index.scss index 31eae63776..d0d968df63 100644 --- a/frappe/public/scss/desk/index.scss +++ b/frappe/public/scss/desk/index.scss @@ -10,6 +10,7 @@ @import "mobile"; @import "form"; @import "print_preview"; +@import "scrollbar"; @import "navbar"; @import "../common/modal"; @import "slides"; diff --git a/frappe/public/scss/desk/scrollbar.scss b/frappe/public/scss/desk/scrollbar.scss new file mode 100644 index 0000000000..806ffd13eb --- /dev/null +++ b/frappe/public/scss/desk/scrollbar.scss @@ -0,0 +1,29 @@ +/* Works on Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); +} + +html { + scrollbar-width: auto; +} + +/* Works on Chrome, Edge, and Safari */ +*::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +*::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb-color); +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-corner { + background: var(--scrollbar-track-color); +} + +body::-webkit-scrollbar { + width: unset; + height: unset; +} diff --git a/frappe/templates/base.html b/frappe/templates/base.html index 78aa573c99..d59c4b0f2b 100644 --- a/frappe/templates/base.html +++ b/frappe/templates/base.html @@ -60,7 +60,7 @@ window.is_chat_enabled = {{ chat_enable }}; - + {% include "public/icons/timeless/symbol-defs.svg" %} {%- block banner -%} {% include "templates/includes/banner_extension.html" ignore missing %} diff --git a/frappe/translate.py b/frappe/translate.py index 62ee733f5f..4baf4bdd89 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -115,7 +115,7 @@ def get_dict(fortype, name=None): messages.extend(get_server_messages(app)) messages = deduplicate_messages(messages) - messages += frappe.db.sql("""select "navbar", item_label from `tabNavbar Item` where item_label is not null""") + messages += frappe.db.sql("""select 'navbar', item_label from `tabNavbar Item` where item_label is not null""") messages = get_messages_from_include_files() messages += frappe.db.sql("select 'Print Format:', name from `tabPrint Format`") messages += frappe.db.sql("select 'DocType:', name from tabDocType") @@ -518,8 +518,13 @@ def get_messages_from_file(path): apps_path = get_bench_dir() if os.path.exists(path): with open(path, 'r') as sourcefile: + try: + file_contents = sourcefile.read() + except Exception: + print("Could not scan file for translation: {0}".format(path)) + return [] data = [(os.path.relpath(path, apps_path), message, context, line) \ - for line, message, context in extract_messages_from_code(sourcefile.read())] + for line, message, context in extract_messages_from_code(file_contents)] return data else: # print "Translate: {0} missing".format(os.path.abspath(path)) @@ -601,11 +606,23 @@ def write_csv_file(path, app_messages, lang_dict): from csv import writer with open(path, 'w', newline='') as msgfile: w = writer(msgfile, lineterminator='\n') - for p, m in app_messages: - t = lang_dict.get(m, '') + + for app_message in app_messages: + context = None + if len(app_message) == 2: + path, message = app_message + elif len(app_message) == 3: + path, message, lineno = app_message + elif len(app_message) == 4: + path, message, context, lineno = app_message + else: + continue + + t = lang_dict.get(message, '') # strip whitespaces - t = re.sub('{\s?([0-9]+)\s?}', "{\g<1>}", t) - w.writerow([p if p else '', m, t]) + translated_string = re.sub('{\s?([0-9]+)\s?}', "{\g<1>}", t) + if translated_string: + w.writerow([message, translated_string, context]) def get_untranslated(lang, untranslated_file, get_all=False): """Returns all untranslated strings for a language and writes in a file @@ -827,7 +844,7 @@ def get_all_languages(with_language_name=False): return frappe.db.sql_list('select name from tabLanguage') def get_all_language_with_name(): - return frappe.db.get_all('language', ['language_code', 'language_name']) + return frappe.db.get_all('Language', ['language_code', 'language_name']) if not frappe.db: frappe.connect() diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index efa69d4453..251a095343 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -307,14 +307,23 @@ def unesc(s, esc_chars): s = s.replace(esc_str, c) return s -def execute_in_shell(cmd, verbose=0): +def execute_in_shell(cmd, verbose=0, low_priority=False): # using Popen instead of os.system - as recommended by python docs import tempfile from subprocess import Popen with tempfile.TemporaryFile() as stdout: with tempfile.TemporaryFile() as stderr: - p = Popen(cmd, shell=True, stdout=stdout, stderr=stderr) + kwargs = { + "shell": True, + "stdout": stdout, + "stderr": stderr + } + + if low_priority: + kwargs["preexec_fn"] = lambda: os.nice(10) + + p = Popen(cmd, **kwargs) p.wait() stdout.seek(0) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 77c5761527..b21efc5e89 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -15,7 +15,7 @@ import click # imports - module imports import frappe from frappe import _, conf -from frappe.utils import get_file_size, get_url, now, now_datetime +from frappe.utils import get_file_size, get_url, now, now_datetime, cint # backup variable for backwards compatibility verbose = False @@ -315,8 +315,6 @@ class BackupGenerator: print(template.format(_type.title(), info["path"], info["size"])) def backup_files(self): - import subprocess - for folder in ("public", "private"): files_path = frappe.get_site_path(folder, "files") backup_path = ( @@ -327,12 +325,12 @@ class BackupGenerator: cmd_string = "tar cf - {1} | gzip > {0}" else: cmd_string = "tar -cf {0} {1}" - output = subprocess.check_output( - cmd_string.format(backup_path, files_path), shell=True - ) - if self.verbose and output: - print(output.decode("utf8")) + frappe.utils.execute_in_shell( + cmd_string.format(backup_path, files_path), + verbose=self.verbose, + low_priority=True + ) def copy_site_config(self): site_config_backup_path = self.backup_path_conf @@ -436,7 +434,7 @@ class BackupGenerator: if self.verbose: print(command + "\n") - err, out = frappe.utils.execute_in_shell(command) + frappe.utils.execute_in_shell(command, low_priority=True) def send_email(self): """ @@ -474,29 +472,6 @@ download only after 24 hours.""" % { return recipient_list -@frappe.whitelist() -def get_backup(): - """ - This function is executed when the user clicks on - Toos > Download Backup - """ - delete_temp_backups() - odb = BackupGenerator( - frappe.conf.db_name, - frappe.conf.db_name, - frappe.conf.db_password, - db_host=frappe.db.host, - db_type=frappe.conf.db_type, - db_port=frappe.conf.db_port, - ) - odb.get_backup() - recipient_list = odb.send_email() - frappe.msgprint( - _( - "Download link for your backup will be emailed on the following email address: {0}" - ).format(", ".join(recipient_list)) - ) - @frappe.whitelist() def fetch_latest_backups(partial=False): """Fetches paths of the latest backup taken in the last 30 days @@ -570,7 +545,7 @@ def new_backup( force=False, verbose=False, ): - delete_temp_backups(older_than=frappe.conf.keep_backups_for_hours or 24) + delete_temp_backups() odb = BackupGenerator( frappe.conf.db_name, frappe.conf.db_name, @@ -595,8 +570,9 @@ def new_backup( def delete_temp_backups(older_than=24): """ - Cleans up the backup_link_path directory by deleting files older than 24 hours + Cleans up the backup_link_path directory by deleting older files """ + older_than = cint(frappe.conf.keep_backups_for_hours) or older_than backup_path = get_backup_path() if os.path.exists(backup_path): file_list = os.listdir(get_backup_path()) diff --git a/frappe/utils/oauth.py b/frappe/utils/oauth.py index 6596701ee3..6a92737a0d 100644 --- a/frappe/utils/oauth.py +++ b/frappe/utils/oauth.py @@ -64,8 +64,6 @@ def get_oauth2_authorize_url(provider, redirect_to): state = { "site": frappe.utils.get_url(), "token": frappe.generate_hash(), "redirect_to": redirect_to } - frappe.cache().set_value("{0}:{1}".format(provider, state["token"]), True, expires_in_sec=120) - # relative to absolute url data = { "redirect_uri": get_redirect_uri(provider), @@ -176,11 +174,6 @@ def login_oauth_user(data=None, provider=None, state=None, email_id=None, key=No frappe.respond_as_web_page(_("Invalid Request"), _("Token is missing"), http_status_code=417) return - token = frappe.cache().get_value("{0}:{1}".format(provider, state["token"]), expires=True) - if not token: - frappe.respond_as_web_page(_("Invalid Request"), _("Invalid Token"), http_status_code=417) - return - user = get_email(data) if not user: diff --git a/frappe/utils/xlsxutils.py b/frappe/utils/xlsxutils.py index 3c7b027470..356e2ddfdb 100644 --- a/frappe/utils/xlsxutils.py +++ b/frappe/utils/xlsxutils.py @@ -1,18 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -from __future__ import unicode_literals - -import frappe +import re +from io import BytesIO import openpyxl import xlrd -import re -from openpyxl.styles import Font from openpyxl import load_workbook +from openpyxl.styles import Font from openpyxl.utils import get_column_letter -from six import BytesIO, string_types + +import frappe ILLEGAL_CHARACTERS_RE = re.compile(r'[\000-\010]|[\013-\014]|[\016-\037]') + + # return xlsx file object def make_xlsx(data, sheet_name, wb=None, column_widths=None): column_widths = column_widths or [] @@ -31,12 +32,12 @@ def make_xlsx(data, sheet_name, wb=None, column_widths=None): for row in data: clean_row = [] for item in row: - if isinstance(item, string_types) and (sheet_name not in ['Data Import Template', 'Data Export']): + if isinstance(item, str) and (sheet_name not in ['Data Import Template', 'Data Export']): value = handle_html(item) else: value = item - if isinstance(item, string_types) and next(ILLEGAL_CHARACTERS_RE.finditer(value), None): + if isinstance(item, str) and next(ILLEGAL_CHARACTERS_RE.finditer(value), None): # Remove illegal characters from the string value = re.sub(ILLEGAL_CHARACTERS_RE, '', value) @@ -80,12 +81,12 @@ def handle_html(data): return value + def read_xlsx_file_from_attached_file(file_url=None, fcontent=None, filepath=None): if file_url: _file = frappe.get_doc("File", {"file_url": file_url}) filename = _file.get_full_path() elif fcontent: - from io import BytesIO filename = BytesIO(fcontent) elif filepath: filename = filepath @@ -102,6 +103,7 @@ def read_xlsx_file_from_attached_file(file_url=None, fcontent=None, filepath=Non rows.append(tmp_list) return rows + def read_xls_file_from_attached_file(content): book = xlrd.open_workbook(file_contents=content) sheets = book.sheets() @@ -111,6 +113,7 @@ def read_xls_file_from_attached_file(content): rows.append(sheet.row_values(i)) return rows + def build_xlsx_response(data, filename): xlsx_file = make_xlsx(data, filename) # write out response as a xlsx type diff --git a/frappe/website/doctype/website_settings/google_indexing.py b/frappe/website/doctype/website_settings/google_indexing.py index 599de5a2b6..75095bd7df 100644 --- a/frappe/website/doctype/website_settings/google_indexing.py +++ b/frappe/website/doctype/website_settings/google_indexing.py @@ -2,17 +2,18 @@ # Copyright (c) 2020, Frappe Technologies and contributors # For license information, please see license.txt -from __future__ import unicode_literals -import frappe -import requests -import googleapiclient.discovery -import google.oauth2.credentials -from frappe import _ +from urllib.parse import quote + +import google.oauth2.credentials +import requests +from googleapiclient.discovery import build from googleapiclient.errors import HttpError -from frappe.utils import get_request_site_address -from six.moves.urllib.parse import quote + +import frappe +from frappe import _ from frappe.integrations.doctype.google_settings.google_settings import get_auth_url +from frappe.utils import get_request_site_address SCOPES = "https://www.googleapis.com/auth/indexing" @@ -82,7 +83,12 @@ def get_google_indexing_object(): } credentials = google.oauth2.credentials.Credentials(**credentials_dict) - google_indexing = googleapiclient.discovery.build("indexing", "v3", credentials=credentials) + google_indexing = build( + serviceName="indexing", + version="v3", + credentials=credentials, + static_discovery=False + ) return google_indexing diff --git a/frappe/website/doctype/website_settings/website_settings.js b/frappe/website/doctype/website_settings/website_settings.js index 422deb244e..2f15b4c00e 100644 --- a/frappe/website/doctype/website_settings/website_settings.js +++ b/frappe/website/doctype/website_settings/website_settings.js @@ -33,20 +33,12 @@ frappe.ui.form.on('Website Settings', { frm.fields_dict.top_bar_items.grid.update_docfield_property( 'parent_label', 'options', frm.events.get_parent_options(frm, "top_bar_items") ); - - if ($(frm.fields_dict.top_bar_items.grid.wrapper).find(".grid-row-open")) { - frm.fields_dict.top_bar_items.grid.refresh(); - } }, set_parent_label_options_footer: function(frm) { frm.fields_dict.footer_items.grid.update_docfield_property( - 'parent_label', 'options', frm.events.get_parent_options(frm, "top_bar_items") + 'parent_label', 'options', frm.events.get_parent_options(frm, "footer_items") ); - - if ($(frm.fields_dict.footer_items.grid.wrapper).find(".grid-row-open")) { - frm.fields_dict.footer_items.grid.refresh(); - } }, authorize_api_indexing_access: function(frm) { @@ -122,10 +114,18 @@ frappe.ui.form.on('Website Settings', { }); frappe.ui.form.on('Top Bar Item', { + top_bar_items_delete(frm) { + frm.events.set_parent_label_options(frm); + }, + footer_items_add(frm, cdt, cdn) { frappe.model.set_value(cdt, cdn, 'right', 0); }, + footer_items_delete(frm) { + frm.events.set_parent_label_options_footer(frm); + }, + parent_label: function(frm, doctype, name) { frm.events.set_parent_options(frm, doctype, name); }, diff --git a/frappe/www/login.py b/frappe/www/login.py index 76b232f8ee..1ce25a81d9 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -95,14 +95,6 @@ def login_via_frappe(code, state): def login_via_office365(code, state): login_via_oauth2_id_token("office_365", code, state, decoder=decoder_compat) -@frappe.whitelist(allow_guest=True) -def login_oauth_user(data=None, provider=None, state=None, email_id=None, key=None, generate_login_token=False): - if not ((data and provider and state) or (email_id and key)): - frappe.respond_as_web_page(_("Invalid Request"), _("Missing parameters for login"), http_status_code=417) - return - - _login_oauth_user(data, provider, state, email_id, key, generate_login_token) - @frappe.whitelist(allow_guest=True) def login_via_token(login_token): sid = frappe.cache().get_value("login_token:{0}".format(login_token), expires=True) diff --git a/package.json b/package.json index 3c8da66242..6e82890617 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "driver.js": "^0.9.8", "express": "^4.17.1", "fast-deep-equal": "^2.0.1", - "frappe-charts": "^2.0.0-rc11", + "frappe-charts": "^2.0.0-rc13", "frappe-datatable": "^1.15.3", "frappe-gantt": "^0.5.0", "fuse.js": "^3.4.6", diff --git a/requirements.txt b/requirements.txt index 0f88a48f73..91f235159f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,79 +1,79 @@ -Babel==2.6.0 -beautifulsoup4==4.8.2 -bleach-whitelist==0.0.10 -bleach==3.3.0 -boto3==1.10.18 -braintree==3.57.1 -chardet==3.0.4 -Click==7.0 -coverage==4.5.4 -croniter==0.3.31 -cryptography==3.3.2 -dropbox==9.1.0 -email-reply-parser==0.5.9 -Faker==2.0.4 +Babel~=2.9.0 +beautifulsoup4~=4.9.3 +bleach-whitelist~=0.0.11 +bleach~=3.3.0 +boto3~=1.17.53 +braintree~=4.8.0 +chardet~=4.0.0 +Click~=7.1.2 +coverage~=5.5 +croniter~=1.0.11 +cryptography~=3.4.7 +dropbox~=11.7.0 +email-reply-parser~=0.5.12 +Faker~=8.1.0 future==0.18.2 -gitdb2==2.0.6;python_version<'3.4' -GitPython==2.1.15 -git-url-parse==1.2.2 -google-api-python-client==1.9.3 -google-auth-httplib2==0.0.3 -google-auth-oauthlib==0.4.1 -google-auth==1.18.0 -googlemaps==3.1.1 -gunicorn==19.10.0 -html2text==2016.9.19 -html5lib==1.0.1 -ipython==7.14.0 -jedi==0.17.2 # not directly required. Pinned to fix upstream issue with ipython. -Jinja2==2.11.3 -ldap3==2.7 -markdown2==2.4.0 +git-url-parse~=1.2.2 +gitdb~=4.0.7 +GitPython~=3.1.14 +google-api-python-client~=2.2.0 +google-auth-httplib2~=0.1.0 +google-auth-oauthlib~=0.4.4 +google-auth~=1.29.0 +googlemaps~=4.4.5 +gunicorn~=20.1.0 +html2text==2020.1.16 +html5lib~=1.1 +ipython~=7.16.1 +jedi==0.17.2 # not directly required. Pinned to fix upstream IPython issue (https://github.com/ipython/ipython/issues/12740) +Jinja2~=2.11.3 +ldap3~=2.9 +markdown2~=2.4.0 maxminddb-geolite2==2018.703 -ndg-httpsclient==0.5.1 -num2words==0.5.10 -oauthlib==3.1.0 -openpyxl==2.6.4 -passlib==1.7.3 -pdfkit==0.6.1 -Pillow>=8.0.0 -premailer==3.6.1 -psutil==5.7.2 -psycopg2-binary==2.8.4 -pyasn1==0.4.8 -PyJWT==1.7.1 -PyMySQL==0.9.3 -pyngrok==4.1.6 -pyOpenSSL==19.1.0 -pyotp==2.3.0 -PyPDF2==1.26.0 -pypng==0.0.20 -PyQRCode==1.2.1 -python-dateutil==2.8.1 -pytz==2019.3 -PyYAML==5.4 -rauth==0.7.3 -redis==3.5.3 -requests-oauthlib==1.3.0 -requests==2.23.0 -RestrictedPython==5.0 -rq>=1.1.0 -schedule==0.6.0 -semantic-version==2.8.4 -simple-chalk==0.1.0 -six==1.14.0 -sqlparse==0.2.4 -stripe==2.40.0 -terminaltables==3.1.0 -unittest-xml-reporting==2.5.2 -urllib3==1.25.9 -watchdog==0.8.0 -Werkzeug==0.16.1 -Whoosh==2.7.4 -xlrd==1.2.0 -zxcvbn-python==4.4.24 -pycryptodome==3.9.8 -paytmchecksum==1.7.0 -wrapt==1.10.11 -razorpay==1.2.0 +ndg-httpsclient~=0.5.1 +num2words~=0.5.10 +oauthlib~=3.1.0 +openpyxl~=3.0.7 +passlib~=1.7.4 +paytmchecksum~=1.7.0 +pdfkit~=0.6.1 +Pillow~=8.2.0 +premailer~=3.8.0 +psutil~=5.8.0 +psycopg2-binary~=2.8.6 +pyasn1~=0.4.8 +pycryptodome~=3.10.1 +PyJWT~=1.7.1 +PyMySQL~=1.0.2 +pyngrok~=5.0.5 +pyOpenSSL~=20.0.1 +pyotp~=2.6.0 +PyPDF2~=1.26.0 +pypng~=0.0.20 +PyQRCode~=1.2.1 +python-dateutil~=2.8.1 +pytz==2021.1 +PyYAML~=5.4.1 +rauth~=0.7.3 +razorpay~=1.2.0 +redis~=3.5.3 +requests-oauthlib~=1.3.0 +requests~=2.25.1 +RestrictedPython~=5.1 +rq~=1.8.0 rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability +schedule~=1.1.0 +semantic-version~=2.8.5 +simple-chalk~=0.1.0 +six~=1.15.0 +sqlparse~=0.4.1 +stripe~=2.56.0 +terminaltables~=3.1.0 +unittest-xml-reporting~=3.0.4 +urllib3~=1.26.4 +watchdog~=2.0.2 +Werkzeug~=0.16.1 +Whoosh~=2.7.4 +wrapt~=1.12.1 +xlrd~=2.0.1 +zxcvbn-python~=4.4.24 diff --git a/yarn.lock b/yarn.lock index 4f6f62ac0a..8ac348011d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2699,10 +2699,10 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -frappe-charts@^2.0.0-rc11: - version "2.0.0-rc11" - resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc11.tgz#0724fa0d43593362c075c3805ebbbe1a608fcef7" - integrity sha512-DY3tThT1lNGcJlRMOtIhnILtSm5h1iKysWhZAyj7yrGiOnOWbZpYx/NZzXZYwtRrWwMlYiLX2ylV76qo31ONsg== +frappe-charts@^2.0.0-rc13: + version "2.0.0-rc13" + resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc13.tgz#fdb251d7ae311c41e38f90a3ae108070ec6b9072" + integrity sha512-Bv7IfllIrjRbKWHn5b769dOSenqdBixAr6m5kurf8ZUOJSLOgK4HOXItJ7BA8n9PvviH9/k5DaloisjLM2Bm1w== frappe-datatable@^1.15.3: version "1.15.3"