diff --git a/.travis.yml b/.travis.yml index 30eb882256..a1568c9118 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,6 +25,7 @@ cache: # https://docs.cypress.io/guides/guides/continuous-integration.html#Caching - ~/.cache + matrix: include: - name: "Python 3.7 MariaDB" @@ -46,7 +47,30 @@ matrix: script: bench --site test_site run-ui-tests frappe --headless before_install: - # install wkhtmltopdf + # do we really want to run travis? check which files are changed and if git doesnt face any fatal errors + - | + FILES_CHANGED=$( git diff --name-only $TRAVIS_COMMIT_RANGE 2>&1 ) + + if [[ $FILES_CHANGED != *"fatal"* ]]; then + ONLY_DOCS_CHANGES=$( echo $FILES_CHANGED | grep -qvE '\.(md|png|jpg|jpeg)$|^.github|LICENSE' ; echo $? ) + ONLY_JS_CHANGES=$( echo $FILES_CHANGED | grep -qvE '\.js$' ; echo $? ) + ONLY_PY_CHANGES=$( echo $FILES_CHANGED | grep -qvE '\.py$' ; echo $? ) + + if [[ $ONLY_DOCS_CHANGES == "1" ]]; then + echo "Only docs were updated, stopping build process."; + exit; + fi + if [[ $ONLY_JS_CHANGES == "1" && $TYPE == "server" ]]; then + echo "Only JavaScript code was updated; Stopping Python build process."; + exit; + fi + if [[ $ONLY_PY_CHANGES == "1" && $TYPE == "ui" ]]; then + echo "Only Python code was updated, stopping Cypress build process."; + exit; + fi + fi + + # install wkhtmltopdf - wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz - tar -xf /tmp/wkhtmltox.tar.xz -C /tmp - sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf diff --git a/README.md b/README.md index 860958087e..7545249610 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- + frappe

