diff --git a/cypress/integration/relative_filters.js b/cypress/integration/relative_time_filters.js similarity index 85% rename from cypress/integration/relative_filters.js rename to cypress/integration/relative_time_filters.js index 411ede62fa..ac70c44345 100644 --- a/cypress/integration/relative_filters.js +++ b/cypress/integration/relative_time_filters.js @@ -9,14 +9,14 @@ context('Relative Timeframe', () => { frappe.call("frappe.tests.ui_test_helpers.create_todo_records"); }); }); - it('set relative filter for Previous and check list', () => { + it('sets relative timespan filter for last week and filters list', () => { cy.visit('/desk#List/ToDo/List'); cy.get('.list-row:contains("this is fourth todo")').should('exist'); cy.get('.tag-filters-area .btn:contains("Add Filter")').click(); cy.get('.fieldname-select-area').should('exist'); cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 }); - cy.get('select.condition.form-control').select("Previous"); - cy.get('.filter-field select.input-with-feedback.form-control').select("1 week"); + cy.get('select.condition.form-control').select("Timespan"); + cy.get('.filter-field select.input-with-feedback.form-control').select("last week"); cy.server(); cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.get('.filter-box .btn:contains("Apply")').click(); @@ -28,13 +28,13 @@ context('Relative Timeframe', () => { cy.get('.remove-filter.btn').click(); cy.wait('@save_user_settings'); }); - it('set relative filter for Next and check list', () => { + it('sets relative timespan filter for next week and filters list', () => { cy.visit('/desk#List/ToDo/List'); cy.get('.list-row:contains("this is fourth todo")').should('exist'); cy.get('.tag-filters-area .btn:contains("Add Filter")').click(); cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 }); - cy.get('select.condition.form-control').select("Next"); - cy.get('.filter-field select.input-with-feedback.form-control').select("1 week"); + cy.get('select.condition.form-control').select("Timespan"); + cy.get('.filter-field select.input-with-feedback.form-control').select("next week"); cy.server(); cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.get('.filter-box .btn:contains("Apply")').click(); diff --git a/frappe/__init__.py b/frappe/__init__.py index f0b6bfe41b..8f36c0c4d3 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -231,9 +231,8 @@ def get_site_config(sites_path=None, site_path=None): if os.path.exists(site_config): config.update(get_file_json(site_config)) elif local.site and not local.flags.new_site: - print("{0} does not exist".format(local.site)) + print("Site {0} does not exist".format(local.site)) sys.exit(1) - #raise IncorrectSitePath, "{0} does not exist".format(site_config) return _dict(config) @@ -1559,10 +1558,10 @@ def get_doctype_app(doctype): loggers = {} log_level = None -def logger(module=None, with_more_info=True): +def logger(module=None, with_more_info=False): '''Returns a python logger that uses StreamHandler''' from frappe.utils.logger import get_logger - return get_logger(module or 'default', with_more_info=with_more_info) + return get_logger(module=module, with_more_info=with_more_info) def log_error(message=None, title=_("Error")): '''Log error to Error Log''' diff --git a/frappe/app.py b/frappe/app.py index 3bb764149b..50d09177d6 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -99,6 +99,16 @@ def application(request): frappe.monitor.stop(response) frappe.recorder.dump() + frappe.logger("web").info({ + "site": get_site_name(request.host), + "remote_addr": getattr(request, "remote_addr", "NOTFOUND"), + "base_url": getattr(request, "base_url", "NOTFOUND"), + "full_path": getattr(request, "full_path", "NOTFOUND"), + "method": getattr(request, "method", "NOTFOUND"), + "scheme": getattr(request, "scheme", "NOTFOUND"), + "http_status_code": getattr(response, "status_code", "NOTFOUND") + }) + if response and hasattr(frappe.local, 'rate_limiter'): response.headers.extend(frappe.local.rate_limiter.headers()) @@ -195,7 +205,6 @@ def handle_exception(e): frappe.local.login_manager.clear_cookies() if http_status_code >= 500: - frappe.logger().error('Request Error', exc_info=True) make_error_snapshot(e) if return_as_message: diff --git a/frappe/boot.py b/frappe/boot.py index e615cc49fa..8862ce3c61 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -19,6 +19,7 @@ from frappe.email.inbox import get_email_accounts from frappe.social.doctype.energy_point_settings.energy_point_settings import is_energy_point_enabled from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points +from frappe.model.base_document import get_controller from frappe.social.doctype.post.post import frequently_visited_links def get_bootinfo(): @@ -84,6 +85,7 @@ def get_bootinfo(): bootinfo.points = get_energy_points(frappe.session.user) bootinfo.frequently_visited_links = frequently_visited_links() bootinfo.link_preview_doctypes = get_link_preview_doctypes() + bootinfo.additional_filters_config = get_additional_filters_from_hooks() return bootinfo @@ -106,6 +108,7 @@ def load_desktop_data(bootinfo): from frappe.desk.desktop import get_desk_sidebar_items bootinfo.allowed_modules = get_modules_from_all_apps_for_user() bootinfo.allowed_workspaces = get_desk_sidebar_items(True) + bootinfo.module_page_map = get_controller("Desk Page").get_module_page_map() bootinfo.dashboards = frappe.get_all("Dashboard") def get_allowed_pages(cache=False): @@ -295,3 +298,11 @@ def get_link_preview_doctypes(): link_preview_doctypes.append(custom.doc_type) return link_preview_doctypes + +def get_additional_filters_from_hooks(): + filter_config = frappe._dict() + filter_hooks = frappe.get_hooks('filters_config') + for hook in filter_hooks: + filter_config.update(frappe.get_attr(hook)()) + + return filter_config diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py index 8110f2ec19..42f4440547 100644 --- a/frappe/commands/__init__.py +++ b/frappe/commands/__init__.py @@ -22,7 +22,11 @@ def pass_context(f): pr = cProfile.Profile() pr.enable() - ret = f(frappe._dict(ctx.obj), *args, **kwargs) + try: + ret = f(frappe._dict(ctx.obj), *args, **kwargs) + except frappe.exceptions.SiteNotSpecifiedError as e: + click.secho(str(e), fg='yellow') + sys.exit(1) if profile: pr.disable() @@ -44,8 +48,7 @@ def get_site(context): site = context.sites[0] return site except (IndexError, TypeError): - print('Please specify --site sitename') - sys.exit(1) + raise frappe.SiteNotSpecifiedError def popen(command, *args, **kwargs): output = kwargs.get('output', True) diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index 6f51c81211..511fac6e0d 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -4,6 +4,7 @@ import sys import frappe from frappe.utils import cint from frappe.commands import pass_context, get_site +from frappe.exceptions import SiteNotSpecifiedError def _is_scheduler_enabled(): enable_scheduler = False @@ -30,6 +31,8 @@ def trigger_scheduler_event(context, event): frappe.utils.scheduler.trigger(site, event, now=True) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('enable-scheduler') @pass_context @@ -45,6 +48,8 @@ def enable_scheduler(context): print("Enabled for", site) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('disable-scheduler') @pass_context @@ -60,7 +65,8 @@ def disable_scheduler(context): print("Disabled for", site) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('scheduler') diff --git a/frappe/commands/site.py b/frappe/commands/site.py index f0c4adb157..399d0efd68 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -15,6 +15,7 @@ import frappe from frappe import _ from frappe.commands import get_site, pass_context from frappe.commands.scheduler import _is_scheduler_enabled +from frappe.exceptions import SiteNotSpecifiedError from frappe.installer import update_site_config from frappe.utils import get_site_path, touch_file @@ -130,30 +131,47 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file if not os.path.exists(sql_file_path): - sql_file_path = '../' + sql_file_path + base_path = '..' + sql_file_path = os.path.join(base_path, sql_file_path) if not os.path.exists(sql_file_path): print('Invalid path {0}'.format(sql_file_path[3:])) sys.exit(1) + elif sql_file_path.startswith(os.sep): + base_path = os.sep + else: + base_path = '.' + if sql_file_path.endswith('sql.gz'): - sql_file_path = extract_sql_gzip(os.path.abspath(sql_file_path)) + decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path)) + else: + decompressed_file_name = sql_file_path site = get_site(context) frappe.init(site=site) _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password, admin_password=admin_password, - verbose=context.verbose, install_apps=install_app, source_sql=sql_file_path, - force=context.force) + verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, + force=True) # Extract public and/or private files to the restored site, if user has given the path if with_public_files: + with_public_files = os.path.join(base_path, with_public_files) public = extract_tar_files(site, with_public_files, 'public') os.remove(public) if with_private_files: + with_private_files = os.path.join(base_path, with_private_files) private = extract_tar_files(site, with_private_files, 'private') os.remove(private) + # Removing temporarily created file + if decompressed_file_name != sql_file_path: + os.remove(decompressed_file_name) + + success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "") + click.secho(success_message, fg="green") + @click.command('reinstall') @click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--mariadb-root-username', help='Root username for MariaDB') @@ -200,6 +218,8 @@ def install_app(context, apps): _install_app(app, verbose=context.verbose) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('list-apps') @pass_context @@ -229,7 +249,8 @@ def add_system_manager(context, email, first_name, last_name, send_welcome_email frappe.db.commit() finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('disable-user') @click.argument('email') @@ -260,6 +281,8 @@ def migrate(context, rebuild_website=False, skip_failing=False): migrate(context.verbose, rebuild_website=rebuild_website, skip_failing=skip_failing) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError print("Compiling Python Files...") compileall.compile_dir('../apps', quiet=1, rx=re.compile('.*node_modules.*')) @@ -271,7 +294,12 @@ def migrate_to(context, frappe_provider): "Migrates site to the specified provider" from frappe.integrations.frappe_providers import migrate_to for site in context.sites: + frappe.init(site=site) + frappe.connect() migrate_to(site, frappe_provider) + frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('run-patch') @click.argument('module') @@ -286,6 +314,8 @@ def run_patch(context, module): frappe.modules.patch_handler.run_single(module, force=context.force) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('reload-doc') @click.argument('module') @@ -302,6 +332,8 @@ def reload_doc(context, module, doctype, docname): frappe.db.commit() finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('reload-doctype') @click.argument('doctype') @@ -316,6 +348,8 @@ def reload_doctype(context, doctype): frappe.db.commit() finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('add-to-hosts') @pass_context @@ -323,6 +357,8 @@ def add_to_hosts(context): "Add site to hosts" for site in context.sites: frappe.commands.popen('echo 127.0.0.1\t{0} | sudo tee -a /etc/hosts'.format(site)) + if not context.sites: + raise SiteNotSpecifiedError @click.command('use') @click.argument('site') @@ -336,7 +372,7 @@ def use(site, sites_path='.'): sitefile.write(site) print("Current Site set to {}".format(site)) else: - print("{} does not exist".format(site)) + print("Site {} does not exist".format(site)) @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") @@ -369,6 +405,9 @@ def backup(context, with_files=False, backup_path_db=None, backup_path_files=Non print("Private files: ", odb.backup_path_private_files) frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError + sys.exit(exit_code) @click.command('remove-from-installed-apps') @@ -384,6 +423,8 @@ def remove_from_installed_apps(context, app): remove_from_installed_apps(app) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('uninstall-app') @click.argument('app') @@ -400,6 +441,8 @@ def uninstall(context, app, dry_run=False, yes=False): remove_app(app, dry_run, yes) finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('drop-site') @@ -430,7 +473,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= else: click.echo("="*80) click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site)) - click.echo("Reason: {reason}{sep}".format(reason=err[1], sep="\n")) + click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n")) click.echo("Fix the issue and try again.") click.echo( "Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site) @@ -491,6 +534,8 @@ def set_admin_password(context, admin_password, logout_all_sessions=False): admin_password = None finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('set-last-active-for-user') @click.option('--user', help="Setup last active date for user") @@ -536,6 +581,8 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte frappe.db.commit() finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('browse') @click.argument('site', required=False) @@ -563,6 +610,8 @@ def start_recording(context): for site in context.sites: frappe.init(site=site) frappe.recorder.start() + if not context.sites: + raise SiteNotSpecifiedError @click.command('stop-recording') @@ -571,6 +620,8 @@ def stop_recording(context): for site in context.sites: frappe.init(site=site) frappe.recorder.stop() + if not context.sites: + raise SiteNotSpecifiedError commands = [ diff --git a/frappe/commands/translate.py b/frappe/commands/translate.py index 5a48e2b409..48a7fd1db7 100644 --- a/frappe/commands/translate.py +++ b/frappe/commands/translate.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals, absolute_import, print_function import click from frappe.commands import pass_context, get_site +from frappe.exceptions import SiteNotSpecifiedError # translation @click.command('build-message-files') @@ -15,6 +16,8 @@ def build_message_files(context): frappe.translate.rebuild_all_translation_files() finally: frappe.destroy() + if not context.sites: + raise SiteNotSpecifiedError @click.command('new-language') #, help="Create lang-code.csv for given app") @pass_context diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 3610393d9a..86db7cdc8f 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -6,6 +6,7 @@ import json, os, sys, subprocess from distutils.spawn import find_executable import frappe from frappe.commands import pass_context, get_site +from frappe.exceptions import SiteNotSpecifiedError from frappe.utils import update_progress_bar, get_bench_path from frappe.utils.response import json_handler from coverage import Coverage @@ -51,7 +52,8 @@ def clear_cache(context): frappe.website.render.clear_cache() finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('clear-website-cache') @pass_context @@ -65,7 +67,8 @@ def clear_website_cache(context): frappe.website.render.clear_cache() finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('destroy-all-sessions') @click.option('--reason') @@ -81,7 +84,8 @@ def destroy_all_sessions(context, reason=None): frappe.db.commit() finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('show-config') @pass_context @@ -117,7 +121,8 @@ def reset_perms(context): reset_perms(d) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('execute') @click.argument('method') @@ -164,6 +169,9 @@ def execute(context, method, args=None, kwargs=None, profile=False): if ret: print(json.dumps(ret, default=json_handler)) + if not context.sites: + raise SiteNotSpecifiedError + @click.command('add-to-email-queue') @click.argument('email-path') @@ -197,7 +205,8 @@ def export_doc(context, doctype, docname): frappe.modules.export_doc(doctype, docname) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('export-json') @click.argument('doctype') @@ -214,7 +223,8 @@ def export_json(context, doctype, path, name=None): data_import.export_json(doctype, path, name=name) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('export-csv') @click.argument('doctype') @@ -230,7 +240,8 @@ def export_csv(context, doctype, path): data_import.export_csv(doctype, path) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('export-fixtures') @click.option('--app', default=None, help='Export fixtures of a specific app') @@ -245,7 +256,8 @@ def export_fixtures(context, app=None): export_fixtures(app=app) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('import-doc') @click.argument('path') @@ -267,7 +279,8 @@ def import_doc(context, path, force=False): data_import.import_doc(path, overwrite=context.force) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('import-csv') @click.argument('path') @@ -364,6 +377,8 @@ def mariadb(context): import os site = get_site(context) + if not site: + raise SiteNotSpecifiedError frappe.init(site=site) # This is assuming you're within the bench instance. @@ -577,7 +592,8 @@ def request(context, args=None, path=None): print(frappe.response) finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('make-app') @click.argument('destination') @@ -658,7 +674,8 @@ def rebuild_global_search(context, static_pages=False): finally: frappe.destroy() - + if not context.sites: + raise SiteNotSpecifiedError @click.command('auto-deploy') @click.argument('app') diff --git a/frappe/core/doctype/access_log/test_access_log.py b/frappe/core/doctype/access_log/test_access_log.py index 312f77c026..9830507423 100644 --- a/frappe/core/doctype/access_log/test_access_log.py +++ b/frappe/core/doctype/access_log/test_access_log.py @@ -158,11 +158,7 @@ class TestAccessLog(unittest.TestCase): request = requests.post(private_file_link, headers=self.header) last_doc = frappe.get_last_doc('Access Log') - if request.status_code == 403: - # if file is not accessible, access log wont be generated - pass - - else: + if request.ok: # check for the access log of downloaded file self.assertEqual(new_private_file.doctype, last_doc.export_from) self.assertEqual(new_private_file.name, last_doc.reference_document) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b35abfa861..a17b3acd02 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -48,6 +48,8 @@ class File(Document): def before_insert(self): frappe.local.rollback_observers.append(self) self.set_folder_name() + if self.file_name: + self.file_name = re.sub(r'/', '', self.file_name) self.content = self.get("content", None) self.decode = self.get("decode", False) if self.content: @@ -192,6 +194,8 @@ class File(Document): def set_file_name(self): if not self.file_name and self.file_url: self.file_name = self.file_url.split('/')[-1] + else: + self.file_name = re.sub(r'/', '', self.file_name) def generate_content_hash(self): if self.content_hash or not self.file_url or self.file_url.startswith('http'): @@ -405,6 +409,12 @@ class File(Document): frappe.throw(_("URL must start with 'http://' or 'https://'")) return + if not self.file_url.startswith(("http://", "https://")): + # local file + root_files_path = get_files_path(is_private=self.is_private) + if not os.path.commonpath([root_files_path]) == os.path.commonpath([root_files_path, self.get_full_path()]): + # basically the file url is skewed to not point to /files/ or /private/files + frappe.throw(_("{0} is not a valid file url").format(self.file_url)) self.file_url = unquote(self.file_url) self.file_size = frappe.form_dict.file_size or self.file_size 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 c179054550..765ae5fe93 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -84,7 +84,7 @@ class ScheduledJobType(Document): def log_status(self, status): # log file - frappe.logger(__name__).info('Scheduled Job {0}: {1} for {2}'.format(status, self.method, frappe.local.site)) + frappe.logger("scheduler").info('Scheduled Job {0}: {1} for {2}'.format(status, self.method, frappe.local.site)) self.update_scheduler_log(status) def update_scheduler_log(self, status): diff --git a/frappe/desk/doctype/desk_page/desk_page.py b/frappe/desk/doctype/desk_page/desk_page.py index dd9cc0706a..f14535cb5f 100644 --- a/frappe/desk/doctype/desk_page/desk_page.py +++ b/frappe/desk/doctype/desk_page/desk_page.py @@ -20,6 +20,17 @@ class DeskPage(Document): if frappe.conf.developer_mode and self.is_standard: export_to_files(record_list=[['Desk Page', self.name]], record_module=self.module) + @staticmethod + def get_module_page_map(): + filters = { + 'extends_another_page': 0, + 'for_user': '', + } + + pages = frappe.get_all("Desk Page", fields=["name", "module"], filters=filters, as_list=1) + + return { page[1]: page[0] for page in pages } + def disable_saving_as_standard(): return frappe.flags.in_install or \ frappe.flags.in_patch or \ diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py index dcfb38bd08..2926a74a55 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -93,10 +93,10 @@ class TestEvent(unittest.TestCase): self.assertEqual(set(json.loads(ev._assign)), set(["test@example.com", self.test_user])) - # close an assignment + # Remove an assignment todo = frappe.get_doc("ToDo", {"reference_type": ev.doctype, "reference_name": ev.name, "owner": self.test_user}) - todo.status = "Closed" + todo.status = "Cancelled" todo.save() ev = frappe.get_doc("Event", ev.name) diff --git a/frappe/desk/doctype/list_view_setting/list_view_setting.js b/frappe/desk/doctype/list_view_setting/list_view_setting.js deleted file mode 100644 index 2c70ddf82d..0000000000 --- a/frappe/desk/doctype/list_view_setting/list_view_setting.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2019, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('List View Setting', { - // refresh: function(frm) { - - // } -}); diff --git a/frappe/desk/doctype/list_view_setting/list_view_setting.json b/frappe/desk/doctype/list_view_setting/list_view_setting.json deleted file mode 100644 index cd18d3f766..0000000000 --- a/frappe/desk/doctype/list_view_setting/list_view_setting.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "Prompt", - "beta": 0, - "creation": "2019-03-06 13:29:21.101860", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "disable_count", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Disable Count", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "disable_sidebar_stats", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Disable Sidebar Stats", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "disable_auto_refresh", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Disable Auto Refresh", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-03-06 13:40:59.533586", - "modified_by": "Administrator", - "module": "Desk", - "name": "List View Setting", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 0, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 -} \ No newline at end of file diff --git a/frappe/desk/doctype/list_view_setting/list_view_setting.py b/frappe/desk/doctype/list_view_setting/list_view_setting.py deleted file mode 100644 index b66dc29a43..0000000000 --- a/frappe/desk/doctype/list_view_setting/list_view_setting.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -# import frappe -from frappe.model.document import Document - -class ListViewSetting(Document): - pass diff --git a/frappe/desk/doctype/list_view_setting/__init__.py b/frappe/desk/doctype/list_view_settings/__init__.py similarity index 100% rename from frappe/desk/doctype/list_view_setting/__init__.py rename to frappe/desk/doctype/list_view_settings/__init__.py diff --git a/frappe/desk/doctype/list_view_settings/list_view_settings.js b/frappe/desk/doctype/list_view_settings/list_view_settings.js new file mode 100644 index 0000000000..db33f71675 --- /dev/null +++ b/frappe/desk/doctype/list_view_settings/list_view_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('List View Settings', { + // refresh: function(frm) { + + // } +}); \ No newline at end of file diff --git a/frappe/desk/doctype/list_view_settings/list_view_settings.json b/frappe/desk/doctype/list_view_settings/list_view_settings.json new file mode 100644 index 0000000000..44761992f1 --- /dev/null +++ b/frappe/desk/doctype/list_view_settings/list_view_settings.json @@ -0,0 +1,76 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2019-10-23 15:00:48.392374", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "disable_count", + "disable_sidebar_stats", + "disable_auto_refresh", + "total_fields", + "fields_html", + "fields" + ], + "fields": [ + { + "default": "0", + "fieldname": "disable_count", + "fieldtype": "Check", + "label": "Disable Count" + }, + { + "default": "0", + "fieldname": "disable_sidebar_stats", + "fieldtype": "Check", + "label": "Disable Sidebar Stats" + }, + { + "default": "0", + "fieldname": "disable_auto_refresh", + "fieldtype": "Check", + "label": "Disable Auto Refresh" + }, + { + "fieldname": "total_fields", + "fieldtype": "Select", + "label": "Maximum Number of Fields", + "options": "\n4\n5\n6\n7\n8\n9\n10" + }, + { + "fieldname": "fields_html", + "fieldtype": "HTML", + "label": "Fields" + }, + { + "fieldname": "fields", + "fieldtype": "Code", + "hidden": 1, + "label": "Fields", + "read_only": 1 + } + ], + "links": [], + "modified": "2020-05-12 18:27:15.568199", + "modified_by": "Administrator", + "module": "Desk", + "name": "List View Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/list_view_settings/list_view_settings.py b/frappe/desk/doctype/list_view_settings/list_view_settings.py new file mode 100644 index 0000000000..74e029f499 --- /dev/null +++ b/frappe/desk/doctype/list_view_settings/list_view_settings.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document + +class ListViewSettings(Document): + + def on_update(self): + frappe.clear_document_cache(self.doctype, self.name) + +@frappe.whitelist() +def save_listview_settings(doctype, listview_settings, removed_listview_fields): + + listview_settings = frappe.parse_json(listview_settings) + removed_listview_fields = frappe.parse_json(removed_listview_fields) + + if frappe.get_all("List View Settings", filters={"name": doctype}): + doc = frappe.get_doc("List View Settings", doctype) + doc.update(listview_settings) + doc.save() + else: + doc = frappe.new_doc("List View Settings") + doc.name = doctype + doc.update(listview_settings) + doc.insert() + + set_listview_fields(doctype, listview_settings.get("fields"), removed_listview_fields) + + return { + "meta": frappe.get_meta(doctype, False), + "listview_settings": doc + } + +def set_listview_fields(doctype, listview_fields, removed_listview_fields): + meta = frappe.get_meta(doctype) + + listview_fields = [f.get("fieldname") for f in frappe.parse_json(listview_fields) if f.get("fieldname")] + + for field in removed_listview_fields: + set_in_list_view_property(doctype, meta.get_field(field), "0") + + for field in listview_fields: + set_in_list_view_property(doctype, meta.get_field(field), "1") + +def set_in_list_view_property(doctype, field, value): + if not field or field.fieldname == "status_field": + return + + property_setter = frappe.db.get_value("Property Setter", {"doc_type": doctype, "field_name": field.fieldname, "property": "in_list_view"}) + if property_setter: + doc = frappe.get_doc("Property Setter", property_setter) + doc.value = value + doc.save() + else: + frappe.make_property_setter({ + "doctype": doctype, + "doctype_or_field": "DocField", + "fieldname": field.fieldname, + "property": "in_list_view", + "value": value, + "property_type": "Check" + }, ignore_validate=True) + +@frappe.whitelist() +def get_default_listview_fields(doctype): + meta = frappe.get_meta(doctype) + path = frappe.get_module_path(frappe.scrub(meta.module), "doctype", frappe.scrub(meta.name), frappe.scrub(meta.name) + ".json") + doctype_json = frappe.get_file_json(path) + + fields = [f.get("fieldname") for f in doctype_json.get("fields") if f.get("in_list_view")] + + if meta.title_field: + if not meta.title_field.strip() in fields: + fields.append(meta.title_field.strip()) + + return fields diff --git a/frappe/desk/doctype/list_view_setting/test_list_view_setting.py b/frappe/desk/doctype/list_view_settings/test_list_view_settings.py similarity index 72% rename from frappe/desk/doctype/list_view_setting/test_list_view_setting.py rename to frappe/desk/doctype/list_view_settings/test_list_view_settings.py index 143fc4cce7..c1b2f4a0da 100644 --- a/frappe/desk/doctype/list_view_setting/test_list_view_setting.py +++ b/frappe/desk/doctype/list_view_settings/test_list_view_settings.py @@ -3,7 +3,8 @@ # See license.txt from __future__ import unicode_literals +# import frappe import unittest -class TestListViewSetting(unittest.TestCase): +class TestListViewSettings(unittest.TestCase): pass diff --git a/frappe/desk/doctype/notification_log/notification_log.js b/frappe/desk/doctype/notification_log/notification_log.js index 654b2b2b06..1f381d115b 100644 --- a/frappe/desk/doctype/notification_log/notification_log.js +++ b/frappe/desk/doctype/notification_log/notification_log.js @@ -3,10 +3,43 @@ frappe.ui.form.on('Notification Log', { refresh: function(frm) { - let dt = frm.doc.document_type; - let dn = frm.doc.document_name; - frm.fields_dict.document_name.$input_wrapper - .find('.control-value') - .wrapInner(``); + if (frm.doc.attached_file) { + frm.trigger('set_attachment'); + } else { + frm.get_field('attachment_link').$wrapper.empty(); + } + }, + + open_reference_document: function(frm) { + const dt = frm.doc.document_type; + const dn = frm.doc.document_name; + frappe.set_route('Form', dt, dn); + }, + + set_attachment: function(frm) { + const attachment = JSON.parse(frm.doc.attached_file); + + const $wrapper = frm.get_field('attachment_link').$wrapper; + $wrapper.html(` +
{{ doc.name }} DeliveredCondition Examples:
\ndoc.status==\"Open\"\n" + "options": "
doc.due_date==nowdate()
doc.total > 40000\n
Condition Examples:
\ndoc.status==\"Open\"\n", + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "property_section", "fieldtype": "Section Break", - "label": "Set Property After Alert" + "label": "Set Property After Alert", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "set_property_after_alert", "fieldtype": "Select", - "label": "Set Property After Alert" + "label": "Set Property After Alert", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "property_value", "fieldtype": "Data", - "label": "Value To Be Set" + "label": "Value To Be Set", + "show_days": 1, + "show_seconds": 1 }, { - "depends_on": "eval:doc.channel=='Email'", + "depends_on": "eval:doc.channel!=='Slack'", "fieldname": "column_break_5", "fieldtype": "Section Break", - "label": "Recipients" + "label": "Recipients", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "recipients", "fieldtype": "Table", "label": "Recipients", - "options": "Notification Recipient" + "mandatory_depends_on": "eval:doc.channel!=='Slack'", + "options": "Notification Recipient", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "message_sb", "fieldtype": "Section Break", - "label": "Message" + "label": "Message", + "show_days": 1, + "show_seconds": 1 }, { "default": "Add your message here", "fieldname": "message", "fieldtype": "Code", "ignore_xss_filter": 1, - "label": "Message" + "label": "Message", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.channel=='Email'", "fieldname": "message_examples", "fieldtype": "HTML", "label": "Message Examples", - "options": "
doc.due_date==nowdate()
doc.total > 40000\n
<h3>Order Overdue</h3>\n\n<p>Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.</p>\n\n<!-- show last comment -->\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n<h4>Details</h4>\n\n<ul>\n<li>Customer: {{ doc.customer }}\n<li>Amount: {{ doc.grand_total }}\n</ul>\n"
+ "options": "<h3>Order Overdue</h3>\n\n<p>Transaction {{ doc.name }} has exceeded Due Date. Please take necessary action.</p>\n\n<!-- show last comment -->\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n<h4>Details</h4>\n\n<ul>\n<li>Customer: {{ doc.customer }}\n<li>Amount: {{ doc.grand_total }}\n</ul>\n",
+ "show_days": 1,
+ "show_seconds": 1
},
{
"depends_on": "eval:doc.channel=='Slack'",
"fieldname": "slack_message_examples",
"fieldtype": "HTML",
"label": "Message Examples",
- "options": "*Order Overdue*\n\nTransaction {{ doc.name }} has exceeded Due Date. Please take necessary action.\n\n\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n*Details*\n\n\u2022 Customer: {{ doc.customer }}\n\u2022 Amount: {{ doc.grand_total }}\n"
+ "options": "*Order Overdue*\n\nTransaction {{ doc.name }} has exceeded Due Date. Please take necessary action.\n\n\n{% if comments %}\nLast comment: {{ comments[-1].comment }} by {{ comments[-1].by }}\n{% endif %}\n\n*Details*\n\n\u2022 Customer: {{ doc.customer }}\n\u2022 Amount: {{ doc.grand_total }}\n",
+ "show_days": 1,
+ "show_seconds": 1
},
{
"fieldname": "view_properties",
"fieldtype": "Button",
- "label": "View Properties (via Customize Form)"
+ "label": "View Properties (via Customize Form)",
+ "show_days": 1,
+ "show_seconds": 1
},
{
"collapsible": 1,
"collapsible_depends_on": "attach_print",
"fieldname": "column_break_25",
"fieldtype": "Section Break",
- "label": "Print Settings"
+ "label": "Print Settings",
+ "show_days": 1,
+ "show_seconds": 1
},
{
"default": "0",
"fieldname": "attach_print",
"fieldtype": "Check",
- "label": "Attach Print"
+ "label": "Attach Print",
+ "show_days": 1,
+ "show_seconds": 1
},
{
"depends_on": "attach_print",
"fieldname": "print_format",
"fieldtype": "Link",
"label": "Print Format",
- "options": "Print Format"
+ "options": "Print Format",
+ "show_days": 1,
+ "show_seconds": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.channel !== 'System Notification'",
+ "description": "If enabled, the notification will show up in the notifications dropdown on the top right corner of the navigation bar.",
+ "fieldname": "send_system_notification",
+ "fieldtype": "Check",
+ "label": "Send System Notification",
+ "show_days": 1,
+ "show_seconds": 1
}
],
"icon": "fa fa-envelope",
- "modified": "2019-07-15 13:17:02.585013",
+ "links": [],
+ "modified": "2020-05-29 16:03:10.914526",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",
diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py
index 8c011ade65..8e53b50fa2 100644
--- a/frappe/email/doctype/notification/notification.py
+++ b/frappe/email/doctype/notification/notification.py
@@ -13,6 +13,7 @@ from frappe.utils.jinja import validate_template
from frappe.modules.utils import export_module_json, get_doc_module
from six import string_types
from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message
+from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification
class Notification(Document):
def onload(self):
@@ -125,6 +126,9 @@ def get_context(context):
if self.channel == 'Slack':
self.send_a_slack_msg(doc, context)
+ if self.channel == 'System Notification' or self.send_system_notification:
+ self.create_system_notification(doc, context)
+
if self.set_property_after_alert:
allow_update = True
if doc.docstatus == 1 and not doc.meta.get_field(self.set_property_after_alert).allow_on_submit:
@@ -143,6 +147,25 @@ def get_context(context):
except Exception:
frappe.log_error(title='Document update failed', message=frappe.get_traceback())
+ def create_system_notification(self, doc, context):
+ subject = self.subject
+ if "{" in subject:
+ subject = frappe.render_template(self.subject, context)
+
+ attachments = self.get_attachment(doc)
+ recipients, cc, bcc = self.get_list_of_recipients(doc, context)
+ users = recipients + cc + bcc
+
+ notification_doc = {
+ 'type': 'Alert',
+ 'document_type': doc.doctype,
+ 'document_name': doc.name,
+ 'subject': subject,
+ 'email_content': frappe.render_template(self.message, context),
+ 'attached_file': attachments and json.dumps(attachments[0])
+ }
+ enqueue_create_notification(users, notification_doc)
+
def send_an_email(self, doc, context):
from email.utils import formataddr
subject = self.subject
@@ -228,8 +251,7 @@ def get_context(context):
# ignoring attachment as draft and cancelled documents are not allowed to print
status = "Draft" if doc.docstatus == 0 else "Cancelled"
- frappe.throw(_("""Not allowed to attach {0} document,
- please enable Allow Print For {0} in Print Settings""".format(status)),
+ frappe.throw(_("""Not allowed to attach {0} document, please enable Allow Print For {0} in Print Settings""").format(status),
title=_("Error in Notification"))
else:
return [{
diff --git a/frappe/exceptions.py b/frappe/exceptions.py
index 5a1181f31e..1aac339228 100644
--- a/frappe/exceptions.py
+++ b/frappe/exceptions.py
@@ -13,6 +13,11 @@ if sys.version_info.major == 2:
else:
from builtins import FileNotFoundError
+class SiteNotSpecifiedError(Exception):
+ def __init__(self, *args, **kwargs):
+ self.message = "Please specify --site sitename"
+ super(Exception, self).__init__(self.message)
+
class ValidationError(Exception):
http_status_code = 417
diff --git a/frappe/installer.py b/frappe/installer.py
index 54402f0087..4fc19b282a 100755
--- a/frappe/installer.py
+++ b/frappe/installer.py
@@ -269,6 +269,7 @@ def make_site_dirs():
os.path.join(site_private_path, 'backups'),
os.path.join(site_public_path, 'files'),
os.path.join(site_private_path, 'files'),
+ os.path.join(frappe.local.site_path, 'logs'),
os.path.join(frappe.local.site_path, 'task-logs')):
if not os.path.exists(dir_path):
os.makedirs(dir_path)
@@ -298,7 +299,8 @@ def remove_missing_apps():
def extract_sql_gzip(sql_gz_path):
try:
- subprocess.check_call(['gzip', '-d', '-v', '-f', sql_gz_path])
+ # kdvf - keep, decompress, verbose, force
+ subprocess.check_call(['gzip', '-kdvf', sql_gz_path])
except:
raise
diff --git a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py
index 5e464d4882..1d2f7f9495 100644
--- a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py
+++ b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py
@@ -64,6 +64,8 @@ from __future__ import unicode_literals
import frappe
from frappe import _
import json
+import hmac
+import hashlib
from six.moves.urllib.parse import urlencode
from frappe.model.document import Document
from frappe.utils import get_url, call_hook_method, cint, get_timestamp
@@ -317,6 +319,20 @@ class RazorpaySettings(Document):
except Exception:
frappe.log_error(frappe.get_traceback())
+ def verify_signature(self, body, signature, key):
+ key = bytes(key, 'utf-8')
+ body = bytes(body, 'utf-8')
+
+ dig = hmac.new(key=key, msg=body, digestmod=hashlib.sha256)
+
+ generated_signature = dig.hexdigest()
+ result = hmac.compare_digest(generated_signature, signature)
+
+ if not result:
+ frappe.throw(_('Razorpay Signature Verification Failed'), exc=frappe.PermissionError)
+
+ return result
+
def capture_payment(is_sandbox=False, sanbox_response=None):
"""
Verifies the purchase as complete by the merchant.
diff --git a/frappe/integrations/frappe_providers/__init__.py b/frappe/integrations/frappe_providers/__init__.py
index 0b689478d2..887e191e16 100644
--- a/frappe/integrations/frappe_providers/__init__.py
+++ b/frappe/integrations/frappe_providers/__init__.py
@@ -7,7 +7,6 @@ from frappe.integrations.frappe_providers.frappecloud import frappecloud_migrato
def migrate_to(local_site, frappe_provider):
if frappe_provider in ("frappe.cloud", "frappecloud.com"):
- frappe_provider = "frappecloud.com"
return frappecloud_migrator(local_site, frappe_provider)
else:
print("{} is not supported yet".format(frappe_provider))
diff --git a/frappe/integrations/frappe_providers/frappecloud.py b/frappe/integrations/frappe_providers/frappecloud.py
index 4f33c990f9..3e4b584246 100644
--- a/frappe/integrations/frappe_providers/frappecloud.py
+++ b/frappe/integrations/frappe_providers/frappecloud.py
@@ -13,7 +13,129 @@ import requests
import frappe
import frappe.utils.backups
from frappe.utils import get_installed_apps_info
-from frappe.utils.commands import render_table, add_line_after
+from frappe.utils.commands import render_table, add_line_after, add_line_before
+
+
+# TODO: check upgrade compatibility
+
+
+def render_actions_table():
+ actions_table = [["#", "Action"]]
+ actions = []
+
+ for n, action in enumerate(migrator_actions):
+ actions_table.append([n+1, action["title"]])
+ actions.append(action["fn"])
+
+ render_table(actions_table)
+ return actions
+
+
+def render_site_table(sites_info):
+ sites_table = [["#", "Site Name", "Status"]]
+ available_sites = []
+
+ for n, site_data in enumerate(sites_info):
+ name, status = site_data["name"], site_data["status"]
+ if status in ("Active", "Broken"):
+ sites_table.append([n + 1, name, status])
+ available_sites.append(name)
+
+ render_table(sites_table)
+ return available_sites
+
+
+def render_teams_table(teams):
+ teams_table = [["#", "Team"]]
+
+ for n, team in enumerate(teams):
+ teams_table.append([n+1, team])
+
+ render_table(teams_table)
+
+
+def render_plan_table(plans_list):
+ plans_table = [["Plan", "CPU Time"]]
+ visible_headers = ["name", "cpu_time_per_day"]
+
+ for plan in plans_list:
+ plan, cpu_time = [plan[header] for header in visible_headers]
+ plans_table.append([plan, "{} hour{}/day".format(cpu_time, "" if cpu_time < 2 else "s")])
+
+ render_table(plans_table)
+
+
+def render_group_table(app_groups):
+ # title row
+ app_groups_table = [["#", "App Group", "Apps"]]
+
+ # all rows
+ for idx, app_group in enumerate(app_groups):
+ apps_list = ", ".join(["{}:{}".format(app["scrubbed"], app["branch"]) for app in app_group["apps"]])
+ row = [idx + 1, app_group["name"], apps_list]
+ app_groups_table.append(row)
+
+ render_table(app_groups_table)
+
+
+def handle_request_failure(request=None, message=None, traceback=True, exit_code=1):
+ message = message or "Request failed with error code {}".format(request.status_code)
+ response = html2text(request.text) if traceback else ""
+
+ print("{0}{1}".format(message, "\n" + response))
+ sys.exit(exit_code)
+
+
+@add_line_after
+def select_primary_action():
+ actions = render_actions_table()
+ idx = click.prompt("What do you want to do?", type=click.IntRange(1, len(actions))) - 1
+
+ return actions[idx]
+
+
+@add_line_after
+def select_site():
+ get_all_sites_request = session.post(all_site_url, headers={
+ "accept": "application/json",
+ "accept-encoding": "gzip, deflate, br",
+ "content-type": "application/json; charset=utf-8"
+ })
+
+ if get_all_sites_request.ok:
+ all_sites = get_all_sites_request.json()["message"]
+ available_sites = render_site_table(all_sites)
+
+ while True:
+ selected_site = click.prompt("Name of the site you want to restore to", type=str).strip()
+ if selected_site in available_sites:
+ return selected_site
+ else:
+ print("Site {} does not exist. Try again ❌".format(selected_site))
+ else:
+ print("Couldn't retrive sites list...Try again later")
+ sys.exit(1)
+
+
+@add_line_before
+def select_team(session):
+ # get team options
+ account_details_sc = session.post(account_details_url)
+ if account_details_sc.ok:
+ account_details = account_details_sc.json()["message"]
+ available_teams = account_details["teams"]
+
+ # ask if they want to select, go ahead with if only one exists
+ if len(available_teams) == 1:
+ team = available_teams[0]
+ else:
+ render_teams_table(available_teams)
+ idx = click.prompt("Select Team", type=click.IntRange(1, len(available_teams))) - 1
+ team = available_teams[idx]
+
+ print("Team '{}' set for current session".format(team))
+
+ return team
def get_new_site_options():
@@ -46,21 +168,6 @@ def is_subdomain_available(subdomain):
return available
-def render_plan_table(plans_list):
- plans_table = []
-
- # title row
- visible_headers = ["name", "cpu_time_per_day"]
- plans_table.append(["Plan", "CPU Time"])
-
- # all rows
- for plan in plans_list:
- plan, cpu_time = [plan[header] for header in visible_headers]
- plans_table.append([plan, "{} hour{}/day".format(cpu_time, "" if cpu_time < 2 else "s")])
-
- render_table(plans_table)
-
-
@add_line_after
def choose_plan(plans_list):
print("{} plans available".format(len(plans_list)))
@@ -113,19 +220,6 @@ def check_app_compat(available_group):
return is_compat, filtered_apps
-def render_group_table(app_groups):
- # title row
- app_groups_table = [["#", "App Group", "Apps"]]
-
- # all rows
- for idx, app_group in enumerate(app_groups):
- apps_list = ", ".join(["{}:{}".format(app["scrubbed"], app["branch"]) for app in app_group["apps"]])
- row = [idx + 1, app_group["name"], apps_list]
- app_groups_table.append(row)
-
- render_table(app_groups_table)
-
-
@add_line_after
def filter_apps(app_groups):
render_group_table(app_groups)
@@ -148,24 +242,6 @@ def filter_apps(app_groups):
return selected_group["name"], filtered_apps
-@add_line_after
-def create_session():
- # take user input from STDIN
- username = click.prompt("Username").strip()
- password = getpass.unix_getpass()
-
- auth_credentials = {"usr": username, "pwd": password}
-
- session = requests.Session()
- login_sc = session.post(login_url, auth_credentials)
-
- if login_sc.ok:
- print("Authorization Successful! ✅")
- session.headers.update({"X-Press-Team": username})
- return session
- else:
- print("Authorization Failed with Error Code {}".format(login_sc.status_code))
-
@add_line_after
def get_subdomain(domain):
@@ -208,61 +284,114 @@ def upload_backup(local_site):
return files_uploaded
-def frappecloud_migrator(local_site, remote_site):
- global login_url, upload_url, files_url, options_url, site_exists_url, session
+def new_site(local_site):
+ # get new site options
+ site_options = get_new_site_options()
+
+ # set preferences from site options
+ subdomain = get_subdomain(site_options["domain"])
+ plan = choose_plan(site_options["plans"])
+
+ app_groups = site_options["groups"]
+ selected_group, filtered_apps = filter_apps(app_groups)
+ files_uploaded = upload_backup(local_site)
+
+ # push to frappe_cloud
+ payload = json.dumps({
+ "site": {
+ "apps": filtered_apps,
+ "files": files_uploaded,
+ "group": selected_group,
+ "name": subdomain,
+ "plan": plan
+ }
+ })
+
+ session.headers.update({"Content-Type": "application/json; charset=utf-8"})
+ site_creation_request = session.post(upload_url, payload)
+
+ if site_creation_request.ok:
+ site_url = site_creation_request.json()["message"]
+ print("Your site {} is being migrated ✨".format(local_site))
+ print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, site_url))
+ print("Your site URL: {}".format(site_url))
+ else:
+ handle_request_failure(site_creation_request)
+
+
+def restore_site(local_site):
+ # get list of existing sites they can restore
+ selected_site = select_site()
+
+ # TODO: check if they can restore it
+
+ click.confirm("This is an irreversible action. Are you sure you want to continue?", abort=True)
+
+ # backup site
+ files_uploaded = upload_backup(local_site)
+
+ # push to frappe_cloud
+ payload = json.dumps({
+ "name": selected_site,
+ "files": files_uploaded
+ })
+ headers = {"Content-Type": "application/json; charset=utf-8"}
+ site_restore_request = session.post(restore_site_url, payload, headers=headers)
+
+ if site_restore_request.ok:
+ print("Your site {0} is being restored on {1} ✨".format(local_site, selected_site))
+ print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, selected_site))
+ print("Your site URL: {}".format(selected_site))
+ else:
+ handle_request_failure(site_restore_request)
+
+
+@add_line_after
+def create_session():
+ print("Frappe Cloud credentials @ {}".format(remote_site))
+
+ # take user input from STDIN
+ username = click.prompt("Username").strip()
+ password = getpass.unix_getpass()
+
+ auth_credentials = {"usr": username, "pwd": password}
+
+ session = requests.Session()
+ login_sc = session.post(login_url, auth_credentials)
+
+ if login_sc.ok:
+ print("Authorization Successful! ✅")
+ team = select_team(session)
+ session.headers.update({"X-Press-Team": team })
+ return session
+ else:
+ handle_request_failure(message="Authorization Failed with Error Code {}".format(login_sc.status_code), traceback=False)
+
+
+def frappecloud_migrator(local_site, frappecloud_site):
+ global login_url, upload_url, files_url, options_url, site_exists_url, restore_site_url, account_details_url, all_site_url
+ global session, migrator_actions, remote_site
+
+ remote_site = frappe.conf.frappecloud_url or "frappecloud.com"
login_url = "https://{}/api/method/login".format(remote_site)
upload_url = "https://{}/api/method/press.api.site.new".format(remote_site)
files_url = "https://{}/api/method/upload_file".format(remote_site)
options_url = "https://{}/api/method/press.api.site.options_for_new".format(remote_site)
site_exists_url = "https://{}/api/method/press.api.site.exists".format(remote_site)
+ account_details_url = "https://{}/api/method/press.api.account.get".format(remote_site)
+ all_site_url = "https://{}/api/method/press.api.site.all".format(remote_site)
+ restore_site_url = "https://{}/api/method/press.api.site.restore".format(remote_site)
- print("Frappe Cloud credentials @ {}".format(remote_site))
+ migrator_actions = [
+ { "title": "Create a new site", "fn": new_site },
+ { "title": "Restore to an existing site", "fn": restore_site }
+ ]
# get credentials + auth user + start session
session = create_session()
- if session:
- # connect to site db
- frappe.init(site=local_site)
- frappe.connect()
+ # available actions defined in migrator_actions
+ primary_action = select_primary_action()
- # get new site options
- site_options = get_new_site_options()
-
- # set preferences from site options
- subdomain = get_subdomain(site_options["domain"])
- plan = choose_plan(site_options["plans"])
-
- app_groups = site_options["groups"]
- selected_group, filtered_apps = filter_apps(app_groups)
- files_uploaded = upload_backup(local_site)
-
- # push to frappe_cloud
- payload = json.dumps({
- "site": {
- "apps": filtered_apps,
- "files": files_uploaded,
- "group": selected_group,
- "name": subdomain,
- "plan": plan
- }
- })
-
- session.headers.update({"Content-Type": "application/json; charset=utf-8"})
- site_creation_request = session.post(upload_url, payload)
- frappe.destroy()
-
- if site_creation_request.ok:
- site_url = site_creation_request.json()["message"]
- print("Your site {} is being migrated ✨".format(local_site))
- print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, site_url))
- print("Your site URL: {}".format(site_url))
- else:
- print("Request failed with error code {}".format(site_creation_request.status_code))
- reason = html2text(site_creation_request.text)
- print(reason)
- sys.exit(1)
-
- else:
- sys.exit(1)
+ primary_action(local_site)
diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py
index 596aa18b09..19517aa4a1 100644
--- a/frappe/model/db_query.py
+++ b/frappe/model/db_query.py
@@ -16,7 +16,7 @@ import frappe, json, copy, re
from frappe.model import optional_fields
from frappe.client import check_parent_permission
from frappe.model.utils.user_settings import get_user_settings, update_user_settings
-from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, nowdate
+from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, get_timespan_date_range
from frappe.model.meta import get_table_columns
class DatabaseQuery(object):
@@ -354,7 +354,9 @@ class DatabaseQuery(object):
ifnull(`tabDocType`.`fieldname`, fallback) operator "value"
"""
- f = get_filter(self.doctype, f)
+ from frappe.boot import get_additional_filters_from_hooks
+ additional_filters_config = get_additional_filters_from_hooks()
+ f = get_filter(self.doctype, f, additional_filters_config)
tname = ('`tab' + f.doctype + '`')
if not tname in self.tables:
@@ -368,6 +370,9 @@ class DatabaseQuery(object):
can_be_null = True
+ if f.operator.lower() in additional_filters_config:
+ f.update(get_additional_filter_field(additional_filters_config, f, f.value))
+
# prepare in condition
if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'):
values = f.value or ''
@@ -426,29 +431,8 @@ class DatabaseQuery(object):
if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"):
can_be_null = False
- if f.operator.lower() in ('previous', 'next'):
- if f.operator.lower() == "previous":
- if f.value == "1 week":
- date_range = [add_to_date(nowdate(), days=-7), nowdate()]
- elif f.value == "1 month":
- date_range = [add_to_date(nowdate(), months=-1), nowdate()]
- elif f.value == "3 months":
- date_range = [add_to_date(nowdate(), months=-3), nowdate()]
- elif f.value == "6 months":
- date_range = [add_to_date(nowdate(), months=-6), nowdate()]
- elif f.value == "1 year":
- date_range = [add_to_date(nowdate(), years=-1), nowdate()]
- elif f.operator.lower() == "next":
- if f.value == "1 week":
- date_range = [nowdate(), add_to_date(nowdate(), days=7)]
- elif f.value == "1 month":
- date_range = [nowdate(), add_to_date(nowdate(), months=1)]
- elif f.value == "3 months":
- date_range = [nowdate(), add_to_date(nowdate(), months=3)]
- elif f.value == "6 months":
- date_range = [nowdate(), add_to_date(nowdate(), months=6)]
- elif f.value == "1 year":
- date_range = [nowdate(), add_to_date(nowdate(), years=1)]
+ if f.operator.lower() in ('previous', 'next', 'timespan'):
+ date_range = get_date_range(f.operator.lower(), f.value)
f.operator = "Between"
f.value = date_range
fallback = "'0001-01-01 00:00:00'"
@@ -843,4 +827,31 @@ def get_between_date_filter(value, df=None):
frappe.db.format_date(from_date),
frappe.db.format_date(to_date))
- return data
\ No newline at end of file
+ return data
+
+def get_additional_filter_field(additional_filters_config, f, value):
+ additional_filter = additional_filters_config[f.operator.lower()]
+ f = frappe._dict(frappe.get_attr(additional_filter['get_field'])())
+ if f.query_value:
+ for option in f.options:
+ option = frappe._dict(option)
+ if option.value == value:
+ f.value = option.query_value
+ return f
+
+def get_date_range(operator, value):
+ timespan_map = {
+ '1 week': 'week',
+ '1 month': 'month',
+ '3 months': 'quarter',
+ '6 months': '6 months',
+ '1 year': 'year',
+ }
+ period_map = {
+ 'previous': 'last',
+ 'next': 'next',
+ }
+
+ timespan = period_map[operator] + ' ' + timespan_map[value] if operator != 'timespan' else value
+
+ return get_timespan_date_range(timespan)
\ No newline at end of file
diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py
index 4491a352bc..1e3f127b99 100644
--- a/frappe/model/rename_doc.py
+++ b/frappe/model/rename_doc.py
@@ -56,6 +56,8 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F
if not merge:
rename_parent_and_child(doctype, old, new, meta)
+ else:
+ update_assignments(old, new, doctype)
# update link fields' values
link_fields = get_link_fields(doctype)
@@ -104,6 +106,27 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F
return new
+def update_assignments(old, new, doctype):
+ old_assignments = frappe.parse_json(frappe.db.get_value(doctype, old, '_assign')) or []
+ new_assignments = frappe.parse_json(frappe.db.get_value(doctype, new, '_assign')) or []
+ common_assignments = list(set(old_assignments).intersection(new_assignments))
+
+ for user in common_assignments:
+ # delete todos linked to old doc
+ todos = frappe.db.get_all('ToDo',
+ {
+ 'owner': user,
+ 'reference_type': doctype,
+ 'reference_name': old,
+ },
+ ['name', 'description']
+ )
+
+ for todo in todos:
+ frappe.delete_doc('ToDo', todo.name)
+
+ unique_assignments = list(set(old_assignments + new_assignments))
+ frappe.db.set_value(doctype, new, '_assign', frappe.as_json(unique_assignments, indent=0))
def update_user_settings(old, new, link_fields):
'''
diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py
index 4384e7c8f5..ea563dfc13 100644
--- a/frappe/model/workflow.py
+++ b/frappe/model/workflow.py
@@ -299,6 +299,7 @@ def set_workflow_state_on_action(doc, workflow_name, action):
return
action_map = {
+ 'update_after_submit': '1',
'submit': '1',
'cancel': '2'
}
diff --git a/frappe/patches.txt b/frappe/patches.txt
index 0a7d368ee2..fb5bf447b7 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -278,6 +278,7 @@ frappe.patches.v13_0.set_path_for_homepage_in_web_page_view
frappe.patches.v13_0.migrate_translation_column_data
frappe.patches.v13_0.set_read_times
frappe.patches.v13_0.remove_web_view
+frappe.patches.v13_0.site_wise_logging
frappe.patches.v13_0.set_unique_for_page_view
frappe.patches.v13_0.remove_tailwind_from_page_builder
frappe.patches.v13_0.rename_onboarding
@@ -285,4 +286,5 @@ frappe.patches.v13_0.email_unsubscribe
execute:frappe.delete_doc("Web Template", "Section with Left Image", force=1)
execute:frappe.delete_doc("DocType", "Onboarding Slide")
execute:frappe.delete_doc("DocType", "Onboarding Slide Field")
-execute:frappe.delete_doc("DocType", "Onboarding Slide Help Link")
\ No newline at end of file
+execute:frappe.delete_doc("DocType", "Onboarding Slide Help Link")
+frappe.patches.v13_0.update_date_filters_in_user_settings
diff --git a/frappe/patches/v13_0/site_wise_logging.py b/frappe/patches/v13_0/site_wise_logging.py
new file mode 100644
index 0000000000..6f04e0c9dd
--- /dev/null
+++ b/frappe/patches/v13_0/site_wise_logging.py
@@ -0,0 +1,10 @@
+import os
+import frappe
+
+
+def execute():
+ site = frappe.local.site
+
+ log_folder = os.path.join(site, 'logs')
+ if not os.path.exists(log_folder):
+ os.mkdir(log_folder)
\ No newline at end of file
diff --git a/frappe/patches/v13_0/update_date_filters_in_user_settings.py b/frappe/patches/v13_0/update_date_filters_in_user_settings.py
new file mode 100644
index 0000000000..d4c6aa1d03
--- /dev/null
+++ b/frappe/patches/v13_0/update_date_filters_in_user_settings.py
@@ -0,0 +1,54 @@
+from __future__ import unicode_literals
+import frappe, json
+from frappe.model.utils.user_settings import update_user_settings, sync_user_settings
+
+def execute():
+ users = frappe.db.sql("select distinct(user) from `__UserSettings`", as_dict=True)
+
+ for user in users:
+ user_settings = frappe.db.sql('''
+ select
+ * from `__UserSettings`
+ where
+ user="{user}"
+ '''.format(user = user.user), as_dict=True)
+
+ for setting in user_settings:
+ data = frappe.parse_json(setting.get('data'))
+ if data:
+ for key in data:
+ update_user_setting_filters(data, key, setting)
+
+ sync_user_settings()
+
+
+def update_user_setting_filters(data, key, user_setting):
+ timespan_map = {
+ '1 week': 'week',
+ '1 month': 'month',
+ '3 months': 'quarter',
+ '6 months': '6 months',
+ '1 year': 'year',
+ }
+
+ period_map = {
+ 'Previous': 'last',
+ 'Next': 'next'
+ }
+
+ if data.get(key):
+ update = False
+ if isinstance(data.get(key), dict):
+ filters = data.get(key).get('filters')
+ if filters and isinstance(filters, list):
+ for f in filters:
+ if f[2] == 'Next' or f[2] == 'Previous':
+ update = True
+ f[3] = period_map[f[2]] + ' ' + timespan_map[f[3]]
+ f[2] = 'Timespan'
+
+ if update:
+ data[key]['filters'] = filters
+ update_user_settings(user_setting['doctype'], json.dumps(data), for_update=True)
+
+
diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js
index 15f77fada5..b94257106e 100644
--- a/frappe/public/js/frappe/list/base_list.js
+++ b/frappe/public/js/frappe/list/base_list.js
@@ -338,6 +338,11 @@ frappe.views.BaseList = class BaseList {
: [];
}
+ get_filter_value(fieldname) {
+ return this.get_filters_for_args().filter(f => f[1] == fieldname)[0] &&
+ this.get_filters_for_args().filter(f => f[1] == fieldname)[0][3];
+ }
+
get_args() {
return {
doctype: this.doctype,
diff --git a/frappe/public/js/frappe/list/list_settings.js b/frappe/public/js/frappe/list/list_settings.js
new file mode 100644
index 0000000000..f2045c9c34
--- /dev/null
+++ b/frappe/public/js/frappe/list/list_settings.js
@@ -0,0 +1,383 @@
+export default class ListSettings {
+ constructor({ listview, doctype, meta, settings }) {
+ if (!doctype) {
+ frappe.throw(__('Doctype required'));
+ }
+
+ this.listview = listview;
+ this.doctype = doctype;
+ this.meta = meta;
+ this.settings = settings;
+ this.dialog = null;
+ this.fields = this.settings && this.settings.fields ? JSON.parse(this.settings.fields) : [];
+ this.subject_field = null;
+
+ frappe.model.with_doctype("List View Settings", () => {
+ this.make();
+ this.get_listview_fields(meta);
+ this.setup_fields();
+ this.setup_remove_fields();
+ this.add_new_fields();
+ this.show_dialog();
+ });
+ }
+
+ make() {
+ let me = this;
+
+ let list_view_settings = frappe.get_meta("List View Settings");
+
+ me.dialog = new frappe.ui.Dialog({
+ title: __("{0} Settings", [__(me.doctype)]),
+ fields: list_view_settings.fields
+ });
+ me.dialog.set_values(me.settings);
+ me.dialog.set_primary_action(__('Save'), () => {
+ let values = me.dialog.get_values();
+
+ frappe.show_alert({
+ message: __("Saving"),
+ indicator: "green"
+ });
+
+ frappe.call({
+ method: "frappe.desk.doctype.list_view_settings.list_view_settings.save_listview_settings",
+ args: {
+ doctype: me.doctype,
+ listview_settings: values,
+ removed_listview_fields: me.removed_fields || []
+ },
+ callback: function (r) {
+ me.listview.refresh_columns(r.message.meta, r.message.listview_settings);
+ me.dialog.hide();
+ }
+ });
+ });
+
+ me.dialog.fields_dict["total_fields"].df.onchange = () => me.refresh();
+ }
+
+ refresh() {
+ let me = this;
+
+ me.setup_fields();
+ me.add_new_fields();
+ me.setup_remove_fields();
+ }
+
+ show_dialog() {
+ let me = this;
+
+ if (!this.settings.fields) {
+ me.update_fields();
+ }
+
+ if (!me.dialog.get_value("total_fields")) {
+ let field_count = me.fields.length;
+
+ if (field_count < 4) {
+ field_count = 4;
+ } else if (field_count > 10) {
+ field_count = 4;
+ }
+
+ me.dialog.set_value("total_fields", field_count);
+ }
+
+ me.dialog.show();
+ }
+
+ setup_fields() {
+ function is_status_field(field) {
+ return field.fieldname === "status_field";
+ }
+
+ let me = this;
+
+ let fields_html = me.dialog.get_field("fields_html");
+ let wrapper = fields_html.$wrapper[0];
+ let fields = ``;
+ let total_fields = me.dialog.get_values().total_fields || me.settings.total_fields;
+
+ for (let idx in me.fields) {
+ if (idx == parseInt(total_fields)) {
+ break;
+ }
+ let is_sortable = (idx == 0) ? `` : `sortable`;
+ let show_sortable_handle = (idx == 0) ? `hide` : ``;
+ let can_remove = (idx == 0 || is_status_field(me.fields[idx])) ? `hide` : ``;
+
+ fields += `
+ + ${__('Are you sure you want to save this document?')} +
+${__(`There are documents which have workflow states that do not exist in this Workflow. + It is recommended that you add these states to the Workflow and change their states + before removing these states.`)} +
`; + const message_html = warning_html + frm.state_table_html; + let proceed_action = () => { + frm.ignore_warning = true; + frm.save(); + }; + + frappe.warn(__(`Worflow States Don't Exist`), message_html, proceed_action, __(`Save Anyway`)); + }, + set_table_html: function(frm) { + + const promises = frm.states.map(r => { + const state = r[frm.doc.workflow_state_field]; + return frappe.utils.get_indicator_color(state).then(color => { + return `| ${__('State')} | +${__('Count')} | +
|---|
+ ${'Document States that do not exist in your Workflow'} +
+ ${frm.state_table_html} +