diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..24f122a8d4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Root editor config file +root = true + +# Common settings +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# python, js indentation settings +[{*.py,*.js}] +indent_style = tab +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..26bb7ab280 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Community Forum + url: https://discuss.erpnext.com/ + about: For general QnA, discussions and community help. diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index 3fc14ba61b..08d1d1aa9c 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -21,8 +21,8 @@ def docs_link_exists(body): if word.startswith('http') and uri_validator(word): parsed_url = urlparse(word) if parsed_url.netloc == "github.com": - _, org, repo, _type, ref = parsed_url.path.split('/') - if org == "frappe" and repo in docs_repos: + parts = parsed_url.path.split('/') + if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: return True diff --git a/.travis.yml b/.travis.yml index 63895675ea..2331217363 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,12 +31,12 @@ matrix: - name: "Python 3.7 MariaDB" python: 3.7 env: DB=mariadb TYPE=server - script: bench --site test_site run-tests --coverage + script: bench --verbose --site test_site run-tests --coverage - name: "Python 3.7 PostgreSQL" python: 3.7 env: DB=postgres TYPE=server - script: bench --site test_site run-tests --coverage + script: bench --verbose --site test_site run-tests --coverage - name: "Cypress" python: 3.7 diff --git a/frappe/__init__.py b/frappe/__init__.py index c5f13f2295..9958ae9700 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -148,6 +148,7 @@ def init(site, sites_path=None, new_site=False): "new_site": new_site }) local.rollback_observers = [] + local.before_commit = [] local.test_objects = {} local.site = site @@ -326,7 +327,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, :param is_minimizable: [optional] Allow users to minimize the modal :param wide: [optional] Show wide modal """ - from frappe.utils import encode + from frappe.utils import strip_html_tags msg = safe_decode(msg) out = _dict(message=msg) @@ -348,12 +349,12 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, if as_table and type(msg) in (list, tuple): out.as_table = 1 - + if as_list and type(msg) in (list, tuple) and len(msg) > 1: out.as_list = 1 if flags.print_messages and out.message: - print(f"Message: {repr(out.message).encode('utf-8')}") + print(f"Message: {strip_html_tags(out.message)}") if title: out.title = title @@ -796,11 +797,17 @@ def get_doc(*args, **kwargs): return doc -def get_last_doc(doctype): +def get_last_doc(doctype, filters=None, order_by="creation desc"): """Get last created document of this type.""" - d = get_all(doctype, ["name"], order_by="creation desc", limit_page_length=1) + d = get_all( + doctype, + filters=filters, + limit_page_length=1, + order_by=order_by, + pluck="name" + ) if d: - return get_doc(doctype, d[0].name) + return get_doc(doctype, d[0]) else: raise DoesNotExistError @@ -939,7 +946,11 @@ def get_installed_apps(sort=False, frappe_last=False): connect() if not local.all_apps: - local.all_apps = get_all_apps(True) + local.all_apps = cache().get_value('all_apps', get_all_apps) + + #cache bench apps + if not cache().get_value('all_apps'): + cache().set_value('all_apps', local.all_apps) installed = json.loads(db.get_global("installed_apps") or "[]") diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js index 774befc15e..ee1a076465 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.js +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js @@ -57,7 +57,8 @@ frappe.ui.form.on('Assignment Rule', { frm.set_fields_as_options( 'field', doctype, - (df) => df.fieldtype == 'Link' && df.options == 'User', + (df) => ['Dynamic Link', 'Data'].includes(df.fieldtype) + || (df.fieldtype == 'Link' && df.options == 'User'), [{ label: 'Owner', value: 'owner' }] ); if (doctype) { diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index c85cb149ea..d20398d564 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -82,7 +82,7 @@ class AssignmentRule(Document): elif self.rule == 'Load Balancing': return self.get_user_load_balancing() elif self.rule == 'Based on Field': - return doc.get(self.field) + return self.get_user_based_on_field(doc) def get_user_round_robin(self): ''' @@ -119,6 +119,11 @@ class AssignmentRule(Document): # pick the first user return sorted_counts[0].get('user') + def get_user_based_on_field(self, doc): + val = doc.get(self.field) + if frappe.db.exists('User', val): + return val + def safe_eval(self, fieldname, doc): try: if self.get(fieldname): diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index a11de1d881..c2c84692d8 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -44,6 +44,20 @@ frappe.ui.form.on('Auto Repeat', { // auto repeat schedule frappe.auto_repeat.render_schedule(frm); + + frm.trigger('toggle_submit_on_creation'); + }, + + reference_doctype: function(frm) { + frm.trigger('toggle_submit_on_creation'); + }, + + toggle_submit_on_creation: function(frm) { + // submit on creation checkbox + frappe.model.with_doctype(frm.doc.reference_doctype, () => { + let meta = frappe.get_meta(frm.doc.reference_doctype); + frm.toggle_display('submit_on_creation', meta.is_submittable); + }); }, template: function(frm) { @@ -86,10 +100,7 @@ frappe.ui.form.on('Auto Repeat', { frappe.auto_repeat.render_schedule = function(frm) { if (!frm.is_dirty() && frm.doc.status !== 'Disabled') { - frappe.call({ - method: "get_auto_repeat_schedule", - doc: frm.doc - }).done((r) => { + frm.call("get_auto_repeat_schedule").then(r => { frm.dashboard.wrapper.empty(); frm.dashboard.add_section( frappe.render_template("auto_repeat_schedule", { diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.json b/frappe/automation/doctype/auto_repeat/auto_repeat.json index 8ee6ca1d45..5ff4cbeead 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.json +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "format:AUT-AR-{#####}", @@ -12,6 +13,7 @@ "section_break_3", "reference_doctype", "reference_document", + "submit_on_creation", "column_break_5", "start_date", "end_date", @@ -21,6 +23,8 @@ "repeat_on_last_day", "column_break_12", "next_schedule_date", + "section_break_12", + "repeat_on_days", "notification", "notify_by_email", "recipients", @@ -186,9 +190,28 @@ "fieldname": "repeat_on_last_day", "fieldtype": "Check", "label": "Repeat on Last Day of the Month" + }, + { + "depends_on": "eval:doc.frequency==='Weekly';", + "fieldname": "repeat_on_days", + "fieldtype": "Table", + "label": "Repeat on Days", + "options": "Auto Repeat Day" + }, + { + "depends_on": "eval:doc.frequency==='Weekly';", + "fieldname": "section_break_12", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "submit_on_creation", + "fieldtype": "Check", + "label": "Submit on Creation" } ], - "modified": "2019-07-17 11:30:51.412317", + "links": [], + "modified": "2020-12-10 10:43:13.449172", "modified_by": "Administrator", "module": "Automation", "name": "Auto Repeat", diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index fcf24bf1a9..830af68de7 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ +from datetime import timedelta from frappe.desk.form import assign_to from frappe.utils.jinja import validate_template from dateutil.relativedelta import relativedelta @@ -13,16 +14,19 @@ from frappe.utils import cstr, getdate, split_emails, add_days, today, get_last_ from frappe.model.document import Document from frappe.core.doctype.communication.email import make from frappe.utils.background_jobs import get_jobs +from frappe.automation.doctype.assignment_rule.assignment_rule import get_repeated month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12} - +week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} class AutoRepeat(Document): def validate(self): self.update_status() self.validate_reference_doctype() + self.validate_submit_on_creation() self.validate_dates() self.validate_email_id() + self.validate_auto_repeat_days() self.set_dates() self.update_auto_repeat_id() self.unlink_if_applicable() @@ -48,7 +52,7 @@ class AutoRepeat(Document): if self.disabled: self.next_schedule_date = None else: - self.next_schedule_date = get_next_schedule_date(self.start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, self.end_date) + self.next_schedule_date = self.get_next_schedule_date(schedule_date=self.start_date) def unlink_if_applicable(self): if self.status == 'Completed' or self.disabled: @@ -60,6 +64,11 @@ class AutoRepeat(Document): if not frappe.get_meta(self.reference_doctype).allow_auto_repeat: frappe.throw(_("Enable Allow Auto Repeat for the doctype {0} in Customize Form").format(self.reference_doctype)) + def validate_submit_on_creation(self): + if self.submit_on_creation and not frappe.get_meta(self.reference_doctype).is_submittable: + frappe.throw(_('Cannot enable {0} for a non-submittable doctype').format( + frappe.bold('Submit on Creation'))) + def validate_dates(self): if frappe.flags.in_patch: return @@ -82,6 +91,12 @@ class AutoRepeat(Document): else: frappe.throw(_("'Recipients' not specified")) + def validate_auto_repeat_days(self): + auto_repeat_days = self.get_auto_repeat_days() + if not len(set(auto_repeat_days)) == len(auto_repeat_days): + repeated_days = get_repeated(auto_repeat_days) + frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days))) + def update_auto_repeat_id(self): #check if document is already on auto repeat auto_repeat = frappe.db.get_value(self.reference_doctype, self.reference_document, "auto_repeat") @@ -107,7 +122,7 @@ class AutoRepeat(Document): end_date = getdate(self.end_date) if not self.end_date: - next_date = get_next_schedule_date(start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day) + next_date = self.get_next_schedule_date(schedule_date=start_date) row = { "reference_document": self.reference_document, "frequency": self.frequency, @@ -116,8 +131,7 @@ class AutoRepeat(Document): schedule_details.append(row) if self.end_date: - next_date = get_next_schedule_date( - start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, for_full_schedule=True) + next_date = self.get_next_schedule_date(schedule_date=start_date, for_full_schedule=True) while (getdate(next_date) < getdate(end_date)): row = { @@ -126,8 +140,7 @@ class AutoRepeat(Document): "next_scheduled_date" : next_date } schedule_details.append(row) - next_date = get_next_schedule_date( - next_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) + next_date = self.get_next_schedule_date(schedule_date=next_date, for_full_schedule=True) return schedule_details @@ -150,6 +163,9 @@ class AutoRepeat(Document): self.update_doc(new_doc, reference_doc) new_doc.insert(ignore_permissions = True) + if self.submit_on_creation: + new_doc.submit() + return new_doc def update_doc(self, new_doc, reference_doc): @@ -160,7 +176,7 @@ class AutoRepeat(Document): if new_doc.meta.get_field('auto_repeat'): new_doc.set('auto_repeat', self.name) - for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'remarks', 'owner']: + for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'user_remark', 'remarks', 'owner']: if new_doc.meta.get_field(fieldname): new_doc.set(fieldname, reference_doc.get(fieldname)) @@ -202,6 +218,75 @@ class AutoRepeat(Document): new_doc.set('from_date', from_date) new_doc.set('to_date', to_date) + def get_next_schedule_date(self, schedule_date, for_full_schedule=False): + """ + Returns the next schedule date for auto repeat after a recurring document has been created. + Adds required offset to the schedule_date param and returns the next schedule date. + + :param schedule_date: The date when the last recurring document was created. + :param for_full_schedule: If True, returns the immediate next schedule date, else the full schedule. + """ + if month_map.get(self.frequency): + month_count = month_map.get(self.frequency) + month_diff(schedule_date, self.start_date) - 1 + else: + month_count = 0 + + day_count = 0 + if month_count and self.repeat_on_last_day: + day_count = 31 + next_date = get_next_date(self.start_date, month_count, day_count) + elif month_count and self.repeat_on_day: + day_count = self.repeat_on_day + next_date = get_next_date(self.start_date, month_count, day_count) + elif month_count: + next_date = get_next_date(self.start_date, month_count) + else: + days = self.get_days(schedule_date) + next_date = add_days(schedule_date, days) + + # next schedule date should be after or on current date + if not for_full_schedule: + while getdate(next_date) < getdate(today()): + if month_count: + month_count += month_map.get(self.frequency, 0) + next_date = get_next_date(self.start_date, month_count, day_count) + else: + days = self.get_days(next_date) + next_date = add_days(next_date, days) + + return next_date + + def get_days(self, schedule_date): + if self.frequency == "Weekly": + days = self.get_offset_for_weekly_frequency(schedule_date) + else: + # daily frequency + days = 1 + + return days + + def get_offset_for_weekly_frequency(self, schedule_date): + # if weekdays are not set, offset is 7 from current schedule date + if not self.repeat_on_days: + return 7 + + repeat_on_days = self.get_auto_repeat_days() + current_schedule_day = getdate(schedule_date).weekday() + weekdays = list(week_map.keys()) + + # if repeats on more than 1 day or + # start date's weekday is not in repeat days, then get next weekday + # else offset is 7 + if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days: + weekday = get_next_weekday(current_schedule_day, repeat_on_days) + next_weekday_number = week_map.get(weekday, 0) + # offset for upcoming weekday + return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days + return 7 + + def get_auto_repeat_days(self): + return [d.day for d in self.get('repeat_on_days', [])] + def send_notification(self, new_doc): """Notify concerned people about recurring document generation""" subject = self.subject or '' @@ -282,42 +367,24 @@ class AutoRepeat(Document): ) -def get_next_schedule_date(schedule_date, frequency, start_date, repeat_on_day=None, repeat_on_last_day=False, end_date=None, for_full_schedule=False): - if month_map.get(frequency): - month_count = month_map.get(frequency) + month_diff(schedule_date, start_date) - 1 - else: - month_count = 0 - - day_count = 0 - if month_count and repeat_on_last_day: - day_count = 31 - next_date = get_next_date(start_date, month_count, day_count) - elif month_count and 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(schedule_date, days) - - # next schedule date should be after or on current date - if not for_full_schedule: - while getdate(next_date) < getdate(today()): - if month_count: - month_count += month_map.get(frequency) - next_date = get_next_date(start_date, month_count, day_count) - elif days: - next_date = add_days(next_date, days) - - return next_date - - def get_next_date(dt, mcount, day=None): dt = getdate(dt) dt += relativedelta(months=mcount, day=day) return dt + +def get_next_weekday(current_schedule_day, weekdays): + days = list(week_map.keys()) + if current_schedule_day > 0: + days = days[(current_schedule_day + 1):] + days[:current_schedule_day] + else: + days = days[(current_schedule_day + 1):] + + for entry in days: + if entry in weekdays: + return entry + + #called through hooks def make_auto_repeat_entry(): enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries' @@ -328,6 +395,7 @@ def make_auto_repeat_entry(): data = get_auto_repeat_entries(date) frappe.enqueue(enqueued_method, data=data) + def create_repeated_entries(data): for d in data: doc = frappe.get_doc('Auto Repeat', d.name) @@ -337,10 +405,11 @@ def create_repeated_entries(data): if schedule_date == current_date and not doc.disabled: doc.create_documents() - schedule_date = get_next_schedule_date(schedule_date, doc.frequency, doc.start_date, doc.repeat_on_day, doc.repeat_on_last_day, doc.end_date) + schedule_date = doc.get_next_schedule_date(schedule_date=schedule_date) if schedule_date and not doc.disabled: frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date) + def get_auto_repeat_entries(date=None): if not date: date = getdate(today()) @@ -349,6 +418,7 @@ def get_auto_repeat_entries(date=None): ['status', '=', 'Active'] ]) + #called through hooks def set_auto_repeat_as_completed(): auto_repeat = frappe.get_all("Auto Repeat", filters = {'status': ['!=', 'Disabled']}) @@ -358,6 +428,7 @@ def set_auto_repeat_as_completed(): doc.status = 'Completed' doc.save() + @frappe.whitelist() def make_auto_repeat(doctype, docname, frequency = 'Daily', start_date = None, end_date = None): if not start_date: diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 60fa9cb59e..0d6229cd9e 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -7,10 +7,9 @@ import unittest import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_field -from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries +from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries, week_map from frappe.utils import today, add_days, getdate, add_months - def add_custom_fields(): df = dict( fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', insert_after='sender', @@ -42,6 +41,52 @@ class TestAutoRepeat(unittest.TestCase): self.assertEqual(todo.get('description'), new_todo.get('description')) + def test_weekly_auto_repeat(self): + todo = frappe.get_doc( + dict(doctype='ToDo', description='test weekly todo', assigned_by='Administrator')).insert() + + doc = make_auto_repeat(reference_doctype='ToDo', + frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7)) + + self.assertEqual(doc.next_schedule_date, today()) + data = get_auto_repeat_entries(getdate(today())) + create_repeated_entries(data) + frappe.db.commit() + + todo = frappe.get_doc(doc.reference_doctype, doc.reference_document) + self.assertEqual(todo.auto_repeat, doc.name) + + new_todo = frappe.db.get_value('ToDo', + {'auto_repeat': doc.name, 'name': ('!=', todo.name)}, 'name') + + new_todo = frappe.get_doc('ToDo', new_todo) + + self.assertEqual(todo.get('description'), new_todo.get('description')) + + def test_weekly_auto_repeat_with_weekdays(self): + todo = frappe.get_doc( + dict(doctype='ToDo', description='test auto repeat with weekdays', assigned_by='Administrator')).insert() + + weekdays = list(week_map.keys()) + current_weekday = getdate().weekday() + days = [ + {'day': weekdays[current_weekday]}, + {'day': weekdays[(current_weekday + 2) % 7]} + ] + doc = make_auto_repeat(reference_doctype='ToDo', + frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7), days=days) + + self.assertEqual(doc.next_schedule_date, today()) + data = get_auto_repeat_entries(getdate(today())) + create_repeated_entries(data) + frappe.db.commit() + + todo = frappe.get_doc(doc.reference_doctype, doc.reference_document) + self.assertEqual(todo.auto_repeat, doc.name) + + doc.reload() + self.assertEqual(doc.next_schedule_date, add_days(getdate(), 2)) + def test_monthly_auto_repeat(self): start_date = today() end_date = add_months(start_date, 12) @@ -111,6 +156,25 @@ class TestAutoRepeat(unittest.TestCase): doc = make_auto_repeat(frequency='Daily', reference_document=todo.name, start_date=add_days(today(), -2)) self.assertEqual(getdate(doc.next_schedule_date), current_date) + def test_submit_on_creation(self): + doctype = 'Test Submittable DocType' + create_submittable_doctype(doctype) + + current_date = getdate() + submittable_doc = frappe.get_doc(dict(doctype=doctype, test='test submit on creation')).insert() + submittable_doc.submit() + doc = make_auto_repeat(frequency='Daily', reference_doctype=doctype, reference_document=submittable_doc.name, + start_date=add_days(current_date, -1), submit_on_creation=1) + + data = get_auto_repeat_entries(current_date) + create_repeated_entries(data) + docnames = frappe.db.get_all(doc.reference_doctype, + filters={'auto_repeat': doc.name}, + fields=['docstatus'], + limit=1 + ) + self.assertEquals(docnames[0].docstatus, 1) + def make_auto_repeat(**args): args = frappe._dict(args) @@ -118,13 +182,46 @@ def make_auto_repeat(**args): 'doctype': 'Auto Repeat', 'reference_doctype': args.reference_doctype or 'ToDo', 'reference_document': args.reference_document or frappe.db.get_value('ToDo', 'name'), + 'submit_on_creation': args.submit_on_creation or 0, 'frequency': args.frequency or 'Daily', 'start_date': args.start_date or add_days(today(), -1), 'end_date': args.end_date or "", 'notify_by_email': args.notify or 0, 'recipients': args.recipients or "", 'subject': args.subject or "", - 'message': args.message or "" + 'message': args.message or "", + 'repeat_on_days': args.days or [] }).insert(ignore_permissions=True) return doc + + +def create_submittable_doctype(doctype): + if frappe.db.exists('DocType', doctype): + return + else: + doc = frappe.get_doc({ + 'doctype': 'DocType', + '__newname': doctype, + 'module': 'Custom', + 'custom': 1, + 'is_submittable': 1, + 'fields': [{ + 'fieldname': 'test', + 'label': 'Test', + 'fieldtype': 'Data' + }], + 'permissions': [{ + 'role': 'System Manager', + 'read': 1, + 'write': 1, + 'create': 1, + 'delete': 1, + 'submit': 1, + 'cancel': 1, + 'amend': 1 + }] + }).insert() + + doc.allow_auto_repeat = 1 + doc.save() \ No newline at end of file diff --git a/frappe/integrations/doctype/twilio_number_group/__init__.py b/frappe/automation/doctype/auto_repeat_day/__init__.py similarity index 100% rename from frappe/integrations/doctype/twilio_number_group/__init__.py rename to frappe/automation/doctype/auto_repeat_day/__init__.py diff --git a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json similarity index 51% rename from frappe/integrations/doctype/twilio_number_group/twilio_number_group.json rename to frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json index 9d51e4b452..6f5c3060cd 100644 --- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json @@ -1,32 +1,29 @@ { "actions": [], - "autoname": "field:phone_number", - "creation": "2020-02-24 13:58:58.036914", + "creation": "2020-11-10 22:30:53.690228", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "phone_number" + "day" ], "fields": [ { - "fieldname": "phone_number", - "fieldtype": "Data", + "fieldname": "day", + "fieldtype": "Select", "in_list_view": 1, - "label": "Phone Number", - "options": "Phone", - "show_days": 1, - "show_seconds": 1, - "unique": 1 + "label": "Day", + "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday", + "reqd": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-20 22:48:57.166791", + "modified": "2020-11-10 22:30:53.690228", "modified_by": "Administrator", - "module": "Integrations", - "name": "Twilio Number Group", + "module": "Automation", + "name": "Auto Repeat Day", "owner": "Administrator", "permissions": [], "quick_entry": 1, diff --git a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py similarity index 87% rename from frappe/integrations/doctype/twilio_number_group/twilio_number_group.py rename to frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py index 04cb9ae146..3a7ced1370 100644 --- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class TwilioNumberGroup(Document): +class AutoRepeatDay(Document): pass diff --git a/frappe/build.py b/frappe/build.py index f14b250a92..f47a7cb32b 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -105,7 +105,7 @@ def download_frappe_assets(verbose=True): if frappe_head: try: url = get_assets_link(frappe_head) - click.secho("Retreiving assets...", fg="yellow") + click.secho("Retrieving assets...", fg="yellow") prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head) assets_archive = download_file(url, prefix) print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url)) diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py index 5aa80a85ae..82df6dd127 100644 --- a/frappe/chat/util/util.py +++ b/frappe/chat/util/util.py @@ -1,27 +1,21 @@ from __future__ import unicode_literals +# imports - standard imports +import json +from collections.abc import MutableMapping, MutableSequence, Sequence + # imports - third-party imports import requests - -# imports - compatibility imports -import six - -# imports - standard imports -from collections import Sequence, MutableSequence, Mapping, MutableMapping -if six.PY2: - from urlparse import urlparse # PY2 -else: - from urllib.parse import urlparse # PY3 -import json +from urllib.parse import urlparse # imports - module imports -from frappe.model.document import Document -from frappe.exceptions import DuplicateEntryError -from frappe import _dict import frappe +from frappe.exceptions import DuplicateEntryError +from frappe.model.document import Document session = frappe.session + def get_user_doc(user = None): if isinstance(user, Document): return user @@ -38,12 +32,12 @@ def squashify(what): return what def safe_json_loads(*args): - results = [ ] + results = [] for arg in args: try: arg = json.loads(arg) - except Exception as e: + except Exception: pass results.append(arg) @@ -81,7 +75,7 @@ def dictify(arg): for i, a in enumerate(arg): arg[i] = dictify(a) elif isinstance(arg, MutableMapping): - arg = _dict(arg) + arg = frappe._dict(arg) return arg @@ -113,4 +107,4 @@ def get_emojis(): emojis = resp.json() redis.hset('frappe_emojis', 'emojis', emojis) - return dictify(emojis) \ No newline at end of file + return dictify(emojis) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 38e70534a5..4a631be3ac 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -9,7 +9,7 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import get_site_path, touch_file +from frappe.installer import _new_site @click.command('new-site') @@ -42,57 +42,6 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin if len(frappe.utils.get_sites()) == 1: use(site) -def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None, - admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False, - no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None, - db_port=None, new_site=False): - """Install a new Frappe site""" - - if not force and os.path.exists(site): - print('Site {0} already exists'.format(site)) - sys.exit(1) - - if no_mariadb_socket and not db_type == "mariadb": - print('--no-mariadb-socket requires db_type to be set to mariadb.') - sys.exit(1) - - if not db_name: - import hashlib - db_name = '_' + hashlib.sha1(site.encode()).hexdigest()[:16] - - from frappe.commands.scheduler import _is_scheduler_enabled - from frappe.installer import install_db, make_site_dirs - from frappe.installer import install_app as _install_app - import frappe.utils.scheduler - - frappe.init(site=site) - - try: - - # enable scheduler post install? - enable_scheduler = _is_scheduler_enabled() - except Exception: - enable_scheduler = False - - make_site_dirs() - - installing = touch_file(get_site_path('locks', 'installing.lock')) - - install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name, - admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall, - db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket) - apps_to_install = ['frappe'] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or []) - for app in apps_to_install: - _install_app(app, verbose=verbose, set_as_patched=not source_sql) - - os.remove(installing) - - frappe.utils.scheduler.toggle_scheduler(enable_scheduler) - frappe.db.commit() - - scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled" - print("*** Scheduler is", scheduler_status, "***") - @click.command('restore') @click.argument('sql-file-path') @@ -103,36 +52,45 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N @click.option('--install-app', multiple=True, help='Install app after installation') @click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file') @click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file') -@click.option('--force', is_flag=True, default=False, help='Ignore the site downgrade warning, if applicable') +@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended') @pass_context def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): "Restore site database from an sql file" - from frappe.installer import extract_sql_gzip, extract_files, is_downgrade + from frappe.installer import ( + extract_sql_from_archive, + extract_files, + is_downgrade, + is_partial, + validate_database_sql + ) + force = context.force or force + decompressed_file_name = extract_sql_from_archive(sql_file_path) - # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file - if not os.path.exists(sql_file_path): - base_path = '..' - sql_file_path = os.path.join(base_path, sql_file_path) - if not os.path.exists(sql_file_path): - print('Invalid path {0}'.format(sql_file_path[3:])) - sys.exit(1) - elif sql_file_path.startswith(os.sep): - base_path = os.sep - else: - base_path = '.' + # check if partial backup + if is_partial(decompressed_file_name): + click.secho( + "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.", + fg="red" + ) + click.secho( + "Use `bench partial-restore` to restore a partial backup to an existing site.", + fg="yellow" + ) + sys.exit(1) - if sql_file_path.endswith('sql.gz'): - decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path)) - else: - decompressed_file_name = sql_file_path + # check if valid SQL file + validate_database_sql(decompressed_file_name, _raise=not force) site = get_site(context) frappe.init(site=site) # dont allow downgrading to older versions of frappe without force if not force and is_downgrade(decompressed_file_name, verbose=True): - warn_message = "This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?" + warn_message = ( + "This is not recommended and may lead to unexpected behaviour. " + "Do you want to continue anyway?" + ) click.confirm(warn_message, abort=True) _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, @@ -142,22 +100,39 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas # Extract public and/or private files to the restored site, if user has given the path if with_public_files: - with_public_files = os.path.join(base_path, with_public_files) - public = extract_files(site, with_public_files, 'public') + public = extract_files(site, with_public_files) os.remove(public) if with_private_files: - with_private_files = os.path.join(base_path, with_private_files) - private = extract_files(site, with_private_files, 'private') + private = extract_files(site, with_private_files) os.remove(private) # Removing temporarily created file if decompressed_file_name != sql_file_path: os.remove(decompressed_file_name) - success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "") + success_message = "Site {0} has been restored{1}".format( + site, + " with files" if (with_public_files or with_private_files) else "" + ) click.secho(success_message, fg="green") + +@click.command('partial-restore') +@click.argument('sql-file-path') +@click.option("--verbose", "-v", is_flag=True) +@pass_context +def partial_restore(context, sql_file_path, verbose): + from frappe.installer import partial_restore + verbose = context.verbose or verbose + + site = get_site(context) + frappe.init(site=site) + frappe.connect(site=site) + partial_restore(sql_file_path, verbose) + frappe.destroy() + + @click.command('reinstall') @click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--mariadb-root-username', help='Root username for MariaDB') @@ -222,15 +197,51 @@ def install_app(context, apps): sys.exit(exit_code) -@click.command('list-apps') +@click.command("list-apps") @pass_context def list_apps(context): "List apps in site" - site = get_site(context) - frappe.init(site=site) - frappe.connect() - print("\n".join(frappe.get_installed_apps())) - frappe.destroy() + + def fix_whitespaces(text): + if site == context.sites[-1]: + text = text.rstrip() + if len(context.sites) == 1: + text = text.lstrip() + return text + + for site in context.sites: + frappe.init(site=site) + frappe.connect() + site_title = ( + click.style(f"{site}", fg="green") if len(context.sites) > 1 else "" + ) + apps = frappe.get_single("Installed Applications").installed_applications + + if apps: + name_len, ver_len = [ + max([len(x.get(y)) for x in apps]) + for y in ["app_name", "app_version"] + ] + template = "{{0:{0}}} {{1:{1}}} {{2}}".format(name_len, ver_len) + + installed_applications = [ + template.format(app.app_name, app.app_version, app.git_branch) + for app in apps + ] + applications_summary = "\n".join(installed_applications) + summary = f"{site_title}\n{applications_summary}\n" + + else: + applications_summary = "\n".join(frappe.get_installed_apps()) + summary = f"{site_title}\n{applications_summary}\n" + + summary = fix_whitespaces(summary) + + if applications_summary and summary: + print(summary) + + frappe.destroy() + @click.command('add-system-manager') @click.argument('email') @@ -265,14 +276,12 @@ def disable_user(context, email): user.save(ignore_permissions=True) frappe.db.commit() - @click.command('migrate') @click.option('--skip-failing', is_flag=True, help="Skip patches that fail to run") @click.option('--skip-search-index', is_flag=True, help="Skip search indexing for web documents") @pass_context def migrate(context, skip_failing=False, skip_search_index=False): "Run patches, sync schema and rebuild files/translations" - import compileall import re from frappe.migrate import migrate @@ -291,9 +300,6 @@ def migrate(context, skip_failing=False, skip_search_index=False): if not context.sites: raise SiteNotSpecifiedError - print("Compiling Python files...") - compileall.compile_dir('../apps', quiet=1, rx=re.compile('.*node_modules.*')) - @click.command('migrate-to') @click.argument('frappe_provider') @pass_context @@ -310,15 +316,16 @@ def migrate_to(context, frappe_provider): @click.command('run-patch') @click.argument('module') +@click.option('--force', is_flag=True) @pass_context -def run_patch(context, module): +def run_patch(context, module, force): "Run a particular patch" import frappe.modules.patch_handler for site in context.sites: frappe.init(site=site) try: frappe.connect() - frappe.modules.patch_handler.run_single(module, force=context.force) + frappe.modules.patch_handler.run_single(module, force=force or context.force) finally: frappe.destroy() if not context.sites: @@ -383,16 +390,20 @@ def use(site, sites_path='.'): @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") +@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas") +@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") @click.option('--backup-path', default=None, help="Set path for saving all the files in this operation") @click.option('--backup-path-db', default=None, help="Set path for saving database file") @click.option('--backup-path-files', default=None, help="Set path for saving public file") @click.option('--backup-path-private-files', default=None, help="Set path for saving private file") @click.option('--backup-path-conf', default=None, help="Set path for saving config file") +@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config") @click.option('--verbose', default=False, is_flag=True, help="Add verbosity") @click.option('--compress', default=False, is_flag=True, help="Compress private and public files") @pass_context def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, - backup_path_private_files=None, backup_path_conf=None, verbose=False, compress=False): + backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False, + compress=False, include="", exclude=""): "Backup" from frappe.utils.backups import scheduled_backup verbose = verbose or context.verbose @@ -402,11 +413,27 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac try: frappe.init(site=site) frappe.connect() - odb = scheduled_backup(ignore_files=not with_files, backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, force=True, verbose=verbose, compress=compress) + odb = scheduled_backup( + ignore_files=not with_files, + backup_path=backup_path, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + backup_path_conf=backup_path_conf, + ignore_conf=ignore_backup_conf, + include_doctypes=include, + exclude_doctypes=exclude, + compress=compress, + verbose=verbose, + force=True + ) except Exception: click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") + if verbose: + print(frappe.get_traceback()) exit_code = 1 continue + odb.print_summary() click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green") frappe.destroy() @@ -479,13 +506,14 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= if force: pass else: - click.echo("="*80) - click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site)) - click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n")) - click.echo("Fix the issue and try again.") - click.echo( - "Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site) - ) + messages = [ + "=" * 80, + "Error: The operation has stopped because backup of {0}'s database failed.".format(site), + "Reason: {0}\n".format(str(err)), + "Fix the issue and try again.", + "Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site) + ] + click.echo("\n".join(messages)) sys.exit(1) drop_user_and_database(frappe.conf.db_name, root_login, root_password) @@ -701,5 +729,6 @@ commands = [ stop_recording, add_to_hosts, start_ngrok, - build_search_index + build_search_index, + partial_restore ] diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 8a9c130fbe..3e283e1699 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -56,7 +56,8 @@ class DocType(Document): - Check fieldnames (duplication etc) - Clear permission table for child tables - Add `amended_from` and `amended_by` if Amendable - - Add custom field `auto_repeat` if Repeatable""" + - Add custom field `auto_repeat` if Repeatable + - Check if links point to valid fieldnames""" self.check_developer_mode() @@ -88,6 +89,7 @@ class DocType(Document): self.make_repeatable() self.validate_nestedset() self.validate_website() + self.validate_links_table_fieldnames() if not self.is_new(): self.before_update = frappe.get_doc('DocType', self.name) @@ -288,9 +290,15 @@ class DocType(Document): self.update_fields_to_fetch() - from frappe import conf - allow_doctype_export = frappe.flags.allow_doctype_export or (not frappe.flags.in_test and conf.get('developer_mode')) - if not self.custom and not frappe.flags.in_import and allow_doctype_export: + allow_doctype_export = ( + not self.custom + and not frappe.flags.in_import + and ( + frappe.conf.developer_mode + or frappe.flags.allow_doctype_export + ) + ) + if allow_doctype_export: self.export_doc() self.make_controller_template() @@ -380,19 +388,33 @@ class DocType(Document): if merge: frappe.throw(_("DocType can not be merged")) - # Do not rename and move files and folders for custom doctype - if not self.custom and not frappe.flags.in_test and not frappe.flags.in_patch: - self.rename_files_and_folders(old, new) - def after_rename(self, old, new, merge=False): """Change table name using `RENAME TABLE` if table exists. Or update `doctype` property for Single type.""" + if self.issingle: frappe.db.sql("""update tabSingles set doctype=%s where doctype=%s""", (new, old)) frappe.db.sql("""update tabSingles set value=%s where doctype=%s and field='name' and value = %s""", (new, new, old)) else: - frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) + frappe.db.multisql({ + "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`", + "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`" + }) + frappe.db.commit() + + # Do not rename and move files and folders for custom doctype + if not self.custom: + if not frappe.flags.in_patch: + self.rename_files_and_folders(old, new) + + for site in frappe.utils.get_sites(): + frappe.cache().delete(f"{site}:doctype_classes", old) + + def after_delete(self): + if not self.custom: + for site in frappe.utils.get_sites(): + frappe.cache().delete(f"{site}:doctype_classes", self.name) def rename_files_and_folders(self, old, new): # move files @@ -570,7 +592,8 @@ class DocType(Document): def make_repeatable(self): """If allow_auto_repeat is set, add auto_repeat custom field.""" if self.allow_auto_repeat: - if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.name}): + if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.name}) and \ + not frappe.db.exists('DocField', {'fieldname': 'auto_repeat', 'parent': self.name}): insert_after = self.fields[len(self.fields) - 1].fieldname df = dict(fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', options='Auto Repeat', insert_after=insert_after, read_only=1, no_copy=1, print_hide=1) create_custom_field(self.name, df) @@ -656,6 +679,19 @@ class DocType(Document): if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags): frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError) + def validate_links_table_fieldnames(self): + """Validate fieldnames in Links table""" + if frappe.flags.in_patch: return + if frappe.flags.in_fixtures: return + if not self.links: return + + for index, link in enumerate(self.links): + meta = frappe.get_meta(link.link_doctype) + if not meta.get_field(link.link_fieldname): + message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype)) + frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname")) + + def validate_fields_for_doctype(doctype): doc = frappe.get_doc("DocType", doctype) diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 6f4a400577..10169073e5 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -451,6 +451,33 @@ class TestDocType(unittest.TestCase): test_doc_1.delete() frappe.db.commit() + def test_links_table_fieldname_validation(self): + doc = new_doctype("Test Links Table Validation") + + # check valid data + doc.append("links", { + 'link_doctype': "User", + 'link_fieldname': "first_name" + }) + doc.validate_links_table_fieldnames() # no error + doc.links = [] # reset links table + + # check invalid doctype + doc.append("links", { + 'link_doctype': "User2", + 'link_fieldname': "first_name" + }) + self.assertRaises(frappe.DoesNotExistError, doc.validate_links_table_fieldnames) + doc.links = [] # reset links table + + # check invalid fieldname + doc.append("links", { + 'link_doctype': "User", + 'link_fieldname': "a_field_that_does_not_exists" + }) + self.assertRaises(InvalidFieldNameError, doc.validate_links_table_fieldnames) + + def new_doctype(name, unique=0, depends_on='', fields=None): doc = frappe.get_doc({ "doctype": "DocType", diff --git a/frappe/core/doctype/domain_settings/domain_settings.js b/frappe/core/doctype/domain_settings/domain_settings.js index 1428727993..7178cb4cd6 100644 --- a/frappe/core/doctype/domain_settings/domain_settings.js +++ b/frappe/core/doctype/domain_settings/domain_settings.js @@ -18,6 +18,9 @@ frappe.ui.form.on('Domain Settings', { checked: active_domains.includes(domain) }; }); + }, + on_change: () => { + frm.dirty(); } }, render_input: true diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b8bed89a4d..8614740d26 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -30,7 +30,7 @@ import frappe from frappe import _, conf from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip - +from frappe.utils.image import strip_exif_data class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -93,6 +93,7 @@ class File(Document): self.set_is_private() self.set_file_name() self.validate_duplicate_entry() + self.validate_attachment_limit() self.validate_folder() if not self.file_url and not self.flags.ignore_file_validate: @@ -140,6 +141,26 @@ class File(Document): if self.file_url and (self.is_private != self.file_url.startswith('/private')): frappe.throw(_('Invalid file URL. Please contact System Administrator.')) + def validate_attachment_limit(self): + attachment_limit = 0 + if self.attached_to_doctype and self.attached_to_name: + attachment_limit = cint(frappe.get_meta(self.attached_to_doctype).max_attachments) + + if attachment_limit: + current_attachment_count = len(frappe.get_all('File', filters={ + 'attached_to_doctype': self.attached_to_doctype, + 'attached_to_name': self.attached_to_name, + }, limit=attachment_limit + 1)) + + if current_attachment_count >= attachment_limit: + frappe.throw( + _("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format( + frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name + ), + exc=frappe.exceptions.AttachmentLimitReached, + title=_('Attachment Limit Reached') + ) + def set_folder_name(self): """Make parent folders if not exists based on reference doctype and name""" if self.attached_to_doctype and not self.folder: @@ -435,6 +456,7 @@ class File(Document): def save_file(self, content=None, decode=False, ignore_existing_file_check=False): file_exists = False self.content = content + if decode: if isinstance(content, text_type): self.content = content.encode("utf-8") @@ -445,10 +467,19 @@ class File(Document): if not self.is_private: self.is_private = 0 - self.file_size = self.check_max_file_size() - self.content_hash = get_content_hash(self.content) + self.content_type = mimetypes.guess_type(self.file_name)[0] + + self.file_size = self.check_max_file_size() + + if ( + self.content_type and "image" in self.content_type + and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images") + ): + self.content = strip_exif_data(self.content, self.content_type) + self.content_hash = get_content_hash(self.content) + duplicate_file = None # check if a file exists with the same content hash and is also in the same folder (public or private) @@ -612,7 +643,12 @@ def get_extension(filename, extn, content): return extn def get_local_image(file_url): - file_path = frappe.get_site_path("public", file_url.lstrip("/")) + if file_url.startswith("/private"): + file_url_path = (file_url.lstrip("/"), ) + else: + file_url_path = ("public", file_url.lstrip("/")) + + file_path = frappe.get_site_path(*file_url_path) try: image = Image.open(file_path) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 85397ea1ee..e627558680 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -160,6 +160,31 @@ class TestSameContent(unittest.TestCase): def test_saved_content(self): self.assertFalse(os.path.exists(get_files_path(self.dup_filename))) + def test_attachment_limit(self): + doctype, docname = make_test_doc() + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + limit_property = make_property_setter('ToDo', None, 'max_attachments', 1, 'int', for_doctype=True) + file1 = frappe.get_doc({ + "doctype": "File", + "file_name": 'test-attachment', + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": 'test' + }) + + file1.insert() + + file2 = frappe.get_doc({ + "doctype": "File", + "file_name": 'test-attachment', + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": 'test2' + }) + + self.assertRaises(frappe.exceptions.AttachmentLimitReached, file2.insert) + limit_property.delete() + frappe.clear_cache(doctype='ToDo') def tearDown(self): # File gets deleted on rollback, so blank diff --git a/frappe/core/doctype/module_def/module_def.py b/frappe/core/doctype/module_def/module_def.py index 930c46e60b..7e63572162 100644 --- a/frappe/core/doctype/module_def/module_def.py +++ b/frappe/core/doctype/module_def/module_def.py @@ -43,7 +43,7 @@ class ModuleDef(Document): def on_trash(self): """Delete module name from modules.txt""" - if frappe.flags.in_uninstall or self.custom: + if not frappe.conf.get('developer_mode') or frappe.flags.in_uninstall or self.custom: return modules = None diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 2c02d99dad..1d0d6ebb09 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -89,20 +89,18 @@ def delete_expired_prepared_reports(): 'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)] }) - args = { - 'reports': prepared_reports_to_delete, - 'limit': 50 - } - - enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) + batches = frappe.utils.create_batch(prepared_reports_to_delete, 100) + for batch in batches: + args = { + 'reports': batch, + } + enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) @frappe.whitelist() -def delete_prepared_reports(reports, limit=None): +def delete_prepared_reports(reports): reports = frappe.parse_json(reports) - for index, doc in enumerate(reports): - if limit and index == limit: - return - frappe.delete_doc('Prepared Report', doc['name'], ignore_permissions=True) + for report in reports: + frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True) def create_json_gz_file(data, dt, dn): # Storing data in CSV file causes information loss diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 9d30409a2a..01c32bcb57 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -61,8 +61,9 @@ class Report(Document): def set_doctype_roles(self): if not self.get('roles') and self.is_standard == 'No': meta = frappe.get_meta(self.ref_doctype) - roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0] - self.set('roles', roles) + if not meta.istable: + roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0] + self.set('roles', roles) def is_permitted(self): """Returns true if Has Role is not set or the user is allowed.""" diff --git a/frappe/core/doctype/report_filter/report_filter.json b/frappe/core/doctype/report_filter/report_filter.json index 9d277db11d..964294b96e 100644 --- a/frappe/core/doctype/report_filter/report_filter.json +++ b/frappe/core/doctype/report_filter/report_filter.json @@ -44,7 +44,7 @@ }, { "fieldname": "options", - "fieldtype": "Data", + "fieldtype": "Small Text", "label": "Options" }, { @@ -58,7 +58,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-17 16:15:46.937267", + "modified": "2020-12-05 19:20:00.503097", "modified_by": "Administrator", "module": "Core", "name": "Report Filter", diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index e458b401e4..1920189f78 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -37,7 +37,7 @@ class Role(Document): def get_info_based_on_role(role, field='email'): ''' Get information of all users that have been assigned this role ''' users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"}, - fields=["parent"]) + fields=["parent as user_name"]) return get_user_info(users, field) @@ -45,7 +45,7 @@ def get_user_info(users, field='email'): ''' Fetch details about users for the specified field ''' info_list = [] for user in users: - user_info, enabled = frappe.db.get_value("User", user.parent, [field, "enabled"]) + user_info, enabled = frappe.db.get_value("User", user.get("user_name"), [field, "enabled"]) if enabled and user_info not in ["admin@example.com", "guest@example.com"]: info_list.append(user_info) return info_list diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index 78ef2d0509..a317d69166 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -48,29 +48,33 @@ frappe.ui.form.on('Server Script', { setup_help(frm) { frm.get_field('help_html').html(` -
+Add logic for standard doctype events like Before Insert, After Submit, etc.
+
+
# set property
if "test" in doc.description:
- doc.status = 'Closed'
+ doc.status = 'Closed'
# validate
if "validate" in doc.description:
- raise frappe.ValidationError
+ raise frappe.ValidationError
# auto create another document
-if doc.allocted_to:
- frappe.get_doc(dict(
- doctype = 'ToDo'
- owner = doc.allocated_to,
- description = doc.subject
- )).insert()
-
+if doc.allocated_to:
+ frappe.get_doc(dict(
+ doctype = 'ToDo'
+ owner = doc.allocated_to,
+ description = doc.subject
+ )).insert()
+
+
+
Respond to /api/method/<method-name> calls, just like whitelisted methods
# respond to API
@@ -79,6 +83,21 @@ if frappe.form_dict.message == "ping":
else:
frappe.response['message'] = "ok"
+
+Add conditions to the where clause of list queries.
+
+# generate dynamic conditions and set it in the conditions variable
+tenant_id = frappe.db.get_value(...)
+conditions = 'tenant_id = {}'.format(tenant_id)
+
+# resulting select query
+select name from \`tabPerson\`
+where tenant_id = 2
+order by creation desc
+
`);
}
diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json
index cc3995ad1d..94a48f196c 100644
--- a/frappe/core/doctype/server_script/server_script.json
+++ b/frappe/core/doctype/server_script/server_script.json
@@ -24,17 +24,18 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Script Type",
- "options": "DocType Event\nScheduler Event\nAPI",
+ "options": "DocType Event\nScheduler Event\nPermission Query\nAPI",
"reqd": 1
},
{
"fieldname": "script",
"fieldtype": "Code",
"label": "Script",
+ "options": "Python",
"reqd": 1
},
{
- "depends_on": "eval:doc.script_type==='DocType Event'",
+ "depends_on": "eval:['DocType Event', 'Permission Query'].includes(doc.script_type)",
"fieldname": "reference_doctype",
"fieldtype": "Link",
"in_list_view": 1,
@@ -87,7 +88,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-08-24 16:44:41.060350",
+ "modified": "2020-12-03 22:42:02.708148",
"modified_by": "Administrator",
"module": "Core",
"name": "Server Script",
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index 839b784651..88d68dba14 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -4,6 +4,8 @@
from __future__ import unicode_literals
+import ast
+
import frappe
from frappe.model.document import Document
from frappe.utils.safe_exec import safe_exec
@@ -11,9 +13,9 @@ from frappe import _
class ServerScript(Document):
- @staticmethod
- def validate():
+ def validate(self):
frappe.only_for('Script Manager', True)
+ ast.parse(self.script)
@staticmethod
def on_update():
@@ -41,6 +43,12 @@ class ServerScript(Document):
# wrong report type!
raise frappe.DoesNotExistError
+ def get_permission_query_conditions(self, user):
+ locals = {"user": user, "conditions": ""}
+ safe_exec(self.script, None, locals)
+ if locals["conditions"]:
+ return locals["conditions"]
+
@frappe.whitelist()
def setup_scheduler_events(script_name, frequency):
method = frappe.scrub('{0}-{1}'.format(script_name, frequency))
diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py
index e03504f30b..4dc4f12b34 100644
--- a/frappe/core/doctype/server_script/server_script_utils.py
+++ b/frappe/core/doctype/server_script/server_script_utils.py
@@ -50,6 +50,9 @@ def get_server_script_map():
# },
# '_api': {
# '[path]': '[server script]'
+ # },
+ # 'permission_query': {
+ # 'DocType': '[server script]'
# }
# }
if frappe.flags.in_patch and not frappe.db.table_exists('Server Script'):
@@ -57,16 +60,20 @@ def get_server_script_map():
script_map = frappe.cache().get_value('server_script_map')
if script_map is None:
- script_map = {}
+ script_map = {
+ 'permission_query': {}
+ }
enabled_server_scripts = frappe.get_all('Server Script',
fields=('name', 'reference_doctype', 'doctype_event','api_method', 'script_type'),
filters={'disabled': 0})
for script in enabled_server_scripts:
if script.script_type == 'DocType Event':
script_map.setdefault(script.reference_doctype, {}).setdefault(script.doctype_event, []).append(script.name)
+ elif script.script_type == 'Permission Query':
+ script_map['permission_query'][script.reference_doctype] = script.name
else:
script_map.setdefault('_api', {})[script.api_method] = script.name
frappe.cache().set_value('server_script_map', script_map)
- return script_map
\ No newline at end of file
+ return script_map
diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py
index 3356e584af..957cbbf72d 100644
--- a/frappe/core/doctype/server_script/test_server_script.py
+++ b/frappe/core/doctype/server_script/test_server_script.py
@@ -45,6 +45,22 @@ frappe.response['message'] = 'hello'
allow_guest = 1,
script = '''
frappe.flags = 'hello'
+'''
+ ),
+ dict(
+ name='test_permission_query',
+ script_type = 'Permission Query',
+ reference_doctype = 'ToDo',
+ script = '''
+conditions = '1 = 1'
+'''),
+ dict(
+ name='test_invalid_namespace_method',
+ script_type = 'DocType Event',
+ doctype_event = 'Before Insert',
+ reference_doctype = 'Note',
+ script = '''
+frappe.method_that_doesnt_exist("do some magic")
'''
)
]
@@ -85,3 +101,12 @@ class TestServerScript(unittest.TestCase):
def test_api_return(self):
self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')
+
+ def test_permission_query(self):
+ self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1))
+ self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))
+
+ def test_attribute_error(self):
+ """Raise AttributeError if method not found in Namespace"""
+ note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"})
+ self.assertRaises(AttributeError, note.insert)
diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js
index b6514dea9f..c0c9074cbc 100644
--- a/frappe/core/doctype/system_settings/system_settings.js
+++ b/frappe/core/doctype/system_settings/system_settings.js
@@ -1,37 +1,36 @@
-frappe.ui.form.on("System Settings", "refresh", function(frm) {
- frappe.call({
- method: "frappe.core.doctype.system_settings.system_settings.load",
- callback: function(data) {
- frappe.all_timezones = data.message.timezones;
- frm.set_df_property("time_zone", "options", frappe.all_timezones);
+frappe.ui.form.on("System Settings", {
+ refresh: function(frm) {
+ frappe.call({
+ method: "frappe.core.doctype.system_settings.system_settings.load",
+ callback: function(data) {
+ frappe.all_timezones = data.message.timezones;
+ frm.set_df_property("time_zone", "options", frappe.all_timezones);
- $.each(data.message.defaults, function(key, val) {
- frm.set_value(key, val);
- frappe.sys_defaults[key] = val;
- })
+ $.each(data.message.defaults, function(key, val) {
+ frm.set_value(key, val);
+ frappe.sys_defaults[key] = val;
+ });
+ }
+ });
+ },
+ enable_password_policy: function(frm) {
+ if (frm.doc.enable_password_policy == 0) {
+ frm.set_value("minimum_password_score", "");
+ } else {
+ frm.set_value("minimum_password_score", "2");
}
- });
-});
-
-frappe.ui.form.on("System Settings", "enable_password_policy", function(frm) {
- if(frm.doc.enable_password_policy == 0){
- frm.set_value("minimum_password_score", "");
- } else {
- frm.set_value("minimum_password_score", "2");
- }
-});
-
-frappe.ui.form.on("System Settings", "enable_two_factor_auth", function(frm) {
- if(frm.doc.enable_two_factor_auth == 0){
- frm.set_value("bypass_2fa_for_retricted_ip_users", 0);
- frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
- }
-});
-
-frappe.ui.form.on("System Settings", "enable_prepared_report_auto_deletion", function(frm) {
- if (frm.doc.enable_prepared_report_auto_deletion) {
- if (!frm.doc.prepared_report_expiry_period) {
- frm.set_value('prepared_report_expiry_period', 7);
+ },
+ enable_two_factor_auth: function(frm) {
+ if (frm.doc.enable_two_factor_auth == 0) {
+ frm.set_value("bypass_2fa_for_retricted_ip_users", 0);
+ frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
+ }
+ },
+ enable_prepared_report_auto_deletion: function(frm) {
+ if (frm.doc.enable_prepared_report_auto_deletion) {
+ if (!frm.doc.prepared_report_expiry_period) {
+ frm.set_value('prepared_report_expiry_period', 7);
+ }
}
}
});
diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index 17f97b3e1a..565ee373f1 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -37,6 +37,7 @@
"allow_login_using_mobile_number",
"allow_login_using_user_name",
"allow_error_traceback",
+ "strip_exif_metadata_from_uploaded_images",
"password_settings",
"logout_on_password_reset",
"force_user_to_reset_password",
@@ -356,7 +357,7 @@
"collapsible": 1,
"fieldname": "email",
"fieldtype": "Section Break",
- "label": "EMail"
+ "label": "Email"
},
{
"description": "Your organization name and address for the email footer.",
@@ -460,12 +461,18 @@
"fieldname": "prepared_report_section",
"fieldtype": "Section Break",
"label": "Prepared Report"
+ },
+ {
+ "default": "1",
+ "fieldname": "strip_exif_metadata_from_uploaded_images",
+ "fieldtype": "Check",
+ "label": "Strip EXIF tags from uploaded images"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2020-08-12 14:35:45.214327",
+ "modified": "2020-11-30 18:52:22.161391",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
@@ -483,4 +490,4 @@
"sort_field": "modified",
"sort_order": "ASC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 2c5865fb69..da4026d8fd 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -13,7 +13,7 @@ from frappe.utils.user import get_system_managers
from bs4 import BeautifulSoup
import frappe.permissions
import frappe.share
-
+import frappe.defaults
from frappe.website.utils import is_signup_enabled
from frappe.utils.background_jobs import enqueue
@@ -98,15 +98,20 @@ class User(Document):
self.share_with_self()
clear_notifications(user=self.name)
frappe.clear_cache(user=self.name)
+ now=frappe.flags.in_test or frappe.flags.in_install
self.send_password_notification(self.__new_password)
frappe.enqueue(
'frappe.core.doctype.user.user.create_contact',
user=self,
ignore_mandatory=True,
- now=frappe.flags.in_test or frappe.flags.in_install
+ now=now
)
if self.name not in ('Administrator', 'Guest') and not self.user_image:
- frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name)
+ frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name, now=now)
+
+ # Set user selected timezone
+ if self.time_zone:
+ frappe.defaults.set_default("time_zone", self.time_zone, self.name)
def has_website_permission(self, ptype, user, verbose=False):
"""Returns true if current user is the session user"""
@@ -1129,4 +1134,4 @@ def check_password_reset_limit(user, rate_limit):
frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later."))
def get_generated_link_count(user):
- return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
\ No newline at end of file
+ return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js
index 2d220b864c..17343573ed 100644
--- a/frappe/custom/doctype/customize_form/customize_form.js
+++ b/frappe/custom/doctype/customize_form/customize_form.js
@@ -81,6 +81,11 @@ frappe.ui.form.on("Customize Form", {
} else {
f._sortable = false;
}
+ if (f.fieldtype == "Table") {
+ frm.add_custom_button(f.options, function() {
+ frm.set_value('doc_type', f.options);
+ }, __('Customize Child Table'));
+ }
});
frm.fields_dict.fields.grid.refresh();
},
diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py
index 9ce602906c..82513783c7 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -39,7 +39,7 @@ class CustomizeForm(Document):
translation = self.get_name_translation()
self.label = translation.translated_text if translation else ''
- self.create_auto_repeat_custom_field_if_requried(meta)
+ self.create_auto_repeat_custom_field_if_required(meta)
# NOTE doc (self) is sent to clientside by run_method
@@ -74,19 +74,25 @@ class CustomizeForm(Document):
for d in meta.get(fieldname):
self.append(fieldname, d)
- def create_auto_repeat_custom_field_if_requried(self, meta):
+ def create_auto_repeat_custom_field_if_required(self, meta):
+ '''
+ Create auto repeat custom field if it's not already present
+ '''
if self.allow_auto_repeat:
- if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat',
- 'dt': self.doc_type}):
- insert_after = self.fields[len(self.fields) - 1].fieldname
- df = dict(
- fieldname='auto_repeat',
- label='Auto Repeat',
- fieldtype='Link',
- options='Auto Repeat',
- insert_after=insert_after,
- read_only=1, no_copy=1, print_hide=1)
- create_custom_field(self.doc_type, df)
+ all_fields = [df.fieldname for df in meta.fields]
+
+ if "auto_repeat" in all_fields:
+ return
+
+ insert_after = self.fields[len(self.fields) - 1].fieldname
+ create_custom_field(self.doc_type, dict(
+ fieldname='auto_repeat',
+ label='Auto Repeat',
+ fieldtype='Link',
+ options='Auto Repeat',
+ insert_after=insert_after,
+ read_only=1, no_copy=1, print_hide=1
+ ))
def get_name_translation(self):
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 616dd3c3ec..179206a4af 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -746,6 +746,9 @@ class Database(object):
def commit(self):
"""Commit current transaction. Calls SQL `COMMIT`."""
+ for method in frappe.local.before_commit:
+ frappe.call(method[0], *(method[1] or []), **(method[2] or {}))
+
self.sql("commit")
frappe.local.rollback_observers = []
@@ -753,6 +756,9 @@ class Database(object):
enqueue_jobs_after_commit()
flush_local_link_count()
+ def add_before_commit(self, method, args=None, kwargs=None):
+ frappe.local.before_commit.append([method, args, kwargs])
+
@staticmethod
def flush_realtime_log():
for args in frappe.local.realtime_log:
diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py
index 3345fce735..b8ffae519b 100644
--- a/frappe/database/db_manager.py
+++ b/frappe/database/db_manager.py
@@ -3,7 +3,6 @@ import frappe
class DbManager:
-
def __init__(self, db):
"""
Pass root_conn here for access to all databases.
@@ -66,10 +65,10 @@ class DbManager:
esc = make_esc('$ ')
from distutils.spawn import find_executable
- pipe = find_executable('pv')
- if pipe:
- pipe = '{pipe} {source} |'.format(
- pipe=pipe,
+ pv = find_executable('pv')
+ if pv:
+ pipe = '{pv} {source} |'.format(
+ pv=pv,
source=source
)
source = ''
@@ -78,7 +77,7 @@ class DbManager:
source = '< {source}'.format(source=source)
if pipe:
- print('Creating Database...')
+ print('Restoring Database file...')
command = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}'
command = command.format(
diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql
index 15b0bed699..a52efd01e3 100644
--- a/frappe/database/mariadb/framework_mariadb.sql
+++ b/frappe/database/mariadb/framework_mariadb.sql
@@ -233,7 +233,7 @@ CREATE TABLE `tabDocType` (
DROP TABLE IF EXISTS `tabSeries`;
CREATE TABLE `tabSeries` (
- `name` varchar(100) DEFAULT NULL,
+ `name` varchar(100),
`current` int(10) NOT NULL DEFAULT 0,
PRIMARY KEY(`name`)
) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py
index a4e4d624ae..9b73d77171 100644
--- a/frappe/database/mariadb/setup_db.py
+++ b/frappe/database/mariadb/setup_db.py
@@ -1,7 +1,7 @@
from __future__ import unicode_literals
import frappe
-import os, sys
+import os
from frappe.database.db_manager import DbManager
expected_settings_10_2_earlier = {
@@ -86,6 +86,8 @@ def drop_user_and_database(db_name, root_login, root_password):
dbman.drop_database(db_name)
def bootstrap_database(db_name, verbose, source_sql=None):
+ import sys
+
frappe.connect(db_name=db_name)
if not check_database_settings():
print('Database settings do not match expected values; stopping database setup.')
@@ -94,9 +96,17 @@ def bootstrap_database(db_name, verbose, source_sql=None):
import_db_from_sql(source_sql, verbose)
frappe.connect(db_name=db_name)
- if not 'tabDefaultValue' in frappe.db.get_tables():
- print('''Database not installed, this can due to lack of permission, or that the database name exists.
- Check your mysql root password, or use --force to reinstall''')
+ if 'tabDefaultValue' not in frappe.db.get_tables():
+ from click import secho
+
+ secho(
+ "Table 'tabDefaultValue' missing in the restored site. "
+ "Database not installed correctly, this can due to lack of "
+ "permission, or that the database name exists. Check your mysql"
+ " root password, validity of the backup file or use --force to"
+ " reinstall",
+ fg="red"
+ )
sys.exit(1)
def import_db_from_sql(source_sql=None, verbose=False):
diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py
index f53872db82..3ee6b6a286 100644
--- a/frappe/database/postgres/setup_db.py
+++ b/frappe/database/postgres/setup_db.py
@@ -1,5 +1,7 @@
-import frappe, subprocess, os
-from six.moves import input
+import os
+
+import frappe
+
def setup_database(force, source_sql=None, verbose=False):
root_conn = get_root_connection()
@@ -10,24 +12,62 @@ def setup_database(force, source_sql=None, verbose=False):
root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name,
frappe.conf.db_password))
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name))
+ root_conn.close()
+
+ bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql)
+ frappe.connect()
+
+def bootstrap_database(db_name, verbose, source_sql=None):
+ frappe.connect(db_name=db_name)
+ import_db_from_sql(source_sql, verbose)
+ frappe.connect(db_name=db_name)
+
+ if 'tabDefaultValue' not in frappe.db.get_tables():
+ import sys
+ from click import secho
+
+ secho(
+ "Table 'tabDefaultValue' missing in the restored site. "
+ "This may be due to incorrect permissions or the result of a restore from a bad backup file. "
+ "Database not installed correctly.",
+ fg="red"
+ )
+ sys.exit(1)
+
+def import_db_from_sql(source_sql=None, verbose=False):
+ from shutil import which
+ from subprocess import run, PIPE
# we can't pass psql password in arguments in postgresql as mysql. So
# set password connection parameter in environment variable
subprocess_env = os.environ.copy()
subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password)
+
# bootstrap db
if not source_sql:
source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql')
- subprocess.check_output([
- 'psql', frappe.conf.db_name,
- '-h', frappe.conf.db_host or 'localhost',
- '-p', str(frappe.conf.db_port or '5432'),
- '-U', frappe.conf.db_name,
- '-f', source_sql
- ], env=subprocess_env)
+ pv = which('pv')
- frappe.connect()
+ _command = (
+ f"psql {frappe.conf.db_name} "
+ f"-h {frappe.conf.db_host or 'localhost'} -p {str(frappe.conf.db_port or '5432')} "
+ f"-U {frappe.conf.db_name}"
+ )
+
+ if pv:
+ command = f"{pv} {source_sql} | " + _command
+ else:
+ command = _command + f" -f {source_sql}"
+
+ print("Restoring Database file...")
+ if verbose:
+ print(command)
+
+ restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE)
+
+ if verbose:
+ print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}")
def setup_help_database(help_db_name):
root_conn = get_root_connection()
@@ -38,19 +78,20 @@ def setup_help_database(help_db_name):
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name))
def get_root_connection(root_login=None, root_password=None):
- import getpass
if not frappe.local.flags.root_connection:
if not root_login:
root_login = frappe.conf.get("root_login") or None
if not root_login:
+ from six.moves import input
root_login = input("Enter postgres super user: ")
if not root_password:
root_password = frappe.conf.get("root_password") or None
if not root_password:
- root_password = getpass.getpass("Postgres super user password: ")
+ from getpass import getpass
+ root_password = getpass("Postgres super user password: ")
frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password)
diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py
index b12bcfe27d..fa03bf8f80 100644
--- a/frappe/desk/doctype/dashboard/dashboard.py
+++ b/frappe/desk/doctype/dashboard/dashboard.py
@@ -5,6 +5,7 @@
from __future__ import unicode_literals
from frappe.model.document import Document
from frappe.modules.export_file import export_to_files
+from frappe.config import get_modules_from_all_apps_for_user
import frappe
from frappe import _
import json
@@ -42,6 +43,24 @@ class Dashboard(Document):
except ValueError as error:
frappe.throw(_("Invalid json added in the custom options: {0}").format(error))
+
+def get_permission_query_conditions(user):
+ if not user:
+ user = frappe.session.user
+
+ if user == 'Administrator':
+ return
+
+ roles = frappe.get_roles(user)
+ if "System Manager" in roles:
+ return None
+
+ allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
+ module_condition = '`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL'.format(
+ allowed_modules=','.join(allowed_modules))
+
+ return module_condition
+
@frappe.whitelist()
def get_permitted_charts(dashboard_name):
permitted_charts = []
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
index 7e2d952928..2fa36b5514 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
@@ -7,17 +7,18 @@ import frappe
from frappe import _
import datetime
import json
-from frappe.utils.dashboard import cache_source, get_from_date_from_timespan
-from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate,\
- get_datetime, cint, now_datetime
+from frappe.utils.dashboard import cache_source
+from frappe.utils import nowdate, getdate, get_datetime, cint, now_datetime
+from frappe.utils.dateutils import\
+ get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain
from frappe.model.naming import append_number_if_name_exists
from frappe.boot import get_allowed_reports
+from frappe.config import get_modules_from_all_apps_for_user
from frappe.model.document import Document
from frappe.modules.export_file import export_to_files
def get_permission_query_conditions(user):
-
if not user:
user = frappe.session.user
@@ -30,9 +31,11 @@ def get_permission_query_conditions(user):
doctype_condition = False
report_condition = False
+ module_condition = False
allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
allowed_reports = [frappe.db.escape(key) if type(key) == str else key.encode('UTF8') for key in get_allowed_reports()]
+ allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
if allowed_doctypes:
doctype_condition = '`tabDashboard Chart`.`document_type` in ({allowed_doctypes})'.format(
@@ -40,18 +43,24 @@ def get_permission_query_conditions(user):
if allowed_reports:
report_condition = '`tabDashboard Chart`.`report_name` in ({allowed_reports})'.format(
allowed_reports=','.join(allowed_reports))
+ if allowed_modules:
+ module_condition = '''`tabDashboard Chart`.`module` in ({allowed_modules})
+ or `tabDashboard Chart`.`module` is NULL'''.format(
+ allowed_modules=','.join(allowed_modules))
return '''
- (`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
- and {doctype_condition})
- or
- (`tabDashboard Chart`.`chart_type` = 'Report'
- and {report_condition})
- '''.format(
- doctype_condition=doctype_condition,
- report_condition=report_condition
- )
-
+ ((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
+ and {doctype_condition})
+ or
+ (`tabDashboard Chart`.`chart_type` = 'Report'
+ and {report_condition}))
+ and
+ ({module_condition})
+ '''.format(
+ doctype_condition=doctype_condition,
+ report_condition=report_condition,
+ module_condition=module_condition
+ )
def has_permission(doc, ptype, user):
roles = frappe.get_roles(user)
@@ -156,6 +165,7 @@ def add_chart_to_dashboard(args):
def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
if not from_date:
from_date = get_from_date_from_timespan(to_date, timespan)
+ from_date = get_period_beginning(from_date, timegrain)
if not to_date:
to_date = now_datetime()
@@ -185,7 +195,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
result = get_result(data, timegrain, from_date, to_date)
chart_config = {
- "labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result],
+ "labels": [get_period(r[0], timegrain) for r in result],
"datasets": [{
"name": chart.name,
"values": [r[1] for r in result]
@@ -279,16 +289,8 @@ def get_aggregate_function(chart_type):
def get_result(data, timegrain, from_date, to_date):
- start_date = getdate(from_date)
- end_date = getdate(to_date)
-
- result = [[start_date, 0.0]]
-
- while start_date < end_date:
- next_date = get_next_expected_date(start_date, timegrain)
- result.append([next_date, 0.0])
- start_date = next_date
-
+ dates = get_dates_from_timegrain(from_date, to_date, timegrain)
+ result = [[date, 0] for date in dates]
data_index = 0
if data:
for i, d in enumerate(result):
@@ -298,65 +300,6 @@ def get_result(data, timegrain, from_date, to_date):
return result
-def get_next_expected_date(date, timegrain):
- next_date = None
- # given date is always assumed to be the period ending date
- next_date = get_period_ending(add_to_date(date, days=1), timegrain)
- return getdate(next_date)
-
-def get_period_ending(date, timegrain):
- date = getdate(date)
- if timegrain == 'Daily':
- pass
- elif timegrain == 'Weekly':
- date = get_week_ending(date)
- elif timegrain == 'Monthly':
- date = get_month_ending(date)
- elif timegrain == 'Quarterly':
- date = get_quarter_ending(date)
- elif timegrain == 'Yearly':
- date = get_year_ending(date)
-
- return getdate(date)
-
-def get_week_ending(date):
- # week starts on monday
- from datetime import timedelta
- start = date - timedelta(days = date.weekday())
- end = start + timedelta(days=6)
-
- return end
-
-def get_month_ending(date):
- month_of_the_year = int(date.strftime('%m'))
- # first day of next month (note month starts from 1)
-
- date = add_to_date('{}-01-01'.format(date.year), months = month_of_the_year)
- # last day of this month
- return add_to_date(date, days=-1)
-
-def get_quarter_ending(date):
- date = getdate(date)
-
- # find the earliest quarter ending date that is after
- # the given date
- for month in (3, 6, 9, 12):
- quarter_end_month = getdate('{}-{}-01'.format(date.year, month))
- quarter_end_date = getdate(get_last_day(quarter_end_month))
- if date <= quarter_end_date:
- date = quarter_end_date
- break
-
- return date
-
-def get_year_ending(date):
- ''' returns year ending of the given date '''
-
- # first day of next year (note year starts from 1)
- date = add_to_date('{}-01-01'.format(date.year), months = 12)
- # last day of this month
- return add_to_date(date, days=-1)
-
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters):
diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
index 5e39998e62..3c37ad4a09 100644
--- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
@@ -5,8 +5,8 @@ from __future__ import unicode_literals
import unittest, frappe
from frappe.utils import getdate, formatdate, get_last_day
-from frappe.desk.doctype.dashboard_chart.dashboard_chart import (get,
- get_period_ending)
+from frappe.utils.dateutils import get_period_ending, get_period
+from frappe.desk.doctype.dashboard_chart.dashboard_chart import get
from datetime import datetime
from dateutil.relativedelta import relativedelta
@@ -53,15 +53,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1)
result = get(chart_name='Test Dashboard Chart', refresh=1)
- self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))
- if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
- cur_date += relativedelta(months=1)
-
- for idx in range(1, 13):
+ for idx in range(13):
month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d'))
- self.assertEqual(result.get('labels')[idx], month)
+ self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1)
frappe.db.rollback()
@@ -87,15 +83,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1)
result = get(chart_name ='Test Empty Dashboard Chart', refresh=1)
- self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))
- if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
- cur_date += relativedelta(months=1)
-
- for idx in range(1, 13):
+ for idx in range(13):
month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d'))
- self.assertEqual(result.get('labels')[idx], month)
+ self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1)
frappe.db.rollback()
@@ -124,15 +116,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1)
result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1)
- self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))
- if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
- cur_date += relativedelta(months=1)
-
- for idx in range(1, 13):
+ for idx in range(13):
month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d'))
- self.assertEqual(result.get('labels')[idx], month)
+ self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1)
# only 1 data point with value
@@ -183,13 +171,12 @@ class TestDashboardChart(unittest.TestCase):
timeseries = 1
)).insert()
- result = get(chart_name ='Test Daily Dashboard Chart', refresh = 1)
+ result = get(chart_name = 'Test Daily Dashboard Chart', refresh = 1)
self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0])
self.assertEqual(
result.get('labels'),
- [formatdate('2019-01-06'), formatdate('2019-01-07'), formatdate('2019-01-08'),\
- formatdate('2019-01-09'), formatdate('2019-01-10'), formatdate('2019-01-11')]
+ ['06-01-19', '07-01-19', '08-01-19', '09-01-19', '10-01-19', '11-01-19']
)
frappe.db.rollback()
@@ -218,7 +205,10 @@ class TestDashboardChart(unittest.TestCase):
result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1)
self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0])
- self.assertEqual(result.get('labels'), [formatdate('2018-12-30'), formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')])
+ self.assertEqual(
+ result.get('labels'),
+ ['30-12-18', '06-01-19', '13-01-19', '20-01-19']
+ )
frappe.db.rollback()
diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py
index d4a2b00c57..6bddd09fc7 100644
--- a/frappe/desk/doctype/number_card/number_card.py
+++ b/frappe/desk/doctype/number_card/number_card.py
@@ -8,6 +8,7 @@ from frappe.model.document import Document
from frappe.utils import cint
from frappe.model.naming import append_number_if_name_exists
from frappe.modules.export_file import export_to_files
+from frappe.config import get_modules_from_all_apps_for_user
class NumberCard(Document):
def autoname(self):
@@ -33,16 +34,24 @@ def get_permission_query_conditions(user=None):
return None
doctype_condition = False
+ module_condition = False
allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
+ allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
if allowed_doctypes:
doctype_condition = '`tabNumber Card`.`document_type` in ({allowed_doctypes})'.format(
allowed_doctypes=','.join(allowed_doctypes))
+ if allowed_modules:
+ module_condition = '''`tabNumber Card`.`module` in ({allowed_modules})
+ or `tabNumber Card`.`module` is NULL'''.format(
+ allowed_modules=','.join(allowed_modules))
return '''
- {doctype_condition}
- '''.format(doctype_condition=doctype_condition)
+ {doctype_condition}
+ and
+ {module_condition}
+ '''.format(doctype_condition=doctype_condition, module_condition=module_condition)
def has_permission(doc, ptype, user):
roles = frappe.get_roles(user)
diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py
index 3aa3a4fa88..66164948f2 100644
--- a/frappe/desk/form/document_follow.py
+++ b/frappe/desk/form/document_follow.py
@@ -21,7 +21,7 @@ def follow_document(doctype, doc_name, user, force=False):
avoided for some doctype
follow only if track changes are set to 1
'''
- if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment")
+ if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment", "Email Account", "Email Domain")
or doctype in log_types):
return
diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json
index 0d0922f16f..dc73acacc1 100644
--- a/frappe/email/doctype/email_template/email_template.json
+++ b/frappe/email/doctype/email_template/email_template.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "Prompt",
@@ -8,6 +9,8 @@
"engine": "InnoDB",
"field_order": [
"subject",
+ "use_html",
+ "response_html",
"response",
"owner",
"section_break_4",
@@ -22,11 +25,12 @@
"reqd": 1
},
{
+ "depends_on": "eval:!doc.use_html",
"fieldname": "response",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Response",
- "reqd": 1
+ "mandatory_depends_on": "eval:!doc.use_html"
},
{
"default": "user",
@@ -45,10 +49,24 @@
"fieldtype": "HTML",
"label": "Email Reply Help",
"options": "Order Overdue\n\nTransaction {{ name }} has exceeded Due Date. Please take necessary action.\n\nDetails\n\n- Customer: {{ customer }}\n- Amount: {{ grand_total }}\n\n\nThe fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Sales Invoice)
\n\nTemplates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.
\n" + }, + { + "default": "0", + "fieldname": "use_html", + "fieldtype": "Check", + "label": "Use HTML" + }, + { + "depends_on": "eval:doc.use_html", + "fieldname": "response_html", + "fieldtype": "Code", + "label": "Response ", + "options": "HTML" } ], "icon": "fa fa-comment", - "modified": "2019-10-30 14:15:00.956347", + "links": [], + "modified": "2020-11-30 14:12:50.321633", "modified_by": "Administrator", "module": "Email", "name": "Email Template", diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index 2743032331..6708e9dd3f 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -9,7 +9,29 @@ from six import string_types class EmailTemplate(Document): def validate(self): - validate_template(self.response) + if self.use_html: + validate_template(self.response_html) + else: + validate_template(self.response) + + def get_formatted_subject(self, doc): + return frappe.render_template(self.subject, doc) + + def get_formatted_response(self, doc): + if self.use_html: + return frappe.render_template(self.response_html, doc) + + return frappe.render_template(self.response, doc) + + def get_formatted_email(self, doc): + if isinstance(doc, string_types): + doc = json.loads(doc) + + return { + "subject" : self.get_formatted_subject(doc), + "message" : self.get_formatted_response(doc) + } + @frappe.whitelist() def get_email_template(template_name, doc): @@ -18,5 +40,4 @@ def get_email_template(template_name, doc): doc = json.loads(doc) email_template = frappe.get_doc("Email Template", template_name) - return {"subject" : frappe.render_template(email_template.subject, doc), - "message" : frappe.render_template(email_template.response, doc)} \ No newline at end of file + return email_template.get_formatted_email(doc) \ No newline at end of file diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index a4d60706eb..2791ebb75b 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -85,11 +85,11 @@ class Newsletter(WebsiteGenerator): self.db_set("scheduled_to_send", len(self.recipients)) def get_message(self): - + if self.content_type == "HTML": + return frappe.render_template(self.message_html, {"doc": self.as_dict()}) return { 'Rich Text': self.message, - 'Markdown': markdown(self.message_md), - 'HTML': self.message_html + 'Markdown': markdown(self.message_md) }[self.content_type or 'Rich Text'] def get_recipients(self): diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index 2cc027acd6..27fcd0e453 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -97,14 +97,7 @@ frappe.notification = { }, setup_example_message: function(frm) { let template = ''; - if (frm.doc.channel === 'WhatsApp') { - template = `
-Your appointment is coming up on {{ doc.date }} at {{ doc.time }}
-`;
- } else if (frm.doc.channel === 'Email') {
+ if (frm.doc.channel === 'Email') {
template = `<h3>Order Overdue</h3>
@@ -124,7 +117,7 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
</ul>
`;
- } else {
+ } else if (in_list(['Slack', 'System Notification', 'SMS'], frm.doc.channel)) {
template = `*Order Overdue*
@@ -142,7 +135,9 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
• Amount: {{ doc.grand_total }}
`;
}
- frm.set_df_property('message_examples', 'options', template);
+ if (template) {
+ frm.set_df_property('message_examples', 'options', template);
+ }
}
};
diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json
index 2a8ee1aeb1..c1c877efd4 100644
--- a/frappe/email/doctype/notification/notification.json
+++ b/frappe/email/doctype/notification/notification.json
@@ -10,7 +10,6 @@
"enabled",
"column_break_2",
"channel",
- "twilio_number",
"slack_webhook_url",
"filters",
"subject",
@@ -61,7 +60,7 @@
"fieldname": "channel",
"fieldtype": "Select",
"label": "Channel",
- "options": "Email\nSlack\nSystem Notification\nWhatsApp\nSMS",
+ "options": "Email\nSlack\nSystem Notification\nSMS",
"reqd": 1,
"set_only_once": 1
},
@@ -80,14 +79,14 @@
"label": "Filters"
},
{
- "depends_on": "eval: !in_list(['SMS', 'WhatsApp'], doc.channel)",
+ "depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)",
"description": "To add dynamic subject, use jinja tags like\n\n{{ doc.name }} Delivered