@@ -33,8 +33,8 @@ Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com) ### Table of Contents -* [Installation](#installation) -* [Documentation](https://frappe.io/docs) +* [Installation](https://frappeframework.com/docs/user/en/installation) +* [Documentation](https://frappeframework.com/docs) * [License](#license) ### Installation @@ -49,7 +49,7 @@ Full-stack web application framework that uses Python and MariaDB on the server ### Website For details and documentation, see the website -[https://frappe.io](https://frappe.io) +[https://frappeframework.com](https://frappeframework.com) ### License This repository has been released under the [MIT License](LICENSE). diff --git a/cypress.json b/cypress.json index ae0c45c3ae..97ac41bb61 100644 --- a/cypress.json +++ b/cypress.json @@ -2,6 +2,6 @@ "baseUrl": "http://test_site_ui:8000", "projectId": "92odwv", "adminPassword": "admin", - "defaultCommandTimeout": 10000, + "defaultCommandTimeout": 20000, "pageLoadTimeout": 15000 } diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js new file mode 100644 index 0000000000..edad759216 --- /dev/null +++ b/cypress/integration/control_duration.js @@ -0,0 +1,45 @@ +context('Control Duration', () => { + before(() => { + cy.login(); + cy.visit('/desk#workspace/Website'); + }); + + function get_dialog_with_duration(hide_days=0, hide_seconds=0) { + return cy.dialog({ + title: 'Duration', + fields: [{ + 'fieldname': 'duration', + 'fieldtype': 'Duration', + 'hide_days': hide_days, + 'hide_seconds': hide_seconds + }] + }); + } + + it('should set duration', () => { + get_dialog_with_duration().as('dialog'); + cy.get('.frappe-control[data-fieldname=duration] input') + .first() + .click(); + cy.get('.duration-input[data-duration=days]') + .type(45, {force: true}) + .blur({force: true}); + cy.get('.duration-input[data-duration=minutes]') + .type(30) + .blur({force: true}); + cy.get('.frappe-control[data-fieldname=duration] input').first().should('have.value', '45d 30m'); + cy.get('.frappe-control[data-fieldname=duration] input').first().blur(); + cy.get('.duration-picker').should('not.be.visible'); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('duration'); + expect(value).to.equal(3889800); + }); + }); + + it('should hide days or seconds according to duration options', () => { + get_dialog_with_duration(1, 1).as('dialog'); + cy.get('.frappe-control[data-fieldname=duration] input').first().click(); + cy.get('.duration-input[data-duration=days]').should('not.be.visible'); + cy.get('.duration-input[data-duration=seconds]').should('not.be.visible'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 658a7fe320..0dc7d5b88e 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -1,7 +1,11 @@ context('Control Link', () => { - beforeEach(() => { + before(() => { cy.login(); cy.visit('/desk#workspace/Website'); + }); + + beforeEach(() => { + cy.visit('/desk#workspace/Website'); cy.create_records({ doctype: 'ToDo', description: 'this is a test todo for link' @@ -30,7 +34,7 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); cy.wait('@search_link'); - cy.get('@input').type('todo for link'); + cy.get('@input').type('todo for link', { delay: 200 }); cy.wait('@search_link'); cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 23fc57fc57..ef89a18e7d 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -9,6 +9,7 @@ context('Form', () => { it('create a new form', () => { cy.visit('/desk#Form/ToDo/New ToDo 1'); cy.fill_field('description', 'this is a test todo', 'Text Editor').blur(); + cy.wait(300); cy.get('.page-title').should('contain', 'Not Saved'); cy.server(); cy.route({ diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index f03384cb93..b383f30bb8 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -40,12 +40,12 @@ context('Grid Pagination', () => { cy.get('@table').find('.current-page-number').should('contain', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); }); - it('deletes all rows', ()=> { - cy.visit('/desk#Form/Contact/Test Contact'); - cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true}); - cy.get('@table').find('button.grid-remove-all-rows').click(); - cy.get('.modal-dialog .btn-primary').contains('Yes').click(); - cy.get('@table').find('.grid-body .grid-row').should('have.length', 0); - }); + // it('deletes all rows', ()=> { + // cy.visit('/desk#Form/Contact/Test Contact'); + // cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + // cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true}); + // cy.get('@table').find('button.grid-remove-all-rows').click(); + // cy.get('.modal-dialog .btn-primary').contains('Yes').click(); + // cy.get('@table').find('.grid-body .grid-row').should('have.length', 0); + // }); }); \ No newline at end of file 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 986c5ce342..ac70c44345 100644 --- a/cypress/integration/relative_filters.js +++ b/cypress/integration/relative_time_filters.js @@ -1,7 +1,6 @@ context('Relative Timeframe', () => { beforeEach(() => { cy.login(); - cy.visit('/desk#workspace/Website'); }); before(() => { cy.login(); @@ -10,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(); @@ -29,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 0eb6265942..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,7 +108,8 @@ 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.dashboards = frappe.get_list("Dashboard") + bootinfo.module_page_map = get_controller("Desk Page").get_module_page_map() + bootinfo.dashboards = frappe.get_all("Dashboard") def get_allowed_pages(cache=False): return get_user_pages_or_reports('Page', cache=cache) @@ -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..b7294fff77 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() @@ -39,13 +43,14 @@ def pass_context(f): return click.pass_context(_func) -def get_site(context): +def get_site(context, raise_err=True): try: site = context.sites[0] return site except (IndexError, TypeError): - print('Please specify --site sitename') - sys.exit(1) + if raise_err: + raise frappe.SiteNotSpecifiedError + return None def popen(command, *args, **kwargs): output = kwargs.get('output', True) diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index 6f51c81211..bd9c9d2cb0 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') @@ -120,7 +126,7 @@ def doctor(context, site=None): "Get diagnostic info about background workers" from frappe.utils.doctor import doctor as _doctor if not site: - site = get_site(context) + site = get_site(context, raise_err=False) return _doctor(site=site) @click.command('show-pending-jobs') diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 82ed72dd5c..28e61282eb 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 @@ -43,14 +44,16 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin _new_site(db_name, site, mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password, admin_password=admin_password, verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force, - no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port) + no_mariadb_socket=no_mariadb_socket, db_password=db_password, db_type=db_type, db_host=db_host, + db_port=db_port, new_site=True) if len(frappe.utils.get_sites()) == 1: use(site) def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None, admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False, - no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None, db_port=None): + no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None, + db_port=None, new_site=False): """Install a new Frappe site""" if not force and os.path.exists(site): @@ -79,7 +82,6 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N make_site_dirs() installing = touch_file(get_site_path('locks', 'installing.lock')) - atexit.register(_new_site_cleanup, site, mariadb_root_username, mariadb_root_password) install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name, admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall, @@ -96,15 +98,6 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled" print("*** Scheduler is", scheduler_status, "***") -def _new_site_cleanup(site, mariadb_root_username, mariadb_root_password): - installing = get_site_path('locks', 'installing.lock') - - if installing and os.path.exists(installing): - if mariadb_root_password: - _drop_site(site, mariadb_root_username, mariadb_root_password, force=True, no_backup=True) - shutil.rmtree(site) - - frappe.destroy() @click.command('restore') @click.argument('sql-file-path') @@ -122,30 +115,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') @@ -192,6 +202,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 @@ -221,7 +233,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') @@ -252,6 +265,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.*')) @@ -263,7 +278,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') @@ -278,6 +298,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') @@ -294,6 +316,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') @@ -308,6 +332,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 @@ -315,6 +341,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') @@ -328,7 +356,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") @@ -361,6 +389,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') @@ -376,6 +407,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') @@ -392,6 +425,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') @@ -422,7 +457,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) @@ -483,6 +518,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") @@ -528,6 +565,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) @@ -555,6 +594,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') @@ -563,6 +604,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..343dc6e2bc 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. @@ -487,7 +502,17 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), if coverage: # Generate coverage report only for app that is being tested source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe') - cov = Coverage(source=[source_path], omit=['*.html', '*.js', '*.xml', '*.css', '*/doctype/*/*_dashboard.py', '*/patches/*']) + cov = Coverage(source=[source_path], omit=[ + '*.html', + '*.js', + '*.xml', + '*.css', + '*.less', + '*.scss', + '*.vue', + '*/doctype/*/*_dashboard.py', + '*/patches/*' + ]) cov.start() ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, @@ -577,7 +602,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 +684,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/communication/communication.py b/frappe/core/doctype/communication/communication.py index abd24fb468..232d485f36 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -2,20 +2,21 @@ # MIT License. See license.txt from __future__ import unicode_literals, absolute_import +from collections import Counter import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import validate_email_address, get_fullname, strip_html, cstr -from frappe.core.doctype.communication.email import (validate_email, - notify, _notify, update_parent_mins_to_first_response) +from frappe.utils import validate_email_address, strip_html, cstr, time_diff_in_seconds +from frappe.core.doctype.communication.email import validate_email, notify, _notify from frappe.core.utils import get_parent_doc from frappe.utils.bot import BotReply from frappe.utils import parse_addr from frappe.core.doctype.comment.comment import update_comment_in_doc from email.utils import parseaddr from six.moves.urllib.parse import unquote -from collections import Counter +from frappe.utils.user import is_system_user from frappe.contacts.doctype.contact.contact import get_contact_name +from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule exclude_from_linked_with = True @@ -119,7 +120,7 @@ class Communication(Document): update_comment_in_doc(self) if self.comment_type != 'Updated': - update_parent_mins_to_first_response(self) + update_parent_document_on_communication(self) self.bot_reply() def on_trash(self): @@ -258,7 +259,12 @@ class Communication(Document): # Timeline Links def set_timeline_links(self): - contacts = get_contacts([self.sender, self.recipients, self.cc, self.bcc]) + contacts = [] + if (self.email_account and frappe.db.get_value("Email Account", self.email_account, "create_contact")) or \ + frappe.flags.in_test: + + contacts = get_contacts([self.sender, self.recipients, self.cc, self.bcc]) + for contact_name in contacts: self.add_link('Contact', contact_name) @@ -423,3 +429,63 @@ def get_email_without_link(email): email_host = email.split("@")[1] return "{0}@{1}".format(email_id, email_host) + +def update_parent_document_on_communication(doc): + """Update mins_to_first_communication of parent document based on who is replying.""" + + parent = get_parent_doc(doc) + if not parent: + return + + # update parent mins_to_first_communication only if we create the Email communication + # ignore in case of only Comment is added + if doc.communication_type == "Comment": + return + + status_field = parent.meta.get_field("status") + if status_field: + options = (status_field.options or "").splitlines() + + # if status has a "Replied" option, then update the status for received communication + if ("Replied" in options) and doc.sent_or_received == "Received": + parent.db_set("status", "Open") + parent.run_method("handle_hold_time", "Replied") + apply_assignment_rule(parent) + else: + # update the modified date for document + parent.update_modified() + + update_mins_to_first_communication(parent, doc) + set_avg_response_time(parent, doc) + parent.run_method("notify_communication", doc) + parent.notify_update() + +def update_mins_to_first_communication(parent, communication): + if parent.meta.has_field("mins_to_first_response") and not parent.get("mins_to_first_response"): + if is_system_user(communication.sender): + first_responded_on = communication.creation + if parent.meta.has_field("first_responded_on") and communication.sent_or_received == "Sent": + parent.db_set("first_responded_on", first_responded_on) + parent.db_set("mins_to_first_response", round(time_diff_in_seconds(first_responded_on, parent.creation) / 60), 2) + +def set_avg_response_time(parent, communication): + if parent.meta.has_field("avg_response_time") and communication.sent_or_received == "Sent": + # avg response time for all the responses + communications = frappe.get_list("Communication", filters={ + "reference_doctype": parent.doctype, + "reference_name": parent.name + }, + fields=["sent_or_received", "name", "creation"], + order_by="creation" + ) + + if len(communications): + response_times = [] + for i in range(len(communications)): + if communications[i].sent_or_received == "Sent" and communications[i-1].sent_or_received == "Received": + response_time = round(time_diff_in_seconds(communications[i].creation, communications[i-1].creation), 2) + if response_time > 0: + response_times.append(response_time) + if response_times: + avg_response_time = sum(response_times) / len(response_times) + parent.db_set("avg_response_time", avg_response_time) \ No newline at end of file diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 8793c60934..daf64d4b8b 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -9,7 +9,7 @@ import json from email.utils import formataddr from frappe.core.utils import get_parent_doc from frappe.utils import (get_url, get_formatted_email, cint, - validate_email_address, split_emails, time_diff_in_seconds, parse_addr, get_datetime) + validate_email_address, split_emails, parse_addr, get_datetime) from frappe.email.email_body import get_message_id import frappe.email.smtp import time @@ -172,33 +172,6 @@ def _notify(doc, print_html=None, print_format=None, attachments=None, print_letterhead=frappe.flags.print_letterhead ) -def update_parent_mins_to_first_response(doc): - """Update mins_to_first_communication of parent document based on who is replying.""" - - parent = get_parent_doc(doc) - if not parent: - return - - # update parent mins_to_first_communication only if we create the Email communication - # ignore in case of only Comment is added - if doc.communication_type == "Comment": - return - - status_field = parent.meta.get_field("status") - if status_field: - options = (status_field.options or '').splitlines() - - # if status has a "Replied" option, then update the status for received communication - if ('Replied' in options) and doc.sent_or_received=="Received": - parent.db_set("status", "Open") - else: - # update the modified date for document - parent.update_modified() - - update_mins_to_first_communication(parent, doc) - parent.run_method('notify_communication', doc) - parent.notify_update() - def get_recipients_cc_and_bcc(doc, recipients, cc, bcc, fetched_from_email_account=False): doc.all_email_addresses = [] doc.sent_email_addresses = [] @@ -499,15 +472,6 @@ def sendmail(communication_name, print_html=None, print_format=None, attachments traceback = frappe.log_error("frappe.core.doctype.communication.email.sendmail") raise -def update_mins_to_first_communication(parent, communication): - if parent.meta.has_field('mins_to_first_response') and not parent.get('mins_to_first_response'): - if frappe.db.get_all('User', filters={'email': communication.sender, - 'user_type': 'System User', 'enabled': 1}, limit=1): - first_responded_on = communication.creation - if parent.meta.has_field('first_responded_on') and communication.sent_or_received == "Sent": - parent.db_set('first_responded_on', first_responded_on) - parent.db_set('mins_to_first_response', round(time_diff_in_seconds(first_responded_on, parent.creation) / 60), 2) - @frappe.whitelist(allow_guest=True) def mark_email_as_seen(name=None): try: diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index fb859586bb..6df90baaae 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -202,6 +202,8 @@ class TestCommunication(unittest.TestCase): self.assertIn(("Note", note.name), doc_links) def create_email_account(): + frappe.delete_doc_if_exists("Email Account", "_Test Comm Account 1") + frappe.flags.mute_emails = False frappe.flags.sent_mail = None diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 8e7516cd0a..aab59a5a0a 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -13,6 +13,8 @@ "fieldname", "precision", "length", + "hide_days", + "hide_seconds", "reqd", "search_index", "in_list_view", @@ -87,7 +89,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", "reqd": 1, "search_index": 1 }, @@ -450,6 +452,20 @@ "fieldname": "column_break_38", "fieldtype": "Column Break" }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, { "default": "0", "depends_on": "eval:doc.fieldtype=='Section Break'", @@ -461,7 +477,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2020-04-27 11:38:21.223185", + "modified": "2020-02-06 09:06:25.224413", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 904deb9990..6ca3cccdba 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -688,6 +688,9 @@ def validate_fields(meta): def check_link_table_options(docname, d): if frappe.flags.in_patch: return + + if frappe.flags.in_fixtures: return + if d.fieldtype in ("Link",) + table_fields: if not d.options: frappe.throw(_("{0}: Options required for Link or Table type field {1} in row {2}").format(docname, d.label, d.idx), DoctypeLinkError) @@ -908,6 +911,8 @@ def validate_fields(meta): frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True) def check_child_table_option(docfield): + + if frappe.flags.in_fixtures: return if docfield.fieldtype not in ['Table MultiSelect', 'Table']: return doctype = docfield.options diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b35abfa861..831d2ab22d 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: @@ -180,11 +182,11 @@ class File(Document): if duplicate_file: duplicate_file_doc = frappe.get_cached_doc('File', duplicate_file.name) if duplicate_file_doc.exists_on_disk(): - # if it is attached to a document then throw DuplicateEntryError + # if it is attached to a document then throw FileAlreadyAttachedException if self.attached_to_doctype and self.attached_to_name: self.duplicate_entry = duplicate_file.name frappe.throw(_("Same file has already been attached to the record"), - frappe.DuplicateEntryError) + frappe.FileAlreadyAttachedException) # else just use the url, to avoid uploading a duplicate else: self.file_url = duplicate_file.file_url @@ -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 @@ -704,7 +714,12 @@ def remove_all(dt, dn, from_delete=False): try: for fid in frappe.db.sql_list("""select name from `tabFile` where attached_to_doctype=%s and attached_to_name=%s""", (dt, dn)): - remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, from_delete=from_delete) + if from_delete: + # If deleting a doc, directly delete files + frappe.delete_doc("File", fid, ignore_permissions=True) + else: + # Removes file and adds a comment in the document it is attached to + remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, from_delete=from_delete) except Exception as e: if e.args[0]!=1054: raise # (temp till for patched) 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/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 0c5ebc3ede..7b9266ff64 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals, print_function import frappe from frappe.model.document import Document -from frappe.utils import cint, has_gravatar, format_datetime, now_datetime, get_formatted_email, today +from frappe.utils import cint, flt, has_gravatar, format_datetime, now_datetime, get_formatted_email, today from frappe import throw, msgprint, _ from frappe.utils.password import update_password as _update_password from frappe.desk.notifications import clear_notifications @@ -841,11 +841,11 @@ def user_query(doctype, txt, searchfield, start, page_len, filters): def get_total_users(): """Returns total no. of system users""" - return frappe.db.sql('''SELECT SUM(`simultaneous_sessions`) + return flt(frappe.db.sql('''SELECT SUM(`simultaneous_sessions`) FROM `tabUser` WHERE `enabled` = 1 AND `user_type` = 'System User' - AND `name` NOT IN ({})'''.format(", ".join(["%s"]*len(STANDARD_USERS))), STANDARD_USERS)[0][0] + AND `name` NOT IN ({})'''.format(", ".join(["%s"]*len(STANDARD_USERS))), STANDARD_USERS)[0][0]) def get_system_users(exclude_users=None, limit=None): if not exclude_users: diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 122e6c7070..6fa7b29161 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -16,6 +16,8 @@ "column_break_6", "fieldtype", "precision", + "hide_seconds", + "hide_days", "options", "fetch_from", "fetch_if_empty", @@ -56,368 +58,382 @@ ], "fields": [ { - "bold": 1, - "fieldname": "dt", - "fieldtype": "Link", - "in_filter": 1, - "in_list_view": 1, - "label": "Document", - "oldfieldname": "dt", - "oldfieldtype": "Link", - "options": "DocType", - "reqd": 1, - "search_index": 1 + "bold": 1, + "fieldname": "dt", + "fieldtype": "Link", + "in_filter": 1, + "in_list_view": 1, + "label": "Document", + "oldfieldname": "dt", + "oldfieldtype": "Link", + "options": "DocType", + "reqd": 1, + "search_index": 1 }, { - "bold": 1, - "fieldname": "label", - "fieldtype": "Data", - "in_filter": 1, - "label": "Label", - "no_copy": 1, - "oldfieldname": "label", - "oldfieldtype": "Data" + "bold": 1, + "fieldname": "label", + "fieldtype": "Data", + "in_filter": 1, + "label": "Label", + "no_copy": 1, + "oldfieldname": "label", + "oldfieldtype": "Data" }, { - "fieldname": "label_help", - "fieldtype": "HTML", - "label": "Label Help", - "oldfieldtype": "HTML" + "fieldname": "label_help", + "fieldtype": "HTML", + "label": "Label Help", + "oldfieldtype": "HTML" }, { - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Fieldname", - "no_copy": 1, - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "read_only": 1 + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Fieldname", + "no_copy": 1, + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "read_only": 1 }, { - "description": "Select the label after which you want to insert new field.", - "fieldname": "insert_after", - "fieldtype": "Select", - "label": "Insert After", - "no_copy": 1, - "oldfieldname": "insert_after", - "oldfieldtype": "Select" + "description": "Select the label after which you want to insert new field.", + "fieldname": "insert_after", + "fieldtype": "Select", + "label": "Insert After", + "no_copy": 1, + "oldfieldname": "insert_after", + "oldfieldtype": "Select" }, { - "fieldname": "column_break_6", - "fieldtype": "Column Break" + "fieldname": "column_break_6", + "fieldtype": "Column Break" }, { - "bold": 1, - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_filter": 1, - "in_list_view": 1, - "label": "Field Type", - "oldfieldname": "fieldtype", - "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nGeolocation\nHTML\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", - "reqd": 1 + "bold": 1, + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_filter": 1, + "in_list_view": 1, + "label": "Field Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nGeolocation\nHTML\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", + "reqd": 1 }, { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" }, { - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" }, { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" }, { - "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch If Empty" + "default": "0", + "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch If Empty" }, { - "fieldname": "options_help", - "fieldtype": "HTML", - "label": "Options Help", - "oldfieldtype": "HTML" + "fieldname": "options_help", + "fieldtype": "HTML", + "label": "Options Help", + "oldfieldtype": "HTML" }, { - "fieldname": "section_break_11", - "fieldtype": "Section Break" + "fieldname": "section_break_11", + "fieldtype": "Section Break" }, { - "default": "0", - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible" + "default": "0", + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible" }, { - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On" + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On" }, { - "fieldname": "default", - "fieldtype": "Text", - "label": "Default Value", - "oldfieldname": "default", - "oldfieldtype": "Text" + "fieldname": "default", + "fieldtype": "Text", + "label": "Default Value", + "oldfieldname": "default", + "oldfieldtype": "Text" }, { - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Depends On", - "length": 255 + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Depends On", + "length": 255 }, { - "fieldname": "description", - "fieldtype": "Text", - "label": "Field Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" + "fieldname": "description", + "fieldtype": "Text", + "label": "Field Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" }, { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "label": "Permission Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int" + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "label": "Permission Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int" }, { - "fieldname": "width", - "fieldtype": "Data", - "label": "Width", - "oldfieldname": "width", - "oldfieldtype": "Data" + "fieldname": "width", + "fieldtype": "Data", + "label": "Width", + "oldfieldname": "width", + "oldfieldtype": "Data" }, { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" + "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" }, { - "fieldname": "properties", - "fieldtype": "Column Break", - "oldfieldtype": "Column Break", - "print_width": "50%", - "width": "50%" + "fieldname": "properties", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", + "print_width": "50%", + "width": "50%" }, { - "default": "0", - "fieldname": "reqd", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Is Mandatory Field", - "oldfieldname": "reqd", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "reqd", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Mandatory Field", + "oldfieldname": "reqd", + "oldfieldtype": "Check" }, { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" }, { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only" + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only" }, { - "default": "0", - "depends_on": "eval:doc.fieldtype===\"Link\"", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Link\"", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" }, { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden" + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden" }, { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check" }, { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" }, { - "fieldname": "print_width", - "fieldtype": "Data", - "hidden": 1, - "label": "Print Width", - "no_copy": 1, - "print_hide": 1 + "fieldname": "print_width", + "fieldtype": "Data", + "hidden": 1, + "label": "Print Width", + "no_copy": 1, + "print_hide": 1 }, { - "default": "0", - "fieldname": "no_copy", - "fieldtype": "Check", - "label": "No Copy", - "oldfieldname": "no_copy", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy", + "oldfieldname": "no_copy", + "oldfieldtype": "Check" }, { - "default": "0", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check" }, { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View" + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View" }, { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In Standard Filter" + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In Standard Filter" }, { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" }, { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" }, { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check" }, { - "default": "0", - "fieldname": "search_index", - "fieldtype": "Check", - "hidden": 1, - "label": "Index", - "no_copy": 1, - "print_hide": 1 + "default": "0", + "fieldname": "search_index", + "fieldtype": "Check", + "hidden": 1, + "label": "Index", + "no_copy": 1, + "print_hide": 1 }, { - "default": "0", - "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", - "fieldname": "ignore_xss_filter", - "fieldtype": "Check", - "label": "Ignore XSS Filter" + "default": "0", + "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", + "fieldname": "ignore_xss_filter", + "fieldtype": "Check", + "label": "Ignore XSS Filter" }, { - "default": "1", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" + "default": "1", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" }, { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" }, { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On", - "length": 255 + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "length": 255 }, { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On", - "length": 255 + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "length": 255 }, { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" }, { - "default": "0", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" + "default": "0", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" }, { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "fieldname": "hide_border", - "fieldtype": "Check", - "label": "Hide Border" + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" } ], "icon": "fa fa-glass", "idx": 1, "links": [], - "modified": "2020-04-27 11:40:48.325481", + "modified": "2020-02-06 23:43:00.123575", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", "owner": "Administrator", "permissions": [ { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "share": 1, - "write": 1 + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 }, { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 } ], "search_fields": "dt,label,fieldtype,options", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 21679c5bc7..a24777a80a 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -46,6 +46,9 @@ class CustomField(Document): if not self.fieldname: frappe.throw(_("Fieldname not set for Custom Field")) + if self.fieldname in fieldnames: + frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt)) + if self.get('translatable', 0) and not supports_translation(self.fieldtype): self.translatable = 0 diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 6a54d9c7e6..d4eeba3f93 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -77,7 +77,9 @@ docfield_properties = { 'allow_bulk_edit': 'Check', 'auto_repeat': 'Link', 'allow_in_quick_entry': 'Check', - 'hide_border': 'Check' + 'hide_border': 'Check', + 'hide_days': 'Check', + 'hide_seconds': 'Check' } allowed_fieldtype_change = (('Currency', 'Float', 'Percent'), ('Small Text', 'Data'), diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 2c5fb874f7..267213517c 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -11,6 +11,8 @@ "label", "fieldtype", "fieldname", + "hide_seconds", + "hide_days", "reqd", "unique", "in_list_view", @@ -58,350 +60,364 @@ ], "fields": [ { - "fieldname": "label_and_type", - "fieldtype": "Section Break", - "label": "Label and Type" + "fieldname": "label_and_type", + "fieldtype": "Section Break", + "label": "Label and Type" }, { - "fieldname": "label", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Label", - "oldfieldname": "label", - "oldfieldtype": "Data", - "search_index": 1 + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "oldfieldname": "label", + "oldfieldtype": "Data", + "search_index": 1 }, { - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Type", - "oldfieldname": "fieldtype", - "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", - "reqd": 1, - "search_index": 1 + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "reqd": 1, + "search_index": 1 }, { - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Name", - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "read_only": 1, - "search_index": 1 + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Name", + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "read_only": 1, + "search_index": 1 }, { - "default": "0", - "depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", - "fieldname": "reqd", - "fieldtype": "Check", - "label": "Mandatory", - "oldfieldname": "reqd", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" + "default": "0", + "depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", + "fieldname": "reqd", + "fieldtype": "Check", + "label": "Mandatory", + "oldfieldname": "reqd", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" }, { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" }, { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View" + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View" }, { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In Standard Filter" + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In Standard Filter" }, { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" }, { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" }, { - "default": "1", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" + "default": "1", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" }, { - "fieldname": "column_break_7", - "fieldtype": "Column Break" + "fieldname": "column_break_7", + "fieldtype": "Column Break" }, { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" }, { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" }, { - "description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.", - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" + "description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.", + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" }, { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" }, { - "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch If Empty" + "default": "0", + "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch If Empty" }, { - "fieldname": "permissions", - "fieldtype": "Section Break", - "label": "Permissions" + "fieldname": "permissions", + "fieldtype": "Section Break", + "label": "Permissions" }, { - "description": "This field will appear only if the fieldname defined here has value OR the rules are true (examples): \nmyfield\neval:doc.myfield=='My Value'\neval:doc.age>18", - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Depends On", - "oldfieldname": "depends_on", - "oldfieldtype": "Data", - "options": "JS" + "description": "This field will appear only if the fieldname defined here has value OR the rules are true (examples): \nmyfield\neval:doc.myfield=='My Value'\neval:doc.age>18", + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Depends On", + "oldfieldname": "depends_on", + "oldfieldtype": "Data", + "options": "JS" }, { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "in_list_view": 1, - "label": "Perm Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int" + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Perm Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int" }, { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden", - "oldfieldname": "hidden", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden", + "oldfieldname": "hidden", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" }, { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only" + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only" }, { - "default": "0", - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible" + "default": "0", + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible" }, { - "default": "0", - "depends_on": "eval: doc.fieldtype == \"Table\"", - "fieldname": "allow_bulk_edit", - "fieldtype": "Check", - "label": "Allow Bulk Edit" + "default": "0", + "depends_on": "eval: doc.fieldtype == \"Table\"", + "fieldname": "allow_bulk_edit", + "fieldtype": "Check", + "label": "Allow Bulk Edit" }, { - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On", - "options": "JS" + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On", + "options": "JS" }, { - "fieldname": "column_break_14", - "fieldtype": "Column Break" + "fieldname": "column_break_14", + "fieldtype": "Column Break" }, { - "default": "0", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" + "default": "0", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" }, { - "default": "0", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check" }, { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check" }, { - "default": "0", - "depends_on": "eval:(doc.fieldtype == 'Link')", - "fieldname": "remember_last_selected_value", - "fieldtype": "Check", - "label": "Remember Last Selected Value" + "default": "0", + "depends_on": "eval:(doc.fieldtype == 'Link')", + "fieldname": "remember_last_selected_value", + "fieldtype": "Check", + "label": "Remember Last Selected Value" }, { - "fieldname": "display", - "fieldtype": "Section Break", - "label": "Display" + "fieldname": "display", + "fieldtype": "Section Break", + "label": "Display" }, { - "fieldname": "default", - "fieldtype": "Text", - "label": "Default", - "oldfieldname": "default", - "oldfieldtype": "Text" + "fieldname": "default", + "fieldtype": "Text", + "label": "Default", + "oldfieldname": "default", + "oldfieldtype": "Text" }, { - "default": "0", - "fieldname": "in_filter", - "fieldtype": "Check", - "label": "In Filter", - "oldfieldname": "in_filter", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" + "default": "0", + "fieldname": "in_filter", + "fieldtype": "Check", + "label": "In Filter", + "oldfieldname": "in_filter", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" }, { - "fieldname": "column_break_21", - "fieldtype": "Column Break" + "fieldname": "column_break_21", + "fieldtype": "Column Break" }, { - "fieldname": "description", - "fieldtype": "Text", - "label": "Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" + "fieldname": "description", + "fieldtype": "Text", + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" }, { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check" + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check" }, { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" }, { - "description": "Print Width of the field, if the field is a column in a table", - "fieldname": "print_width", - "fieldtype": "Data", - "label": "Print Width", - "print_width": "50px", - "width": "50px" + "description": "Print Width of the field, if the field is a column in a table", + "fieldname": "print_width", + "fieldtype": "Data", + "label": "Print Width", + "print_width": "50px", + "width": "50px" }, { - "depends_on": "eval:cur_frm.doc.istable", - "description": "Number of columns for a field in a Grid (Total Columns in a grid should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" + "depends_on": "eval:cur_frm.doc.istable", + "description": "Number of columns for a field in a Grid (Total Columns in a grid should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" }, { - "fieldname": "width", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Width", - "oldfieldname": "width", - "oldfieldtype": "Data", - "print_width": "50px", - "width": "50px" + "fieldname": "width", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Width", + "oldfieldname": "width", + "oldfieldtype": "Data", + "print_width": "50px", + "width": "50px" }, { - "default": "0", - "fieldname": "is_custom_field", - "fieldtype": "Check", - "hidden": 1, - "label": "Is Custom Field", - "read_only": 1 + "default": "0", + "fieldname": "is_custom_field", + "fieldtype": "Check", + "hidden": 1, + "label": "Is Custom Field", + "read_only": 1 }, { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" }, { - "fieldname": "property_depends_on_section", - "fieldtype": "Section Break", - "label": "Property Depends On" + "fieldname": "property_depends_on_section", + "fieldtype": "Section Break", + "label": "Property Depends On" }, { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On", - "options": "JS" + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "options": "JS" }, { - "fieldname": "column_break_33", - "fieldtype": "Column Break" + "fieldname": "column_break_33", + "fieldtype": "Column Break" }, { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On", - "options": "JS" + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "options": "JS" }, { - "default": "0", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" + "default": "0", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" }, { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "fieldname": "hide_border", - "fieldtype": "Check", - "label": "Hide Border" + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-04-27 11:39:26.389300", + "modified": "2020-06-02 23:45:46.810868", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index cd053569f0..4ec89c126d 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -55,7 +55,8 @@ class MariaDBDatabase(Database): 'Signature': ('longtext', ''), 'Color': ('varchar', self.VARCHAR_LEN), 'Barcode': ('longtext', ''), - 'Geolocation': ('longtext', '') + 'Geolocation': ('longtext', ''), + 'Duration': ('decimal', '18,6') } def get_connection(self): diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index bd93069a3f..af537e0612 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -64,6 +64,8 @@ CREATE TABLE `tabDocField` ( `length` int(11) NOT NULL DEFAULT 0, `translatable` int(1) NOT NULL DEFAULT 0, `hide_border` int(1) NOT NULL DEFAULT 0, + `hide_days` int(1) NOT NULL DEFAULT 0, + `hide_seconds` int(1) NOT NULL DEFAULT 0, PRIMARY KEY (`name`), KEY `parent` (`parent`), KEY `label` (`label`), diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index e30ef3293f..e348916705 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -60,7 +60,8 @@ class PostgresDatabase(Database): 'Signature': ('text', ''), 'Color': ('varchar', self.VARCHAR_LEN), 'Barcode': ('text', ''), - 'Geolocation': ('text', '') + 'Geolocation': ('text', ''), + 'Duration': ('decimal', '18,6') } def get_connection(self): diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 76309e7347..8f77ed6230 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -64,6 +64,8 @@ CREATE TABLE "tabDocField" ( "length" bigint NOT NULL DEFAULT 0, "translatable" smallint NOT NULL DEFAULT 0, "hide_border" smallint NOT NULL DEFAULT 0, + "hide_days" smallint NOT NULL DEFAULT 0, + "hide_seconds" smallint NOT NULL DEFAULT 0, PRIMARY KEY ("name") ) ; diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 6ca101c3a8..956308568b 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -8,12 +8,26 @@ from json import loads, dumps from frappe import _, DoesNotExistError, ValidationError, _dict from frappe.boot import get_allowed_pages, get_allowed_reports from six import string_types +from functools import wraps from frappe.cache_manager import ( build_domain_restriced_doctype_cache, build_domain_restriced_page_cache, build_table_count_cache ) +def handle_not_exist(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except DoesNotExistError: + if frappe.message_log: + frappe.message_log.pop() + return [] + + return wrapper + + class Workspace: def __init__(self, page_name): self.page_name = page_name @@ -157,7 +171,7 @@ class Workspace: 'user_can_dismiss': self.onboarding_doc.user_can_dismiss, 'items': self.get_onboarding_steps() } - + @handle_not_exist def get_cards(self): cards = self.doc.cards if not self.doc.hide_custom: @@ -169,8 +183,8 @@ class Workspace: def _doctype_contains_a_record(name): exists = self.table_counts.get(name, None) - if exists is None: - if not frappe.db.get_value('DocType', name, 'issingle', cache=True): + if not exists: + if not frappe.db.get_value('DocType', name, 'issingle'): exists = frappe.db.count(name) else: exists = True @@ -227,6 +241,7 @@ class Workspace: return new_data + @handle_not_exist def get_charts(self): all_charts = [] if frappe.has_permission("Dashboard Chart", throw=False): @@ -242,6 +257,7 @@ class Workspace: return all_charts + @handle_not_exist def get_shortcuts(self): def _in_active_domains(item): @@ -272,6 +288,7 @@ class Workspace: return items + @handle_not_exist def get_onboarding_steps(self): steps = [] for doc in self.onboarding_doc.get_steps(): @@ -296,21 +313,15 @@ def get_desktop_page(page): Returns: dict: dictionary of cards, charts and shortcuts to be displayed on website """ - try: - wspace = Workspace(page) - wspace.build_workspace() - return { - 'charts': wspace.charts, - 'shortcuts': wspace.shortcuts, - 'cards': wspace.cards, - 'onboarding': wspace.onboarding, - 'allow_customization': not wspace.doc.disable_user_customization - } - - except DoesNotExistError: - if frappe.message_log: - frappe.message_log.pop() - return None + wspace = Workspace(page) + wspace.build_workspace() + return { + 'charts': wspace.charts, + 'shortcuts': wspace.shortcuts, + 'cards': wspace.cards, + 'onboarding': wspace.onboarding, + 'allow_customization': not wspace.doc.disable_user_customization + } @frappe.whitelist() def get_desk_sidebar_items(flatten=False): diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index e2be095fce..a10d3d96f2 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -251,6 +251,7 @@ frappe.ui.form.on('Dashboard Chart', { render_filters_table: function(frm) { frm.set_df_property("filters_section", "hidden", 0); let is_document_type = frm.doc.chart_type!== 'Report' && frm.doc.chart_type!=='Custom'; + let is_dynamic_filter = f => ['Date', 'DateRange'].includes(f.fieldtype) && f.default; let wrapper = $(frm.get_field('filters_json').wrapper).empty(); let table = $(` @@ -268,6 +269,18 @@ frappe.ui.form.on('Dashboard Chart', { let filters = JSON.parse(frm.doc.filters_json || '[]'); var filters_set = false; + // Set dynamic filters for reports + if (frm.doc.chart_type == 'Report') { + let set_filters = false; + frm.chart_filters.forEach(f => { + if (is_dynamic_filter(f)) { + filters[f.fieldname] = f.default; + set_filters = true; + } + }); + set_filters && frm.set_value('filters_json', JSON.stringify(filters)); + } + let fields; if (is_document_type) { fields = [ @@ -292,6 +305,7 @@ frappe.ui.form.on('Dashboard Chart', { } } else if (frm.chart_filters.length) { fields = frm.chart_filters.filter(f => f.fieldname); + fields.map( f => { if (filters[f.fieldname]) { let condition = '='; @@ -318,7 +332,7 @@ frappe.ui.form.on('Dashboard Chart', { let dialog = new frappe.ui.Dialog({ title: __('Set Filters'), - fields: fields, + fields: fields.filter(f => !is_dynamic_filter(f)), primary_action: function() { let values = this.get_values(); if (values) { @@ -351,8 +365,15 @@ frappe.ui.form.on('Dashboard Chart', { } dialog.show(); - //Set query report object so that it can be used while fetching filter values in the report - frappe.query_report = new frappe.views.QueryReport({'filters': dialog.fields_list}); + + if (frm.doc.chart_type == 'Report') { + //Set query report object so that it can be used while fetching filter values in the report + frappe.query_report = new frappe.views.QueryReport({'filters': dialog.fields_list}); + frappe.query_reports[frm.doc.report_name] + && frappe.query_reports[frm.doc.report_name].onload + && frappe.query_reports[frm.doc.report_name].onload(frappe.query_report); + } + dialog.set_values(filters); }); }, diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 6cb8f8bfd9..ab1863ca0b 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -137,7 +137,6 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): to_date = datetime.datetime.now() doctype = chart.document_type - unit_function = get_unit_function(doctype, chart.based_on, timegrain) datefield = chart.based_on aggregate_function = get_aggregate_function(chart.chart_type) value_field = chart.value_based_on or '1' @@ -150,23 +149,18 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): data = frappe.db.get_list( doctype, fields = [ - 'extract(year from `tab{doctype}`.{datefield}) as _year'.format(doctype=doctype, datefield=datefield), - '{} as _unit'.format(unit_function), + '{} as _unit'.format(datefield), '{aggregate_function}({value_field})'.format(aggregate_function=aggregate_function, value_field=value_field), ], filters = filters, - group_by = '_year, _unit', - order_by = '_year asc, _unit asc', + group_by = '_unit', + order_by = '_unit asc', as_list = True, ignore_ifnull = True ) + result = get_result(data, timegrain, from_date, to_date) - # result given as year, unit -> convert it to end of period of that unit - result = convert_to_dates(data, timegrain) - - # add missing data points for periods where there was no result - result = add_missing_values(result, timegrain, timespan, from_date, to_date) chart_config = { "labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result], "datasets": [{ @@ -261,75 +255,22 @@ def get_aggregate_function(chart_type): }[chart_type] -def convert_to_dates(data, timegrain): - """ Converts individual dates within data to the end of period """ - result = [] - for d in data: - if d[2] != 0: - if timegrain == 'Daily': - result.append([add_to_date('{:d}-01-01'.format(int(d[0])), days = d[1] - 1), d[2]]) - elif timegrain == 'Weekly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), weeks = d[1] + 1), days = -1), d[2]]) - elif timegrain == 'Monthly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), months=d[1]), days = -1), d[2]]) - elif timegrain == 'Quarterly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), months=d[1] * 3), days = -1), d[2]]) - elif timegrain == 'Yearly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), months=12), days = -1), d[2]]) - result[-1][0] = getdate(result[-1][0]) - - return result - -def get_unit_function(doctype, datefield, timegrain): - unit_function = '' - if timegrain=='Daily': - if frappe.db.db_type == 'mariadb': - unit_function = 'dayofyear(`tab{doctype}`.{datefield})'.format( - doctype=doctype, datefield=datefield) - else: - unit_function = 'extract(doy from `tab{doctype}`.{datefield})'.format( - doctype=doctype, datefield=datefield) - - else: - unit_function = 'extract({unit} from `tab{doctype}`.{datefield})'.format( - unit = timegrain[:-2].lower(), doctype=doctype, datefield=datefield) - - return unit_function - -def add_missing_values(data, timegrain, timespan, from_date, to_date): - # add missing intervals +def get_result(data, timegrain, from_date, to_date): + start_date = getdate(from_date) + end_date = getdate(to_date) result = [] - if timespan != 'All Time': - first_expected_date = get_period_ending(from_date, timegrain) - # fill out data before the first data point - first_data_point_date = data[0][0] if data else getdate(add_to_date(to_date, days=1)) - while first_data_point_date > first_expected_date: - result.append([first_expected_date, 0.0]) - first_expected_date = get_next_expected_date(first_expected_date, timegrain) + while start_date <= end_date: + next_date = get_next_expected_date(start_date, timegrain) + result.append([next_date, 0.0]) + start_date = next_date - # fill data points and missing points - for i, d in enumerate(data): - result.append(d) - - next_expected_date = get_next_expected_date(d[0], timegrain) - - if i < len(data)-1: - next_date = data[i+1][0] - else: - # already reached at end of data, see if we need any more dates - next_date = getdate(nowdate()) - - # if next data point is earler than the expected date - # need to fill out missing data points - while next_date > next_expected_date: - # fill missing value - result.append([next_expected_date, 0.0]) - next_expected_date = get_next_expected_date(next_expected_date, timegrain) - - # add date for the last period (if missing) - if result and get_period_ending(to_date, timegrain) > result[-1][0]: - result.append([get_period_ending(to_date, timegrain), 0.0]) + data_index = 0 + if data: + for i, d in enumerate(result): + while data_index < len(data) and getdate(data[data_index][0]) <= d[0]: + d[1] += data[data_index][1] + data_index += 1 return result @@ -358,17 +299,12 @@ def get_period_ending(date, timegrain): return getdate(date) def get_week_ending(date): - # fun fact: week ends on the day before 1st Jan of the year. - # for 2019 it is Monday + # week starts on monday + from datetime import timedelta + start = date - timedelta(days = date.weekday()) + end = start + timedelta(days=6) - week_of_the_year = int(date.strftime('%U')) - - if week_of_the_year == 52: - date = add_to_date(date, years=1) - # first day of next week - date = add_to_date('{}-01-01'.format(date.year), weeks = (week_of_the_year%52) + 1) - # last day of this week - return add_to_date(date, days=-1) + return end def get_month_ending(date): month_of_the_year = int(date.strftime('%m')) diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 4425c4fd45..dfc6edbf58 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -17,10 +17,9 @@ class TestDashboardChart(unittest.TestCase): self.assertEqual(get_period_ending('2019-04-10', 'Daily'), getdate('2019-04-10')) - # fun fact: week ends on the day before 1st Jan of the year. - # for 2019 it is Monday + # week starts on monday self.assertEqual(get_period_ending('2019-04-10', 'Weekly'), - getdate('2019-04-15')) + getdate('2019-04-14')) self.assertEqual(get_period_ending('2019-04-10', 'Monthly'), getdate('2019-04-30')) @@ -133,6 +132,34 @@ class TestDashboardChart(unittest.TestCase): frappe.db.rollback() + def test_weekly_dashboard_chart(self): + insert_test_records() + + if frappe.db.exists('Dashboard Chart', 'Test Weekly Dashboard Chart'): + frappe.delete_doc('Dashboard Chart', 'Test Weekly Dashboard Chart') + + frappe.get_doc(dict( + doctype = 'Dashboard Chart', + chart_name = 'Test Weekly Dashboard Chart', + chart_type = 'Sum', + document_type = 'Communication', + based_on = 'communication_date', + value_based_on = 'rating', + timespan = 'Select Date Range', + time_interval = 'Weekly', + from_date = datetime(2018, 12, 30), + to_date = datetime(2019, 1, 15), + filters_json = '[]', + timeseries = 1 + )).insert() + + result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) + + self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 0.0]) + self.assertEqual(result.get('labels'), [formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')]) + + frappe.db.rollback() + def test_group_by_chart_type(self): if frappe.db.exists('Dashboard Chart', 'Test Group By Dashboard Chart'): frappe.delete_doc('Dashboard Chart', 'Test Group By Dashboard Chart') @@ -155,17 +182,16 @@ class TestDashboardChart(unittest.TestCase): frappe.db.rollback() - def test_dashboard_with_single_doctype(self): - if frappe.db.exists('Dashboard Chart', 'Test Single DocType In Dashboard Chart'): - frappe.delete_doc('Dashboard Chart', 'Test Single DocType In Dashboard Chart') +def insert_test_records(): + create_new_communication(datetime(2019, 1, 10), 100) + create_new_communication(datetime(2019, 1, 6), 200) + create_new_communication(datetime(2019, 1, 8), 300) - chart_doc = frappe.get_doc(dict( - doctype = 'Dashboard Chart', - chart_name = 'Test Single DocType In Dashboard Chart', - chart_type = 'Count', - document_type = 'System Settings', - group_by_based_on = 'Created On', - filters_json = '{}', - )) - - self.assertRaises(frappe.ValidationError, chart_doc.insert) +def create_new_communication(date, rating): + communication = { + 'doctype': 'Communication', + 'subject': 'Test Communication', + 'rating': rating, + 'communication_date': date + } + frappe.get_doc(communication).insert() 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(` +
+ +
+ `); + + $wrapper.find(".attached-file-link").click(() => { + const w = window.open( + frappe.urllib.get_full_url(`/api/method/frappe.utils.print_format.download_pdf? + doctype=${encodeURIComponent(attachment.doctype)} + &name=${encodeURIComponent(attachment.name)} + &format=${encodeURIComponent(attachment.print_format)} + &lang=${encodeURIComponent(attachment.lang)}`) + ); + if (!w) { + frappe.msgprint(__("Please enable pop-ups")); + } + }); } }); diff --git a/frappe/desk/doctype/notification_log/notification_log.json b/frappe/desk/doctype/notification_log/notification_log.json index ecb746df64..050bf85ead 100644 --- a/frappe/desk/doctype/notification_log/notification_log.json +++ b/frappe/desk/doctype/notification_log/notification_log.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-08-26 13:37:34.165254", "doctype": "DocType", "editable_grid": 1, @@ -8,10 +9,12 @@ "for_user", "type", "email_content", - "column_break_4", "document_type", "read", "document_name", + "attached_file", + "attachment_link", + "open_reference_document", "from_user" ], "fields": [ @@ -20,57 +23,65 @@ "fieldtype": "Text", "in_list_view": 1, "label": "Subject", - "read_only": 1 + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "for_user", "fieldtype": "Link", + "hidden": 1, "label": "For User", "options": "User", - "read_only": 1 + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "type", "fieldtype": "Select", + "hidden": 1, "in_list_view": 1, "in_standard_filter": 1, "label": "Type", - "options": "Mention\nEnergy Point\nAssignment\nShare", - "read_only": 1, - "search_index": 1 + "options": "Mention\nEnergy Point\nAssignment\nShare\nAlert", + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "email_content", - "fieldtype": "Text", - "label": "Email Content", - "read_only": 1 - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" + "fieldtype": "Text Editor", + "label": "Message", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "document_type", "fieldtype": "Link", + "hidden": 1, "label": "Document Type", "options": "DocType", - "read_only": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "document_name", "fieldtype": "Data", - "label": "Document Name", - "read_only": 1, - "search_index": 1 + "hidden": 1, + "label": "Document Link", + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "from_user", "fieldtype": "Link", + "hidden": 1, "label": "From User", "options": "User", - "read_only": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", @@ -78,26 +89,51 @@ "fieldtype": "Check", "hidden": 1, "ignore_user_permissions": 1, - "label": "Read" + "label": "Read", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "open_reference_document", + "fieldtype": "Button", + "label": "Open Reference Document", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "attached_file", + "fieldtype": "Code", + "hidden": 1, + "label": "Attached File", + "options": "JSON", + "show_days": 1, + "show_seconds": 1 + }, + { + "fieldname": "attachment_link", + "fieldtype": "HTML", + "label": "Attachment Link", + "show_days": 1, + "show_seconds": 1 } ], + "hide_toolbar": 1, "in_create": 1, - "modified": "2019-11-12 15:22:35.283678", + "links": [], + "modified": "2020-05-31 22:31:12.886950", "modified_by": "umair@erpnext.com", "module": "Desk", "name": "Notification Log", "owner": "Administrator", "permissions": [ { - "create": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "All", - "share": 1, - "write": 1 + "share": 1 } ], "sort_field": "modified", diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index 17eb6371b1..211b3ae5e6 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -48,6 +48,7 @@ def enqueue_create_notification(users, doc): if isinstance(users, frappe.string_types): users = [user.strip() for user in users.split(',') if user.strip()] + users = list(set(users)) frappe.enqueue( 'frappe.desk.doctype.notification_log.notification_log.make_notification_logs', @@ -58,6 +59,7 @@ def enqueue_create_notification(users, doc): def make_notification_logs(doc, users): from frappe.social.doctype.energy_point_settings.energy_point_settings import is_energy_point_enabled + for user in users: if frappe.db.exists('User', user): if is_notifications_enabled(user): @@ -68,7 +70,7 @@ def make_notification_logs(doc, users): _doc.update(doc) _doc.for_user = user _doc.subject = _doc.subject.replace('
', '').replace('
', '') - if _doc.for_user != _doc.from_user or doc.type == 'Energy Point': + if _doc.for_user != _doc.from_user or doc.type == 'Energy Point' or doc.type == 'Alert': _doc.insert(ignore_permissions=True) def send_notification_email(doc): diff --git a/frappe/desk/doctype/notification_settings/notification_settings.json b/frappe/desk/doctype/notification_settings/notification_settings.json index 6af325507b..85f93e156e 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.json +++ b/frappe/desk/doctype/notification_settings/notification_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "Prompt", "creation": "2019-09-11 22:15:44.851526", "doctype": "DocType", @@ -21,52 +22,68 @@ "default": "1", "fieldname": "enabled", "fieldtype": "Check", - "label": "Enabled" + "label": "Enabled", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "subscribed_documents", "fieldtype": "Table MultiSelect", "label": "Subscribed Documents", - "options": "Notification Subscribed Document" + "options": "Notification Subscribed Document", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_3", "fieldtype": "Section Break", - "label": "Email Settings" + "label": "Email Settings", + "show_days": 1, + "show_seconds": 1 }, { "default": "1", "fieldname": "enable_email_notifications", "fieldtype": "Check", - "label": "Enable Email Notifications" + "label": "Enable Email Notifications", + "show_days": 1, + "show_seconds": 1 }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_mention", "fieldtype": "Check", - "label": "Mentions" + "label": "Mentions", + "show_days": 1, + "show_seconds": 1 }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_assignment", "fieldtype": "Check", - "label": "Assignments" + "label": "Assignments", + "show_days": 1, + "show_seconds": 1 }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_energy_point", "fieldtype": "Check", - "label": "Energy Points" + "label": "Energy Points", + "show_days": 1, + "show_seconds": 1 }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_share", "fieldtype": "Check", - "label": "Document Share" + "label": "Document Share", + "show_days": 1, + "show_seconds": 1 }, { "default": "__user", @@ -75,18 +92,23 @@ "hidden": 1, "label": "User", "options": "User", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "seen", "fieldtype": "Check", "hidden": 1, - "label": "Seen" + "label": "Seen", + "show_days": 1, + "show_seconds": 1 } ], "in_create": 1, - "modified": "2019-11-19 12:57:59.356786", + "links": [], + "modified": "2020-05-31 22:16:40.798019", "modified_by": "Administrator", "module": "Desk", "name": "Notification Settings", diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py index 6b5a13ee27..9b124cd6f4 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.py +++ b/frappe/desk/doctype/notification_settings/notification_settings.py @@ -28,6 +28,9 @@ def is_email_notifications_enabled_for_type(user, notification_type): if not is_email_notifications_enabled(user): return False + if notification_type == 'Alert': + return False + fieldname = 'enable_email_' + frappe.scrub(notification_type) enabled = frappe.db.get_value('Notification Settings', user, fieldname) if enabled is None: diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 8e8102d093..804174b56b 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -64,7 +64,7 @@ class ToDo(Document): filters={ "reference_type": self.reference_type, "reference_name": self.reference_name, - "status": "Open" + "status": ("!=", "Cancelled") }, fields=["owner"], as_list=True)] diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 6c679bf312..72917d0341 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -13,7 +13,7 @@ from frappe.modules import load_doctype_module @frappe.whitelist() -def get_submitted_linked_docs(doctype, name, docs=None): +def get_submitted_linked_docs(doctype, name, docs=None, linked=None): """ Get all nested submitted linked doctype linkinfo @@ -31,12 +31,26 @@ def get_submitted_linked_docs(doctype, name, docs=None): if not docs: docs = [] + if not linked: + linked = {} + linkinfo = get_linked_doctypes(doctype) linked_docs = get_linked_docs(doctype, name, linkinfo) link_count = 0 for link_doctype, link_names in linked_docs.items(): + if link_doctype not in linked: + linked[link_doctype] = [] + for link in link_names: + if link['name'] == name: + continue + + if linked and name in linked[link_doctype]: + continue + + linked[link_doctype].append(link['name']) + docinfo = link.update({"doctype": link_doctype}) validated_doc = validate_linked_doc(docinfo) @@ -47,7 +61,7 @@ def get_submitted_linked_docs(doctype, name, docs=None): if link.name in [doc.get("name") for doc in docs]: continue - links = get_submitted_linked_docs(link_doctype, link.name, docs) + links = get_submitted_linked_docs(link_doctype, link.name, docs, linked) docs.append({ "doctype": link_doctype, "name": link.name, diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index e7f56d313e..1bce14fb2d 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -7,17 +7,16 @@ import frappe @frappe.whitelist() def get_list_settings(doctype): try: - return frappe.get_cached_doc("List View Setting", doctype) + return frappe.get_cached_doc("List View Settings", doctype) except frappe.DoesNotExistError: frappe.clear_messages() - @frappe.whitelist() def set_list_settings(doctype, values): try: - doc = frappe.get_doc("List View Setting", doctype) + doc = frappe.get_doc("List View Settings", doctype) except frappe.DoesNotExistError: - doc = frappe.new_doc("List View Setting") + doc = frappe.new_doc("List View Settings") doc.name = doctype frappe.clear_messages() doc.update(frappe.parse_json(values)) diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 4a1302788b..4b584a2429 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -252,7 +252,7 @@ def get_open_count(doctype, name, items=[]): continue filters = get_filters_for(d) - fieldname = links.get("non_standard_fieldnames", {}).get(d, links.fieldname) + fieldname = links.get("non_standard_fieldnames", {}).get(d, links.get('fieldname')) data = {"name": d} if filters: # get the fieldname for the current document diff --git a/frappe/desk/page/user_profile/user_profile.js b/frappe/desk/page/user_profile/user_profile.js index c43ff27ba3..646c31f7a1 100644 --- a/frappe/desk/page/user_profile/user_profile.js +++ b/frappe/desk/page/user_profile/user_profile.js @@ -110,7 +110,11 @@ class UserProfile { render_line_chart() { - this.line_chart_filters = [['Energy Point Log', 'user', '=', this.user_id, false]]; + this.line_chart_filters = [ + ['Energy Point Log', 'user', '=', this.user_id, false], + ['Energy Point Log', 'type', '!=', 'Review', false] + ]; + this.line_chart_config = { timespan: 'Last Month', time_interval: 'Daily', @@ -186,7 +190,10 @@ class UserProfile { options: ['All', 'Auto', 'Criticism', 'Appreciation', 'Revert'], action: (selected_item) => { if (selected_item === 'All') { - if (this.line_chart_filters.length > 1) this.line_chart_filters.pop(); + this.line_chart_filters = [ + ['Energy Point Log', 'user', '=', this.user_id, false], + ['Energy Point Log', 'type', '!=', 'Review', false] + ]; } else { this.line_chart_filters[1] = ['Energy Point Log', 'type', '=', selected_item, false]; } diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 74e841f107..0edfd57d4f 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -62,8 +62,16 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) ljust_list(res, 6) if report.custom_columns: + # Original query columns, needed to reorder data as per custom columns + query_columns = columns + # Reordered columns columns = json.loads(report.custom_columns) + + if report.report_type == 'Query Report': + result = reorder_data_for_custom_columns(columns, query_columns, result) + result = add_data_to_custom_columns(columns, result) + if custom_columns: result = add_data_to_custom_columns(custom_columns, result) @@ -208,6 +216,23 @@ def add_data_to_custom_columns(columns, result): return data +def reorder_data_for_custom_columns(custom_columns, columns, result): + reordered_result = [] + columns = [col.split(":")[0] for col in columns] + + for res in result: + r = [] + for col in custom_columns: + try: + idx = columns.index(col.get("label")) + r.append(res[idx]) + except ValueError: + pass + + reordered_result.append(r) + + return reordered_result + def get_prepared_report_result(report, filters, dn="", user=None): latest_report_data = {} doc = None diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index cb00614019..539f6c9db8 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -216,8 +216,10 @@ def send_daily(): elif auto_email_report.frequency == 'Weekly': if auto_email_report.day_of_week != current_day: continue - - auto_email_report.send() + try: + auto_email_report.send() + except Exception as e: + frappe.log_error(e, _('Failed to send {0} Auto Email Report').format(auto_email_report.name)) def send_monthly(): diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 6bde0291a0..057638697a 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -29,6 +29,7 @@ "default_incoming", "email_sync_option", "initial_sync_count", + "create_contact", "section_break_12", "enable_automatic_linking", "section_break_13", @@ -114,9 +115,9 @@ "depends_on": "eval:!doc.service", "fieldname": "domain", "fieldtype": "Link", - "label": "Domain", "in_list_view": 1, "in_standard_filter": 1, + "label": "Domain", "options": "Email Domain" }, { @@ -408,11 +409,17 @@ "fieldname": "use_ssl_for_outgoing", "fieldtype": "Check", "label": "Use SSL for Outgoing" + }, + { + "default": "1", + "fieldname": "create_contact", + "fieldtype": "Check", + "label": "Create Contacts from Incoming Emails" } ], "icon": "fa fa-inbox", "links": [], - "modified": "2020-04-06 19:20:50.491146", + "modified": "2020-05-11 15:18:43.931499", "modified_by": "Administrator", "module": "Email", "name": "Email Account", @@ -427,11 +434,11 @@ "write": 1 }, { - "read": 1, - "role": "Inbox User" + "read": 1, + "role": "Inbox User" } ], "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index 44056955f7..02fc8512ca 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -80,7 +80,6 @@ frappe.ui.form.on("Notification", { }); }, refresh: function(frm) { - frm.toggle_reqd("recipients", frm.doc.channel=="Email"); frappe.notification.setup_fieldname_select(frm); frm.get_field("is_standard").toggle(frappe.boot.developer_mode); frm.trigger('event'); diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json index 14eff2251a..d1526f5fe4 100644 --- a/frappe/email/doctype/notification/notification.json +++ b/frappe/email/doctype/notification/notification.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "Prompt", "creation": "2014-07-11 17:18:09.923399", @@ -22,6 +23,7 @@ "days_in_advance", "value_changed", "sender", + "send_system_notification", "sender_email", "section_break_9", "condition", @@ -46,32 +48,43 @@ "default": "1", "fieldname": "enabled", "fieldtype": "Check", - "label": "Enabled" + "label": "Enabled", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_2", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "default": "Email", + "depends_on": "eval: !doc.disable_channel", "fieldname": "channel", "fieldtype": "Select", "label": "Channel", - "options": "Email\nSlack", + "options": "Email\nSlack\nSystem Notification", "reqd": 1, - "set_only_once": 1 + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.channel=='Slack'", "fieldname": "slack_webhook_url", "fieldtype": "Link", "label": "Slack Channel", - "options": "Slack Webhook URL" + "mandatory_depends_on": "eval:doc.channel=='Slack'", + "options": "Slack Webhook URL", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "filters", "fieldtype": "Section Break", - "label": "Filters" + "label": "Filters", + "show_days": 1, + "show_seconds": 1 }, { "description": "To add dynamic subject, use jinja tags like\n\n
{{ doc.name }} Delivered
", @@ -80,7 +93,9 @@ "ignore_xss_filter": 1, "in_list_view": 1, "label": "Subject", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "document_type", @@ -90,13 +105,17 @@ "label": "Document Type", "options": "DocType", "reqd": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "is_standard", "fieldtype": "Check", - "label": "Is Standard" + "label": "Is Standard", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "is_standard", @@ -104,11 +123,15 @@ "fieldtype": "Link", "in_standard_filter": 1, "label": "Module", - "options": "Module Def" + "options": "Module Def", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "col_break_1", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "event", @@ -117,21 +140,27 @@ "label": "Send Alert On", "options": "\nNew\nSave\nSubmit\nCancel\nDays After\nDays Before\nValue Change\nMethod\nCustom", "reqd": 1, - "search_index": 1 + "search_index": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.event=='Method'", "description": "Trigger on valid methods like \"before_insert\", \"after_update\", etc (will depend on the DocType selected)", "fieldname": "method", "fieldtype": "Data", - "label": "Trigger Method" + "label": "Trigger Method", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.event==\"Days After\" || doc.event==\"Days Before\"", "description": "Send alert if date matches this field's value", "fieldname": "date_changed", "fieldtype": "Select", - "label": "Reference Date" + "label": "Reference Date", + "show_days": 1, + "show_seconds": 1 }, { "default": "0", @@ -139,31 +168,41 @@ "description": "Send days before or after the reference date", "fieldname": "days_in_advance", "fieldtype": "Int", - "label": "Days Before or After" + "label": "Days Before or After", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.event==\"Value Change\"", "description": "Send alert if this field's value changes", "fieldname": "value_changed", "fieldtype": "Select", - "label": "Value Changed" + "label": "Value Changed", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "sender", "fieldtype": "Link", "label": "Sender", - "options": "Email Account" + "options": "Email Account", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "sender_email", "fieldtype": "Data", "label": "Sender Email", "options": "Email", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "section_break_9", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "show_days": 1, + "show_seconds": 1 }, { "description": "Optional: The alert will be sent if this expression is true", @@ -171,99 +210,143 @@ "fieldtype": "Code", "ignore_xss_filter": 1, "in_list_view": 1, - "label": "Condition" + "label": "Condition", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_6", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "html_7", "fieldtype": "HTML", - "options": "

Condition Examples:

\n
doc.status==\"Open\"
doc.due_date==nowdate()
doc.total > 40000\n
\n" + "options": "

Condition Examples:

\n
doc.status==\"Open\"
doc.due_date==nowdate()
doc.total > 40000\n
\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": "
Message Example
\n\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": "
Message Example
\n\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
", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.channel=='Slack'", "fieldname": "slack_message_examples", "fieldtype": "HTML", "label": "Message Examples", - "options": "
Message Example
\n\n
*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": "
Message Example
\n\n
*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..8ebda9c7b8 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 @@ -98,6 +103,7 @@ class InvalidColumnName(ValidationError): pass class IncompatibleApp(ValidationError): pass class InvalidDates(ValidationError): pass class DataTooLongException(ValidationError): pass +class FileAlreadyAttachedException(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass diff --git a/frappe/hooks.py b/frappe/hooks.py index 200280f6de..f5a8701089 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -56,6 +56,8 @@ website_route_rules = [ {"from_route": "/profile", "to_route": "me"}, ] +base_template = "templates/base.html" + write_file_keys = ["file_url", "file_name"] notification_config = "frappe.core.notifications.get_notification_config" @@ -270,7 +272,10 @@ setup_wizard_exception = [ ] before_migrate = ['frappe.patches.v11_0.sync_user_permission_doctype_before_migrate.execute'] -after_migrate = ['frappe.website.doctype.website_theme.website_theme.generate_theme_files_if_not_exist'] +after_migrate = [ + 'frappe.website.doctype.website_theme.website_theme.generate_theme_files_if_not_exist', + 'frappe.modules.full_text_search.build_index_for_all_routes' +] otp_methods = ['OTP App','Email','SMS'] user_privacy_documents = [ 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/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index 2a036f4838..f177aa6620 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -56,7 +56,8 @@ def take_backup_to_dropbox(retry_count=0, upload_db_backup=True): did_not_upload, error_log = backup_to_dropbox(upload_db_backup) if did_not_upload: raise Exception - send_email(True, "Dropbox", "Dropbox Settings", "send_notifications_to") + if cint(frappe.db.get_value("Dropbox Settings", None, "send_email_for_successful_backup")): + send_email(True, "Dropbox", "Dropbox Settings", "send_notifications_to") except JobTimeoutException: if retry_count < 2: args = { @@ -90,7 +91,7 @@ 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']) + dropbox_client = dropbox.Dropbox(dropbox_settings['access_token'], timeout=None) if upload_db_backup: if frappe.flags.create_new_backup: diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index 5874c79108..6455623281 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -147,11 +147,14 @@ def sync_contacts_from_google_contacts(g_contact): results = [] contacts_updated = 0 + sync_token = account.get_password(fieldname="next_sync_token", raise_exception=False) or None + contacts = frappe._dict() + while True: try: - sync_token = account.get_password(fieldname="next_sync_token", raise_exception=False) or None - contacts = google_contacts.people().connections().list(resourceName='people/me',syncToken=sync_token, - personFields="names,emailAddresses,organizations,phoneNumbers").execute() + contacts = google_contacts.people().connections().list(resourceName='people/me', pageToken=contacts.get("nextPageToken"), + syncToken=sync_token, pageSize=2000, requestSyncToken=True, personFields="names,emailAddresses,organizations,phoneNumbers").execute() + except HttpError as err: frappe.throw(_("Google Contacts - Could not sync contacts from Google Contacts {0}, error code {1}.").format(account.name, err.resp.status)) 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/__init__.py b/frappe/model/__init__.py index 93ef78df7b..3c5d996439 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -34,7 +34,8 @@ data_fieldtypes = ( 'Signature', 'Color', 'Barcode', - 'Geolocation' + 'Geolocation', + 'Duration' ) no_value_fields = ('Section Break', 'Column Break', 'HTML', 'Table', 'Table MultiSelect', 'Button', 'Image', diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index feeb96898a..106d21eb51 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -693,7 +693,7 @@ class BaseDocument(object): df = self.meta.get_field(fieldname) sanitized_value = value - if df and df.get("fieldtype") in ("Data", "Code", "Small Text") and df.get("options")=="Email": + if df and df.get("fieldtype") in ("Data", "Code", "Small Text", "Text") and df.get("options")=="Email": sanitized_value = sanitize_email(value) elif df and (df.get("ignore_xss_filter") 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/mapper.py b/frappe/model/mapper.py index 3639a947c0..d3014435e0 100644 --- a/frappe/model/mapper.py +++ b/frappe/model/mapper.py @@ -14,6 +14,12 @@ def make_mapped_doc(method, source_name, selected_children=None, args=None): Sets selected_children as flags for the `get_mapped_doc` method. Called from `open_mapped_doc` from create_new.js''' + + for hook in frappe.get_hooks("override_whitelisted_methods", {}).get(method, []): + # override using the first hook + method = hook + break + method = frappe.get_attr(method) if method not in frappe.whitelisted: diff --git a/frappe/model/meta.py b/frappe/model/meta.py index c8fd1a2ac2..1cc3abba5b 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -437,7 +437,7 @@ class Meta(Document): if not self.custom: for hook in frappe.get_hooks("override_doctype_dashboards", {}).get(self.name, []): - data = frappe.get_attr(hook)(data=data) + data = frappe._dict(frappe.get_attr(hook)(data=data)) return data @@ -483,6 +483,9 @@ class Meta(Document): def get_row_template(self): return self.get_web_template(suffix='_row') + def get_list_template(self): + return self.get_web_template(suffix='_list') + def get_web_template(self, suffix=''): '''Returns the relative path of the row template for this doctype''' module_name = frappe.scrub(self.module) 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/modules/full_text_search.py b/frappe/modules/full_text_search.py new file mode 100644 index 0000000000..fce9983907 --- /dev/null +++ b/frappe/modules/full_text_search.py @@ -0,0 +1,106 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe +from whoosh.index import create_in, open_dir +from whoosh.fields import TEXT, ID, Schema +from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin +from whoosh.query import Prefix +from bs4 import BeautifulSoup +from frappe.website.render import render_page +from frappe.utils import set_request, cint +from frappe.utils.global_search import get_routes_to_index + + +def build_index_for_all_routes(): + print("Building search index for all web routes...") + routes = get_routes_to_index() + documents = [get_document_to_index(route) for route in routes] + build_index("web_routes", documents) + + +@frappe.whitelist(allow_guest=True) +def web_search(index_name, query, scope=None, limit=20): + limit = cint(limit) + return search(index_name, query, scope, limit) + + +def get_document_to_index(route): + frappe.set_user("Guest") + frappe.local.no_cache = True + + try: + set_request(method="GET", path=route) + content = render_page(route) + soup = BeautifulSoup(content, "html.parser") + page_content = soup.find(class_="page_content") + text_content = page_content.text if page_content else "" + title = soup.title.text.strip() if soup.title else route + + frappe.set_user("Administrator") + + return frappe._dict(title=title, content=text_content, path=route) + except ( + frappe.PermissionError, + frappe.DoesNotExistError, + frappe.ValidationError, + Exception, + ): + pass + + +def build_index(index_name, documents): + schema = Schema( + title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True) + ) + + index_dir = get_index_path(index_name) + frappe.create_folder(index_dir) + + ix = create_in(index_dir, schema) + writer = ix.writer() + + for document in documents: + if document: + writer.add_document( + title=document.title, path=document.path, content=document.content + ) + + writer.commit() + + +def search(index_name, text, scope=None, limit=20): + index_dir = get_index_path(index_name) + ix = open_dir(index_dir) + + results = None + out = [] + with ix.searcher() as searcher: + parser = MultifieldParser(["title", "content"], ix.schema) + parser.remove_plugin_class(FieldsPlugin) + parser.remove_plugin_class(WildcardPlugin) + query = parser.parse(text) + + filter_scoped = None + if scope: + filter_scoped = Prefix("path", scope) + results = searcher.search(query, limit=limit, filter=filter_scoped) + + for r in results: + title_highlights = r.highlights("title") + content_highlights = r.highlights("content") + out.append( + frappe._dict( + title=r["title"], + path=r["path"], + title_highlights=title_highlights, + content_highlights=content_highlights, + ) + ) + + return out + + +def get_index_path(index_name): + return frappe.get_site_path("indexes", index_name) diff --git a/frappe/patches.txt b/frappe/patches.txt index 8ab9418e6c..582b369343 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -278,6 +278,14 @@ 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 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") +frappe.patches.v13_0.update_date_filters_in_user_settings +frappe.patches.v13_0.update_duration_options diff --git a/frappe/patches/v13_0/set_unique_for_page_view.py b/frappe/patches/v13_0/set_unique_for_page_view.py new file mode 100644 index 0000000000..2a084e52e3 --- /dev/null +++ b/frappe/patches/v13_0/set_unique_for_page_view.py @@ -0,0 +1,6 @@ +import frappe + +def execute(): + frappe.reload_doc('website', 'doctype', 'web_page_view', force=True) + site_url = frappe.utils.get_site_url(frappe.local.site) + frappe.db.sql("""UPDATE `tabWeb Page View` set is_unique=1 where referrer LIKE '%{0}%'""".format(site_url)) 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/patches/v13_0/update_duration_options.py b/frappe/patches/v13_0/update_duration_options.py new file mode 100644 index 0000000000..60eef8fc93 --- /dev/null +++ b/frappe/patches/v13_0/update_duration_options.py @@ -0,0 +1,28 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc('core', 'doctype', 'DocField') + + if frappe.db.has_column('DocField', 'show_days'): + frappe.db.sql(""" + UPDATE + tabDocField + SET + hide_days = 1 WHERE show_days = 0 + """) + frappe.db.sql_ddl('alter table tabDocField drop column show_days') + + if frappe.db.has_column('DocField', 'show_seconds'): + frappe.db.sql(""" + UPDATE + tabDocField + SET + hide_seconds = 1 WHERE show_seconds = 0 + """) + frappe.db.sql_ddl('alter table tabDocField drop column show_seconds') + + frappe.clear_cache(doctype='DocField') \ No newline at end of file diff --git a/frappe/public/build.json b/frappe/public/build.json index 30cb2adf87..997a3092ad 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -250,6 +250,8 @@ "public/less/form_grid.less" ], "js/form.min.js": [ + "public/js/frappe/form/templates/address_list.html", + "public/js/frappe/form/templates/contact_list.html", "public/js/frappe/form/templates/print_layout.html", "public/js/frappe/form/templates/users_in_sidebar.html", "public/js/frappe/form/templates/set_sharing.html", diff --git a/frappe/public/css/email.css b/frappe/public/css/email.css index 92ac433fd2..40c6149927 100644 --- a/frappe/public/css/email.css +++ b/frappe/public/css/email.css @@ -1,82 +1,64 @@ /* csslint ignore:start */ - /* palette colors*/ - body { line-height: 1.5; color: #36414c; } - p { margin: 1em 0 !important; } - hr { border-top: 1px solid #d1d8dd; } - .body-table { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } - .body-table td { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } - .email-header, .email-body, .email-footer { width: 100% !important; min-width: 100% !important; } - .email-body { font-size: 14px; } - .email-footer { border-top: 1px solid #d1d8dd; font-size: 12px; } - .email-header { border: 1px solid #d1d8dd; border-radius: 4px 4px 0 0; } - .email-header .brand-image { width: 24px; height: 24px; display: block; } - .email-header-title { font-weight: bold; } - .body-table.has-header .email-body { border: 1px solid #d1d8dd; border-radius: 0 0 4px 4px; border-top: none; } - .body-table.has-header .email-footer { border-top: none; } - .email-footer-container { margin-top: 30px; } - .email-footer-container > div:not(:last-child) { margin-bottom: 5px; } - .email-unsubscribe a { color: #8d99a6; text-decoration: underline; } - .btn { text-decoration: none; padding: 7px 10px; @@ -84,24 +66,20 @@ hr { border: 1px solid; border-radius: 3px; } - .btn.btn-default { color: #fff; background-color: #f0f4f7; border-color: transparent; } - .btn.btn-primary { color: #fff; background-color: #5e64ff; border-color: #444bff; } - .table { width: 100%; border-collapse: collapse; } - .table td, .table th { padding: 8px; @@ -110,68 +88,53 @@ hr { border-top: 1px solid #d1d8dd; text-align: left; } - .table th { font-weight: bold; } - .table > thead > tr > th { vertical-align: middle; border-bottom: 2px solid #d1d8dd; } - .table > thead:first-child > tr:first-child > th { border-top: none; } - .table.table-bordered { border: 1px solid #d1d8dd; } - .table.table-bordered td, .table.table-bordered th { border: 1px solid #d1d8dd; } - .more-info { font-size: 80% !important; color: #8d99a6 !important; border-top: 1px solid #ebeff2; padding-top: 10px; } - .text-right { text-align: right !important; } - .text-center { text-align: center !important; } - .text-muted { color: #8d99a6 !important; } - .text-extra-muted { color: #d1d8dd !important; } - .text-regular { font-size: 14px; } - .text-medium { font-size: 12px; } - .text-small { font-size: 10px; } - .text-bold { font-weight: bold; } - .indicator { width: 8px; height: 8px; @@ -180,43 +143,33 @@ hr { display: inline-block; margin-right: 5px; } - .indicator.indicator-blue { background-color: #5e64ff; } - .indicator.indicator-green { background-color: #98d85b; } - .indicator.indicator-orange { background-color: #ffa00a; } - .indicator.indicator-red { background-color: #ff5858; } - .indicator.indicator-yellow { background-color: #feef72; } - .screenshot { box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.1); border: 1px solid #d1d8dd; margin: 8px 0; max-width: 100%; } - .list-unstyled { list-style-type: none; padding: 0; } - /* auto email report */ - .report-title { margin-bottom: 20px; } - /* csslint ignore:end */ diff --git a/frappe/public/css/hljs-night-owl.css b/frappe/public/css/hljs-night-owl.css new file mode 100644 index 0000000000..932ad2e46f --- /dev/null +++ b/frappe/public/css/hljs-night-owl.css @@ -0,0 +1,183 @@ +/* + +Night Owl for highlight.js (c) Carl Baxter + +An adaptation of Sarah Drasner's Night Owl VS Code Theme +https://github.com/sdras/night-owl-vscode-theme + +Copyright (c) 2018 Sarah Drasner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +.hljs { + display: block; + overflow-x: auto; + padding: 1rem 1.25rem; + background: #011627; + color: #d6deeb; + border-radius: 0.5rem; + } + + /* General Purpose */ + .hljs-keyword { + color: #c792ea; + font-style: italic; + } + .hljs-built_in { + color: #addb67; + font-style: italic; + } + .hljs-type { + color: #82aaff; + } + .hljs-literal { + color: #ff5874; + } + .hljs-number { + color: #F78C6C; + } + .hljs-regexp { + color: #5ca7e4; + } + .hljs-string { + color: #ecc48d; + } + .hljs-subst { + color: #d3423e; + } + .hljs-symbol { + color: #82aaff; + } + .hljs-class { + color: #ffcb8b; + } + .hljs-function { + color: #82AAFF; + } + .hljs-title { + color: #DCDCAA; + font-style: italic; + } + .hljs-params { + color: #7fdbca; + } + + /* Meta */ + .hljs-comment { + color: #637777; + font-style: italic; + } + .hljs-doctag { + color: #7fdbca; + } + .hljs-meta { + color: #82aaff; + } + .hljs-meta-keyword { + color: #82aaff; + } + .hljs-meta-string { + color: #ecc48d; + } + + /* Tags, attributes, config */ + .hljs-section { + color: #82b1ff; + } + .hljs-tag, + .hljs-name, + .hljs-builtin-name { + color: #7fdbca; + } + .hljs-attr { + color: #7fdbca; + } + .hljs-attribute { + color: #80cbc4; + } + .hljs-variable { + color: #addb67; + } + + /* Markup */ + .hljs-bullet { + color: #d9f5dd; + } + .hljs-code { + color: #80CBC4; + } + .hljs-emphasis { + color: #c792ea; + font-style: italic; + } + .hljs-strong { + color: #addb67; + font-weight: bold; + } + .hljs-formula { + color: #c792ea; + } + .hljs-link { + color: #ff869a; + } + .hljs-quote { + color: #697098; + font-style: italic; + } + + /* CSS */ + .hljs-selector-tag { + color: #ff6363; + } + + .hljs-selector-id { + color: #fad430; + } + + .hljs-selector-class { + color: #addb67; + font-style: italic; + } + + .hljs-selector-attr, + .hljs-selector-pseudo { + color: #c792ea; + font-style: italic; + } + + /* Templates */ + .hljs-template-tag { + color: #c792ea; + } + .hljs-template-variable { + color: #addb67; + } + + /* diff */ + .hljs-addition { + color: #addb67ff; + font-style: italic; + } + + .hljs-deletion { + color: #EF535090; + font-style: italic; + } diff --git a/frappe/public/js/frappe/form/controls/control.js b/frappe/public/js/frappe/form/controls/control.js index 2bf6292abc..168da2717c 100644 --- a/frappe/public/js/frappe/form/controls/control.js +++ b/frappe/public/js/frappe/form/controls/control.js @@ -38,6 +38,7 @@ import './table_multiselect'; import './multiselect_pills'; import './multiselect_list'; import './rating'; +import './duration'; frappe.ui.form.make_control = function (opts) { var control_class_name = "Control" + opts.df.fieldtype.replace(/ /g, ""); diff --git a/frappe/public/js/frappe/form/controls/duration.js b/frappe/public/js/frappe/form/controls/duration.js new file mode 100644 index 0000000000..e70afd6e65 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/duration.js @@ -0,0 +1,152 @@ +frappe.ui.form.ControlDuration = frappe.ui.form.ControlData.extend({ + make_input: function() { + this._super(); + this.make_picker(); + }, + + make_picker: function() { + this.inputs = []; + this.set_duration_options(); + this.$picker = $( + `
+
+
` + ); + this.$wrapper.append(this.$picker); + this.build_numeric_input("days", this.duration_options.hide_days); + this.build_numeric_input("hours", false); + this.build_numeric_input("minutes", false); + this.build_numeric_input("seconds", this.duration_options.hide_seconds); + this.set_duration_picker_value(this.value); + this.$picker.hide(); + this.bind_events(); + this.refresh(); + }, + + build_numeric_input: function(label, hidden, max) { + let $duration_input = $(` + + `); + + let $input = $(`
`).prepend($duration_input); + + if (max) { + $duration_input.attr("max", max); + } + + this.inputs[label] = $duration_input; + + let $control = $(` +
+
${__(label)}
+
` + ); + + if (hidden) { + $control.addClass("hidden"); + } + $control.prepend($input); + $control.appendTo(this.$picker.find(".picker-row")); + }, + + set_duration_options() { + this.duration_options = frappe.utils.get_duration_options(this.df); + }, + + set_duration_picker_value: function(value) { + let total_duration = frappe.utils.seconds_to_duration(value, this.duration_options); + + if (this.$picker) { + Object.keys(total_duration).forEach(duration => { + this.inputs[duration].prop("value", total_duration[duration]); + }); + } + }, + + bind_events: function() { + // flag to handle the display property of the picker + let clicked = false; + + this.$wrapper.find(".duration-input").mousedown(() => { + // input in individual duration boxes + clicked = true; + }); + + this.$picker.on("change", ".duration-input", () => { + // duration changed in individual boxes + clicked = false; + let duration = this.get_duration(); + let value = frappe.utils.duration_to_seconds( + duration.days, + duration.hours, + duration.minutes, + duration.seconds + ); + this.set_value(value); + this.set_focus(); + }); + + this.$input.on("focus", () => { + this.$picker.show(); + let is_picker_set = this.is_duration_picker_set(this.inputs); + if (!is_picker_set) { + this.set_duration_picker_value(this.value); + } + }); + + this.$input.on("blur", () => { + // input in duration boxes, don't close the picker + if (clicked) { + clicked = false; + } else { + // blur event was not due to duration inputs + this.$picker.hide(); + } + }); + }, + + get_value() { + return cint(this.value); + }, + + refresh_input: function() { + this._super(); + this.set_duration_options(); + this.set_duration_picker_value(this.value); + }, + + format_for_input: function(value) { + return frappe.utils.get_formatted_duration(value, this.duration_options); + }, + + get_duration() { + // returns an object of days, hours, minutes and seconds from the inputs array + let total_duration = { + minutes: 0, + hours: 0, + days: 0, + seconds: 0 + }; + if (this.inputs) { + total_duration.minutes = parseInt(this.inputs.minutes.val()); + total_duration.hours = parseInt(this.inputs.hours.val()); + if (!this.duration_options.hide_days) { + total_duration.days = parseInt(this.inputs.days.val()); + } + if (!this.duration_options.hide_seconds) { + total_duration.seconds = parseInt(this.inputs.seconds.val()); + } + } + return total_duration; + }, + + is_duration_picker_set(inputs) { + let is_set = false; + Object.values(inputs).forEach(duration => { + if (duration.prop("value") != 0) { + is_set = true; + } + }); + return is_set; + } +}); \ No newline at end of file diff --git a/frappe/public/js/frappe/form/controls/multiselect_pills.js b/frappe/public/js/frappe/form/controls/multiselect_pills.js index 8796c95eaa..6190204357 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_pills.js +++ b/frappe/public/js/frappe/form/controls/multiselect_pills.js @@ -129,7 +129,8 @@ frappe.ui.form.ControlMultiSelectPills = frappe.ui.form.ControlAutocomplete.exte get_data() { let data; if(this.df.get_data) { - data = this.df.get_data(); + let txt = this.$input.val(); + data = this.df.get_data(txt); if (data && data.then) { data.then((r) => { this.set_data(r); diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index beec168dfd..bb44408c2a 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -589,7 +589,6 @@ frappe.ui.form.Timeline = class Timeline { out.push(me.get_version_comment(version, message)); } } else { - p = p.map(frappe.utils.escape_html); const df = frappe.meta.get_docfield(me.frm.doctype, p[0], me.frm.docname); if (df && !df.hidden) { const field_display_status = frappe.perm.get_field_display_status(df, null, @@ -597,8 +596,8 @@ frappe.ui.form.Timeline = class Timeline { if (field_display_status === 'Read' || field_display_status === 'Write') { parts.push(__('{0} from {1} to {2}', [ __(df.label), - (frappe.ellipsis(frappe.utils.html2text(p[1]), 40) || '""').bold(), - (frappe.ellipsis(frappe.utils.html2text(p[2]), 40) || '""').bold() + me.format_content_for_timeline(p[1]), + me.format_content_for_timeline(p[2]) ])); } } @@ -608,9 +607,9 @@ frappe.ui.form.Timeline = class Timeline { if (parts.length) { let message; if (updater_reference_link) { - message = __("changed value of {0} {1}", [parts.join(', ').bold(), updater_reference_link]); + message = __("changed value of {0} {1}", [parts.join(', '), updater_reference_link]); } else { - message = __("changed value of {0}", [parts.join(', ').bold()]); + message = __("changed value of {0}", [parts.join(', ')]); } out.push(me.get_version_comment(version, message)); } @@ -618,23 +617,23 @@ frappe.ui.form.Timeline = class Timeline { // value changed in table field if (data.row_changed && data.row_changed.length) { - var parts = [], count = 0; + let parts = []; data.row_changed.every(function(row) { row[3].every(function(p) { var df = me.frm.fields_dict[row[0]] && frappe.meta.get_docfield(me.frm.fields_dict[row[0]].grid.doctype, p[0], me.frm.docname); - if(df && !df.hidden) { + if (df && !df.hidden) { var field_display_status = frappe.perm.get_field_display_status(df, null, me.frm.perm); - if(field_display_status === 'Read' || field_display_status === 'Write') { + if (field_display_status === 'Read' || field_display_status === 'Write') { parts.push(__('{0} from {1} to {2} in row #{3}', [ frappe.meta.get_label(me.frm.fields_dict[row[0]].grid.doctype, p[0]), - (frappe.ellipsis(p[1], 40) || '""').bold(), - (frappe.ellipsis(p[2], 40) || '""').bold(), + me.format_content_for_timeline(p[1]), + me.format_content_for_timeline(p[2]), row[1] ])); } @@ -657,20 +656,22 @@ frappe.ui.form.Timeline = class Timeline { // rows added / removed // __('added'), __('removed') # for translation, don't remove ['added', 'removed'].forEach(function(key) { - if(data[key] && data[key].length) { - parts = (data[key] || []).map(function(p) { + if (data[key] && data[key].length) { + let parts = (data[key] || []).map(function(p) { var df = frappe.meta.get_docfield(me.frm.doctype, p[0], me.frm.docname); - if(df && !df.hidden) { + if (df && !df.hidden) { var field_display_status = frappe.perm.get_field_display_status(df, null, me.frm.perm); - if(field_display_status === 'Read' || field_display_status === 'Write') { + if (field_display_status === 'Read' || field_display_status === 'Write') { return frappe.meta.get_label(me.frm.doctype, p[0]) } } }); - parts = parts.filter(function(p) { return p; }); - if(parts.length) { + parts = parts.filter(function(p) { + return p; + }); + if (parts.length) { out.push(me.get_version_comment(version, __("{0} rows for {1}", [__(key), parts.join(', ')]))); } @@ -717,6 +718,17 @@ frappe.ui.form.Timeline = class Timeline { } + format_content_for_timeline(content) { + // text to HTML + // limits content to 40 characters + // escapes HTML + // and makes it bold + content = frappe.utils.html2text(content); + content = frappe.ellipsis(content, 40) || '""'; + content = frappe.utils.escape_html(content); + return content.bold(); + } + delete_comment(name) { var me = this; diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index bad7c877fc..c09d7b06ff 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -650,13 +650,14 @@ frappe.ui.form.Form = class FrappeForm { frappe.utils.play_sound("submit"); callback && callback(); me.script_manager.trigger("on_submit") - .then(() => resolve(me)); - if (frappe.route_hooks.after_submit) { - let route_callback = frappe.route_hooks.after_submit; - delete frappe.route_hooks.after_submit; - - route_callback(me); - } + .then(() => resolve(me)) + .then(() => { + if (frappe.route_hooks.after_submit) { + let route_callback = frappe.route_hooks.after_submit; + delete frappe.route_hooks.after_submit; + route_callback(me); + } + }); } }, btn, () => me.handle_save_fail(btn, on_error), resolve); }); @@ -786,15 +787,24 @@ frappe.ui.form.Form = class FrappeForm { frappe.msgprint(__('"amended_from" field must be present to do an amendment.')); return; } - this.validate_form_action("Amend"); - var me = this; - var fn = function(newdoc) { - newdoc.amended_from = me.docname; - if(me.fields_dict && me.fields_dict['amendment_date']) - newdoc.amendment_date = frappe.datetime.obj_to_str(new Date()); - }; - this.copy_doc(fn, 1); - frappe.utils.play_sound("click"); + + frappe.xcall('frappe.client.is_document_amended', { + 'doctype': this.doc.doctype, + 'docname': this.doc.name + }).then(is_amended => { + if (is_amended) { + frappe.throw(__('This document is already amended, you cannot ammend it again')); + } + this.validate_form_action("Amend"); + var me = this; + var fn = function(newdoc) { + newdoc.amended_from = me.docname; + if (me.fields_dict && me.fields_dict['amendment_date']) + newdoc.amendment_date = frappe.datetime.obj_to_str(new Date()); + }; + this.copy_doc(fn, 1); + frappe.utils.play_sound("click"); + }); } validate_form_action(action, resolve) { @@ -1586,7 +1596,7 @@ frappe.ui.form.Form = class FrappeForm { let steps = frappe.tour[this.doctype].map(step => { let field = this.get_docfield(step.fieldname); return { - element: `.frappe-control[title='${step.fieldname}']`, + element: `.frappe-control[data-fieldname='${step.fieldname}']`, popover: { title: step.title || field.label, description: step.description diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index d178c59100..9f4a2a61d6 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -142,10 +142,7 @@ frappe.form.formatters = { }, DateRange: function(value) { if($.isArray(value)) { - return __("{0} to {1}", [ - frappe.datetime.str_to_user(value[0]), - frappe.datetime.str_to_user(value[1]) - ]); + return __("{0} to {1}", [frappe.datetime.str_to_user(value[0]), frappe.datetime.str_to_user(value[1])]); } else { return value || ""; } @@ -188,6 +185,14 @@ frappe.form.formatters = { return value || ""; }, + Duration: function(value, docfield) { + if (value) { + let duration_options = frappe.utils.get_duration_options(docfield); + value = frappe.utils.get_formatted_duration(value, duration_options); + } + + return value || ""; + }, LikedBy: function(value) { var html = ""; $.each(JSON.parse(value || "[]"), function(i, v) { diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index cdd385a6ea..41b87e0207 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -1,110 +1,62 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// MIT License. See license.txt - -frappe.ui.form.MultiSelectDialog = Class.extend({ - init: function(opts) { - /* Options: doctype, target, setters, get_query, action */ - $.extend(this, opts); - +frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { + constructor(opts) { + /* Options: doctype, target, setters, get_query, action, add_filters_group, data_fields, primary_action_label */ + Object.assign(this, opts); var me = this; - if(this.doctype!="[Select]") { - frappe.model.with_doctype(this.doctype, function(r) { + if (this.doctype != "[Select]") { + frappe.model.with_doctype(this.doctype, function () { me.make(); }); } else { this.make(); } - }, - make: function() { - let me = this; + } + make() { + let me = this; this.page_length = 20; this.start = 0; + let fields = this.get_primary_filters(); - let fields = [ - { - fieldtype: "Data", - label: __("Search Term"), - fieldname: "search_term" - }, - { - fieldtype: "Column Break" - } - ]; - let count = 0; - if(!this.date_field) { - this.date_field = "transaction_date"; - } - - // setters can be defined as a dict or a list of fields - // setters define the additional filters that get applied - // for selection - - // CASE 1: DocType name and fieldname is the same, example "customer" and "customer" - // setters define the filters applied in the modal - // if the fieldnames and doctypes are consistently named, - // pass a dict with the setter key and value, for example - // {customer: [customer_name]} - - // CASE 2: if the fieldname of the target is different, - // then pass a list of fields with appropriate fieldname - - if($.isArray(this.setters)) { - for (let df of this.setters) { - fields.push(df, {fieldtype: "Column Break"}); - } - } else { - Object.keys(this.setters).forEach(function(setter) { - fields.push({ - fieldtype: me.target.fields_dict[setter].df.fieldtype, - label: me.target.fields_dict[setter].df.label, - fieldname: setter, - options: me.target.fields_dict[setter].df.options, - default: me.setters[setter] - }); - if (count++ < Object.keys(me.setters).length) { - fields.push({fieldtype: "Column Break"}); - } - }); - } - + // Make results area fields = fields.concat([ - { - "fieldname":"date_range", - "label": __("Date Range"), - "fieldtype": "DateRange", - }, - { fieldtype: "Section Break" }, { fieldtype: "HTML", fieldname: "results_area" }, - { fieldtype: "Button", fieldname: "more_btn", label: __("More"), - click: function(){ - me.start += 20; - frappe.flags.auto_scroll = true; - me.get_results(); + { + fieldtype: "Button", fieldname: "more_btn", label: __("More"), + click: () => { + this.start += 20; + this.get_results(); } } ]); - let doctype_plural = !this.doctype.endsWith('y') ? this.doctype + 's' - : this.doctype.slice(0, -1) + 'ies'; + // Custom Data Fields + if (this.data_fields) { + fields.push({ fieldtype: "Section Break" }); + fields = fields.concat(this.data_fields); + } + + let doctype_plural = this.doctype.plural(); + this.dialog = new frappe.ui.Dialog({ - title: __("Select {0}", [(this.doctype=='[Select]') ? __("value") : __(doctype_plural)]), + title: __("Select {0}", [(this.doctype == '[Select]') ? __("value") : __(doctype_plural)]), fields: fields, - primary_action_label: __("Get Items"), + primary_action_label: this.primary_action_label || __("Get Items"), secondary_action_label: __("Make {0}", [me.doctype]), - primary_action: function() { - me.action(me.get_checked_values(), me.args); + primary_action: function () { + let filters_data = me.get_custom_filters(); + me.action(me.get_checked_values(), cur_dialog.get_values(), me.args, filters_data); }, - secondary_action: function(e) { + secondary_action: function (e) { // If user wants to close the modal if (e) { frappe.route_options = {}; - if($.isArray(me.setters)) { + if (Array.isArray(me.setters)) { for (let df of me.setters) { frappe.route_options[df.fieldname] = me.dialog.fields_dict[df.fieldname].get_value() || undefined; } } else { - Object.keys(me.setters).forEach(function(setter) { + Object.keys(me.setters).forEach(function (setter) { frappe.route_options[setter] = me.dialog.fields_dict[setter].get_value() || undefined; }); } @@ -114,6 +66,10 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ } }); + if (this.add_filters_group) { + this.make_filter_area(); + } + this.$parent = $(this.dialog.body); this.$wrapper = this.dialog.fields_dict.results_area.$wrapper.append(`
`); @@ -126,9 +82,89 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ this.bind_events(); this.get_results(); this.dialog.show(); - }, + } - bind_events: function() { + get_primary_filters() { + let fields = []; + + let columns = new Array(3); + + // Hack for three column layout + // To add column break + columns[0] = [ + { + fieldtype: "Data", + label: __("Search"), + fieldname: "search_term" + } + ]; + columns[1] = []; + columns[2] = []; + + Object.keys(this.setters).forEach((setter, index) => { + let df_prop = frappe.meta.docfield_map[this.doctype][setter]; + + // Index + 1 to start filling from index 1 + // Since Search is a standrd field already pushed + columns[(index + 1) % 3].push({ + fieldtype: df_prop.fieldtype, + label: df_prop.label, + fieldname: setter, + options: df_prop.options, + default: this.setters[setter] + }); + }); + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal + if (Object.seal) { + Object.seal(columns); + // now a is a fixed-size array with mutable entries + } + + fields = [ + ...columns[0], + { fieldtype: "Column Break" }, + ...columns[1], + { fieldtype: "Column Break" }, + ...columns[2], + { fieldtype: "Section Break", fieldname: "primary_filters_sb" } + ]; + + if (this.add_filters_group) { + fields.push( + { + fieldtype: 'HTML', + fieldname: 'filter_area', + } + ); + } + + return fields; + } + + make_filter_area() { + this.filter_group = new frappe.ui.FilterGroup({ + parent: this.dialog.get_field('filter_area').$wrapper, + doctype: this.doctype, + on_change: () => { + this.get_results(); + } + }); + } + + get_custom_filters() { + if (this.add_filters_group && this.filter_group) { + return this.filter_group.get_filters().reduce((acc, filter) => { + return Object.assign(acc, { + [filter[1]]: [filter[2], filter[3]] + }); + }, {}); + } else { + return []; + } + } + + bind_events() { let me = this; this.$results.on('click', '.list-item-container', function (e) { @@ -136,48 +172,44 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ $(this).find(':checkbox').trigger('click'); } }); + this.$results.on('click', '.list-item--head :checkbox', (e) => { this.$results.find('.list-item-container .list-row-check') .prop("checked", ($(e.target).is(':checked'))); }); - this.$parent.find('.input-with-feedback').on('change', (e) => { + this.$parent.find('.input-with-feedback').on('change', () => { frappe.flags.auto_scroll = false; this.get_results(); }); - this.$parent.find('[data-fieldname="date_range"]').on('blur', (e) => { - frappe.flags.auto_scroll = false; - this.get_results(); - }); - - this.$parent.find('[data-fieldname="search_term"]').on('input', (e) => { + this.$parent.find('[data-fieldtype="Data"]').on('input', () => { var $this = $(this); clearTimeout($this.data('timeout')); - $this.data('timeout', setTimeout(function() { + $this.data('timeout', setTimeout(function () { frappe.flags.auto_scroll = false; me.empty_list(); me.get_results(); }, 300)); }); - }, + } - get_checked_values: function() { + get_checked_values() { // Return name of checked value. - return this.$results.find('.list-item-container').map(function() { - if ($(this).find('.list-row-check:checkbox:checked').length > 0 ) { + return this.$results.find('.list-item-container').map(function () { + if ($(this).find('.list-row-check:checkbox:checked').length > 0) { return $(this).attr('data-item-name'); } }).get(); - }, + } - get_checked_items: function() { + get_checked_items() { // Return checked items with all the column values. let checked_values = this.get_checked_values(); return this.results.filter(res => checked_values.includes(res.name)); - }, + } - make_list_row: function(result={}) { + make_list_row(result = {}) { var me = this; // Make a head row by default (if result not passed) let head = Object.keys(result).length === 0; @@ -185,26 +217,17 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ let contents = ``; let columns = ["name"]; - if($.isArray(this.setters)) { - for (let df of this.setters) { - columns.push(df.fieldname); - } - } else { - columns = columns.concat(Object.keys(this.setters)); - } - columns.push("Date"); + columns = columns.concat(Object.keys(this.setters)); - columns.forEach(function(column) { + columns.forEach(function (column) { contents += `
${ - head ? `${__(frappe.model.unscrub(column))}` - - : (column !== "name" ? `${__(result[column])}` - : ` - ${__(result[column])}`) - } + head ? `${__(frappe.model.unscrub(column))}` + : (column !== "name" ? `${__(result[column] || '')}` + : ` + ${__(result[column] || '')}`)}
`; - }) + }); let $row = $(`
@@ -215,10 +238,12 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ head ? $row.addClass('list-item--head') : $row = $(`
`).append($row); - return $row; - }, - render_result_list: function(results, more = 0, empty=true) { + $(".modal-dialog .list-item--head").css("z-index", 0); + return $row; + } + + render_result_list(results, more = 0, empty = true) { var me = this; var more_btn = me.dialog.fields_dict.more_btn.$wrapper; @@ -240,44 +265,44 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ }); if (frappe.flags.auto_scroll) { - this.$results.animate({scrollTop: me.$results.prop('scrollHeight')}, 500); + this.$results.animate({ scrollTop: me.$results.prop('scrollHeight') }, 500); } - }, + } - empty_list: function() { + empty_list() { + // Store all checked items let checked = this.get_checked_items().map(item => { return { ...item, checked: true - } + }; }); + + // Remove **all** items this.$results.find('.list-item-container').remove(); + + // Rerender checked items this.render_result_list(checked, 0, false); - }, + } - get_results: function() { + get_results() { let me = this; + let filters = this.get_query ? this.get_query().filters : {} || {}; + let filter_fields = []; - let filters = this.get_query ? this.get_query().filters : {}; - let filter_fields = [me.date_field]; - if($.isArray(this.setters)) { - for (let df of this.setters) { - filters[df.fieldname] = me.dialog.fields_dict[df.fieldname].get_value() || undefined; - me.args[df.fieldname] = filters[df.fieldname]; - filter_fields.push(df.fieldname); - } - } else { - Object.keys(this.setters).forEach(function(setter) { - filters[setter] = me.dialog.fields_dict[setter].get_value() || undefined; + Object.keys(this.setters).forEach(function (setter) { + var value = me.dialog.fields_dict[setter].get_value(); + if (me.dialog.fields_dict[setter].df.fieldtype == "Data" && value) { + filters[setter] = ["like", "%" + value + "%"]; + } else { + filters[setter] = value || undefined; me.args[setter] = filters[setter]; filter_fields.push(setter); - }); - } + } + }); - let date_val = this.dialog.fields_dict["date_range"].get_value(); - if(date_val) { - filters[this.date_field] = ['between', date_val]; - } + let filter_group = this.get_custom_filters(); + Object.assign(filters, filter_group); let args = { doctype: me.doctype, @@ -288,13 +313,13 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ page_length: this.page_length + 1, query: this.get_query ? this.get_query().query : '', as_dict: 1 - } + }; frappe.call({ type: "GET", - method:'frappe.desk.search.search_widget', + method: 'frappe.desk.search.search_widget', no_spinner: true, args: args, - callback: function(r) { + callback: function (r) { let more = 0; me.results = []; if (r.values.length) { @@ -302,30 +327,13 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ r.values.pop(); more = 1; } - r.values.forEach(function(result) { - if(me.date_field in result) { - result["Date"] = result[me.date_field] - } + r.values.forEach(function (result) { result.checked = 0; - result.parsed_date = Date.parse(result["Date"]); me.results.push(result); }); - me.results.map( (result) => { - result["Date"] = frappe.format(result["Date"], {"fieldtype":"Date"}); - }) - - me.results.sort((a, b) => { - return a.parsed_date - b.parsed_date; - }); - - // Preselect oldest entry - if (me.start < 1 && r.values.length === 1) { - me.results[0].checked = 1; - } } me.render_result_list(me.results, more); } }); - }, - -}); \ No newline at end of file + } +}; \ No newline at end of file diff --git a/frappe/public/js/frappe/form/script_manager.js b/frappe/public/js/frappe/form/script_manager.js index fd5b8d3856..1238bf141c 100644 --- a/frappe/public/js/frappe/form/script_manager.js +++ b/frappe/public/js/frappe/form/script_manager.js @@ -16,12 +16,22 @@ frappe.ui.form.get_event_handler_list = function(doctype, fieldname) { frappe.ui.form.on = frappe.ui.form.on_change = function(doctype, fieldname, handler) { var add_handler = function(fieldname, handler) { var handler_list = frappe.ui.form.get_event_handler_list(doctype, fieldname); - handler_list.push(handler); + + let _handler = (...args) => { + try { + handler(...args); + } catch (error) { + console.error(handler); + throw error; + } + } + + handler_list.push(_handler); // add last handler to events so it can be called as // frm.events.handler(frm) if(cur_frm && cur_frm.doctype===doctype) { - cur_frm.events[fieldname] = handler; + cur_frm.events[fieldname] = _handler; } } diff --git a/frappe/public/js/frappe/form/templates/address_list.html b/frappe/public/js/frappe/form/templates/address_list.html new file mode 100644 index 0000000000..0f967b67a0 --- /dev/null +++ b/frappe/public/js/frappe/form/templates/address_list.html @@ -0,0 +1,22 @@ +
+{% for(var i=0, l=addr_list.length; i +

+ {%= i+1 %}. {%= addr_list[i].address_title %}{% if(addr_list[i].address_type!="Other") { %} + ({%= __(addr_list[i].address_type) %}){% } %} + {% if(addr_list[i].is_primary_address) { %} + ({%= __("Primary") %}){% } %} + {% if(addr_list[i].is_shipping_address) { %} + ({%= __("Shipping") %}){% } %} + + + {%= __("Edit") %} +

+

{%= addr_list[i].display %}

+
+{% } %} +{% if(!addr_list.length) { %} +

{%= __("No address added yet.") %}

+{% } %} +

\ No newline at end of file diff --git a/frappe/public/js/frappe/form/templates/contact_list.html b/frappe/public/js/frappe/form/templates/contact_list.html new file mode 100644 index 0000000000..7e6969163b --- /dev/null +++ b/frappe/public/js/frappe/form/templates/contact_list.html @@ -0,0 +1,54 @@ +
+{% for(var i=0, l=contact_list.length; i +

+ {%= contact_list[i].first_name %} {%= contact_list[i].last_name %} + {% if(contact_list[i].is_primary_contact) { %} + ({%= __("Primary") %}) + {% } %} + {% if(contact_list[i].designation){ %} + – {%= contact_list[i].designation %} + {% } %} + + {%= __("Edit") %} +

+ {% if (contact_list[i].phones || contact_list[i].email_ids) { %} +

+ {% if(contact_list[i].phone) { %} + {%= __("Phone") %}: {%= contact_list[i].phone %} ({%= __("Primary") %})
+ {% endif %} + {% if(contact_list[i].mobile_no) { %} + {%= __("Mobile No") %}: {%= contact_list[i].mobile_no %} ({%= __("Primary") %})
+ {% endif %} + {% if(contact_list[i].phone_nos) { %} + {% for(var j=0, k=contact_list[i].phone_nos.length; j + {% } %} + {% endif %} +

+

+ {% if(contact_list[i].email_id) { %} + {%= __("Email") %}: {%= contact_list[i].email_id %} ({%= __("Primary") %})
+ {% endif %} + {% if(contact_list[i].email_ids) { %} + {% for(var j=0, k=contact_list[i].email_ids.length; j + {% } %} + {% endif %} +

+ {% endif %} +

+ {% if (contact_list[i].address) { %} + {%= __("Address") %}: {%= contact_list[i].address %}
+ {% endif %} +

+
+{% } %} +{% if(!contact_list.length) { %} +

{%= __("No contacts added yet.") %}

+{% } %} +

+

\ No newline at end of file diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 528c874935..6f475fa9e5 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -374,19 +374,24 @@ frappe.ui.form.Toolbar = Class.extend({ var status = this.get_action_status(); if (status) { - if (status !== this.current_status) { - if (status === 'Amend') { - let doc = this.frm.doc; - frappe.xcall('frappe.client.is_document_amended', { - 'doctype': doc.doctype, - 'docname': doc.name - }).then(is_amended => { - if (is_amended) return; - this.set_page_actions(status); - }); - } else { + // When moving from a page with status amend to another page with status amend + // We need to check if document is already amened specifcally and hide + // or clear the menu actions accordingly + + if (status !== this.current_status || status === 'Amend') { + let doc = this.frm.doc; + frappe.xcall('frappe.client.is_document_amended', { + 'doctype': doc.doctype, + 'docname': doc.name + }).then(is_amended => { + if (is_amended) { + this.page.clear_actions(); + return; + } this.set_page_actions(status); - } + }); + } else { + this.set_page_actions(status); } } else { this.page.clear_actions(); 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 += ` +
+ +
+
+ +
+
+ ${me.fields[idx].label} +
+
+ + + +
+
+
`; + } + + fields_html.html(` +
+
+ +
+
+ ${fields} +
+ +
+ `); + + new Sortable(wrapper.getElementsByClassName("control-input-wrapper")[0], { + handle: '.sortable-handle', + draggable: '.sortable', + onUpdate: () => { + me.update_fields(); + me.refresh(); + } + }); + } + + add_new_fields() { + let me = this; + + let fields_html = me.dialog.get_field("fields_html"); + let add_new_fields = fields_html.$wrapper[0].getElementsByClassName("add-new-fields")[0]; + add_new_fields.onclick = () => me.column_selector(); + } + + setup_remove_fields() { + let me = this; + + let fields_html = me.dialog.get_field("fields_html"); + let remove_fields = fields_html.$wrapper[0].getElementsByClassName("remove-field"); + + for (let idx = 0; idx < remove_fields.length; idx++) { + remove_fields.item(idx).onclick = () => me.remove_fields(remove_fields.item(idx).getAttribute("data-fieldname")); + } + } + + remove_fields(fieldname) { + let me = this; + let existing_fields = me.fields.map(f => f.fieldname); + + for (let idx in me.fields) { + let field = me.fields[idx]; + + if (field.fieldname == fieldname) { + me.fields.splice(idx, 1); + break; + } + } + me.set_removed_fields(me.get_removed_listview_fields(me.fields.map(f => f.fieldname), existing_fields)); + me.refresh(); + me.update_fields(); + } + + update_fields() { + let me = this; + + let fields_html = me.dialog.get_field("fields_html"); + let wrapper = fields_html.$wrapper[0]; + + let fields_order = wrapper.getElementsByClassName("fields_order"); + me.fields = []; + + for (let idx = 0; idx < fields_order.length; idx++) { + me.fields.push({ + fieldname: fields_order.item(idx).getAttribute("data-fieldname"), + label: fields_order.item(idx).getAttribute("data-label") + }); + } + + me.dialog.set_value("fields", JSON.stringify(me.fields)); + me.dialog.get_value("fields"); + } + + column_selector() { + let me = this; + + let d = new frappe.ui.Dialog({ + title: __("{0} Fields", [__(me.doctype)]), + fields: [ + { + label: __("Reset Fields"), + fieldtype: "Button", + fieldname: "reset_fields", + click: () => me.reset_listview_fields(d) + }, + { + label: __("Select Fields"), + fieldtype: "MultiCheck", + fieldname: "fields", + options: me.get_doctype_fields(me.meta, me.fields.map(f => f.fieldname)), + columns: 2 + } + ] + }); + d.set_primary_action(__('Save'), () => { + let values = d.get_values().fields; + + me.set_removed_fields(me.get_removed_listview_fields(values, me.fields.map(f => f.fieldname))); + + me.fields = []; + me.set_subject_field(me.meta); + me.set_status_field(); + + for (let idx in values) { + let value = values[idx]; + + if (me.fields.length === parseInt(me.dialog.get_values().total_fields)) { + break; + } else if (value != me.subject_field.fieldname) { + let field = frappe.meta.get_docfield(me.doctype, value); + if (field) { + me.fields.push({ + label: field.label, + fieldname: field.fieldname + }); + } + } + } + + me.refresh(); + me.dialog.set_value("fields", JSON.stringify(me.fields)); + d.hide(); + }); + d.show(); + } + + reset_listview_fields(dialog) { + let me = this; + + frappe.xcall("frappe.desk.doctype.list_view_settings.list_view_settings.get_default_listview_fields", { + doctype: me.doctype + }).then((fields) => { + let field = dialog.get_field("fields"); + field.df.options = me.get_doctype_fields(me.meta, fields); + dialog.refresh(); + }); + + } + + get_listview_fields(meta) { + let me = this; + + if (!me.settings.fields) { + me.set_list_view_fields(meta); + } else { + me.fields = JSON.parse(this.settings.fields); + } + + me.fields.uniqBy(f => f.fieldname); + } + + set_list_view_fields(meta) { + let me = this; + + me.set_subject_field(meta); + me.set_status_field(); + + meta.fields.forEach(field => { + if (field.in_list_view && !in_list(frappe.model.no_value_type, field.fieldtype) && + me.subject_field.fieldname != field.fieldname) { + + me.fields.push({ + label: field.label, + fieldname: field.fieldname + }); + } + }); + } + + set_subject_field(meta) { + let me = this; + + me.subject_field = { + label: "Name", + fieldname: "name" + }; + + if (meta.title_field) { + let field = frappe.meta.get_docfield(me.doctype, meta.title_field.trim()); + + me.subject_field = { + label: field.label, + fieldname: field.fieldname + }; + } + + me.fields.push(me.subject_field); + } + + set_status_field() { + let me = this; + + if (frappe.has_indicator(me.doctype)) { + me.fields.push({ + type: "Status", + label: "Status", + fieldname: "status_field" + }); + } + } + + get_doctype_fields(meta, fields) { + let multiselect_fields = []; + + meta.fields.forEach(field => { + if (!in_list(frappe.model.no_value_type, field.fieldtype)) { + multiselect_fields.push({ + label: field.label, + value: field.fieldname, + checked: in_list(fields, field.fieldname) + }); + } + }); + + return multiselect_fields; + } + + get_removed_listview_fields(new_fields, existing_fields) { + let me = this; + let removed_fields = []; + + if (frappe.has_indicator(me.doctype)) { + new_fields.push("status_field"); + } + + existing_fields.forEach(column => { + if (!in_list(new_fields, column)) { + removed_fields.push(column); + } + }); + + return removed_fields; + } + + set_removed_fields(fields) { + let me = this; + + if (me.removed_fields) { + me.removed_fields.concat(fields); + } else { + me.removed_fields = fields; + } + } +} diff --git a/frappe/public/js/frappe/list/list_sidebar_group_by.js b/frappe/public/js/frappe/list/list_sidebar_group_by.js index 3d64c42f6a..d9324297a7 100644 --- a/frappe/public/js/frappe/list/list_sidebar_group_by.js +++ b/frappe/public/js/frappe/list/list_sidebar_group_by.js @@ -103,7 +103,11 @@ frappe.views.ListGroupBy = class ListGroupBy { this.render_dropdown_items(field_count_list, fieldtype, dropdown); frappe.utils.setup_search(dropdown, '.group-by-item', '.group-by-value', 'data-name'); } else { - dropdown.find('.group-by-loading').html(`${__("No filters found")}`); + dropdown.html( + `
+ ${__("No filters found")} +
` + ); } }); }); diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index dd9362d664..f2cba7c038 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1,4 +1,5 @@ import BulkOperations from "./bulk_operations"; +import ListSettings from "./list_settings"; frappe.provide('frappe.views'); @@ -231,6 +232,20 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } + refresh_columns(meta, list_view_settings) { + this.meta = meta; + this.list_view_settings = list_view_settings; + + this.setup_columns(); + this.refresh(true); + } + + refresh(refresh_header=false) { + super.refresh().then(() => { + this.render_header(refresh_header); + }); + } + setup_freeze_area() { this.$freeze = $(`
${__('Loading')}...
`) @@ -287,19 +302,49 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { })) ); - // limit max to 8 columns + if (this.list_view_settings.fields) { + this.columns = this.reorder_listview_fields(); + } + + // limit max to 8 columns if no total_fields is set in List View Settings // Screen with low density no of columns 4 // Screen with medium density no of columns 6 // Screen with high density no of columns 8 - let column_count = 6; + let total_fields = 6; - if (window.innerWidth <= 1200) { - column_count = 4; - } else if (window.innerWidth > 1440) { - column_count = 8; + if (window.innerWidth <= 1366) { + total_fields = 4; + } else if (window.innerWidth >= 1920) { + total_fields = 8; } - this.columns = this.columns.slice(0, column_count); + this.columns = this.columns.slice(0, this.list_view_settings.total_fields || total_fields); + } + + reorder_listview_fields() { + let fields_order = []; + let fields = JSON.parse(this.list_view_settings.fields); + + //title_field is fixed + fields_order.push(this.columns[0]); + this.columns.splice(0, 1); + + for (let fld in fields) { + for (let col in this.columns) { + let field = fields[fld]; + let column = this.columns[col]; + + if (column.type == "Status" && field.fieldname == "status_field") { + fields_order.push(column); + break; + } else if (column.type == "Field" && field.fieldname === column.df.fieldname) { + fields_order.push(column); + break; + } + } + } + + return fields_order; } get_documentation_link() { @@ -386,7 +431,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } } - render_header() { + render_header(refresh_header=false) { + if (refresh_header) { + this.$result.find('.list-row-head').remove(); + } + if (this.$result.find('.list-row-head').length === 0) { // append header once this.$result.prepend(this.get_header_html()); @@ -1284,18 +1333,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } show_list_settings() { - frappe.model.with_doctype("List View Setting", () => { - let d = new frappe.ui.Dialog({ - title: __("Settings"), - fields: frappe.get_meta("List View Setting").fields - }); - d.set_values(this.list_view_settings); - d.show(); - d.set_primary_action(__('Save'), () => { - let values = d.get_values(); - frappe.call("frappe.desk.listview.set_list_settings", {doctype: this.doctype, values: values}); - Object.assign(this.list_view_settings, values); - d.hide(); + frappe.model.with_doctype(this.doctype, () => { + new ListSettings({ + listview: this, + doctype: this.doctype, + settings: this.list_view_settings, + meta: frappe.get_meta(this.doctype) }); }); } diff --git a/frappe/public/js/frappe/model/meta.js b/frappe/public/js/frappe/model/meta.js index b7ad52838c..c2fd6b1ae6 100644 --- a/frappe/public/js/frappe/model/meta.js +++ b/frappe/public/js/frappe/model/meta.js @@ -161,8 +161,7 @@ $.extend(frappe.meta, { if(!out) { // eslint-disable-next-line - console.log(__('Warning: Unable to find {0} in any table related to {1}', [ - key, __(doctype)])); + console.log(__('Warning: Unable to find {0} in any table related to {1}', [key, __(doctype)])); } } return out; @@ -266,5 +265,5 @@ $.extend(frappe.meta, { precision = cint(frappe.defaults.get_default("float_precision")) || 3; } return precision; - }, + } }); diff --git a/frappe/public/js/frappe/recorder/RecorderDetail.vue b/frappe/public/js/frappe/recorder/RecorderDetail.vue index 68269ad0f4..53b3c8720b 100644 --- a/frappe/public/js/frappe/recorder/RecorderDetail.vue +++ b/frappe/public/js/frappe/recorder/RecorderDetail.vue @@ -114,8 +114,8 @@ export default { {label: "Time", slug: "time", sortable: true}, ], query: { - sort: "time", - order: "asc", + sort: "duration", + order: "desc", filters: {}, pagination: { limit: 20, diff --git a/frappe/public/js/frappe/recorder/RequestDetail.vue b/frappe/public/js/frappe/recorder/RequestDetail.vue index 60795076ec..ac349d7937 100644 --- a/frappe/public/js/frappe/recorder/RequestDetail.vue +++ b/frappe/public/js/frappe/recorder/RequestDetail.vue @@ -79,7 +79,7 @@ -
+
SQL Query #{{ call.index }} @@ -216,8 +216,8 @@ export default { {label: "Exact Copies", slug: "exact_copies", sortable: true}, ], query: { - sort: "index", - order: "asc", + sort: "duration", + order: "desc", pagination: { limit: 20, page: 1, diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js index 9983a35779..1411b6289d 100644 --- a/frappe/public/js/frappe/socketio_client.js +++ b/frappe/public/js/frappe/socketio_client.js @@ -287,7 +287,8 @@ frappe.socketio.SocketIOUploader = class SocketIOUploader { } function fallback_required() { - return !frappe.boot.sysdefaults.use_socketio_to_upload_file || !frappe.socketio.socket.connected; + return !frappe.socketio.socket.connected + || !( !frappe.boot.sysdefaults || frappe.boot.sysdefaults.use_socketio_to_upload_file ); } if (fallback_required()) { diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js index 0f4332a91a..d77481f8b9 100644 --- a/frappe/public/js/frappe/ui/dialog.js +++ b/frappe/public/js/frappe/ui/dialog.js @@ -42,6 +42,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { this.body = this.$body.get(0); this.$message = $('').appendTo(this.modal_body); this.header = this.$wrapper.find(".modal-header"); + this.buttons = this.header.find('.buttons'); + this.set_indicator(); // make fields (if any) super.make(); @@ -164,6 +166,11 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { set_title(t) { this.$wrapper.find(".modal-title").html(t); } + set_indicator() { + if (this.indicator) { + this.header.find('.indicator').removeClass().addClass('indicator ' + this.indicator); + } + } show() { // show it if ( this.animate ) { diff --git a/frappe/public/js/frappe/ui/filters/field_select.js b/frappe/public/js/frappe/ui/filters/field_select.js index 672991a554..65d32184e3 100644 --- a/frappe/public/js/frappe/ui/filters/field_select.js +++ b/frappe/public/js/frappe/ui/filters/field_select.js @@ -119,7 +119,14 @@ frappe.ui.FieldSelect = Class.extend({ // child tables $.each(me.table_fields, function(i, table_df) { if(table_df.options) { - var child_table_fields = [].concat(frappe.meta.docfield_list[table_df.options]); + let child_table_fields = [].concat(frappe.meta.docfield_list[table_df.options]); + + if (table_df.fieldtype === "Table MultiSelect") { + const link_field = frappe.meta.get_docfields(table_df.options) + .find(df => df.fieldtype === 'Link'); + child_table_fields = link_field ? [link_field] : []; + } + $.each(frappe.utils.sort(child_table_fields, "label", "string"), function(i, df) { // show fields where user has read access and if report hide flag is not set if(frappe.perm.has_perm(me.doctype, df.permlevel, "read")) @@ -130,15 +137,22 @@ frappe.ui.FieldSelect = Class.extend({ }, add_field_option(df) { - if (df.fieldname == 'docstatus' && !frappe.model.is_submittable(this.doctype)) + let me = this; + + if (df.fieldname == 'docstatus' && !frappe.model.is_submittable(me.doctype)) return; - var me = this; - var label, table; + if (frappe.model.table_fields.includes(df.fieldtype)) { + me.table_fields.push(df); + return; + } + + let label = null; + let table = null; + if(me.doctype && df.parent==me.doctype) { label = __(df.label); table = me.doctype; - if(frappe.model.table_fields.includes(df.fieldtype)) me.table_fields.push(df); } else { label = __(df.label) + ' (' + __(df.parent) + ')'; table = df.parent; diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index 818612d442..37eab50957 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -6,6 +6,12 @@ frappe.ui.Filter = class { } this.utils = frappe.ui.filter_utils; + this.set_conditions(); + this.set_conditions_from_config(); + this.make(); + } + + set_conditions() { this.conditions = [ ["=", __("Equals")], ["!=", __("Not Equals")], @@ -19,8 +25,7 @@ frappe.ui.Filter = class { [">=", ">="], ["<=", "<="], ["Between", __("Between")], - ["Previous", __("Previous")], - ["Next", __("Next")] + ["Timespan", __("Timespan")], ]; this.nested_set_conditions = [ @@ -35,17 +40,28 @@ frappe.ui.Filter = class { this.invalid_condition_map = { Date: ['like', 'not like'], Datetime: ['like', 'not like'], - Data: ['Between', 'Previous', 'Next'], - Select: ['like', 'not like', 'Between', 'Previous', 'Next'], - Link: ["Between", 'Previous', 'Next', '>', '<', '>=', '<='], - Currency: ["Between", 'Previous', 'Next'], - Color: ["Between", 'Previous', 'Next'], + Data: ['Between', 'Timespan'], + Select: ['like', 'not like', 'Between', 'Timespan'], + Link: ["Between", 'Timespan', '>', '<', '>=', '<='], + Currency: ["Between", 'Timespan'], + Color: ["Between", 'Timespan'], Check: this.conditions.map(c => c[0]).filter(c => c !== '=') }; - this.make(); - this.make_select(); - this.set_events(); - this.setup(); + } + + set_conditions_from_config() { + if (frappe.boot.additional_filters_config) { + this.filters_config = frappe.boot.additional_filters_config; + for (let key of Object.keys(this.filters_config)) { + const filter = this.filters_config[key]; + this.conditions.push([key, __(`{0}`, [filter.label])]); + for (let fieldtype of Object.keys(this.invalid_condition_map)) { + if (!filter.valid_for_fieldtypes.includes(fieldtype)) { + this.invalid_condition_map[fieldtype].push(filter.label); + } + } + } + } } make() { @@ -53,6 +69,10 @@ frappe.ui.Filter = class { conditions: this.conditions })) .appendTo(this.parent.find('.filter-edit-area')); + + this.make_select(); + this.set_events(); + this.setup(); } make_select() { @@ -203,33 +223,23 @@ frappe.ui.Filter = class { this.fieldselect.selected_doctype = doctype; this.fieldselect.selected_fieldname = fieldname; - if(["Previous", "Next"].includes(condition) && ['Date', 'Datetime', 'DateRange', 'Select'].includes(this.field.df.fieldtype)) { - df.fieldtype = 'Select'; - df.options = [ - { - label: __('1 week'), - value: '1 week' - }, - { - label: __('1 month'), - value: '1 month' - }, - { - label: __('3 months'), - value: '3 months' - }, - { - label: __('6 months'), - value: '6 months' - }, - { - label: __('1 year'), - value: '1 year' - } - ]; + if (this.filters_config && this.filters_config[condition] + && this.filters_config[condition].valid_for_fieldtypes.includes(df.fieldtype)) { + let args = {}; + if (this.filters_config[condition].depends_on) { + const field_name = this.filters_config[condition].depends_on; + const filter_value = this.base_list.get_filter_value(field_name); + args[field_name] = filter_value; + } + frappe.xcall(this.filters_config[condition].get_field, args).then(field => { + df.fieldtype = field.fieldtype; + df.options = field.options; + df.fieldname = fieldname; + this.make_field(df, cur.fieldtype); + }); + } else { + this.make_field(df, cur.fieldtype); } - - this.make_field(df, cur.fieldtype); } make_field(df, old_fieldtype) { @@ -440,6 +450,10 @@ frappe.ui.filter_utils = { if(condition == "Between" && (df.fieldtype == 'Date' || df.fieldtype == 'Datetime')){ df.fieldtype = 'DateRange'; } + if (condition == 'Timespan' && ['Date', 'Datetime', 'DateRange', 'Select'].includes(df.fieldtype)) { + df.fieldtype = 'Select'; + df.options = this.get_timespan_options(['Last', 'Today', 'This', 'Next']); + } if (condition === 'is') { df.fieldtype = 'Select'; df.options = [ @@ -447,5 +461,32 @@ frappe.ui.filter_utils = { { label: __('Not Set'), value: 'not set' }, ]; } + return; + }, + + get_timespan_options(periods) { + const period_map = { + 'Last': ['Week', 'Month', 'Quarter', '6 months', 'Year'], + 'Today': null, + 'This': ['Week', 'Month', 'Quarter', 'Year'], + 'Next': ['Week', 'Month', 'Quarter', '6 months', 'Year'] + }; + let options = []; + periods.forEach(period => { + if (period_map[period]) { + period_map[period].forEach(p => { + options.push({ + label: __(`{0} {1}`, [period, p]), + value: `${period.toLowerCase()} ${p.toLowerCase()}`, + }); + }); + } else { + options.push({ + label: __(`{0}`, [period]), + value: `${period.toLowerCase()}`, + }); + } + }); + return options; } }; diff --git a/frappe/public/js/frappe/ui/filters/filter_list.js b/frappe/public/js/frappe/ui/filters/filter_list.js index db6398ca78..ed9ddefe64 100644 --- a/frappe/public/js/frappe/ui/filters/filter_list.js +++ b/frappe/public/js/frappe/ui/filters/filter_list.js @@ -103,7 +103,8 @@ frappe.ui.FilterGroup = class { }, filter_items: (doctype, fieldname) => { return !this.filter_exists([doctype, fieldname]); - } + }, + base_list: this.base_list }; let filter = new frappe.ui.Filter(args); this.filters.push(filter); @@ -132,7 +133,6 @@ frappe.ui.FilterGroup = class { get_filters() { return this.filters.filter(f => f.field).map(f => { - f.freeze(); return f.get_value(); }); // {}: this.list.update_standard_filters(values); diff --git a/frappe/public/js/frappe/ui/filters/filters.js b/frappe/public/js/frappe/ui/filters/filters.js index 3646dc6b6e..a775413d39 100644 --- a/frappe/public/js/frappe/ui/filters/filters.js +++ b/frappe/public/js/frappe/ui/filters/filters.js @@ -200,6 +200,12 @@ frappe.ui.FilterList = Class.extend({ value = {0:"Draft", 1:"Submitted", 2:"Cancelled"}[value] || value; } else if(field.df.original_type==="Check") { value = {0:"No", 1:"Yes"}[cint(value)]; + } else if (field.df.original_type === "Duration") { + let duration_options = { + hide_days: field.df.hide_days, + hide_seconds: field.df.hide_seconds + }; + value = frappe.utils.get_formatted_duration(value, duration_options); } value = frappe.format(value, field.df, {only_value: 1}); diff --git a/frappe/public/js/frappe/ui/messages.js b/frappe/public/js/frappe/ui/messages.js index c6bc994a9d..ab20feeedd 100644 --- a/frappe/public/js/frappe/ui/messages.js +++ b/frappe/public/js/frappe/ui/messages.js @@ -53,6 +53,33 @@ frappe.confirm = function(message, ifyes, ifno) { return d; } +frappe.warn = function(title, message_html, proceed_action, primary_label) { + const d = new frappe.ui.Dialog({ + title: title, + indicator: 'red', + fields: [ + { + fieldtype: 'HTML', + fieldname: 'warning_message', + options: `
${message_html}
` + } + ], + primary_action_label: primary_label, + primary_action: () => { + if (proceed_action) proceed_action(); + d.hide(); + }, + secondary_action_label: __("Cancel"), + }); + + d.buttons.find('.btn-primary').removeClass('btn-primary').addClass('btn-danger'); + const modal_footer = $(``).insertAfter($(d.modal_body)); + modal_footer.html(d.buttons); + + d.show(); + return d; +}; + frappe.prompt = function(fields, callback, title, primary_label) { if (typeof fields === "string") { fields = [{ diff --git a/frappe/public/js/frappe/ui/notifications/notifications.js b/frappe/public/js/frappe/ui/notifications/notifications.js index 2420d6772e..3570420c81 100644 --- a/frappe/public/js/frappe/ui/notifications/notifications.js +++ b/frappe/public/js/frappe/ui/notifications/notifications.js @@ -153,12 +153,12 @@ frappe.ui.Notifications = class Notifications { let title = target ? `title="${__('Your Target')}"` : ''; let $list_item = !target ? $(`
  • - ${label} + ${__(label)} ${value}
  • `) : $(`
  • - ${label} + ${__(label)}
    @@ -304,10 +304,7 @@ frappe.ui.Notifications = class Notifications { } get_dropdown_item_html(field) { - let doc_link = frappe.utils.get_form_link( - field.document_type, - field.document_name - ); + let doc_link = this.get_item_link(field); let read_class = field.read ? '' : 'unread'; let mark_read_action = field.read ? '': 'data-action="mark_as_read"'; let message = field.subject; @@ -336,6 +333,17 @@ frappe.ui.Notifications = class Notifications { return item_html; } + get_item_link(notification_doc) { + const link_doctype = + notification_doc.type == 'Alert' ? 'Notification Log': notification_doc.document_type; + const link_docname = + notification_doc.type == 'Alert' ? notification_doc.name: notification_doc.document_name; + return frappe.utils.get_form_link( + link_doctype, + link_docname + ); + } + render_dropdown_headers() { this.categories = [ { diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 7d2c20c693..38c22c9c9f 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -292,6 +292,25 @@ Object.assign(frappe.utils, { return frappe.utils.guess_style(text, null, true); }, + get_indicator_color: function(state) { + return frappe.db.get_list('Workflow State', {filters: {name: state}, fields: ['name', 'style']}).then(res => { + const state = res[0]; + if (!state.style) { + return frappe.utils.guess_colour(state.name); + } + const style = state.style; + const colour_map = { + "Success": "green", + "Warning": "orange", + "Danger": "red", + "Primary": "blue", + }; + + return colour_map[style]; + }); + + }, + sort: function(list, key, compare_type, reverse) { if(!list || list.length < 2) return list || []; @@ -803,6 +822,70 @@ Object.assign(frappe.utils, { name: M[0], version: M[1], }; + }, + + get_formatted_duration(value, duration_options) { + let duration = ''; + if (value) { + let total_duration = frappe.utils.seconds_to_duration(value, duration_options); + + if (total_duration.days) { + duration += total_duration.days + __('d', null, 'Days (Field: Duration)'); + } + if (total_duration.hours) { + duration += (duration.length ? " " : ""); + duration += total_duration.hours + __('h', null, 'Hours (Field: Duration)'); + } + if (total_duration.minutes) { + duration += (duration.length ? " " : ""); + duration += total_duration.minutes + __('m', null, 'Minutes (Field: Duration)'); + } + if (total_duration.seconds) { + duration += (duration.length ? " " : ""); + duration += total_duration.seconds + __('s', null, 'Seconds (Field: Duration)'); + } + } + return duration; + }, + + seconds_to_duration(value, duration_options) { + let secs = value; + let total_duration = { + days: Math.floor(secs / (3600 * 24)), + hours: Math.floor(secs % (3600 * 24) / 3600), + minutes: Math.floor(secs % 3600 / 60), + seconds: Math.floor(secs % 60) + }; + if (duration_options.hide_days) { + total_duration.hours = Math.floor(secs / 3600); + total_duration.days = 0; + } + return total_duration; + }, + + duration_to_seconds(days=0, hours=0, minutes=0, seconds=0) { + let value = 0; + if (days) { + value += days * 24 * 60 * 60; + } + if (hours) { + value += hours * 60 * 60; + } + if (minutes) { + value += minutes * 60; + } + if (seconds) { + value += seconds; + } + return value; + }, + + get_duration_options: function(docfield) { + let duration_options = { + hide_days: docfield.hide_days, + hide_seconds: docfield.hide_seconds + }; + return duration_options; } }); @@ -823,3 +906,115 @@ if (!Array.prototype.uniqBy) { } }); } + +// Pluralize +String.prototype.plural = function(revert) { + const plural = { + "(quiz)$": "$1zes", + "^(ox)$": "$1en", + "([m|l])ouse$": "$1ice", + "(matr|vert|ind)ix|ex$": "$1ices", + "(x|ch|ss|sh)$": "$1es", + "([^aeiouy]|qu)y$": "$1ies", + "(hive)$": "$1s", + "(?:([^f])fe|([lr])f)$": "$1$2ves", + "(shea|lea|loa|thie)f$": "$1ves", + sis$: "ses", + "([ti])um$": "$1a", + "(tomat|potat|ech|her|vet)o$": "$1oes", + "(bu)s$": "$1ses", + "(alias)$": "$1es", + "(octop)us$": "$1i", + "(ax|test)is$": "$1es", + "(us)$": "$1es", + "([^s]+)$": "$1s", + }; + + const singular = { + "(quiz)zes$": "$1", + "(matr)ices$": "$1ix", + "(vert|ind)ices$": "$1ex", + "^(ox)en$": "$1", + "(alias)es$": "$1", + "(octop|vir)i$": "$1us", + "(cris|ax|test)es$": "$1is", + "(shoe)s$": "$1", + "(o)es$": "$1", + "(bus)es$": "$1", + "([m|l])ice$": "$1ouse", + "(x|ch|ss|sh)es$": "$1", + "(m)ovies$": "$1ovie", + "(s)eries$": "$1eries", + "([^aeiouy]|qu)ies$": "$1y", + "([lr])ves$": "$1f", + "(tive)s$": "$1", + "(hive)s$": "$1", + "(li|wi|kni)ves$": "$1fe", + "(shea|loa|lea|thie)ves$": "$1f", + "(^analy)ses$": "$1sis", + "((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$": + "$1$2sis", + "([ti])a$": "$1um", + "(n)ews$": "$1ews", + "(h|bl)ouses$": "$1ouse", + "(corpse)s$": "$1", + "(us)es$": "$1", + s$: "", + }; + + const irregular = { + move: "moves", + foot: "feet", + goose: "geese", + sex: "sexes", + child: "children", + man: "men", + tooth: "teeth", + person: "people", + }; + + const uncountable = [ + "sheep", + "fish", + "deer", + "moose", + "series", + "species", + "money", + "rice", + "information", + "equipment", + ]; + + // save some time in the case that singular and plural are the same + if (uncountable.indexOf(this.toLowerCase()) >= 0) return this; + + // check for irregular forms + let word; + let pattern; + let replace; + for (word in irregular) { + if (revert) { + pattern = new RegExp(irregular[word] + "$", "i"); + replace = word; + } else { + pattern = new RegExp(word + "$", "i"); + replace = irregular[word]; + } + if (pattern.test(this)) return this.replace(pattern, replace); + } + + let array; + if (revert) array = singular; + else array = plural; + + // check for matches using regular expressions + let reg; + for (reg in array) { + pattern = new RegExp(reg, "i"); + + if (pattern.test(this)) return this.replace(pattern, array[reg]); + } + + return this; +}; diff --git a/frappe/public/js/frappe/utils/web_page_block.js b/frappe/public/js/frappe/utils/web_page_block.js deleted file mode 100644 index bcf821bcfa..0000000000 --- a/frappe/public/js/frappe/utils/web_page_block.js +++ /dev/null @@ -1,23 +0,0 @@ -frappe.ui.form.on('Web Page Block', { - edit_values(frm, cdt, cdn) { - let row = frm.selected_doc; - frappe.model.with_doc('Web Template', row.web_template).then(doc => { - let d = new frappe.ui.Dialog({ - title: __('Edit Values'), - fields: doc.fields, - primary_action(values) { - frappe.model.set_value( - cdt, - cdn, - 'web_template_values', - JSON.stringify(values) - ); - d.hide(); - } - }); - let values = JSON.parse(row.web_template_values || '{}'); - d.set_values(values); - d.show(); - }); - } -}); diff --git a/frappe/public/js/frappe/views/breadcrumbs.js b/frappe/public/js/frappe/views/breadcrumbs.js index 1c1049391f..0058310e3f 100644 --- a/frappe/public/js/frappe/views/breadcrumbs.js +++ b/frappe/public/js/frappe/views/breadcrumbs.js @@ -89,16 +89,21 @@ frappe.breadcrumbs = { breadcrumbs.module = frappe.breadcrumbs.module_map[breadcrumbs.module]; } - if(frappe.get_module(breadcrumbs.module)) { + let current_module = breadcrumbs.module + // Check if a desk page exists + if (frappe.boot.module_page_map[breadcrumbs.module]) { + breadcrumbs.module = frappe.boot.module_page_map[breadcrumbs.module]; + } + + if(frappe.get_module(current_module)) { // if module access exists - var module_info = frappe.get_module(breadcrumbs.module), + var module_info = frappe.get_module(current_module), icon = module_info && module_info.icon, label = module_info ? module_info.label : breadcrumbs.module; - if(module_info && !module_info.blocked && frappe.visible_modules.includes(module_info.module_name)) { $(repl('
  • %(label)s
  • ', - { module: breadcrumbs.module, label: __(label) })) + { module: breadcrumbs.module, label: __(breadcrumbs.module) })) .appendTo($breadcrumbs); } } diff --git a/frappe/public/js/frappe/views/desktop/desktop.js b/frappe/public/js/frappe/views/desktop/desktop.js index 51add61f07..60dbc928c9 100644 --- a/frappe/public/js/frappe/views/desktop/desktop.js +++ b/frappe/public/js/frappe/views/desktop/desktop.js @@ -26,14 +26,25 @@ export default class Desktop { } make_container() { - this.container = $(`
    + this.container = $(` +
    -
    +
    +
    +
    + +
    +
    +
    +
    `); this.container.appendTo(this.wrapper); this.sidebar = this.container.find(".desk-sidebar"); this.body = this.container.find(".desk-body"); + this.current_title = this.container.find(".current-title"); + this.mobile_list = this.container.find(".mobile-list"); + this.page_switcher = this.container.find(".page-switcher"); } fetch_desktop_settings() { @@ -73,7 +84,9 @@ export default class Desktop { this.current_page = item.name; } let $item = get_sidebar_item(item); - $item.appendTo(this.sidebar); + + $item.appendTo(this.mobile_list); + $item.clone().appendTo(this.sidebar); this.sidebar_items[item.name] = $item; }; @@ -84,6 +97,7 @@ export default class Desktop { `` ); $title.appendTo(this.sidebar); + $title.clone().appendTo(this.mobile_list); }; this.sidebar_categories.forEach(category => { @@ -94,6 +108,11 @@ export default class Desktop { }); } }); + if (frappe.is_mobile) { + this.page_switcher.on('click', () => { + this.mobile_list.toggle(); + }); + } } show_page(page) { @@ -106,6 +125,8 @@ export default class Desktop { this.sidebar_items[page].addClass("selected"); } this.current_page = page; + this.mobile_list.hide(); + this.current_title.empty().append(this.current_page); localStorage.current_desk_page = page; this.pages[page] ? this.pages[page].show() : this.make_page(page); } diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 36ad9c5bd4..5475c302b7 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -10,6 +10,10 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { return 'Report'; } + render_header() { + // Override List View Header + } + setup_defaults() { super.setup_defaults(); this.page_title = __('Report:') + ' ' + this.page_title; @@ -182,7 +186,6 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { if (this.group_by) { this.$charts_wrapper.addClass('hidden'); } else if (this.chart) { - this.$charts_wrapper.removeClass('hidden'); this.refresh_charts(); } @@ -514,7 +517,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { } refresh_charts() { - if (!this.chart) return; + if (!this.chart || !this.chart_args) return; + this.$charts_wrapper.removeClass('hidden'); const { x_axis, y_axes, chart_type } = this.chart_args; this.build_chart_args(x_axis, y_axes, chart_type); this.chart.update(this.chart_args); @@ -1091,8 +1095,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { get_checked_items(only_docnames) { const indexes = this.datatable.rowmanager.getCheckedRows(); - const items = indexes.filter(i => i != undefined) - .map(i => this.data[i]); + const items = indexes.map(i => this.data[i]).filter(i => i != undefined); if (only_docnames) { return items.map(d => d.name); @@ -1258,7 +1261,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { }, { label: __('Toggle Sidebar'), - action: () => this.toggle_side_bar() + action: () => this.toggle_side_bar(), + shortcut: 'Ctrl+K', }, { label: __('Pick Columns'), diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 4dc1a50bc4..8cde4c9ba5 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -139,6 +139,16 @@ export default class WebForm extends frappe.ui.FieldGroup { this.handle_success(response.message); frappe.web_form.events.trigger('after_save'); this.after_save && this.after_save(); + // args doctype and docname added to link doctype in file manager + frappe.call({ + type: 'POST', + method: "frappe.handler.upload_file", + args: { + file_url: response.message.attachment, + doctype: response.message.doctype, + docname: response.message.name + } + }); } }, always: function() { diff --git a/frappe/public/js/frappe/web_form/webform_script.js b/frappe/public/js/frappe/web_form/webform_script.js index 53d9701774..c3211de99f 100644 --- a/frappe/public/js/frappe/web_form/webform_script.js +++ b/frappe/public/js/frappe/web_form/webform_script.js @@ -95,6 +95,11 @@ frappe.ready(function() { }; df.fields = form_data[df.fieldname]; + $.each(df.fields || [], function(_i, field) { + if (field.fieldtype === "Link") { + field.only_select = true; + } + }); if (df.fieldtype === "Attach") { df.is_private = true; diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index e5378cf2ab..8a0eca9eaf 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -97,7 +97,6 @@ export default class ChartWidget extends Widget { this.chart_settings = {}; } this.setup_container(); - this.prepare_chart_object(); if (!this.in_customize_mode) { this.action_area.empty(); this.prepare_chart_actions(); @@ -110,7 +109,10 @@ export default class ChartWidget extends Widget { this.render_time_series_filters(); } } - this.fetch_and_update_chart(); + frappe.run_serially([ + () => this.prepare_chart_object(), + () => this.fetch_and_update_chart(), + ]); }); } @@ -412,6 +414,8 @@ export default class ChartWidget extends Widget { dialog.show(); //Set query report object so that it can be used while fetching filter values in the report frappe.query_report = new frappe.views.QueryReport({'filters': dialog.fields_list}); + frappe.query_reports[this.chart_doc.report_name].onload + && frappe.query_reports[this.chart_doc.report_name].onload(frappe.query_report); dialog.set_values(this.filters); } @@ -577,7 +581,7 @@ export default class ChartWidget extends Widget { colors.push(field.color); }); } else if (["Line", "Bar"].includes(this.chart_doc.type)) { - colors = [this.chart_doc.color || "light-blue"]; + colors = [this.chart_doc.color || []]; } else if (this.chart_doc.type == "Heatmap") { colors = []; } @@ -623,13 +627,41 @@ export default class ChartWidget extends Widget { } prepare_chart_object() { - let saved_filters = this.chart_settings.filters || null; - this.filters = - saved_filters || this.filters || JSON.parse(this.chart_doc.filters_json || "[]"); - if (this.chart_doc.type == 'Heatmap' && !this.chart_doc.heatmap_year) { this.chart_doc.heatmap_year = frappe.dashboard_utils.get_year(frappe.datetime.now_date()); } + + return this.set_chart_filters(); + } + + set_chart_filters() { + let user_saved_filters = this.chart_settings.filters || null; + let chart_saved_filters = JSON.parse(this.chart_doc.filters_json || "null"); + + if (this.chart_doc.chart_type == 'Report') { + return frappe.dashboard_utils + .get_filters_for_chart_type(this.chart_doc).then(filters => { + chart_saved_filters = this.update_default_date_filters(filters, chart_saved_filters); + this.filters = + user_saved_filters || this.filters || chart_saved_filters; + }); + } else { + this.filters = + user_saved_filters || this.filters || chart_saved_filters; + return Promise.resolve(); + } + } + + update_default_date_filters(report_filters, chart_filters) { + report_filters.map(f => { + if (['Date', 'DateRange'].includes(f.fieldtype) && f.default) { + if (f.reqd || chart_filters[f.fieldname]) { + chart_filters[f.fieldname] = f.default; + } + } + }); + + return chart_filters; } get_settings() { diff --git a/frappe/public/js/frappe/widgets/onboarding_widget.js b/frappe/public/js/frappe/widgets/onboarding_widget.js index 821824a2d2..5d6de572ce 100644 --- a/frappe/public/js/frappe/widgets/onboarding_widget.js +++ b/frappe/public/js/frappe/widgets/onboarding_widget.js @@ -293,6 +293,10 @@ export default class OnboardingWidget extends Widget { }); }; } else { + frappe.msgprint({ + message: __("You may continue with onboarding"), + title: __("Looks Great") + }); this.mark_complete(step); } }, diff --git a/frappe/public/js/frappe/widgets/shortcut_widget.js b/frappe/public/js/frappe/widgets/shortcut_widget.js index 572569839c..1de7e8bf75 100644 --- a/frappe/public/js/frappe/widgets/shortcut_widget.js +++ b/frappe/public/js/frappe/widgets/shortcut_widget.js @@ -29,9 +29,13 @@ export default class ShortcutWidget extends Widget { name: this.link_to, type: this.type, is_query_report: this.is_query_report, - doctype: this.ref_doctype + doctype: this.ref_doctype, }); + let filters = this.get_doctype_filter(); + if (this.type == "DocType" && filters) { + frappe.route_options = filters; + } frappe.set_route(route); }); } @@ -40,16 +44,26 @@ export default class ShortcutWidget extends Widget { if (this.in_customize_mode) return; this.widget.addClass("shortcut-widget-box"); - const get_filter = new Function(`return ${this.stats_filter}`); - if (this.type == "DocType" && this.stats_filter) { + + let filters = this.get_doctype_filter(); + if (this.type == "DocType" && filters) { frappe.db .count(this.link_to, { - filters: get_filter(), + filters: filters, }) .then((count) => this.set_count(count)); } } + get_doctype_filter() { + let count_filter = new Function(`return ${this.stats_filter}`)(); + if (count_filter) { + return count_filter; + } + + return null; + } + set_title() { if (this.icon) { this.title_field[0].innerHTML = `
    @@ -82,4 +96,4 @@ export default class ShortcutWidget extends Widget { buttons.appendTo(this.action_area); } -} +} \ No newline at end of file diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index c92bdc1b5f..dff4db807e 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -128,7 +128,7 @@ function go_to_list_with_filters(doctype, filters) { } function shorten_number(number, country) { - country = country || ''; + country = (country == 'India') ? country : ''; const number_system = get_number_system(country); let x = Math.abs(Math.round(number)); for (const map of number_system) { diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index 5c44533b37..d5cd6d9643 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -241,11 +241,14 @@ class ShortcutDialog extends WidgetDialog { if (this.dialog.get_value("type") == "DocType" && this.filter_group) { let filters = this.filter_group.get_filters(); - filters.forEach((arr) => { - stats_filter[arr[1]] = [arr[2], arr[3]]; - }); - data.stats_filter = JSON.stringify(stats_filter); + if (filters.length) { + filters.forEach((arr) => { + stats_filter[arr[1]] = [arr[2], arr[3]]; + }); + + data.stats_filter = JSON.stringify(stats_filter); + } } data.label = data.label diff --git a/frappe/public/less/controls.less b/frappe/public/less/controls.less index 564c77c07f..2b03b93f56 100644 --- a/frappe/public/less/controls.less +++ b/frappe/public/less/controls.less @@ -165,3 +165,76 @@ pointer-events: none; } } + +/* duration control */ + +.duration-picker { + position: absolute; + z-index: 999; + + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0,0,0,.15); + background: #fff; + border: 1px solid @border-color; + padding-top: 10px; + padding-left: 5px; + + &:after, + &:before { + border: solid transparent; + content: " "; + height: 0; + width: 0; + pointer-events: none; + position: absolute; + bottom: 100%; + left: 30px; + } + &:after { + border-color: rgba(255, 255, 255, 0); + border-bottom-color: #fff; + border-width: 8px; + margin-left: -8px; + } + &:before { + border-color: rgba(221, 221, 221, 0); + border-bottom-color: @border-color; + border-width: 9px; + margin-left: -9px; + } + + .row .col { + // for fixing layout in child table + padding-left: 0px !important; + padding-right: 0px !important; + } + + .duration-row { + margin: 7px; + display: flex; + } + + .duration-col { + margin-left: 2px; + } + + .duration-input { + width: 60px; + border: 1px solid rgba(0, 0, 0, 0.25) !important; + } + + .duration-input:focus { + outline: None; + } + + .duration-label { + justify-content: center; + } + + .picker-row { + display: flex; + margin-left: 5px; + margin-bottom: 3px; + margin-right: 12px; + } +} \ No newline at end of file diff --git a/frappe/public/less/desktop.less b/frappe/public/less/desktop.less index 0b17d75861..e68a093d25 100644 --- a/frappe/public/less/desktop.less +++ b/frappe/public/less/desktop.less @@ -3,8 +3,42 @@ .desk-container { margin-top: 20px; + .page-switcher { + border-radius: 5px; + display: none; + border: 1px solid @border-color; + background-color: @panel-bg; + padding: 8px 15px; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + .mobile-list { + display: none; + border-radius: 5px; + padding: 8px 15px; + border: 1px solid @border-color; + + .sidebar-item { + font-size: 12px; + font-weight: bold; + margin-bottom: 1px; + display: flex; + padding: 10px 15px; + border-radius: 4px; + text-decoration: none; + cursor: pointer; + text-rendering: optimizelegibility; + + &.selected { + background-color: @panel-bg; + } + } + } + .desk-sidebar { - width: 24rem; + width: 20rem; display: block; position: fixed; z-index: 1; @@ -69,7 +103,7 @@ } .desk-body { - padding-left: calc(24rem + 15px); + padding-left: 20rem; display: flex; flex-direction: column; height: 100%; @@ -103,6 +137,9 @@ .desk-body { padding-left: 15px !important; } + .page-switcher { + display: flex; + } } } @@ -390,6 +427,10 @@ margin-top: 20px; padding-right: 200px; + @media (max-width: 970px) { + padding-right: 0; + } + &.grid { display: grid; grid-template-columns: 1fr 1fr; @@ -410,6 +451,17 @@ &.grid-rows-5 { grid-template-rows: repeat(5, 1fr); } + + @media (max-width: 768px) { + grid-template-columns: 1fr; + &.grid-rows-2, + &.grid-rows-3, + &.grid-rows-4, + &.grid-rows-5 { + grid-template-columns: 1fr; + grid-auto-flow: row; + } + } } .onboarding-step { diff --git a/frappe/public/less/form_grid.less b/frappe/public/less/form_grid.less index 86482686d1..5cb04a252c 100644 --- a/frappe/public/less/form_grid.less +++ b/frappe/public/less/form_grid.less @@ -247,14 +247,23 @@ } } -.form-in-grid { +.base-grid() { background-color: white; - z-index: 1021; position: relative; + .transition(opacity .2s ease) +} + +.form-in-grid { overflow: hidden; height: 0; opacity: 0; - .transition(opacity .2s ease) + z-index: 1021; + .base-grid(); +} + +.recorder-form-in-grid { + z-index: 0; + .base-grid(); } .grid-row-open .form-in-grid { diff --git a/frappe/public/less/frappe-datatable.less b/frappe/public/less/frappe-datatable.less index 39ed2eebf3..54eecf2b3d 100644 --- a/frappe/public/less/frappe-datatable.less +++ b/frappe/public/less/frappe-datatable.less @@ -36,6 +36,7 @@ .dt-scrollable { max-height: calc(100vh - 250px); min-height: 100px; + scrollbar-width: thin; } table td.dt-cell { diff --git a/frappe/public/scss/base.scss b/frappe/public/scss/base.scss index 36a1df55ac..0b01a83b02 100644 --- a/frappe/public/scss/base.scss +++ b/frappe/public/scss/base.scss @@ -4,6 +4,7 @@ html { body { -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; font-size: 16px; color: $body-color; } @@ -18,6 +19,7 @@ h1 { font-weight: 800; line-height: 1.25; letter-spacing: -0.025em; + margin-bottom: 1rem; @include media-breakpoint-up(sm) { line-height: 2.5rem; @@ -32,6 +34,7 @@ h1 { h2 { font-size: $font-size-xl; font-weight: bold; + margin-bottom: 0.75rem; @include media-breakpoint-up(sm) { font-size: $font-size-2xl; diff --git a/frappe/public/scss/blog.scss b/frappe/public/scss/blog.scss new file mode 100644 index 0000000000..046158f23b --- /dev/null +++ b/frappe/public/scss/blog.scss @@ -0,0 +1,94 @@ +.blog-list { + display: flex; + flex-wrap: wrap; + margin-right: -15px; + margin-left: -15px; + + &.result { + border-bottom: none; + } +} + +.blog-card { + margin-bottom: 2rem; + position: relative; + width: 100%; + + .card-body { + display: flex; + flex-direction: column; + justify-content: space-between; + } + + .card-img-top { + width: 100%; + overflow: hidden; + height: 12rem; + + img { + width: 100%; + min-height: 100%; + } + + .default-cover { + height: 100%; + width: 100%; + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; + background: $gray-200; + + font-size: 1.2rem; + font-weight: 500; + color: $gray-600; + } + } + + .blog-card-footer { + display: flex; + align-items: center; + margin-top: 0.5rem; + + .avatar { + margin-right: 0.5rem; + border-radius: 50%; + } + } +} + +.blog-container { + font-size: 1rem; + max-width: 800px; + margin: 0px auto; + + .blog-title { + margin-top: 1rem; + + @include media-breakpoint-up(xl) { + line-height: 1; + font-size: $font-size-4xl; + } + } + + .blog-footer { + display: flex; + justify-content: space-between; + color: $text-muted; + margin-top: 3rem; + } + + .blog-intro { + font-size: 1.125rem; + font-weight: 400; + } + + .blog-content { + margin-bottom: 1rem; + + .blog-header { + margin-bottom: 3rem; + margin-top: 3rem; + } + } +} diff --git a/frappe/public/scss/doc.scss b/frappe/public/scss/doc.scss new file mode 100644 index 0000000000..1eb3422042 --- /dev/null +++ b/frappe/public/scss/doc.scss @@ -0,0 +1,278 @@ +$navbar-height: 7.625rem; +$navbar-height-lg: 4.5rem; + +.doc-layout { + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: $navbar-height; + // border-bottom: 1px solid $gray-200; + + @include media-breakpoint-up(lg) { + padding-top: $navbar-height-lg; + } +} + +.sidebar-column { + display: none; + + @include media-breakpoint-up(lg) { + display: block; + } +} + +.doc-container { + max-width: 1280px; + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.navbar-expand-lg .doc-container { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.doc-navbar { + background-color: white; + padding-left: 0; + padding-right: 0; + + .navbar-toggler { + margin-left: 0.75rem; + } + + .web-sidebar { + display: block; + border-top: 1px solid $gray-200; + + @include media-breakpoint-up(lg) { + display: none; + } + } + + .navbar-collapse { + height: calc(100vh - #{$navbar-height-lg}); + overflow: auto; + + @include media-breakpoint-up(lg) { + height: auto; + overflow: initial; + } + } + + .navbar-nav { + margin-left: -1rem; + margin-top: 0.75rem; + margin-bottom: 1.5rem; + + @include media-breakpoint-up(lg) { + margin-top: 0; + margin-bottom: 0; + } + } +} + +.doc-search-container { + display: flex; + margin-top: 0.75rem; + + @include media-breakpoint-up(lg) { + margin-top: 0; + } +} + +.doc-search { + position: relative; + width: 100%; + + @include media-breakpoint-up(lg) { + padding-left: 4rem; + padding-right: 4rem; + } + + .search-icon { + position: absolute; + left: 0; + top: 0; + width: 2.5rem; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + } + + svg { + color: $gray-600; + } + + input { + padding-left: 2.5rem; + } + + .dropdown-menu { + .dropdown-item { + padding: 1rem 0.75rem; + } + + .match { + background-color: $primary-light; + color: $primary; + font-weight: 500; + padding: 0 0.125rem; + } + } +} + +.doc-sidebar { + position: sticky; + top: $navbar-height; + padding-bottom: 4rem; + height: 100vh; + overflow: hidden; + + .web-sidebar { + height: 100%; + overflow: auto; + padding-top: 3rem; + padding-bottom: 4rem; + } + + @include media-breakpoint-up(lg) { + top: $navbar-height-lg; + } +} + +.doc-main .page-content-wrapper { + padding: 0 0 2rem 0; + + @include media-breakpoint-up(lg) { + padding: 0rem 4rem 4rem 4rem; + } +} + +.doc-sidebar-logo { + padding-top: 2.5rem; + padding-bottom: 2rem; +} + +.page-toc { + font-size: $font-size-sm; + + h5 { + font-size: $font-size-sm; + margin-bottom: 0.5rem; + color: $gray-500; + } + + > div { + padding-top: 3rem; + padding-bottom: 4rem; + position: sticky; + top: $navbar-height; + + @include media-breakpoint-up(lg) { + top: $navbar-height-lg; + } + } + + ul { + padding-left: 0; + list-style-type: none; + } + + li > ul { + padding-left: 0.5rem; + } + + a { + display: block; + padding: 0.25rem 0; + + color: $gray-600; + text-decoration: none; + font-weight: 500; + @include transition(); + + &:hover { + color: $gray-800; + } + } +} + +// typography styles for documentation content +.doc-content .from-markdown { + > :first-child { + margin-top: 3rem; + } + + h1 { + font-size: $font-size-3xl; + font-weight: 500; + } + + h1 + p { + font-size: $font-size-lg; + } + + h2 { + font-size: $font-size-2xl; + font-weight: 400; + } + + h3 { + font-size: $font-size-xl; + font-weight: 500; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + &::before { + height: 6rem; + margin-top: -6rem; + content: ''; + display: block; + visibility: hidden; + } + } + + h4 { + font-size: $font-size-lg; + font-weight: 500; + } + + strong { + font-weight: 600; + } + + table { + border-color: $gray-200; + } + + table thead { + background-color: $light; + } + + .table-bordered, + .table-bordered th, + .table-bordered td { + border-left: none; + border-right: none; + border-color: $gray-200; + } + + .table-bordered thead th, + .table-bordered thead td { + border-bottom-width: 1px; + } +} + +// next links +.btn-next-wrapper { + border-top: 1px solid $gray-200; + margin-top: 2rem; + padding-top: 1rem; + text-align: right; +} diff --git a/frappe/public/scss/markdown.scss b/frappe/public/scss/markdown.scss index 440a4cfe88..a77b8b941e 100644 --- a/frappe/public/scss/markdown.scss +++ b/frappe/public/scss/markdown.scss @@ -1,4 +1,5 @@ .from-markdown { + color: $gray-700; line-height: 1.625; > * + * { @@ -32,12 +33,11 @@ } > blockquote { - padding: 0.75rem 1rem; + padding: 1.25rem 1rem; font-size: $font-size-sm; font-weight: 500; - color: $gray-900; - border-left: 4px solid $yellow; - background-color: lighten($yellow, 42%); + border: 1px solid $gray-200; + border-left: 3px solid $yellow; border-top-left-radius: 0.1rem; border-bottom-left-radius: 0.1rem; border-top-right-radius: 0.375rem; @@ -49,11 +49,17 @@ margin-bottom: 0; } + b, strong { + color: $gray-800; + } + + h1, h2, h3, h4, h5, h6 { + color: $gray-900; + } + h1 + p { - max-width: 42rem; margin-top: 0.75rem; font-size: $font-size-base; - color: $gray-900; @include media-breakpoint-up(sm) { margin-top: 1.25rem; @@ -104,6 +110,7 @@ tr > td, tr > th { font-size: $font-size-sm; + padding: 0.5rem; } th:empty { @@ -114,4 +121,10 @@ border: 1px solid $gray-400; border-radius: 0.375rem; } + + code:not(.hljs) { + padding: 0 0.25rem; + background: $light; + border-radius: 0.125rem; + } } diff --git a/frappe/public/scss/page-builder.scss b/frappe/public/scss/page-builder.scss index f792209c24..defeb19e6e 100644 --- a/frappe/public/scss/page-builder.scss +++ b/frappe/public/scss/page-builder.scss @@ -1,13 +1,19 @@ .hero-subtitle { @extend .lead; + font-weight: 400; + color: $gray-600; max-width: 42rem; + font-size: 1rem; + + @include media-breakpoint-up(sm) { + font-size: 1.25rem; + } } .section-description { max-width: 56rem; margin-top: 0.5rem; font-size: $font-size-base; - color: $gray-900; @include media-breakpoint-up(lg) { font-size: $font-size-lg; @@ -88,16 +94,14 @@ } .card { - .card-title { - color: $black; - } - - .card-body { - color: $gray-900; - } + @include transition(); &:hover { - border-color: $gray-600; + border-color: $gray-500; + } + + .card-title { + line-height: 1; } &.card-sm { @@ -156,12 +160,20 @@ } .nav-tabs { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + // 1 pixel bottom padding so that the 2px active border is visible + padding-bottom: 1px; + .nav-link { - color: $gray-700; + color: $gray-800; font-weight: 500; border: none; padding: 1rem 0.5rem; margin-right: 2rem; + white-space: nowrap; + @include transition(); &:hover { color: $primary; @@ -171,7 +183,7 @@ .nav-link.active, .nav-item.show .nav-link { color: darken($primary, 5%); - background-color: #fff; + background-color: transparent; border-bottom: 2px solid $primary; } } @@ -183,7 +195,7 @@ .section-cta { padding: 3rem 2rem; text-align: center; - background-color: lighten($primary, 42%); + background-color: $primary-light; border-radius: 0.75rem; @include media-breakpoint-up(sm) { @@ -210,7 +222,6 @@ margin: 0 auto; margin-top: 0.5rem; font-size: $font-size-base; - color: $gray-900; @include media-breakpoint-up(md) { font-size: $font-size-lg; } @@ -220,7 +231,50 @@ margin: 0 auto; margin-top: 0.5rem; font-size: $font-size-xs; + } +} + +.section-small-cta { + padding: 1.8rem; + background-color: lighten($primary, 42%); + border-radius: 0.75rem; + display: flex; + flex-direction: column; + text-align: center; + + @include media-breakpoint-up(sm) { + flex-direction: column; + text-align: left; + } + + @include media-breakpoint-up(md) { + flex-direction: row; + justify-content: space-between; + + div { + align-self: center; + } + } + + .title { + max-width: 36rem; + font-size: $font-size-xl; + font-weight: 800; + line-height: 1.25; + @include media-breakpoint-up(md) { + font-size: $font-size-2xl; + } + } + .subtitle { + max-width: 36rem; + font-size: $font-size-base; color: $gray-900; + margin-bottom: 1.2rem; + + @include media-breakpoint-up(md) { + font-size: $font-size-lg; + margin-bottom: 0px; + } } } @@ -250,3 +304,93 @@ left: 16.67%; } } + +.testimonial { + text-align: center; +} + +.testimonial-logo img { + display: inline-block; + max-width: 10rem; + max-height: 2.5rem; +} + +.testimonial-content { + margin-left: auto; + margin-right: auto; + margin-top: 2rem; + max-width: 52rem; + font-size: $font-size-lg; + font-weight: 500; + + @include media-breakpoint-up(lg) { + font-size: $font-size-2xl; + } +} + +.testimonial-by { + font-size: $font-size-base; + margin-top: 2rem; + + &:before { + content: '—' + } + + @include media-breakpoint-up(lg) { + font-size: $font-size-lg; + } +} + +.split-section-content { + margin-top: 2rem; +} + +.section-image-grid { + display: flex; + flex-wrap: wrap; + width: 100%; + + // Offset for padding + margin-right: -2px; + margin-left: -2px; + + .image-container { + overflow: hidden; + border: 2px solid #fff; + border-radius: $border-radius; + + width: 100%; + max-height: 8rem; + + img { + width: 100%; + object-fit: cover; + } + + @include media-breakpoint-up(sm) { + &.wide { + max-width: 75%; + width: 75%; + max-height: 15rem; + height: 15rem; + + img { + width: 100%; + object-fit: cover; + } + } + + &.narrow { + max-width: 25%; + width: 25%; + max-height: 15rem; + height: 15rem; + + img { + height: 100%; + object-fit: cover; + } + } + } + } +} diff --git a/frappe/public/scss/sidebar.scss b/frappe/public/scss/sidebar.scss new file mode 100644 index 0000000000..4dc8e64631 --- /dev/null +++ b/frappe/public/scss/sidebar.scss @@ -0,0 +1,46 @@ +.web-sidebar { + padding-top: 2rem; + position: sticky; + top: 0; +} + +.sidebar-item a { + display: block; + padding: 0.25rem 0.5rem; + margin-top: 0.25rem; + border-radius: 0.375rem; + font-size: $font-size-sm; + color: $gray-600; + text-decoration: none; + font-weight: 500; + @include transition(); + + &:hover { + color: $gray-900; + } +} + +.sidebar-item a.active { + color: $primary; + background-color: $primary-light; +} + +.sidebar-item-icon { + width: 24px; + height: 24px; + display: inline-block; +} + +.sidebar-group { + margin-bottom: 1rem; + + h6 { + font-size: $font-size-sm; + margin-bottom: 0.75rem; + } + + > ul { + padding-left: 0.5rem; + margin-bottom: 2rem; + } +} diff --git a/frappe/public/scss/variables.scss b/frappe/public/scss/variables.scss index e5f3a47f6f..1339af29a9 100644 --- a/frappe/public/scss/variables.scss +++ b/frappe/public/scss/variables.scss @@ -1,20 +1,23 @@ -$gray-100: #fafbfc !default; -$gray-150: #f5f7fa !default; -$gray-200: #ebecf1 !default; -$gray-300: #d1d8dd !default; -$gray-400: #ced4da !default; -$gray-500: #adb5bd !default; -$gray-600: #8d99a6 !default; -$gray-700: #495057 !default; -$gray-800: #36414c !default; -$gray-900: #2e3338 !default; -$primary: #2490ef !default; +$gray-50: #F9FAFA !default; +$gray-100: #F4F5F6 !default; +$gray-200: #EEF0F2 !default; +$gray-300: #E2E6E9 !default; +$gray-400: #C8CFD5 !default; +$gray-500: #A6B1B9 !default; +$gray-600: #74808B !default; +$gray-700: #4C5A67 !default; +$gray-800: #313B44 !default; +$gray-900: #192734 !default; $black: #000 !default; +$primary: #2490ef !default; +$primary-light: lighten($primary, 42%) !default; +$light: $gray-50 !default; -$body-color: $gray-800 !default; +$body-color: $gray-700 !default; $text-muted: $gray-600 !default; $border-color: $gray-300 !default; +$headings-color: $gray-900 !default; $font-size-xs: 0.75rem !default; $font-size-sm: 0.875rem !default; @@ -33,20 +36,32 @@ $btn-font-size-lg: 1.125rem !default; $btn-line-height-lg: 1 !default; $btn-border-radius-lg: 0.5rem !default; $btn-border-radius: 0.375rem !default; -$btn-font-size: $font-size-sm; +$btn-font-size: $font-size-sm !default; $btn-padding-x: 1rem !default; $btn-padding-y: 0.5rem !default; $btn-font-weight: 500 !default; $navbar-nav-link-padding-x: 1rem !default; -$navbar-padding-y: 1rem; +$navbar-padding-y: 1rem !default; $card-border-radius: 0.75rem !default; -$card-spacer-y: 1rem !default; +$card-spacer-y: 0.5rem !default; $dropdown-font-size: $font-size-sm !default; $dropdown-border-radius: 0.375rem !default; $dropdown-item-padding-y: 0.5rem !default; $dropdown-item-padding-x: 0.5rem !default; +$grid-breakpoints: ( + xs: 0, + sm: 576px, + md: 768px, + lg: 992px, + xl: 1200px, + 2xl: 1440px +) !default; + @import '~bootstrap/scss/functions'; @import '~bootstrap/scss/variables'; +@import "~bootstrap/scss/mixins"; + +$code-color: $purple; diff --git a/frappe/public/scss/website-image.scss b/frappe/public/scss/website-image.scss index 8c32e821fe..d416c05650 100644 --- a/frappe/public/scss/website-image.scss +++ b/frappe/public/scss/website-image.scss @@ -55,6 +55,12 @@ img:after { width: 100%; } +.website-image-extra-small { + @include website-image; + width: 2.5rem; + height: 2.5rem; +} + .website-image-small { @include website-image; width: 5rem; diff --git a/frappe/public/scss/website.scss b/frappe/public/scss/website.scss index 30781c52c1..5164fb2eba 100644 --- a/frappe/public/scss/website.scss +++ b/frappe/public/scss/website.scss @@ -5,7 +5,10 @@ @import 'multilevel-dropdown'; @import 'website-image'; @import 'page-builder'; +@import 'blog'; @import 'markdown'; +@import 'sidebar'; +@import 'doc'; .container { padding-left: 1.25rem; @@ -14,26 +17,26 @@ @include media-breakpoint-up(sm) { .container { - padding-left: 1rem; - padding-right: 1rem; - } -} - -@include media-breakpoint-up(md) { - .container { - padding-left: 1rem; - padding-right: 1rem; + padding-left: 0; + padding-right: 0; } } @include media-breakpoint-up(lg) { .container { - padding-left: 1rem; - padding-right: 1rem; + padding-left: 2.5rem; + padding-right: 2.5rem; } } @include media-breakpoint-up(xl) { + .container { + padding-left: 5rem; + padding-right: 5rem; + } +} + +@include media-breakpoint-up(2xl) { .container { padding-left: 1.5rem; padding-right: 1.5rem; @@ -45,7 +48,7 @@ } .navbar-light .navbar-nav .nav-link { - color: $gray-900; + color: $gray-700; font-size: $font-size-sm; font-weight: 500; @@ -55,6 +58,14 @@ } } +.navbar-brand { + img { + display: inline-block; + max-width: 150px; + max-height: 25px; + } +} + .dropdown-menu { padding: 0.25rem; } @@ -135,11 +146,13 @@ a.card { .footer-logo { width: 5rem; height: 2rem; + object-fit: contain; + object-position: left; } .footer-link, .footer-child-item a { font-weight: 500; - color: $gray-900; + color: $gray-700; &:hover { color: $primary; @@ -148,8 +161,9 @@ a.card { } .footer-col-left, .footer-col-right { - padding-top: 1rem; + padding-top: 0.8rem; padding-bottom: 1rem; + line-height: 2; } .footer-col-right { @@ -270,7 +284,6 @@ h5.modal-title { } .btn-primary-light { - $primary-light: lighten($primary, 42%); @include button-variant( $background: $primary-light, $border: $primary-light, diff --git a/frappe/sessions.py b/frappe/sessions.py index cca40cbc55..d317d6caf3 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -33,7 +33,7 @@ def clear_sessions(user=None, keep_current=False, device=None, force=False): :param user: user name (default: current user) :param keep_current: keep current session (default: false) - :param device: delete sessions of this device (default: desktop) + :param device: delete sessions of this device (default: desktop, mobile) :param force: triggered by the user (default false) ''' @@ -49,13 +49,16 @@ def get_sessions_to_clear(user=None, keep_current=False, device=None): :param user: user name (default: current user) :param keep_current: keep current session (default: false) - :param device: delete sessions of this device (default: desktop) + :param device: delete sessions of this device (default: desktop, mobile) ''' if not user: user = frappe.session.user if not device: - device = frappe.session.data.device or "desktop" + device = ("desktop", "mobile") + + if not isinstance(device, (tuple, list)): + device = (device,) offset = 0 if user == frappe.session.user: @@ -68,12 +71,12 @@ def get_sessions_to_clear(user=None, keep_current=False, device=None): return frappe.db.sql_list(""" SELECT `sid` FROM `tabSessions` - WHERE user=%s - AND device=%s + WHERE user=%(user)s + AND device in %(device)s {condition} ORDER BY `lastupdate` DESC LIMIT 100 OFFSET {offset}""".format(condition=condition, offset=offset), - (user, device)) + {"user": user, "device": device}) def delete_session(sid=None, user=None, reason="Session Expired"): from frappe.core.doctype.activity_log.feed import logout_feed diff --git a/frappe/templates/doc.html b/frappe/templates/doc.html new file mode 100644 index 0000000000..bb3cb6ec77 --- /dev/null +++ b/frappe/templates/doc.html @@ -0,0 +1,187 @@ +{% extends "templates/base.html" %} +{%- from "templates/includes/navbar/navbar_items.html" import render_item -%} + +{% macro page_content() %} +{%- block page_content -%}{%- endblock -%} +{% endmacro %} + +{%- block head_include %} + +{% endblock -%} + +{%- block navbar -%} + +{%- endblock -%} + +{% block content %} + +{% macro main_content() %} +
    + {% block page_container %} +
    +
    + {{ page_content() }} +
    +
    + {% endblock %} +
    +{% endmacro %} + +{% macro container_attributes() -%} +id="page-{{ name or route | e }}" data-path="{{ pathname | e }}" +{%- if page_or_generator=="Generator" %}source-type="Generator" data-doctype="{{ doctype }}"{%- endif %} +{%- if source_content_type %}source-content-type="{{ source_content_type }}"{%- endif %} +{%- endmacro %} + +
    +
    + +
    + {{ main_content() }} +
    +
    +
    +
    On this page
    + {{ page_toc_html }} +
    +
    +
    +
    + +{% endblock %} + +{%- block script -%} + +{%- endblock -%} diff --git a/frappe/templates/includes/blog/blog.html b/frappe/templates/includes/blog/blog.html deleted file mode 100644 index 5afaeb6ab8..0000000000 --- a/frappe/templates/includes/blog/blog.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "templates/web.html" %} - -{% block title %}{{ blog_title or _("Blog") }}{% endblock %} -{% block header %}

    {{ blog_title or _("Blog") }}

    {% endblock %} -{% block hero %}{% endblock %} - -{% block page_content %} - - -
    -
    - {% include "templates/includes/list/list.html" %} -
    -
    -{% endblock %} - -{% block script %} - -{% endblock %} diff --git a/frappe/templates/includes/blog/blogger.html b/frappe/templates/includes/blog/blogger.html index 68df22786d..ef8f8257e8 100644 --- a/frappe/templates/includes/blog/blogger.html +++ b/frappe/templates/includes/blog/blogger.html @@ -1,7 +1,7 @@ {% from "frappe/templates/includes/macros.html" import square_image_with_fallback %}
    - {{ square_image_with_fallback(src=blogger_info.avatar, size='72px', alt=blogger_info.full_name, class='align-self-start mr-3 rounded') }} + {{ square_image_with_fallback(src=blogger_info.avatar, size='small', alt=blogger_info.full_name, class='align-self-start mr-3 rounded') }}
    {{ blogger_info.full_name }} diff --git a/frappe/templates/includes/breadcrumbs.html b/frappe/templates/includes/breadcrumbs.html index 3fda731372..4cb3ef5c0c 100644 --- a/frappe/templates/includes/breadcrumbs.html +++ b/frappe/templates/includes/breadcrumbs.html @@ -1,4 +1,4 @@ -{% if not no_breadcrumbs and parents %} +{%- if not no_breadcrumbs and parents -%}
    -{% endif %} +{%- endif -%} diff --git a/frappe/templates/includes/comments/comment.html b/frappe/templates/includes/comments/comment.html index 3fe3d7df58..1deb49bb3e 100644 --- a/frappe/templates/includes/comments/comment.html +++ b/frappe/templates/includes/comments/comment.html @@ -1,7 +1,7 @@ {% from "frappe/templates/includes/macros.html" import square_image_with_fallback %}
    - {{ square_image_with_fallback(src=frappe.get_gravatar(comment.comment_email or comment.sender), size='48px', alt=comment.sender_full_name, class='align-self-start mr-3') }} + {{ square_image_with_fallback(src=frappe.get_gravatar(comment.comment_email or comment.sender), size='extra-small', alt=comment.sender_full_name, class='align-self-start mr-3') }}
    diff --git a/frappe/templates/includes/macros.html b/frappe/templates/includes/macros.html index 3e822b8bf3..767bd59ec9 100644 --- a/frappe/templates/includes/macros.html +++ b/frappe/templates/includes/macros.html @@ -1,18 +1,6 @@ -{% macro square_image_with_fallback(src=None, size=None, alt=None, class="") %} +{% macro square_image_with_fallback(src=None, size='small', alt=None, class="") %} {% if src %} -{{ alt or '' }} + {% else %}
    {% endif %} diff --git a/frappe/templates/includes/navbar/navbar.html b/frappe/templates/includes/navbar/navbar.html index d669eee9d3..3ae0aef164 100644 --- a/frappe/templates/includes/navbar/navbar.html +++ b/frappe/templates/includes/navbar/navbar.html @@ -1,7 +1,13 @@
    @@ -95,7 +96,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" {%- macro render_text_field(df, doc) -%} {%- if doc.get(df.fieldname) != None -%}
    - {%- if df.fieldtype in ("Text", "Code", "Long Text") %}{%- endif %} + {%- if df.fieldtype in ("Text", "Code", "Long Text", "Text Editor") %}{%- endif %} {%- if df.fieldtype=="Code" %}
    {{ doc.get(df.fieldname) }}
    {% else -%} diff --git a/frappe/templates/styles/standard.css b/frappe/templates/styles/standard.css index f5eeb1c7fb..b87aa46d23 100644 --- a/frappe/templates/styles/standard.css +++ b/frappe/templates/styles/standard.css @@ -145,6 +145,11 @@ table.no-border, table.no-border td { margin: 3px 0px 3px; } +.print-format table td pre { + white-space: normal; + word-break: normal; +} + table td div { {% if not print_settings.allow_page_break_inside_tables %} /* needed to avoid partial cutting of text between page break in wkhtmltopdf */ diff --git a/frappe/templates/web.html b/frappe/templates/web.html index f6d78a8b14..2e7aea6b53 100644 --- a/frappe/templates/web.html +++ b/frappe/templates/web.html @@ -48,17 +48,27 @@ id="page-{{ name or route | e }}" data-path="{{ pathname | e }}" {%- if source_content_type %}source-content-type="{{ source_content_type }}"{%- endif %} {%- endmacro %} +{% macro sidebar() %} + +{% endmacro %} + {% if show_sidebar %}
    - -
    + {%- set columns = sidebar_columns or 2 -%} + {%- if not sidebar_right -%} + {{ sidebar() }} + {%- endif -%} +
    {{ main_content() }}
    + {%- if sidebar_right -%} + {{ sidebar() }} + {%- endif -%}
    {% else %} diff --git a/frappe/tests/test_listview.py b/frappe/tests/test_listview.py index 3a73301608..1ef72fdd32 100644 --- a/frappe/tests/test_listview.py +++ b/frappe/tests/test_listview.py @@ -10,14 +10,14 @@ from frappe.desk.listview import get_list_settings, set_list_settings, get_group class TestListView(unittest.TestCase): def setUp(self): - if frappe.db.exists("List View Setting", "DocType"): - frappe.delete_doc("List View Setting", "DocType") + if frappe.db.exists("List View Settings", "DocType"): + frappe.delete_doc("List View Settings", "DocType") def test_get_list_settings_without_settings(self): self.assertIsNone(get_list_settings("DocType"), None) def test_get_list_settings_with_default_settings(self): - frappe.get_doc({"doctype": "List View Setting", "name": "DocType"}).insert() + frappe.get_doc({"doctype": "List View Settings", "name": "DocType"}).insert() settings = get_list_settings("DocType") self.assertIsNotNone(settings) @@ -26,7 +26,7 @@ class TestListView(unittest.TestCase): self.assertEqual(settings.disable_sidebar_stats, 0) def test_get_list_settings_with_non_default_settings(self): - frappe.get_doc({"doctype": "List View Setting", "name": "DocType", "disable_count": 1}).insert() + frappe.get_doc({"doctype": "List View Settings", "name": "DocType", "disable_count": 1}).insert() settings = get_list_settings("DocType") self.assertIsNotNone(settings) @@ -36,16 +36,16 @@ class TestListView(unittest.TestCase): def test_set_list_settings_without_settings(self): set_list_settings("DocType", json.dumps({})) - settings = frappe.get_doc("List View Setting","DocType") + settings = frappe.get_doc("List View Settings","DocType") self.assertEqual(settings.disable_auto_refresh, 0) self.assertEqual(settings.disable_count, 0) self.assertEqual(settings.disable_sidebar_stats, 0) def test_set_list_settings_with_existing_settings(self): - frappe.get_doc({"doctype": "List View Setting", "name": "DocType", "disable_count": 1}).insert() + frappe.get_doc({"doctype": "List View Settings", "name": "DocType", "disable_count": 1}).insert() set_list_settings("DocType", json.dumps({"disable_count": 0, "disable_auto_refresh": 1})) - settings = frappe.get_doc("List View Setting","DocType") + settings = frappe.get_doc("List View Settings","DocType") self.assertEqual(settings.disable_auto_refresh, 1) self.assertEqual(settings.disable_count, 0) diff --git a/frappe/translate.py b/frappe/translate.py index c3dbdfe7ea..2fc2d3f328 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -119,21 +119,19 @@ def get_dict(fortype, name=None): messages += frappe.db.sql("select 'Role:', name from tabRole") messages += frappe.db.sql("select 'Module:', name from `tabModule Def`") - message_dict = make_dict_from_messages(messages) message_dict.update(get_dict_from_hooks(fortype, name)) - # remove untranslated message_dict = {k:v for k, v in iteritems(message_dict) if k!=v} - - if fortype=="boot": - message_dict.update(get_user_translations(frappe.local.lang)) - translation_assets[asset_key] = message_dict - cache.hset("translation_assets", frappe.local.lang, translation_assets, shared=True) - return translation_assets[asset_key] + translation_map = translation_assets[asset_key] + if fortype == "boot": + translation_map.update(get_user_translations(frappe.local.lang)) + + return translation_map + def get_dict_from_hooks(fortype, name): translated_dict = {} diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index f2e2319802..60179e98b4 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -6,6 +6,7 @@ from __future__ import unicode_literals, print_function from werkzeug.test import Client import os, re, sys, json, hashlib, requests, traceback +import functools from .html_utils import sanitize_html import frappe from frappe.utils.identicon import Identicon @@ -360,6 +361,7 @@ def decode_dict(d, encoding="utf-8"): return d +@functools.lru_cache() def get_site_name(hostname): return hostname.split(':')[0] diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 4b37e850f0..2701ca9bc6 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -116,12 +116,12 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, is_async=is_async, retry=retry+1) else: - frappe.log_error(method_name) + frappe.log_error(title=method_name) raise except: frappe.db.rollback() - frappe.log_error(method_name) + frappe.log_error(title=method_name) frappe.db.commit() print(frappe.get_traceback()) raise diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 0a04db2c3e..e4e9b65e49 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -3,18 +3,20 @@ """This module handles the On Demand Backup utility""" -from __future__ import unicode_literals, print_function +from __future__ import print_function, unicode_literals -#Imports -from frappe import _ -import os, frappe +import os from datetime import datetime + +import frappe +from frappe import _, conf from frappe.utils import cstr, get_url, now_datetime -#Global constants -verbose = 0 -from frappe import conf -#------------------------------------------------------------------------------- +# backup variable for backwards compatibility +verbose = False +_verbose = verbose + + class BackupGenerator: """ This class contains methods to perform On Demand Backup @@ -23,7 +25,8 @@ class BackupGenerator: If specifying db_file_name, also append ".sql.gz" """ def __init__(self, db_name, user, password, backup_path_db=None, backup_path_files=None, - backup_path_private_files=None, db_host="localhost", db_port=3306): + backup_path_private_files=None, db_host="localhost", db_port=3306, verbose=False): + global _verbose self.db_host = db_host self.db_port = db_port or 3306 self.db_name = db_name @@ -32,6 +35,8 @@ class BackupGenerator: self.backup_path_files = backup_path_files self.backup_path_db = backup_path_db self.backup_path_private_files = backup_path_private_files + self.verbose = verbose + _verbose = verbose def get_backup(self, older_than=24, ignore_files=False, force=False): """ @@ -103,7 +108,7 @@ class BackupGenerator: cmd_string = """tar -cf %s %s""" % (backup_path, files_path) err, out = frappe.utils.execute_in_shell(cmd_string) - if verbose: + if self.verbose: print('Backed up files', os.path.abspath(backup_path)) def take_dump(self): @@ -159,21 +164,22 @@ def 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))) -def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False): +def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): """this function is called from scheduler deletes backups older than 7 days takes backup""" - odb = new_backup(older_than, ignore_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=force) + odb = new_backup(older_than, ignore_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=force, verbose=verbose) return odb -def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False): +def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): delete_temp_backups(older_than = frappe.conf.keep_backups_for_hours or 24) odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ frappe.conf.db_password, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, db_host = frappe.db.host, - db_port = frappe.db.port) + db_port = frappe.db.port, + verbose=verbose) odb.get_backup(older_than, ignore_files, force=force) return odb @@ -202,20 +208,22 @@ def is_file_old(db_file_name, older_than=24): file_datetime = datetime.fromtimestamp\ (os.stat(db_file_name).st_ctime) if datetime.today() - file_datetime >= timedelta(hours = older_than): - if verbose: print("File is old") + if _verbose: + print("File is old") return True else: - if verbose: print("File is recent") + if _verbose: + print("File is recent") return False else: - if verbose: print("File does not exist") + if _verbose: + print("File does not exist") return True def get_backup_path(): backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups")) return backup_path -#------------------------------------------------------------------------------- def backup(with_files=False, backup_path_db=None, backup_path_files=None, quiet=False): "Backup" odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=True) @@ -225,6 +233,7 @@ def backup(with_files=False, backup_path_db=None, backup_path_files=None, quiet= "backup_path_private_files": odb.backup_path_private_files } + if __name__ == "__main__": """ is_file_old db_name user password db_host diff --git a/frappe/utils/bench_helper.py b/frappe/utils/bench_helper.py index 7c5d209179..c46b42b132 100644 --- a/frappe/utils/bench_helper.py +++ b/frappe/utils/bench_helper.py @@ -50,14 +50,16 @@ def app_group(ctx, site=False, force=False, verbose=False, profile=False): ctx.info_name = '' def get_sites(site_arg): - if site_arg and site_arg == 'all': + if site_arg == 'all': return frappe.utils.get_sites() - else: - if site_arg: - return [site_arg] - if os.path.exists('currentsite.txt'): - with open('currentsite.txt') as f: - return [f.read().strip()] + elif site_arg: + return [site_arg] + elif os.path.exists('currentsite.txt'): + with open('currentsite.txt') as f: + site = f.read().strip() + if site: + return [site] + return [] def get_app_commands(app): if os.path.exists(os.path.join('..', 'apps', app, app, 'commands.py'))\ diff --git a/frappe/utils/commands.py b/frappe/utils/commands.py index 99322b50ba..113014c135 100644 --- a/frappe/utils/commands.py +++ b/frappe/utils/commands.py @@ -27,6 +27,15 @@ def add_line_after(function): return empty_line +def add_line_before(function): + """Adds an extra line to STDOUT before the execution of a function this decorates""" + def empty_line(*args, **kwargs): + print() + result = function(*args, **kwargs) + return result + return empty_line + + def log(message, colour=''): """Coloured log outputs to STDOUT""" colours = { diff --git a/frappe/utils/dashboard.py b/frappe/utils/dashboard.py index f06f9272b8..c1884d62fb 100644 --- a/frappe/utils/dashboard.py +++ b/frappe/utils/dashboard.py @@ -89,10 +89,14 @@ def sync_dashboards(app=None): config = get_config(app_name, module_name) if config: frappe.flags.in_import = True - make_records(config.charts, "Dashboard Chart") - make_records(config.number_cards, "Number Card") - make_records(config.dashboards, "Dashboard") - frappe.flags.in_import = False + try: + make_records(config.charts, "Dashboard Chart") + make_records(config.number_cards, "Number Card") + make_records(config.dashboards, "Dashboard") + except Exception as e: + frappe.log_error(e, _("Dashboard Import Error")) + finally: + frappe.flags.in_import = False def make_records(config, doctype): if not config: diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 9796aa3c4a..a34f09a2be 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals # IMPORTANT: only import safe functions as this module will be included in jinja environment import frappe +from dateutil.parser._parser import ParserError import subprocess import operator import re, datetime, math, time @@ -43,8 +44,12 @@ def getdate(string_date=None): if is_invalid_date_string(string_date): return None - - return parser.parse(string_date).date() + try: + return parser.parse(string_date).date() + except ParserError: + frappe.throw(frappe._('{} is not a valid date string.').format( + frappe.bold(string_date) + ), title=frappe._('Invalid Date')) def get_datetime(datetime_str=None): if not datetime_str: @@ -174,7 +179,7 @@ def nowtime(): """return current time in hh:mm""" return now_datetime().strftime(TIME_FORMAT) -def get_first_day(dt, d_years=0, d_months=0): +def get_first_day(dt, d_years=0, d_months=0, as_str=False): """ Returns the first day of the month for the date specified by date object Also adds `d_years` and `d_months` if specified @@ -185,10 +190,27 @@ def get_first_day(dt, d_years=0, d_months=0): overflow_years, month = divmod(dt.month + d_months - 1, 12) year = dt.year + d_years + overflow_years - return datetime.date(year, month + 1, 1) + return datetime.date(year, month + 1, 1).strftime(DATE_FORMAT) if as_str else datetime.date(year, month + 1, 1) -def get_first_day_of_week(dt): - return dt - datetime.timedelta(days=dt.weekday()) +def get_quarter_start(dt, as_str=False): + date = getdate(dt) + quarter = (date.month - 1) // 3 + 1 + first_date_of_quarter = datetime.date(date.year, ((quarter - 1) * 3) + 1, 1) + return first_date_of_quarter.strftime(DATE_FORMAT) if as_str else first_date_of_quarter + +def get_first_day_of_week(dt, as_str=False): + dt = getdate(dt) + date = dt - datetime.timedelta(days=dt.weekday()) + return date.strftime(DATE_FORMAT) if as_str else date + +def get_year_start(dt, as_str=False): + dt = getdate(dt) + date = datetime.date(dt.year, 1, 1) + return date.strftime(DATE_FORMAT) if as_str else date + +def get_last_day_of_week(dt): + dt = get_first_day_of_week(dt) + return dt + datetime.timedelta(days=6) def get_last_day(dt): """ @@ -319,6 +341,34 @@ def format_datetime(datetime_string, format_string=None): formatted_datetime = datetime.strftime('%Y-%m-%d %H:%M:%S') return formatted_datetime +def format_duration(seconds, hide_days=False): + total_duration = { + 'days': math.floor(seconds / (3600 * 24)), + 'hours': math.floor(seconds % (3600 * 24) / 3600), + 'minutes': math.floor(seconds % 3600 / 60), + 'seconds': math.floor(seconds % 60) + } + + if hide_days: + total_duration['hours'] = math.floor(seconds / 3600) + total_duration['days'] = 0 + + duration = '' + if total_duration: + if total_duration.get('days'): + duration += str(total_duration.get('days')) + 'd' + if total_duration.get('hours'): + duration += ' ' if len(duration) else '' + duration += str(total_duration.get('hours')) + 'h' + if total_duration.get('minutes'): + duration += ' ' if len(duration) else '' + duration += str(total_duration.get('minutes')) + 'm' + if total_duration.get('seconds'): + duration += ' ' if len(duration) else '' + duration += str(total_duration.get('seconds')) + 's' + + return duration + def get_weekdays(): return ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] @@ -328,6 +378,27 @@ def get_weekday(datetime=None): weekdays = get_weekdays() return weekdays[datetime.weekday()] +def get_timespan_date_range(timespan): + date_range_map = { + "last week": [add_to_date(nowdate(), days=-7), nowdate()], + "last month": [add_to_date(nowdate(), months=-1), nowdate()], + "last quarter": [add_to_date(nowdate(), months=-3), nowdate()], + "last 6 months": [add_to_date(nowdate(), months=-6), nowdate()], + "last year": [add_to_date(nowdate(), years=-1), nowdate()], + "today": [nowdate(), nowdate()], + "this week": [get_first_day_of_week(nowdate(), as_str=True), nowdate()], + "this month": [get_first_day(nowdate(), as_str=True), nowdate()], + "this quarter": [get_quarter_start(nowdate(), as_str=True), nowdate()], + "this year": [get_year_start(nowdate(), as_str=True), nowdate()], + "next week": [nowdate(), add_to_date(nowdate(), days=7)], + "next month": [nowdate(), add_to_date(nowdate(), months=1)], + "next quarter": [nowdate(), add_to_date(nowdate(), months=3)], + "next 6 months": [nowdate(), add_to_date(nowdate(), months=6)], + "next year": [nowdate(), add_to_date(nowdate(), years=1)], + } + + return date_range_map.get(timespan) + def global_date_format(date, format="long"): """returns localized date in the form of January 1, 2012""" date = getdate(date) @@ -348,7 +419,7 @@ def flt(s, precision=None): if precision is not None: num = rounded(num, precision) except Exception: - num = 0 + num = 0.0 return num @@ -705,6 +776,8 @@ def image_to_base64(image, extn): from io import BytesIO buffered = BytesIO() + if extn.lower() == 'jpg': + extn = 'JPEG' image.save(buffered, extn) img_str = base64.b64encode(buffered.getvalue()) return img_str @@ -966,7 +1039,7 @@ def compare(val1, condition, val2): return ret -def get_filter(doctype, f): +def get_filter(doctype, f, filters_config=None): """Returns a _dict like { @@ -1001,7 +1074,15 @@ def get_filter(doctype, f): f.operator = "=" valid_operators = ("=", "!=", ">", "<", ">=", "<=", "like", "not like", "in", "not in", "is", - "between", "descendants of", "ancestors of", "not descendants of", "not ancestors of", "previous", "next") + "between", "descendants of", "ancestors of", "not descendants of", "not ancestors of", + "timespan", "previous", "next") + + if filters_config: + additional_operators = [] + for key in filters_config: + additional_operators.append(key.lower()) + valid_operators = tuple(set(valid_operators + tuple(additional_operators))) + if f.operator.lower() not in valid_operators: frappe.throw(frappe._("Operator must be one of {0}").format(", ".join(valid_operators))) @@ -1125,6 +1206,7 @@ def md_to_html(markdown_text): 'fenced-code-blocks': None, 'tables': None, 'header-ids': None, + 'toc': None, 'highlightjs-lang': None, 'html-classes': { 'table': 'table table-bordered', diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index d5b7a3136b..90abdeb6cd 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -6,6 +6,9 @@ import frappe import frappe.defaults import datetime from frappe.utils import get_datetime +from frappe.utils import add_to_date, getdate +from frappe.utils.data import get_last_day_of_week +from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_period_ending from six import string_types # global values -- used for caching @@ -73,3 +76,30 @@ def datetime_in_user_format(date_time): date_time = get_datetime(date_time) from frappe.utils import formatdate return formatdate(date_time.date()) + " " + date_time.strftime("%H:%M") + +def get_dates_from_timegrain(from_date, to_date, timegrain="Daily"): + from_date = getdate(from_date) + to_date = getdate(to_date) + + days = months = years = 0 + if "Daily" == timegrain: + days = 1 + elif "Weekly" == timegrain: + days = 7 + elif "Monthly" == timegrain: + months = 1 + elif "Quarterly" == timegrain: + months = 3 + + if "Weekly" == timegrain: + dates = [get_last_day_of_week(from_date)] + else: + dates = [get_period_ending(from_date, timegrain)] + + while getdate(dates[-1]) < getdate(to_date): + if "Weekly" == timegrain: + date = get_last_day_of_week(add_to_date(dates[-1], years=years, months=months, days=days)) + else: + date = get_period_ending(add_to_date(dates[-1], years=years, months=months, days=days), timegrain) + dates.append(date) + return dates \ No newline at end of file diff --git a/frappe/utils/error.py b/frappe/utils/error.py index c124410a7f..d0e21a4188 100644 --- a/frappe/utils/error.py +++ b/frappe/utils/error.py @@ -21,7 +21,7 @@ def make_error_snapshot(exception): if frappe.conf.disable_error_snapshot: return - logger = frappe.logger(__name__, with_more_info=False) + logger = frappe.logger(with_more_info=True) try: error_id = '{timestamp:s}-{ip:s}-{hash:s}'.format( @@ -123,22 +123,13 @@ def get_snapshot(exception, context=10): # add exception type, value and attributes if isinstance(evalue, BaseException): for name in dir(evalue): - # prevent py26 DeprecationWarning - if (name != 'messages' or sys.version_info < (2.6)) and not name.startswith('__'): + if name != 'messages' and not name.startswith('__'): value = pydoc.text.repr(getattr(evalue, name)) - - # render multilingual string properly - if isinstance(value, six.text_type): - value = eval(value) - s['exception'][name] = encode(value) # add all local values (of last frame) to the snapshot for name, value in locals.items(): - if isinstance(value, six.text_type): - value = eval(value) - - s['locals'][name] = pydoc.text.repr(value) + s['locals'][name] = value if isinstance(value, six.text_type) else pydoc.text.repr(value) return s diff --git a/frappe/utils/formatters.py b/frappe/utils/formatters.py index 46e3694e6f..d68102ae9e 100644 --- a/frappe/utils/formatters.py +++ b/frappe/utils/formatters.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe import datetime -from frappe.utils import formatdate, fmt_money, flt, cstr, cint, format_datetime, format_time +from frappe.utils import formatdate, fmt_money, flt, cstr, cint, format_datetime, format_time, format_duration from frappe.model.meta import get_field_currency, get_field_precision import re from six import string_types @@ -90,4 +90,8 @@ def format_value(value, df=None, doc=None, currency=None, translated=False): values = [v.get(link_field.fieldname, 'asdf') for v in value] return ', '.join(values) + elif df.get("fieldtype") == "Duration": + hide_days = df.hide_days + return format_duration(value, hide_days) + return value diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 0272ae16f4..e945039d0d 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -274,6 +274,10 @@ def update_global_search(doc): sync_value_in_queue(value) def update_global_search_for_all_web_pages(): + if frappe.conf.get('disable_global_search'): + return + + print('Update global search for all web pages...') routes_to_index = get_routes_to_index() for route in routes_to_index: add_route_to_global_search(route) diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index d571185bce..2863ec99cd 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -21,7 +21,8 @@ def get_jenv(): jenv.globals.update(get_jenv_customization('methods')) jenv.globals.update({ 'resolve_class': resolve_class, - 'inspect': inspect + 'inspect': inspect, + 'web_blocks': web_blocks }) frappe.local.jenv = jenv @@ -189,3 +190,30 @@ def inspect(var, render=True): else: html = "" return get_jenv().from_string(html).render(context) + +def web_blocks(blocks): + from frappe import get_doc + from frappe.website.doctype.web_page.web_page import get_web_blocks_html + + web_blocks = [] + for block in blocks: + doc = { + 'doctype': 'Web Page Block', + 'web_template': block['template'], + 'web_template_values': block['values'], + 'add_top_padding': 1, + 'add_bottom_padding': 1, + 'add_container': 1, + 'hide_block': 0, + 'css_class': '' + } + doc.update(block) + web_blocks.append(get_doc(doc)) + + out = get_web_blocks_html(web_blocks) + + html = out.html + for script in out.scripts: + html += ''.format(script) + + return html diff --git a/frappe/utils/logger.py b/frappe/utils/logger.py index 5a77434cde..89e3711b0f 100755 --- a/frappe/utils/logger.py +++ b/frappe/utils/logger.py @@ -1,30 +1,53 @@ +# imports - compatibility imports from __future__ import unicode_literals -import frappe + +# imports - standard imports import logging +import os from logging.handlers import RotatingFileHandler + +# imports - third party imports from six import text_type -default_log_level = logging.DEBUG -LOG_FILENAME = '../logs/{}-frappe.log'.format(frappe.local.site) +# imports - module imports +import frappe -def get_logger(module, with_more_info=True): + +default_log_level = logging.DEBUG +site = getattr(frappe.local, 'site', None) + + +def get_logger(module, with_more_info=False): + global site if module in frappe.loggers: return frappe.loggers[module] - formatter = logging.Formatter('[%(levelname)s] %(asctime)s | %(pathname)s:\n%(message)s') - # handler = logging.StreamHandler() + if not module: + module = "frappe" + with_more_info = True - handler = RotatingFileHandler( - LOG_FILENAME, maxBytes=100000, backupCount=20) - handler.setFormatter(formatter) + logfile = module + '.log' + site = getattr(frappe.local, 'site', None) + LOG_FILENAME = os.path.join('..', 'logs', logfile) + + logger = logging.getLogger(module) + logger.setLevel(frappe.log_level or default_log_level) + logger.propagate = False + + formatter = logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s') + handler = RotatingFileHandler(LOG_FILENAME, maxBytes=100_000, backupCount=20) + logger.addHandler(handler) +# + if site: + SITELOG_FILENAME = os.path.join(site, 'logs', logfile) + site_handler = RotatingFileHandler(SITELOG_FILENAME, maxBytes=100_000, backupCount=20) + site_handler.setFormatter(formatter) + logger.addHandler(site_handler) if with_more_info: handler.addFilter(SiteContextFilter()) - logger = logging.getLogger(module) - logger.setLevel(frappe.log_level or default_log_level) - logger.addHandler(handler) - logger.propagate = False + handler.setFormatter(formatter) frappe.loggers[module] = logger @@ -33,25 +56,9 @@ def get_logger(module, with_more_info=True): class SiteContextFilter(logging.Filter): """This is a filter which injects request information (if available) into the log.""" def filter(self, record): - record.msg = get_more_info_for_log() + text_type(record.msg) - return True - -def get_more_info_for_log(): - '''Adds Site, Form Dict into log entry''' - more_info = [] - site = getattr(frappe.local, 'site', None) - if site: - more_info.append('Site: {0}'.format(site)) - - form_dict = getattr(frappe.local, 'form_dict', None) - if form_dict: - more_info.append('Form Dict: {0}'.format(frappe.as_json(form_dict))) - - if more_info: - # to append a \n - more_info = more_info + [''] - - return '\n'.join(more_info) + if "Form Dict" not in text_type(record.msg): + record.msg = text_type(record.msg) + "\nSite: {0}\nForm Dict: {1}".format(site, getattr(frappe.local, 'form_dict', None)) + return True def set_log_level(level): '''Use this method to set log level to something other than the default DEBUG''' diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index 596595a160..749a41682f 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -7,17 +7,24 @@ Events: monthly weekly """ +# imports - compatibility imports +from __future__ import print_function, unicode_literals -from __future__ import unicode_literals, print_function +# imports - standard imports +import os +import time -import frappe, os, time +# imports - third party imports import schedule -from frappe.utils import now_datetime, get_datetime -from frappe.utils import get_sites -from frappe.installer import update_site_config + +# imports - module imports +import frappe from frappe.core.doctype.user.user import STANDARD_USERS +from frappe.installer import update_site_config +from frappe.utils import get_sites, now_datetime from frappe.utils.background_jobs import get_jobs + DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' def start_scheduler(): @@ -48,9 +55,8 @@ def enqueue_events_for_all_sites(): def enqueue_events_for_site(site): def log_and_raise(): - frappe.logger(__name__).error('Exception in Enqueue Events for Site {0}'.format(site) + - '\n' + frappe.get_traceback()) - raise # pylint: disable=misplaced-bare-raise + error_message = 'Exception in Enqueue Events for Site {0}\n{1}'.format(site, frappe.get_traceback()) + frappe.logger("scheduler").error(error_message) try: frappe.init(site=site) @@ -60,10 +66,10 @@ def enqueue_events_for_site(site): enqueue_events(site=site) - frappe.logger(__name__).debug('Queued events for site {0}'.format(site)) + frappe.logger("scheduler").debug('Queued events for site {0}'.format(site)) except frappe.db.OperationalError as e: if frappe.db.is_access_denied(e): - frappe.logger(__name__).debug('Access denied for site {0}'.format(site)) + frappe.logger("scheduler").debug('Access denied for site {0}'.format(site)) else: log_and_raise() except: diff --git a/frappe/website/context.py b/frappe/website/context.py index 5663199545..9d0f3d2067 100644 --- a/frappe/website/context.py +++ b/frappe/website/context.py @@ -120,7 +120,7 @@ def build_context(context): # determine templates to be used if not context.base_template_path: app_base = frappe.get_hooks("base_template") - context.base_template_path = app_base[0] if app_base else "templates/base.html" + context.base_template_path = app_base[-1] if app_base else "templates/base.html" if context.title_prefix and context.title and not context.title.startswith(context.title_prefix): context.title = '{0} - {1}'.format(context.title_prefix, context.title) diff --git a/frappe/website/dashboard_fixtures.py b/frappe/website/dashboard_fixtures.py new file mode 100644 index 0000000000..1ac7ca60ec --- /dev/null +++ b/frappe/website/dashboard_fixtures.py @@ -0,0 +1,36 @@ +import frappe + +def get_data(): + return frappe._dict({ + "dashboards": get_dashboards(), + "charts": get_charts(), + "number_cards": None, + }) + +def get_dashboards(): + return [{ + "name": "Website", + "dashboard_name": "Website", + "charts": [ + { "chart": "Website Analytics", "width": "Full" } + ] + }] + +def get_charts(): + return [{ + "chart_name": "Website Analytics", + "chart_type": "Report", + "custom_options": "{\"type\": \"line\", \"lineOptions\": {\"regionFill\": 1}, \"axisOptions\": {\"shortenYAxisNumbers\": 1}, \"tooltipOptions\": {}}", + "doctype": "Dashboard Chart", + "filters_json": "{}", + "group_by_type": "Count", + "is_custom": 1, + "is_public": 1, + "name": "Website Analytics", + "number_of_groups": 0, + "report_name": "Website Analytics", + "time_interval": "Yearly", + "timeseries": 0, + "timespan": "Last Year", + "type": "Line" + }] \ No newline at end of file diff --git a/frappe/website/desk_page/website/website.json b/frappe/website/desk_page/website/website.json index 1c6066d21e..9936f8d7e2 100644 --- a/frappe/website/desk_page/website/website.json +++ b/frappe/website/desk_page/website/website.json @@ -27,17 +27,22 @@ } ], "category": "Modules", - "charts": [], + "charts": [ + { + "chart_name": "Website Analytics" + } + ], "creation": "2020-03-02 14:13:51.089373", "developer_mode_only": 0, "disable_user_customization": 0, "docstatus": 0, "doctype": "Desk Page", "extends_another_page": 0, + "hide_custom": 0, "idx": 0, "is_standard": 1, "label": "Website", - "modified": "2020-04-26 13:03:49.094728", + "modified": "2020-05-28 13:53:10.736212", "modified_by": "Administrator", "module": "Website", "name": "Website", @@ -47,7 +52,7 @@ "pin_to_top": 0, "shortcuts": [ { - "color": "", + "color": "#cef6d1", "format": "{} Published", "label": "Blog Post", "link_to": "Blog Post", @@ -55,6 +60,7 @@ "type": "DocType" }, { + "color": "#cef6d1", "format": "{} Active", "label": "Blogger", "link_to": "Blogger", @@ -62,8 +68,11 @@ "type": "DocType" }, { + "color": "#cef6d1", + "format": "{} Published", "label": "Web Page", "link_to": "Web Page", + "stats_filter": "{ \"published\": 1 }", "type": "DocType" }, { diff --git a/frappe/website/doctype/blog_post/blog_post.js b/frappe/website/doctype/blog_post/blog_post.js index 7b260af8bc..7aa83f536d 100644 --- a/frappe/website/doctype/blog_post/blog_post.js +++ b/frappe/website/doctype/blog_post/blog_post.js @@ -3,6 +3,10 @@ frappe.ui.form.on('Blog Post', { refresh: function(frm) { + frappe.db.get_single_value('Blog Settings', 'show_cta_in_blog').then(value => { + frm.set_df_property("hide_cta", "hidden", !value); + }); + generate_google_search_preview(frm); }, title: function(frm) { diff --git a/frappe/website/doctype/blog_post/blog_post.json b/frappe/website/doctype/blog_post/blog_post.json index 3d24879c62..8821be1555 100644 --- a/frappe/website/doctype/blog_post/blog_post.json +++ b/frappe/website/doctype/blog_post/blog_post.json @@ -8,14 +8,16 @@ "engine": "InnoDB", "field_order": [ "title", - "published_on", - "published", - "read_time", - "disable_comments", - "column_break_3", "blog_category", "blogger", "route", + "read_time", + "column_break_3", + "published_on", + "published", + "featured", + "hide_cta", + "disable_comments", "section_break_5", "blog_intro", "content_type", @@ -83,7 +85,7 @@ "fieldtype": "Section Break" }, { - "description": "Description for listing page, in plain text, only a couple of lines. (max 140 characters)", + "description": "Description for listing page, in plain text, only a couple of lines. (max 200 characters)", "fieldname": "blog_intro", "fieldtype": "Small Text", "label": "Blog Intro" @@ -143,7 +145,8 @@ { "fieldname": "meta_image", "fieldtype": "Attach Image", - "label": "Meta Image" + "label": "Meta Image", + "mandatory_depends_on": "eval:doc.featured" }, { "fieldname": "section_break_20", @@ -165,8 +168,22 @@ "description": "in minutes", "fieldname": "read_time", "fieldtype": "Int", + "hidden": 1, "label": "Read Time", "read_only": 1 + }, + { + "default": "0", + "fieldname": "featured", + "fieldtype": "Check", + "label": "Featured" + }, + { + "default": "0", + "fieldname": "hide_cta", + "fieldtype": "Check", + "hidden": 1, + "label": "Hide CTA" } ], "has_web_view": 1, @@ -175,7 +192,7 @@ "is_published_field": "published", "links": [], "max_attachments": 5, - "modified": "2020-04-30 17:32:41.055883", + "modified": "2020-06-01 13:37:57.465434", "modified_by": "Administrator", "module": "Website", "name": "Blog Post", diff --git a/frappe/website/doctype/blog_post/blog_post.py b/frappe/website/doctype/blog_post/blog_post.py index 4596c60710..beffcdca25 100644 --- a/frappe/website/doctype/blog_post/blog_post.py +++ b/frappe/website/doctype/blog_post/blog_post.py @@ -30,22 +30,32 @@ class BlogPost(WebsiteGenerator): if not self.blog_intro: content = get_html_content_based_on_type(self, 'content', self.content_type) - self.blog_intro = content[:140] + self.blog_intro = content[:200] self.blog_intro = strip_html_tags(self.blog_intro) if self.blog_intro: - self.blog_intro = self.blog_intro[:140] + self.blog_intro = self.blog_intro[:200] + + if not self.meta_description: + self.meta_description = self.blog_intro[:140] + else: + self.meta_description = self.meta_description[:140] if self.published and not self.published_on: self.published_on = today() - # update posts - frappe.db.sql("""UPDATE `tabBlogger` SET `posts`=(SELECT COUNT(*) FROM `tabBlog Post` - WHERE IFNULL(`blogger`,'')=`tabBlogger`.`name`) - WHERE `name`=%s""", (self.blogger,)) + if self.featured: + if not self.meta_image: + frappe.throw(_("A featured post must have a cover image")) + self.reset_featured_for_other_blogs() self.set_read_time() + def reset_featured_for_other_blogs(self): + all_posts = frappe.get_all("Blog Post", {"featured": 1}) + for post in all_posts: + frappe.db.set_value("Blog Post", post.name, "featured", 0) + def on_update(self): super(BlogPost, self).on_update() clear_cache("writers") @@ -58,10 +68,14 @@ class BlogPost(WebsiteGenerator): if not cint(self.published): raise Exception("This blog has not been published yet!") + context.no_breadcrumbs = True + # temp fields context.full_name = get_fullname(self.owner) context.updated = global_date_format(self.published_on) context.social_links = self.fetch_social_links_info() + context.cta = self.fetch_cta() + context.enable_cta = not self.hide_cta and frappe.db.get_single_value("Blog Settings", "show_cta_in_blog", cache=True) if self.blogger: context.blogger_info = frappe.get_doc("Blogger", self.blogger).as_dict() @@ -90,27 +104,34 @@ class BlogPost(WebsiteGenerator): {"name": "Blog", "route": "/blog"}, {"label": context.category.title, "route":context.category.route}] + def fetch_cta(self): + if frappe.db.get_single_value("Blog Settings", "show_cta_in_blog", cache=True): + blog_settings = frappe.get_cached_doc("Blog Settings") + + return { + "show_cta_in_blog": 1, + "title": blog_settings.title, + "subtitle": blog_settings.subtitle, + "cta_label": blog_settings.cta_label, + "cta_url": blog_settings.cta_url + } + + return {} def fetch_social_links_info(self): + if not frappe.db.get_single_value("Blog Settings", "enable_social_sharing", cache=True): + return [] + url = frappe.local.site + "/" +self.route - social_url_map = { - "twitter": "https://twitter.com/intent/tweet?text=" +self.title + "&url=" + url, - "facebook": "https://www.facebook.com/sharer.php?u=" + url, - "linkedin": "https://www.linkedin.com/sharing/share-offsite/?url=" + url, - "email": "mailto:?subject=" + self.title + "&body=" + url, - } - social_link = [] - for link in frappe.get_cached_doc("Blog Settings").social_share_settings: - social_media = link.social_link_type + social_links = [ + { "icon": "twitter", "link": "https://twitter.com/intent/tweet?text=" + self.title + "&url=" + url }, + { "icon": "facebook", "link": "https://www.facebook.com/sharer.php?u=" + url }, + { "icon": "linkedin", "link": "https://www.linkedin.com/sharing/share-offsite/?url=" + url }, + { "icon": "envelope", "link": "mailto:?subject=" + self.title + "&body=" + url } + ] - social_link.append({ - 'icon': social_media if not social_media == 'email' else 'envelope', - 'url': social_url_map.get(social_media), - 'color': link.color, - 'background': link.background_color - }) - return social_link + return social_links def load_comments(self, context): context.comment_list = get_comment_list(self.doctype, self.name) @@ -133,8 +154,8 @@ class BlogPost(WebsiteGenerator): def get_list_context(context=None): list_context = frappe._dict( - template = "templates/includes/blog/blog.html", get_list = get_blog_list, + no_breadcrumbs = True, hide_filters = True, children = get_children(), # show_search = True, @@ -161,7 +182,8 @@ def get_list_context(context=None): else: list_context.parents = [{"name": _("Home"), "route": "/"}] - list_context.update(frappe.get_doc("Blog Settings", "Blog Settings").as_dict(no_default_fields=True)) + list_context.update(frappe.get_doc("Blog Settings").as_dict(no_default_fields=True)) + return list_context def get_children(): @@ -201,6 +223,9 @@ def get_blog_list(doctype, txt=None, filters=None, limit_start=0, limit_page_len select t1.title, t1.name, t1.blog_category, t1.route, t1.published_on, t1.read_time, t1.published_on as creation, + t1.read_time as read_time, + t1.featured as featured, + t1.meta_image as cover_image, t1.content as content, t1.content_type as content_type, t1.content_html as content_html, @@ -216,7 +241,7 @@ def get_blog_list(doctype, txt=None, filters=None, limit_start=0, limit_page_len where ifnull(t1.published,0)=1 and t1.blogger = t2.name %(condition)s - order by published_on desc, name asc + order by featured desc, published_on desc, name asc limit %(start)s, %(page_len)s""" % { "start": limit_start, "page_len": limit_page_length, "condition": (" and " + " and ".join(conditions)) if conditions else "" @@ -225,9 +250,9 @@ def get_blog_list(doctype, txt=None, filters=None, limit_start=0, limit_page_len posts = frappe.db.sql(query, as_dict=1) for post in posts: - post.content = get_html_content_based_on_type(post, 'content', post.content_type) - post.cover_image = find_first_image(post.content) + if not post.cover_image: + post.cover_image = find_first_image(post.content) post.published = global_date_format(post.creation) post.content = strip_html_tags(post.content) @@ -240,7 +265,7 @@ def get_blog_list(doctype, txt=None, filters=None, limit_start=0, limit_page_len post.avatar = post.avatar or "" post.category = frappe.db.get_value('Blog Category', post.blog_category, - ['route', 'title'], as_dict=True) + ['name', 'route', 'title'], as_dict=True) if post.avatar and (not "http:" in post.avatar and not "https:" in post.avatar) and not post.avatar.startswith("/"): post.avatar = "/" + post.avatar diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html index 12e5ccf2d7..dd3e59c3c1 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post.html +++ b/frappe/website/doctype/blog_post/templates/blog_post.html @@ -6,38 +6,57 @@ {% block page_content %}
    -
    +
    -
    - -

    {{ title }}

    -

    - {{ blog_intro }} -

    -
    - -
    - {{ frappe.format_date(published_on) }} - {% if read_time %} - · - {{ read_time }} min read - {% endif %} - {% if social_links %} - - {% endif %} +
    + +

    {{ title }}

    +

    + {{ blog_intro }} +

    +
    + + {%- if read_time -%} +  · + {{ read_time }} min read + {%- endif -%}
    -
    - {{ content }} +
    +
    + {{ content }}
    + {%- if enable_cta -%} + {{ web_blocks([ + { + 'template': "Section With Small CTA", + 'values': cta, + 'add_container': 0, + 'add_top_padding': 0, + 'add_bottom_padding': 0, + 'css_class': "my-5" + } + ]) + }} + {%- endif -%} + {% if blogger_info %}
    @@ -45,7 +64,7 @@ {% endif %} {% if not disable_comments %} -
    +
    {% include 'templates/includes/comments/comments.html' %}
    {% endif %} @@ -55,30 +74,3 @@ frappe.ready(() => frappe.set_search_path("/blog")) {% endblock %} - -{% block style %} - -{% endblock %} diff --git a/frappe/website/doctype/blog_post/templates/blog_post_list.html b/frappe/website/doctype/blog_post/templates/blog_post_list.html new file mode 100644 index 0000000000..8df47b7187 --- /dev/null +++ b/frappe/website/doctype/blog_post/templates/blog_post_list.html @@ -0,0 +1,42 @@ +{% extends "templates/web.html" %} +{% block title %}{{ blog_title or _("Blog") }}{% endblock %} +{% block hero %}{% endblock %} + +{% block page_content %} + +{{ web_blocks([ + { + 'template': "Hero", + 'values': { + 'title': blog_title or _("Blog"), + 'subtitle': blog_introduction or '', + }, + 'add_container': 0, + 'add_top_padding': 0, + 'add_bottom_padding': 0, + 'css_class': "py-5" + } + ]) +}} + +
    +
    +
    + {% if not result -%} +
    + {{ no_result_message or _("Nothing to show") }} +
    + {% else %} + {% for item in result %} + {{ item }} + {% endfor %} + {% endif %} +
    + +
    +
    +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/frappe/website/doctype/blog_post/templates/blog_post_row.html b/frappe/website/doctype/blog_post/templates/blog_post_row.html index dffe0ef81d..7daf27adc8 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post_row.html +++ b/frappe/website/doctype/blog_post/templates/blog_post_row.html @@ -1,38 +1,41 @@ {%- set post = doc -%} -
    -
    -
    -
    -
    -
    -
    {{ post.category.title }}
    -

    {{ post.title }}

    -

    {{ post.intro }}

    -
    -
    - {{ post.full_name }} - · - {{ frappe.format_date(post.published_on) }} - {% if post.comments %} - · - {% if post.comments == 1 %} - {{ _('1 comment') }} - {% else %} - {{ _('{0} comments').format(post.comments) }} - {% endif %} - {% endif %} - {% if post.read_time %} - · - {{ _('{0} min read').format(post.read_time) }} - {% endif %} -
    +
    +
    +
    + {% if post.cover_image %} + {{post.title}} - Cover Image + {% else %} +
    + {{ post.title }}
    -
    - {% if post.cover_image %} - {{post.title}} - Cover Image - {% endif %} + {% endif %} +
    +
    +
    +
    + {%- if post.featured -%} + {{ _('Featured') }} · + {%- endif -%} + {{ post.category.title }} +
    + {%- if post.featured -%} +
    {{ post.title }}
    + {%- else -%} +
    {{ post.title }}
    + {%- endif -%} +

    {{ post.intro }}

    +
    +
    +
    -
    +
    \ No newline at end of file diff --git a/frappe/website/doctype/blog_post/test_blog_post.py b/frappe/website/doctype/blog_post/test_blog_post.py index aecc813e9d..15634a7caf 100644 --- a/frappe/website/doctype/blog_post/test_blog_post.py +++ b/frappe/website/doctype/blog_post/test_blog_post.py @@ -19,7 +19,7 @@ class TestBlogPost(unittest.TestCase): self.assertTrue(response.status_code, 200) html = response.get_data().decode() - self.assertTrue('
    ' in html) + self.assertTrue('
    ' in html) def test_generator_not_found(self): pages = frappe.get_all('Blog Post', fields=['name', 'route'], diff --git a/frappe/website/doctype/blog_settings/blog_settings.json b/frappe/website/doctype/blog_settings/blog_settings.json index f0e51de170..73ea3ce877 100644 --- a/frappe/website/doctype/blog_settings/blog_settings.json +++ b/frappe/website/doctype/blog_settings/blog_settings.json @@ -7,9 +7,15 @@ "field_order": [ "blog_title", "blog_introduction", - "writers_introduction", - "section_break_4", - "social_share_settings" + "column_break", + "enable_social_sharing", + "show_cta_in_blog", + "cta_section", + "title", + "subtitle", + "column_break_11", + "cta_label", + "cta_url" ], "fields": [ { @@ -23,27 +29,62 @@ "label": "Blog Introduction" }, { - "fieldname": "writers_introduction", - "fieldtype": "Small Text", - "label": "Writers Introduction" + "default": "0", + "fieldname": "enable_social_sharing", + "fieldtype": "Check", + "label": "Enable Social Sharing" }, { "collapsible": 1, - "fieldname": "section_break_4", - "fieldtype": "Section Break" + "fieldname": "column_break", + "fieldtype": "Column Break" }, { - "fieldname": "social_share_settings", - "fieldtype": "Table", - "label": "Social Share Settings", - "options": "Social Link Settings" + "default": "0", + "fieldname": "show_cta_in_blog", + "fieldtype": "Check", + "label": "Show CTA in Blog" + }, + { + "depends_on": "eval:doc.show_cta_in_blog", + "fieldname": "cta_section", + "fieldtype": "Section Break", + "label": "CTA" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "mandatory_depends_on": "eval:doc.show_cta_in_blog" + }, + { + "fieldname": "subtitle", + "fieldtype": "Data", + "label": "Subtitle", + "mandatory_depends_on": "eval:doc.show_cta_in_blog" + }, + { + "fieldname": "cta_label", + "fieldtype": "Data", + "label": "CTA Label", + "mandatory_depends_on": "eval:doc.show_cta_in_blog" + }, + { + "fieldname": "cta_url", + "fieldtype": "Data", + "label": "CTA URL", + "mandatory_depends_on": "eval:doc.show_cta_in_blog" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2020-05-04 09:10:41.815238", + "modified": "2020-06-01 15:57:21.564652", "modified_by": "Administrator", "module": "Website", "name": "Blog Settings", @@ -57,6 +98,13 @@ "role": "Website Manager", "share": 1, "write": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "role": "Blogger", + "share": 1 } ], "sort_field": "modified", diff --git a/frappe/website/doctype/blog_settings/test_blog_settings.py b/frappe/website/doctype/blog_settings/test_blog_settings.py new file mode 100644 index 0000000000..e4ddb85c4b --- /dev/null +++ b/frappe/website/doctype/blog_settings/test_blog_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestBlogSettings(unittest.TestCase): + pass diff --git a/frappe/website/doctype/blogger/blogger.json b/frappe/website/doctype/blogger/blogger.json index b8165a5908..f7494e7ec5 100644 --- a/frappe/website/doctype/blogger/blogger.json +++ b/frappe/website/doctype/blogger/blogger.json @@ -13,8 +13,7 @@ "full_name", "user", "bio", - "avatar", - "posts" + "avatar" ], "fields": [ { @@ -51,20 +50,13 @@ }, { "fieldname": "avatar", - "fieldtype": "Attach", + "fieldtype": "Attach Image", "label": "Avatar" - }, - { - "fieldname": "posts", - "fieldtype": "Int", - "in_list_view": 1, - "label": "Posts", - "no_copy": 1, - "read_only": 1 } ], "icon": "fa fa-user", "idx": 1, + "image_field": "avatar", "links": [ { "link_doctype": "Blog Post", @@ -72,7 +64,7 @@ } ], "max_attachments": 1, - "modified": "2020-04-19 08:21:09.684300", + "modified": "2020-05-28 19:22:40.959895", "modified_by": "Administrator", "module": "Website", "name": "Blogger", diff --git a/frappe/website/doctype/web_form_field/web_form_field.json b/frappe/website/doctype/web_form_field/web_form_field.json index 90f9b24a16..ef7b1c4ddf 100644 --- a/frappe/website/doctype/web_form_field/web_form_field.json +++ b/frappe/website/doctype/web_form_field/web_form_field.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2014-09-01 14:14:14.292173", "doctype": "DocType", "editable_grid": 1, @@ -34,7 +35,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Fieldtype", - "options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nFloat\nHTML\nInt\nLink\nRating\nSelect\nSmall Text\nText\nText Editor\nTable\nSection Break\nColumn Break" + "options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nDuration\nFloat\nHTML\nInt\nLink\nRating\nSelect\nSmall Text\nText\nText Editor\nTable\nSection Break\nColumn Break" }, { "fieldname": "label", @@ -119,7 +120,8 @@ } ], "istable": 1, - "modified": "2019-06-07 12:17:10.547133", + "links": [], + "modified": "2020-05-13 13:35:08.454427", "modified_by": "Administrator", "module": "Website", "name": "Web Form Field", diff --git a/frappe/website/doctype/web_page/web_page.js b/frappe/website/doctype/web_page/web_page.js index c0a3bcdc20..b2e06efc79 100644 --- a/frappe/website/doctype/web_page/web_page.js +++ b/frappe/website/doctype/web_page/web_page.js @@ -2,9 +2,6 @@ // MIT License. See license.txt frappe.ui.form.on('Web Page', { - onload: function() { - frappe.require('/assets/frappe/js/frappe/utils/web_page_block.js'); - }, title: function(frm) { if (frm.doc.title && !frm.doc.route) { frm.set_value('route', frappe.scrub(frm.doc.title, '-')); @@ -48,3 +45,97 @@ frappe.ui.form.on('Web Page', { frappe.utils.set_meta_tag(frm.doc.route); } }); + +frappe.ui.form.on("Web Page Block", { + edit_values(frm, cdt, cdn) { + let row = frm.selected_doc; + frappe.model.with_doc("Web Template", row.web_template).then((doc) => { + let d = new frappe.ui.Dialog({ + title: __("Edit Values"), + fields: doc.fields.map((df) => { + if (df.fieldtype == "Section Break") { + df.collapsible = 1; + } + return df; + }), + primary_action(values) { + frappe.model.set_value( + cdt, + cdn, + "web_template_values", + JSON.stringify(values) + ); + d.hide(); + }, + }); + let values = JSON.parse(row.web_template_values || "{}"); + d.set_values(values); + d.show(); + + d.sections.forEach((sect) => { + let fields_with_value = sect.fields_list.filter( + (field) => values[field.df.fieldname] + ); + + if (fields_with_value.length) { + sect.collapse(false); + } + }); + }); + }, +}); + +frappe.tour['Web Page'] = [ + { + fieldname: "title", + title: __("Title of the page"), + description: __("This title will be used as the title of the webpage as well as in meta tags"), + }, + { + fieldname: "published", + title: __("Makes the page public"), + description: __("Checking this will publish the page on your website and it'll be visible to everyone."), + }, + { + fieldname: "route", + title: __("URL of the page"), + description: __("This will be automatically generated when you publish the page, you can also enter a route yourself if you wish"), + }, + { + fieldname: "content_type", + title: __("Content type for building the page"), + description: `${__('You can select one from the following,')}
    +
      +
    • ${__('Rich Text')}: ${__('Standard rich text editor with controls')}
    • +
    • ${__('Markdown')}: ${__('Github flavoured markdown syntax')}
    • +
    • ${__('HTML')}: ${__('HTML with jinja support')}
    • +
    • ${__('Page Builder')}: ${__('Frappe page builder using components')}
    • +
    + ` + }, + { + fieldname: "insert_code", + title: __("Client Script"), + description: __("Checking this will show a text area where you can write custom javascript that will run on this page."), + }, + { + fieldname: "meta_title", + title: __("Meta title for SEO"), + description: __("By default the title is used as meta title, adding a value here will override it."), + }, + { + fieldname: "meta_title", + title: __("Meta Title"), + description: __("By default the title is used as meta title, adding a value here will override it."), + }, + { + fieldname: "meta_description", + title: __("Meta Description"), + description: __("The meta description is an HTML attribute that provides a brief summary of a web page. Search engines such as Google often display the meta description in search results, which can influence click-through rates.") + }, + { + fieldname: "meta_image", + title: __("Meta Image"), + description: __("The meta image is unique image representing the content of the page. Images for this Card should be at least 280px in width, and at least 150px in height.") + }, +]; diff --git a/frappe/website/doctype/web_page_view/web_page_view.json b/frappe/website/doctype/web_page_view/web_page_view.json index 7a1a210d62..4243df39b1 100644 --- a/frappe/website/doctype/web_page_view/web_page_view.json +++ b/frappe/website/doctype/web_page_view/web_page_view.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_import": 1, "creation": "2020-04-15 22:54:46.009703", "doctype": "DocType", "editable_grid": 1, @@ -9,7 +10,9 @@ "referrer", "browser", "browser_version", - "date" + "is_unique", + "time_zone", + "user_agent" ], "fields": [ { @@ -39,15 +42,24 @@ "set_only_once": 1 }, { - "fieldname": "date", - "fieldtype": "Datetime", - "label": "Date", - "set_only_once": 1 + "fieldname": "is_unique", + "fieldtype": "Data", + "label": "Is Unique" + }, + { + "fieldname": "time_zone", + "fieldtype": "Data", + "label": "Time Zone" + }, + { + "fieldname": "user_agent", + "fieldtype": "Data", + "label": "User Agent" } ], "in_create": 1, "links": [], - "modified": "2020-04-15 23:31:27.517793", + "modified": "2020-05-05 14:11:24.718770", "modified_by": "Administrator", "module": "Website", "name": "Web Page View", diff --git a/frappe/website/doctype/web_template/web_template.json b/frappe/website/doctype/web_template/web_template.json index 9c0ef37b1c..18b36b9b81 100644 --- a/frappe/website/doctype/web_template/web_template.json +++ b/frappe/website/doctype/web_template/web_template.json @@ -16,7 +16,6 @@ "depends_on": "eval:!doc.standard", "fieldname": "template", "fieldtype": "Code", - "in_list_view": 1, "label": "Template", "options": "HTML" }, @@ -35,7 +34,7 @@ } ], "links": [], - "modified": "2020-04-17 14:05:28.499020", + "modified": "2020-05-15 17:50:51.856135", "modified_by": "Administrator", "module": "Website", "name": "Web Template", diff --git a/frappe/website/doctype/website_settings/website_settings.js b/frappe/website/doctype/website_settings/website_settings.js index 7fbb785acf..1575802f5f 100644 --- a/frappe/website/doctype/website_settings/website_settings.js +++ b/frappe/website/doctype/website_settings/website_settings.js @@ -9,8 +9,7 @@ frappe.ui.form.on('Website Settings', { if (!frm.doc.banner_image) { frappe.msgprint(__("Select a Brand Image first.")); } - frm.set_value("brand_html", ""); + frm.set_value("brand_html", ""); }, onload_post_render: function(frm) { diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json index 4fba1b0530..c91366c103 100644 --- a/frappe/website/doctype/website_settings/website_settings.json +++ b/frappe/website/doctype/website_settings/website_settings.json @@ -20,6 +20,7 @@ "favicon", "top_bar", "navbar_search", + "hide_login", "top_bar_items", "call_to_action", "call_to_action_url", @@ -344,6 +345,12 @@ "fieldname": "call_to_action_url", "fieldtype": "Data", "label": "Call To Action URL" + }, + { + "default": "0", + "fieldname": "hide_login", + "fieldtype": "Check", + "label": "Hide Login" } ], "icon": "fa fa-cog", @@ -351,7 +358,7 @@ "issingle": 1, "links": [], "max_attachments": 10, - "modified": "2020-05-11 07:14:37.302357", + "modified": "2020-05-15 14:12:32.907352", "modified_by": "Administrator", "module": "Website", "name": "Website Settings", diff --git a/frappe/website/doctype/website_settings/website_settings.py b/frappe/website/doctype/website_settings/website_settings.py index 5da49ae20b..aaaa1c2cbf 100644 --- a/frappe/website/doctype/website_settings/website_settings.py +++ b/frappe/website/doctype/website_settings/website_settings.py @@ -115,7 +115,7 @@ def get_website_settings(): }) settings = frappe.get_single("Website Settings") - for k in ["banner_html", "brand_html", "copyright", "twitter_share_via", + for k in ["banner_html", "banner_image", "brand_html", "copyright", "twitter_share_via", "facebook_share", "google_plus_one", "twitter_share", "linked_in_share", "disable_signup", "hide_footer_signup", "head_html", "title_prefix", "navbar_search", "enable_view_tracking", "footer_logo", "call_to_action", "call_to_action_url"]: @@ -156,6 +156,8 @@ def get_website_settings(): if settings.favicon and settings.favicon != "attach_files:": context["favicon"] = settings.favicon + context["hide_login"] = settings.hide_login + return context def get_items(parentfield): diff --git a/frappe/website/doctype/website_theme/website_theme.js b/frappe/website/doctype/website_theme/website_theme.js index 28b18a1bcd..75ecbe15e3 100644 --- a/frappe/website/doctype/website_theme/website_theme.js +++ b/frappe/website/doctype/website_theme/website_theme.js @@ -2,9 +2,6 @@ // MIT License. See license.txt frappe.ui.form.on('Website Theme', { - onload: function() { - frappe.require('/assets/frappe/js/frappe/utils/web_page_block.js'); - }, refresh(frm) { frm.clear_custom_buttons(); frm.toggle_display(["module", "custom"], !frappe.boot.developer_mode); diff --git a/frappe/website/doctype/website_theme/website_theme.json b/frappe/website/doctype/website_theme/website_theme.json index 1c5ef1b53d..7af6c91281 100644 --- a/frappe/website/doctype/website_theme/website_theme.json +++ b/frappe/website/doctype/website_theme/website_theme.json @@ -135,7 +135,7 @@ "options": "Color" }, { - "default": "300,600", + "default": "wght@300;400;500;600;700;800", "fieldname": "font_properties", "fieldtype": "Data", "label": "Font Properties" @@ -170,7 +170,7 @@ } ], "links": [], - "modified": "2020-05-11 16:01:04.654990", + "modified": "2020-05-16 18:36:22.203519", "modified_by": "Administrator", "module": "Website", "name": "Website Theme", diff --git a/frappe/website/js/website.js b/frappe/website/js/website.js index d400e7633c..c95345770d 100644 --- a/frappe/website/js/website.js +++ b/frappe/website/js/website.js @@ -112,13 +112,6 @@ $.extend(frappe, { opts.args.cmd = opts.method; } - // stringify - $.each(opts.args, function(key, val) { - if(typeof val != "string") { - opts.args[key] = JSON.stringify(val); - } - }); - if(!opts.no_spinner) { //NProgress.start(); } @@ -329,6 +322,22 @@ $.extend(frappe, { add_switch_to_desk: function() { $('.switch-to-desk').removeClass('hidden'); }, + add_link_to_headings: function() { + $('.doc-content .from-markdown').find('h2, h3, h4, h5, h6').each((i, $heading) => { + let id = $heading.id; + let $a = $('') + .prop('href', '#' + id) + .attr('aria-hidden', 'true') + .html(` + + + + + `); + $($heading).append($a); + }); + }, setup_lazy_images: function() { // Use IntersectionObserver to only load images that are visible in the viewport // Fallback for browsers that don't support it @@ -445,6 +454,7 @@ $(document).on("page-change", function() { frappe.trigger_ready(); frappe.bind_filters(); frappe.highlight_code_blocks(); + frappe.add_link_to_headings(); frappe.make_navbar_active(); // scroll to hash if (window.location.hash) { diff --git a/frappe/website/module_onboarding/website/website.json b/frappe/website/module_onboarding/website/website.json index b849a809ed..c010d27eea 100644 --- a/frappe/website/module_onboarding/website/website.json +++ b/frappe/website/module_onboarding/website/website.json @@ -10,7 +10,7 @@ "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/website", "idx": 0, "is_complete": 0, - "modified": "2020-04-30 20:23:06.438314", + "modified": "2020-05-28 13:51:57.535269", "modified_by": "Administrator", "module": "Website", "name": "Website", @@ -27,6 +27,9 @@ }, { "step": "Enable Website Tracking" + }, + { + "step": "Web Page Tour" } ], "subtitle": "Blogs, website view tracking, and more.", diff --git a/frappe/website/onboarding_step/add_blog_category/add_blog_category.json b/frappe/website/onboarding_step/add_blog_category/add_blog_category.json index a0d07c8464..71924f8848 100644 --- a/frappe/website/onboarding_step/add_blog_category/add_blog_category.json +++ b/frappe/website/onboarding_step/add_blog_category/add_blog_category.json @@ -6,11 +6,14 @@ "idx": 0, "is_complete": 0, "is_mandatory": 0, + "is_single": 0, "is_skipped": 0, - "modified": "2020-04-30 19:06:10.750976", + "modified": "2020-05-28 13:51:53.157256", "modified_by": "Administrator", "name": "Add Blog Category", "owner": "Administrator", "reference_document": "Blog Category", - "title": "Add Blog Category" + "show_full_form": 0, + "title": "Add Blog Category", + "validate_action": 0 } \ No newline at end of file diff --git a/frappe/website/onboarding_step/create_blogger/create_blogger.json b/frappe/website/onboarding_step/create_blogger/create_blogger.json index 5162e7e895..48a0997025 100644 --- a/frappe/website/onboarding_step/create_blogger/create_blogger.json +++ b/frappe/website/onboarding_step/create_blogger/create_blogger.json @@ -6,11 +6,14 @@ "idx": 0, "is_complete": 0, "is_mandatory": 0, + "is_single": 0, "is_skipped": 0, "modified": "2020-04-30 19:06:10.694419", "modified_by": "Administrator", "name": "Create Blogger", "owner": "Administrator", "reference_document": "Blogger", - "title": "Create Blogger" + "show_full_form": 0, + "title": "Create Blogger", + "validate_action": 0 } \ No newline at end of file diff --git a/frappe/website/onboarding_step/enable_website_tracking/enable_website_tracking.json b/frappe/website/onboarding_step/enable_website_tracking/enable_website_tracking.json index 56a4fa58b6..c445123243 100644 --- a/frappe/website/onboarding_step/enable_website_tracking/enable_website_tracking.json +++ b/frappe/website/onboarding_step/enable_website_tracking/enable_website_tracking.json @@ -7,12 +7,15 @@ "idx": 0, "is_complete": 0, "is_mandatory": 0, + "is_single": 0, "is_skipped": 0, "modified": "2020-04-30 20:22:50.778590", "modified_by": "Administrator", "name": "Enable Website Tracking", "owner": "Administrator", "reference_document": "Website Settings", + "show_full_form": 0, "title": "Enable Website Tracking", + "validate_action": 0, "value_to_validate": "1" } \ No newline at end of file diff --git a/frappe/website/onboarding_step/introduction_to_website/introduction_to_website.json b/frappe/website/onboarding_step/introduction_to_website/introduction_to_website.json index 683d0a889e..29c1d8ff60 100644 --- a/frappe/website/onboarding_step/introduction_to_website/introduction_to_website.json +++ b/frappe/website/onboarding_step/introduction_to_website/introduction_to_website.json @@ -6,11 +6,14 @@ "idx": 0, "is_complete": 0, "is_mandatory": 1, + "is_single": 0, "is_skipped": 0, - "modified": "2020-04-30 19:06:10.578218", + "modified": "2020-05-28 13:51:51.485924", "modified_by": "Administrator", "name": "Introduction to Website", "owner": "Administrator", + "show_full_form": 0, "title": "Introduction to Website", + "validate_action": 0, "video_url": "https://www.youtube.com/watch?v=lyW6mfFBSNw" } \ No newline at end of file diff --git a/frappe/website/onboarding_step/web_page_tour/web_page_tour.json b/frappe/website/onboarding_step/web_page_tour/web_page_tour.json new file mode 100644 index 0000000000..e73610d847 --- /dev/null +++ b/frappe/website/onboarding_step/web_page_tour/web_page_tour.json @@ -0,0 +1,19 @@ +{ + "action": "Show Form Tour", + "creation": "2020-05-13 12:32:15.966570", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_mandatory": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2020-05-28 13:51:54.264456", + "modified_by": "Administrator", + "name": "Web Page Tour", + "owner": "Administrator", + "reference_document": "Web Page", + "show_full_form": 0, + "title": "Learn about Web Pages", + "validate_action": 0 +} \ No newline at end of file diff --git a/frappe/website/render.py b/frappe/website/render.py index c1bca3f5c5..d5269ed1cb 100644 --- a/frappe/website/render.py +++ b/frappe/website/render.py @@ -216,7 +216,6 @@ def build_page(path): if context.source: html = frappe.render_template(context.source, context) - elif context.template: if path.endswith('min.js'): html = frappe.get_jloader().get_source(frappe.get_jenv(), context.template)[0] diff --git a/frappe/website/web_template/section_with_left_image/__init__.py b/frappe/website/report/__init__.py similarity index 100% rename from frappe/website/web_template/section_with_left_image/__init__.py rename to frappe/website/report/__init__.py diff --git a/frappe/website/report/website_analytics/__init__.py b/frappe/website/report/website_analytics/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/website/report/website_analytics/website_analytics.js b/frappe/website/report/website_analytics/website_analytics.js new file mode 100644 index 0000000000..9079949724 --- /dev/null +++ b/frappe/website/report/website_analytics/website_analytics.js @@ -0,0 +1,32 @@ +// Copyright (c) 2016, Frappe Technologies and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Website Analytics"] = { + "filters": [ + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_days(frappe.datetime.now_date(true), -100), + }, + { + fieldname:"to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.now_date(true), + }, + { + fieldname: "range", + label: __("Range"), + fieldtype: "Select", + options: [ + { "value": "Daily", "label": __("Daily") }, + { "value": "Weekly", "label": __("Weekly") }, + { "value": "Monthly", "label": __("Monthly") }, + ], + default: "Daily", + reqd: 1 + } + ] +}; diff --git a/frappe/website/report/website_analytics/website_analytics.json b/frappe/website/report/website_analytics/website_analytics.json new file mode 100644 index 0000000000..62c5751a5c --- /dev/null +++ b/frappe/website/report/website_analytics/website_analytics.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "creation": "2020-04-17 13:04:45.770148", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "idx": 0, + "is_standard": "Yes", + "modified": "2020-04-17 16:10:30.168312", + "modified_by": "Administrator", + "module": "Website", + "name": "Website Analytics", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Web Page View", + "report_name": "Website Analytics", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Website Manager" + } + ] +} \ No newline at end of file diff --git a/frappe/website/report/website_analytics/website_analytics.py b/frappe/website/report/website_analytics/website_analytics.py new file mode 100644 index 0000000000..97c330fed9 --- /dev/null +++ b/frappe/website/report/website_analytics/website_analytics.py @@ -0,0 +1,224 @@ +# Copyright (c) 2013, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from datetime import datetime +from frappe.utils import getdate +from frappe.utils.dateutils import get_dates_from_timegrain + +def execute(filters=None): + return WebsiteAnalytics(filters).run() + +class WebsiteAnalytics(object): + def __init__(self, filters=None): + self.filters = frappe._dict(filters or {}) + + if not self.filters.to_date: + self.filters.to_date = datetime.now() + + if not self.filters.from_date: + self.filters.from_date = frappe.utils.add_days(self.filters.to_date, -7) + + if not self.filters.range: + self.filters.range = "Daily" + + self.filters.to_date = frappe.utils.add_days(self.filters.to_date, 1) + self.query_filters = {'creation': ['between', [self.filters.from_date, self.filters.to_date]]} + + def run(self): + columns = self.get_columns() + data = self.get_data() + chart = self.get_chart_data() + summary = self.get_report_summary() + + return columns, data[:250], None, chart, summary + + def get_columns(self): + return [ + { + "fieldname": "path", + "label": "Page", + "fieldtype": "Data", + "width": 300 + }, + { + "fieldname": "count", + "label": "Page Views", + "fieldtype": "Int", + "width": 150 + }, + { + "fieldname": "unique_count", + "label": "Unique Visitors", + "fieldtype": "Int", + "width": 150 + } + ] + + def get_data(self): + pg_query = """ + SELECT + path, + COUNT(*) as count, + COUNT(CASE WHEN CAST(is_unique as Integer) = 1 THEN 1 END) as unique_count + FROM `tabWeb Page View` + WHERE coalesce("tabWeb Page View".creation, '0001-01-01') BETWEEN %s AND %s + GROUP BY path + ORDER BY count desc + """ + + mariadb_query = """ + SELECT + path, + COUNT(*) as count, + COUNT(CASE WHEN is_unique = 1 THEN 1 END) as unique_count + FROM `tabWeb Page View` + WHERE creation BETWEEN %s AND %s + GROUP BY path + ORDER BY count desc + """ + + data = frappe.db.multisql({ + "mariadb": mariadb_query, + "postgres": pg_query + }, (self.filters.from_date, self.filters.to_date)) + return data + + def _get_query_for_mariadb(self): + filters_range = self.filters.range + field = 'creation' + date_format = '%Y-%m-%d' + + if filters_range == "Weekly": + field = 'ADDDATE(creation, INTERVAL 1-DAYOFWEEK(creation) DAY)' + + elif filters_range == "Monthly": + date_format = '%Y-%m-01' + + query = """ + SELECT + DATE_FORMAT({0}, %s) as date, + COUNT(*) as count, + COUNT(CASE WHEN is_unique = 1 THEN 1 END) as unique_count + FROM `tabWeb Page View` + WHERE creation BETWEEN %s AND %s + GROUP BY DATE_FORMAT({0}, %s) + ORDER BY creation + """.format(field) + + values = (date_format, self.filters.from_date, self.filters.to_date, date_format) + + return query, values + + def _get_query_for_postgres(self): + filters_range = self.filters.range + field = 'creation' + granularity = 'day' + + if filters_range == "Weekly": + granularity = 'week' + + elif filters_range == "Monthly": + granularity = 'day' + + query = """ + SELECT + DATE_TRUNC(%s, {0}) as date, + COUNT(*) as count, + COUNT(CASE WHEN CAST(is_unique as Integer) = 1 THEN 1 END) as unique_count + FROM "tabWeb Page View" + WHERE coalesce("tabWeb Page View".{0}, '0001-01-01') BETWEEN %s AND %s + GROUP BY date_trunc(%s, {0}) + ORDER BY date + """.format(field) + + values = (granularity, self.filters.from_date, self.filters.to_date, granularity) + + return query, values + + def get_chart_data(self): + current_dialect = frappe.db.db_type or 'mariadb' + + if current_dialect == 'mariadb': + query, values = self._get_query_for_mariadb() + else: + query, values = self._get_query_for_postgres() + + self.chart_data = frappe.db.sql(query, values=values, as_dict=1) + + return self.prepare_chart_data(self.chart_data) + + def prepare_chart_data(self, data): + date_range = get_dates_from_timegrain(self.filters.from_date, self.filters.to_date, self.filters.range) + if self.filters.range == "Monthly": + date_range = [frappe.utils.add_days(dd, 1) for dd in date_range] + + labels = [] + total_dataset = [] + unique_dataset = [] + + def get_data_for_date(date): + for item in data: + item_date = getdate(item.get("date")) + if item_date == date: + return item + return {'count': 0, 'unique_count': 0} + + + for date in date_range: + labels.append(date.strftime("%b %d %Y")) + match = get_data_for_date(date) + total_dataset.append(match.get('count', 0)) + unique_dataset.append(match.get('unique_count', 0)) + + chart = { + "data": { + 'labels': labels, + 'datasets': [ + { + 'name': "Total Views", + 'type': 'line', + 'values': total_dataset + }, + { + 'name': "Unique Visits", + 'type': 'line', + 'values': unique_dataset + } + ] + }, + "type": "axis-mixed", + 'lineOptions': { + 'regionFill': 1, + }, + 'axisOptions': { + 'xIsSeries': 1 + }, + 'colors': ['#7cd6fd', '#5e64ff'] + } + + return chart + + + def get_report_summary(self): + total_count = 0 + unique_count = 0 + for data in self.chart_data: + unique_count += data.get('unique_count') + total_count += data.get('count') + + report_summary = [ + { + "value": total_count, + "label": "Total Page Views", + "datatype": "Int", + }, + { + "value": unique_count, + "label": "Unique Page Views", + "datatype": "Int", + }, + + ] + return report_summary \ No newline at end of file diff --git a/frappe/website/router.py b/frappe/website/router.py index 4a9db0868f..b291671a4a 100644 --- a/frappe/website/router.py +++ b/frappe/website/router.py @@ -270,13 +270,18 @@ def setup_source(page_info): if page_info.template.endswith('.md'): source = frappe.utils.md_to_html(source) + page_info.page_toc_html = source.toc_html if not page_info.show_sidebar: source = '
    ' + source + '
    ' - # if only content - if page_info.template.endswith('.html') or page_info.template.endswith('.md'): - html = extend_from_base_template(page_info, source) + if not page_info.base_template: + page_info.base_template = get_base_template(page_info.route) + + if page_info.template.endswith(('.html', '.md', )) and \ + '{%- extends' not in source and '{% extends' not in source: + # set the source only if it contains raw content + html = source # load css/js files js, css = '', '' @@ -300,22 +305,23 @@ def setup_source(page_info): # show table of contents setup_index(page_info) -def extend_from_base_template(page_info, source): - '''Extend the content with appropriate base template if required. - - For easy composition, the users will only add the content of the page, - not its template. But if the user has explicitly put Jinja blocks, or tags, - or comment tags like - then the system will not try and put it inside the "web.template" +def get_base_template(path=None): ''' + Returns the `base_template` for given `path`. + The default `base_template` for any web route is `templates/web.html` defined in `hooks.py`. + This can be overridden for certain routes in `custom_app/hooks.py` based on regex pattern. + ''' + if not path: + path = frappe.local.request.path - if (('' not in source) and ('{% block' not in source) - and ('