From 7cd329fac97ee02745345a5ccd6eb4ac21839342 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Tue, 24 Sep 2019 00:16:44 +0530 Subject: [PATCH 01/76] reactor(scheduler): created "Scheduler Job Type" and cleaned up scheduler --- .pylintrc | 2 + frappe/__init__.py | 16 +- frappe/core/doctype/communication/email.py | 13 +- frappe/core/doctype/doctype/doctype.json | 15 +- .../core/doctype/doctype_action/__init__.py | 0 .../doctype_action/doctype_action.json | 54 ++++ .../doctype/doctype_action/doctype_action.py | 10 + .../doctype/scheduled_job_log/__init__.py | 0 .../scheduled_job_log/scheduled_job_log.js | 8 + .../scheduled_job_log/scheduled_job_log.json | 62 +++++ .../scheduled_job_log/scheduled_job_log.py | 10 + .../test_scheduled_job_log.py | 10 + .../doctype/scheduled_job_type/__init__.py | 0 .../scheduled_job_type/scheduled_job_type.js | 8 + .../scheduled_job_type.json | 92 +++++++ .../scheduled_job_type/scheduled_job_type.py | 129 +++++++++ .../test_scheduled_job_type.py | 62 +++++ frappe/core/doctype/version/version.py | 2 +- .../doctype/email_account/email_account.py | 3 +- frappe/email/doctype/newsletter/newsletter.py | 3 +- frappe/email/queue.py | 3 +- frappe/email/receive.py | 5 +- frappe/hooks.py | 13 +- frappe/migrate.py | 3 + frappe/model/__init__.py | 2 +- frappe/model/base_document.py | 8 +- frappe/model/document.py | 4 +- frappe/model/meta.py | 11 +- frappe/model/sync.py | 1 + frappe/patches.txt | 1 + frappe/public/js/frappe/form/form.js | 19 ++ frappe/test_runner.py | 2 +- frappe/tests/test_scheduler.py | 68 +---- frappe/utils/background_jobs.py | 6 +- frappe/utils/scheduler.py | 249 ++---------------- 35 files changed, 561 insertions(+), 333 deletions(-) create mode 100644 .pylintrc create mode 100644 frappe/core/doctype/doctype_action/__init__.py create mode 100644 frappe/core/doctype/doctype_action/doctype_action.json create mode 100644 frappe/core/doctype/doctype_action/doctype_action.py create mode 100644 frappe/core/doctype/scheduled_job_log/__init__.py create mode 100644 frappe/core/doctype/scheduled_job_log/scheduled_job_log.js create mode 100644 frappe/core/doctype/scheduled_job_log/scheduled_job_log.json create mode 100644 frappe/core/doctype/scheduled_job_log/scheduled_job_log.py create mode 100644 frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py create mode 100644 frappe/core/doctype/scheduled_job_type/__init__.py create mode 100644 frappe/core/doctype/scheduled_job_type/scheduled_job_type.js create mode 100644 frappe/core/doctype/scheduled_job_type/scheduled_job_type.json create mode 100644 frappe/core/doctype/scheduled_job_type/scheduled_job_type.py create mode 100644 frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000000..c11c0ab6a3 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +disable=access-member-before-definition +disable=no-member \ No newline at end of file diff --git a/frappe/__init__.py b/frappe/__init__.py index 6424dcd9b5..5827541590 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -123,7 +123,6 @@ def init(site, sites_path=None, new_site=False): local.debug_log = [] local.realtime_log = [] local.flags = _dict({ - "ran_schedulers": [], "currently_saving": [], "redirect_location": "", "in_install_db": False, @@ -1504,7 +1503,20 @@ def logger(module=None, with_more_info=True): def log_error(message=None, title=None): '''Log error to Error Log''' - return get_doc(dict(doctype='Error Log', error=as_unicode(message or get_traceback()), + + # AI ALERT: + # the title and message may be swapped + # the better API for this is log_error(title, message), and used in many cases this way + # this hack tries to be smart about whats a title (single line ;-)) and fixes it + + if message: + if '\n' not in message: + title = message + error = get_traceback() + else: + error = message + + return get_doc(dict(doctype='Error Log', error=as_unicode(error), method=title)).insert(ignore_permissions=True) def get_desk_link(doctype, name): diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index a285941c68..361030d07a 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -10,7 +10,6 @@ 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) -from frappe.utils.scheduler import log from frappe.email.email_body import get_message_id import frappe.email.smtp import time @@ -509,17 +508,7 @@ def sendmail(communication_name, print_html=None, print_format=None, attachments break except: - traceback = log("frappe.core.doctype.communication.email.sendmail", frappe.as_json({ - "communication_name": communication_name, - "print_html": print_html, - "print_format": print_format, - "attachments": attachments, - "recipients": recipients, - "cc": cc, - "bcc": bcc, - "lang": lang - })) - frappe.logger(__name__).error(traceback) + traceback = frappe.log_error("frappe.core.doctype.communication.email.sendmail") raise def update_mins_to_first_communication(parent, communication): diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index a6a5e258ee..834ac9b60b 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -57,6 +57,8 @@ "restrict_to_domain", "read_only", "in_create", + "actions_section", + "actions", "web_view", "has_web_view", "allow_guest_to_view", @@ -454,11 +456,22 @@ "fieldname": "nsm_parent_field", "fieldtype": "Data", "label": "Parent Field (Tree)" + }, + { + "fieldname": "actions_section", + "fieldtype": "Section Break", + "label": "Actions" + }, + { + "fieldname": "actions", + "fieldtype": "Table", + "label": "Actions", + "options": "DocType Action" } ], "icon": "fa fa-bolt", "idx": 6, - "modified": "2019-09-07 14:28:05.392490", + "modified": "2019-09-23 16:29:21.209832", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype_action/__init__.py b/frappe/core/doctype/doctype_action/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/doctype_action/doctype_action.json b/frappe/core/doctype/doctype_action/doctype_action.json new file mode 100644 index 0000000000..86757ea050 --- /dev/null +++ b/frappe/core/doctype/doctype_action/doctype_action.json @@ -0,0 +1,54 @@ +{ + "actions": [], + "creation": "2019-09-23 16:28:13.953520", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "action_type", + "method", + "group" + ], + "fields": [ + { + "columns": 2, + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "columns": 6, + "fieldname": "method", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Method", + "reqd": 1 + }, + { + "fieldname": "group", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group" + }, + { + "fieldname": "action_type", + "fieldtype": "Data", + "label": "Action Type", + "options": "Server Action" + } + ], + "istable": 1, + "modified": "2019-09-23 21:34:39.971700", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType Action", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype_action/doctype_action.py b/frappe/core/doctype/doctype_action/doctype_action.py new file mode 100644 index 0000000000..a745c7da40 --- /dev/null +++ b/frappe/core/doctype/doctype_action/doctype_action.py @@ -0,0 +1,10 @@ +# -*- 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 DocTypeAction(Document): + pass diff --git a/frappe/core/doctype/scheduled_job_log/__init__.py b/frappe/core/doctype/scheduled_job_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.js b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.js new file mode 100644 index 0000000000..d43160c658 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Scheduled Job Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json new file mode 100644 index 0000000000..cfa2f27d1a --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json @@ -0,0 +1,62 @@ +{ + "creation": "2019-09-23 14:36:36.935869", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "status", + "scheduled_job", + "details" + ], + "fields": [ + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Scheduled\nSuccess\nFailed", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "scheduled_job", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Scheduled Job", + "options": "Scheduled Job Type", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "details", + "fieldtype": "Code", + "label": "Details", + "read_only": 1 + } + ], + "modified": "2019-09-23 14:36:36.935869", + "modified_by": "Administrator", + "module": "Core", + "name": "Scheduled Job Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py new file mode 100644 index 0000000000..26871c9adf --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py @@ -0,0 +1,10 @@ +# -*- 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 ScheduledJobLog(Document): + pass diff --git a/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py new file mode 100644 index 0000000000..1e5290425b --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestScheduledJobLog(unittest.TestCase): + pass diff --git a/frappe/core/doctype/scheduled_job_type/__init__.py b/frappe/core/doctype/scheduled_job_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.js b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.js new file mode 100644 index 0000000000..55907b17fc --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Scheduled Job Type', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json new file mode 100644 index 0000000000..c8dac630e9 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -0,0 +1,92 @@ +{ + "actions": [ + { + "action_path": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", + "label": "Execute", + "method": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event" + } + ], + "creation": "2019-09-23 14:34:09.205368", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "stopped", + "method", + "queue", + "cron_format", + "last_execution", + "create_log" + ], + "fields": [ + { + "fieldname": "method", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Method", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "queue", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Queue", + "options": "All\nHourly\nDaily\nDaily Long\nWeekly\nWeekly Long\nMonthly\nMonthly Long\nCron\nYearly\nAnnual", + "read_only": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "stopped", + "fieldtype": "Check", + "label": "Stopped" + }, + { + "default": "0", + "depends_on": "eval:doc.queue==='All'", + "fieldname": "create_log", + "fieldtype": "Check", + "label": "Create Log" + }, + { + "fieldname": "last_execution", + "fieldtype": "Datetime", + "label": "Last Execution", + "read_only": 1 + }, + { + "allow_in_quick_entry": 1, + "depends_on": "eval:doc.queue==='Cron'", + "fieldname": "cron_format", + "fieldtype": "Data", + "label": "Cron Format", + "read_only": 1 + } + ], + "in_create": 1, + "modified": "2019-09-23 22:19:22.594874", + "modified_by": "Administrator", + "module": "Core", + "name": "Scheduled Job Type", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py new file mode 100644 index 0000000000..6733ddb014 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import frappe, json +from frappe.model.document import Document +from frappe.utils import now_datetime, get_datetime +from datetime import datetime +from croniter import croniter +from frappe.utils.background_jobs import enqueue + +CRON_MAP = { + "Yearly": "0 0 1 1 *", + "Annual": "0 0 1 1 *", + "Monthly": "0 0 1 * *", + "Monthly Long": "0 0 1 * *", + "Weekly": "0 0 * * 0", + "Weekly Long": "0 0 * * 0", + "Daily": "0 0 * * *", + "Daily Long": "0 0 * * *", + "Hourly": "0 * * * *", + "Hourly Long": "0 * * * *", + "All": "0/" + str((frappe.get_conf().scheduler_interval or 240) // 60) + " * * * *", +} + +class ScheduledJobType(Document): + def autoname(self): + self.name = '.'.join(self.method.split('.')[-2:]) + + def validate(self): + if self.queue != 'All': + # force logging for all events other than continuous ones (ALL) + self.create_log = 1 + + def enqueue(self): + # enqueue event if last execution is done + if self.is_event_due(): + self.update_last_execution() + frappe.flags.enqueued_jobs.append(self.method) + enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', + job_type=self.method) + + def is_event_due(self, current_time = None): + '''Return true if event is due based on time lapsed since last execution''' + # save last execution in expected execution time as per cron + self.last_execution = self.get_next_execution() + + # if the next scheduled event is before NOW, then its due! + return self.last_execution <= (current_time or now_datetime()) + + def get_next_execution(self): + if not self.cron_format: + self.cron_format = CRON_MAP[self.queue] + + return croniter(self.cron_format, + get_datetime(self.last_execution)).get_next(datetime) + + def execute(self): + try: + frappe.logger(__name__).info('Started Scheduled Job: {0} for {1}'.format(self.method, frappe.local.site)) + frappe.get_attr(self.method)() + frappe.db.commit() + frappe.logger(__name__).info('Completed Scheduled Job: {0} for {1}'.format(self.method, frappe.local.site)) + except Exception: + frappe.db.rollback() + frappe.log_error('{} failed'.format(self.method)) + frappe.logger(__name__).info('Failed Scheduled Job: {0} for {1}'.format(self.method, frappe.local.site)) + + + def update_last_execution(self): + self.db_set('last_execution', self.last_execution, update_modified=False) + frappe.db.commit() + + def get_queue_name(self): + return self.queue.replace(' ', '_').lower() + +@frappe.whitelist() +def execute_event(doc): + frappe.only_for('System Manager') + doc = json.loads(doc) + frappe.get_doc('Scheduled Job Type', doc.get('name')).execute() + +def run_scheduled_job(job_type): + '''This is a wrapper function that runs a hooks.scheduler_events method''' + frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() + +def sync_jobs(): + frappe.reload_doc('core', 'doctype', 'scheduled_job_type') + all_events = [] + scheduler_events = frappe.get_hooks("scheduler_events") + insert_events(all_events, scheduler_events) + clear_events(all_events, scheduler_events) + +def insert_events(all_events, scheduler_events): + for event_type in scheduler_events: + events = scheduler_events.get(event_type) + if isinstance(events, dict): + insert_cron_event(events, all_events) + else: + # hourly, daily etc + insert_event_list(events, event_type, all_events) + +def insert_cron_event(events, all_events): + for cron_format in events: + for event in events.get(cron_format): + all_events.append(event) + insert_single_event('Cron', event, cron_format) + +def insert_event_list(events, event_type, all_events): + for event in events: + all_events.append(event) + queue = event_type.replace('_', ' ').title() + insert_single_event(queue, event) + +def insert_single_event(queue, event, cron_format = None): + if not frappe.db.exists('Scheduled Job Type', dict(method=event)): + frappe.get_doc(dict( + doctype = 'Scheduled Job Type', + method = event, + cron_format = cron_format, + queue = queue + )).insert() + +def clear_events(all_events, scheduler_events): + for event in frappe.get_all('Scheduled Job Type', ('name', 'method')): + if event.method not in all_events: + frappe.db.delete_doc('Scheduled Job Type', event.name) diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py new file mode 100644 index 0000000000..75af876367 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from frappe.utils import get_datetime + +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs + +class TestScheduledJobType(unittest.TestCase): + def setUp(self): + if not frappe.get_all('Scheduled Job Type', limit=1): + frappe.db.rollback() + frappe.db.sql('truncate `tabScheduled Job Type`') + sync_jobs() + frappe.db.commit() + + def test_sync_jobs(self): + all_job = frappe.get_doc('Scheduled Job Type', + dict(method='frappe.email.queue.flush')) + self.assertEqual(all_job.queue, 'All') + + daily_job = frappe.get_doc('Scheduled Job Type', + dict(method='frappe.email.queue.clear_outbox')) + self.assertEqual(daily_job.queue, 'Daily') + + # check if cron jobs are synced + cron_job = frappe.get_doc('Scheduled Job Type', + dict(method='frappe.oauth.delete_oauth2_data')) + self.assertEqual(cron_job.queue, 'Cron') + self.assertEqual(cron_job.cron_format, '0/15 * * * *') + + def test_daily_job(self): + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.email.queue.clear_outbox')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-01-02 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 23:59:59'))) + + def test_weekly_job(self): + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.utils.change_log.check_for_update')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-01-06 00:00:01'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-02 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-05 23:59:59'))) + + def test_monthly_job(self): + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.email.doctype.auto_email_report.auto_email_report.send_monthly')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-02-01 00:00:01'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-15 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-31 23:59:59'))) + + def test_cron_job(self): + # runs every 15 mins + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.oauth.delete_oauth2_data')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-01-01 00:15:01'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:05:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:14:59'))) diff --git a/frappe/core/doctype/version/version.py b/frappe/core/doctype/version/version.py index 05cc102ab9..cb891afa0c 100644 --- a/frappe/core/doctype/version/version.py +++ b/frappe/core/doctype/version/version.py @@ -50,7 +50,7 @@ def get_diff(old, new, for_child=False): if df.fieldtype in no_value_fields and df.fieldtype not in table_fields: continue - old_value, new_value = old.get(df.fieldname), new.get(df.fieldname) + old_value, new_value = old.get(df.fieldname) or [], new.get(df.fieldname) or [] if df.fieldtype in table_fields: # make maps diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index f10f08664c..c05a0f3fe4 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -21,7 +21,6 @@ from frappe.desk.form import assign_to from frappe.utils.user import get_system_managers from frappe.utils.background_jobs import enqueue, get_jobs from frappe.core.doctype.communication.email import set_incoming_outgoing_accounts -from frappe.utils.scheduler import log from frappe.utils.html_utils import clean_email_html from frappe.email.utils import get_port @@ -284,7 +283,7 @@ class EmailAccount(Document): except Exception: frappe.db.rollback() - log('email_account.receive') + frappe.log_error('email_account.receive') if self.use_imap: self.handle_bad_emails(email_server, uid, msg, frappe.get_traceback()) exceptions.append(frappe.get_traceback()) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 4f3e7994a5..b0d1756643 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -9,7 +9,6 @@ from frappe import throw, _ from frappe.website.website_generator import WebsiteGenerator from frappe.utils.verified_command import get_signed_params, verify_request from frappe.utils.background_jobs import enqueue -from frappe.utils.scheduler import log from frappe.email.queue import send from frappe.email.doctype.email_group.email_group import add_subscribers from frappe.utils import parse_addr @@ -213,7 +212,7 @@ def send_newsletter(newsletter): doc.db_set("email_sent", 0) frappe.db.commit() - log("send_newsletter") + frappe.log_error("send_newsletter") raise diff --git a/frappe/email/queue.py b/frappe/email/queue.py index f51451751d..792b47296a 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -12,7 +12,6 @@ from frappe.utils.verified_command import get_signed_params, verify_request from html2text import html2text from frappe.utils import get_url, nowdate, encode, now_datetime, add_days, split_emails, cstr, cint from rq.timeouts import JobTimeoutException -from frappe.utils.scheduler import log from six import text_type, string_types class EmailLimitCrossedError(frappe.ValidationError): pass @@ -469,7 +468,7 @@ def send_one(email, smtpserver=None, auto_commit=True, now=False, from_test=Fals else: # log to Error Log - log('frappe.email.queue.flush', text_type(e)) + frappe.log_error('frappe.email.queue.flush') def prepare_message(email, recipient, recipients_list): message = email.message diff --git a/frappe/email/receive.py b/frappe/email/receive.py index ee7075b570..ac48b3e070 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -12,7 +12,6 @@ import frappe from frappe import _, safe_decode, safe_encode from frappe.utils import (extract_email_id, convert_utc_to_user_timezone, now, cint, cstr, strip, markdown, parse_addr) -from frappe.utils.scheduler import log from frappe.core.doctype.file.file import get_random_filename, MaxFileSizeReachedError class EmailSizeExceededError(frappe.ValidationError): pass @@ -80,7 +79,7 @@ class EmailServer: except _socket.error: # log performs rollback and logs error in Error Log - log("receive.connect_pop") + frappe.log_error("receive.connect_pop") # Invalid mail server -- due to refusing connection frappe.msgprint(_('Invalid Mail Server. Please rectify and try again.')) @@ -255,7 +254,7 @@ class EmailServer: else: # log performs rollback and logs error in Error Log - log("receive.get_messages", self.make_error_msg(msg_num, incoming_mail)) + frappe.log_error("receive.get_messages", self.make_error_msg(msg_num, incoming_mail)) self.errors = True frappe.db.rollback() diff --git a/frappe/hooks.py b/frappe/hooks.py index b35387efe9..5406d0fc57 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -76,8 +76,7 @@ leaderboards = "frappe.desk.leaderboard.get_leaderboards" on_session_creation = [ "frappe.core.doctype.activity_log.feed.login_feed", - "frappe.core.doctype.user.user.notify_admin_access_to_system_manager", - "frappe.utils.scheduler.reset_enabled_scheduler_events", + "frappe.core.doctype.user.user.notify_admin_access_to_system_manager" ] on_logout = "frappe.core.doctype.session_default_settings.session_default_settings.clear_session_defaults" @@ -153,14 +152,18 @@ doc_events = { } scheduler_events = { + "cron": { + "0/15 * * * *": [ + "frappe.oauth.delete_oauth2_data", + "frappe.website.doctype.web_page.web_page.check_publish_status", + "frappe.twofactor.delete_all_barcodes_for_users" + ] + }, "all": [ "frappe.email.queue.flush", "frappe.email.doctype.email_account.email_account.pull", "frappe.email.doctype.email_account.email_account.notify_unreplied", - "frappe.oauth.delete_oauth2_data", "frappe.integrations.doctype.razorpay_settings.razorpay_settings.capture_payment", - "frappe.twofactor.delete_all_barcodes_for_users", - "frappe.website.doctype.web_page.web_page.check_publish_status", 'frappe.utils.global_search.sync_global_search' ], "hourly": [ diff --git a/frappe/migrate.py b/frappe/migrate.py index 6778a3f18f..043b6817d7 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -15,6 +15,7 @@ from frappe.desk.notifications import clear_notifications from frappe.website import render from frappe.core.doctype.language.language import sync_languages from frappe.modules.utils import sync_customizations +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.utils import global_search def migrate(verbose=True, rebuild_website=False, skip_failing=False): @@ -46,9 +47,11 @@ def migrate(verbose=True, rebuild_website=False, skip_failing=False): # run patches frappe.modules.patch_handler.run_all(skip_failing) + # sync frappe.model.sync.sync_all(verbose=verbose) frappe.translate.clear_cache() + sync_jobs() sync_fixtures() sync_customizations() sync_languages() diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 09db9bb68a..1c6f965d42 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -45,7 +45,7 @@ default_fields = ('doctype','name','owner','creation','modified','modified_by', 'parent','parentfield','parenttype','idx','docstatus') optional_fields = ("_user_tags", "_comments", "_assign", "_liked_by", "_seen") table_fields = ('Table', 'Table MultiSelect') -core_doctypes_list = ('DocType', 'DocField', 'DocPerm', 'User', 'Role', 'Has Role', +core_doctypes_list = ('DocType', 'DocField', 'DocPerm', 'DocType Action','User', 'Role', 'Has Role', 'Page', 'Module Def', 'Print Format', 'Report', 'Customize Form', 'Customize Form Field', 'Property Setter', 'Custom Field', 'Custom Script') diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 6c917b8d4d..1648c64a13 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -22,6 +22,8 @@ max_positive_value = { 'bigint': 2 ** 63 } +DOCTYPES_FOR_DOCTYPE = ('DocType', 'DocField', 'DocPerm', 'DocType Action') + _classes = {} def get_controller(doctype): @@ -255,7 +257,7 @@ class BaseDocument(object): def get_valid_columns(self): if self.doctype not in frappe.local.valid_columns: - if self.doctype in ("DocField", "DocPerm") and self.parent in ("DocType", "DocField", "DocPerm"): + if self.doctype in DOCTYPES_FOR_DOCTYPE: from frappe.model.meta import get_table_columns valid = get_table_columns(self.doctype) else: @@ -312,7 +314,7 @@ class BaseDocument(object): self.created_by = self.modified_by = frappe.session.user # if doctype is "DocType", don't insert null values as we don't know who is valid yet - d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in ('DocType', 'DocField', 'DocPerm')) + d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE) columns = list(d) try: @@ -347,7 +349,7 @@ class BaseDocument(object): self.db_insert() return - d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in ('DocType', 'DocField', 'DocPerm')) + d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE) # don't update name, as case might've been changed name = d['name'] diff --git a/frappe/model/document.py b/frappe/model/document.py index 7f04895308..c58e09ef5a 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -150,8 +150,8 @@ class Document(BaseDocument): super(Document, self).__init__(d) if self.name=="DocType" and self.doctype=="DocType": - from frappe.model.meta import doctype_table_fields - table_fields = doctype_table_fields + from frappe.model.meta import DOCTYPE_TABLE_FIELDS + table_fields = DOCTYPE_TABLE_FIELDS else: table_fields = self.meta.get_table_fields() diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 0b1011b119..cd342b77aa 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -151,7 +151,7 @@ class Meta(Document): if self.name!="DocType": self._table_fields = self.get('fields', {"fieldtype": ['in', table_fields]}) else: - self._table_fields = doctype_table_fields + self._table_fields = DOCTYPE_TABLE_FIELDS return self._table_fields @@ -165,7 +165,7 @@ class Meta(Document): def get_valid_columns(self): if not hasattr(self, "_valid_columns"): - if self.name in ("DocType", "DocField", "DocPerm", "Property Setter"): + if self.name in ("DocType", "DocField", "DocPerm", 'DocType Action',"Property Setter"): self._valid_columns = get_table_columns(self.name) else: self._valid_columns = self.default_fields + \ @@ -174,7 +174,7 @@ class Meta(Document): return self._valid_columns def get_table_field_doctype(self, fieldname): - return { "fields": "DocField", "permissions": "DocPerm"}.get(fieldname) + return { "fields": "DocField", "permissions": "DocPerm", "actions": "DocType Action"}.get(fieldname) def get_field(self, fieldname): '''Return docfield from meta''' @@ -441,9 +441,10 @@ class Meta(Document): def is_nested_set(self): return self.has_field('lft') and self.has_field('rgt') -doctype_table_fields = [ +DOCTYPE_TABLE_FIELDS = [ frappe._dict({"fieldname": "fields", "options": "DocField"}), - frappe._dict({"fieldname": "permissions", "options": "DocPerm"}) + frappe._dict({"fieldname": "permissions", "options": "DocPerm"}), + frappe._dict({"fieldname": "actions", "options": "DocType Action"}), ] ####### diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 18bf827c5f..070ab6bbc7 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -29,6 +29,7 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe # these need to go first at time of install for d in (("core", "docfield"), ("core", "docperm"), + ("core", "doctype_action"), ("core", "role"), ("core", "has_role"), ("core", "doctype"), diff --git a/frappe/patches.txt b/frappe/patches.txt index e107449dea..1a89641a6a 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -9,6 +9,7 @@ frappe.patches.v7_2.remove_in_filter frappe.patches.v11_0.drop_column_apply_user_permissions execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) #2017-09-22 execute:frappe.reload_doc('core', 'doctype', 'docfield', force=True) #2018-02-20 +execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 execute:frappe.reload_doc('core', 'doctype', 'custom_docperm') execute:frappe.reload_doc('core', 'doctype', 'docperm') #2018-05-29 execute:frappe.reload_doc('core', 'doctype', 'comment') diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 5a983986d8..edf8860a01 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -111,6 +111,7 @@ frappe.ui.form.Form = class FrappeForm { $("body").attr("data-sidebar", 1); } this.setup_file_drop(); + this.setup_doctype_actions(); this.setup_done = true; } @@ -319,6 +320,24 @@ frappe.ui.form.Form = class FrappeForm { } } + // sets up the refresh event for custom buttons + // added via configuration + setup_doctype_actions() { + if (this.meta.actions) { + for (let action of this.meta.actions) { + frappe.ui.form.on(this.doctype, 'refresh', () => { + if (!this.is_new()) { + this.add_custom_button(action.label, () => { + frappe.xcall(action.method, {doc: this.doc}).then(() => { + frappe.msgprint({message:__('Event Executed'), alert:true}); + }); + }, action.group); + } + }); + } + } + } + switch_doc(docname) { // record switch if(this.docname != docname && (!this.meta.in_dialog || this.in_form) && !this.meta.istable) { diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 76140e442c..d06d890ac9 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -71,7 +71,7 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(), else: ret = run_all_tests(app, verbose, profile, ui_tests, failfast=failfast) - frappe.db.commit() + if frappe.db: frappe.db.commit() # workaround! since there is no separate test db frappe.clear_cache() diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index 1f8c7943c4..4e193354b0 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -2,11 +2,9 @@ from __future__ import unicode_literals from unittest import TestCase from dateutil.relativedelta import relativedelta -from frappe.utils.scheduler import (enqueue_applicable_events, restrict_scheduler_events_if_dormant, - get_enabled_scheduler_events) -from frappe import _dict +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.utils.background_jobs import enqueue -from frappe.utils import now_datetime, today, add_days, add_to_date +from frappe.utils.scheduler import enqueue_events import frappe import time @@ -17,60 +15,19 @@ def test_timeout(): class TestScheduler(TestCase): def setUp(self): - frappe.db.set_global('enabled_scheduler_events', "") - frappe.flags.ran_schedulers = [] - - def test_all_events(self): - last = now_datetime() - relativedelta(hours=2) - enqueue_applicable_events(frappe.local.site, now_datetime(), last) - self.assertTrue("all" in frappe.flags.ran_schedulers) - - def test_enabled_events(self): - frappe.flags.enabled_events = ["hourly", "hourly_long", "daily", "daily_long", - "weekly", "weekly_long", "monthly", "monthly_long"] - - # maintain last_event and next_event on the same day - last_event = now_datetime().replace(hour=0, minute=0, second=0, microsecond=0) - next_event = last_event + relativedelta(minutes=30) - - enqueue_applicable_events(frappe.local.site, next_event, last_event) - self.assertFalse("cron" in frappe.flags.ran_schedulers) - - # maintain last_event and next_event on the same day - last_event = now_datetime().replace(hour=0, minute=0, second=0, microsecond=0) - next_event = last_event + relativedelta(hours=2) - - frappe.flags.ran_schedulers = [] - enqueue_applicable_events(frappe.local.site, next_event, last_event) - self.assertTrue("all" in frappe.flags.ran_schedulers) - self.assertTrue("hourly" in frappe.flags.ran_schedulers) - - frappe.flags.enabled_events = None - - def test_enabled_events_day_change(self): - - # use flags instead of globals as this test fails intermittently - # the root cause has not been identified but the culprit seems cache - # since cache is mutable, it maybe be changed by a parallel process - frappe.flags.enabled_events = ["daily", "daily_long", "weekly", "weekly_long", - "monthly", "monthly_long"] - - # maintain last_event and next_event on different days - next_event = now_datetime().replace(hour=0, minute=0, second=0, microsecond=0) - last_event = next_event - relativedelta(hours=2) - - frappe.flags.ran_schedulers = [] - enqueue_applicable_events(frappe.local.site, next_event, last_event) - self.assertTrue("all" in frappe.flags.ran_schedulers) - self.assertFalse("hourly" in frappe.flags.ran_schedulers) - - frappe.flags.enabled_events = None - - + if not frappe.get_all('Scheduled Job Type', limit=1): + sync_jobs() + def test_enqueue_jobs(self): + frappe.db.sql('update `tabScheduled Job Type` set last_execution = "2010-01-01 00:00:00"') + enqueue_events(site = frappe.local.site) + self.assertTrue('frappe.email.queue.clear_outbox', frappe.flags.enqueued_jobs) + self.assertTrue('frappe.utils.change_log.check_for_update', frappe.flags.enqueued_jobs) + self.assertTrue('frappe.email.doctype.auto_email_report.auto_email_report.send_monthly', frappe.flags.enqueued_jobs) def test_job_timeout(self): + return job = enqueue(test_timeout, timeout=10) count = 5 while count > 0: @@ -80,6 +37,3 @@ class TestScheduler(TestCase): break self.assertTrue(job.is_failed) - - def tearDown(self): - frappe.flags.ran_schedulers = [] diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index ef345c67df..61e866e2f8 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -79,8 +79,6 @@ def run_doc_method(doctype, name, doc_method, **kwargs): def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, retry=0): '''Executes job in a worker, performs commit/rollback and logs if there is any error''' - from frappe.utils.scheduler import log - if is_async: frappe.connect(site) if os.environ.get('CI'): @@ -115,12 +113,12 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, is_async=is_async, retry=retry+1) else: - log(method_name, message=repr(locals())) + frappe.log_error(method_name) raise except: frappe.db.rollback() - log(method_name, message=repr(locals())) + frappe.log_error(method_name) raise else: diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index 68c3bc58a8..efd7fc6c12 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -10,38 +10,15 @@ Events: from __future__ import unicode_literals, print_function -import frappe -import json +import frappe, os, time import schedule -import time -import frappe.utils -import os +from frappe.utils import now_datetime, get_datetime from frappe.utils import get_sites -from datetime import datetime -from frappe.utils.background_jobs import enqueue, get_jobs, queue_timeout -from frappe.utils.data import get_datetime, now_datetime from frappe.core.doctype.user.user import STANDARD_USERS -from frappe.installer import update_site_config -from six import string_types -from croniter import croniter +from frappe.utils.background_jobs import get_jobs DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S' -cron_map = { - "yearly": "0 0 1 1 *", - "annual": "0 0 1 1 *", - "monthly": "0 0 1 * *", - "monthly_long": "0 0 1 * *", - "weekly": "0 0 * * 0", - "weekly_long": "0 0 * * 0", - "daily": "0 0 * * *", - "daily_long": "0 0 * * *", - "midnight": "0 0 * * *", - "hourly": "0 * * * *", - "hourly_long": "0 * * * *", - "all": "0/" + str((frappe.get_conf().scheduler_interval or 240) // 60) + " * * * *", -} - def start_scheduler(): '''Run enqueue_events_for_all_sites every 2 minutes (default). Specify scheduler_interval in seconds in common_site_config.json''' @@ -60,17 +37,16 @@ def enqueue_events_for_all_sites(): return with frappe.init_site(): - jobs_per_site = get_jobs() sites = get_sites() for site in sites: try: - enqueue_events_for_site(site=site, queued_jobs=jobs_per_site[site]) + enqueue_events_for_site(site=site) except: # it should try to enqueue other sites print(frappe.get_traceback()) -def enqueue_events_for_site(site, queued_jobs): +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()) @@ -82,7 +58,7 @@ def enqueue_events_for_site(site, queued_jobs): if is_scheduler_inactive(): return - enqueue_events(site=site, queued_jobs=queued_jobs) + enqueue_events(site=site) frappe.logger(__name__).debug('Queued events for site {0}'.format(site)) except frappe.db.OperationalError as e: @@ -96,128 +72,13 @@ def enqueue_events_for_site(site, queued_jobs): finally: frappe.destroy() -def enqueue_events(site, queued_jobs): - nowtime = frappe.utils.now_datetime() - last = frappe.db.get_value('System Settings', 'System Settings', 'scheduler_last_event') - - # set scheduler last event - frappe.db.set_value('System Settings', 'System Settings', - 'scheduler_last_event', nowtime.strftime(DATETIME_FORMAT), - update_modified=False) - frappe.db.commit() - - out = [] - if last: - last = datetime.strptime(last, DATETIME_FORMAT) - out = enqueue_applicable_events(site, nowtime, last, queued_jobs) - - return '\n'.join(out) - -def enqueue_applicable_events(site, nowtime, last, queued_jobs=()): - nowtime_str = nowtime.strftime(DATETIME_FORMAT) - out = [] - - enabled_events = get_enabled_scheduler_events() - - def trigger_if_enabled(site, event, last, queued_jobs): - trigger(site, event, last, queued_jobs) - _log(event) - - def _log(event): - out.append("{time} - {event} - queued".format(time=nowtime_str, event=event)) - - for event in enabled_events: - trigger_if_enabled(site, event, last, queued_jobs) - - if "all" not in enabled_events: - trigger_if_enabled(site, "all", last, queued_jobs) - - return out - -def trigger(site, event, last=None, queued_jobs=(), now=False): - """Trigger method in hooks.scheduler_events.""" - - queue = 'long' if event.endswith('_long') else 'short' - timeout = queue_timeout[queue] - if not queued_jobs and not now: - queued_jobs = get_jobs(site=site, queue=queue) - - if frappe.flags.in_test: - frappe.flags.ran_schedulers.append(event) - - events_from_hooks = get_scheduler_events(event) - if not events_from_hooks: - return - - events = events_from_hooks - if not now: - events = [] - if event == "cron": - for e in events_from_hooks: - e = cron_map.get(e, e) - if croniter.is_valid(e): - if croniter(e, last).get_next(datetime) <= frappe.utils.now_datetime(): - events.extend(events_from_hooks[e]) - else: - frappe.log_error("Cron string " + e + " is not valid", "Error triggering cron job") - frappe.logger(__name__).error('Exception in Trigger Events for Site {0}, Cron String {1}'.format(site, e)) - - else: - if croniter(cron_map[event], last).get_next(datetime) <= frappe.utils.now_datetime(): - events.extend(events_from_hooks) - - for handler in events: - if not now: - if handler not in queued_jobs: - enqueue(handler, queue, timeout, event) - else: - scheduler_task(site=site, event=event, handler=handler, now=True) - -def get_scheduler_events(event): - '''Get scheduler events from hooks and integrations''' - scheduler_events = frappe.cache().get_value('scheduler_events') - if not scheduler_events: - scheduler_events = frappe.get_hooks("scheduler_events") - frappe.cache().set_value('scheduler_events', scheduler_events) - - return scheduler_events.get(event) or [] - -def log(method, message=None): - """log error in patch_log""" - message = frappe.utils.cstr(message) + "\n" if message else "" - message += frappe.get_traceback() - - if not (frappe.db and frappe.db._conn): - frappe.connect() - - frappe.db.rollback() - frappe.db.begin() - - d = frappe.new_doc("Error Log") - d.method = method - d.error = message - d.insert(ignore_permissions=True) - - frappe.db.commit() - - return message - -def get_enabled_scheduler_events(): - if 'enabled_events' in frappe.flags and frappe.flags.enabled_events: - return frappe.flags.enabled_events - - enabled_events = frappe.db.get_global("enabled_scheduler_events") - if frappe.flags.in_test: - # TEMP for debug: this test fails randomly - print('found enabled_scheduler_events {0}'.format(enabled_events)) - - if enabled_events: - if isinstance(enabled_events, string_types): - enabled_events = json.loads(enabled_events) - return enabled_events - - return ["all", "hourly", "hourly_long", "daily", "daily_long", - "weekly", "weekly_long", "monthly", "monthly_long", "cron"] +def enqueue_events(site): + frappe.flags.enqueued_jobs = [] + queued_jobs = get_jobs(key='job_type').get(site) or [] + for job_type in frappe.get_all('Scheduled Job Type', dict(stopped=0)): + if not job_type.method in queued_jobs: + # don't add it to queue if still pending + frappe.get_doc('Scheduled Job Type', job_type.name).enqueue() def is_scheduler_inactive(): if frappe.local.conf.maintenance_mode: @@ -229,6 +90,9 @@ def is_scheduler_inactive(): if is_scheduler_disabled(): return True + if is_dormant(): + return True + return False def is_scheduler_disabled(): @@ -246,90 +110,15 @@ def enable_scheduler(): def disable_scheduler(): toggle_scheduler(False) -def get_errors(from_date, to_date, limit): - errors = frappe.db.sql("""select modified, method, error from `tabError Log` - where date(modified) between %s and %s - and error not like '%%[Errno 110] Connection timed out%%' - order by modified limit %s""", (from_date, to_date, limit), as_dict=True) - return ["""

Time: {modified}

Method: {method}\n{error}
""".format(**e) - for e in errors] - -def get_error_report(from_date=None, to_date=None, limit=10): - from frappe.utils import get_url, now_datetime, add_days - - if not from_date: - from_date = add_days(now_datetime().date(), -1) - if not to_date: - to_date = add_days(now_datetime().date(), -1) - - errors = get_errors(from_date, to_date, limit) - - if errors: - return 1, """

Error Logs (max {limit}):

-

URL: {url}


{errors}""".format( - limit=limit, url=get_url(), errors="
".join(errors)) - else: - return 0, "

No error logs

" - -def scheduler_task(site, event, handler, now=False): - '''This is a wrapper function that runs a hooks.scheduler_events method''' - frappe.logger(__name__).info('running {handler} for {site} for event: {event}'.format(handler=handler, site=site, event=event)) - try: - if not now: - frappe.connect(site=site) - - frappe.flags.in_scheduler = True - frappe.get_attr(handler)() - - except Exception: - frappe.db.rollback() - traceback = log(handler, "Method: {event}, Handler: {handler}".format(event=event, handler=handler)) - frappe.logger(__name__).error(traceback) - raise - - else: - frappe.db.commit() - - frappe.logger(__name__).info('ran {handler} for {site} for event: {event}'.format(handler=handler, site=site, event=event)) - - -def reset_enabled_scheduler_events(login_manager): - if login_manager.info.user_type == "System User": - try: - if frappe.db.get_global('enabled_scheduler_events'): - # clear restricted events, someone logged in! - frappe.db.set_global('enabled_scheduler_events', None) - except frappe.db.InternalError as e: - if frappe.db.is_timedout(e): - frappe.log_error(frappe.get_traceback(), "Error in reset_enabled_scheduler_events") - else: - raise - else: - is_dormant = frappe.conf.get('dormant') - if is_dormant: - update_site_config('dormant', 'None') - - -def restrict_scheduler_events_if_dormant(): - if is_dormant(): - restrict_scheduler_events() - update_site_config('dormant', True) - -def restrict_scheduler_events(*args, **kwargs): - val = json.dumps(["hourly", "hourly_long", "daily", "daily_long", "weekly", "weekly_long", "monthly", "monthly_long", "cron"]) - frappe.db.set_global('enabled_scheduler_events', val) - def is_dormant(since = 345600): last_user_activity = get_last_active() if not last_user_activity: # no user has ever logged in, so not yet used return False - last_active = get_datetime(last_user_activity) - # Get now without tz info - now = now_datetime().replace(tzinfo=None) - time_since_last_active = now - last_active - if time_since_last_active.total_seconds() > since: # 4 days + + if now_datetime() - get_datetime(last_user_activity) > since: # 4 days return True + return False def get_last_active(): From 34b4b069ba44985a8244c59ec08276ba41ed82aa Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Tue, 24 Sep 2019 10:11:03 +0530 Subject: [PATCH 02/76] feat(scheduler): log scheduler events --- .../doctype_action/doctype_action.json | 27 +++++----- .../scheduled_job_type.json | 4 +- .../scheduled_job_type/scheduled_job_type.py | 25 +++++++-- .../system_settings/system_settings.json | 19 +++---- frappe/database/mariadb/framework_mariadb.sql | 24 +++++++++ .../database/postgres/framework_postgres.sql | 25 +++++++++ frappe/public/js/frappe/form/form.js | 11 ++-- frappe/public/js/frappe/utils/utils.js | 6 +-- frappe/utils/scheduler.py | 54 +++++++++++-------- 9 files changed, 141 insertions(+), 54 deletions(-) diff --git a/frappe/core/doctype/doctype_action/doctype_action.json b/frappe/core/doctype/doctype_action/doctype_action.json index 86757ea050..7a1b845af3 100644 --- a/frappe/core/doctype/doctype_action/doctype_action.json +++ b/frappe/core/doctype/doctype_action/doctype_action.json @@ -7,7 +7,7 @@ "field_order": [ "label", "action_type", - "method", + "action", "group" ], "fields": [ @@ -19,14 +19,6 @@ "label": "Label", "reqd": 1 }, - { - "columns": 6, - "fieldname": "method", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Method", - "reqd": 1 - }, { "fieldname": "group", "fieldtype": "Data", @@ -34,14 +26,25 @@ "label": "Group" }, { + "columns": 2, "fieldname": "action_type", - "fieldtype": "Data", + "fieldtype": "Select", + "in_list_view": 1, "label": "Action Type", - "options": "Server Action" + "options": "Server Action", + "reqd": 1 + }, + { + "columns": 4, + "fieldname": "action", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Action", + "reqd": 1 } ], "istable": 1, - "modified": "2019-09-23 21:34:39.971700", + "modified": "2019-09-24 09:11:39.860100", "modified_by": "Administrator", "module": "Core", "name": "DocType Action", diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index c8dac630e9..aea8611b6a 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -1,7 +1,9 @@ { "actions": [ { + "action": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", "action_path": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", + "action_type": "Server Action", "label": "Execute", "method": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event" } @@ -66,7 +68,7 @@ } ], "in_create": 1, - "modified": "2019-09-23 22:19:22.594874", + "modified": "2019-09-24 09:11:29.115423", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", 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 6733ddb014..5ebbf29a5f 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -39,6 +39,9 @@ class ScheduledJobType(Document): if self.is_event_due(): self.update_last_execution() frappe.flags.enqueued_jobs.append(self.method) + if frappe.flags.in_test: + self.execute() + else: enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', job_type=self.method) @@ -58,16 +61,30 @@ class ScheduledJobType(Document): get_datetime(self.last_execution)).get_next(datetime) def execute(self): + self.scheduler_log = None try: - frappe.logger(__name__).info('Started Scheduled Job: {0} for {1}'.format(self.method, frappe.local.site)) + self.log_status('Start') frappe.get_attr(self.method)() frappe.db.commit() - frappe.logger(__name__).info('Completed Scheduled Job: {0} for {1}'.format(self.method, frappe.local.site)) + self.log_status('Complete') except Exception: frappe.db.rollback() - frappe.log_error('{} failed'.format(self.method)) - frappe.logger(__name__).info('Failed Scheduled Job: {0} for {1}'.format(self.method, frappe.local.site)) + self.log_status('Failed') + def log_status(self, status): + # log file + frappe.logger(__name__).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): + if not self.create_log: + return + if not self.scheduler_log: + self.scheduler_log = frappe.get_doc(dict(doctype = 'Scheduled Job Log', scheduled_job=self.name)).insert(ignore_permissions=True) + self.scheduler_log.db_set('status', status) + if status == 'Failed': + self.scheduler_log.db_set('details', frappe.get_traceback()) + frappe.db.commit() def update_last_execution(self): self.db_set('last_execution', self.last_execution, update_modified=False) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 55bc5d49db..9e8bc8c3fd 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2014-04-17 16:53:52.640856", "doctype": "DocType", "document_type": "System", @@ -21,7 +22,7 @@ "backup_limit", "background_workers", "enable_scheduler", - "scheduler_last_event", + "dormant_days", "permissions", "apply_strict_user_permissions", "column_break_21", @@ -168,13 +169,6 @@ "hidden": 1, "label": "Enable Scheduled Jobs" }, - { - "fieldname": "scheduler_last_event", - "fieldtype": "Data", - "hidden": 1, - "label": "Scheduler Last Event", - "report_hide": 1 - }, { "collapsible": 1, "fieldname": "permissions", @@ -397,11 +391,18 @@ "fieldname": "allow_guests_to_upload_files", "fieldtype": "Check", "label": "Allow Guests to Upload Files" + }, + { + "default": "4", + "description": "Will run scheduled jobs only once a day for inactive sites. Default 4 days if set to 0.", + "fieldname": "dormant_days", + "fieldtype": "Int", + "label": "Run Jobs only Daily if Inactive For (Days)" } ], "icon": "fa fa-cog", "issingle": 1, - "modified": "2019-08-16 08:26:45.936626", + "modified": "2019-09-24 10:04:28.807388", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 7058ed0325..cc90938bae 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -105,6 +105,30 @@ CREATE TABLE `tabDocPerm` ( KEY `parent` (`parent`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- +-- Table structure for table `tabDocType Action` +-- + +| tabDocType Action | CREATE TABLE `tabDocType Action` ( + `name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL, + `creation` datetime(6) DEFAULT NULL, + `modified` datetime(6) DEFAULT NULL, + `modified_by` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `docstatus` int(1) NOT NULL DEFAULT 0, + `parent` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `parentfield` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `parenttype` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idx` int(8) NOT NULL DEFAULT 0, + `label` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `group` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `action_type` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `action` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`name`), + KEY `parent` (`parent`), + KEY `modified` (`modified`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED | + -- -- Table structure for table `tabDocType` -- diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index df59de92df..a0293b29f3 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -106,6 +106,31 @@ CREATE TABLE "tabDocPerm" ( create index on "tabDocPerm" ("parent"); +-- +-- Table structure for table "tabDocType Action" +-- + +DROP TABLE IF EXISTS "tabDocType Action"; +CREATE TABLE "tabDocType Action" ( + "name" varchar(255) NOT NULL, + "creation" timestamp(6) DEFAULT NULL, + "modified" timestamp(6) DEFAULT NULL, + "modified_by" varchar(255) DEFAULT NULL, + "owner" varchar(255) DEFAULT NULL, + "docstatus" smallint NOT NULL DEFAULT 0, + "parent" varchar(255) DEFAULT NULL, + "parentfield" varchar(255) DEFAULT NULL, + "parenttype" varchar(255) DEFAULT NULL, + "idx" bigint NOT NULL DEFAULT 0, + "label" varchar(140) DEFAULT NOT NULL, + "group" varchar(140) DEFAULT NULL, + "action_type" varchar(140) DEFAULT NOT NULL, + "action" varchar(140) DEFAULT NOT NULL, + PRIMARY KEY ("name") +) ; + +create index on "tabDocType Action" ("parent"); + -- -- Table structure for table "tabDocType" -- diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index edf8860a01..0c28b22f35 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -328,9 +328,14 @@ frappe.ui.form.Form = class FrappeForm { frappe.ui.form.on(this.doctype, 'refresh', () => { if (!this.is_new()) { this.add_custom_button(action.label, () => { - frappe.xcall(action.method, {doc: this.doc}).then(() => { - frappe.msgprint({message:__('Event Executed'), alert:true}); - }); + if (action.action_type==='Server Action') { + frappe.xcall(action.action, {doc: this.doc}).then(() => { + frappe.msgprint({ + message: __('{} Complete', [action.label]), + alert: true + }); + }); + } }, action.group); } }); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 00b6f95f06..d4caa45a89 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -264,11 +264,11 @@ Object.assign(frappe.utils, { if(has_words(["Pending", "Review", "Medium", "Not Approved"], text)) { style = "warning"; colour = "orange"; - } else if(has_words(["Open", "Urgent", "High"], text)) { + } else if(has_words(["Open", "Urgent", "High", "Failed"], text)) { style = "danger"; colour = "red"; - } else if(has_words(["Closed", "Finished", "Converted", "Completed", "Confirmed", - "Approved", "Yes", "Active", "Available", "Paid"], text)) { + } else if(has_words(["Closed", "Finished", "Converted", "Completed", "Complete", "Confirmed", + "Approved", "Yes", "Active", "Available", "Paid", "Success"], text)) { style = "success"; colour = "green"; } else if(has_words(["Submitted"], text)) { diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index efd7fc6c12..afc2bec009 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -73,12 +73,13 @@ def enqueue_events_for_site(site): frappe.destroy() def enqueue_events(site): - frappe.flags.enqueued_jobs = [] - queued_jobs = get_jobs(key='job_type').get(site) or [] - for job_type in frappe.get_all('Scheduled Job Type', dict(stopped=0)): - if not job_type.method in queued_jobs: - # don't add it to queue if still pending - frappe.get_doc('Scheduled Job Type', job_type.name).enqueue() + if schedule_jobs_based_on_activity(): + frappe.flags.enqueued_jobs = [] + queued_jobs = get_jobs(key='job_type').get(site) or [] + for job_type in frappe.get_all('Scheduled Job Type', dict(stopped=0)): + if not job_type.method in queued_jobs: + # don't add it to queue if still pending + frappe.get_doc('Scheduled Job Type', job_type.name).enqueue() def is_scheduler_inactive(): if frappe.local.conf.maintenance_mode: @@ -90,9 +91,6 @@ def is_scheduler_inactive(): if is_scheduler_disabled(): return True - if is_dormant(): - return True - return False def is_scheduler_disabled(): @@ -110,19 +108,31 @@ def enable_scheduler(): def disable_scheduler(): toggle_scheduler(False) -def is_dormant(since = 345600): - last_user_activity = get_last_active() - if not last_user_activity: - # no user has ever logged in, so not yet used - return False - - if now_datetime() - get_datetime(last_user_activity) > since: # 4 days +def schedule_jobs_based_on_activity(): + '''Returns True for active sites defined by Activity Log + Returns True for inactive sites once in 24 hours''' + if is_dormant(): + # ensure last job is one day old + last_job = frappe.db.get_all('Scheduled Job Log', ('creation'), limit=1, order_by='creation desc') + if not last_job: + return True + else: + if (now_datetime() - get_datetime(last_job[0].creation)).seconds > 86400: + # one day is passed since jobs are run, so lets do this + return True + else: + # schedulers run in the last 24 hours, do nothing + return False + else: + # site active, lets run the jobs return True - return False +def is_dormant(): + last_activity_log = frappe.db.get_all('Activity Log', ('modified'), limit=1, order_by='modified desc') + since = (frappe.get_system_settings('dormant_days') or 4) * 86400 + if not last_activity_log: + return True + if (now_datetime() - get_datetime(last_activity_log[0].modified)).seconds > since: + return True -def get_last_active(): - return frappe.db.sql("""SELECT MAX(`last_active`) FROM `tabUser` - WHERE `user_type` = 'System User' AND `name` NOT IN ({standard_users})""" - .format(standard_users=", ".join(["%s"]*len(STANDARD_USERS))), - STANDARD_USERS)[0][0] + return False \ No newline at end of file From 3e4e6d4b3f48b86551d208cbc3ee84bbfd05679b Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Tue, 24 Sep 2019 12:50:51 +0530 Subject: [PATCH 03/76] feat(doctype link): add links to doctype for link dashboard --- frappe/core/doctype/doctype/doctype.json | 16 ++++++- frappe/core/doctype/doctype_link/__init__.py | 0 .../doctype/doctype_link/doctype_link.json | 46 +++++++++++++++++++ .../core/doctype/doctype_link/doctype_link.py | 10 ++++ .../scheduled_job_type.json | 12 +++-- .../scheduled_job_type/scheduled_job_type.py | 28 +++++------ frappe/database/mariadb/framework_mariadb.sql | 27 ++++++++++- .../database/postgres/framework_postgres.sql | 26 +++++++++++ frappe/installer.py | 2 + frappe/model/__init__.py | 2 +- frappe/model/base_document.py | 2 +- frappe/model/meta.py | 43 ++++++++++++++++- frappe/model/sync.py | 1 + frappe/patches.txt | 1 + 14 files changed, 191 insertions(+), 25 deletions(-) create mode 100644 frappe/core/doctype/doctype_link/__init__.py create mode 100644 frappe/core/doctype/doctype_link/doctype_link.json create mode 100644 frappe/core/doctype/doctype_link/doctype_link.py diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 834ac9b60b..5c3fd8d302 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "Prompt", "creation": "2013-02-18 13:36:19", @@ -59,6 +60,8 @@ "in_create", "actions_section", "actions", + "links_section", + "links", "web_view", "has_web_view", "allow_guest_to_view", @@ -467,11 +470,22 @@ "fieldtype": "Table", "label": "Actions", "options": "DocType Action" + }, + { + "fieldname": "links_section", + "fieldtype": "Section Break", + "label": "Links Section" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "DocType Link" } ], "icon": "fa fa-bolt", "idx": 6, - "modified": "2019-09-23 16:29:21.209832", + "modified": "2019-09-24 11:42:18.081499", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype_link/__init__.py b/frappe/core/doctype/doctype_link/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/doctype_link/doctype_link.json b/frappe/core/doctype/doctype_link/doctype_link.json new file mode 100644 index 0000000000..752b4bb5da --- /dev/null +++ b/frappe/core/doctype/doctype_link/doctype_link.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "creation": "2019-09-24 11:41:25.291377", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "link_doctype", + "link_fieldname", + "group" + ], + "fields": [ + { + "fieldname": "link_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Link DocType", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "link_fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Link Fieldname", + "reqd": 1 + }, + { + "fieldname": "group", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group" + } + ], + "istable": 1, + "modified": "2019-09-24 11:41:25.291377", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType Link", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype_link/doctype_link.py b/frappe/core/doctype/doctype_link/doctype_link.py new file mode 100644 index 0000000000..efe8b09809 --- /dev/null +++ b/frappe/core/doctype/doctype_link/doctype_link.py @@ -0,0 +1,10 @@ +# -*- 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 DocTypeLink(Document): + pass diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index aea8611b6a..ec68544ab5 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -2,10 +2,8 @@ "actions": [ { "action": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", - "action_path": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", "action_type": "Server Action", - "label": "Execute", - "method": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event" + "label": "Execute" } ], "creation": "2019-09-23 14:34:09.205368", @@ -68,7 +66,13 @@ } ], "in_create": 1, - "modified": "2019-09-24 09:11:29.115423", + "links": [ + { + "link_doctype": "Scheduled Job Log", + "link_fieldname": "scheduled_job" + } + ], + "modified": "2019-09-24 11:45:34.748779", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", 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 5ebbf29a5f..517f57469b 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -11,20 +11,6 @@ from datetime import datetime from croniter import croniter from frappe.utils.background_jobs import enqueue -CRON_MAP = { - "Yearly": "0 0 1 1 *", - "Annual": "0 0 1 1 *", - "Monthly": "0 0 1 * *", - "Monthly Long": "0 0 1 * *", - "Weekly": "0 0 * * 0", - "Weekly Long": "0 0 * * 0", - "Daily": "0 0 * * *", - "Daily Long": "0 0 * * *", - "Hourly": "0 * * * *", - "Hourly Long": "0 * * * *", - "All": "0/" + str((frappe.get_conf().scheduler_interval or 240) // 60) + " * * * *", -} - class ScheduledJobType(Document): def autoname(self): self.name = '.'.join(self.method.split('.')[-2:]) @@ -54,6 +40,20 @@ class ScheduledJobType(Document): return self.last_execution <= (current_time or now_datetime()) def get_next_execution(self): + CRON_MAP = { + "Yearly": "0 0 1 1 *", + "Annual": "0 0 1 1 *", + "Monthly": "0 0 1 * *", + "Monthly Long": "0 0 1 * *", + "Weekly": "0 0 * * 0", + "Weekly Long": "0 0 * * 0", + "Daily": "0 0 * * *", + "Daily Long": "0 0 * * *", + "Hourly": "0 * * * *", + "Hourly Long": "0 * * * *", + "All": "0/" + str((frappe.get_conf().scheduler_interval or 240) // 60) + " * * * *", + } + if not self.cron_format: self.cron_format = CRON_MAP[self.queue] diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index cc90938bae..b1a769b189 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -109,7 +109,7 @@ CREATE TABLE `tabDocPerm` ( -- Table structure for table `tabDocType Action` -- -| tabDocType Action | CREATE TABLE `tabDocType Action` ( +CREATE TABLE `tabDocType Action` ( `name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL, `creation` datetime(6) DEFAULT NULL, `modified` datetime(6) DEFAULT NULL, @@ -127,7 +127,30 @@ CREATE TABLE `tabDocPerm` ( PRIMARY KEY (`name`), KEY `parent` (`parent`), KEY `modified` (`modified`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED | +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED; + +-- +-- Table structure for table `tabDocType Action` +-- + +CREATE TABLE `tabDocType Link` ( + `name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL, + `creation` datetime(6) DEFAULT NULL, + `modified` datetime(6) DEFAULT NULL, + `modified_by` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `owner` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `docstatus` int(1) NOT NULL DEFAULT 0, + `parent` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `parentfield` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `parenttype` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idx` int(8) NOT NULL DEFAULT 0, + `group` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `link_doctype` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `link_fieldname` varchar(140) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`name`), + KEY `parent` (`parent`), + KEY `modified` (`modified`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED; -- -- Table structure for table `tabDocType` diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index a0293b29f3..e49b461518 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -131,6 +131,32 @@ CREATE TABLE "tabDocType Action" ( create index on "tabDocType Action" ("parent"); +-- +-- Table structure for table "tabDocType Link" +-- + +DROP TABLE IF EXISTS "tabDocType Link"; +CREATE TABLE "tabDocType Link" ( + "name" varchar(255) NOT NULL, + "creation" timestamp(6) DEFAULT NULL, + "modified" timestamp(6) DEFAULT NULL, + "modified_by" varchar(255) DEFAULT NULL, + "owner" varchar(255) DEFAULT NULL, + "docstatus" smallint NOT NULL DEFAULT 0, + "parent" varchar(255) DEFAULT NULL, + "parentfield" varchar(255) DEFAULT NULL, + "parenttype" varchar(255) DEFAULT NULL, + "idx" bigint NOT NULL DEFAULT 0, + "label" varchar(140) DEFAULT NOT NULL, + "group" varchar(140) DEFAULT NULL, + "link_doctype" varchar(140) DEFAULT NOT NULL, + "link_fieldname" varchar(140) DEFAULT NOT NULL, + PRIMARY KEY ("name") +) ; + +create index on "tabDocType Link" ("parent"); + + -- -- Table structure for table "tabDocType" -- diff --git a/frappe/installer.py b/frappe/installer.py index 764a0b6780..f691a6cb22 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -18,6 +18,7 @@ from frappe.utils.fixtures import sync_fixtures from frappe.website import render from frappe.modules.utils import sync_customizations from frappe.database import setup_database +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs def install_db(root_login="root", root_password=None, db_name=None, source_sql=None, admin_password=None, verbose=True, force=0, site_config=None, reinstall=False, @@ -91,6 +92,7 @@ def install_app(name, verbose=False, set_as_patched=True): for after_install in app_hooks.after_install or []: frappe.get_attr(after_install)() + sync_jobs() sync_fixtures(name) sync_customizations(name) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 1c6f965d42..1fe92d7a67 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -45,7 +45,7 @@ default_fields = ('doctype','name','owner','creation','modified','modified_by', 'parent','parentfield','parenttype','idx','docstatus') optional_fields = ("_user_tags", "_comments", "_assign", "_liked_by", "_seen") table_fields = ('Table', 'Table MultiSelect') -core_doctypes_list = ('DocType', 'DocField', 'DocPerm', 'DocType Action','User', 'Role', 'Has Role', +core_doctypes_list = ('DocType', 'DocField', 'DocPerm', 'DocType Action', 'DocType Link', 'User', 'Role', 'Has Role', 'Page', 'Module Def', 'Print Format', 'Report', 'Customize Form', 'Customize Form Field', 'Property Setter', 'Custom Field', 'Custom Script') diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 1648c64a13..a50bf9fdaf 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -22,7 +22,7 @@ max_positive_value = { 'bigint': 2 ** 63 } -DOCTYPES_FOR_DOCTYPE = ('DocType', 'DocField', 'DocPerm', 'DocType Action') +DOCTYPES_FOR_DOCTYPE = ('DocType', 'DocField', 'DocPerm', 'DocType Action', 'DocType Link') _classes = {} diff --git a/frappe/model/meta.py b/frappe/model/meta.py index cd342b77aa..97e526c4e0 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -165,7 +165,7 @@ class Meta(Document): def get_valid_columns(self): if not hasattr(self, "_valid_columns"): - if self.name in ("DocType", "DocField", "DocPerm", 'DocType Action',"Property Setter"): + if self.name in ("DocType", "DocField", "DocPerm", 'DocType Action', 'DocType Link', "Property Setter"): self._valid_columns = get_table_columns(self.name) else: self._valid_columns = self.default_fields + \ @@ -174,7 +174,12 @@ class Meta(Document): return self._valid_columns def get_table_field_doctype(self, fieldname): - return { "fields": "DocField", "permissions": "DocPerm", "actions": "DocType Action"}.get(fieldname) + return { + "fields": "DocField", + "permissions": "DocPerm", + "actions": "DocType Action", + 'links': 'DocType Link' + }.get(fieldname) def get_field(self, fieldname): '''Return docfield from meta''' @@ -419,11 +424,44 @@ class Meta(Document): except ImportError: pass + self.add_doctype_links(data) + for hook in frappe.get_hooks("override_doctype_dashboards", {}).get(self.name, []): data = frappe.get_attr(hook)(data=data) return data + def add_doctype_links(self, data): + '''add `links` child table in standard link dashboard format''' + if self.links: + if not data.transactions: + # init groups + data.transactions = [] + data.non_standard_fieldnames = {} + + for link in self.links: + link.added = False + for group in data.transactions: + # group found + if group.label == link.label: + if not link.link_doctype in group.items: + group.items.append(link.link_doctype) + link.added = True + + if not link.added: + # group not found, make a new group + data.transactions.append(dict( + label = link.group, + items = [link.link_doctype] + )) + + if link.link_fieldname != data.fieldname: + if data.fieldname: + data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname + else: + data.fieldname = link.link_fieldname + + def get_row_template(self): return self.get_web_template(suffix='_row') @@ -445,6 +483,7 @@ DOCTYPE_TABLE_FIELDS = [ frappe._dict({"fieldname": "fields", "options": "DocField"}), frappe._dict({"fieldname": "permissions", "options": "DocPerm"}), frappe._dict({"fieldname": "actions", "options": "DocType Action"}), + frappe._dict({"fieldname": "links", "options": "DocType Link"}), ] ####### diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 070ab6bbc7..2d9a48390d 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -30,6 +30,7 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe for d in (("core", "docfield"), ("core", "docperm"), ("core", "doctype_action"), + ("core", "doctype_link"), ("core", "role"), ("core", "has_role"), ("core", "doctype"), diff --git a/frappe/patches.txt b/frappe/patches.txt index 1a89641a6a..7d7a3dc2ea 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -10,6 +10,7 @@ frappe.patches.v11_0.drop_column_apply_user_permissions execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) #2017-09-22 execute:frappe.reload_doc('core', 'doctype', 'docfield', force=True) #2018-02-20 execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 +execute:frappe.reload_doc('core', 'doctype', 'doctype_link', force=True) #2019-09-23 execute:frappe.reload_doc('core', 'doctype', 'custom_docperm') execute:frappe.reload_doc('core', 'doctype', 'docperm') #2018-05-29 execute:frappe.reload_doc('core', 'doctype', 'comment') From 0035772f8fb7b7cd72af7bbde6b1099299554cb4 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Tue, 24 Sep 2019 21:45:48 +0530 Subject: [PATCH 04/76] fix(tests) --- frappe/core/doctype/version/version.py | 2 +- frappe/database/postgres/framework_postgres.sql | 12 ++++++------ .../email/doctype/notification/test_notification.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frappe/core/doctype/version/version.py b/frappe/core/doctype/version/version.py index cb891afa0c..05cc102ab9 100644 --- a/frappe/core/doctype/version/version.py +++ b/frappe/core/doctype/version/version.py @@ -50,7 +50,7 @@ def get_diff(old, new, for_child=False): if df.fieldtype in no_value_fields and df.fieldtype not in table_fields: continue - old_value, new_value = old.get(df.fieldname) or [], new.get(df.fieldname) or [] + old_value, new_value = old.get(df.fieldname), new.get(df.fieldname) if df.fieldtype in table_fields: # make maps diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index e49b461518..373a55279b 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -122,10 +122,10 @@ CREATE TABLE "tabDocType Action" ( "parentfield" varchar(255) DEFAULT NULL, "parenttype" varchar(255) DEFAULT NULL, "idx" bigint NOT NULL DEFAULT 0, - "label" varchar(140) DEFAULT NOT NULL, + "label" varchar(140) NOT NULL, "group" varchar(140) DEFAULT NULL, - "action_type" varchar(140) DEFAULT NOT NULL, - "action" varchar(140) DEFAULT NOT NULL, + "action_type" varchar(140) NOT NULL, + "action" varchar(140) NOT NULL, PRIMARY KEY ("name") ) ; @@ -147,10 +147,10 @@ CREATE TABLE "tabDocType Link" ( "parentfield" varchar(255) DEFAULT NULL, "parenttype" varchar(255) DEFAULT NULL, "idx" bigint NOT NULL DEFAULT 0, - "label" varchar(140) DEFAULT NOT NULL, + "label" varchar(140) NOT NULL, "group" varchar(140) DEFAULT NULL, - "link_doctype" varchar(140) DEFAULT NOT NULL, - "link_fieldname" varchar(140) DEFAULT NOT NULL, + "link_doctype" varchar(140) NOT NULL, + "link_fieldname" varchar(140) NOT NULL, PRIMARY KEY ("name") ) ; diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py index 4d3c3167a5..b9bbde172d 100644 --- a/frappe/email/doctype/notification/test_notification.py +++ b/frappe/email/doctype/notification/test_notification.py @@ -136,7 +136,7 @@ class TestNotification(unittest.TestCase): "reference_name": event.name, "status": "Not Sent"})) frappe.set_user('Administrator') - frappe.utils.scheduler.trigger(frappe.local.site, "daily", now=True) + frappe.get_doc('Scheduled Job Type', dict(method='frappe.email.doctype.notification.notification.trigger_daily_alerts')).execute() # not today, so no alert self.assertFalse(frappe.db.get_value("Email Queue", {"reference_doctype": "Event", @@ -150,7 +150,7 @@ class TestNotification(unittest.TestCase): self.assertFalse(frappe.db.get_value("Email Queue", {"reference_doctype": "Event", "reference_name": event.name, "status": "Not Sent"})) - frappe.utils.scheduler.trigger(frappe.local.site, "daily", now=True) + frappe.get_doc('Scheduled Job Type', dict(method='frappe.email.doctype.notification.notification.trigger_daily_alerts')).execute() # today so show alert self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": "Event", From c583be6f33f4af37bac9e0ed0730b0e4e8afab86 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Tue, 24 Sep 2019 22:44:08 +0530 Subject: [PATCH 05/76] fix(scheduler): implement queue peeking --- .../scheduled_job_type/scheduled_job_type.py | 28 +++++++++++++------ .../database/postgres/framework_postgres.sql | 2 +- frappe/tests/test_scheduler.py | 21 +++++++++++++- frappe/utils/background_jobs.py | 15 +++++++--- 4 files changed, 51 insertions(+), 15 deletions(-) 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 517f57469b..609cfd06a1 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.utils import now_datetime, get_datetime from datetime import datetime from croniter import croniter -from frappe.utils.background_jobs import enqueue +from frappe.utils.background_jobs import enqueue, get_jobs class ScheduledJobType(Document): def autoname(self): @@ -24,12 +24,18 @@ class ScheduledJobType(Document): # enqueue event if last execution is done if self.is_event_due(): self.update_last_execution() - frappe.flags.enqueued_jobs.append(self.method) - if frappe.flags.in_test: - self.execute() - else: - enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', - job_type=self.method) + if frappe.flags.enqueued_jobs: + frappe.flags.enqueued_jobs.append(self.method) + + if frappe.flags.execute_job: + self.execute() + else: + if not self.is_job_in_queue(): + enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', + queue = self.get_queue_name(), job_type=self.method) + return True + else: + return False def is_event_due(self, current_time = None): '''Return true if event is due based on time lapsed since last execution''' @@ -39,6 +45,10 @@ class ScheduledJobType(Document): # if the next scheduled event is before NOW, then its due! return self.last_execution <= (current_time or now_datetime()) + def is_job_in_queue(self): + queued_jobs = get_jobs(site=frappe.local.site, key='job_type')[frappe.local.site] + return self.method in queued_jobs + def get_next_execution(self): CRON_MAP = { "Yearly": "0 0 1 1 *", @@ -91,7 +101,7 @@ class ScheduledJobType(Document): frappe.db.commit() def get_queue_name(self): - return self.queue.replace(' ', '_').lower() + return 'long' if ('Long' in self.queue) else 'default' @frappe.whitelist() def execute_event(doc): @@ -143,4 +153,4 @@ def insert_single_event(queue, event, cron_format = None): def clear_events(all_events, scheduler_events): for event in frappe.get_all('Scheduled Job Type', ('name', 'method')): if event.method not in all_events: - frappe.db.delete_doc('Scheduled Job Type', event.name) + frappe.delete_doc('Scheduled Job Type', event.name) diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 373a55279b..cd2f02d8e4 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -147,7 +147,7 @@ CREATE TABLE "tabDocType Link" ( "parentfield" varchar(255) DEFAULT NULL, "parenttype" varchar(255) DEFAULT NULL, "idx" bigint NOT NULL DEFAULT 0, - "label" varchar(140) NOT NULL, + "label" varchar(140) DEFAULT NULL, "group" varchar(140) DEFAULT NULL, "link_doctype" varchar(140) NOT NULL, "link_fieldname" varchar(140) NOT NULL, diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index 4e193354b0..c1b03f7251 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from unittest import TestCase from dateutil.relativedelta import relativedelta from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs -from frappe.utils.background_jobs import enqueue +from frappe.utils.background_jobs import enqueue, get_jobs from frappe.utils.scheduler import enqueue_events import frappe @@ -20,12 +20,31 @@ class TestScheduler(TestCase): def test_enqueue_jobs(self): frappe.db.sql('update `tabScheduled Job Type` set last_execution = "2010-01-01 00:00:00"') + + frappe.flags.execute_job = True enqueue_events(site = frappe.local.site) + frappe.flags.execute_job = False self.assertTrue('frappe.email.queue.clear_outbox', frappe.flags.enqueued_jobs) self.assertTrue('frappe.utils.change_log.check_for_update', frappe.flags.enqueued_jobs) self.assertTrue('frappe.email.doctype.auto_email_report.auto_email_report.send_monthly', frappe.flags.enqueued_jobs) + def test_queue_peeking(self): + if not frappe.db.exists('Scheduled Job Type', 'test_scheduler.test_timeout'): + job = frappe.get_doc(dict( + doctype = 'Scheduled Job Type', + method = 'frappe.tests.test_scheduler.test_timeout', + last_execution = '2010-01-01 00:00:00', + queue = 'All' + )).insert() + else: + job = frappe.get_doc('Scheduled Job Type', 'test_scheduler.test_timeout') + + self.assertTrue(job.enqueue()) + print(get_jobs(site=frappe.local.site, key='job_type')) + self.assertFalse(job.enqueue()) + job.delete() + def test_job_timeout(self): return job = enqueue(test_timeout, timeout=10) diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 61e866e2f8..7681e76646 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -160,18 +160,25 @@ def get_worker_name(queue): def get_jobs(site=None, queue=None, key='method'): '''Gets jobs per queue or per site or both''' jobs_per_site = defaultdict(list) + + def add_to_dict(job): + if key in job.kwargs: + jobs_per_site[job.kwargs['site']].append(job.kwargs[key]) + + elif key in job.kwargs.get('kwargs', {}): + # optional keyword arguments are stored in 'kwargs' of 'kwargs' + jobs_per_site[job.kwargs['site']].append(job.kwargs['kwargs'][key]) + for queue in get_queue_list(queue): q = get_queue(queue) for job in q.jobs: if job.kwargs.get('site'): if site is None: - # get jobs for all sites - jobs_per_site[job.kwargs['site']].append(job.kwargs[key]) + add_to_dict(job) elif job.kwargs['site'] == site: - # get jobs only for given site - jobs_per_site[site].append(job.kwargs[key]) + add_to_dict(job) else: print('No site found in job', job.__dict__) From e85cd6549caa4d391b902739d0d56a79fda71f89 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 25 Sep 2019 09:55:17 +0530 Subject: [PATCH 06/76] fix(tests): fix queue peeking test --- frappe/__init__.py | 2 ++ frappe/core/doctype/scheduled_job_type/scheduled_job_type.py | 4 ++-- frappe/tests/test_scheduler.py | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 5827541590..1ebaebce97 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1515,6 +1515,8 @@ def log_error(message=None, title=None): error = get_traceback() else: error = message + else: + error = get_traceback() return get_doc(dict(doctype='Error Log', error=as_unicode(error), method=title)).insert(ignore_permissions=True) 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 609cfd06a1..c08afa179a 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -34,8 +34,8 @@ class ScheduledJobType(Document): enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', queue = self.get_queue_name(), job_type=self.method) return True - else: - return False + + return False def is_event_due(self, current_time = None): '''Return true if event is due based on time lapsed since last execution''' diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index c1b03f7251..b8c46d376f 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -37,11 +37,14 @@ class TestScheduler(TestCase): last_execution = '2010-01-01 00:00:00', queue = 'All' )).insert() + frappe.db.commit() else: job = frappe.get_doc('Scheduled Job Type', 'test_scheduler.test_timeout') self.assertTrue(job.enqueue()) - print(get_jobs(site=frappe.local.site, key='job_type')) + job.db_set('last_execution', '2010-01-01 00:00:00') + frappe.db.commit() + time.sleep(1) # wait if job is not yet queued self.assertFalse(job.enqueue()) job.delete() From d6f14608f104d6f64386c3b3d2f2b167285d1112 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 25 Sep 2019 11:06:22 +0530 Subject: [PATCH 07/76] fix(minor): set execute function in a try catch block to detect if execution cannot even begin --- frappe/core/doctype/scheduled_job_type/scheduled_job_type.py | 5 ++++- frappe/utils/scheduler.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 c08afa179a..7c6b2a0e0c 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -111,7 +111,10 @@ def execute_event(doc): def run_scheduled_job(job_type): '''This is a wrapper function that runs a hooks.scheduler_events method''' - frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() + try: + frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() + except Exception: + print(frappe.get_traceback()) def sync_jobs(): frappe.reload_doc('core', 'doctype', 'scheduled_job_type') diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index afc2bec009..4d9f782542 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -75,8 +75,8 @@ def enqueue_events_for_site(site): def enqueue_events(site): if schedule_jobs_based_on_activity(): frappe.flags.enqueued_jobs = [] - queued_jobs = get_jobs(key='job_type').get(site) or [] - for job_type in frappe.get_all('Scheduled Job Type', dict(stopped=0)): + queued_jobs = get_jobs(site=site, key='job_type').get(site) or [] + for job_type in frappe.get_all('Scheduled Job Type', ('name', 'method'), dict(stopped=0)): if not job_type.method in queued_jobs: # don't add it to queue if still pending frappe.get_doc('Scheduled Job Type', job_type.name).enqueue() From fea34e3f6beba9507f5ca74ca45d2c94ddd7fdf1 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 25 Sep 2019 12:24:29 +0530 Subject: [PATCH 08/76] fix(tests): test_scheduler --- .../scheduled_job_log/scheduled_job_log.json | 20 +++--- .../scheduled_job_type.json | 4 +- .../scheduled_job_type/scheduled_job_type.py | 7 +- frappe/database/database.py | 12 +++- frappe/tests/test_scheduler.py | 65 +++++++++++++++---- frappe/utils/scheduler.py | 18 ++--- 6 files changed, 90 insertions(+), 36 deletions(-) diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json index cfa2f27d1a..9e7f72a722 100644 --- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json @@ -1,11 +1,12 @@ { + "actions": [], "creation": "2019-09-23 14:36:36.935869", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ "status", - "scheduled_job", + "scheduled_job_type", "details" ], "fields": [ @@ -20,7 +21,13 @@ "reqd": 1 }, { - "fieldname": "scheduled_job", + "fieldname": "details", + "fieldtype": "Code", + "label": "Details", + "read_only": 1 + }, + { + "fieldname": "scheduled_job_type", "fieldtype": "Link", "in_list_view": 1, "in_standard_filter": 1, @@ -28,15 +35,10 @@ "options": "Scheduled Job Type", "read_only": 1, "reqd": 1 - }, - { - "fieldname": "details", - "fieldtype": "Code", - "label": "Details", - "read_only": 1 } ], - "modified": "2019-09-23 14:36:36.935869", + "links": [], + "modified": "2019-09-25 11:55:10.646458", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Log", diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index ec68544ab5..d35b88e9de 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -69,10 +69,10 @@ "links": [ { "link_doctype": "Scheduled Job Log", - "link_fieldname": "scheduled_job" + "link_fieldname": "scheduled_job_type" } ], - "modified": "2019-09-24 11:45:34.748779", + "modified": "2019-09-25 11:54:58.013623", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", 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 7c6b2a0e0c..9325d62078 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -35,6 +35,10 @@ class ScheduledJobType(Document): queue = self.get_queue_name(), job_type=self.method) return True + else: + pass + #print('not yet due') + return False def is_event_due(self, current_time = None): @@ -90,7 +94,7 @@ class ScheduledJobType(Document): if not self.create_log: return if not self.scheduler_log: - self.scheduler_log = frappe.get_doc(dict(doctype = 'Scheduled Job Log', scheduled_job=self.name)).insert(ignore_permissions=True) + self.scheduler_log = frappe.get_doc(dict(doctype = 'Scheduled Job Log', scheduled_job_type=self.name)).insert(ignore_permissions=True) self.scheduler_log.db_set('status', status) if status == 'Failed': self.scheduler_log.db_set('details', frappe.get_traceback()) @@ -112,6 +116,7 @@ def execute_event(doc): def run_scheduled_job(job_type): '''This is a wrapper function that runs a hooks.scheduler_events method''' try: + print('executing job {}'.format(job_type)) frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() except Exception: print(frappe.get_traceback()) diff --git a/frappe/database/database.py b/frappe/database/database.py index 1e6a85236e..5779c77e86 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -15,7 +15,7 @@ import frappe.model.meta from frappe import _ from time import time -from frappe.utils import now, getdate, cast_fieldtype +from frappe.utils import now, getdate, cast_fieldtype, get_datetime from frappe.utils.background_jobs import execute_job, get_queue from frappe.model.utils.link_count import flush_local_link_count from frappe.utils import cint @@ -940,6 +940,16 @@ class Database(object): else: frappe.throw(_('No conditions provided')) + def get_last_created(self, doctype): + last_record = self.get_all(doctype, ('creation'), limit=1, order_by='creation desc') + if last_record: + return get_datetime(last_record[0].creation) + else: + return None + + def clear_table(self, doctype): + self.sql('truncate `tab{}`'.format(doctype)) + def log_touched_tables(self, query, values=None): if values: query = frappe.safe_decode(self._cursor.mogrify(query, values)) diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index b8c46d376f..3617a3983b 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -4,15 +4,21 @@ from unittest import TestCase from dateutil.relativedelta import relativedelta from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.utils.background_jobs import enqueue, get_jobs -from frappe.utils.scheduler import enqueue_events +from frappe.utils.scheduler import enqueue_events, is_dormant, schedule_jobs_based_on_activity +from frappe.utils import add_days, get_datetime import frappe import time def test_timeout(): - '''This function needs to be pickleable''' time.sleep(100) +def test_timeout_10(): + time.sleep(10) + +def test_method(): + pass + class TestScheduler(TestCase): def setUp(self): if not frappe.get_all('Scheduled Job Type', limit=1): @@ -30,24 +36,38 @@ class TestScheduler(TestCase): self.assertTrue('frappe.email.doctype.auto_email_report.auto_email_report.send_monthly', frappe.flags.enqueued_jobs) def test_queue_peeking(self): - if not frappe.db.exists('Scheduled Job Type', 'test_scheduler.test_timeout'): - job = frappe.get_doc(dict( - doctype = 'Scheduled Job Type', - method = 'frappe.tests.test_scheduler.test_timeout', - last_execution = '2010-01-01 00:00:00', - queue = 'All' - )).insert() - frappe.db.commit() - else: - job = frappe.get_doc('Scheduled Job Type', 'test_scheduler.test_timeout') - + job = get_test_job() self.assertTrue(job.enqueue()) job.db_set('last_execution', '2010-01-01 00:00:00') frappe.db.commit() - time.sleep(1) # wait if job is not yet queued + time.sleep(3) # wait if job is not yet queued self.assertFalse(job.enqueue()) job.delete() + def test_is_dormant(self): + self.assertTrue(is_dormant(check_time= get_datetime('2100-01-01 00:00:00'))) + self.assertTrue(is_dormant(check_time = add_days(frappe.db.get_last_created('Activity Log'), 5))) + self.assertFalse(is_dormant(check_time = frappe.db.get_last_created('Activity Log'))) + + def test_once_a_day_for_dormant(self): + frappe.db.clear_table('Scheduled Job Log') + self.assertTrue(schedule_jobs_based_on_activity(check_time= get_datetime('2100-01-01 00:00:00'))) + self.assertTrue(schedule_jobs_based_on_activity(check_time = add_days(frappe.db.get_last_created('Activity Log'), 5))) + + # create a fake job executed 5 days from now + job = get_test_job(method='frappe.tests.test_scheduler.test_method', queue='Daily') + job.execute() + job_log = frappe.get_doc('Scheduled Job Log', dict(scheduled_job_type=job.name)) + job_log.db_set('creation', add_days(frappe.db.get_last_created('Activity Log'), 5)) + + # inactive site with recent job, don't run + self.assertFalse(schedule_jobs_based_on_activity(check_time = add_days(frappe.db.get_last_created('Activity Log'), 5))) + + # one more day has passed + self.assertTrue(schedule_jobs_based_on_activity(check_time = add_days(frappe.db.get_last_created('Activity Log'), 6))) + + frappe.db.rollback() + def test_job_timeout(self): return job = enqueue(test_timeout, timeout=10) @@ -59,3 +79,20 @@ class TestScheduler(TestCase): break self.assertTrue(job.is_failed) + +def get_test_job(method='frappe.tests.test_scheduler.test_timeout_10', queue='All'): + if not frappe.db.exists('Scheduled Job Type', dict(method=method)): + job = frappe.get_doc(dict( + doctype = 'Scheduled Job Type', + method = method, + last_execution = '2010-01-01 00:00:00', + queue = queue + )).insert() + frappe.db.commit() + else: + job = frappe.get_doc('Scheduled Job Type', dict(method=method)) + job.db_set('last_execution', '2010-01-01 00:00:00') + job.db_set('queue', queue) + + return job + diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index 4d9f782542..fb606d6d3d 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -108,16 +108,16 @@ def enable_scheduler(): def disable_scheduler(): toggle_scheduler(False) -def schedule_jobs_based_on_activity(): +def schedule_jobs_based_on_activity(check_time=None): '''Returns True for active sites defined by Activity Log Returns True for inactive sites once in 24 hours''' - if is_dormant(): + if is_dormant(check_time=check_time): # ensure last job is one day old - last_job = frappe.db.get_all('Scheduled Job Log', ('creation'), limit=1, order_by='creation desc') - if not last_job: + last_job_timestamp = frappe.db.get_last_created('Scheduled Job Log') + if not last_job_timestamp: return True else: - if (now_datetime() - get_datetime(last_job[0].creation)).seconds > 86400: + if ((check_time or now_datetime()) - last_job_timestamp).total_seconds() >= 86400: # one day is passed since jobs are run, so lets do this return True else: @@ -127,12 +127,12 @@ def schedule_jobs_based_on_activity(): # site active, lets run the jobs return True -def is_dormant(): - last_activity_log = frappe.db.get_all('Activity Log', ('modified'), limit=1, order_by='modified desc') +def is_dormant(check_time=None): + last_activity_log_timestamp = frappe.db.get_last_created('Activity Log') since = (frappe.get_system_settings('dormant_days') or 4) * 86400 - if not last_activity_log: + if not last_activity_log_timestamp: return True - if (now_datetime() - get_datetime(last_activity_log[0].modified)).seconds > since: + if ((check_time or now_datetime()) - last_activity_log_timestamp).total_seconds() >= since: return True return False \ No newline at end of file From 463d1c5ec5312c355b280959b2d0865997df3dcb Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 26 Sep 2019 12:22:37 +0530 Subject: [PATCH 09/76] fix(major): upgrade redis-py and rq and try and fix scheduler tests --- .../doctype/scheduled_job_type/scheduled_job_type.py | 7 +++---- frappe/tests/test_scheduler.py | 11 +++++++++-- frappe/utils/background_jobs.py | 12 +++--------- frappe/utils/redis_wrapper.py | 5 +++++ requirements.txt | 4 ++-- 5 files changed, 22 insertions(+), 17 deletions(-) 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 9325d62078..dcdf810995 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -34,10 +34,10 @@ class ScheduledJobType(Document): enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', queue = self.get_queue_name(), job_type=self.method) return True - + else: + pass else: pass - #print('not yet due') return False @@ -111,12 +111,11 @@ class ScheduledJobType(Document): def execute_event(doc): frappe.only_for('System Manager') doc = json.loads(doc) - frappe.get_doc('Scheduled Job Type', doc.get('name')).execute() + frappe.get_doc('Scheduled Job Type', doc.get('name')).enqueue() def run_scheduled_job(job_type): '''This is a wrapper function that runs a hooks.scheduler_events method''' try: - print('executing job {}'.format(job_type)) frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() except Exception: print(frappe.get_traceback()) diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index 3617a3983b..4f4c918576 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -37,10 +37,17 @@ class TestScheduler(TestCase): def test_queue_peeking(self): job = get_test_job() + self.assertTrue(job.enqueue()) job.db_set('last_execution', '2010-01-01 00:00:00') frappe.db.commit() - time.sleep(3) # wait if job is not yet queued + + # 1 job in queue + self.assertTrue(job.enqueue()) + job.db_set('last_execution', '2010-01-01 00:00:00') + frappe.db.commit() + + # 2nd job not loaded self.assertFalse(job.enqueue()) job.delete() @@ -88,11 +95,11 @@ def get_test_job(method='frappe.tests.test_scheduler.test_timeout_10', queue='Al last_execution = '2010-01-01 00:00:00', queue = queue )).insert() - frappe.db.commit() else: job = frappe.get_doc('Scheduled Job Type', dict(method=method)) job.db_set('last_execution', '2010-01-01 00:00:00') job.db_set('queue', queue) + frappe.db.commit() return job diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 7681e76646..c1ac7581dc 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -119,6 +119,8 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, except: frappe.db.rollback() frappe.log_error(method_name) + frappe.db.commit() + print(frappe.get_traceback()) raise else: @@ -140,8 +142,6 @@ def start_worker(queue=None, quiet = False): with Connection(redis_connection): queues = get_queue_list(queue) logging_level = "INFO" - if quiet: - logging_level = "WARNING" Worker(queues, name=get_worker_name(queue)).work(logging_level = logging_level) def get_worker_name(queue): @@ -203,13 +203,7 @@ def get_queue_list(queue_list=None): def get_queue(queue, is_async=True): '''Returns a Queue object tied to a redis connection''' validate_queue(queue) - - kwargs = { - 'connection': get_redis_conn(), - 'async': is_async - } - - return Queue(queue, **kwargs) + return Queue(queue, connection=get_redis_conn(), is_async=is_async) def validate_queue(queue, default_queue_list=None): if not default_queue_list: diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py index cb92ced5c6..204efc9d1e 100644 --- a/frappe/utils/redis_wrapper.py +++ b/frappe/utils/redis_wrapper.py @@ -141,6 +141,9 @@ class RedisWrapper(redis.Redis): return super(RedisWrapper, self).llen(self.make_key(key)) def hset(self, name, key, value, shared=False): + if key is None: + return + _name = self.make_key(name, shared=shared) # set in local @@ -164,6 +167,8 @@ class RedisWrapper(redis.Redis): if not _name in frappe.local.cache: frappe.local.cache[_name] = {} + if not key: return None + if key in frappe.local.cache[_name]: return frappe.local.cache[_name][key] diff --git a/requirements.txt b/requirements.txt index 84788c863e..a7b09b797d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ werkzeug semantic_version rauth>=0.6.2 requests -redis==2.10.6 +redis>=3.0 selenium babel==2.6.0 ipython @@ -26,7 +26,7 @@ bleach==2.1.4 bleach-whitelist Pillow beautifulsoup4 -rq==0.12.0 +rq>=1.1.0 schedule cryptography pyopenssl From 63e14f6ee2a80a1f55d6464c4974b56cc0f5951b Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 26 Sep 2019 12:37:47 +0530 Subject: [PATCH 10/76] fix(tests): strict redis --- frappe/twofactor.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frappe/twofactor.py b/frappe/twofactor.py index a539532f25..e60113215b 100644 --- a/frappe/twofactor.py +++ b/frappe/twofactor.py @@ -248,9 +248,7 @@ def get_link_for_qrcode(user, totp_uri): key = frappe.generate_hash(length=20) key_user = "{}_user".format(key) key_uri = "{}_uri".format(key) - lifespan = int(frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image')) - if lifespan<=0: - lifespan = 240 + lifespan = int(frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image')) or 240 frappe.cache().set_value(key_uri, totp_uri, expires_in_sec=lifespan) frappe.cache().set_value(key_user, user, expires_in_sec=lifespan) return get_url('/qrcode?k={}'.format(key)) @@ -387,7 +385,7 @@ def should_remove_barcode_image(barcode): '''Check if it's time to delete barcode image from server. ''' if isinstance(barcode, string_types): barcode = frappe.get_doc('File', barcode) - lifespan = frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image') + lifespan = frappe.db.get_value('System Settings', 'System Settings', 'lifespan_qrcode_image') or 240 if time_diff_in_seconds(get_datetime(), barcode.creation) > int(lifespan): return True return False From 74e2e051623ad0ec4d599169933f9fa00d095098 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 26 Sep 2019 14:00:55 +0530 Subject: [PATCH 11/76] fix(test): two_factor --- frappe/tests/test_twofactor.py | 4 ++-- frappe/utils/redis_wrapper.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/tests/test_twofactor.py b/frappe/tests/test_twofactor.py index 27129d9832..57c98d2aee 100644 --- a/frappe/tests/test_twofactor.py +++ b/frappe/tests/test_twofactor.py @@ -157,8 +157,8 @@ def create_http_request(): '''Get http request object.''' set_request(method='POST', path='login') enable_2fa() - frappe.form_dict['usr'] = 'test@erpnext.com' - frappe.form_dict['pwd'] = 'test' + frappe.form_dict['usr'] = 'test@example.com' + frappe.form_dict['pwd'] = 'Eastern_43A1W' frappe.local.form_dict['cmd'] = 'login' http_requests = HTTPRequest() return http_requests diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py index 204efc9d1e..4af59bceb2 100644 --- a/frappe/utils/redis_wrapper.py +++ b/frappe/utils/redis_wrapper.py @@ -43,7 +43,7 @@ class RedisWrapper(redis.Redis): try: if expires_in_sec: - self.setex(key, pickle.dumps(val), expires_in_sec) + self.setex(key, expires_in_sec, pickle.dumps(val)) else: self.set(key, pickle.dumps(val)) From 2415eac7708d50f98cbb7af9ffd0806a4027e05e Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 26 Sep 2019 15:59:32 +0530 Subject: [PATCH 12/76] fix(minor): postgres compatibility --- frappe/tests/test_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index 4f4c918576..0ce7265fd7 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -25,7 +25,7 @@ class TestScheduler(TestCase): sync_jobs() def test_enqueue_jobs(self): - frappe.db.sql('update `tabScheduled Job Type` set last_execution = "2010-01-01 00:00:00"') + frappe.db.sql("update `tabScheduled Job Type` set last_execution = '2010-01-01 00:00:00'") frappe.flags.execute_job = True enqueue_events(site = frappe.local.site) From a050312b1a35bf04c4c0e274b5a50b76ff8e48e5 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Fri, 27 Sep 2019 12:13:45 +0530 Subject: [PATCH 13/76] fix(linting): added .codacy.yml to ignore .sql files --- .codacy.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .codacy.yml diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000000..944b90d2ac --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,2 @@ +exclude_paths: + - '*.sql' \ No newline at end of file From a93fcd3de6a2dd8f1337f5b62c12f109afa461ed Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Fri, 27 Sep 2019 12:46:07 +0530 Subject: [PATCH 14/76] fix(minor): rename "queue" to "frequency" --- .codacy.yml | 2 +- .../scheduled_job_type.json | 24 +++++++++---------- .../scheduled_job_type/scheduled_job_type.py | 18 ++++++-------- .../test_scheduled_job_type.py | 6 ++--- 4 files changed, 23 insertions(+), 27 deletions(-) diff --git a/.codacy.yml b/.codacy.yml index 944b90d2ac..4754a63e7e 100644 --- a/.codacy.yml +++ b/.codacy.yml @@ -1,2 +1,2 @@ exclude_paths: - - '*.sql' \ No newline at end of file + - '**.sql' \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index d35b88e9de..1aafdb47b0 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -13,7 +13,7 @@ "field_order": [ "stopped", "method", - "queue", + "frequency", "cron_format", "last_execution", "create_log" @@ -27,16 +27,6 @@ "read_only": 1, "reqd": 1 }, - { - "fieldname": "queue", - "fieldtype": "Select", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Queue", - "options": "All\nHourly\nDaily\nDaily Long\nWeekly\nWeekly Long\nMonthly\nMonthly Long\nCron\nYearly\nAnnual", - "read_only": 1, - "reqd": 1 - }, { "default": "0", "fieldname": "stopped", @@ -63,6 +53,16 @@ "fieldtype": "Data", "label": "Cron Format", "read_only": 1 + }, + { + "fieldname": "frequency", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Frequency", + "options": "All\nHourly\nDaily\nDaily Long\nWeekly\nWeekly Long\nMonthly\nMonthly Long\nCron\nYearly\nAnnual", + "read_only": 1, + "reqd": 1 } ], "in_create": 1, @@ -72,7 +72,7 @@ "link_fieldname": "scheduled_job_type" } ], - "modified": "2019-09-25 11:54:58.013623", + "modified": "2019-09-27 12:19:23.259989", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", 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 dcdf810995..38123cea33 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -16,7 +16,7 @@ class ScheduledJobType(Document): self.name = '.'.join(self.method.split('.')[-2:]) def validate(self): - if self.queue != 'All': + if self.frequency != 'All': # force logging for all events other than continuous ones (ALL) self.create_log = 1 @@ -34,10 +34,6 @@ class ScheduledJobType(Document): enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', queue = self.get_queue_name(), job_type=self.method) return True - else: - pass - else: - pass return False @@ -69,7 +65,7 @@ class ScheduledJobType(Document): } if not self.cron_format: - self.cron_format = CRON_MAP[self.queue] + self.cron_format = CRON_MAP[self.frequency] return croniter(self.cron_format, get_datetime(self.last_execution)).get_next(datetime) @@ -105,7 +101,7 @@ class ScheduledJobType(Document): frappe.db.commit() def get_queue_name(self): - return 'long' if ('Long' in self.queue) else 'default' + return 'long' if ('Long' in self.frequency) else 'default' @frappe.whitelist() def execute_event(doc): @@ -145,16 +141,16 @@ def insert_cron_event(events, all_events): def insert_event_list(events, event_type, all_events): for event in events: all_events.append(event) - queue = event_type.replace('_', ' ').title() - insert_single_event(queue, event) + frequency = event_type.replace('_', ' ').title() + insert_single_event(frequency, event) -def insert_single_event(queue, event, cron_format = None): +def insert_single_event(frequency, event, cron_format = None): if not frappe.db.exists('Scheduled Job Type', dict(method=event)): frappe.get_doc(dict( doctype = 'Scheduled Job Type', method = event, cron_format = cron_format, - queue = queue + frequency = frequency )).insert() def clear_events(all_events, scheduler_events): diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index 75af876367..6a4530f6ff 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -20,16 +20,16 @@ class TestScheduledJobType(unittest.TestCase): def test_sync_jobs(self): all_job = frappe.get_doc('Scheduled Job Type', dict(method='frappe.email.queue.flush')) - self.assertEqual(all_job.queue, 'All') + self.assertEqual(all_job.frequency, 'All') daily_job = frappe.get_doc('Scheduled Job Type', dict(method='frappe.email.queue.clear_outbox')) - self.assertEqual(daily_job.queue, 'Daily') + self.assertEqual(daily_job.frequency, 'Daily') # check if cron jobs are synced cron_job = frappe.get_doc('Scheduled Job Type', dict(method='frappe.oauth.delete_oauth2_data')) - self.assertEqual(cron_job.queue, 'Cron') + self.assertEqual(cron_job.frequency, 'Cron') self.assertEqual(cron_job.cron_format, '0/15 * * * *') def test_daily_job(self): From 2c54347076d9e1f4c983149618df75c2a77690e0 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Fri, 27 Sep 2019 13:13:06 +0530 Subject: [PATCH 15/76] fix(minor): rename "queue" to "frequency" --- frappe/tests/test_scheduler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_scheduler.py b/frappe/tests/test_scheduler.py index 0ce7265fd7..e554fd23be 100644 --- a/frappe/tests/test_scheduler.py +++ b/frappe/tests/test_scheduler.py @@ -62,7 +62,7 @@ class TestScheduler(TestCase): self.assertTrue(schedule_jobs_based_on_activity(check_time = add_days(frappe.db.get_last_created('Activity Log'), 5))) # create a fake job executed 5 days from now - job = get_test_job(method='frappe.tests.test_scheduler.test_method', queue='Daily') + job = get_test_job(method='frappe.tests.test_scheduler.test_method', frequency='Daily') job.execute() job_log = frappe.get_doc('Scheduled Job Log', dict(scheduled_job_type=job.name)) job_log.db_set('creation', add_days(frappe.db.get_last_created('Activity Log'), 5)) @@ -87,18 +87,18 @@ class TestScheduler(TestCase): self.assertTrue(job.is_failed) -def get_test_job(method='frappe.tests.test_scheduler.test_timeout_10', queue='All'): +def get_test_job(method='frappe.tests.test_scheduler.test_timeout_10', frequency='All'): if not frappe.db.exists('Scheduled Job Type', dict(method=method)): job = frappe.get_doc(dict( doctype = 'Scheduled Job Type', method = method, last_execution = '2010-01-01 00:00:00', - queue = queue + frequency = frequency )).insert() else: job = frappe.get_doc('Scheduled Job Type', dict(method=method)) job.db_set('last_execution', '2010-01-01 00:00:00') - job.db_set('queue', queue) + job.db_set('frequency', frequency) frappe.db.commit() return job From 8d4610504a243cd5bc55a9bd753ff919b5e0d031 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 6 Nov 2019 10:01:31 +0530 Subject: [PATCH 16/76] fix(notifications): login, logout notifications --- frappe/desk/notifications.py | 24 ++++++++++++++++-------- frappe/tests/test_api.py | 21 +++++++++++---------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 84d515050c..0216ab83af 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -11,15 +11,19 @@ import json @frappe.whitelist() @frappe.read_only() def get_notifications(): + out = { + "open_count_doctype": {}, + "targets": {}, + } if (frappe.flags.in_install or not frappe.db.get_single_value('System Settings', 'setup_complete')): - return { - "open_count_doctype": {}, - "targets": {}, - } + return out config = get_notification_config() + if not config: + return out + groups = list(config.get("for_doctype")) + list(config.get("for_module")) cache = frappe.cache() @@ -31,10 +35,10 @@ def get_notifications(): if count is not None: notification_count[name] = count - return { - "open_count_doctype": get_notifications_for_doctypes(config, notification_count), - "targets": get_notifications_for_targets(config, notification_percent), - } + out['open_count_doctype'] = get_notifications_for_doctypes(config, notification_count) + out['targets'] = get_notifications_for_targets(config, notification_percent) + + return out def get_notifications_for_doctypes(config, notification_count): """Notifications for DocTypes""" @@ -118,6 +122,10 @@ def clear_notifications(user=None): return cache = frappe.cache() config = get_notification_config() + + if not config: + return + for_doctype = list(config.get('for_doctype')) if config.get('for_doctype') else [] for_module = list(config.get('for_module')) if config.get('for_module') else [] groups = for_doctype + for_module diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 232b2be4a8..19ea9a0124 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -5,13 +5,14 @@ from __future__ import unicode_literals import unittest, frappe, os from frappe.core.doctype.user.user import generate_keys from frappe.frappeclient import FrappeClient +from frappe.utils.data import get_url import requests import base64 class TestAPI(unittest.TestCase): def test_insert_many(self): - server = FrappeClient(frappe.get_site_config().host_name, "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", "admin", verify=False) frappe.db.sql("delete from `tabNote` where title in ('Sing','a','song','of','sixpence')") frappe.db.commit() @@ -30,7 +31,7 @@ class TestAPI(unittest.TestCase): self.assertTrue(frappe.db.get_value('Note', {'title': 'sixpence'})) def test_create_doc(self): - server = FrappeClient(frappe.get_site_config().host_name, "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", "admin", verify=False) frappe.db.sql("delete from `tabNote` where title = 'test_create'") frappe.db.commit() @@ -39,13 +40,13 @@ class TestAPI(unittest.TestCase): self.assertTrue(frappe.db.get_value('Note', {'title': 'test_create'})) def test_list_docs(self): - server = FrappeClient(frappe.get_site_config().host_name, "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", "admin", verify=False) doc_list = server.get_list("Note") self.assertTrue(len(doc_list)) def test_get_doc(self): - server = FrappeClient(frappe.get_site_config().host_name, "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", "admin", verify=False) frappe.db.sql("delete from `tabNote` where title = 'get_this'") frappe.db.commit() @@ -56,7 +57,7 @@ class TestAPI(unittest.TestCase): self.assertTrue(doc) def test_update_doc(self): - server = FrappeClient(frappe.get_site_config().host_name, "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", "admin", verify=False) frappe.db.sql("delete from `tabNote` where title in ('Sing','sing')") frappe.db.commit() @@ -68,7 +69,7 @@ class TestAPI(unittest.TestCase): self.assertTrue(doc["title"] == changed_title) def test_delete_doc(self): - server = FrappeClient(frappe.get_site_config().host_name, "Administrator", "admin", verify=False) + server = FrappeClient(get_url(), "Administrator", "admin", verify=False) frappe.db.sql("delete from `tabNote` where title = 'delete'") frappe.db.commit() @@ -90,21 +91,21 @@ class TestAPI(unittest.TestCase): api_key = frappe.db.get_value("User", "Administrator", "api_key") header = {"Authorization": "token {}:{}".format(api_key, generated_secret)} - res = requests.post(frappe.get_site_config().host_name + "/api/method/frappe.auth.get_logged_user", headers=header) + res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header) self.assertEqual(res.status_code, 200) self.assertEqual("Administrator", res.json()["message"]) self.assertEqual(keys['api_secret'], generated_secret) header = {"Authorization": "Basic {}".format(base64.b64encode(frappe.safe_encode("{}:{}".format(api_key, generated_secret))).decode())} - res = requests.post(frappe.get_site_config().host_name + "/api/method/frappe.auth.get_logged_user", headers=header) + res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header) self.assertEqual(res.status_code, 200) self.assertEqual("Administrator", res.json()["message"]) # Valid api key, invalid api secret api_secret = "ksk&93nxoe3os" header = {"Authorization": "token {}:{}".format(api_key, api_secret)} - res = requests.post(frappe.get_site_config().host_name + "/api/method/frappe.auth.get_logged_user", headers=header) + res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header) self.assertEqual(res.status_code, 403) @@ -112,5 +113,5 @@ class TestAPI(unittest.TestCase): api_key = "@3djdk3kld" api_secret = "ksk&93nxoe3os" header = {"Authorization": "token {}:{}".format(api_key, api_secret)} - res = requests.post(frappe.get_site_config().host_name + "/api/method/frappe.auth.get_logged_user", headers=header) + res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header) self.assertEqual(res.status_code, 401) From d1e461623199a4918752bf524f3bfb83d4fdcf74 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 7 Nov 2019 12:34:38 +0530 Subject: [PATCH 17/76] fix(test): the test event has hanged frequency --- .../core/doctype/scheduled_job_type/test_scheduled_job_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index 6a4530f6ff..ec1e70ad6a 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -40,7 +40,7 @@ class TestScheduledJobType(unittest.TestCase): self.assertFalse(job.is_event_due(get_datetime('2019-01-01 23:59:59'))) def test_weekly_job(self): - job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.utils.change_log.check_for_update')) + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.social.doctype.energy_point_log.energy_point_log.send_weekly_summary')) job.db_set('last_execution', '2019-01-01 00:00:00') self.assertTrue(job.is_event_due(get_datetime('2019-01-06 00:00:01'))) self.assertFalse(job.is_event_due(get_datetime('2019-01-02 00:00:06'))) From 0c77540ed4fad1f138d73ac3518e6367092ec0d6 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 7 Nov 2019 15:07:56 +0530 Subject: [PATCH 18/76] fix(ux): added dropdown in print layout to avoid linebreak and fix for login --- frappe/desk/notifications.py | 2 ++ .../frappe/form/templates/print_layout.html | 25 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 0216ab83af..b142047059 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -147,6 +147,8 @@ def delete_notification_count_for(doctype): def clear_doctype_notifications(doc, method=None, *args, **kwargs): config = get_notification_config() + if not config: + return if isinstance(doc, string_types): doctype = doc # assuming doctype name was passed directly else: diff --git a/frappe/public/js/frappe/form/templates/print_layout.html b/frappe/public/js/frappe/form/templates/print_layout.html index 9bdba0d99d..e4197aa0a2 100644 --- a/frappe/public/js/frappe/form/templates/print_layout.html +++ b/frappe/public/js/frappe/form/templates/print_layout.html @@ -15,18 +15,27 @@ From babcf7d25b6d64de031077b60c8cb7a5e9ebea67 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 12 Nov 2019 16:07:19 +0530 Subject: [PATCH 19/76] chore: trimmed requirements by pipreqs --- requirements.txt | 130 ++++++++++++++++++++++------------------------- 1 file changed, 62 insertions(+), 68 deletions(-) diff --git a/requirements.txt b/requirements.txt index 84788c863e..41f2a405e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,68 +1,62 @@ -boto3 -chardet -dropbox==9.1.0 -gunicorn -jinja2 -markdown2==2.3.5 -PyMySQL -maxminddb-geolite2 -python-dateutil -pytz -six -werkzeug -semantic_version -rauth>=0.6.2 -requests -redis==2.10.6 -selenium -babel==2.6.0 -ipython -html2text==2016.9.19 -email_reply_parser -click==7.0 -num2words==0.5.5 -watchdog==0.8.0 -bleach==2.1.4 -bleach-whitelist -Pillow -beautifulsoup4 -rq==0.12.0 -schedule -cryptography -pyopenssl -ndg-httpsclient -pyasn1 -zxcvbn-python -unittest-xml-reporting -oauthlib -requests-oauthlib -pdfkit -PyJWT -PyPDF2 -openpyxl -pyotp -pyqrcode -pypng -premailer -croniter -googlemaps==3.1.1 -braintree -future -passlib -google-api-python-client -google-auth -google-auth-httplib2 -google-auth-oauthlib -faker -stripe -coverage -urllib3 -GitPython==2.1.11 -psycopg2==2.7.5 -psycopg2-binary==2.7.5 -sqlparse==0.2.4 -Pygments==2.2.0 -frontmatter -PyYAML==3.13 -xlrd -RestrictedPython==5.0 \ No newline at end of file +setuptools==40.8.0 +urllib3==1.25.6 +Jinja2==2.10.3 +semantic_version==2.8.2 +pip==19.2.3 +chardet==3.0.4 +Click==7.0 +six==1.12.0 +requests==2.22.0 +ipython==7.9.0 +Image==1.5.27 +Pillow==6.2.1 +PyPDF2==1.26.0 +RestrictedPython==5.0 +google_api_python_client==1.7.11 +Babel==2.7.0 +bleach==3.1.0 +bleach_whitelist==0.0.10 +boto3==1.10.15 +botocore==1.13.15 +braintree==3.57.1 +beautifulsoup4==4.8.1 +coverage==4.5.4 +croniter==0.3.30 +cryptography==2.8 +pycups==1.9.74 +python_dateutil==2.8.1 +dropbox==9.4.0 +email_reply_parser==0.5.9 +Faker==2.0.3 +python_frontmatter==0.4.5 +protobuf==3.10.0 +html2text==2019.9.26 +PyJWT==1.7.1 +python_ldap==3.2.0 +ldap3==2.6.1 +markdown2==2.3.8 +num2words==0.5.10 +oauthlib==3.1.0 +openpyxl==3.0.0 +passlib==1.7.1 +future==0.18.2 +pdfkit==0.6.1 +premailer==3.6.1 +psycopg2==2.8.4 +Pygments==2.4.2 +pymysql==0.9.3 +pyotp==2.3.0 +pyqrcode==1.2.1 +pytz==2019.3 +rauth==0.7.3 +redis==3.3.11 +rq==1.1.0 +schedule==0.6.0 +selenium==3.141.0 +sqlparse==0.3.0 +stripe==2.40.0 +watchdog==0.9.0 +Werkzeug==0.16.0 +xlrd==1.2.0 +xmlrunner==1.7.7 +zxcvbn==4.4.28 From 355c5fb3709aec446adf57571dd4a542fa269499 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 12 Nov 2019 19:05:08 +0530 Subject: [PATCH 20/76] fix: add cups dependency perf: conditional config for database --- .travis.yml | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index df66db88a7..ffd05eae03 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,6 +39,16 @@ matrix: env: DB=mariadb TYPE=server script: bench --site test_site run-tests --coverage +before_install: + # 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 + - sudo chmod o+x /usr/local/bin/wkhtmltopdf + + # install cups + - sudo apt-get install libcups2-dev + install: - cd ~ - source ./.nvm/nvm.sh @@ -52,23 +62,20 @@ install: - mkdir ~/frappe-bench/sites/test_site - cp $TRAVIS_BUILD_DIR/.travis/$DB.json ~/frappe-bench/sites/test_site/site_config.json - - mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" - - mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + - if: env(DB) = "mariadb" + - mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" + - mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" - - mysql -u root -e "CREATE DATABASE test_frappe" - - mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" - - mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" + - mysql -u root -e "CREATE DATABASE test_frappe" + - mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" + - mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" - - mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" - - mysql -u root -e "FLUSH PRIVILEGES" + - mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" + - mysql -u root -e "FLUSH PRIVILEGES" - - psql -c "CREATE DATABASE test_frappe" -U postgres - - psql -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres - - - 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 - - sudo chmod o+x /usr/local/bin/wkhtmltopdf + - if: env(DB) = "postgres" + - psql -c "CREATE DATABASE test_frappe" -U postgres + - psql -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres - cd ./frappe-bench From 5bd5eb8911eb37cb7a16c5a56c6072e1cb536512 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 11 Nov 2019 23:25:05 +0530 Subject: [PATCH 21/76] chore: moved set_request function to frappe.utils --- frappe/utils/__init__.py | 6 ++++++ frappe/utils/global_search.py | 2 +- frappe/website/doctype/blog_post/test_blog_post.py | 2 +- frappe/website/doctype/web_page/test_web_page.py | 2 +- .../doctype/website_route_meta/test_website_route_meta.py | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 29c9387248..22a87f612f 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -679,3 +679,9 @@ def create_batch(iterable, batch_size): total_count = len(iterable) for i in range(0, total_count, batch_size): yield iterable[i:min(i + batch_size, total_count)] + +def set_request(**kwargs): + from werkzeug.test import EnvironBuilder + from werkzeug.wrappers import Request + builder = EnvironBuilder(**kwargs) + frappe.local.request = Request(builder.get_environ()) diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 447466770d..ff23a09c1a 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -302,7 +302,7 @@ def get_routes_to_index(): def add_route_to_global_search(route): from frappe.website.render import render_page - from frappe.tests.test_website import set_request + from frappe.utils import set_request frappe.set_user('Guest') frappe.local.no_cache = True diff --git a/frappe/website/doctype/blog_post/test_blog_post.py b/frappe/website/doctype/blog_post/test_blog_post.py index 8278125723..aecc813e9d 100644 --- a/frappe/website/doctype/blog_post/test_blog_post.py +++ b/frappe/website/doctype/blog_post/test_blog_post.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe import unittest -from frappe.tests.test_website import set_request +from frappe.utils import set_request from frappe.website.render import render from frappe.utils import random_string diff --git a/frappe/website/doctype/web_page/test_web_page.py b/frappe/website/doctype/web_page/test_web_page.py index 38daebe5e8..b1600a338f 100644 --- a/frappe/website/doctype/web_page/test_web_page.py +++ b/frappe/website/doctype/web_page/test_web_page.py @@ -3,7 +3,7 @@ import unittest import frappe from frappe.website.router import resolve_route import frappe.website.render -from frappe.tests import set_request +from frappe.utils import set_request test_records = frappe.get_test_records('Web Page') diff --git a/frappe/website/doctype/website_route_meta/test_website_route_meta.py b/frappe/website/doctype/website_route_meta/test_website_route_meta.py index 90e40972ef..c02dc398bf 100644 --- a/frappe/website/doctype/website_route_meta/test_website_route_meta.py +++ b/frappe/website/doctype/website_route_meta/test_website_route_meta.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import frappe import unittest -from frappe.tests.test_website import set_request +from frappe.utils import set_request from frappe.website.render import render class TestWebsiteRouteMeta(unittest.TestCase): From cbd89842ee67f9955d21fab523ba1edf7e9341a9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 12 Nov 2019 12:45:38 +0530 Subject: [PATCH 22/76] chore: dropped legacy test runner --- frappe/core/doctype/test_runner/__init__.py | 0 .../doctype/test_runner/_test_test_runner.js | 23 --- .../core/doctype/test_runner/test_runner.js | 87 ---------- .../core/doctype/test_runner/test_runner.json | 152 ------------------ .../core/doctype/test_runner/test_runner.py | 36 ----- frappe/patches.txt | 1 + 6 files changed, 1 insertion(+), 298 deletions(-) delete mode 100644 frappe/core/doctype/test_runner/__init__.py delete mode 100644 frappe/core/doctype/test_runner/_test_test_runner.js delete mode 100644 frappe/core/doctype/test_runner/test_runner.js delete mode 100644 frappe/core/doctype/test_runner/test_runner.json delete mode 100644 frappe/core/doctype/test_runner/test_runner.py diff --git a/frappe/core/doctype/test_runner/__init__.py b/frappe/core/doctype/test_runner/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/core/doctype/test_runner/_test_test_runner.js b/frappe/core/doctype/test_runner/_test_test_runner.js deleted file mode 100644 index 0b0bd9a98b..0000000000 --- a/frappe/core/doctype/test_runner/_test_test_runner.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Test Runner", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially('Test Runner', [ - // insert a new Test Runner - () => frappe.tests.make([ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/frappe/core/doctype/test_runner/test_runner.js b/frappe/core/doctype/test_runner/test_runner.js deleted file mode 100644 index d08a3626a3..0000000000 --- a/frappe/core/doctype/test_runner/test_runner.js +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) 2017, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Test Runner', { - refresh: (frm) => { - frm.disable_save(); - frm.page.set_primary_action(__("Run Tests"), () => { - return new Promise(resolve => { - let wrapper = $(frm.fields_dict.output.wrapper).empty(); - $("

Loading...

").appendTo(wrapper); - - // all tests - frappe.call({ - method: 'frappe.core.doctype.test_runner.test_runner.get_test_js', - args: { test_path: frm.doc.module_path } - }).always((data) => { - $("
").appendTo(wrapper.empty()); - frm.events.run_tests(frm, data.message); - resolve(); - }); - }); - }); - - }, - run_tests: function(frm, files) { - frappe.flags.in_test = true; - let require_list = [ - "assets/frappe/js/lib/jquery/qunit.js", - "assets/frappe/js/lib/jquery/qunit.css" - ].concat(); - - frappe.require(require_list, () => { - files.forEach((f) => { - frappe.dom.eval(f.script); - }); - - QUnit.config.notrycatch = true; - - window.onerror = function(msg, url, lineNo, columnNo, error) { - console.log(error.stack); // eslint-disable-line - $('
').appendTo($('body')); - }; - - QUnit.testDone(function(details) { - // var result = { - // "Module name": details.module, - // "Test name": details.name, - // "Assertions": { - // "Total": details.total, - // "Passed": details.passed, - // "Failed": details.failed - // }, - // "Skipped": details.skipped, - // "Todo": details.todo, - // "Runtime": details.runtime - // }; - - // eslint-disable-next-line - // console.log(JSON.stringify(result, null, 2)); - - details.assertions.map(a => { - // eslint-disable-next-line - console.log(`${a.result ? '✔' : '✗'} ${a.message}`); - }); - - }); - QUnit.load(); - - QUnit.done(({ total, failed, passed, runtime }) => { - // flag for selenium that test is done - - console.log( `Total: ${total}, Failed: ${failed}, Passed: ${passed}, Runtime: ${runtime}` ); // eslint-disable-line - - if(failed) { - console.log('Tests Failed'); // eslint-disable-line - } else { - console.log('Tests Passed'); // eslint-disable-line - } - frappe.set_route('Form', 'Test Runner', 'Test Runner'); - - $('
').appendTo($('body')); - - }); - }); - - } -}); diff --git a/frappe/core/doctype/test_runner/test_runner.json b/frappe/core/doctype/test_runner/test_runner.json deleted file mode 100644 index ccc1361dc9..0000000000 --- a/frappe/core/doctype/test_runner/test_runner.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-06-26 10:57:19.976624", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", - "fields": [ - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "module_path", - "fieldtype": "Data", - "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": "Module Path", - "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, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "app", - "fieldtype": "Data", - "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": "App", - "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, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "output", - "fieldtype": "HTML", - "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": "Output", - "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, - "unique": 0 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2017-07-19 03:22:33.221169", - "modified_by": "Administrator", - "module": "Core", - "name": "Test Runner", - "name_case": "", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "apply_user_permissions": 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": "Administrator", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "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 -} \ No newline at end of file diff --git a/frappe/core/doctype/test_runner/test_runner.py b/frappe/core/doctype/test_runner/test_runner.py deleted file mode 100644 index 2961e9f38b..0000000000 --- a/frappe/core/doctype/test_runner/test_runner.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe, os -from frappe.model.document import Document - -class TestRunner(Document): - pass - -@frappe.whitelist() -def get_test_js(test_path=None): - '''Get test + data for app, example: app/tests/ui/test_name.js''' - if not test_path: - test_path = frappe.db.get_single_value('Test Runner', 'module_path') - test_js = [] - - # split - app, test_path = test_path.split(os.path.sep, 1) - - # now full path - test_path = frappe.get_app_path(app, test_path) - - def add_file(path): - with open(path, 'r') as fileobj: - test_js.append(dict( - script = fileobj.read() - )) - - # add test_lib.js - add_file(frappe.get_app_path('frappe', 'tests', 'ui', 'data', 'test_lib.js')) - add_file(test_path) - - return test_js - diff --git a/frappe/patches.txt b/frappe/patches.txt index 621a2107df..bd8e8378c1 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -255,3 +255,4 @@ frappe.patches.v12_0.set_default_incoming_email_port frappe.patches.v12_0.update_global_search execute:frappe.reload_doc('desk', 'doctype', 'notification_settings') frappe.patches.v12_0.setup_tags +execute:frappe.delete_doc("Test Runner") \ No newline at end of file From ae48dfe2b4fdb2a03f3e8f1028546cb5f8839d04 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 13 Nov 2019 15:01:05 +0530 Subject: [PATCH 23/76] chore: dropped legacy selenium dependant tests selenium TestDriver deprecated --ui-tests flag in `bench run-tests` deprecated --- frappe/commands/utils.py | 21 -- frappe/test_runner.py | 18 +- frappe/tests/test_frappeoauth2client.py | 85 ----- frappe/tests/ui/__init__.py | 0 frappe/tests/ui/data/test_lib.js | 249 --------------- frappe/tests/ui/test_calendar_view.js | 79 ----- frappe/tests/ui/test_control_geolocation.js | 39 --- frappe/tests/ui/test_control_html.js | 57 ---- .../ui/test_kanban/test_kanban_column.js | 31 -- .../ui/test_kanban/test_kanban_creation.js | 34 -- .../ui/test_kanban/test_kanban_filters.js | 27 -- .../tests/ui/test_kanban/test_kanban_view.js | 29 -- frappe/tests/ui/test_linked_with.js | 19 -- frappe/tests/ui/test_list/test_list_filter.js | 39 --- frappe/tests/ui/test_list/test_list_paging.js | 25 -- frappe/tests/ui/test_list_count.js | 33 -- frappe/tests/ui/test_module_view.js | 41 --- frappe/tests/ui/test_number_format.js | 38 --- .../tests/ui/test_social_login_key_buttons.py | 33 -- frappe/tests/ui/test_test_runner.py | 84 ----- frappe/tests/ui/test_todo.py | 50 --- frappe/tests/ui/tests.txt | 20 -- frappe/utils/selenium_testdriver.py | 299 ------------------ requirements.txt | 1 - 24 files changed, 3 insertions(+), 1348 deletions(-) delete mode 100644 frappe/tests/test_frappeoauth2client.py delete mode 100644 frappe/tests/ui/__init__.py delete mode 100644 frappe/tests/ui/data/test_lib.js delete mode 100644 frappe/tests/ui/test_calendar_view.js delete mode 100644 frappe/tests/ui/test_control_geolocation.js delete mode 100644 frappe/tests/ui/test_control_html.js delete mode 100644 frappe/tests/ui/test_kanban/test_kanban_column.js delete mode 100644 frappe/tests/ui/test_kanban/test_kanban_creation.js delete mode 100644 frappe/tests/ui/test_kanban/test_kanban_filters.js delete mode 100644 frappe/tests/ui/test_kanban/test_kanban_view.js delete mode 100644 frappe/tests/ui/test_linked_with.js delete mode 100644 frappe/tests/ui/test_list/test_list_filter.js delete mode 100644 frappe/tests/ui/test_list/test_list_paging.js delete mode 100644 frappe/tests/ui/test_list_count.js delete mode 100644 frappe/tests/ui/test_module_view.js delete mode 100644 frappe/tests/ui/test_number_format.js delete mode 100644 frappe/tests/ui/test_social_login_key_buttons.py delete mode 100644 frappe/tests/ui/test_test_runner.py delete mode 100644 frappe/tests/ui/test_todo.py delete mode 100644 frappe/tests/ui/tests.txt delete mode 100644 frappe/utils/selenium_testdriver.py diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 9a408430e7..d29f0a9c97 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -507,26 +507,6 @@ def run_ui_tests(context, app, headless=False): formatted_command = command.format(site_env=site_env, password_env=password_env, run_or_open=run_or_open) frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True) -@click.command('run-setup-wizard-ui-test') -@click.option('--app', help="App to run tests on, leave blank for all apps") -@click.option('--profile', is_flag=True, default=False) -@pass_context -def run_setup_wizard_ui_test(context, app=None, profile=False): - "Run setup wizard UI test" - import frappe.test_runner - - site = get_site(context) - frappe.init(site=site) - frappe.connect() - - ret = frappe.test_runner.run_setup_wizard_ui_test(app=app, verbose=context.verbose, - profile=profile) - if len(ret.failures) == 0 and len(ret.errors) == 0: - ret = 0 - - if os.environ.get('CI'): - sys.exit(ret) - @click.command('serve') @click.option('--port', default=8000) @click.option('--profile', is_flag=True, default=False) @@ -752,7 +732,6 @@ commands = [ reset_perms, run_tests, run_ui_tests, - run_setup_wizard_ui_test, serve, set_config, show_config, diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 76140e442c..be2e909dd0 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -36,6 +36,9 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(), with open(frappe.get_app_path(app, doctype_list_path), 'r') as f: doctype = f.read().strip().splitlines() + if ui_tests: + print("Selenium testing has been deprecated\nUse bench --site {site_name} run-ui-tests for Cypress tests") + xmloutput_fh = None if junit_xml_output: xmloutput_fh = open(junit_xml_output, 'w') @@ -170,21 +173,6 @@ def run_tests_for_module(module, verbose=False, tests=(), profile=False): return _run_unittest(module, verbose=verbose, tests=tests, profile=profile) -def run_setup_wizard_ui_test(app=None, verbose=False, profile=False): - '''Run setup wizard UI test using test_test_runner''' - frappe.flags.run_setup_wizard_ui_test = 1 - return run_ui_tests(app=app, test=None, verbose=verbose, profile=profile) - -def run_ui_tests(app=None, test=None, test_list=None, verbose=False, profile=False): - '''Run a single unit test for UI using test_test_runner''' - module = importlib.import_module('frappe.tests.ui.test_test_runner') - frappe.flags.ui_test_app = app - if test_list: - frappe.flags.ui_test_list = test_list - else: - frappe.flags.ui_test_path = test - return _run_unittest(module, verbose=verbose, tests=(), profile=profile) - def _run_unittest(modules, verbose=False, tests=(), profile=False): test_suite = unittest.TestSuite() diff --git a/frappe/tests/test_frappeoauth2client.py b/frappe/tests/test_frappeoauth2client.py deleted file mode 100644 index ebf09adf6d..0000000000 --- a/frappe/tests/test_frappeoauth2client.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt -from __future__ import unicode_literals - -import unittest, frappe, requests, time -from frappe.test_runner import make_test_records -from frappe.utils.selenium_testdriver import TestDriver -from six.moves.urllib.parse import urlparse -from frappe.frappeclient import FrappeOAuth2Client - -class TestFrappeOAuth2Client(unittest.TestCase): - def setUp(self): - self.driver = TestDriver() - make_test_records("OAuth Client") - make_test_records("User") - self.client_id = frappe.get_all("OAuth Client", fields=["*"])[0].get("client_id") - - # Set Frappe server URL reqired for id_token generation - try: - frappe_login_key = frappe.get_doc("Social Login Key", "frappe") - except frappe.DoesNotExistError: - frappe_login_key = frappe.new_doc("Social Login Key") - frappe_login_key.get_social_login_provider("Frappe", initialize=True) - frappe_login_key.base_url = "http://localhost:8000" - frappe_login_key.save() - - def test_insert_note(self): - - # Go to Authorize url - self.driver.get( - "api/method/frappe.integrations.oauth2.authorize?client_id=" + - self.client_id + - "&scope=all%20openid&response_type=code&redirect_uri=http%3A%2F%2Flocalhost" - ) - - time.sleep(2) - - # Login - username = self.driver.find("#login_email")[0] - username.send_keys("test@example.com") - - password = self.driver.find("#login_password")[0] - password.send_keys("Eastern_43A1W") - - sign_in = self.driver.find(".btn-login")[0] - sign_in.submit() - - time.sleep(2) - - # Allow access to resource - allow = self.driver.find("#allow")[0] - allow.click() - - time.sleep(2) - - # Get authorization code from redirected URL - auth_code = urlparse(self.driver.driver.current_url).query.split("=")[1] - - payload = "grant_type=authorization_code&code=" - payload += auth_code - payload += "&redirect_uri=http%3A%2F%2Flocalhost&client_id=" - payload += self.client_id - - headers = {'content-type':'application/x-www-form-urlencoded'} - - # Request for bearer token - token_response = requests.post( frappe.get_site_config().host_name + - "/api/method/frappe.integrations.oauth2.get_token", data=payload, headers=headers) - - # Parse bearer token json - bearer_token = token_response.json() - client = FrappeOAuth2Client(frappe.get_site_config().host_name, bearer_token.get("access_token")) - - notes = [ - {"doctype": "Note", "title": "Sing", "public": True}, - {"doctype": "Note", "title": "a", "public": True}, - {"doctype": "Note", "title": "Song", "public": True}, - {"doctype": "Note", "title": "of", "public": True}, - {"doctype": "Note", "title": "sixpence", "public": True} - ] - - for note in notes: - client.insert(note) - - self.assertTrue(len(frappe.get_all("Note")) == 5) diff --git a/frappe/tests/ui/__init__.py b/frappe/tests/ui/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/tests/ui/data/test_lib.js b/frappe/tests/ui/data/test_lib.js deleted file mode 100644 index 0fbbc2b748..0000000000 --- a/frappe/tests/ui/data/test_lib.js +++ /dev/null @@ -1,249 +0,0 @@ -frappe.tests = { - data: {}, - make: function(doctype, data) { - let dialog_is_active = () => { - return ( - cur_dialog && (!cur_frm || cur_frm.doc.doctype != doctype) - ); - }; - return frappe.run_serially([ - () => frappe.set_route('List', doctype), - () => frappe.new_doc(doctype), - () => { - if (frappe.quick_entry) { - frappe.quick_entry.dialog.$wrapper.find('.edit-full').click(); - return frappe.timeout(1); - } else { - let root_node; - if (cur_tree) { - for (const key in cur_tree.nodes) { - if (cur_tree.nodes[key].parent_label && cur_tree.nodes[key].expandable) { - root_node = cur_tree.nodes[key].label; - break; - } - } - } - if (root_node){ - frappe.tests.open_add_child_dialog(root_node); - return frappe.timeout(1); - } - } - }, - () => { - if(dialog_is_active()) { - return frappe.tests.set_dialog_values(cur_dialog, data); - } else { - return frappe.tests.set_form_values(cur_frm, data); - } - }, - - () => { - if(dialog_is_active()) { - return cur_dialog.get_primary_btn().click(); - } else { - return frappe.quick_entry ? frappe.quick_entry.insert() : cur_frm.save(); - } - } - ]); - }, - open_add_child_dialog: (root_node) => { - frappe.tests.click_link(root_node); - frappe.timeout(1); - frappe.tests.click_button('Add Child'); - }, - set_form_values: (frm, data) => { - let tasks = []; - - data.forEach(item => { - for (let key in item) { - let task = () => { - let value = item[key]; - if ($.isArray(value)) { - return frappe.tests.set_grid_values(frm, key, value); - } else { - // single value - return frm.set_value(key, value); - } - }; - tasks.push(task); - tasks.push(frappe.after_ajax); - tasks.push(() => frappe.timeout(0.4)); - } - }); - - // set values - return frappe.run_serially(tasks); - - }, - set_dialog_values: (dialog, data) => { - let tasks = []; - - data.forEach(item => { - for (let key in item) { - let task = () => { - let value = item[key]; - return dialog.set_value(key, value); - }; - tasks.push(task); - tasks.push(frappe.after_ajax); - tasks.push(() => frappe.timeout(0.4)); - } - }); - - return frappe.run_serially(tasks); - }, - set_grid_values: (frm, key, value) => { - // set value in grid - let grid = frm.get_field(key).grid; - grid.remove_all(); - - let grid_row_tasks = []; - - // build tasks for each row - value.forEach(d => { - grid_row_tasks.push(() => { - - let grid_value_tasks = []; - grid_value_tasks.push(() => grid.add_new_row()); - grid_value_tasks.push(() => grid.get_row(-1).toggle_view(true)); - grid_value_tasks.push(() => frappe.timeout(0.5)); - - // build tasks to set each row value - d.forEach(child_value => { - for (let child_key in child_value) { - grid_value_tasks.push(() => { - let grid_row = grid.get_row(-1); - return frappe.model.set_value(grid_row.doc.doctype, - grid_row.doc.name, child_key, child_value[child_key]); - }); - grid_value_tasks.push(frappe.after_ajax); - grid_value_tasks.push(() => frappe.timeout(0.4)); - } - }); - - return frappe.run_serially(grid_value_tasks); - }); - }); - return frappe.run_serially(grid_row_tasks); - }, - setup_doctype: (doctype, data) => { - return frappe.run_serially([ - () => frappe.set_route('List', doctype), - () => frappe.timeout(1), - () => { - frappe.tests.data[doctype] = []; - let expected = Object.keys(data); - cur_list.data.forEach((d) => { - frappe.tests.data[doctype].push(d.name); - if(expected.indexOf(d.name) !== -1) { - expected[expected.indexOf(d.name)] = null; - } - }); - - let tasks = []; - - expected.forEach(function(d) { - if(d) { - tasks.push(() => frappe.tests.make(doctype, - data[d])); - } - }); - - return frappe.run_serially(tasks); - }]); - }, - click_page_head_item: (text) => { - // Method to items present on the page header like New, Save, Delete etc. - let possible_texts = ["New", "Delete", "Save", "Yes"]; - return frappe.run_serially([ - () => { - if (text == "Menu"){ - $(`span.menu-btn-group-label:contains('Menu'):visible`).click(); - } else if (text == "Refresh") { - $(`.btn-secondary:contains('Refresh'):visible`).click(); - } else if (possible_texts.includes(text)) { - $(`.btn-primary:contains("${text}"):visible`).click(); - } - }, - () => frappe.timeout(1) - ]); - }, - click_dropdown_item: (text) => { - // Method to click dropdown elements - return frappe.run_serially([ - () => { - let li = $(`.dropdown-menu li:contains("${text}"):visible`).get(0); - $(li).find(`a`).click(); - }, - () => frappe.timeout(1) - ]); - }, - click_desktop_icon: (text) => { - // Method to click the desktop icons on the Desk, by their name - return frappe.run_serially([ - () => $("#icon-grid > div > div.app-icon[title="+text+"]").click(), - () => frappe.timeout(1) - ]); - }, - is_visible: (text, tag='a') => { - // Method to check the visibility of an element - return $(`${tag}:contains("${text}")`).is(`:visible`); - }, - /** - * Clicks a button on a form. - * @param {String} text - The button's text - * @return {frappe.timeout} - * @throws will throw an exception if a matching visible button is not found - */ - click_button: function(text) { - let element = $(`.btn:contains("${text}"):visible`); - if(!element.length) { - throw `did not find any button containing ${text}`; - } - element.click(); - return frappe.timeout(0.5); - }, - /** - * Clicks a link on a form. - * @param {String} text - The text of the link to be clicked - * @return {frappe.timeout} - * @throws will throw an exception if a link with the given text is not found - */ - click_link: function(text) { - let element = $(`a:contains("${text}"):visible`); - if(!element.length) { - throw `did not find any link containing ${text}`; - } - element.get(0).click(); - return frappe.timeout(0.5); - }, - /** - * Sets the given control to the value given. - * @param {String} fieldname - The Doctype's field name - * @param {String} value - The value the control should be changed to - * @return {frappe.timeout} - * @throws will throw an exception if the field is not found or is not visible - */ - set_control: function(fieldname, value) { - let control = $(`.form-control[data-fieldname="${fieldname}"]:visible`); - if(!control.length) { - throw `did not find any control with fieldname ${fieldname}`; - } - control.val(value).trigger('change'); - return frappe.timeout(0.5); - }, - /** - * Checks if given field is disabled. - * @param {String} fieldname - The Doctype field name - * @return {Boolean} true if condition is met - * @throws will throw an exception if the field is not found or is not a form control - */ - is_disabled_field: function(fieldname){ - let control = $(`.form-control[data-fieldname="${fieldname}"]:disabled`); - if(!control.length) { - throw `did not find any control with fieldname ${fieldname}`; - } else { - return true; - } - } -}; \ No newline at end of file diff --git a/frappe/tests/ui/test_calendar_view.js b/frappe/tests/ui/test_calendar_view.js deleted file mode 100644 index 914f6174da..0000000000 --- a/frappe/tests/ui/test_calendar_view.js +++ /dev/null @@ -1,79 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Calendar View Tests", function(assert) { - assert.expect(3); - let done = assert.async(); - let random_text = frappe.utils.get_random(3); - let today = frappe.datetime.get_today()+" 16:20:35"; //arbitrary value taken to prevent cases like 12a for 12:00am and 12h to 24h conversion - let visible_time = () => { - // Method to return the start-time (hours) of the event visible - return $('.fc-time').text().split('p')[0]; // 'p' because the arbitrary time is pm - }; - let event_title_text = () => { - // Method to return the title of the event visible - return $('.fc-title:visible').text(); - }; - - frappe.run_serially([ - // create 2 events, one private, one public - () => frappe.tests.make("Event", [ - {subject: random_text + ':Pri'}, - {starts_on: today}, - {event_type: 'Private'} - ]), - - () => frappe.timeout(1), - - () => frappe.tests.make("Event", [ - {subject: random_text + ':Pub'}, - {starts_on: today}, - {event_type: 'Public'} - ]), - - () => frappe.timeout(1), - - // Goto Calendar view - () => frappe.set_route(["List", "Event", "Calendar"]), - - // clear filter - () => cur_list.filter_area.remove('event_type'), - () => frappe.timeout(2), - // Check if event is created - () => { - // Check if the event exists and if its title matches with the one created - assert.ok(event_title_text().includes(random_text + ':Pri'), - "Event title verified"); - }, - - // check filter - () => cur_list.filter_area.add('Event', 'event_type', '=', 'Public'), - () => frappe.timeout(1), - () => { - // private event should be hidden - assert.notOk(event_title_text().includes(random_text + ':Pri'), - "Event title verified"); - }, - - // Delete event - // Goto Calendar view - () => frappe.set_route(["List", "Event", "Calendar"]), - () => frappe.timeout(1), - // delete event - () => frappe.click_link(random_text + ':Pub'), - () => { - frappe.tests.click_page_head_item('Menu'); - frappe.tests.click_dropdown_item('Delete'); - }, - () => frappe.timeout(0.5), - () => frappe.click_button('Yes'), - () => frappe.timeout(2), - () => frappe.set_route(["List", "Event", "Calendar"]), - () => frappe.click_button("Refresh"), - () => frappe.timeout(1), - - // Check if event is deleted - () => assert.notOk(event_title_text().includes(random_text + ':Pub'), - "Event deleted"), - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_control_geolocation.js b/frappe/tests/ui/test_control_geolocation.js deleted file mode 100644 index 0e3bedda26..0000000000 --- a/frappe/tests/ui/test_control_geolocation.js +++ /dev/null @@ -1,39 +0,0 @@ -QUnit.module('controls'); - -QUnit.test("Test ControlGeolocation", function(assert) { - assert.expect(1); - - const random_name = frappe.utils.get_random(3).toLowerCase(); - - let done = assert.async(); - - // geolocation alert dialog suppressed (only secure origins or localhost allowed) - window.alert = function() { - console.log.apply(console, arguments); //eslint-disable-line - }; - - frappe.run_serially([ - () => { - return frappe.tests.make('Custom Field', [ - {dt: 'ToDo'}, - {fieldtype: 'Geolocation'}, - {label: random_name}, - ]); - }, - () => frappe.set_route('List', 'ToDo'), - () => frappe.new_doc('ToDo'), - () => { - if (frappe.quick_entry) - { - frappe.quick_entry.dialog.$wrapper.find('.edit-full').click(); - return frappe.timeout(1); - } - }, - () => { - const control = $(`.frappe-control[data-fieldname="${random_name}"]`); - - return assert.ok(control.data('fieldtype') === 'Geolocation'); - }, - () => done() - ]); -}); diff --git a/frappe/tests/ui/test_control_html.js b/frappe/tests/ui/test_control_html.js deleted file mode 100644 index 0cb70cc4fd..0000000000 --- a/frappe/tests/ui/test_control_html.js +++ /dev/null @@ -1,57 +0,0 @@ -QUnit.module('controls'); - -QUnit.test("Test ControlHTML", function(assert) { - assert.expect(3); - const random_name = frappe.utils.get_random(3).toLowerCase(); - - let done = assert.async(); - - frappe.run_serially([ - () => { - return frappe.tests.make('Custom Field', [ - {dt: 'ToDo'}, - {fieldtype: 'HTML'}, - {label: random_name}, - {options: '

Test

'} - ]); - }, - () => { - return frappe.tests.make('Custom Field', [ - {dt: 'ToDo'}, - {fieldtype: 'HTML'}, - {label: random_name + "_template"}, - {options: '

Test {{ doc.status }}

'} - ]); - }, - () => frappe.set_route('List', 'ToDo'), - () => frappe.new_doc('ToDo'), - () => { - if (frappe.quick_entry) - { - frappe.quick_entry.dialog.$wrapper.find('.edit-full').click(); - return frappe.timeout(1); - } - }, - () => { - const control = $(`.frappe-control[data-fieldname="${random_name}"]`)[0]; - - return assert.ok(control.innerHTML === '

Test

'); - }, - () => { - const control = $(`.frappe-control[data-fieldname="${random_name}_template"]`)[0]; - // refresh input must be called independently - cur_frm.get_field(`${random_name}_template`).refresh_input(); - - return assert.ok(control.innerHTML === '

Test Open

'); - }, - () => frappe.tests.set_control("status", "Closed"), - () => frappe.timeout(1), - () => { - const control = $(`.frappe-control[data-fieldname="${random_name}_template"]`)[0]; - // refresh input must be called independently - cur_frm.get_field(`${random_name}_template`).refresh_input(); - return assert.ok(control.innerHTML === '

Test Closed

'); - }, - () => done() - ]); -}); diff --git a/frappe/tests/ui/test_kanban/test_kanban_column.js b/frappe/tests/ui/test_kanban/test_kanban_column.js deleted file mode 100644 index 0c4593a8dd..0000000000 --- a/frappe/tests/ui/test_kanban/test_kanban_column.js +++ /dev/null @@ -1,31 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test: Setting column colour [Kanban view]", function(assert) { - assert.expect(3); - let done = assert.async(); - function get_column(name, colour) { - return ('.kanban-column:contains('+name+')>div>div>ul>li>div.'+colour); - } - - frappe.run_serially([ - () => frappe.set_route("List", "ToDo", "Kanban", "Kanban test"), - () => frappe.timeout(1), - () => assert.deepEqual(["List", "ToDo", "Kanban", "Kanban test"], frappe.get_route(), - "Kanban view opened successfully."), - () => { - // set colour for columns - $(get_column('High', "red")).click(); - $(get_column('Medium', "green")).click(); - $(get_column('Low', "yellow")).click(); - }, - () => frappe.timeout(1), - () => { - //check if different colours are set - assert.equal($('.red > span')[0].innerText, 'High', - "Colour is set for kanban column."); - assert.equal($('.green > span')[0].innerText, 'Medium', - "Different colour is set for other column."); - }, - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_kanban/test_kanban_creation.js b/frappe/tests/ui/test_kanban/test_kanban_creation.js deleted file mode 100644 index 3e1afbefdf..0000000000 --- a/frappe/tests/ui/test_kanban/test_kanban_creation.js +++ /dev/null @@ -1,34 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test: Creation [Kanban view]", function(assert) { - assert.expect(2); - let done = assert.async(); - - const board_name = 'Kanban test'; - - frappe.run_serially([ - () => frappe.set_route("List", "ToDo", "List"), - // wait for cur_list to initialize - () => cur_list.init(), - // click kanban in side bar - () => frappe.tests.click_link('Kanban'), - () => frappe.tests.click_link('New Kanban Board'), - () => frappe.timeout(0.5), - // create new kanban - () => { - assert.equal(cur_dialog.title, 'New Kanban Board', - "Dialog for new kanban opened."); - cur_dialog.set_value('board_name', board_name); - cur_dialog.set_value('field_name', 'Priority'); - }, - () => frappe.timeout(0.5), - () => cur_dialog.get_primary_btn().click(), - () => frappe.timeout(1), - () => frappe.set_route("List", "Kanban Board", "List"), - () => frappe.timeout(0.5), - // check in kanban list if new kanban is created - () => assert.equal(cur_list.data[0].name, board_name, - "Added kanban is visible in kanban list."), - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_kanban/test_kanban_filters.js b/frappe/tests/ui/test_kanban/test_kanban_filters.js deleted file mode 100644 index 5e9af9f0fa..0000000000 --- a/frappe/tests/ui/test_kanban/test_kanban_filters.js +++ /dev/null @@ -1,27 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test: Filters [Kanban view]", function(assert) { - assert.expect(3); - let done = assert.async(); - - frappe.run_serially([ - () => frappe.set_route("List", "ToDo", "Kanban", "Kanban test"), - () => frappe.timeout(1), - () => { - assert.deepEqual(["List", "ToDo", "Kanban", "Kanban test"], frappe.get_route(), - "Kanban view opened successfully."); - }, - // set filter values - () => cur_list.filter_area.add('ToDo', 'priority', '=', 'Low'), - () => frappe.timeout(1), - () => cur_list.page.btn_secondary.click(), - () => frappe.timeout(1), - () => { - assert.equal(cur_list.data[0].priority, 'Low', - 'visible element has low priority'); - let non_low_items = cur_list.data.filter(d => d.priority != 'Low'); - assert.equal(non_low_items.length, 0, 'No item without low priority'); - }, - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_kanban/test_kanban_view.js b/frappe/tests/ui/test_kanban/test_kanban_view.js deleted file mode 100644 index 4ed9597cf0..0000000000 --- a/frappe/tests/ui/test_kanban/test_kanban_view.js +++ /dev/null @@ -1,29 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test: Kanban view", function(assert) { - assert.expect(4); - let done = assert.async(); - - frappe.run_serially([ - () => frappe.set_route("List", "ToDo", "List"), - // calculate number of element in list - () => frappe.timeout(1), - () => frappe.set_route("List", "ToDo", "Kanban", "Kanban test"), - () => frappe.timeout(2), - () => { - assert.equal('Kanban', cur_list.view_name, - "Current view is kanban."); - assert.equal("Kanban test", cur_list.page_title, - "Kanban view opened successfully."); - // check if all elements are visible in kanban view - const $high_priority_cards = - $('.kanban-column[data-column-value="High"] .kanban-card-wrapper'); - const $low_priority_cards = - $('.kanban-column[data-column-value="Low"] .kanban-card-wrapper'); - - assert.equal($high_priority_cards.length, 1); - assert.equal($low_priority_cards.length, 1); - }, - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_linked_with.js b/frappe/tests/ui/test_linked_with.js deleted file mode 100644 index aeaced2d19..0000000000 --- a/frappe/tests/ui/test_linked_with.js +++ /dev/null @@ -1,19 +0,0 @@ -QUnit.module('form'); - -QUnit.test("Test Linked With", function(assert) { - assert.expect(2); - const done = assert.async(); - - frappe.run_serially([ - () => frappe.set_route('Form', 'Module Def', 'Contacts'), - () => frappe.tests.click_page_head_item('Menu'), - () => frappe.tests.click_dropdown_item('Links'), - () => frappe.timeout(4), - () => { - assert.equal(cur_dialog.title, 'Linked With', 'Linked with dialog is opened'); - const link_tables_count = cur_dialog.$wrapper.find('.list-item-table').length; - assert.equal(link_tables_count, 2, 'Two DocTypes are linked with Contacts'); - }, - done - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_list/test_list_filter.js b/frappe/tests/ui/test_list/test_list_filter.js deleted file mode 100644 index 059122f0c0..0000000000 --- a/frappe/tests/ui/test_list/test_list_filter.js +++ /dev/null @@ -1,39 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test list filters", function(assert) { - assert.expect(3); - let done = assert.async(); - - frappe.run_serially([ - () => { - return frappe.tests.make('ToDo', [ - {description: 'low priority'}, - {priority: 'Low'} - ]); - }, - () => { - return frappe.tests.make('ToDo', [ - {description: 'high priority'}, - {priority: 'High'} - ]); - }, - () => frappe.set_route('List', 'ToDo', 'List'), - () => frappe.timeout(0.5), - () => { - assert.deepEqual(['List', 'ToDo', 'List'], frappe.get_route(), - "List opened successfully."); - //set filter values - return frappe.set_control('priority', 'Low'); - }, - () => frappe.timeout(0.5), - () => cur_list.page.btn_secondary.click(), - () => frappe.timeout(1), - () => { - assert.equal(cur_list.data[0].priority, 'Low', - 'visible element has low priority'); - let non_low_items = cur_list.data.filter(d => d.priority != 'Low'); - assert.equal(non_low_items.length, 0, 'no item without low priority'); - }, - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_list/test_list_paging.js b/frappe/tests/ui/test_list/test_list_paging.js deleted file mode 100644 index b760a11370..0000000000 --- a/frappe/tests/ui/test_list/test_list_paging.js +++ /dev/null @@ -1,25 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test paging in list view", function(assert) { - assert.expect(5); - let done = assert.async(); - - frappe.run_serially([ - () => frappe.set_route('List', 'DocType'), - () => frappe.timeout(0.5), - () => assert.deepEqual(['List', 'DocType', 'List'], frappe.get_route(), - "List opened successfully."), - //check elements less then page length [20 in this case] - () => assert.equal(cur_list.data.length, 20, 'show 20 items'), - () => frappe.click_button('More'), - () => frappe.timeout(2), - () => assert.equal(cur_list.data.length, 40, 'show more items'), - () => frappe.tests.click_button('100'), - () => frappe.timeout(2), - () => assert.ok(cur_list.data.length > 40, 'show 100 items'), - () => frappe.tests.click_button('20'), - () => frappe.timeout(2), - () => assert.equal(cur_list.data.length, 20, 'show 20 items again'), - () => done() - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_list_count.js b/frappe/tests/ui/test_list_count.js deleted file mode 100644 index 31f73964d2..0000000000 --- a/frappe/tests/ui/test_list_count.js +++ /dev/null @@ -1,33 +0,0 @@ -QUnit.module('Setup'); - -QUnit.test("Test List Count", function(assert) { - assert.expect(3); - const done = assert.async(); - - frappe.run_serially([ - () => frappe.set_route('List', 'DocType'), - () => frappe.timeout(0.5), - () => { - let count = $('.list-count').text().split(' ')[0]; - assert.equal(cur_list.data.length, count, "Correct Count"); - }, - - () => frappe.timeout(1), - () => cur_list.filter_area.add('Doctype', 'module', '=', 'Desk'), - () => frappe.click_button('Refresh'), - () => { - let count = $('.list-count').text().split(' ')[0]; - assert.equal(cur_list.data.length, count, "Correct Count"); - }, - - () => cur_list.filter_area.clear(), - () => frappe.timeout(1), - () => { - cur_list.filter_area.add('DocField', 'fieldname', 'like', 'owner'); - let count = $('.list-count').text().split(' ')[0]; - assert.equal(cur_list.data.length, count, "Correct Count"); - }, - - done - ]); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_module_view.js b/frappe/tests/ui/test_module_view.js deleted file mode 100644 index 7340945b29..0000000000 --- a/frappe/tests/ui/test_module_view.js +++ /dev/null @@ -1,41 +0,0 @@ -QUnit.module('views'); - -QUnit.test("Test modules view", function(assert) { - assert.expect(4); - let done = assert.async(); - - frappe.run_serially([ - - //click Document Share Report in Permissions section [Report] - () => frappe.set_route("modules", "Setup"), - () => frappe.timeout(0.5), - () => frappe.click_link('Document Share Report'), - () => assert.deepEqual(frappe.get_route(), ["List", "DocShare", "Report", "Document Share Report"], - 'document share report'), - - //click Print Setting in Printing section [Form] - () => frappe.set_route("modules", "Setup"), - () => frappe.timeout(0.5), - () => frappe.click_link('Print Settings'), - () => assert.deepEqual(frappe.get_route(), ["Form", "Print Settings"], - 'print settings'), - - //click Workflow Action in Workflow section [List] - () => frappe.set_route("modules", "Setup"), - () => frappe.timeout(0.5), - () => frappe.click_link('Workflow Action'), - () => assert.deepEqual(frappe.get_route(), ["List", "Workflow Action", "List"], - 'workflow action'), - - //click Workflow Action in Workflow section [List] - () => frappe.set_route("modules"), - () => frappe.timeout(0.5), - () => frappe.click_link('Tools'), - () => frappe.timeout(0.5), - () => frappe.click_link('To Do'), - () => assert.deepEqual(frappe.get_route(), ["List", "ToDo", "List"], - 'todo list'), - - () => done() - ]); -}); diff --git a/frappe/tests/ui/test_number_format.js b/frappe/tests/ui/test_number_format.js deleted file mode 100644 index 2bca7d92f6..0000000000 --- a/frappe/tests/ui/test_number_format.js +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// MIT License. See license.txt - -QUnit.module("Number Formatting"); - -QUnit.test("#,###.##", function(assert) { - assert.equal(format_number(100, "#,###.##"), "100.00"); - assert.equal(format_number(1000, "#,###.##"), "1,000.00"); - assert.equal(format_number(10000, "#,###.##"), "10,000.00"); - assert.equal(format_number(1000000, "#,###.##"), "1,000,000.00"); - assert.equal(format_number(1000000.345, "#,###.##"), "1,000,000.35"); -}); - -QUnit.test("#,##,###.##", function(assert) { - assert.equal(format_number(100, "#,##,###.##"), "100.00"); - assert.equal(format_number(1000, "#,##,###.##"), "1,000.00"); - assert.equal(format_number(10000, "#,##,###.##"), "10,000.00"); - assert.equal(format_number(1000000, "#,##,###.##"), "10,00,000.00"); - assert.equal(format_number(1000000.341, "#,##,###.##"), "10,00,000.34"); - assert.equal(format_number(10000000.341, "#,##,###.##"), "1,00,00,000.34"); -}); - -QUnit.test("#.###,##", function(assert) { - assert.equal(format_number(100, "#.###,##"), "100,00"); - assert.equal(format_number(1000, "#.###,##"), "1.000,00"); - assert.equal(format_number(10000, "#.###,##"), "10.000,00"); - assert.equal(format_number(1000000, "#.###,##"), "1.000.000,00"); - assert.equal(format_number(1000000.345, "#.###,##"), "1.000.000,35"); -}); - -QUnit.test("#.###", function(assert) { - assert.equal(format_number(100, "#.###"), "100"); - assert.equal(format_number(1000, "#.###"), "1.000"); - assert.equal(format_number(10000, "#.###"), "10.000"); - assert.equal(format_number(-100000, "#.###"), "-100.000"); - assert.equal(format_number(1000000, "#.###"), "1.000.000"); - assert.equal(format_number(1000000.345, "#.###"), "1.000.000"); -}); \ No newline at end of file diff --git a/frappe/tests/ui/test_social_login_key_buttons.py b/frappe/tests/ui/test_social_login_key_buttons.py deleted file mode 100644 index 376d4139e4..0000000000 --- a/frappe/tests/ui/test_social_login_key_buttons.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt -from __future__ import unicode_literals - -import unittest, frappe, time -from frappe.utils.selenium_testdriver import TestDriver - -class TestSocialLoginKeyButtons(unittest.TestCase): - def setUp(self): - try: - frappe_login_key = frappe.get_doc("Social Login Key", "frappe") - except frappe.DoesNotExistError: - frappe_login_key = frappe.new_doc("Social Login Key") - frappe_login_key.get_social_login_provider("Frappe", initialize=True) - frappe_login_key.base_url = "http://localhost:8000" - frappe_login_key.enable_social_login = 1 - frappe_login_key.client_id = "test_client_id" - frappe_login_key.client_secret = "test_client_secret" - frappe_login_key.save() - - self.driver = TestDriver() - - def test_login_buttons(self): - - # Go to Login Page - self.driver.get("login") - - time.sleep(2) - frappe_social_login = self.driver.find(".btn-frappe") - self.assertTrue(len(frappe_social_login) > 0) - - def tearDown(self): - self.driver.close() diff --git a/frappe/tests/ui/test_test_runner.py b/frappe/tests/ui/test_test_runner.py deleted file mode 100644 index 8b207cd02d..0000000000 --- a/frappe/tests/ui/test_test_runner.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import print_function, unicode_literals -from frappe.utils.selenium_testdriver import TestDriver -import unittest, os, frappe, time - -class TestTestRunner(unittest.TestCase): - def test_test_runner(self): - if frappe.flags.run_setup_wizard_ui_test: - for setup_wizard_test in frappe.get_hooks("setup_wizard_test"): - passed = frappe.get_attr(setup_wizard_test)() - self.assertTrue(passed) - return - - driver = TestDriver() - frappe.db.set_default('in_selenium', '1') - driver.login() - for test in get_tests(): - if test.startswith('#'): - continue - - timeout = 60 - passed = False - if '#' in test: - test, comment = test.split('#') - test = test.strip() - if comment.strip()=='long': - timeout = 300 - - print('Running {0}...'.format(test)) - - frappe.db.set_value('Test Runner', None, 'module_path', test) - frappe.db.commit() - driver.refresh() - driver.set_route('Form', 'Test Runner') - try: - driver.click_primary_action() - driver.wait_for('#frappe-qunit-done', timeout=timeout) - console = driver.get_console() - passed = 'Tests Passed' in console - finally: - console = driver.get_console() - passed = 'Test Passed' in console - if frappe.flags.tests_verbose or not passed: - for line in console: - print(line) - print('-' * 40) - else: - self.assertTrue(passed) - time.sleep(1) - frappe.db.set_default('in_selenium', None) - driver.close() - -def get_tests(): - '''Get tests base on flag''' - frappe.db.set_value('Test Runner', None, 'app', frappe.flags.ui_test_app or '') - - if frappe.flags.ui_test_list: - # list of tests - return get_tests_for(test_list=frappe.flags.ui_test_list) - elif frappe.flags.ui_test_path: - # specific test - return (frappe.flags.ui_test_path,) - elif frappe.flags.ui_test_app: - # specific app - return get_tests_for(frappe.flags.ui_test_app) - else: - # all apps - tests = [] - for app in frappe.get_installed_apps(): - tests.extend(get_tests_for(app)) - return tests - -def get_tests_for(app=None, test_list=None): - tests = [] - if test_list: - # Get all tests from a particular txt file - app, test_list = test_list.split(os.path.sep, 1) - tests_path = frappe.get_app_path(app, test_list) - else: - # Get all tests for a particular app - tests_path = frappe.get_app_path(app, 'tests', 'ui', 'tests.txt') - if os.path.exists(tests_path): - with open(tests_path, 'r') as fileobj: - tests = fileobj.read().strip().splitlines() - return tests diff --git a/frappe/tests/ui/test_todo.py b/frappe/tests/ui/test_todo.py deleted file mode 100644 index bb367e7851..0000000000 --- a/frappe/tests/ui/test_todo.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import print_function, unicode_literals -from frappe.utils.selenium_testdriver import TestDriver -import unittest -import time, os - -class TestToDo(unittest.TestCase): - def setUp(self): - self.driver = TestDriver() - - def test_todo(self): - self.driver.login() - - # list view - self.driver.set_route('List', 'ToDo') - - time.sleep(2) - - # new - self.driver.click_primary_action() - - time.sleep(2) - - # set input - self.driver.set_text_editor('description', 'hello') - - # save - self.driver.click_modal_primary_action() - - time.sleep(2) - - # refresh - self.driver.click_secondary_action() - - time.sleep(2) - - result_list = self.driver.get_visible_element('.result-list') - first_element_text = (result_list - .find_element_by_css_selector('.list-item') - .find_element_by_css_selector('.list-id').text) - - # if os.environ.get('CI'): - # # we don't run this test in Travis as it always fails - # # reinforcing why we use Unit Testing instead of integration - # # testing - # return - - self.assertTrue('hello' in first_element_text) - - def tearDown(self): - self.driver.close() diff --git a/frappe/tests/ui/tests.txt b/frappe/tests/ui/tests.txt deleted file mode 100644 index 2fcbf64851..0000000000 --- a/frappe/tests/ui/tests.txt +++ /dev/null @@ -1,20 +0,0 @@ -frappe/tests/ui/test_number_format.js -frappe/tests/ui/test_list/test_list_filter.js -frappe/tests/ui/test_list/test_list_paging.js -frappe/tests/ui/test_module_view.js -frappe/tests/ui/test_calendar_view.js -frappe/tests/ui/test_kanban/test_kanban_creation.js -frappe/tests/ui/test_kanban/test_kanban_view.js -frappe/tests/ui/test_kanban/test_kanban_filters.js -frappe/tests/ui/test_kanban/test_kanban_column.js -frappe/core/doctype/report/test_query_report.js -frappe/tests/ui/test_linked_with.js -frappe/custom/doctype/customize_form/test_customize_form.js -frappe/desk/doctype/event/test_event.js -frappe/tests/ui/test_control_html.js -frappe/tests/ui/test_control_geolocation.js -frappe/core/doctype/role_profile/test_role_profile.js -frappe/core/doctype/user/test_user_with_role_profile.js -frappe/tests/ui/test_list_count.js -frappe/workflow/doctype/workflow/tests/test_workflow_create.js -frappe/workflow/doctype/workflow/tests/test_workflow_test.js \ No newline at end of file diff --git a/frappe/utils/selenium_testdriver.py b/frappe/utils/selenium_testdriver.py deleted file mode 100644 index cde0e5cb45..0000000000 --- a/frappe/utils/selenium_testdriver.py +++ /dev/null @@ -1,299 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt - -from __future__ import unicode_literals, print_function - -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -#from selenium.webdriver.support.select import Select -from selenium.webdriver.support import expected_conditions as EC -#from selenium.common.exceptions import TimeoutException -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.common.desired_capabilities import DesiredCapabilities - -import time -import signal -import os, sys -import frappe -from ast import literal_eval - -class TestDriver(object): - def __init__(self, port=None): - self.port = port or frappe.get_site_config().webserver_port or '8000' - - chrome_options = Options() - capabilities = DesiredCapabilities.CHROME - - if os.environ.get('CI'): - self.host = 'localhost' - else: - self.host = frappe.local.site - - # enable browser logging - capabilities['loggingPrefs'] = {'browser':'ALL'} - - chrome_options.add_argument('--no-sandbox') - chrome_options.add_argument('--start-maximized') - self.driver = webdriver.Chrome(chrome_options=chrome_options, - desired_capabilities=capabilities, port=9515) - - # self.driver.set_window_size(1080,800) - self.cur_route = None - self.logged_in = False - - @property - def localhost(self): - return "http://{host}:{port}".format(host=self.host, port=self.port) - - def get(self, url): - return self.driver.get(os.path.join(self.localhost, url)) - - def start(self): - def signal_handler(signal, frame): - self.close() - sys.exit(0) - signal.signal(signal.SIGINT, signal_handler) - - def refresh(self): - self.driver.refresh() - - def close(self): - if self.driver: - self.driver.quit() - self.driver = None - - def login(self, wait_for_id="#page-desktop", animate=0, scroll_offset=0): - if self.logged_in: - return - self.get('login') - self.wait_for("#login_email") - self.set_input("#login_email", "Administrator") - self.set_input("#login_password", "admin") - self.click('.btn-login', animate=animate, offset=scroll_offset) - self.wait_for(wait_for_id) - self.logged_in = True - - def set_input(self, selector, text, key=None, xpath=None): - elem = self.find(selector, xpath=xpath)[0] - elem.clear() - elem.send_keys(text) - if key: - time.sleep(0.5) - elem.send_keys(key) - time.sleep(0.2) - - def set_field(self, fieldname, text): - elem = self.wait_for(xpath='//input[@data-fieldname="{0}"]'.format(fieldname)) - time.sleep(0.2) - elem.send_keys(text) - - def set_select(self, fieldname, text): - elem = self.wait_for(xpath='//select[@data-fieldname="{0}"]'.format(fieldname)) - time.sleep(0.2) - elem.send_keys(text) - - def set_multicheck(self, fieldname, values): - for value in values: - path = '//div[@data-fieldname="{0}"]//span[@data-unit="{1}"]'.format(fieldname, value) - elem = self.wait_for(xpath=path) - time.sleep(0.2) - elem.click() - - def set_text_editor(self, fieldname, text): - elem = self.wait_for(xpath='//div[@data-fieldname="{0}"]//div[@contenteditable="true"]'.format(fieldname)) - time.sleep(0.2) - elem.send_keys(text) - - def find(self, selector=None, everywhere=False, xpath=None): - if xpath: - return self.driver.find_elements_by_xpath(xpath) - else: - if self.cur_route and not everywhere: - selector = self.cur_route + " " + selector - return self.driver.find_elements_by_css_selector(selector) - - def wait_for(self, selector=None, everywhere=False, timeout=20, xpath=None, for_invisible=False): - if self.cur_route and not everywhere: - selector = self.cur_route + " " + selector - - time.sleep(0.5) - - if selector: - _by = By.CSS_SELECTOR - if xpath: - _by = By.XPATH - selector = xpath - - try: - if not for_invisible: - elem = self.get_wait(timeout).until( - EC.presence_of_element_located((_by, selector))) - else: - elem = self.get_wait(timeout).until( - EC.invisibility_of_element_located((_by, selector))) - return elem - except Exception as e: - # body = self.driver.find_element_by_id('body_div') - # print(body.get_attribute('innerHTML')) - self.print_console() - raise e - - def wait_for_invisible(self, selector=None, everywhere=False, timeout=20, xpath=None): - self.wait_for(selector, everywhere, timeout, xpath, True) - - def get_console(self): - out = [] - for entry in self.driver.get_log('browser'): - source, line_no, message = entry.get('message').split(' ', 2) - - if message and message[0] in ('"', "'"): - # message is a quoted/escaped string - message = literal_eval(message) - - out.append(source + ' ' + line_no) - out.append(message) - out.append('-'*40) - - return out - - def print_console(self): - for line in self.get_console(): - print(line) - - def get_wait(self, timeout=20): - return WebDriverWait(self.driver, timeout) - - def scroll_to(self, selector, animate=0, offset=0): - self.execute_script("frappe.ui.scroll('{0}', {1}, {2})".format(selector, animate, offset)) - - def set_route(self, *args): - self.execute_script('frappe.set_route({0})'\ - .format(', '.join(['"{0}"'.format(r) for r in args]))) - - self.wait_for(xpath='//div[@data-page-route="{0}"]'.format('/'.join(args)), timeout=4) - - def click(self, css_selector, xpath=None, timeout=20, animate=0, offset=0): - element = self.wait_till_clickable(css_selector, xpath, timeout) - self.scroll_to(css_selector, animate, offset) - time.sleep(0.5) - element.click() - return element - - def click_primary_action(self): - selector = ".page-actions .primary-action" - #self.scroll_to(selector) - self.wait_till_clickable(selector).click() - self.wait_for_ajax() - - def click_secondary_action(self): - selector = ".page-actions .btn-secondary" - #self.scroll_to(selector) - self.wait_till_clickable(selector).click() - self.wait_for_ajax() - - def click_modal_primary_action(self): - self.get_visible_modal().find_element_by_css_selector('.btn-primary').click() - - def get_visible_modal(self): - return self.get_visible_element('.modal-content') - - def get_visible_element(self, selector=None, xpath=None): - for elem in self.find(selector=selector, xpath=xpath): - if elem.is_displayed(): - return elem - - def wait_till_clickable(self, selector=None, xpath=None, timeout=20): - if self.cur_route: - selector = self.cur_route + " " + selector - - by = By.CSS_SELECTOR - if xpath: - by = By.XPATH - selector = xpath - - return self.get_wait(timeout).until(EC.element_to_be_clickable( - (by, selector))) - - - def execute_script(self, js): - self.driver.execute_script(js) - - def wait_for_ajax(self, freeze = False): - self.wait_for('body[data-ajax-state="complete"]', True) - if freeze: - self.wait_for_invisible(".freeze-message-container") - - -# def go_to_module(module_name, item=None): -# global cur_route -# -# # desktop -# find(".navbar-home", True)[0].click() -# cur_route = None -# wait("#page-desktop") -# -# page = "Module/" + module_name -# m = find('#page-desktop [data-link="{0}"] .app-icon'.format(page)) -# if not m: -# page = "List/" + module_name -# m = find('#page-desktop [data-link="{0}"] .app-icon'.format(page)) -# if not m: -# raise Exception("Module {0} not found".format(module_name)) -# -# m[0].click() -# wait_for_page(page) -# -# if item: -# elem = find('[data-label="{0}"]'.format(item))[0] -# elem.click() -# page = elem.get_attribute("data-route") -# wait_for_page(page) -# -# def new_doc(module, doctype): -# go_to_module(module, doctype) -# primary_action() -# wait_for_page("Form/" + doctype) -# -# def add_child(fieldname): -# find('[data-fieldname="{0}"] .grid-add-row'.format(fieldname))[0].click() -# wait('[data-fieldname="{0}"] .form-grid'.format(fieldname)) -# -# def done_add_child(fieldname): -# selector = '[data-fieldname="{0}"] .grid-row-open .btn-success'.format(fieldname) -# scroll_to(selector) -# wait_till_clickable(selector).click() -# -# def set_field(fieldname, value, fieldtype="input"): -# _driver.switch_to.window(_driver.current_window_handle) -# selector = '{0}[data-fieldname="{1}"]'.format(fieldtype, fieldname) -# set_input(selector, value, key=Keys.TAB) -# wait_for_ajax() -# -# def set_select(fieldname, value): -# select = Select(find('select[data-fieldname="{0}"]'.format(fieldname))[0]) -# select.select_by_value(value) -# wait_for_ajax() -# -# -# def wait_for_page(name): -# global cur_route -# cur_route = None -# route = '[data-page-route="{0}"]'.format(name) -# wait_for_ajax() -# elem = wait(route) -# wait_for_ajax() -# cur_route = route -# return elem -# -# -# def wait_till_visible(selector): -# if cur_route: -# selector = cur_route + " " + selector -# return get_wait().until(EC.visibility_of_element_located((By.CSS_SELECTOR, selector))) -# -# -# def wait_for_state(state): -# return wait(cur_route + '[data-state="{0}"]'.format(state), True) -# -# diff --git a/requirements.txt b/requirements.txt index 84788c863e..84b77993b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,6 @@ semantic_version rauth>=0.6.2 requests redis==2.10.6 -selenium babel==2.6.0 ipython html2text==2016.9.19 From a3b100daaeaaf3336ea521c5759d89b447f039f3 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 15 Nov 2019 11:23:12 +0530 Subject: [PATCH 24/76] chore: manual requirements.txt generation --- requirements.txt | 99 +++++++++++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/requirements.txt b/requirements.txt index 41f2a405e0..5d961b90c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,62 +1,67 @@ -setuptools==40.8.0 -urllib3==1.25.6 -Jinja2==2.10.3 -semantic_version==2.8.2 -pip==19.2.3 +Babel==2.6.0 +beautifulsoup4==4.8.1 +bleach-whitelist==0.0.10 +bleach==2.1.4 +boto3==1.10.18 +braintree==3.57.1 chardet==3.0.4 Click==7.0 -six==1.12.0 -requests==2.22.0 -ipython==7.9.0 -Image==1.5.27 -Pillow==6.2.1 -PyPDF2==1.26.0 -RestrictedPython==5.0 -google_api_python_client==1.7.11 -Babel==2.7.0 -bleach==3.1.0 -bleach_whitelist==0.0.10 -boto3==1.10.15 -botocore==1.13.15 -braintree==3.57.1 -beautifulsoup4==4.8.1 coverage==4.5.4 croniter==0.3.30 cryptography==2.8 -pycups==1.9.74 -python_dateutil==2.8.1 -dropbox==9.4.0 -email_reply_parser==0.5.9 -Faker==2.0.3 -python_frontmatter==0.4.5 -protobuf==3.10.0 -html2text==2019.9.26 -PyJWT==1.7.1 -python_ldap==3.2.0 -ldap3==2.6.1 -markdown2==2.3.8 -num2words==0.5.10 -oauthlib==3.1.0 -openpyxl==3.0.0 -passlib==1.7.1 +dropbox==9.1.0 +email-reply-parser==0.5.9 +Faker==2.0.4 +frontmatter==3.0.5 future==0.18.2 +GitPython==2.1.11 +google-api-python-client==1.7.11 +google-auth-httplib2==0.0.3 +google-auth-oauthlib==0.4.1 +google-auth==1.7.1 +googlemaps==3.1.1 +gunicorn==20.0.0 +html2text==2016.9.19 +ipython==7.9.0 +Jinja2==2.10.3 +markdown2==2.3.5 +maxminddb-geolite2==2018.703 +ndg-httpsclient==0.5.1 +num2words==0.5.5 +oauthlib==3.1.0 +openpyxl==3.0.1 +passlib==1.7.1 pdfkit==0.6.1 +Pillow==6.2.1 premailer==3.6.1 -psycopg2==2.8.4 -Pygments==2.4.2 -pymysql==0.9.3 +psycopg2-binary==2.7.5 +psycopg2==2.7.5 +pyasn1==0.4.7 +Pygments==2.2.0 +PyJWT==1.7.1 +PyMySQL==0.9.3 +pyOpenSSL==19.0.0 pyotp==2.3.0 -pyqrcode==1.2.1 +PyPDF2==1.26.0 +pypng==0.0.20 +PyQRCode==1.2.1 +python-dateutil==2.8.1 pytz==2019.3 +PyYAML==3.13 rauth==0.7.3 -redis==3.3.11 -rq==1.1.0 +redis==2.10.6 +requests-oauthlib==1.3.0 +requests==2.22.0 +RestrictedPython==5.0 +rq==0.12.0 schedule==0.6.0 -selenium==3.141.0 -sqlparse==0.3.0 +semantic-version==2.8.2 +six==1.13.0 +sqlparse==0.2.4 stripe==2.40.0 -watchdog==0.9.0 +unittest-xml-reporting==2.5.2 +urllib3==1.25.7 +watchdog==0.8.0 Werkzeug==0.16.0 xlrd==1.2.0 -xmlrunner==1.7.7 -zxcvbn==4.4.28 +zxcvbn-python==4.4.24 From e55135f216ff80894a05a7cc837c2fc82ac846e7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 15 Nov 2019 13:37:50 +0530 Subject: [PATCH 25/76] fix: updated travis config --- .travis.yml | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index ffd05eae03..6990a0df8a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python dist: trusty -sudo: required addons: hosts: @@ -62,20 +61,20 @@ install: - mkdir ~/frappe-bench/sites/test_site - cp $TRAVIS_BUILD_DIR/.travis/$DB.json ~/frappe-bench/sites/test_site/site_config.json - - if: env(DB) = "mariadb" - - mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" - - mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + - if [ $DB == "mariadb" ];then + mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'"; + mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"; + mysql -u root -e "CREATE DATABASE test_frappe"; + mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"; + mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"; + mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'"; + mysql -u root -e "FLUSH PRIVILEGES"; + fi - - mysql -u root -e "CREATE DATABASE test_frappe" - - mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" - - mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" - - - mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" - - mysql -u root -e "FLUSH PRIVILEGES" - - - if: env(DB) = "postgres" - - psql -c "CREATE DATABASE test_frappe" -U postgres - - psql -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres + - if [ $DB == "postgres" ];then + psql -c "CREATE DATABASE test_frappe" -U postgres; + psql -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; + fi - cd ./frappe-bench From 6cb2a9c2ee65673bde193d23baa63b311321c048 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 15 Nov 2019 13:45:33 +0530 Subject: [PATCH 26/76] chore: added selenium requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 5d961b90c8..dd68094ca5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,6 +55,7 @@ requests==2.22.0 RestrictedPython==5.0 rq==0.12.0 schedule==0.6.0 +selenium==3.141.0 semantic-version==2.8.2 six==1.13.0 sqlparse==0.2.4 From aa217e3b46a47be7ce9e0f5ac9ce8f9490b1f38d Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 15 Nov 2019 14:31:01 +0530 Subject: [PATCH 27/76] fix(compat): pin requirements for backwards compatibility gunicorn==19.9.0 ipython==5.8.0 openpyxl==2.6.4 to update these packages, will have to drop support for py2 --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index dd68094ca5..dcfaac89ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,16 +20,16 @@ google-auth-httplib2==0.0.3 google-auth-oauthlib==0.4.1 google-auth==1.7.1 googlemaps==3.1.1 -gunicorn==20.0.0 +gunicorn==19.9.0 html2text==2016.9.19 -ipython==7.9.0 +ipython==5.8.0 Jinja2==2.10.3 markdown2==2.3.5 maxminddb-geolite2==2018.703 ndg-httpsclient==0.5.1 num2words==0.5.5 oauthlib==3.1.0 -openpyxl==3.0.1 +openpyxl==2.6.4 passlib==1.7.1 pdfkit==0.6.1 Pillow==6.2.1 From 27d87a7028e7eb988160951717036af973b30aef Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 21 Nov 2019 16:09:43 +0530 Subject: [PATCH 28/76] feat: added a null state to list view --- frappe/public/images/ui-states/empty.png | Bin 0 -> 50732 bytes frappe/public/js/frappe/list/list_view.js | 3 +++ frappe/public/less/list.less | 5 +++++ 3 files changed, 8 insertions(+) create mode 100644 frappe/public/images/ui-states/empty.png diff --git a/frappe/public/images/ui-states/empty.png b/frappe/public/images/ui-states/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..de2a89350854b07203c53051eacbe4e9170864f4 GIT binary patch literal 50732 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 literal 0 HcmV?d00001 diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index b980ae7684..d5db15d126 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -308,6 +308,9 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {

` : ''; return `
+
+ Generic Empty State +

${__('No {0} found', [__(this.doctype)])}

${new_button}
`; diff --git a/frappe/public/less/list.less b/frappe/public/less/list.less index efcbad4653..99ba3e7c22 100644 --- a/frappe/public/less/list.less +++ b/frappe/public/less/list.less @@ -4,6 +4,11 @@ .result, .no-result, .freeze { min-height: ~"calc(100vh - 284px)"; } + + .null-state { + height: 12em !important; + width: auto; + } } .freeze-row { From 27245d14bed9db30c366a4ded0b9725b7e88b896 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 21 Nov 2019 20:21:13 +0530 Subject: [PATCH 29/76] feat: onboarding wizard with configurable slides --- .../setup_wizard_help_link/__init__.py | 0 .../setup_wizard_help_link.json | 35 +++ .../setup_wizard_help_link.py | 10 + .../doctype/setup_wizard_settings/__init__.py | 0 .../setup_wizard_settings.js | 8 + .../setup_wizard_settings.json | 46 ++++ .../setup_wizard_settings.py | 84 +++++++ .../test_setup_wizard_settings.py | 10 + .../doctype/setup_wizard_slide/__init__.py | 0 .../setup_wizard_slide/setup_wizard_slide.js | 8 + .../setup_wizard_slide.json | 153 +++++++++++++ .../setup_wizard_slide/setup_wizard_slide.py | 10 + .../test_setup_wizard_slide.py | 10 + .../setup_wizard_slide_field/__init__.py | 0 .../setup_wizard_slide_field.json | 109 +++++++++ .../setup_wizard_slide_field.py | 10 + .../setup_wizard_slide_order/__init__.py | 0 .../setup_wizard_slide_order.json | 30 +++ .../setup_wizard_slide_order.py | 10 + .../setup_wizard_settings.json | 24 ++ frappe/desk/setup_wizard_slide/__init__.py | 0 .../add_a_few_customers/__init__.py | 0 .../add_a_few_customers.json | 64 ++++++ .../__init__.py | 0 .../add_a_few_products_you_buy_or_sell.json | 78 +++++++ .../add_a_few_suppliers/__init__.py | 0 .../add_a_few_suppliers.json | 65 ++++++ .../company_logo_and_letter_head/__init__.py | 0 .../company_logo_and_letter_head.json | 50 +++++ .../welcome_to_erpnext!/__init__.py | 0 .../welcome_to_erpnext!.json | 36 +++ frappe/model/sync.py | 9 +- frappe/public/js/frappe/desk.js | 29 +++ .../public/js/frappe/ui/onboarding_dialog.js | 126 +++++++++++ frappe/public/js/frappe/ui/slides.js | 11 +- .../frappe/ui/toolbar/user_progress_dialog.js | 209 ------------------ frappe/public/less/desk.less | 18 ++ frappe/sessions.py | 2 +- 38 files changed, 1039 insertions(+), 215 deletions(-) create mode 100644 frappe/desk/doctype/setup_wizard_help_link/__init__.py create mode 100644 frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.json create mode 100644 frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.py create mode 100644 frappe/desk/doctype/setup_wizard_settings/__init__.py create mode 100644 frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.js create mode 100644 frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.json create mode 100644 frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py create mode 100644 frappe/desk/doctype/setup_wizard_settings/test_setup_wizard_settings.py create mode 100644 frappe/desk/doctype/setup_wizard_slide/__init__.py create mode 100644 frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.js create mode 100644 frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json create mode 100644 frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.py create mode 100644 frappe/desk/doctype/setup_wizard_slide/test_setup_wizard_slide.py create mode 100644 frappe/desk/doctype/setup_wizard_slide_field/__init__.py create mode 100644 frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.json create mode 100644 frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.py create mode 100644 frappe/desk/doctype/setup_wizard_slide_order/__init__.py create mode 100644 frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.json create mode 100644 frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.py create mode 100644 frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json create mode 100644 frappe/desk/setup_wizard_slide/__init__.py create mode 100644 frappe/desk/setup_wizard_slide/add_a_few_customers/__init__.py create mode 100644 frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json create mode 100644 frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/__init__.py create mode 100644 frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json create mode 100644 frappe/desk/setup_wizard_slide/add_a_few_suppliers/__init__.py create mode 100644 frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json create mode 100644 frappe/desk/setup_wizard_slide/company_logo_and_letter_head/__init__.py create mode 100644 frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json create mode 100644 frappe/desk/setup_wizard_slide/welcome_to_erpnext!/__init__.py create mode 100644 frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json create mode 100644 frappe/public/js/frappe/ui/onboarding_dialog.js delete mode 100644 frappe/public/js/frappe/ui/toolbar/user_progress_dialog.js diff --git a/frappe/desk/doctype/setup_wizard_help_link/__init__.py b/frappe/desk/doctype/setup_wizard_help_link/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.json b/frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.json new file mode 100644 index 0000000000..b97b482cac --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.json @@ -0,0 +1,35 @@ +{ + "creation": "2019-11-19 12:22:42.805741", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "video_id" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "fieldname": "video_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Video" + } + ], + "istable": 1, + "modified": "2019-11-19 13:39:57.716248", + "modified_by": "Administrator", + "module": "Desk", + "name": "Setup Wizard Help Link", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.py b/frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.py new file mode 100644 index 0000000000..fbb96bbd91 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_help_link/setup_wizard_help_link.py @@ -0,0 +1,10 @@ +# -*- 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 SetupWizardHelpLink(Document): + pass diff --git a/frappe/desk/doctype/setup_wizard_settings/__init__.py b/frappe/desk/doctype/setup_wizard_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.js b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.js new file mode 100644 index 0000000000..48b4497182 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Setup Wizard Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.json b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.json new file mode 100644 index 0000000000..900a2da83b --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.json @@ -0,0 +1,46 @@ +{ + "creation": "2019-11-13 15:55:07.016295", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "slide_order_section", + "slide_order" + ], + "fields": [ + { + "fieldname": "slide_order_section", + "fieldtype": "Section Break", + "label": "Slide Order" + }, + { + "fieldname": "slide_order", + "fieldtype": "Table", + "label": "Slide Order", + "options": "Setup Wizard Slide Order", + "reqd": 1 + } + ], + "issingle": 1, + "modified": "2019-11-19 11:56:32.757433", + "modified_by": "Administrator", + "module": "Desk", + "name": "Setup Wizard Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py new file mode 100644 index 0000000000..4a959190a7 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py @@ -0,0 +1,84 @@ +# -*- 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 +from erpnext.setup.doctype.setup_progress.setup_progress import get_action_completed_state +from frappe.modules import get_module_path, scrub_dt_dn +from frappe.modules.export_file import export_to_files, create_init_py + +class SetupWizardSettings(Document): + def on_update(self): + if frappe.flags.in_import or frappe.flags.in_test: + return + + if frappe.local.conf.get('developer_mode'): + record_list =[['Setup Wizard Settings', self.name]] + + for s in self.slide_order: + record_list.append(['Setup Wizard Slide', s.slide]) + + export_to_files(record_list=record_list, record_module='Desk') + + for s in self.slide_order: + dt, dn = scrub_dt_dn('Setup Wizard Slide', s.slide) + create_init_py(get_module_path('Desk'), dt, dn) + +def get_slide_settings(): + slides = [] + slide_settings = frappe.get_single('Setup Wizard Settings') + for entry in slide_settings.slide_order: + slide_doc = frappe.get_doc('Setup Wizard Slide', entry.slide) + print(frappe.get_installed_apps()) + if frappe.scrub(slide_doc.app) in frappe.get_installed_apps(): + domains = get_domains(slide_doc) + help_links = get_help_links(slide_doc) + if slide_doc.slide_type == 'Action': + submit_method = frappe.scrub(slide_doc.app) + '.utilities.onboarding_utils.' + slide_doc.submit_method + else: + submit_method = None + if slide_doc.image_src: + image_src = slide_doc.image_src + else: + image_src = None + slides.append(frappe._dict( + slide_type = slide_doc.slide_type, + title = slide_doc.slide_title, + help = slide_doc.slide_desc, + domains = domains, + fields = slide_doc.slide_fields, + help_links = help_links, + add_more = slide_doc.add_more_button, + max_count = slide_doc.max_count, + submit_method = submit_method, + image_src= image_src + )) + return slides + +@frappe.whitelist() +def get_onboarding_slides(): + slides = [] + slide_settings = get_slide_settings() + + domains = frappe.get_active_domains() + for s in slide_settings: + if not s.domains or any(d in domains for d in s.domains): + slides.append(s) + return slides + +def get_domains(slide_doc): + domains_list = [] + for entry in slide_doc.domains: + domains_list.append(entry.domain) + return domains_list + +def get_help_links(slide_doc): + links=[] + for link in slide_doc.help_links: + links.append({ + 'label': link.label, + 'video_id': link.video_id + }) + return links diff --git a/frappe/desk/doctype/setup_wizard_settings/test_setup_wizard_settings.py b/frappe/desk/doctype/setup_wizard_settings/test_setup_wizard_settings.py new file mode 100644 index 0000000000..08aa831d2b --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_settings/test_setup_wizard_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestSetupWizardSettings(unittest.TestCase): + pass diff --git a/frappe/desk/doctype/setup_wizard_slide/__init__.py b/frappe/desk/doctype/setup_wizard_slide/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.js b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.js new file mode 100644 index 0000000000..dcc6fb15a8 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Setup Wizard Slide', { + // refresh: { + + // } +}); diff --git a/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json new file mode 100644 index 0000000000..661a855e39 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json @@ -0,0 +1,153 @@ +{ + "autoname": "field:slide_title", + "creation": "2019-11-13 14:39:56.834658", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "slide_title", + "app", + "column_break_4", + "image_src", + "description_section_break", + "slide_desc", + "action_section_break", + "slide_type", + "submit_method", + "column_break_6", + "max_count", + "add_more_button", + "section_break_5", + "slide_fields", + "section_break_10", + "domains", + "column_break_12", + "help_links" + ], + "fields": [ + { + "fieldname": "slide_title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Slide Title", + "reqd": 1, + "unique": 1 + }, + { + "depends_on": "eval:doc.slide_type === 'Action';", + "fieldname": "submit_method", + "fieldtype": "Data", + "label": "Submit Method" + }, + { + "fieldname": "slide_desc", + "fieldtype": "HTML Editor", + "label": "Slide Description" + }, + { + "default": "3", + "depends_on": "add_more_button", + "fieldname": "max_count", + "fieldtype": "Int", + "label": "Max Count" + }, + { + "default": "0", + "depends_on": "eval: doc.slide_type === 'Action';", + "fieldname": "add_more_button", + "fieldtype": "Check", + "label": "Add More Button" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "label": "Fields" + }, + { + "fieldname": "slide_fields", + "fieldtype": "Table", + "label": "Slide Fields", + "options": "Setup Wizard Slide Field" + }, + { + "fieldname": "section_break_10", + "fieldtype": "Section Break" + }, + { + "fieldname": "domains", + "fieldtype": "Table", + "label": "Domains", + "options": "Has Domain", + "reqd": 1 + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "help_links", + "fieldtype": "Table", + "label": "Help Links", + "options": "Setup Wizard Help Link" + }, + { + "fieldname": "action_section_break", + "fieldtype": "Section Break", + "label": "Action Settings" + }, + { + "fieldname": "slide_type", + "fieldtype": "Select", + "label": "Slide Type", + "options": "\nAction\nInfo", + "reqd": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "fieldname": "app", + "fieldtype": "Select", + "label": "App", + "options": "Frappe\nERPNext", + "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "image_src", + "fieldtype": "Data", + "label": "Slide Image Source" + }, + { + "fieldname": "description_section_break", + "fieldtype": "Section Break", + "label": "Description" + } + ], + "modified": "2019-11-21 17:35:30.846135", + "modified_by": "ruchamahabal2@gmail.com", + "module": "Desk", + "name": "Setup Wizard Slide", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.py b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.py new file mode 100644 index 0000000000..2a87820efc --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.py @@ -0,0 +1,10 @@ +# -*- 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 SetupWizardSlide(Document): + pass diff --git a/frappe/desk/doctype/setup_wizard_slide/test_setup_wizard_slide.py b/frappe/desk/doctype/setup_wizard_slide/test_setup_wizard_slide.py new file mode 100644 index 0000000000..58652c4ec2 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide/test_setup_wizard_slide.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestSetupWizardSlide(unittest.TestCase): + pass diff --git a/frappe/desk/doctype/setup_wizard_slide_field/__init__.py b/frappe/desk/doctype/setup_wizard_slide_field/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.json b/frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.json new file mode 100644 index 0000000000..d0f643a9ab --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.json @@ -0,0 +1,109 @@ +{ + "creation": "2019-11-13 13:35:08.617909", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "fieldtype", + "fieldname", + "options", + "align", + "placeholder", + "reqd", + "column_break_4", + "max_length", + "max_value", + "section_break_6", + "description", + "column_break_8", + "default" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Fieldtype", + "options": "Attach\nAttach Image\nCheck\nCurrency\nData\nDate\nDatetime\nFloat\nHTML\nInt\nRating\nSelect\nLink\nSmall Text\nText\nText Editor\nSection Break\nColumn Break" + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Fieldname" + }, + { + "fieldname": "options", + "fieldtype": "Text", + "in_list_view": 1, + "label": "Options" + }, + { + "fieldname": "align", + "fieldtype": "Select", + "label": "Align", + "options": "\ncenter\nleft\nright" + }, + { + "fieldname": "placeholder", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Placeholder" + }, + { + "default": "0", + "fieldname": "reqd", + "fieldtype": "Check", + "label": "Mandatory" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "max_length", + "fieldtype": "Int", + "label": "Max Length" + }, + { + "depends_on": "eval:doc.fieldtype=='Int'", + "fieldname": "max_value", + "fieldtype": "Int", + "label": "Max Value" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Description" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "fieldname": "default", + "fieldtype": "Data", + "label": "Default" + } + ], + "istable": 1, + "modified": "2019-11-21 17:47:12.648395", + "modified_by": "ruchamahabal2@gmail.com", + "module": "Desk", + "name": "Setup Wizard Slide Field", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.py b/frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.py new file mode 100644 index 0000000000..1b880ef916 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide_field/setup_wizard_slide_field.py @@ -0,0 +1,10 @@ +# -*- 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 SetupWizardSlideField(Document): + pass diff --git a/frappe/desk/doctype/setup_wizard_slide_order/__init__.py b/frappe/desk/doctype/setup_wizard_slide_order/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.json b/frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.json new file mode 100644 index 0000000000..7cffeb55e7 --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.json @@ -0,0 +1,30 @@ +{ + "creation": "2019-11-13 15:54:09.558748", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "slide" + ], + "fields": [ + { + "fieldname": "slide", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Slide", + "options": "Setup Wizard Slide", + "reqd": 1 + } + ], + "istable": 1, + "modified": "2019-11-19 11:55:58.818555", + "modified_by": "Administrator", + "module": "Desk", + "name": "Setup Wizard Slide Order", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.py b/frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.py new file mode 100644 index 0000000000..7ac5ac3fda --- /dev/null +++ b/frappe/desk/doctype/setup_wizard_slide_order/setup_wizard_slide_order.py @@ -0,0 +1,10 @@ +# -*- 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 SetupWizardSlideOrder(Document): + pass diff --git a/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json b/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json new file mode 100644 index 0000000000..7b77beb2b6 --- /dev/null +++ b/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json @@ -0,0 +1,24 @@ +{ + "creation": "2019-11-13 16:07:24.786989", + "docstatus": 0, + "doctype": "Setup Wizard Settings", + "idx": "0", + "modified": "2019-11-21 12:38:33.839741", + "modified_by": "ruchamahabal2@gmail.com", + "name": "Setup Wizard Settings", + "owner": "Administrator", + "slide_order": [ + { + "slide": "Welcome to ERPNext!" + }, + { + "slide": "Add A Few Products You Buy Or Sell" + }, + { + "slide": "Add A Few Customers" + }, + { + "slide": "Add A Few Suppliers" + } + ] +} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/__init__.py b/frappe/desk/setup_wizard_slide/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/setup_wizard_slide/add_a_few_customers/__init__.py b/frappe/desk/setup_wizard_slide/add_a_few_customers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json b/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json new file mode 100644 index 0000000000..f2b90d231b --- /dev/null +++ b/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json @@ -0,0 +1,64 @@ +{ + "add_more_button": 1, + "app": "ERPNext", + "creation": "2019-11-15 14:44:10.065014", + "docstatus": 0, + "doctype": "Setup Wizard Slide", + "domains": [ + { + "domain": "Manufacturing" + }, + { + "domain": "Services" + }, + { + "domain": "Retail" + }, + { + "domain": "Distribution" + } + ], + "help_links": [ + { + "label": "Customers", + "video_id": "zsrrVDk6VBs" + } + ], + "idx": 0, + "max_count": 3, + "modified": "2019-11-21 10:54:10.736056", + "modified_by": "Administrator", + "name": "Add A Few Customers", + "owner": "Administrator", + "slide_fields": [ + { + "align": "", + "fieldname": "customer_name", + "fieldtype": "Data", + "label": "Customer Name", + "max_length": 0, + "max_value": 0, + "placeholder": "", + "reqd": 1 + }, + { + "align": "", + "fieldtype": "Column Break", + "max_length": 0, + "max_value": 0, + "reqd": 0 + }, + { + "align": "", + "fieldname": "customer_contact", + "fieldtype": "Data", + "label": "Customer Contact", + "max_length": 0, + "max_value": 0, + "reqd": 1 + } + ], + "slide_title": "Add A Few Customers", + "slide_type": "Action", + "submit_method": "create_customers" +} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/__init__.py b/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json b/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json new file mode 100644 index 0000000000..5946a13624 --- /dev/null +++ b/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json @@ -0,0 +1,78 @@ +{ + "add_more_button": 1, + "app": "ERPNext", + "creation": "2019-11-15 14:41:12.007359", + "docstatus": 0, + "doctype": "Setup Wizard Slide", + "domains": [ + { + "domain": "Manufacturing" + }, + { + "domain": "Retail" + }, + { + "domain": "Services" + }, + { + "domain": "Distribution" + } + ], + "help_links": [], + "idx": 0, + "image_src": "/assets/erpnext/images/illustrations/product.png", + "max_count": 3, + "modified": "2019-11-21 12:31:57.487975", + "modified_by": "ruchamahabal2@gmail.com", + "name": "Add A Few Products You Buy Or Sell", + "owner": "Administrator", + "slide_desc": "", + "slide_fields": [ + { + "align": "", + "fieldname": "item", + "fieldtype": "Data", + "label": "Item", + "max_length": 0, + "max_value": 0, + "placeholder": "Product Name", + "reqd": 1 + }, + { + "align": "", + "fieldtype": "Column Break", + "max_length": 0, + "max_value": 0, + "reqd": 1 + }, + { + "align": "", + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "max_length": 0, + "max_value": 0, + "options": "UOM", + "reqd": 1 + }, + { + "align": "", + "fieldtype": "Column Break", + "max_length": 0, + "max_value": 0, + "reqd": 0 + }, + { + "align": "", + "fieldname": "item_price", + "fieldtype": "Currency", + "label": "Item Price", + "max_length": 0, + "max_value": 0, + "reqd": 1 + } + ], + "slide_title": "Add A Few Products You Buy Or Sell", + "slide_type": "Action", + "submit_method": "create_items" +} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/add_a_few_suppliers/__init__.py b/frappe/desk/setup_wizard_slide/add_a_few_suppliers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json b/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json new file mode 100644 index 0000000000..0d72f532e6 --- /dev/null +++ b/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json @@ -0,0 +1,65 @@ +{ + "add_more_button": 1, + "app": "ERPNext", + "creation": "2019-11-15 14:45:32.626641", + "docstatus": 0, + "doctype": "Setup Wizard Slide", + "domains": [ + { + "domain": "Manufacturing" + }, + { + "domain": "Services" + }, + { + "domain": "Retail" + }, + { + "domain": "Distribution" + } + ], + "help_links": [ + { + "label": "Supplier", + "video_id": "zsrrVDk6VBs" + } + ], + "idx": 0, + "max_count": 3, + "modified": "2019-11-21 10:54:03.196745", + "modified_by": "Administrator", + "name": "Add A Few Suppliers", + "owner": "Administrator", + "slide_desc": "", + "slide_fields": [ + { + "align": "", + "fieldname": "supplier_name", + "fieldtype": "Data", + "label": "Supplier Name", + "max_length": 0, + "max_value": 0, + "placeholder": "", + "reqd": 1 + }, + { + "align": "", + "fieldtype": "Column Break", + "max_length": 0, + "max_value": 0, + "reqd": 0 + }, + { + "align": "", + "fieldname": "supplier_contact", + "fieldtype": "Data", + "label": "Supplier Contact", + "max_length": 0, + "max_value": 0, + "reqd": 1 + } + ], + "slide_title": "Add A Few Suppliers", + "slide_type": "Action", + "submit_method": "create_suppliers" +} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/__init__.py b/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json b/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json new file mode 100644 index 0000000000..6357db09b1 --- /dev/null +++ b/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json @@ -0,0 +1,50 @@ +{ + "add_more_button": 0, + "creation": "2019-11-13 16:03:01.422088", + "docstatus": 0, + "doctype": "Setup Wizard Slide", + "domains": [ + { + "domain": "Services" + }, + { + "domain": "Manufacturing" + }, + { + "domain": "Retail" + }, + { + "domain": "Distribution" + } + ], + "help_links": [], + "idx": 0, + "max_count": 0, + "modified": "2019-11-19 13:35:05.379455", + "modified_by": "Administrator", + "name": "Company Logo and Letter Head", + "owner": "Administrator", + "slide_fields": [ + { + "align": "center", + "fieldname": "attach_logo", + "fieldtype": "Attach Image", + "label": "Attach Logo", + "max_length": 0, + "max_value": 0, + "options": "image", + "reqd": 0 + }, + { + "align": "center", + "fieldname": "attach_letterhead", + "fieldtype": "Attach Image", + "label": "Attach Letterhead", + "max_length": 0, + "max_value": 0, + "options": "image", + "reqd": 0 + } + ], + "slide_title": "Company Logo and Letter Head" +} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/__init__.py b/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json b/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json new file mode 100644 index 0000000000..78ae0e615c --- /dev/null +++ b/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json @@ -0,0 +1,36 @@ +{ + "add_more_button": 0, + "app": "ERPNext", + "creation": "2019-11-13 16:00:04.735347", + "docstatus": 0, + "doctype": "Setup Wizard Slide", + "domains": [ + { + "domain": "Manufacturing" + }, + { + "domain": "Retail" + }, + { + "domain": "Services" + }, + { + "domain": "Distribution" + }, + { + "domain": "Education" + } + ], + "help_links": [], + "idx": 0, + "image_src": "/assets/erpnext/images/illustrations/handshake.png", + "max_count": 0, + "modified": "2019-11-21 12:31:45.856754", + "modified_by": "ruchamahabal2@gmail.com", + "name": "Welcome to ERPNext!", + "owner": "Administrator", + "slide_desc": "Setting up an ERP can be overwhelming. But don't worry, we have got your back!
\nLet's setup your company.\nThis wizard will help you onboard to ERPNext in a short time!", + "slide_fields": [], + "slide_title": "Welcome to ERPNext!", + "slide_type": "Info" +} \ No newline at end of file diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 18bf827c5f..8e4504d160 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -41,7 +41,12 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe ("data_migration", "data_migration_mapping_detail"), ("data_migration", "data_migration_mapping"), ("data_migration", "data_migration_plan_mapping"), - ("data_migration", "data_migration_plan")): + ("data_migration", "data_migration_plan"), + ("desk", "setup_wizard_slide_field"), + ("desk", "setup_wizard_help_link"), + ("desk", "setup_wizard_slide"), + ("desk", "setup_wizard_slide_order"), + ("desk", "setup_wizard_settings")): files.append(os.path.join(frappe.get_app_path("frappe"), d[0], "doctype", d[1], d[1] + ".json")) @@ -70,7 +75,7 @@ def get_doc_files(files, start_path, force=0, sync_everything = False, verbose=F # load in sequence - warning for devs document_types = ['doctype', 'page', 'report', 'dashboard_chart_source', 'print_format', 'website_theme', 'web_form', 'notification', 'print_style', - 'data_migration_mapping', 'data_migration_plan'] + 'data_migration_mapping', 'data_migration_plan', 'setup_wizard_slide', 'setup_wizard_settings'] for doctype in document_types: doctype_path = os.path.join(start_path, doctype) if os.path.exists(doctype_path): diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 3deeb02ae4..75a17932ff 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -88,6 +88,9 @@ frappe.Application = Class.extend({ } this.show_update_available(); + if (frappe.boot.is_first_startup) { + this.setup_onboarding_wizard(); + } if(frappe.ui.startup_setup_dialog && !frappe.boot.setup_complete) { frappe.ui.startup_setup_dialog.pre_show(); @@ -486,6 +489,32 @@ frappe.Application = Class.extend({ }); }, + setup_onboarding_wizard: () => { + var me = this; + frappe.call({ + method: "frappe.desk.doctype.setup_wizard_settings.setup_wizard_settings.get_onboarding_slides", + callback: function(r) { + if(r.message) { + let slides = r.message; + if(slides.length) { + frappe.require("assets/frappe/js/frappe/ui/onboarding_dialog.js", () => { + me.progress_dialog = new frappe.setup.OnboardingDialog({ + slides: slides + }); + me.progress_dialog.show(); + frappe.call({ + method: "frappe.desk.page.setup_wizard.setup_wizard.reset_is_first_startup", + args: {}, + callback: () => {} + }); + }); + } + } + }, + freeze: false + }); + }, + setup_analytics: function() { if(window.mixpanel) { window.mixpanel.identify(frappe.session.user); diff --git a/frappe/public/js/frappe/ui/onboarding_dialog.js b/frappe/public/js/frappe/ui/onboarding_dialog.js new file mode 100644 index 0000000000..c9f9896b8e --- /dev/null +++ b/frappe/public/js/frappe/ui/onboarding_dialog.js @@ -0,0 +1,126 @@ +// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +frappe.provide("frappe.setup"); +frappe.provide("frappe.ui"); + +frappe.setup.OnboardingSlide = class OnboardingSlide extends frappe.ui.Slide { + constructor(slide = null) { + super(slide); + } + + make() { + super.make(); + this.$done_state = $(`
+ +
`).appendTo(this.$body); + if(this.help_links) { + this.setup_help_links(); + } + } + + before_show() { + this.$action_button = this.slides_footer.find('.next-btn') + if (this.id === 0) { + this.$action_button.text(__('Start')); + } else { + this.$action_button.text(__('Next')); + } if (this.id === this.parent[0].children.length-1) { + this.slides_footer.find('.complete-btn').removeClass('hide').addClass('btn-primary action primary'); + this.slides_footer.find('.next-btn').removeClass('action primary'); + this.$action_button = this.slides_footer.find('.complete-btn') + } else { + this.slides_footer.find('.complete-btn').removeClass('btn-primary action primary').addClass('hide'); + this.slides_footer.find('.next-btn').addClass('action primary'); + this.$action_button = this.slides_footer.find('.next-btn') + } if (this.slide_type == 'Action') { + this.$action_button.addClass('primary'); + } else if (this.slide_type == 'Info') { + this.$action_button.removeClass('primary'); + } + } + + primary_action() { + let me = this; + if (me.set_values()) { + me.slides_footer.find('.primary').addClass('disabled'); + frappe.call({ + method: me.submit_method, + args: {args_data: me.values}, + callback: function() { + if (me.id === me.parent[0].children.length-1) { + if (me.slides_footer.find('.complete-btn').hasClass('primary')) { + $('.user-progress-dialog').modal('toggle'); + frappe.msgprint({ + message: __('You are all set up!'), + indicator: 'green', + title: __('Success') + }); + } + } + }, + onerror: function() { + me.slides_footer.find('.primary').removeClass('disabled'); + }, + freeze: true + }); + } + } + + unbind_primary_action() { + // unbind only action method as next button is same as create button in this setup wizard + this.slides_footer.find('.action').off('click.primary_action'); + } + + setup_help_links() { + let $help_links = this.$done_state.find('.help-links'); + this.help_links.map(link => { + let $link = $( + `${link.label} + + + ` + ); + if(link.video_id) { + $link.on('click', () => { + frappe.help.show_video(link.video_id, link.label); + }) + } + $help_links.append($link); + }); + $('.help-links').append($help_links); + } +}; + +frappe.setup.OnboardingDialog = class OnboardingDialog { + constructor({ + slides = [] + }) { + this.slides = slides; + this.setup(); + } + + setup() { + this.dialog = new frappe.ui.Dialog({title: __("Let's Onboard!")}); + this.$wrapper = $(this.dialog.$wrapper).addClass('user-progress-dialog'); + this.slide_container = new frappe.ui.Slides({ + parent: this.dialog.body, + slides: this.slides, + slide_class: frappe.setup.OnboardingSlide, + unidirectional: 1, + before_load: ($footer) => { + $footer.find('.prev-btn').addClass('hide'); + $footer.find('.next-btn').removeClass('btn-default').addClass('btn-primary action'); + $footer.find('.text-right').prepend( + $(` + ${__("Complete")}`)); + } + }); + + this.$wrapper.find('.modal-title').prepend(``); + } + + show() { + this.dialog.show(); + } +}; diff --git a/frappe/public/js/frappe/ui/slides.js b/frappe/public/js/frappe/ui/slides.js index 7db8e958ce..d2e504337c 100644 --- a/frappe/public/js/frappe/ui/slides.js +++ b/frappe/public/js/frappe/ui/slides.js @@ -26,7 +26,9 @@ frappe.ui.Slide = class Slide { `).appendTo(this.$wrapper); @@ -35,9 +37,9 @@ frappe.ui.Slide = class Slide { this.$form = this.$body.find(".form"); this.$primary_btn = this.slides_footer.find('.primary'); - if(this.help) this.$content.append($(`

${this.help}

`)); if(this.image_src) this.$content.append( $(``)); + if(this.help) this.$content.append($(`

${this.help}

`)); this.reqd_fields = []; @@ -69,6 +71,8 @@ frappe.ui.Slide = class Slide { this.set_reqd_fields(); } + setup_done_state() {} + // Form methods get_atomic_fields() { var fields = JSON.parse(JSON.stringify(this.fields)); @@ -122,6 +126,7 @@ frappe.ui.Slide = class Slide { if(!field.static) { if(field.label) field.label += ' ' + this.count; } + field.reqd = 0; return field; })); if(this.count === this.max_count) { @@ -163,7 +168,7 @@ frappe.ui.Slide = class Slide { } bind_primary_action() { - this.slides_footer.find(".primary").on('click', () => { + this.slides_footer.find(".primary").on('click.primary_action', () => { this.primary_action(); }); } diff --git a/frappe/public/js/frappe/ui/toolbar/user_progress_dialog.js b/frappe/public/js/frappe/ui/toolbar/user_progress_dialog.js deleted file mode 100644 index 4da50f61f2..0000000000 --- a/frappe/public/js/frappe/ui/toolbar/user_progress_dialog.js +++ /dev/null @@ -1,209 +0,0 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// License: GNU General Public License v3. See license.txt - -frappe.provide("frappe.setup"); -frappe.provide("frappe.ui"); - -frappe.setup.UserProgressSlide = class UserProgressSlide extends frappe.ui.Slide { - constructor(slide = null) { - super(slide); - } - - make() { - super.make(); - } - - setup_done_state() { - this.$body.find(".slide-help").hide(); - this.$body.find(".form-wrapper").hide(); - this.slides_footer.find('.next-btn').addClass('btn-primary'); - this.slides_footer.find('.done-btn').hide(); - this.$primary_btn.hide(); - this.make_done_state(); - } - - make_done_state() { - this.$done_state = $(`
-
-

- -
`).appendTo(this.$body); - - this.$done_state_title = this.$done_state.find('.title'); - this.$check = this.$done_state.find('.check'); - this.$help_links = this.$done_state.find('.help-links'); - - if(this.done_state_title) { - $("" + this.done_state_title + "").appendTo(this.$done_state_title); - this.$done_state_title.on('click', () => { - frappe.set_route(this.done_state_title_route); - }); - } - - if(this.help_links) { - this.help_links.map(link => { - let $link = $(`${link.label}`); - if(link.url) { - $link.attr({"href": link.url}); - } else if(link.video_id) { - $link.on('click', () => { - frappe.help.show_video(link.video_id, link.label); - }) - } - this.$help_links.append($link); - }); - } - - } - - before_show() { - if(this.done) { - this.slides_footer.find('.next-btn').addClass('btn-primary'); - this.slides_footer.find('.done-btn').hide(); - } else { - this.slides_footer.find('.next-btn').removeClass('btn-primary'); - this.slides_footer.find('.done-btn').show(); - } - if(this.dialog_dismissed) { - this.slides_footer.find('.next-btn').removeClass('btn-primary'); - } - } - - primary_action() { - var me = this; - if(this.set_values()) { - this.slides_footer.find('.make-btn').addClass('disabled'); - frappe.call({ - method: me.submit_method, - args: {args_data: me.values}, - callback: function() { - me.done = 1; - me.refresh(); - }, - onerror: function() { - me.slides_footer.find('.make-btn').removeClass('disabled'); - }, - freeze: true - }); - } - } -}; - -frappe.setup.UserProgressDialog = class UserProgressDialog { - constructor({ - slides = [] - }) { - this.slides = slides; - this.progress_state_dict = {}; - this.slides.map(slide => { - this.progress_state_dict[slide.action_name] = slide.done || 0; - }); - this.progress_percent = 0; - this.setup(); - } - - setup() { - this.dialog = new frappe.ui.Dialog({title: __("Complete Setup")}); - this.$wrapper = $(this.dialog.$wrapper).addClass('user-progress-dialog'); - this.slide_container = new frappe.ui.Slides({ - parent: this.dialog.body, - slides: this.slides, - slide_class: frappe.setup.UserProgressSlide, - done_state: 1, - before_load: ($footer) => { - $footer.find('.text-right') - .prepend($(` - ${__("Mark as Done")}`)) - .append($(` - ${__('Create')}`)); - }, - on_update: (completed, total) => { - let percent = completed * 100 / total; - $('.user-progress .progress-bar').css({'width': percent + '%'}); - if(percent === 100) { - this.dismiss_progress(); - } - } - }); - - this.$wrapper.find('.done-btn').on('click', () => { - this.mark_as_done(); - }); - - this.get_and_update_progress_state(); - this.check_for_updates(); - } - - mark_as_done() { - let me = this; - let current_slide = this.slide_container.current_slide; - frappe.call({ - method: current_slide.mark_as_done_method, - args: {action_name: current_slide.action_name}, - callback: function() { - current_slide.done = 1; - current_slide.refresh(); - }, - freeze: true - }); - } - - check_for_updates() { - this.updater = setInterval(() => { - this.get_and_update_progress_state(); - }, 60000); - } - - get_and_update_progress_state() { - var me = this; - frappe.call({ - method: "frappe.desk.user_progress.update_and_get_user_progress", - callback: function(r) { - let states = r.message; - let changed = 0; - let completed = 0; - Object.keys(states).map(action_name => { - if(states[action_name]) { - completed ++; - } - if(me.progress_state_dict[action_name] != states[action_name]) { - changed = 1; - me.progress_state_dict[action_name] = states[action_name]; - } - }); - - if(changed) { - Object.keys(me.slide_container.slide_dict).map((id) => { - let slide = me.slide_container.slide_dict[id]; - if(me.progress_state_dict[slide.action_name]) { - if(!slide.done) { - slide.done = 1; - slide.refresh(); - } - } - }); - - } - me.progress_percent = completed / Object.keys(states).length * 100; - me.update_progress(); - }, - freeze: false - }); - } - - update_progress() { - $('.user-progress .progress-bar').css({'width': this.progress_percent + '%'}); - if(this.progress_percent === 100) { - this.dismiss_progress(); - } - } - - dismiss_progress() { - $('.user-progress').addClass('hide'); - clearInterval(this.updater); - } - - show() { - this.dialog.show(); - } -}; diff --git a/frappe/public/less/desk.less b/frappe/public/less/desk.less index 5a515b6e84..88761aab99 100644 --- a/frappe/public/less/desk.less +++ b/frappe/public/less/desk.less @@ -961,6 +961,24 @@ input[type="checkbox"] { // User Progress Dialog .user-progress-dialog { + + .modal-dialog { + width: 50%; + height: 80%; + max-width: none; + } + + .onboarding-icon { + color: #3246F5; + margin-right: 5px; + } + + .modal-content .slide-container { + height: auto; + min-height: 100%; + bottom: 0; + } + .slides-progress { margin-top: 15px; } diff --git a/frappe/sessions.py b/frappe/sessions.py index ffad4b5e7b..cca40cbc55 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -155,7 +155,7 @@ def get(): bootinfo["disable_async"] = frappe.conf.disable_async bootinfo["setup_complete"] = cint(frappe.db.get_single_value('System Settings', 'setup_complete')) - + bootinfo["is_first_startup"] = cint(frappe.db.get_single_value('System Settings', 'is_first_startup')) return bootinfo From 9536d6d61c61a25862043cb1d41bd6a5f5b22e3a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 25 Nov 2019 12:01:25 +0530 Subject: [PATCH 30/76] feat: contextual null state messages --- frappe/public/js/frappe/list/list_view.js | 21 ++++++++++++++++++--- frappe/public/less/list.less | 17 ++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index d5db15d126..5620469f8a 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -301,18 +301,33 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { this.columns = this.columns.slice(0, column_count); } + get_documentation_link() { + let find_url = this.meta.description.match("url:[^ ]*"); + if (find_url) { + let link = find_url[0].split("url:")[1] + return `Need Help?` + } + + return '' + } + get_no_result_message() { + let help_link = this.get_documentation_link() + let no_result_message = this.filters.length ? __('No {0} found', [__(this.doctype)]) : __('You haven\'t created a {0} yet', [__(this.doctype)]); + let new_button_label = this.filters.length ? __('Create a new {0}', [__(this.doctype)]) : __('Create your first {0}', [__(this.doctype)]); + const new_button = this.can_create ? `

` : ''; return `
- Generic Empty State + Generic Empty State
-

${__('No {0} found', [__(this.doctype)])}

+

${no_result_message}

${new_button} + ${help_link}
`; } diff --git a/frappe/public/less/list.less b/frappe/public/less/list.less index 99ba3e7c22..0df010c08c 100644 --- a/frappe/public/less/list.less +++ b/frappe/public/less/list.less @@ -5,9 +5,20 @@ min-height: ~"calc(100vh - 284px)"; } - .null-state { - height: 12em !important; - width: auto; + .msg-box { + margin-bottom: 2em; + // To compensate for percieved centering + // + .null-state { + height: 12em !important; + width: auto; + } + + .meta-description { + width: 45%; + margin-right: auto; + margin-left: auto; + } } } From 5351bff370a3df37598c460979f1246aabc2cb55 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 25 Nov 2019 12:04:13 +0530 Subject: [PATCH 31/76] fix: code cleanup --- .../setup_wizard_settings.py | 32 ++++---- .../setup_wizard_slide.json | 9 ++- .../setup_wizard_settings.json | 4 +- .../add_a_few_customers.json | 4 +- .../add_a_few_products_you_buy_or_sell.json | 4 +- .../add_a_few_suppliers.json | 3 +- .../__init__.py | 0 .../company_letter_head.json | 50 ++++++++++++ .../company_logo_and_letter_head.json | 50 ------------ .../welcome_to_erpnext!.json | 6 +- frappe/desk/user_progress.py | 30 ------- frappe/public/css/desk.css | 12 +-- frappe/public/js/frappe/desk.js | 6 +- .../public/js/frappe/ui/onboarding_dialog.js | 78 ++++++++++--------- frappe/public/less/desk.less | 27 ++----- 15 files changed, 135 insertions(+), 180 deletions(-) rename frappe/desk/setup_wizard_slide/{company_logo_and_letter_head => company_letter_head}/__init__.py (100%) create mode 100644 frappe/desk/setup_wizard_slide/company_letter_head/company_letter_head.json delete mode 100644 frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json delete mode 100644 frappe/desk/user_progress.py diff --git a/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py index 4a959190a7..7b0ee157d5 100644 --- a/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py +++ b/frappe/desk/doctype/setup_wizard_settings/setup_wizard_settings.py @@ -5,7 +5,6 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document -from erpnext.setup.doctype.setup_progress.setup_progress import get_action_completed_state from frappe.modules import get_module_path, scrub_dt_dn from frappe.modules.export_file import export_to_files, create_init_py @@ -15,7 +14,7 @@ class SetupWizardSettings(Document): return if frappe.local.conf.get('developer_mode'): - record_list =[['Setup Wizard Settings', self.name]] + record_list = [['Setup Wizard Settings', self.name]] for s in self.slide_order: record_list.append(['Setup Wizard Slide', s.slide]) @@ -31,29 +30,18 @@ def get_slide_settings(): slide_settings = frappe.get_single('Setup Wizard Settings') for entry in slide_settings.slide_order: slide_doc = frappe.get_doc('Setup Wizard Slide', entry.slide) - print(frappe.get_installed_apps()) if frappe.scrub(slide_doc.app) in frappe.get_installed_apps(): - domains = get_domains(slide_doc) - help_links = get_help_links(slide_doc) - if slide_doc.slide_type == 'Action': - submit_method = frappe.scrub(slide_doc.app) + '.utilities.onboarding_utils.' + slide_doc.submit_method - else: - submit_method = None - if slide_doc.image_src: - image_src = slide_doc.image_src - else: - image_src = None slides.append(frappe._dict( slide_type = slide_doc.slide_type, title = slide_doc.slide_title, help = slide_doc.slide_desc, - domains = domains, + domains = get_domains(slide_doc), fields = slide_doc.slide_fields, - help_links = help_links, + help_links = get_help_links(slide_doc), add_more = slide_doc.add_more_button, max_count = slide_doc.max_count, - submit_method = submit_method, - image_src= image_src + submit_method = get_submit_method(slide_doc), + image_src = get_slide_image(slide_doc) )) return slides @@ -82,3 +70,13 @@ def get_help_links(slide_doc): 'video_id': link.video_id }) return links + +def get_submit_method(slide_doc): + if slide_doc.slide_type == 'Action': + return frappe.scrub(slide_doc.app) + '.utilities.onboarding_utils.' + slide_doc.submit_method + return None + +def get_slide_image(slide_doc): + if slide_doc.image_src: + return slide_doc.image_src + return None \ No newline at end of file diff --git a/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json index 661a855e39..86defaf69a 100644 --- a/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json +++ b/frappe/desk/doctype/setup_wizard_slide/setup_wizard_slide.json @@ -35,6 +35,7 @@ }, { "depends_on": "eval:doc.slide_type === 'Action';", + "description": "Place this method in {app_name}.utilities.onboarding_utils.{method_name}\nand specify the method name here", "fieldname": "submit_method", "fieldtype": "Data", "label": "Submit Method" @@ -47,6 +48,7 @@ { "default": "3", "depends_on": "add_more_button", + "description": "The amount of times you want to repeat the set of fields (eg: if you want 3 customers in the slide, set this field to 3. Only the first set of fields is shown as mandatory in the slide)", "fieldname": "max_count", "fieldtype": "Int", "label": "Max Count" @@ -74,6 +76,7 @@ "fieldtype": "Section Break" }, { + "description": "Specify in what all domains should the slides show up. If nothing is specified the slide is shown in all domains by default.", "fieldname": "domains", "fieldtype": "Table", "label": "Domains", @@ -85,6 +88,7 @@ "fieldtype": "Column Break" }, { + "description": "Add a help video link just in case user has no idea about what to fill in the slide.", "fieldname": "help_links", "fieldtype": "Table", "label": "Help Links", @@ -96,6 +100,7 @@ "label": "Action Settings" }, { + "description": "If slide type is Action there should be a submit method bound to be executed after the slide is completed.", "fieldname": "slide_type", "fieldtype": "Select", "label": "Slide Type", @@ -128,8 +133,8 @@ "label": "Description" } ], - "modified": "2019-11-21 17:35:30.846135", - "modified_by": "ruchamahabal2@gmail.com", + "modified": "2019-11-25 10:11:12.710078", + "modified_by": "Administrator", "module": "Desk", "name": "Setup Wizard Slide", "owner": "Administrator", diff --git a/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json b/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json index 7b77beb2b6..3ebd637633 100644 --- a/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json +++ b/frappe/desk/setup_wizard_settings/setup_wizard_settings/setup_wizard_settings.json @@ -3,8 +3,8 @@ "docstatus": 0, "doctype": "Setup Wizard Settings", "idx": "0", - "modified": "2019-11-21 12:38:33.839741", - "modified_by": "ruchamahabal2@gmail.com", + "modified": "2019-11-25 11:50:27.156173", + "modified_by": "Administrator", "name": "Setup Wizard Settings", "owner": "Administrator", "slide_order": [ diff --git a/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json b/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json index f2b90d231b..8a7827226f 100644 --- a/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json +++ b/frappe/desk/setup_wizard_slide/add_a_few_customers/add_a_few_customers.json @@ -25,11 +25,13 @@ } ], "idx": 0, + "image_src": "/assets/erpnext/images/illustrations/customer.png", "max_count": 3, - "modified": "2019-11-21 10:54:10.736056", + "modified": "2019-11-25 11:05:51.169841", "modified_by": "Administrator", "name": "Add A Few Customers", "owner": "Administrator", + "slide_desc": "", "slide_fields": [ { "align": "", diff --git a/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json b/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json index 5946a13624..ce5df244d2 100644 --- a/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json +++ b/frappe/desk/setup_wizard_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json @@ -22,8 +22,8 @@ "idx": 0, "image_src": "/assets/erpnext/images/illustrations/product.png", "max_count": 3, - "modified": "2019-11-21 12:31:57.487975", - "modified_by": "ruchamahabal2@gmail.com", + "modified": "2019-11-22 13:16:32.222715", + "modified_by": "Administrator", "name": "Add A Few Products You Buy Or Sell", "owner": "Administrator", "slide_desc": "", diff --git a/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json b/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json index 0d72f532e6..d156ceff79 100644 --- a/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json +++ b/frappe/desk/setup_wizard_slide/add_a_few_suppliers/add_a_few_suppliers.json @@ -25,8 +25,9 @@ } ], "idx": 0, + "image_src": "/assets/erpnext/images/illustrations/supplier.png", "max_count": 3, - "modified": "2019-11-21 10:54:03.196745", + "modified": "2019-11-25 11:05:58.390770", "modified_by": "Administrator", "name": "Add A Few Suppliers", "owner": "Administrator", diff --git a/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/__init__.py b/frappe/desk/setup_wizard_slide/company_letter_head/__init__.py similarity index 100% rename from frappe/desk/setup_wizard_slide/company_logo_and_letter_head/__init__.py rename to frappe/desk/setup_wizard_slide/company_letter_head/__init__.py diff --git a/frappe/desk/setup_wizard_slide/company_letter_head/company_letter_head.json b/frappe/desk/setup_wizard_slide/company_letter_head/company_letter_head.json new file mode 100644 index 0000000000..cb8e1f9aac --- /dev/null +++ b/frappe/desk/setup_wizard_slide/company_letter_head/company_letter_head.json @@ -0,0 +1,50 @@ +{ + "add_more_button": 0, + "app": "ERPNext", + "creation": "2019-11-22 13:25:42.892593", + "docstatus": 0, + "doctype": "Setup Wizard Slide", + "domains": [ + { + "domain": "Services" + }, + { + "domain": "Manufacturing" + }, + { + "domain": "Retail" + }, + { + "domain": "Distribution" + } + ], + "help_links": [ + { + "label": "Know more about printing and branding through letterhead", + "video_id": "cKZHcx1znMc" + } + ], + "idx": 0, + "image_src": "/assets/erpnext/images/illustrations/letterhead.png", + "max_count": 0, + "modified": "2019-11-25 11:06:07.152239", + "modified_by": "Administrator", + "name": "Company Letter Head", + "owner": "Administrator", + "slide_desc": "Attach Letterhead:(Keep it web friendly as 1024px by 128px)", + "slide_fields": [ + { + "align": "center", + "fieldname": "letterhead", + "fieldtype": "Attach Image", + "label": "Attach Letterhead", + "max_length": 0, + "max_value": 0, + "options": "image", + "reqd": 0 + } + ], + "slide_title": "Company Letter Head", + "slide_type": "Action", + "submit_method": "create_letterhead" +} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json b/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json deleted file mode 100644 index 6357db09b1..0000000000 --- a/frappe/desk/setup_wizard_slide/company_logo_and_letter_head/company_logo_and_letter_head.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "add_more_button": 0, - "creation": "2019-11-13 16:03:01.422088", - "docstatus": 0, - "doctype": "Setup Wizard Slide", - "domains": [ - { - "domain": "Services" - }, - { - "domain": "Manufacturing" - }, - { - "domain": "Retail" - }, - { - "domain": "Distribution" - } - ], - "help_links": [], - "idx": 0, - "max_count": 0, - "modified": "2019-11-19 13:35:05.379455", - "modified_by": "Administrator", - "name": "Company Logo and Letter Head", - "owner": "Administrator", - "slide_fields": [ - { - "align": "center", - "fieldname": "attach_logo", - "fieldtype": "Attach Image", - "label": "Attach Logo", - "max_length": 0, - "max_value": 0, - "options": "image", - "reqd": 0 - }, - { - "align": "center", - "fieldname": "attach_letterhead", - "fieldtype": "Attach Image", - "label": "Attach Letterhead", - "max_length": 0, - "max_value": 0, - "options": "image", - "reqd": 0 - } - ], - "slide_title": "Company Logo and Letter Head" -} \ No newline at end of file diff --git a/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json b/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json index 78ae0e615c..0daeb51da0 100644 --- a/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json +++ b/frappe/desk/setup_wizard_slide/welcome_to_erpnext!/welcome_to_erpnext!.json @@ -23,10 +23,10 @@ ], "help_links": [], "idx": 0, - "image_src": "/assets/erpnext/images/illustrations/handshake.png", + "image_src": "/assets/erpnext/images/illustrations/onboard.png", "max_count": 0, - "modified": "2019-11-21 12:31:45.856754", - "modified_by": "ruchamahabal2@gmail.com", + "modified": "2019-11-25 09:58:53.284461", + "modified_by": "Administrator", "name": "Welcome to ERPNext!", "owner": "Administrator", "slide_desc": "Setting up an ERP can be overwhelming. But don't worry, we have got your back!
\nLet's setup your company.\nThis wizard will help you onboard to ERPNext in a short time!", diff --git a/frappe/desk/user_progress.py b/frappe/desk/user_progress.py deleted file mode 100644 index f62bb2a29d..0000000000 --- a/frappe/desk/user_progress.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt - -from __future__ import unicode_literals - -import frappe -from frappe.utils import cint - -@frappe.whitelist() -def get_user_progress_slides(): - ''' - Return user progress slides for the desktop (called via `get_user_progress_slides` hook) - ''' - slides = [] - if cint(frappe.db.get_single_value('System Settings', 'setup_complete')): - for fn in frappe.get_hooks('get_user_progress_slides'): - slides += frappe.get_attr(fn)() - - return slides - -@frappe.whitelist() -def update_and_get_user_progress(): - ''' - Return setup progress action states (called via `update_and_get_user_progress` hook) - ''' - states = {} - for fn in frappe.get_hooks('update_and_get_user_progress'): - states.update(frappe.get_attr(fn)()) - - return states diff --git a/frappe/public/css/desk.css b/frappe/public/css/desk.css index 8e43be12ea..6ddf93df6a 100644 --- a/frappe/public/css/desk.css +++ b/frappe/public/css/desk.css @@ -1158,16 +1158,6 @@ input[type="checkbox"]:focus { color: #fff; border-color: #b1bdca; } -.user-progress-dialog .slides-progress { +.onboarding-dialog .slides-progress { margin-top: 15px; } -.user-progress-dialog .done-state .check-container { - font-size: 64px; - margin: 40px; -} -.user-progress-dialog .done-state .title { - font-weight: normal; -} -.user-progress-dialog .done-state .help-links a { - margin: 0px 10px; -} diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 75a17932ff..4f5c0027b4 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -88,9 +88,9 @@ frappe.Application = Class.extend({ } this.show_update_available(); - if (frappe.boot.is_first_startup) { - this.setup_onboarding_wizard(); - } + // if (frappe.boot.is_first_startup) { + this.setup_onboarding_wizard(); + // } if(frappe.ui.startup_setup_dialog && !frappe.boot.setup_complete) { frappe.ui.startup_setup_dialog.pre_show(); diff --git a/frappe/public/js/frappe/ui/onboarding_dialog.js b/frappe/public/js/frappe/ui/onboarding_dialog.js index c9f9896b8e..792080a0c7 100644 --- a/frappe/public/js/frappe/ui/onboarding_dialog.js +++ b/frappe/public/js/frappe/ui/onboarding_dialog.js @@ -11,52 +11,47 @@ frappe.setup.OnboardingSlide = class OnboardingSlide extends frappe.ui.Slide { make() { super.make(); - this.$done_state = $(`
- -
`).appendTo(this.$body); - if(this.help_links) { + this.$next_btn = this.slides_footer.find('.next-btn'); + this.$complete_btn = this.slides_footer.find('.complete-btn'); + this.$action_button = this.slides_footer.find('.next-btn') + if (this.help_links) { + this.$help_links = $(`
+ +
`).appendTo(this.$body); this.setup_help_links(); } } before_show() { - this.$action_button = this.slides_footer.find('.next-btn') - if (this.id === 0) { - this.$action_button.text(__('Start')); - } else { - this.$action_button.text(__('Next')); - } if (this.id === this.parent[0].children.length-1) { - this.slides_footer.find('.complete-btn').removeClass('hide').addClass('btn-primary action primary'); - this.slides_footer.find('.next-btn').removeClass('action primary'); - this.$action_button = this.slides_footer.find('.complete-btn') - } else { - this.slides_footer.find('.complete-btn').removeClass('btn-primary action primary').addClass('hide'); - this.slides_footer.find('.next-btn').addClass('action primary'); - this.$action_button = this.slides_footer.find('.next-btn') - } if (this.slide_type == 'Action') { - this.$action_button.addClass('primary'); - } else if (this.slide_type == 'Info') { - this.$action_button.removeClass('primary'); + (this.id === 0) ? + this.$next_btn.text(__('Start')) : this.$next_btn.text(__('Next')); + //last slide + if (this.id === this.parent[0].children.length-1) { + this.$complete_btn.removeClass('hide').addClass('action primary'); + this.$next_btn.removeClass('action primary'); + this.$action_button = this.$complete_btn; } + this.setup_action_button(); } primary_action() { let me = this; - if (me.set_values()) { - me.slides_footer.find('.primary').addClass('disabled'); + if (this.set_values()) { + this.$action_button.addClass('disabled'); + if (me.add_more) { + me.values.append('max_count', cint(me.max_count)); + } frappe.call({ method: me.submit_method, args: {args_data: me.values}, callback: function() { if (me.id === me.parent[0].children.length-1) { - if (me.slides_footer.find('.complete-btn').hasClass('primary')) { - $('.user-progress-dialog').modal('toggle'); - frappe.msgprint({ - message: __('You are all set up!'), - indicator: 'green', - title: __('Success') - }); - } + $('.onboarding-dialog').modal('toggle'); + frappe.msgprint({ + message: __('You are all set up!'), + indicator: 'green', + title: __('Success') + }); } }, onerror: function() { @@ -69,11 +64,10 @@ frappe.setup.OnboardingSlide = class OnboardingSlide extends frappe.ui.Slide { unbind_primary_action() { // unbind only action method as next button is same as create button in this setup wizard - this.slides_footer.find('.action').off('click.primary_action'); + this.$action_button.off('click.primary_action'); } setup_help_links() { - let $help_links = this.$done_state.find('.help-links'); this.help_links.map(link => { let $link = $( `${link.label} @@ -86,9 +80,13 @@ frappe.setup.OnboardingSlide = class OnboardingSlide extends frappe.ui.Slide { frappe.help.show_video(link.video_id, link.label); }) } - $help_links.append($link); + this.$help_links.append($link); }); - $('.help-links').append($help_links); + } + + setup_action_button() { + (this.slide_type == 'Action') ? + this.$action_button.addClass('primary') : this.$action_button.removeClass('primary'); } }; @@ -102,7 +100,7 @@ frappe.setup.OnboardingDialog = class OnboardingDialog { setup() { this.dialog = new frappe.ui.Dialog({title: __("Let's Onboard!")}); - this.$wrapper = $(this.dialog.$wrapper).addClass('user-progress-dialog'); + this.$wrapper = $(this.dialog.$wrapper).addClass('onboarding-dialog'); this.slide_container = new frappe.ui.Slides({ parent: this.dialog.body, slides: this.slides, @@ -112,12 +110,16 @@ frappe.setup.OnboardingDialog = class OnboardingDialog { $footer.find('.prev-btn').addClass('hide'); $footer.find('.next-btn').removeClass('btn-default').addClass('btn-primary action'); $footer.find('.text-right').prepend( - $(` + $(` ${__("Complete")}`)); } }); - this.$wrapper.find('.modal-title').prepend(``); + this.$wrapper.find('.modal-title').prepend( + ` + + ` + ); } show() { diff --git a/frappe/public/less/desk.less b/frappe/public/less/desk.less index 88761aab99..d8f5a141f4 100644 --- a/frappe/public/less/desk.less +++ b/frappe/public/less/desk.less @@ -959,8 +959,8 @@ input[type="checkbox"] { } } -// User Progress Dialog -.user-progress-dialog { +// Onboarding Dialog +.onboarding-dialog { .modal-dialog { width: 50%; @@ -979,26 +979,13 @@ input[type="checkbox"] { bottom: 0; } - .slides-progress { - margin-top: 15px; + img { + max-width: 128px; + max-height: 128px; } - .done-state { - .check-container { - font-size: 64px; - margin: 40px; - } - - .title { - font-weight: normal; - } - - .help-links { - - a { - margin: 0px 10px; - } - } + .slides-progress { + margin-top: 15px; } } From 3da534546d44895951cd4661843ce6188cb19543 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 25 Nov 2019 12:06:46 +0530 Subject: [PATCH 32/76] fix: show setup wizard on first startup --- frappe/public/js/frappe/desk.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 4f5c0027b4..75a17932ff 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -88,9 +88,9 @@ frappe.Application = Class.extend({ } this.show_update_available(); - // if (frappe.boot.is_first_startup) { - this.setup_onboarding_wizard(); - // } + if (frappe.boot.is_first_startup) { + this.setup_onboarding_wizard(); + } if(frappe.ui.startup_setup_dialog && !frappe.boot.setup_complete) { frappe.ui.startup_setup_dialog.pre_show(); From b5e7ce06628689cd1fd46fb9d98edeeb4a89b846 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 25 Nov 2019 12:09:09 +0530 Subject: [PATCH 33/76] feat: allow changing empty_state --- frappe/public/js/frappe/list/list_view.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 5620469f8a..5bc00ddd97 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -302,12 +302,13 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } get_documentation_link() { - let find_url = this.meta.description.match("url:[^ ]*"); - if (find_url) { - let link = find_url[0].split("url:")[1] - return `Need Help?` + if (this.meta.description) { + let find_url = this.meta.description.match("url:[^ ]*"); + if (find_url) { + let link = find_url[0].split("url:")[1] + return `Need Help?` + } } - return '' } @@ -315,6 +316,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { let help_link = this.get_documentation_link() let no_result_message = this.filters.length ? __('No {0} found', [__(this.doctype)]) : __('You haven\'t created a {0} yet', [__(this.doctype)]); let new_button_label = this.filters.length ? __('Create a new {0}', [__(this.doctype)]) : __('Create your first {0}', [__(this.doctype)]); + let empty_state_image = this.settings.empty_state_image || '/assets/frappe/images/ui-states/empty.png' const new_button = this.can_create ? `

+ + +

@@ -12,7 +18,7 @@ {%= __("Letter Head") %} -
+
From ae0bf912c7873ac67697673122ac74c8fedd3d1e Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 28 Nov 2019 16:34:41 +0530 Subject: [PATCH 58/76] fix: Add label to standard print format sections data-label is now added to each section so they can be targeted via CSS --- frappe/templates/print_formats/standard.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/templates/print_formats/standard.html b/frappe/templates/print_formats/standard.html index 5e707cddcb..77b8f09bb7 100644 --- a/frappe/templates/print_formats/standard.html +++ b/frappe/templates/print_formats/standard.html @@ -21,7 +21,7 @@ {% endif %} {% for section in page %} -
+
{%- if doc.print_line_breaks and loop.index != 1 -%}
{%- endif -%} {%- if doc.print_section_headings and section.label and section.has_data -%}

{{ _(section.label) }}

From 44c7f08ccf205d86338a86daebfbf4b377f050d7 Mon Sep 17 00:00:00 2001 From: Shridhar Date: Thu, 28 Nov 2019 18:07:14 +0530 Subject: [PATCH 59/76] convert operator to lowercase while checking --- frappe/model/db_query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 41a186688b..64e242d6af 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -513,7 +513,7 @@ class DatabaseQuery(object): or not can_be_null or (f.value and f.operator.lower() in ('=', 'like')) or 'ifnull(' in column_name.lower()): - if f.operator == 'like' and frappe.conf.get('db_type') == 'postgres': + if f.operator.lower() == 'like' and frappe.conf.get('db_type') == 'postgres': f.operator = 'ilike' condition = '{column_name} {operator} {value}'.format( column_name=column_name, operator=f.operator, From 29fcd291b194df2ff235daf984184ec8161945e6 Mon Sep 17 00:00:00 2001 From: "Chinmay D. Pai" Date: Fri, 29 Nov 2019 13:18:48 +0530 Subject: [PATCH 60/76] fix(reportview): convert to unicode conditionally fixes TypeError: decoding str is not supported Traceback (most recent call last): File "/home/frappe/frappe-bench/apps/frappe/frappe/app.py", line 57, in application response = frappe.handler.handle() File "/home/frappe/frappe-bench/apps/frappe/frappe/handler.py", line 22, in handle data = execute_cmd(cmd) File "/home/frappe/frappe-bench/apps/frappe/frappe/handler.py", line 61, in execute_cmd return frappe.call(method, **frappe.form_dict) File "/home/frappe/frappe-bench/apps/frappe/frappe/__init__.py", line 1038, in call return fn(*args, **newargs) File "/home/frappe/frappe-bench/apps/frappe/frappe/__init__.py", line 511, in wrapper_fn retval = fn(*args, **get_newargs(fn, kwargs)) File "/home/frappe/frappe-bench/apps/frappe/frappe/desk/reportview.py", line 177, in export_query frappe.response['result'] = text_type(f.read(), 'utf-8') TypeError: decoding str is not supported Signed-off-by: Chinmay D. Pai --- frappe/desk/reportview.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 937285206e..2a816293fb 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -9,8 +9,10 @@ from six.moves import range import frappe.permissions from frappe.model.db_query import DatabaseQuery from frappe import _ -from six import text_type, string_types, StringIO +from six import string_types, StringIO from frappe.core.doctype.access_log.access_log import make_access_log +from frappe.utils import cstr + @frappe.whitelist() @frappe.read_only() @@ -174,7 +176,7 @@ def export_query(): if isinstance(v, string_types) else v for v in r]) f.seek(0) - frappe.response['result'] = text_type(f.read(), 'utf-8') + frappe.response['result'] = cstr(f.read()) frappe.response['type'] = 'csv' frappe.response['doctype'] = title From 56b038584157911df8d058fd711d5c0665d94800 Mon Sep 17 00:00:00 2001 From: "Chinmay D. Pai" Date: Fri, 29 Nov 2019 16:25:31 +0530 Subject: [PATCH 61/76] chore: remove useless encode Signed-off-by: Chinmay D. Pai --- frappe/desk/reportview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 2a816293fb..5db6ae18bf 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -172,7 +172,7 @@ def export_query(): writer = csv.writer(f) for r in data: # encode only unicode type strings and not int, floats etc. - writer.writerow([handle_html(frappe.as_unicode(v)).encode('utf-8') \ + writer.writerow([handle_html(frappe.as_unicode(v)) \ if isinstance(v, string_types) else v for v in r]) f.seek(0) From 1a17c25ca49c30d6fb3fab10c0aa62eee08ad8e5 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 29 Nov 2019 17:00:53 +0530 Subject: [PATCH 62/76] fix: Allow Rename in Website Route Meta --- .../website_route_meta.json | 70 +++---------------- 1 file changed, 8 insertions(+), 62 deletions(-) diff --git a/frappe/website/doctype/website_route_meta/website_route_meta.json b/frappe/website/doctype/website_route_meta/website_route_meta.json index 1ca6a70fa0..bcaee8c8b0 100644 --- a/frappe/website/doctype/website_route_meta/website_route_meta.json +++ b/frappe/website/doctype/website_route_meta/website_route_meta.json @@ -1,97 +1,43 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, + "allow_rename": 1, "autoname": "Prompt", - "beta": 0, "creation": "2019-02-16 17:37:27.918909", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "meta_tags" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "meta_tags", "fieldtype": "Table", - "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": "Meta Tags", - "length": 0, - "no_copy": 0, "options": "Website Meta Tag", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 } ], - "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-02-16 17:37:27.918909", - "modified_by": "Administrator", + "modified": "2019-11-28 17:26:49.068372", + "modified_by": "faris@erpnext.com", "module": "Website", "name": "Website Route Meta", - "name_case": "", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 } ], "quick_entry": 1, - "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 + "track_changes": 1 } \ No newline at end of file From 598deb7c4ac6278985d8d42b7b0bfe5ddb02e7ce Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 29 Nov 2019 17:01:02 +0530 Subject: [PATCH 63/76] fix: Add Visit Web Page button in form --- .../doctype/website_route_meta/website_route_meta.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/website/doctype/website_route_meta/website_route_meta.js b/frappe/website/doctype/website_route_meta/website_route_meta.js index f13963e14f..0e41e92b8e 100644 --- a/frappe/website/doctype/website_route_meta/website_route_meta.js +++ b/frappe/website/doctype/website_route_meta/website_route_meta.js @@ -4,5 +4,12 @@ frappe.ui.form.on('Website Route Meta', { refresh: function(frm) { frm.get_field('__newname').set_label('Route'); + frm.add_custom_button(__('Visit Web Page'), () => { + let route = frm.doc.name; + if (!route.startsWith('/')) { + route = '/' + route; + } + window.open(route, '_blank'); + }); } }); From aa1e698acf1592efa99f0374518912a759f805b8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 28 Nov 2019 08:11:43 +0530 Subject: [PATCH 64/76] chore: cleanup redundant function definitions, sort imports --- frappe/tests/__init__.py | 46 ------------------------------------ frappe/tests/test_website.py | 11 +++------ 2 files changed, 3 insertions(+), 54 deletions(-) diff --git a/frappe/tests/__init__.py b/frappe/tests/__init__.py index dda38c93f9..e69de29bb2 100644 --- a/frappe/tests/__init__.py +++ b/frappe/tests/__init__.py @@ -1,46 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt - -from __future__ import unicode_literals - -import frappe -from werkzeug.wrappers import Request -from werkzeug.test import EnvironBuilder - -def set_request(**kwargs): - builder = EnvironBuilder(**kwargs) - frappe.local.request = Request(builder.get_environ()) - -def insert_test_data(doctype, sort_fn=None): - import frappe.model - data = get_test_doclist(doctype) - if sort_fn: - data = sorted(data, key=sort_fn) - - for doclist in data: - frappe.insert(doclist) - -def get_test_doclist(doctype, name=None): - """get test doclist, collection of doclists""" - import os - from frappe import conf - from frappe.modules.utils import peval_doclist - from frappe.modules import scrub - - doctype = scrub(doctype) - doctype_path = os.path.join(os.path.dirname(os.path.abspath(conf.__file__)), - conf.test_data_path, doctype) - - if name: - with open(os.path.join(doctype_path, scrub(name) + ".json"), 'r') as txtfile: - doclist = peval_doclist(txtfile.read()) - - return doclist - - else: - all_doclists = [] - for fname in filter(lambda n: n.endswith(".json"), os.listdir(doctype_path)): - with open(os.path.join(doctype_path, scrub(fname)), 'r') as txtfile: - all_doclists.append(peval_doclist(txtfile.read())) - - return all_doclists diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index 9efd4d04a1..a8f74dcf0d 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -1,14 +1,11 @@ from __future__ import unicode_literals -import frappe, unittest -from werkzeug.wrappers import Request -from werkzeug.test import EnvironBuilder +import unittest +import frappe from frappe.website import render +from frappe.utils import set_request -def set_request(**kwargs): - builder = EnvironBuilder(**kwargs) - frappe.local.request = Request(builder.get_environ()) def get_html_for_route(route): set_request(method='GET', path=route) @@ -17,7 +14,6 @@ def get_html_for_route(route): return html class TestWebsite(unittest.TestCase): - def test_page_load(self): frappe.set_user('Guest') set_request(method='POST', path='login') @@ -76,4 +72,3 @@ class TestWebsite(unittest.TestCase): delattr(frappe.hooks, 'website_redirects') frappe.cache().delete_key('app_hooks') - From ace5ddad8727cc632ffc2bd70f9341715489cfd0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 29 Nov 2019 18:27:18 +0530 Subject: [PATCH 65/76] fix: next schedule date should be on or after current date --- .../doctype/auto_repeat/auto_repeat.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index a950669d12..27f17a1a62 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -105,10 +105,6 @@ class AutoRepeat(Document): schedule_details = [] start_date = getdate(self.start_date) end_date = getdate(self.end_date) - today = frappe.utils.datetime.date.today() - - if start_date < today: - start_date = today if not self.end_date: start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) @@ -121,7 +117,8 @@ class AutoRepeat(Document): start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) if self.end_date: - start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) + start_date = start_date = get_next_schedule_date( + start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) while (getdate(start_date) < getdate(end_date)): row = { "reference_document" : self.reference_document, @@ -129,7 +126,8 @@ class AutoRepeat(Document): "next_scheduled_date" : start_date } schedule_details.append(row) - start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date) + start_date = start_date = get_next_schedule_date( + start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) return schedule_details @@ -271,18 +269,28 @@ class AutoRepeat(Document): ) -def get_next_schedule_date(start_date, frequency, repeat_on_day, repeat_on_last_day = False, end_date = None): +def get_next_schedule_date(start_date, frequency, repeat_on_day, repeat_on_last_day=False, end_date=None, for_full_schedule=False): month_count = month_map.get(frequency) + day_count = 0 if month_count and repeat_on_last_day: next_date = get_next_date(start_date, month_count, 31) + day_count = 31 + next_date = get_next_date(start_date, month_count, day_count) elif month_count and repeat_on_day: next_date = get_next_date(start_date, month_count, repeat_on_day) + day_count = repeat_on_day + next_date = get_next_date(start_date, month_count, day_count) elif month_count: next_date = get_next_date(start_date, month_count) else: days = 7 if frequency == 'Weekly' else 1 next_date = add_days(start_date, days) + # next schedule date should be after or on current date + if not for_full_schedule: + while getdate(next_date) < getdate(today()): + next_date = get_next_date(next_date, month_count, day_count) + return next_date def get_next_date(dt, mcount, day=None): @@ -307,7 +315,7 @@ def create_repeated_entries(data): current_date = getdate(today()) schedule_date = getdate(doc.next_schedule_date) - while schedule_date <= current_date and not doc.disabled: + if schedule_date == current_date and not doc.disabled: doc.create_documents() schedule_date = get_next_schedule_date(schedule_date, doc.frequency, doc.repeat_on_day, doc.repeat_on_last_day, doc.end_date) From f4b68b4c25af69027dfa7f5f75ca839dec922ee7 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 29 Nov 2019 18:27:42 +0530 Subject: [PATCH 66/76] test: next schedule date --- .../doctype/auto_repeat/test_auto_repeat.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index cb98d4a8fd..95f95f3e7d 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -96,6 +96,21 @@ class TestAutoRepeat(unittest.TestCase): linked_comm = frappe.db.exists("Communication", dict(reference_doctype="ToDo", reference_name=new_todo)) self.assertTrue(linked_comm) + def test_next_schedule_date(self): + current_date = getdate(today()) + todo = frappe.get_doc( + dict(doctype='ToDo', description='test next schedule date todo', assigned_by='Administrator')).insert() + doc = make_auto_repeat(frequency='Monthly', reference_document=todo.name, start_date=add_months(today(), -2)) + + #check next_schedule_date is set as per current date + #it should not be a previous month's date + self.assertEqual(doc.next_schedule_date, current_date) + data = get_auto_repeat_entries(current_date) + create_repeated_entries(data) + docnames = frappe.get_all(doc.reference_doctype, {'auto_repeat': doc.name}) + #the original doc + the repeated doc + self.assertEqual(len(docnames), 2) + def make_auto_repeat(**args): args = frappe._dict(args) From 450f5c378515fdf7177ca38bbc142510cfed221a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 29 Nov 2019 20:26:00 +0530 Subject: [PATCH 67/76] chore: moved 'get_html_for_route' to frappe.utils --- frappe/tests/test_website.py | 6 ------ frappe/utils/__init__.py | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index a8f74dcf0d..d114f7d1b2 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -7,12 +7,6 @@ from frappe.website import render from frappe.utils import set_request -def get_html_for_route(route): - set_request(method='GET', path=route) - response = render.render() - html = frappe.safe_decode(response.get_data()) - return html - class TestWebsite(unittest.TestCase): def test_page_load(self): frappe.set_user('Guest') diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 22a87f612f..e69e6b145b 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -685,3 +685,10 @@ def set_request(**kwargs): from werkzeug.wrappers import Request builder = EnvironBuilder(**kwargs) frappe.local.request = Request(builder.get_environ()) + +def get_html_for_route(route): + from frappe.website.render import render + set_request(method='GET', path=route) + response = render.render() + html = frappe.safe_decode(response.get_data()) + return html \ No newline at end of file From e9a6927039e6b282fc9cc23fe900533b23fdb8de Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 29 Nov 2019 20:27:12 +0530 Subject: [PATCH 68/76] chore: fixed imports after module restructure --- frappe/tests/test_recorder.py | 2 +- frappe/tests/test_sitemap.py | 2 +- frappe/tests/test_twofactor.py | 2 +- frappe/utils/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_recorder.py b/frappe/tests/test_recorder.py index bffddc4ea1..d45ef12e86 100644 --- a/frappe/tests/test_recorder.py +++ b/frappe/tests/test_recorder.py @@ -7,7 +7,7 @@ from __future__ import unicode_literals import unittest import frappe import frappe.recorder -from .test_website import set_request +from frappe.utils import set_request import sqlparse diff --git a/frappe/tests/test_sitemap.py b/frappe/tests/test_sitemap.py index 6f98e3ed48..455a80eb3e 100644 --- a/frappe/tests/test_sitemap.py +++ b/frappe/tests/test_sitemap.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import frappe, unittest -from frappe.tests.test_website import get_html_for_route +from frappe.utils import get_html_for_route class TestSitemap(unittest.TestCase): def test_sitemap(self): diff --git a/frappe/tests/test_twofactor.py b/frappe/tests/test_twofactor.py index 27129d9832..34e12851f7 100644 --- a/frappe/tests/test_twofactor.py +++ b/frappe/tests/test_twofactor.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import unittest, frappe, pyotp from frappe.auth import HTTPRequest from frappe.utils import cint -from frappe.tests import set_request +from frappe.utils import set_request from frappe.auth import validate_ip_address from frappe.twofactor import (should_run_2fa, authenticate_for_2factor, get_cached_user_pass, two_factor_is_enabled_for_, confirm_otp_token, get_otpsecret_for_, get_verification_obj) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index e69e6b145b..82e6ea1b45 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -687,7 +687,7 @@ def set_request(**kwargs): frappe.local.request = Request(builder.get_environ()) def get_html_for_route(route): - from frappe.website.render import render + from frappe.website import render set_request(method='GET', path=route) response = render.render() html = frappe.safe_decode(response.get_data()) From 5a2c2b8ec01c357756f5a9fd1f774c3ef75d5ff2 Mon Sep 17 00:00:00 2001 From: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Date: Sat, 30 Nov 2019 17:19:00 +0530 Subject: [PATCH 69/76] fix: Change modified user --- .../doctype/website_route_meta/website_route_meta.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/doctype/website_route_meta/website_route_meta.json b/frappe/website/doctype/website_route_meta/website_route_meta.json index bcaee8c8b0..6e1672c379 100644 --- a/frappe/website/doctype/website_route_meta/website_route_meta.json +++ b/frappe/website/doctype/website_route_meta/website_route_meta.json @@ -18,7 +18,7 @@ } ], "modified": "2019-11-28 17:26:49.068372", - "modified_by": "faris@erpnext.com", + "modified_by": "Administrator", "module": "Website", "name": "Website Route Meta", "owner": "Administrator", @@ -40,4 +40,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} From 9f3b0b59fc8c1f3c619ade7b1159e03f987db584 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 1 Dec 2019 11:41:11 +0530 Subject: [PATCH 70/76] test: Fix listview test by waiting for clear-cache --- cypress/support/commands.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 464cbbe1d5..5f6d917998 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -162,9 +162,15 @@ Cypress.Commands.add('go_to_list', (doctype) => { }); Cypress.Commands.add('clear_cache', () => { + cy.server(); + cy.route({ + method: 'POST', + url: 'frappe.sessions.clear' + }).as('clear-cache'); cy.window().its('frappe').then(frappe => { frappe.ui.toolbar.clear_cache(); }); + cy.wait(['@clear-cache']); }); Cypress.Commands.add('dialog', (opts) => { From d41ca958aba27408fcb83990f0b3a1fea28a6f28 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 1 Dec 2019 12:36:21 +0530 Subject: [PATCH 71/76] test: Move wait to list_view.js --- cypress/integration/list_view.js | 6 ++++++ cypress/support/commands.js | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 20a973c1dd..ab2d4a2d86 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -5,7 +5,13 @@ context('List View', () => { cy.window().its('frappe').then(frappe => { frappe.call("frappe.tests.ui_test_helpers.setup_workflow"); }); + cy.server(); + cy.route({ + method: 'POST', + url: 'frappe.sessions.clear' + }).as('clear-cache'); cy.clear_cache(); + cy.wait(['@clear-cache']); }); it('enables "Actions" button', () => { const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Print', 'Delete']; diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 5f6d917998..464cbbe1d5 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -162,15 +162,9 @@ Cypress.Commands.add('go_to_list', (doctype) => { }); Cypress.Commands.add('clear_cache', () => { - cy.server(); - cy.route({ - method: 'POST', - url: 'frappe.sessions.clear' - }).as('clear-cache'); cy.window().its('frappe').then(frappe => { frappe.ui.toolbar.clear_cache(); }); - cy.wait(['@clear-cache']); }); Cypress.Commands.add('dialog', (opts) => { From 7629f07dbf8265b963e8cbc09e4a3c95cd628b0b Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 1 Dec 2019 13:32:28 +0530 Subject: [PATCH 72/76] test: Try removing clear-cache --- cypress/integration/list_view.js | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index ab2d4a2d86..d6627ea9c4 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -2,16 +2,9 @@ context('List View', () => { before(() => { cy.login(); cy.visit('/desk'); - cy.window().its('frappe').then(frappe => { - frappe.call("frappe.tests.ui_test_helpers.setup_workflow"); + return cy.window().its('frappe').then(frappe => { + return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow"); }); - cy.server(); - cy.route({ - method: 'POST', - url: 'frappe.sessions.clear' - }).as('clear-cache'); - cy.clear_cache(); - cy.wait(['@clear-cache']); }); it('enables "Actions" button', () => { const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Print', 'Delete']; From 1aa7824af5437078458ff403fc5a1d4f46a1f841 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 2 Dec 2019 13:05:46 +0530 Subject: [PATCH 73/76] fix(IntControl): Check for arithmetic expression before eval (#8813) * fix: int eval_expression only checks for comma * fix: get_number_format called for int fields * int control doesn't have get_number_format * fix(Numeric fields): Only match valid arithmetic expressions --- .../js/frappe/form/controls/currency.js | 17 +------------- .../public/js/frappe/form/controls/float.js | 4 ++-- frappe/public/js/frappe/form/controls/int.js | 22 +++++++++---------- 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/currency.js b/frappe/public/js/frappe/form/controls/currency.js index 196ae5fd43..4d6b37aede 100644 --- a/frappe/public/js/frappe/form/controls/currency.js +++ b/frappe/public/js/frappe/form/controls/currency.js @@ -1,21 +1,6 @@ frappe.ui.form.ControlCurrency = frappe.ui.form.ControlFloat.extend({ - eval_expression: function(value) { - if (typeof value ==='string' && value.match(/^[0-9+-/* ]+$/)) { - // Removes seperator - value = strip_number_groups(value, this.get_number_format()); - - try { - return eval(value); - } catch (e) { - return value; - } - } - // If not string - return value; - }, - format_for_input: function(value) { - var formatted_value = format_number(parseFloat(value), this.get_number_format(), this.get_precision()); + var formatted_value = format_number(value, this.get_number_format(), this.get_precision()); return isNaN(parseFloat(value)) ? "" : formatted_value; }, diff --git a/frappe/public/js/frappe/form/controls/float.js b/frappe/public/js/frappe/form/controls/float.js index d32f29e28a..308d970f6e 100644 --- a/frappe/public/js/frappe/form/controls/float.js +++ b/frappe/public/js/frappe/form/controls/float.js @@ -1,7 +1,7 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({ parse: function(value) { value = this.eval_expression(value); - return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision(), + return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision(), // While parsing currency, get_number_format passes currency's number_format // In case of parsing float, it passes global number_format this.get_number_format()); @@ -12,7 +12,7 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({ if (this.df.fieldtype==="Float" && this.df.options && this.df.options.trim()) { number_format = this.get_number_format(); } - var formatted_value = format_number(parseFloat(value), number_format, this.get_precision()); + var formatted_value = format_number(value, number_format, this.get_precision()); return isNaN(parseFloat(value)) ? "" : formatted_value; }, diff --git a/frappe/public/js/frappe/form/controls/int.js b/frappe/public/js/frappe/form/controls/int.js index ead48a996c..5639e5f132 100644 --- a/frappe/public/js/frappe/form/controls/int.js +++ b/frappe/public/js/frappe/form/controls/int.js @@ -20,20 +20,18 @@ frappe.ui.form.ControlInt = frappe.ui.form.ControlData.extend({ }); }, eval_expression: function(value) { - if (typeof value==='string' - && value.match(/^[0-9+-/* ]+$/) - // strings with commas are evaluated incorrectly - // for e.g 47,186.00 -> 186 - && !value.includes(',')) { - try { - return eval(value); - } catch (e) { - // bad expression - return value; + if (typeof value === 'string') { + if (value.match(/^[0-9\+\-\/\* ]+$/)) { + // If it is a string containing operators + try { + return eval(value); + } catch (e) { + // bad expression + return value; + } } - } else { - return value; } + return value; }, parse: function(value) { return cint(this.eval_expression(value), null); From 5aad587a4a89d148c293d49e6ff73e929ebc432a Mon Sep 17 00:00:00 2001 From: Anurag Mishra <32095923+Anurag810@users.noreply.github.com> Date: Mon, 2 Dec 2019 16:33:58 +0530 Subject: [PATCH 74/76] fix: Showing quotes in string (#8904) --- frappe/utils/data.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 8e407736bd..77d43b3778 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -681,13 +681,13 @@ def pretty_date(iso_datetime): else: return '{0} years ago'.format(cint(math.floor(dt_diff_days / 365.0))) -def comma_or(some_list): - return comma_sep(some_list, frappe._("{0} or {1}")) +def comma_or(some_list, add_quotes=True): + return comma_sep(some_list, frappe._("{0} or {1}"), add_quotes) -def comma_and(some_list): - return comma_sep(some_list, frappe._("{0} and {1}")) +def comma_and(some_list ,add_quotes=True): + return comma_sep(some_list, frappe._("{0} and {1}"), add_quotes) -def comma_sep(some_list, pattern): +def comma_sep(some_list, pattern, add_quotes=True): if isinstance(some_list, (list, tuple)): # list(some_list) is done to preserve the existing list some_list = [text_type(s) for s in list(some_list)] @@ -696,7 +696,7 @@ def comma_sep(some_list, pattern): elif len(some_list) == 1: return some_list[0] else: - some_list = ["'%s'" % s for s in some_list] + some_list = ["'%s'" % s for s in some_list] if add_quotes else ["%s" % s for s in some_list] return pattern.format(", ".join(frappe._(s) for s in some_list[:-1]), some_list[-1]) else: return some_list From a8914a2cbd8930a1a65bd5e6ba15426a2aa09b8c Mon Sep 17 00:00:00 2001 From: Ben Knowles Date: Tue, 3 Dec 2019 00:44:11 -0600 Subject: [PATCH 75/76] fix: REST API utf-8 decoding on creates/updates (#8875) * fix: REST API utf-8 decoding on creates/updates Creating or updating a document via the REST API would generate an error of: `TypeError: the JSON object must be str, not 'bytes'` Because get_data() returns bytes which must be explicitly converted to a string before parsing as JSON. Defect introduced by efe94886a and a71a92341e * fix: switch to safe_decode --- frappe/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/api.py b/frappe/api.py index b61c535edd..95a9a408a5 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -82,7 +82,7 @@ def handle(): if frappe.local.request.method=="PUT": if frappe.local.form_dict.data is None: - data = json.loads(frappe.local.request.get_data()) + data = json.loads(frappe.safe_decode(frappe.local.request.get_data())) else: data = json.loads(frappe.local.form_dict.data) doc = frappe.get_doc(doctype, name) @@ -117,7 +117,7 @@ def handle(): if frappe.local.request.method=="POST": if frappe.local.form_dict.data is None: - data = json.loads(frappe.local.request.get_data()) + data = json.loads(frappe.safe_decode(frappe.local.request.get_data())) else: data = json.loads(frappe.local.form_dict.data) data.update({ From 1898b2fb0e6c5f5624ad9021faa7d1c57772444e Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 3 Dec 2019 12:41:04 +0530 Subject: [PATCH 76/76] fix: use hasattr to verify links (#8938) * fix: use hasattr to verify links * fix: verify length explicitly * style: remove unnecessary call to len --- frappe/model/meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 97e526c4e0..927a56b6b8 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -433,7 +433,7 @@ class Meta(Document): def add_doctype_links(self, data): '''add `links` child table in standard link dashboard format''' - if self.links: + if hasattr(self, 'links') and self.links: if not data.transactions: # init groups data.transactions = []