diff --git a/.eslintrc b/.eslintrc index 7e469f7672..e79571f556 100644 --- a/.eslintrc +++ b/.eslintrc @@ -78,6 +78,7 @@ "has_common": true, "has_words": true, "validate_email": true, + "validate_phone": true, "get_number_format": true, "format_number": true, "format_currency": true, diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..2ff8752871 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,17 @@ +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, + +* @surajshetty3416, @netchampfaris +website/ @scmmishra +templates/ @scmmishra +www/ @scmmishra +integrations/ @Mangesh-Khairnar +patches/ @surajshetty3416 @sahil28297 +dashboard/ @prssanna +email/ @Thunderbottom +event_streaming/ @ruchamahabal +data_import* @netchampfaris +core/ @surajshetty3416 +requirements.txt @gavindsouza \ No newline at end of file diff --git a/frappe/api.py b/frappe/api.py index 1b1861b78f..6655ebc4d8 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -81,10 +81,8 @@ def handle(): frappe.local.response.update({"data": doc}) if frappe.local.request.method=="PUT": - if frappe.local.form_dict.data is None: - data = json.loads(frappe.safe_decode(frappe.local.request.get_data())) - else: - data = json.loads(frappe.local.form_dict.data) + data = get_request_form_data() + doc = frappe.get_doc(doctype, name) if "flags" in data: @@ -123,10 +121,7 @@ def handle(): }) if frappe.local.request.method == "POST": - if frappe.local.form_dict.data is None: - data = json.loads(frappe.safe_decode(frappe.local.request.get_data())) - else: - data = json.loads(frappe.local.form_dict.data) + data = get_request_form_data() data.update({ "doctype": doctype }) @@ -142,6 +137,13 @@ def handle(): return build_response("json") +def get_request_form_data(): + if frappe.local.form_dict.data is None: + data = frappe.safe_decode(frappe.local.request.get_data()) + else: + data = frappe.local.form_dict.data + + return frappe.parse_json(data) def validate_oauth(): """ authentication using oauth """ diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index b9239dc1f6..82311b19d4 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -69,23 +69,25 @@ class Contact(Document): return True def add_email(self, email_id, is_primary=0, autosave=False): - self.append("email_ids", { - "email_id": email_id, - "is_primary": is_primary - }) + if not frappe.db.exists("Contact Email", {"email_id": email_id, "parent": self.name}): + self.append("email_ids", { + "email_id": email_id, + "is_primary": is_primary + }) - if autosave: - self.save(ignore_permissions=True) + if autosave: + self.save(ignore_permissions=True) def add_phone(self, phone, is_primary_phone=0, is_primary_mobile_no=0, autosave=False): - self.append("phone_nos", { - "phone": phone, - "is_primary_phone": is_primary_phone, - "is_primary_mobile_no": is_primary_mobile_no - }) + if not frappe.db.exists("Contact Phone", {"phone": phone, "parent": self.name}): + self.append("phone_nos", { + "phone": phone, + "is_primary_phone": is_primary_phone, + "is_primary_mobile_no": is_primary_mobile_no + }) - if autosave: - self.save(ignore_permissions=True) + if autosave: + self.save(ignore_permissions=True) def set_primary_email(self): if not self.email_ids: diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json index 5e34804b93..ae6fb164ec 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -1,9 +1,11 @@ { + "actions": [], "allow_import": 1, "creation": "2013-01-29 10:47:14", "description": "Keeps track of all communications", "doctype": "DocType", "document_type": "Setup", + "email_append_to": 1, "engine": "InnoDB", "field_order": [ "subject", @@ -383,7 +385,8 @@ ], "icon": "fa fa-comment", "idx": 1, - "modified": "2019-10-09 14:22:27.664645", + "links": [], + "modified": "2019-12-27 14:44:04.880373", "modified_by": "Administrator", "module": "Core", "name": "Communication", @@ -430,8 +433,10 @@ } ], "search_fields": "subject", + "sender_field": "sender", "sort_field": "modified", "sort_order": "DESC", + "subject_field": "subject", "title_field": "subject", "track_changes": 1, "track_seen": 1 diff --git a/frappe/core/doctype/doctype/boilerplate/templates/controller_row.html b/frappe/core/doctype/doctype/boilerplate/templates/controller_row.html index 3cc8ce2c2d..66fe744830 100644 --- a/frappe/core/doctype/doctype/boilerplate/templates/controller_row.html +++ b/frappe/core/doctype/doctype/boilerplate/templates/controller_row.html @@ -1,4 +1,4 @@
- {{{{ title }}}} + {{{{ doc.title or doc.name }}}}
- \ No newline at end of file + diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index 9a19185cfc..b3469abf29 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -53,7 +53,7 @@ frappe.ui.form.on('DocType', { frm.events.autoname(frm); }, - autoname(frm) { + autoname: function(frm) { frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); } }) diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 4e3f2fd84a..379ea227cb 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -54,6 +54,10 @@ "color", "show_preview_popup", "show_name_in_global_search", + "email_settings_sb", + "email_append_to", + "sender_field", + "subject_field", "sb2", "permissions", "restrict_to_domain", @@ -488,11 +492,37 @@ "fieldtype": "Table", "label": "Links", "options": "DocType Link" + }, + { + "depends_on": "email_append_to", + "fieldname": "subject_field", + "fieldtype": "Data", + "label": "Subject Field" + }, + { + "depends_on": "email_append_to", + "fieldname": "sender_field", + "fieldtype": "Data", + "label": "Sender Field", + "mandatory_depends_on": "email_append_to" + }, + { + "default": "0", + "fieldname": "email_append_to", + "fieldtype": "Check", + "label": "Allow document creation via Email" + }, + { + "collapsible": 1, + "fieldname": "email_settings_sb", + "fieldtype": "Section Break", + "label": "Email Settings" } ], "icon": "fa fa-bolt", "idx": 6, - "modified": "2019-11-25 17:24:03.690192", + "links": [], + "modified": "2020-03-27 14:51:44.581128", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index da1b184cc1..f970f51419 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -15,7 +15,7 @@ import frappe import frappe.website.render from frappe import _ from frappe.utils import now, cint -from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields +from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields, data_field_options from frappe.model.document import Document from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.custom_field.custom_field import create_custom_field @@ -24,6 +24,7 @@ from frappe.modules import make_boilerplate, get_doc_path from frappe.database.schema import validate_column_name, validate_column_length from frappe.model.docfield import supports_translation from frappe.modules.import_file import get_file_path +from frappe.model.meta import Meta class InvalidFieldNameError(frappe.ValidationError): pass @@ -93,10 +94,11 @@ class DocType(Document): if not self.is_new(): self.setup_fields_to_fetch() + check_email_append_to(self) + if self.default_print_format and not self.custom: frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) - def set_default_in_list_view(self): '''Set default in-list-view for first 4 mandatory fields''' if not [d.fieldname for d in self.fields if d.in_list_view]: @@ -107,14 +109,12 @@ class DocType(Document): cnt += 1 if cnt == 4: break - def set_default_translatable(self): '''Ensure that non-translatable never will be translatable''' for d in self.fields: if d.translatable and not supports_translation(d.fieldtype): d.translatable = 0 - def check_developer_mode(self): """Throw exception if not developer mode or via patch""" if frappe.flags.in_patch or frappe.flags.in_test: @@ -123,7 +123,6 @@ class DocType(Document): if not frappe.conf.get("developer_mode") and not self.custom: frappe.throw(_("Not in Developer Mode! Set in site_config.json or make 'Custom' DocType."), CannotCreateStandardDoctypeError) - def setup_fields_to_fetch(self): '''Setup query to update values for newly set fetch values''' try: @@ -168,21 +167,18 @@ class DocType(Document): ) ) - def update_fields_to_fetch(self): '''Update fetch values based on queries setup''' if self.flags.update_fields_to_fetch_queries and not self.issingle: for query in self.flags.update_fields_to_fetch_queries: frappe.db.sql(query) - def validate_document_type(self): if self.document_type=="Transaction": self.document_type = "Document" if self.document_type=="Master": self.document_type = "Setup" - def validate_website(self): """Ensure that website generator has field 'route'""" if self.has_web_view: @@ -193,7 +189,6 @@ class DocType(Document): # clear website cache frappe.website.render.clear_cache() - def change_modified_of_parent(self): """Change the timestamp of parent DocType if the current one is a child to clear caches.""" if frappe.flags.in_import: @@ -203,7 +198,6 @@ class DocType(Document): for p in parent_list: frappe.db.sql('UPDATE `tabDocType` SET modified=%s WHERE `name`=%s', (now(), p.parent)) - def scrub_field_names(self): """Sluggify fieldnames if not set from Label.""" restricted = ('name','parent','creation','modified','modified_by', @@ -233,7 +227,6 @@ class DocType(Document): # unique is automatically an index if d.unique: d.search_index = 0 - def validate_series(self, autoname=None, name=None): """Validate if `autoname` property is correctly set.""" if not autoname: autoname = self.autoname @@ -270,12 +263,11 @@ class DocType(Document): if used_in: frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0])) - def on_update(self): """Update database schema, make controller templates if `custom` is not set and clear cache.""" self.delete_duplicate_custom_fields() try: - frappe.db.updatedb(self.name, self) + frappe.db.updatedb(self.name, Meta(self)) except Exception as e: print("\n\nThere was an issue while migrating the DocType: {}\n".format(self.name)) raise e @@ -324,7 +316,6 @@ class DocType(Document): dt = {0} and fieldname in ({1}) '''.format('%s', ', '.join(['%s'] * len(fields))), tuple([self.name] + fields), as_dict=True) - def sync_global_search(self): '''If global search settings are changed, rebuild search properties for this table''' global_search_fields_before_update = [d.fieldname for d in @@ -342,7 +333,6 @@ class DocType(Document): frappe.enqueue('frappe.utils.global_search.rebuild_for_doctype', now=now, doctype=self.name) - def set_base_class_for_controller(self): '''Updates the controller class to subclass from `WebsiteGenertor`, if it is a subclass of `Document`''' @@ -362,14 +352,12 @@ class DocType(Document): with open(controller_path, 'w') as f: f.write(code) - def run_module_method(self, method): from frappe.modules import load_doctype_module module = load_doctype_module(self.name, self.module) if hasattr(module, method): getattr(module, method)() - def before_rename(self, old, new, merge=False): """Throw exception if merge. DocTypes cannot be merged.""" if not self.custom and frappe.session.user != "Administrator": @@ -385,7 +373,6 @@ class DocType(Document): 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.""" @@ -396,7 +383,6 @@ class DocType(Document): else: frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) - def rename_files_and_folders(self, old, new): # move files new_path = get_doc_path(self.module, 'doctype', new) @@ -413,7 +399,6 @@ class DocType(Document): self.rename_inside_controller(new, old, new_path) frappe.msgprint(_('Renamed files and replaced code in controllers, please check!')) - def rename_inside_controller(self, new, old, new_path): for fname in ('{}.js', '{}.py', '{}_list.js', '{}_calendar.js', 'test_{}.py', 'test_{}.js'): fname = os.path.join(new_path, fname.format(frappe.scrub(new))) @@ -439,7 +424,6 @@ class DocType(Document): if not (self.issingle and self.istable): self.preserve_naming_series_options_in_property_setter() - def preserve_naming_series_options_in_property_setter(self): """Preserve naming_series as property setter if it does not exist""" naming_series = self.get("fields", {"fieldname": "naming_series"}) @@ -459,7 +443,6 @@ class DocType(Document): if naming_series[0].default: make_property_setter(self.name, "naming_series", "default", naming_series[0].default, "Text", validate_fields_for_doctype=False) - def before_export(self, docdict): # remove null and empty fields def remove_null_fields(o): @@ -504,7 +487,6 @@ class DocType(Document): except ValueError: pass - @staticmethod def prepare_for_import(docdict): # set order of fields from field_order @@ -527,19 +509,16 @@ class DocType(Document): if "field_order" in docdict: del docdict["field_order"] - def export_doc(self): """Export to standard folder `[module]/doctype/[name]/[name].json`.""" from frappe.modules.export_file import export_to_files export_to_files(record_list=[['DocType', self.name]], create_init=True) - def import_doc(self): """Import from standard folder `[module]/doctype/[name]/[name].json`.""" from frappe.modules.import_module import import_from_files import_from_files(record_list=[[self.module, 'doctype', self.name]]) - def make_controller_template(self): """Make boilerplate controller template.""" make_boilerplate("controller._py", self) @@ -556,7 +535,6 @@ class DocType(Document): make_boilerplate('templates/controller.html', self.as_dict()) make_boilerplate('templates/controller_row.html', self.as_dict()) - def make_amendable(self): """If is_submittable is set, add amended_from docfields.""" if self.is_submittable: @@ -572,7 +550,6 @@ class DocType(Document): "no_copy": 1 }) - def make_repeatable(self): """If allow_auto_repeat is set, add auto_repeat custom field.""" if self.allow_auto_repeat: @@ -641,14 +618,12 @@ class DocType(Document): }) self.nsm_parent_field = parent_field_name - def get_max_idx(self): """Returns the highest `idx`""" max_idx = frappe.db.sql("""select max(idx) from `tabDocField` where parent = %s""", self.name) return max_idx and max_idx[0][0] or 0 - def validate_name(self, name=None): if not name: name = self.name @@ -668,7 +643,6 @@ def validate_fields_for_doctype(doctype): doc.delete_duplicate_custom_fields() validate_fields(frappe.get_meta(doctype, cached=False)) - # this is separate because it is also called via custom field def validate_fields(meta): """Validate doctype fields. Checks @@ -692,29 +666,24 @@ def validate_fields(meta): def check_illegal_characters(fieldname): validate_column_name(fieldname) - def check_invalid_fieldnames(docname, fieldname): invalid_fields = ('doctype',) if fieldname in invalid_fields: frappe.throw(_("{0}: Fieldname cannot be one of {1}") .format(docname, ", ".join([frappe.bold(d) for d in invalid_fields]))) - def check_unique_fieldname(docname, fieldname): duplicates = list(filter(None, map(lambda df: df.fieldname==fieldname and str(df.idx) or None, fields))) if len(duplicates) > 1: frappe.throw(_("{0}: Fieldname {1} appears multiple times in rows {2}").format(docname, fieldname, ", ".join(duplicates)), UniqueFieldnameError) - def check_fieldname_length(fieldname): validate_column_length(fieldname) - def check_illegal_mandatory(docname, d): if (d.fieldtype in no_value_fields) and d.fieldtype not in table_fields and d.reqd: frappe.throw(_("{0}: Field {1} of type {2} cannot be mandatory").format(docname, d.label, d.fieldtype), IllegalMandatoryError) - def check_link_table_options(docname, d): if frappe.flags.in_patch: return if d.fieldtype in ("Link",) + table_fields: @@ -733,28 +702,23 @@ def validate_fields(meta): # fix case d.options = options - def check_hidden_and_mandatory(docname, d): if d.hidden and d.reqd and not d.default: frappe.throw(_("{0}: Field {1} in row {2} cannot be hidden and mandatory without default").format(docname, d.label, d.idx), HiddenAndMandatoryWithoutDefaultError) - def check_width(d): if d.fieldtype == "Currency" and cint(d.width) < 100: frappe.throw(_("Max width for type Currency is 100px in row {0}").format(d.idx)) - def check_in_list_view(d): if d.in_list_view and (d.fieldtype in not_allowed_in_list_view): frappe.throw(_("'In List View' not allowed for type {0} in row {1}").format(d.fieldtype, d.idx)) - def check_in_global_search(d): if d.in_global_search and d.fieldtype in no_value_fields: frappe.throw(_("'In Global Search' not allowed for type {0} in row {1}") .format(d.fieldtype, d.idx)) - def check_dynamic_link_options(d): if d.fieldtype=="Dynamic Link": doctype_pointer = list(filter(lambda df: df.fieldname==d.options, fields)) @@ -762,7 +726,6 @@ def validate_fields(meta): or (doctype_pointer[0].fieldtype=="Link" and doctype_pointer[0].options!="DocType"): frappe.throw(_("Options 'Dynamic Link' type of field must point to another Link Field with options as 'DocType'")) - def check_illegal_default(d): if d.fieldtype == "Check" and not d.default: d.default = '0' @@ -771,12 +734,10 @@ def validate_fields(meta): if d.fieldtype == "Select" and d.default and (d.default not in d.options.split("\n")): frappe.throw(_("Default for {0} must be an option").format(d.fieldname)) - def check_precision(d): if d.fieldtype in ("Currency", "Float", "Percent") and d.precision is not None and not (1 <= cint(d.precision) <= 6): frappe.throw(_("Precision should be between 1 and 6")) - def check_unique_and_text(docname, d): if meta.issingle: d.unique = 0 @@ -798,7 +759,6 @@ def validate_fields(meta): if d.search_index and d.fieldtype in ("Text", "Long Text", "Small Text", "Code", "Text Editor"): frappe.throw(_("{0}:Fieldtype {1} for {2} cannot be indexed").format(docname, d.fieldtype, d.label), CannotIndexedError) - def check_fold(fields): fold_exists = False for i, f in enumerate(fields): @@ -813,7 +773,6 @@ def validate_fields(meta): else: frappe.throw(_("Fold can not be at the end of the form")) - def check_search_fields(meta, fields): """Throw exception if `search_fields` don't contain valid fields.""" if not meta.search_fields: @@ -830,7 +789,6 @@ def validate_fields(meta): (fieldname not in fieldname_list): frappe.throw(_("Search field {0} is not valid").format(fieldname)) - def check_title_field(meta): """Throw exception if `title_field` isn't a valid fieldname.""" if not meta.get("title_field"): @@ -857,7 +815,6 @@ def validate_fields(meta): _validate_title_field_pattern(df.options) _validate_title_field_pattern(df.default) - def check_image_field(meta): '''check image_field exists and is of type "Attach Image"''' if not meta.image_field: @@ -869,7 +826,6 @@ def validate_fields(meta): if df[0].fieldtype != 'Attach Image': frappe.throw(_("Image field must be of type Attach Image"), InvalidFieldNameError) - def check_is_published_field(meta): if not meta.is_published_field: return @@ -877,7 +833,6 @@ def validate_fields(meta): if meta.is_published_field not in fieldname_list: frappe.throw(_("Is Published Field must be a valid fieldname"), InvalidFieldNameError) - def check_timeline_field(meta): if not meta.timeline_field: return @@ -889,7 +844,6 @@ def validate_fields(meta): if df.fieldtype not in ("Link", "Dynamic Link"): frappe.throw(_("Timeline field must be a Link or Dynamic Link"), InvalidFieldNameError) - def check_sort_field(meta): '''Validate that sort_field(s) is a valid field''' if meta.sort_field: @@ -902,7 +856,6 @@ def validate_fields(meta): frappe.throw(_("Sort field {0} must be a valid fieldname").format(fieldname), InvalidFieldNameError) - def check_illegal_depends_on_conditions(docfield): ''' assignment operation should not be allowed in the depends on condition.''' depends_on_fields = ["depends_on", "collapsible_depends_on", "mandatory_depends_on", "read_only_depends_on"] @@ -912,7 +865,6 @@ def validate_fields(meta): re.match("""[\w\.:_]+\s*={1}\s*[\w\.@'"]+""", depends_on): frappe.throw(_("Invalid {0} condition").format(frappe.unscrub(field)), frappe.ValidationError) - def check_table_multiselect_option(docfield): '''check if the doctype provided in Option has atleast 1 Link field''' if not docfield.fieldtype == 'Table MultiSelect': return @@ -925,7 +877,6 @@ def validate_fields(meta): frappe.throw(_('DocType {0} provided for the field {1} must have atleast one Link field') .format(doctype, docfield.fieldname), frappe.ValidationError) - def scrub_options_in_select(field): """Strip options for whitespaces""" @@ -937,11 +888,20 @@ def validate_fields(meta): options_list.append(_option) field.options = '\n'.join(options_list) - def scrub_fetch_from(field): if hasattr(field, 'fetch_from') and getattr(field, 'fetch_from'): field.fetch_from = field.fetch_from.strip('\n').strip() + def validate_data_field_type(docfield): + if docfield.fieldtype == "Data": + if docfield.options and (docfield.options not in data_field_options): + df_str = frappe.bold(_(docfield.label)) + text_str = _("{0} is an invalid Data field.").format(df_str) + "
" * 2 + _("Only Options allowed for Data field are:") + "
" + df_options_str = "" + + frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True) + + fields = meta.get("fields") fieldname_list = [d.fieldname for d in fields] @@ -972,6 +932,7 @@ def validate_fields(meta): check_table_multiselect_option(d) scrub_options_in_select(d) scrub_fetch_from(d) + validate_data_field_type(d) check_fold(fields) check_search_fields(meta, fields) @@ -981,7 +942,6 @@ def validate_fields(meta): check_sort_field(meta) check_image_field(meta) - def validate_permissions_for_doctype(doctype, for_remove=False): """Validates if permissions are set correctly.""" doctype = frappe.get_doc("DocType", doctype) @@ -993,7 +953,6 @@ def validate_permissions_for_doctype(doctype, for_remove=False): clear_permissions_cache(doctype.name) - def clear_permissions_cache(doctype): frappe.clear_cache(doctype=doctype) delete_notification_count_for(doctype) @@ -1008,7 +967,6 @@ def clear_permissions_cache(doctype): """, doctype): frappe.clear_cache(user=user) - def validate_permissions(doctype, for_remove=False): permissions = doctype.get("permissions") if not permissions: @@ -1102,7 +1060,6 @@ def validate_permissions(doctype, for_remove=False): check_level_zero_is_set(d) remove_rights_for_single(d) - def make_module_and_roles(doc, perm_fieldname="permissions"): """Make `Module Def` and `Role` records if already not made. Called while installing.""" try: @@ -1133,7 +1090,6 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): else: raise - def check_if_fieldname_conflicts_with_methods(doctype, fieldname): doc = frappe.get_doc({"doctype": doctype}) method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))] @@ -1141,6 +1097,38 @@ def check_if_fieldname_conflicts_with_methods(doctype, fieldname): if fieldname in method_list: frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) - def clear_linked_doctype_cache(): frappe.cache().delete_value('linked_doctypes_without_ignore_user_permissions_enabled') + +def check_email_append_to(doc): + if not hasattr(doc, "email_append_to") or not doc.email_append_to: + return + + # Subject Field + doc.subject_field = doc.subject_field.strip() if doc.subject_field else None + subject_field = get_field(doc, doc.subject_field) + + if doc.subject_field and not subject_field: + frappe.throw(_("Select a valid Subject field for creating documents from Email")) + + if subject_field and subject_field.fieldtype not in ["Data", "Text", "Long Text", "Small Text", "Text Editor"]: + frappe.throw(_("Subject Field type should be Data, Text, Long Text, Small Text, Text Editor")) + + # Sender Field is mandatory + doc.sender_field = doc.sender_field.strip() if doc.sender_field else None + sender_field = get_field(doc, doc.sender_field) + + if doc.sender_field and not sender_field: + frappe.throw(_("Select a valid Sender Field for creating documents from Email")) + + if not sender_field.options == "Email": + frappe.throw(_("Sender Field should have Email in options")) + + +def get_field(doc, fieldname): + if not (doc or fieldname): + return + + for field in doc.fields: + if field.fieldname == fieldname: + return field diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 969a71ab7d..fe9f88b9b3 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -113,6 +113,32 @@ class TestDocType(unittest.TestCase): if condition: self.assertFalse(re.match(pattern, condition)) + def test_data_field_options(self): + doctype_name = "Test Data Fields" + valid_data_field_options = frappe.model.data_field_options + ("",) + invalid_data_field_options = ("Invalid Option 1", frappe.utils.random_string(5)) + + for field_option in (valid_data_field_options + invalid_data_field_options): + test_doctype = frappe.get_doc({ + "doctype": "DocType", + "name": doctype_name, + "module": "Core", + "custom": 1, + "fields": [{ + "fieldname": "{0}_field".format(field_option), + "fieldtype": "Data", + "options": field_option + }] + }) + + if field_option in invalid_data_field_options: + # assert that only data options in frappe.model.data_field_options are valid + self.assertRaises(frappe.ValidationError, test_doctype.insert) + else: + test_doctype.insert() + self.assertEqual(test_doctype.name, doctype_name) + test_doctype.delete() + def test_sync_field_order(self): from frappe.modules.import_file import get_file_path import os @@ -349,4 +375,4 @@ class TestDocType(unittest.TestCase): # delete doctype link_doc.delete() doc.delete() - frappe.db.commit() \ No newline at end of file + frappe.db.commit() diff --git a/frappe/core/doctype/prepared_report/prepared_report.json b/frappe/core/doctype/prepared_report/prepared_report.json index ab6650d9e3..4663dcb463 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.json +++ b/frappe/core/doctype/prepared_report/prepared_report.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "REP.#####", "creation": "2018-06-25 18:39:11.152960", "doctype": "DocType", @@ -101,7 +102,8 @@ } ], "in_create": 1, - "modified": "2019-09-18 04:00:55.644257", + "links": [], + "modified": "2020-03-05 10:52:56.598365", "modified_by": "Administrator", "module": "Core", "name": "Prepared Report", @@ -118,6 +120,15 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Prepared Report User", + "share": 1 } ], "quick_entry": 1, diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index bc9a1fcdcd..4e87bb92dd 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -98,3 +98,34 @@ def download_attachment(dn): attached_file = frappe.get_doc("File", attachment.name) frappe.local.response.filecontent = gzip_decompress(attached_file.get_content()) frappe.local.response.type = "binary" + + +def get_permission_query_condition(user): + if not user: user = frappe.session.user + if user == "Administrator": + return None + + from frappe.utils.user import UserPermissions + user = UserPermissions(user) + + if "System Manager" in user.roles: + return None + + reports = [frappe.db.escape(report) for report in user.get_all_reports().keys()] + + return """`tabPrepared Report`.ref_report_doctype in ({reports})"""\ + .format(reports=','.join(reports)) + + +def has_permission(doc, user): + if not user: user = frappe.session.user + if user == "Administrator": + return True + + from frappe.utils.user import UserPermissions + user = UserPermissions(user) + + if "System Manager" in user.roles: + return True + + return doc.ref_report_doctype in user.get_all_reports().keys() diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 2d49915f59..967b28b8b2 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -6,7 +6,7 @@ import frappe import json, datetime from frappe import _, scrub import frappe.desk.query_report -from frappe.utils import cint +from frappe.utils import cint, cstr from frappe.model.document import Document from frappe.modules.export_file import export_to_files from frappe.modules import make_boilerplate @@ -92,6 +92,18 @@ class Report(Document): make_boilerplate("controller.py", self, {"name": self.name}) make_boilerplate("controller.js", self, {"name": self.name}) + def execute_query_report(self, filters): + if not self.query: + frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error')) + + if not self.query.lower().startswith("select"): + frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error')) + + result = [list(t) for t in frappe.db.sql(self.query, filters)] + columns = [cstr(c[0]) for c in frappe.db.get_description()] + + return [columns, result] + def execute_script_report(self, filters): # save the timestamp to automatically set to prepared threshold = 30 diff --git a/frappe/core/doctype/role_profile/role_profile.js b/frappe/core/doctype/role_profile/role_profile.js index 09aead670a..d31618cc4a 100644 --- a/frappe/core/doctype/role_profile/role_profile.js +++ b/frappe/core/doctype/role_profile/role_profile.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('Role Profile', { - setup: function(frm) { + refresh: function(frm) { if(has_common(frappe.user_roles, ["Administrator", "System Manager"])) { if(!frm.roles_editor) { var role_area = $('
') diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index eb20802624..f2c62ad1a3 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_user_name", "allow_error_traceback", "password_settings", + "logout_on_password_reset", "force_user_to_reset_password", "column_break_31", "enable_password_policy", @@ -407,6 +408,12 @@ "fieldname": "dormant_days", "fieldtype": "Int", "label": "Run Jobs only Daily if Inactive For (Days)" + }, + { + "default": "1", + "fieldname": "logout_on_password_reset", + "fieldtype": "Check", + "label": "Logout All Sessions on Password Reset" } ], "icon": "fa fa-cog", diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 0d981c9e9e..d4c0fa98ed 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -224,3 +224,4 @@ class TestUser(unittest.TestCase): def delete_contact(user): frappe.db.sql("DELETE FROM `tabContact` WHERE `email_id`= %s", user) + frappe.db.sql("DELETE FROM `tabContact Email` WHERE `email_id`= %s", user) diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index c4873ee40e..b17548d994 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -97,6 +97,48 @@ frappe.ui.form.on('User', { }); }, __("Password")); + frappe.db.get_single_value("LDAP Settings", "enabled").then((value) => { + if (value === 1 && frm.doc.name != "Administrator") { + frm.add_custom_button(__("Reset LDAP Password"), function() { + const d = new frappe.ui.Dialog({ + title: __("Reset LDAP Password"), + fields: [ + { + label: __("New Password"), + fieldtype: "Password", + fieldname: "new_password", + reqd: 1 + }, + { + label: __("Confirm New Password"), + fieldtype: "Password", + fieldname: "confirm_password", + reqd: 1 + }, + { + label: __("Logout All Sessions"), + fieldtype: "Check", + fieldname: "logout_sessions" + } + ], + primary_action: (values) => { + d.hide(); + if (values.new_password !== values.confirm_password) { + frappe.throw(__("Passwords do not match!")); + } + frappe.call( + "frappe.integrations.doctype.ldap_settings.ldap_settings.reset_password", { + user: frm.doc.email, + password: values.new_password, + logout: values.logout_sessions + }); + } + }); + d.show(); + }, __("Password")); + } + }); + frm.add_custom_button(__("Reset OTP Secret"), function() { frappe.call({ method: "frappe.core.doctype.user.user.reset_otp_secret", diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index b71c9555e1..5ebde7e7bd 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "creation": "2014-03-11 14:55:00", @@ -178,7 +179,7 @@ { "fieldname": "time_zone", "fieldtype": "Select", - "label": "Timezone" + "label": "Time Zone" }, { "description": "Get your globally recognized avatar from Gravatar.com", @@ -302,7 +303,7 @@ "default": "0", "fieldname": "logout_all_sessions", "fieldtype": "Check", - "label": "Logout from all devices while changing Password" + "label": "Logout From All Devices After Changing Password" }, { "fieldname": "reset_password_key", @@ -338,7 +339,7 @@ "default": "0", "fieldname": "document_follow_notify", "fieldtype": "Check", - "label": "Send Notifications for documents followed by me" + "label": "Send Notifications For Documents Followed By Me" }, { "default": "Daily", @@ -359,7 +360,7 @@ "default": "1", "fieldname": "thread_notify", "fieldtype": "Check", - "label": "Send Notifications for Email threads" + "label": "Send Notifications For Email Threads" }, { "default": "0", @@ -496,7 +497,7 @@ "description": "If enabled, user can login from any IP Address using Two Factor Auth, this can also be set for all users in System Settings", "fieldname": "bypass_restrict_ip_check_if_2fa_enabled", "fieldtype": "Check", - "label": "Bypass restricted IP Address check If Two Factor Auth Enabled" + "label": "Bypass Restricted IP Address Check If Two Factor Auth Enabled" }, { "fieldname": "column_break1", @@ -585,8 +586,9 @@ "icon": "fa fa-user", "idx": 413, "image_field": "user_image", + "links": [], "max_attachments": 5, - "modified": "2019-10-22 14:16:34.810223", + "modified": "2020-03-23 22:59:26.154985", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index b411f809cd..ddad3a91fb 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -101,7 +101,8 @@ class User(Document): frappe.enqueue( 'frappe.core.doctype.user.user.create_contact', user=self, - ignore_mandatory=True + ignore_mandatory=True, + now=frappe.flags.in_test ) if self.name not in ('Administrator', 'Guest') and not self.user_image: frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name) @@ -554,7 +555,8 @@ def update_password(new_password, logout_all_sessions=0, key=None, old_password= else: user = res['user'] - _update_password(user, new_password, logout_all_sessions=int(logout_all_sessions)) + logout_all_sessions = cint(logout_all_sessions) or frappe.db.get_single_value("System Settings", "logout_on_password_reset") + _update_password(user, new_password, logout_all_sessions=cint(logout_all_sessions)) user_doc, redirect_url = reset_user_data(user) @@ -1038,8 +1040,8 @@ def create_contact(user, ignore_links=False, ignore_mandatory=False): from frappe.contacts.doctype.contact.contact import get_contact_name if user.name in ["Administrator", "Guest"]: return - contact_exists = get_contact_name(user.email) - if not contact_exists: + contact_name = get_contact_name(user.email) + if not contact_name: contact = frappe.get_doc({ "doctype": "Contact", "first_name": user.first_name, @@ -1058,7 +1060,7 @@ def create_contact(user, ignore_links=False, ignore_mandatory=False): contact.add_phone(user.mobile_no, is_primary_mobile_no=True) contact.insert(ignore_permissions=True, ignore_links=ignore_links, ignore_mandatory=ignore_mandatory) else: - contact = frappe.get_doc("Contact", contact_exists) + contact = frappe.get_doc("Contact", contact_name) contact.first_name = user.first_name contact.last_name = user.last_name contact.gender = user.gender diff --git a/frappe/core/page/dashboard/dashboard.js b/frappe/core/page/dashboard/dashboard.js index 30941f94c0..ad65b05894 100644 --- a/frappe/core/page/dashboard/dashboard.js +++ b/frappe/core/page/dashboard/dashboard.js @@ -59,7 +59,7 @@ class Dashboard { } show_dashboard(current_dashboard_name) { - if(this.dashboard_name !== current_dashboard_name) { + if (this.dashboard_name !== current_dashboard_name) { this.dashboard_name = current_dashboard_name; let title = this.dashboard_name; if (!this.dashboard_name.toLowerCase().includes(__('dashboard'))) { @@ -76,36 +76,48 @@ class Dashboard { } refresh() { - this.get_dashboard_doc().then((doc) => { - this.dashboard_doc = doc; - this.charts = this.dashboard_doc.charts - .map(chart => { - return { - chart_name: chart.chart, - label: chart.chart, - ...chart - } - }); + this.get_permitted_dashboard_charts().then(charts => { + if (!charts.length) { + frappe.msgprint(__('No Permitted Charts on this Dashboard'), __('No Permitted Charts')) + } - this.chart_group = new frappe.widget.WidgetGroup({ - title: null, - container: this.container, - type: "chart", - columns: 2, - options: { - allow_sorting: false, - allow_create: false, - allow_delete: false, - allow_hiding: false, - allow_edit: false, - }, - widgets: this.charts, - }); + frappe.dashboard_utils.get_dashboard_settings().then((settings) => { + let chart_config = settings.chart_config? JSON.parse(settings.chart_config): {}; + this.charts = + charts.map(chart => { + return { + chart_name: chart.chart, + label: chart.chart, + chart_settings: chart_config[chart.chart] || {}, + ...chart + } + }); + this.chart_group = new frappe.widget.WidgetGroup({ + title: null, + container: this.container, + type: "chart", + columns: 2, + options: { + allow_sorting: false, + allow_create: false, + allow_delete: false, + allow_hiding: false, + allow_edit: false, + }, + widgets: this.charts, + }); + }) }); } - get_dashboard_doc() { - return frappe.model.with_doc('Dashboard', this.dashboard_name); + get_permitted_dashboard_charts() { + return frappe.xcall( + 'frappe.desk.doctype.dashboard.dashboard.get_permitted_charts', + { + dashboard_name: this.dashboard_name + }).then(charts => { + return charts; + }); } set_dropdown() { diff --git a/frappe/core/page/dashboard/dashboard.json b/frappe/core/page/dashboard/dashboard.json index 891dcb26f8..58fda5a34c 100644 --- a/frappe/core/page/dashboard/dashboard.json +++ b/frappe/core/page/dashboard/dashboard.json @@ -4,7 +4,7 @@ "docstatus": 0, "doctype": "Page", "idx": 0, - "modified": "2019-01-08 19:19:48.073410", + "modified": "2020-03-26 13:30:44.603948", "modified_by": "Administrator", "module": "Core", "name": "dashboard", diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js index 710bb51680..ed3b0d17db 100644 --- a/frappe/core/page/permission_manager/permission_manager.js +++ b/frappe/core/page/permission_manager/permission_manager.js @@ -217,6 +217,7 @@ frappe.PermissionEngine = Class.extend({ me.rights.forEach(r => { if (!d.is_submittable && ['submit', 'cancel', 'amend'].includes(r)) return; + if (d.in_create && ['create', 'write', 'delete'].includes(r)) return; me.add_check(perm_container, d, r); }); diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 1afd7bb423..637b526d5c 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -66,6 +66,7 @@ def get_permissions(doctype=None, role=None): meta = frappe.get_meta(d.parent) if meta: d.is_submittable = meta.is_submittable + d.in_create = meta.in_create return out diff --git a/frappe/core/utils.py b/frappe/core/utils.py index 55767ffe34..55cfbc34d7 100644 --- a/frappe/core/utils.py +++ b/frappe/core/utils.py @@ -67,3 +67,19 @@ def find_all(list_of_dict, match_function): if match_function(entry): found.append(entry) return found + +def ljust_list(_list, length, fill_word=None): + """ + Similar to ljust but for list. + + Usage: + $ ljust_list([1, 2, 3], 5) + > [1, 2, 3, None, None] + """ + # make a copy to avoid mutation of passed list + _list = list(_list) + fill_length = length - len(_list) + if fill_length > 0: + _list.extend([fill_word] * fill_length) + + return _list diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 63b094615a..b1743a96a5 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -85,6 +85,10 @@ frappe.ui.form.on("Customize Form", { if(frm.doc.doc_type) { frappe.customize_form.set_primary_action(frm); + frm.add_custom_button(__('Go to {0} List', [frm.doc.doc_type]), function() { + frappe.set_route('List', frm.doc.doc_type); + }); + frm.add_custom_button(__('Refresh Form'), function() { frm.script_manager.trigger("doc_type"); }, "fa fa-refresh", "btn-default"); @@ -139,8 +143,7 @@ frappe.ui.form.on("Customize Form", { }, 1000); } - }, - + } }); frappe.ui.form.on("Customize Form Field", { diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index 0b1df62f9d..51a5c0b85f 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "DL.####", "creation": "2013-01-29 17:55:08", "doctype": "DocType", @@ -28,6 +29,10 @@ "sort_field", "column_break_10", "sort_order", + "section_break_23", + "email_append_to", + "sender_field", + "subject_field", "fields_section_break", "fields" ], @@ -174,13 +179,38 @@ "fieldname": "allow_import", "fieldtype": "Check", "label": "Allow Import (via Data Import Tool)" + }, + { + "depends_on": "email_append_to", + "fieldname": "subject_field", + "fieldtype": "Data", + "label": "Subject Field" + }, + { + "depends_on": "email_append_to", + "fieldname": "sender_field", + "fieldtype": "Data", + "label": "Sender Field", + "mandatory_depends_on": "email_append_to" + }, + { + "default": "0", + "fieldname": "email_append_to", + "fieldtype": "Check", + "label": "Allow document creation via Email" + }, + { + "depends_on": "doc_type", + "fieldname": "section_break_23", + "fieldtype": "Section Break" } ], "hide_toolbar": 1, "icon": "fa fa-glass", "idx": 1, "issingle": 1, - "modified": "2019-10-08 11:16:36.698006", + "links": [], + "modified": "2020-03-27 15:06:35.443861", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 8d47a075ba..68848d26f6 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -12,7 +12,7 @@ from frappe import _ from frappe.utils import cint from frappe.model.document import Document from frappe.model import no_value_fields, core_doctypes_list -from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype +from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype, check_email_append_to from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.model.docfield import supports_translation @@ -31,7 +31,10 @@ doctype_properties = { 'track_changes': 'Check', 'track_views': 'Check', 'allow_auto_repeat': 'Check', - 'allow_import': 'Check' + 'allow_import': 'Check', + 'email_append_to': 'Check', + 'subject_field': 'Data', + 'sender_field': 'Data' } docfield_properties = { @@ -166,11 +169,11 @@ class CustomizeForm(Document): self.flags.update_db = False self.flags.rebuild_doctype_for_global_search = False - self.set_property_setters() self.update_custom_fields() self.set_name_translation() validate_fields_for_doctype(self.doc_type) + check_email_append_to(self) if self.flags.update_db: frappe.db.updatedb(self.doc_type) @@ -362,13 +365,49 @@ class CustomizeForm(Document): def validate_fieldtype_change(self, df, old_value, new_value): allowed = False + self.check_length_for_fieldtypes = [] for allowed_changes in allowed_fieldtype_change: if (old_value in allowed_changes and new_value in allowed_changes): allowed = True + if frappe.db.type_map.get(old_value)[1] > frappe.db.type_map.get(new_value)[1]: + self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value}) + self.validate_fieldtype_length() + else: + self.flags.update_db = True break if not allowed: frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx)) + def validate_fieldtype_length(self): + for field in self.check_length_for_fieldtypes: + df = field.get('df') + max_length = frappe.db.type_map.get(df.fieldtype)[1] + fieldname = df.fieldname + docs = frappe.db.sql(''' + SELECT name, {fieldname}, LENGTH({fieldname}) AS len + FROM `tab{doctype}` + WHERE LENGTH({fieldname}) > {max_length} + '''.format( + fieldname=fieldname, + doctype=self.doc_type, + max_length=max_length + ), as_dict=True) + links = [] + label = df.label + for doc in docs: + links.append(frappe.utils.get_link_to_form(self.doc_type, doc.name)) + links_str = ', '.join(links) + + if docs: + frappe.throw(_('Value for field {0} is too long in {1}. Length should be lesser than {2} characters') + .format( + frappe.bold(label), + links_str, + frappe.bold(max_length) + ), title=_('Data Too Long'), is_minimizable=len(docs) > 1) + + self.flags.update_db = True + def reset_to_defaults(self): if not self.doc_type: return diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 1cd71ea05d..cace25a03d 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -46,7 +46,7 @@ class TestCustomizeForm(unittest.TestCase): d = self.get_customize_form("Event") self.assertEquals(d.doc_type, "Event") - self.assertEquals(len(d.get("fields")), 35) + self.assertEquals(len(d.get("fields")), 36) d = self.get_customize_form("Event") self.assertEquals(d.doc_type, "Event") diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index dbe53df4e4..46940cc846 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -217,6 +217,9 @@ CREATE TABLE `tabDocType` ( `allow_guest_to_view` int(1) NOT NULL DEFAULT 0, `route` varchar(255) DEFAULT NULL, `is_published_field` varchar(255) DEFAULT NULL, + `email_append_to` int(1) NOT NULL DEFAULT 0, + `subject_field` varchar(255) DEFAULT NULL, + `sender_field` varchar(255) DEFAULT NULL, PRIMARY KEY (`name`), KEY `parent` (`parent`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 243d0f934e..e30ef3293f 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -92,7 +92,7 @@ class PostgresDatabase(Database): # pylint: disable=W0221 def sql(self, *args, **kwargs): - if len(args): + if args: # since tuple is immutable args = list(args) args[0] = modify_query(args[0]) @@ -276,13 +276,13 @@ class PostgresDatabase(Database): # pylint: disable=W1401 return self.sql(''' SELECT a.column_name AS name, - CASE a.data_type + CASE LOWER(a.data_type) WHEN 'character varying' THEN CONCAT('varchar(', a.character_maximum_length ,')') - WHEN 'timestamp without TIME zone' THEN 'timestamp' + WHEN 'timestamp without time zone' THEN 'timestamp' ELSE a.data_type END AS type, COUNT(b.indexdef) AS Index, - COALESCE(a.column_default, NULL) AS default, + SPLIT_PART(COALESCE(a.column_default, NULL), '::', 1) AS default, BOOL_OR(b.unique) AS unique FROM information_schema.columns a LEFT JOIN diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 457f6c906a..26760dbcc9 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -222,6 +222,9 @@ CREATE TABLE "tabDocType" ( "allow_guest_to_view" smallint NOT NULL DEFAULT 0, "route" varchar(255) DEFAULT NULL, "is_published_field" varchar(255) DEFAULT NULL, + "email_append_to" smallint NOT NULL DEFAULT 0, + "subject_field" varchar(255) DEFAULT NULL, + "sender_field" varchar(255) DEFAULT NULL, PRIMARY KEY ("name") ) ; diff --git a/frappe/database/schema.py b/frappe/database/schema.py index 88cda9340b..28e055f382 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -13,7 +13,7 @@ class DBTable: def __init__(self, doctype, meta=None): self.doctype = doctype self.table_name = 'tab{}'.format(doctype) - self.meta = meta or frappe.get_meta(doctype) + self.meta = meta or frappe.get_meta(doctype, False) self.columns = {} self.current_columns = {} @@ -65,64 +65,35 @@ class DBTable: """ get columns from docfields and custom fields """ - fl = frappe.db.sql("SELECT * FROM `tabDocField` WHERE parent = %s", self.doctype, as_dict = 1) - lengths = {} - precisions = {} - uniques = {} + fields = self.meta.get_fieldnames_with_value(True) # optional fields like _comments - if not self.meta.istable: + if not self.meta.get('istable'): for fieldname in frappe.db.OPTIONAL_COLUMNS: - fl.append({ + fields.append({ "fieldname": fieldname, "fieldtype": "Text" }) # add _seen column if track_seen - if getattr(self.meta, 'track_seen', False): - fl.append({ + if self.meta.get('track_seen'): + fields.append({ 'fieldname': '_seen', 'fieldtype': 'Text' }) - if (not frappe.flags.in_install_db - and (frappe.flags.in_install != "frappe" - or frappe.flags.ignore_in_install)): - custom_fl = frappe.db.sql(""" - SELECT * FROM `tabCustom Field` - WHERE dt = %s AND docstatus < 2 - """, (self.doctype,), as_dict=1) - if custom_fl: fl += custom_fl - - # apply length, precision and unique from property setters - for ps in frappe.get_all("Property Setter", - fields=["field_name", "property", "value"], - filters={ - "doc_type": self.doctype, - "doctype_or_field": "DocField", - "property": ["in", ["precision", "length", "unique"]] - }): - - if ps.property=="length": - lengths[ps.field_name] = cint(ps.value) - - elif ps.property=="precision": - precisions[ps.field_name] = cint(ps.value) - - elif ps.property=="unique": - uniques[ps.field_name] = cint(ps.value) - - for f in fl: - self.columns[f['fieldname']] = DbColumn(self, - f['fieldname'], - f['fieldtype'], - lengths.get(f["fieldname"]) or f.get('length'), - f.get('default'), - f.get('search_index'), - f.get('options'), - uniques.get(f["fieldname"], - f.get('unique')), - precisions.get(f['fieldname']) or f.get('precision')) + for field in fields: + self.columns[field.get('fieldname')] = DbColumn( + self, + field.get('fieldname'), + field.get('fieldtype'), + field.get('length'), + field.get('default'), + field.get('search_index'), + field.get('options'), + field.get('unique'), + field.get('precision') + ) def validate(self): """Check if change in varchar length isn't truncating the columns""" diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 1f3a83735b..175bfa90ff 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -163,8 +163,9 @@ class Workspace: charts = charts + self.extended_charts for chart in charts: - chart.label = chart.label if chart.label else chart.chart_name - all_charts.append(chart) + if frappe.has_permission('Dashboard Chart', doc=chart.chart_name): + chart.label = chart.label if chart.label else chart.chart_name + all_charts.append(chart) return all_charts diff --git a/frappe/desk/doctype/dashboard/dashboard.json b/frappe/desk/doctype/dashboard/dashboard.json index 239f35bea8..c177ee70ac 100644 --- a/frappe/desk/doctype/dashboard/dashboard.json +++ b/frappe/desk/doctype/dashboard/dashboard.json @@ -34,7 +34,7 @@ } ], "links": [], - "modified": "2020-01-26 20:00:10.069817", + "modified": "2020-03-25 21:09:37.080132", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard", @@ -51,6 +51,27 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dashboard Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1 } ], "quick_entry": 1, diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index c8f22d29b9..5c344956bf 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -12,3 +12,12 @@ class Dashboard(Document): # make all other dashboards non-default frappe.db.sql('''update tabDashboard set is_default = 0 where name != %s''', self.name) + +@frappe.whitelist() +def get_permitted_charts(dashboard_name): + permitted_charts = [] + dashboard = frappe.get_doc('Dashboard', dashboard_name) + for chart in dashboard.charts: + if frappe.has_permission('Dashboard Chart', doc=chart.chart): + permitted_charts.append(chart) + return permitted_charts diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json index 0a017a0de2..9652ae3945 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json @@ -215,7 +215,7 @@ } ], "links": [], - "modified": "2020-03-13 19:19:37.162771", + "modified": "2020-03-31 16:00:01.987059", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", @@ -232,6 +232,27 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dashboard Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1 } ], "sort_field": "modified", diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index f01c976b9c..b2a6f0a0ff 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -10,8 +10,51 @@ import json from frappe.core.page.dashboard.dashboard import cache_source, get_from_date_from_timespan from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate, get_datetime from frappe.model.naming import append_number_if_name_exists +from frappe.boot import get_allowed_reports from frappe.model.document import Document + +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_doctypes = tuple(frappe.permissions.get_doctypes_with_read()) + allowed_reports = tuple([key.encode('UTF8') for key in get_allowed_reports()]) + + return ''' + `tabDashboard Chart`.`document_type` in {allowed_doctypes} + or `tabDashboard Chart`.`report_name` in {allowed_reports} + '''.format( + allowed_doctypes=allowed_doctypes, + allowed_reports=allowed_reports + ) + + +def has_permission(doc, ptype, user): + roles = frappe.get_roles(user) + if "System Manager" in roles: + return True + + + if doc.chart_type == 'Report': + allowed_reports = tuple([key.encode('UTF8') for key in get_allowed_reports()]) + if doc.report_name in allowed_reports: + return True + else: + allowed_doctypes = tuple(frappe.permissions.get_doctypes_with_read()) + if doc.document_type in allowed_doctypes: + return True + + return False + @frappe.whitelist() @cache_source def get(chart_name = None, chart = None, no_cache = None, filters = None, from_date = None, diff --git a/frappe/desk/doctype/dashboard_settings/__init__.py b/frappe/desk/doctype/dashboard_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.js b/frappe/desk/doctype/dashboard_settings/dashboard_settings.js new file mode 100644 index 0000000000..8e7966366d --- /dev/null +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Dashboard Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.json b/frappe/desk/doctype/dashboard_settings/dashboard_settings.json new file mode 100644 index 0000000000..e1eb75db47 --- /dev/null +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2020-03-31 19:41:45.785014", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "chart_config" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "chart_config", + "fieldtype": "Code", + "label": "Chart Configuration", + "options": "JSON", + "read_only": 1 + } + ], + "in_create": 1, + "links": [], + "modified": "2020-04-01 00:07:26.489561", + "modified_by": "Administrator", + "module": "Desk", + "name": "Dashboard Settings", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1, + "write": 1 + } + ], + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.py b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py new file mode 100644 index 0000000000..4697d897fc --- /dev/null +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document +import frappe +import json + +class DashboardSettings(Document): + pass + + +@frappe.whitelist() +def create_dashboard_settings(user): + if not frappe.db.exists("Dashboard Settings", user): + doc = frappe.new_doc('Dashboard Settings') + doc.name = user + doc.insert(ignore_permissions=True) + frappe.db.commit() + return doc + +def get_permission_query_conditions(user): + if not user: user = frappe.session.user + + return '''(`tabDashboard Settings`.name = '{user}')'''.format(user=user) + +@frappe.whitelist() +def save_chart_config(reset, config, chart_name): + reset = frappe.parse_json(reset) + doc = frappe.get_doc('Dashboard Settings', frappe.session.user) + chart_config = frappe.parse_json(doc.chart_config) or {} + + if reset: + chart_config[chart_name] = {} + else: + config = frappe.parse_json(config) + if not chart_name in chart_config: + chart_config[chart_name] = {} + chart_config[chart_name].update(config) + + frappe.db.set_value('Dashboard Settings', frappe.session.user, 'chart_config', json.dumps(chart_config)) \ No newline at end of file diff --git a/frappe/desk/doctype/event/event.json b/frappe/desk/doctype/event/event.json index 032030ddef..5768f00f32 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -1,9 +1,11 @@ { + "actions": [], "allow_import": 1, "autoname": "EV.#####", "creation": "2013-06-10 13:17:47", "doctype": "DocType", "document_type": "Document", + "email_append_to": 1, "engine": "InnoDB", "field_order": [ "details", @@ -17,6 +19,7 @@ "starts_on", "ends_on", "status", + "sender", "all_day", "sync_with_google_calendar", "sb_00", @@ -262,11 +265,19 @@ "fieldtype": "Check", "label": "Pulled from Google Calendar", "read_only": 1 + }, + { + "fieldname": "sender", + "fieldtype": "Data", + "label": "Sender", + "options": "Email", + "read_only": 1 } ], "icon": "fa fa-calendar", "idx": 1, - "modified": "2019-08-08 16:01:19.489396", + "links": [], + "modified": "2020-01-14 21:47:15.825287", "modified_by": "Administrator", "module": "Desk", "name": "Event", @@ -297,8 +308,10 @@ } ], "read_only": 1, + "sender_field": "sender", "sort_field": "modified", "sort_order": "DESC", + "subject_field": "subject", "title_field": "subject", "track_changes": 1, "track_seen": 1, diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index 398a3de351..17eb6371b1 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -6,14 +6,13 @@ from __future__ import unicode_literals import frappe from frappe import _ from frappe.model.document import Document -from frappe.desk.doctype.notification_settings.notification_settings import (is_notifications_enabled, - is_email_notifications_enabled, is_email_notifications_enabled_for_type, set_seen_value) +from frappe.desk.doctype.notification_settings.notification_settings import (is_notifications_enabled, is_email_notifications_enabled_for_type, set_seen_value) class NotificationLog(Document): def after_insert(self): frappe.publish_realtime('notification', after_commit=True, user=self.for_user) set_notifications_as_unseen(self.for_user) - if is_email_notifications_enabled(self.for_user): + if is_email_notifications_enabled_for_type(self.for_user, self.type): send_notification_email(self) @@ -73,9 +72,6 @@ def make_notification_logs(doc, users): _doc.insert(ignore_permissions=True) def send_notification_email(doc): - is_type_enabled = is_email_notifications_enabled_for_type(doc.for_user, doc.type) - if not is_type_enabled: - return if doc.type == 'Energy Point' and doc.email_content is None: return diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py index 295b4c8afd..233d15e3ce 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.py +++ b/frappe/desk/doctype/notification_settings/notification_settings.py @@ -25,6 +25,9 @@ def is_email_notifications_enabled(user): return enabled def is_email_notifications_enabled_for_type(user, notification_type): + if not is_email_notifications_enabled(user): + return False + fieldname = 'enable_email_' + frappe.scrub(notification_type) enabled = frappe.db.get_value('Notification Settings', user, fieldname) if enabled is None: diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json index 508720a488..15e0e4abe1 100644 --- a/frappe/desk/doctype/todo/todo.json +++ b/frappe/desk/doctype/todo/todo.json @@ -1,8 +1,10 @@ { + "actions": [], "autoname": "hash", "creation": "2012-07-03 13:30:35", "doctype": "DocType", "document_type": "Setup", + "email_append_to": 1, "engine": "InnoDB", "field_order": [ "description_and_status", @@ -142,7 +144,8 @@ "fieldname": "sender", "fieldtype": "Data", "hidden": 1, - "label": "Sender" + "label": "Sender", + "options": "Email" }, { "fieldname": "assignment_rule", @@ -154,7 +157,8 @@ ], "icon": "fa fa-check", "idx": 2, - "modified": "2019-09-10 14:34:59.161750", + "links": [], + "modified": "2020-01-14 17:04:36.971002", "modified_by": "Administrator", "module": "Desk", "name": "ToDo", @@ -185,9 +189,11 @@ ], "quick_entry": 1, "search_fields": "description, reference_type, reference_name", + "sender_field": "sender", "sort_field": "modified", "sort_order": "DESC", + "subject_field": "description", "title_field": "description", "track_changes": 1, "track_seen": 1 -} +} \ No newline at end of file diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 6cd7c68368..8e8102d093 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -8,8 +8,6 @@ import json from frappe.model.document import Document from frappe.utils import get_fullname -subject_field = "description" -sender_field = "sender" exclude_from_linked_with = True class ToDo(Document): diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index 498ab50645..4c3bab2e23 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -56,18 +56,20 @@ def validate_link(): frappe.response['valid_value'] = valid_value frappe.response['message'] = 'Ok' + @frappe.whitelist() -def add_comment(reference_doctype, reference_name, content, comment_email): +def add_comment(reference_doctype, reference_name, content, comment_email, comment_by): """allow any logged user to post a comment""" doc = frappe.get_doc(dict( - doctype = 'Comment', - reference_doctype = reference_doctype, - reference_name = reference_name, - comment_email = comment_email, - comment_type = 'Comment' + doctype='Comment', + reference_doctype=reference_doctype, + reference_name=reference_name, + comment_email=comment_email, + comment_type='Comment', + comment_by=comment_by )) doc.content = extract_images_from_html(doc, content) - doc.insert(ignore_permissions = True) + doc.insert(ignore_permissions=True) follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user) return doc.as_dict() diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 301c37cc21..aaf859e7fd 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -8,7 +8,7 @@ import os, json from frappe import _ from frappe.modules import scrub, get_module_path -from frappe.utils import flt, cint, get_html_format, cstr, get_url_to_form +from frappe.utils import flt, cint, get_html_format, get_url_to_form from frappe.model.utils import render_include from frappe.translate import send_translations import frappe.desk.reportview @@ -16,6 +16,7 @@ from frappe.permissions import get_role_permissions from six import string_types, iteritems from datetime import timedelta from frappe.utils import gzip_decompress +from frappe.core.utils import ljust_list def get_report_doc(report_name): doc = frappe.get_doc("Report", report_name) @@ -42,44 +43,32 @@ def get_report_doc(report_name): return doc -def generate_report_result(report, filters=None, user=None): - status = None - if not user: - user = frappe.session.user - if not filters: - filters = [] +def generate_report_result(report, filters=None, user=None, custom_columns=None): + user = user or frappe.session.user + filters = filters or [] if filters and isinstance(filters, string_types): filters = json.loads(filters) - columns, result, message, chart, report_summary, skip_total_row = [], [], None, None, None, 0 + + res = [] + if report.report_type == "Query Report": - if not report.query: - status = "error" - frappe.msgprint(_("Must specify a Query to run"), raise_exception=True) - - if not report.query.lower().startswith("select"): - status = "error" - frappe.msgprint(_("Query must be a SELECT"), raise_exception=True) - - result = [list(t) for t in frappe.db.sql(report.query, filters)] - columns = [cstr(c[0]) for c in frappe.db.get_description()] + res = report.execute_query_report(filters) elif report.report_type == 'Script Report': res = report.execute_script_report(filters) - columns, result = res[0], res[1] - if len(res) > 2: - message = res[2] - if len(res) > 3: - chart = res[3] - if len(res) > 4: - report_summary = res[4] - if len(res) > 5: - skip_total_row = cint(res[5]) + columns, result, message, chart, report_summary, skip_total_row = \ + ljust_list(res, 6) - if report.custom_columns: - columns = json.loads(report.custom_columns) - result = add_data_to_custom_columns(columns, result) + if report.custom_columns: + columns = json.loads(report.custom_columns) + result = add_data_to_custom_columns(columns, result) + if custom_columns: + result = add_data_to_custom_columns(custom_columns, result) + + for custom_column in custom_columns: + columns.insert(custom_column['insert_after_index'] + 1, custom_column) if result: result = get_filtered_data(report.ref_doctype, columns, result, user) @@ -93,8 +82,8 @@ def generate_report_result(report, filters=None, user=None): "message": message, "chart": chart, "report_summary": report_summary, - "skip_total_row": skip_total_row, - "status": status, + "skip_total_row": skip_total_row or 0, + "status": None, "execution_time": frappe.cache().hget('report_execution_time', report.name) or 0 } @@ -161,7 +150,7 @@ def get_script(report_name): @frappe.whitelist() @frappe.read_only() -def run(report_name, filters=None, user=None, ignore_prepared_report=False): +def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None): report = get_report_doc(report_name) if not user: @@ -183,7 +172,7 @@ def run(report_name, filters=None, user=None, ignore_prepared_report=False): dn = "" result = get_prepared_report_result(report, filters, dn, user) else: - result = generate_report_result(report, filters, user) + result = generate_report_result(report, filters, user, custom_columns) result["add_total_row"] = report.add_total_row and not result.get('skip_total_row', False) @@ -294,6 +283,8 @@ def export_query(): if isinstance(data.get("file_format_type"), string_types): file_format_type = data["file_format_type"] + custom_columns = frappe.parse_json(data["custom_columns"]) + include_indentation = data["include_indentation"] if isinstance(data.get("visible_idx"), string_types): visible_idx = json.loads(data.get("visible_idx")) @@ -301,7 +292,7 @@ def export_query(): visible_idx = None if file_format_type == "Excel": - data = run(report_name, filters) + data = run(report_name, filters, custom_columns=custom_columns) data = frappe._dict(data) if not data.columns: frappe.respond_as_web_page(_("No data to export"), diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index f3eb2188b7..c0a198f5e5 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -452,16 +452,15 @@ class EmailAccount(Document): def set_sender_field_and_subject_field(self): '''Identify the sender and subject fields from the `append_to` DocType''' # set subject_field and sender_field - meta_module = frappe.get_meta_module(self.append_to) meta = frappe.get_meta(self.append_to) + self.subject_field = None + self.sender_field = None - self.subject_field = getattr(meta_module, "subject_field", "subject") - if not meta.get_field(self.subject_field): - self.subject_field = None + if hasattr(meta, "subject_field"): + self.subject_field = meta.subject_field - self.sender_field = getattr(meta_module, "sender_field", "sender") - if not meta.get_field(self.sender_field): - self.sender_field = None + if hasattr(meta, "sender_field"): + self.sender_field = meta.sender_field def find_parent_based_on_subject_and_sender(self, communication, email): '''Find parent document based on subject and sender match''' @@ -675,8 +674,21 @@ class EmailAccount(Document): @frappe.whitelist() def get_append_to(doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None): - if not txt: txt = "" - return [[d] for d in frappe.get_hooks("email_append_to") if txt in d] + txt = txt if txt else "" + email_append_to_list = [] + + # Set Email Append To DocTypes via DocType + filters = {"istable": 0, "issingle": 0, "email_append_to": 1} + for dt in frappe.get_all("DocType", filters=filters, fields=["name", "email_append_to"]): + email_append_to_list.append(dt.name) + + # Set Email Append To DocTypes set via Customize Form + for dt in frappe.get_list("Property Setter", filters={"property": "email_append_to", "value": 1}, fields=["doc_type"]): + email_append_to_list.append(dt.doc_type) + + email_append_to = [[d] for d in set(email_append_to_list) if txt in d] + + return email_append_to def test_internet(host="8.8.8.8", port=53, timeout=3): """Returns True if internet is connected diff --git a/frappe/email/doctype/email_group/email_group.json b/frappe/email/doctype/email_group/email_group.json index d02cead0b0..0d784d409a 100644 --- a/frappe/email/doctype/email_group/email_group.json +++ b/frappe/email/doctype/email_group/email_group.json @@ -1,120 +1,70 @@ { - "allow_copy": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "field:title", - "beta": 0, - "creation": "2015-03-18 06:08:32.729800", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "autoname": "field:title", + "creation": "2015-03-18 06:08:32.729800", + "doctype": "DocType", + "document_type": "Setup", + "field_order": [ + "title", + "total_subscribers", + "confirmation_email_template", + "welcome_email_template" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "title", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Title", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "no_copy": 1, + "reqd": 1, + "unique": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "0", - "fieldname": "total_subscribers", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Total Subscribers", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "default": "0", + "fieldname": "total_subscribers", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Total Subscribers", + "read_only": 1 + }, + { + "fieldname": "confirmation_email_template", + "fieldtype": "Link", + "label": "Confirmation Email Template", + "options": "Email Template" + }, + { + "fieldname": "welcome_email_template", + "fieldtype": "Link", + "label": "Welcome Email Template", + "options": "Email Template" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-02-27 19:01:17.203845", - "modified_by": "Administrator", - "module": "Email", - "name": "Email Group", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2020-02-21 14:12:48.884738", + "modified_by": "Administrator", + "module": "Email", + "name": "Email Group", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 1, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Newsletter Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "import": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Newsletter Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "quick_entry": 1, + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/email/doctype/email_group/email_group.py b/frappe/email/doctype/email_group/email_group.py index 70210faf37..23495b2d70 100755 --- a/frappe/email/doctype/email_group/email_group.py +++ b/frappe/email/doctype/email_group/email_group.py @@ -66,6 +66,11 @@ def import_from(name, doctype): def add_subscribers(name, email_list): if not isinstance(email_list, (list, tuple)): email_list = email_list.replace(",", "\n").split("\n") + + template = frappe.db.get_value('Email Group', name, 'welcome_email_template') + if template: + welcome_email = frappe.get_doc("Email Template", template) + count = 0 for email in email_list: email = email.strip() @@ -78,7 +83,9 @@ def add_subscribers(name, email_list): "doctype": "Email Group Member", "email_group": name, "email": parsed_email - }).insert(ignore_permissions = frappe.flags.ignore_permissions) + }).insert(ignore_permissions=frappe.flags.ignore_permissions) + + send_welcome_email(welcome_email, parsed_email, name) count += 1 else: @@ -90,3 +97,15 @@ def add_subscribers(name, email_list): return frappe.get_doc("Email Group", name).update_total_subscribers() +def send_welcome_email(welcome_email, email, email_group): + """Send welcome email for the subscribers of a given email group.""" + if not welcome_email: + return + + args = dict( + email=email, + email_group=email_group + ) + + message = frappe.render_template(welcome_email.response, args) + frappe.sendmail(email, subject=welcome_email.subject, message=message) \ No newline at end of file diff --git a/frappe/email/doctype/newsletter/newsletter.js b/frappe/email/doctype/newsletter/newsletter.js index 7bf0ae6b9a..0f1e8dc57c 100644 --- a/frappe/email/doctype/newsletter/newsletter.js +++ b/frappe/email/doctype/newsletter/newsletter.js @@ -4,23 +4,65 @@ frappe.ui.form.on('Newsletter', { refresh(frm) { let doc = frm.doc; - if(!doc.__islocal && !cint(doc.email_sent) && !doc.__unsaved + if (!doc.__islocal && !cint(doc.email_sent) && !doc.__unsaved && in_list(frappe.boot.user.can_write, doc.doctype)) { - frm.add_custom_button(__('Send'), function() { - frm.call('send_emails').then(() => { - frm.refresh(); + frm.add_custom_button(__('Send Now'), function() { + frappe.confirm(__("Do you really want to send this email newsletter?"), function() { + frm.call('send_emails').then(() => { + frm.refresh(); + }); }); }, "fa fa-play", "btn-success"); } + if (!doc.__islocal && cint(doc.email_sent)) { + frm.set_df_property('schedule_send', "read_only", 1); + } frm.events.setup_dashboard(frm); - if(doc.__islocal && !doc.send_from) { + if (doc.__islocal && !doc.send_from) { let { fullname, email } = frappe.user_info(doc.owner); frm.set_value('send_from', `${fullname} <${email}>`); } }, + onload_post_render(frm) { + frm.trigger('setup_schedule_send'); + }, + + setup_schedule_send(frm) { + let today = new Date(); + + // setting datepicker options to set min date & min time + today.setHours(today.getHours() + 1 ); + frm.get_field('schedule_send').$input.datepicker({ + maxMinutes: 0, + minDate: today, + timeFormat: 'hh:00:00', + onSelect: function (fd, d, picker) { + if (!d) return; + var date = d.toDateString(); + if (date === today.toDateString()) { + picker.update({ + minHours: (today.getHours() + 1) + }); + } else { + picker.update({ + minHours: 0 + }); + } + frm.get_field('schedule_send').$input.trigger('change'); + } + }); + + + const $tp = frm.get_field('schedule_send').datepicker.timepicker; + $tp.$minutes.parent().css('display', 'none'); + $tp.$minutesText.css('display', 'none'); + $tp.$minutesText.prev().css('display', 'none'); + $tp.$seconds.parent().css('display', 'none'); + }, + setup_dashboard(frm) { if(!frm.doc.__islocal && cint(frm.doc.email_sent) && frm.doc.__onload && frm.doc.__onload.status_count) { diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index 1ef51027ed..719d51c176 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "creation": "2013-01-10 16:34:31", "description": "Create and Send Newsletters", @@ -6,9 +7,11 @@ "document_type": "Other", "engine": "InnoDB", "field_order": [ + "send_from", + "column_break_2", + "schedule_send", "recipients", "email_group", - "send_from", "email_sent", "newsletter_content", "subject", @@ -41,7 +44,7 @@ "default": "0", "fieldname": "email_sent", "fieldtype": "Check", - "label": "Email Sent?", + "label": "Email Sent", "no_copy": 1, "read_only": 1 }, @@ -115,14 +118,24 @@ "fieldname": "recipients", "fieldtype": "Section Break", "label": "Recipients" + }, + { + "fieldname": "schedule_send", + "fieldtype": "Datetime", + "label": "Schedule Send" + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" } ], "has_web_view": 1, "icon": "fa fa-envelope", "idx": 1, "is_published_field": "published", + "links": [], "max_attachments": 3, - "modified": "2019-09-06 22:15:55.471254", + "modified": "2020-03-02 06:26:51.622521", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index b0d1756643..2d40ffd800 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -11,7 +11,7 @@ from frappe.utils.verified_command import get_signed_params, verify_request from frappe.utils.background_jobs import enqueue from frappe.email.queue import send from frappe.email.doctype.email_group.email_group import add_subscribers -from frappe.utils import parse_addr +from frappe.utils import parse_addr, now_datetime from frappe.utils import validate_email_address @@ -42,7 +42,6 @@ class Newsletter(WebsiteGenerator): if self.recipients: if getattr(frappe.local, "is_ajax", False): self.validate_send() - # using default queue with a longer timeout as this isn't a scheduled task enqueue(send_newsletter, queue='default', timeout=6000, event='send_newsletter', newsletter=self.name) @@ -53,6 +52,7 @@ class Newsletter(WebsiteGenerator): frappe.msgprint(_("Scheduled to send to {0} recipients").format(len(self.recipients))) frappe.db.set(self, "email_sent", 1) + frappe.db.set(self, "schedule_send", now_datetime()) frappe.db.set(self, 'scheduled_to_send', len(self.recipients)) else: frappe.msgprint(_("Newsletter should have atleast one recipient")) @@ -160,39 +160,52 @@ def create_lead(email_id): @frappe.whitelist(allow_guest=True) -def subscribe(email): +def subscribe(email, email_group=_('Website')): url = frappe.utils.get_url("/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription") +\ - "?" + get_signed_params({"email": email}) + "?" + get_signed_params({"email": email, "email_group": email_group}) - messages = ( - _("Thank you for your interest in subscribing to our updates"), - _("Please verify your Email Address"), - url, - _("Click here to verify") - ) + email_template = frappe.db.get_value('Email Group', email_group, ['confirmation_email_template']) - content = """ -

{0}. {1}.

-

{3}

- """ + content='' + if email_template: + args = dict( + email=email, + confirmation_url=url, + email_group=email_group + ) - frappe.sendmail(email, subject=_("Confirm Your Email"), content=content.format(*messages)) + email_template = frappe.get_doc("Email Template", email_template) + content = frappe.render_template(email_template.response, args) + + if not content: + messages = ( + _("Thank you for your interest in subscribing to our updates"), + _("Please verify your Email Address"), + url, + _("Click here to verify") + ) + + content = """ +

{0}. {1}.

+

{3}

+ """.format(*messages) + + frappe.sendmail(email, subject=getattr('email_template', 'subject', '') or _("Confirm Your Email"), content=content) @frappe.whitelist(allow_guest=True) -def confirm_subscription(email): +def confirm_subscription(email, email_group=_('Website')): if not verify_request(): return - if not frappe.db.exists("Email Group", _("Website")): + if not frappe.db.exists("Email Group", email_group): frappe.get_doc({ "doctype": "Email Group", - "title": _("Website") + "title": email_group }).insert(ignore_permissions=True) - frappe.flags.ignore_permissions = True - add_subscribers(_("Website"), email) + add_subscribers(email_group, email) frappe.db.commit() frappe.respond_as_web_page(_("Confirmed"), @@ -212,7 +225,7 @@ def send_newsletter(newsletter): doc.db_set("email_sent", 0) frappe.db.commit() - frappe.log_error("send_newsletter") + frappe.log_error(title='Send Newsletter') raise @@ -250,3 +263,11 @@ def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20 '''.format(','.join(['%s'] * len(email_group_list)), limit_page_length, limit_start), email_group_list, as_dict=1) +def send_scheduled_email(): + """Send scheduled newsletter to the recipients.""" + scheduled_newsletter = frappe.get_all('Newsletter', filters = { + 'schedule_send': ('<=', now_datetime()), + 'email_sent': 0 + }, fields = ['name']) + for newsletter in scheduled_newsletter: + send_newsletter(newsletter.name) \ No newline at end of file diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 04af44f78a..5a13f99e56 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -3,8 +3,9 @@ from __future__ import unicode_literals import frappe, unittest +from frappe.utils import getdate, add_days -from frappe.email.doctype.newsletter.newsletter import confirmed_unsubscribe +from frappe.email.doctype.newsletter.newsletter import confirmed_unsubscribe, send_scheduled_email from six.moves.urllib.parse import unquote test_dependencies = ["Email Group"] @@ -58,7 +59,7 @@ class TestNewsletter(unittest.TestCase): self.assertTrue(email in recipients) @staticmethod - def send_newsletter(published=0): + def send_newsletter(published=0, schedule_send=None): frappe.db.sql("delete from `tabEmail Queue`") frappe.db.sql("delete from `tabEmail Queue Recipient`") frappe.db.sql("delete from `tabNewsletter`") @@ -67,11 +68,16 @@ class TestNewsletter(unittest.TestCase): "subject": "_Test Newsletter", "send_from": "Test Sender ", "message": "Testing my news.", - "published": published + "published": published, + "schedule_send": schedule_send }).insert(ignore_permissions=True) newsletter.append("email_group", {"email_group": "_Test Email Group"}) newsletter.save() + if schedule_send: + send_scheduled_email() + return + newsletter.send_emails() return newsletter.name @@ -89,4 +95,13 @@ class TestNewsletter(unittest.TestCase): doc = frappe.get_doc("Newsletter", newsletter_name) doc.get_context(context) self.assertEqual(context.no_cache, 1) - self.assertTrue("attachments" not in list(context)) \ No newline at end of file + self.assertTrue("attachments" not in list(context)) + + def test_schedule_send(self): + self.send_newsletter(schedule_send=add_days(getdate(), -1)) + + email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")] + self.assertEqual(len(email_queue_list), 4) + recipients = [e.recipients[0].recipient for e in email_queue_list] + for email in emails: + self.assertTrue(email in recipients) \ No newline at end of file diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 3d63f4b2b4..732fc39e9a 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -78,6 +78,7 @@ class TimestampMismatchError(ValidationError): pass class EmptyTableError(ValidationError): pass class LinkExistsError(ValidationError): pass class InvalidEmailAddressError(ValidationError): pass +class InvalidPhoneNumberError(ValidationError): pass class TemplateNotFoundError(ValidationError): pass class UniqueValidationError(ValidationError): pass class AppNotInstalledError(ValidationError): pass diff --git a/frappe/hooks.py b/frappe/hooks.py index c44c05fdf4..1b12b26c3a 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -86,14 +86,17 @@ permission_query_conditions = { "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", "ToDo": "frappe.desk.doctype.todo.todo.get_permission_query_conditions", "User": "frappe.core.doctype.user.user.get_permission_query_conditions", + "Dashboard Settings": "frappe.desk.doctype.dashboard_settings.dashboard_settings.get_permission_query_conditions", "Notification Log": "frappe.desk.doctype.notification_log.notification_log.get_permission_query_conditions", + "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_permission_query_conditions", "Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.get_permission_query_conditions", "Note": "frappe.desk.doctype.note.note.get_permission_query_conditions", "Kanban Board": "frappe.desk.doctype.kanban_board.kanban_board.get_permission_query_conditions", "Contact": "frappe.contacts.address_and_contact.get_permission_query_conditions_for_contact", "Address": "frappe.contacts.address_and_contact.get_permission_query_conditions_for_address", "Communication": "frappe.core.doctype.communication.communication.get_permission_query_conditions_for_communication", - "Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions" + "Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions", + "Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.get_permission_query_condition" } has_permission = { @@ -101,12 +104,14 @@ has_permission = { "ToDo": "frappe.desk.doctype.todo.todo.has_permission", "User": "frappe.core.doctype.user.user.has_permission", "Note": "frappe.desk.doctype.note.note.has_permission", + "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.has_permission", "Kanban Board": "frappe.desk.doctype.kanban_board.kanban_board.has_permission", "Contact": "frappe.contacts.address_and_contact.has_permission", "Address": "frappe.contacts.address_and_contact.has_permission", "Communication": "frappe.core.doctype.communication.communication.has_permission", "Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.has_permission", - "File": "frappe.core.doctype.file.file.has_permission" + "File": "frappe.core.doctype.file.file.has_permission", + "Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.has_permission" } has_website_permission = { @@ -184,7 +189,8 @@ scheduler_events = { "frappe.desk.page.backups.backups.delete_downloadable_backups", "frappe.deferred_insert.save_to_db", "frappe.desk.form.document_follow.send_hourly_updates", - "frappe.integrations.doctype.google_calendar.google_calendar.sync" + "frappe.integrations.doctype.google_calendar.google_calendar.sync", + "frappe.email.doctype.newsletter.newsletter.send_scheduled_email" ], "daily": [ "frappe.email.queue.clear_outbox", diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.json b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.json index 33d34e0210..858469647a 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.json +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.json @@ -1,487 +1,129 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-09-21 10:12:57.399174", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "System", - "editable_grid": 1, + "creation": "2016-09-21 10:12:57.399174", + "doctype": "DocType", + "document_type": "System", + "editable_grid": 1, + "field_order": [ + "enabled", + "send_notifications_to", + "send_email_for_successful_backup", + "backup_frequency", + "limit_no_of_backups", + "no_of_backups", + "file_backup", + "app_access_key", + "app_secret_key", + "allow_dropbox_access", + "dropbox_access_key", + "dropbox_access_secret", + "dropbox_access_token" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "enabled", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Enabled", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "send_notifications_to", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Send Notifications To", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "send_notifications_to", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Send Notifications To", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "1", - "description": "Note: By default emails for failed backups are sent.", - "fieldname": "send_email_for_successful_backup", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Send Email for Successful Backup", - "length": 0, - "no_copy": 0, - "options": "", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "description": "Note: By default emails for failed backups are sent.", + "fieldname": "send_email_for_successful_backup", + "fieldtype": "Check", + "label": "Send Email for Successful Backup" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "backup_frequency", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Backup Frequency", - "length": 0, - "no_copy": 0, - "options": "\nDaily\nWeekly", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "backup_frequency", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Backup Frequency", + "options": "\nDaily\nWeekly", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "limit_no_of_backups", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Limit Number of DB Backups", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "limit_no_of_backups", + "fieldtype": "Check", + "label": "Limit Number of DB Backups" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "5", - "depends_on": "eval:doc.limit_no_of_backups", - "fieldname": "no_of_backups", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Number of DB Backups", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "5", + "depends_on": "eval:doc.limit_no_of_backups", + "fieldname": "no_of_backups", + "fieldtype": "Int", + "label": "Number of DB Backups" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "1", - "fieldname": "file_backup", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "File Backup", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "fieldname": "file_backup", + "fieldtype": "Check", + "label": "File Backup" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fieldname": "app_access_key", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "App Access Key", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "app_access_key", + "fieldtype": "Data", + "label": "App Access Key" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "", - "fieldname": "app_secret_key", - "fieldtype": "Password", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "App Secret Key", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "app_secret_key", + "fieldtype": "Password", + "label": "App Secret Key" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "allow_dropbox_access", - "fieldtype": "Button", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Allow Dropbox Access", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "allow_dropbox_access", + "fieldtype": "Button", + "label": "Allow Dropbox Access" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "dropbox_access_key", - "fieldtype": "Password", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Dropbox Access Key", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "dropbox_access_key", + "fieldtype": "Password", + "hidden": 1, + "label": "Dropbox Access Key", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "dropbox_access_secret", - "fieldtype": "Password", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Dropbox Access Secret", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "dropbox_access_secret", + "fieldtype": "Password", + "hidden": 1, + "label": "Dropbox Access Secret", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "dropbox_access_token", - "fieldtype": "Password", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Dropbox Access Token", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "dropbox_access_token", + "fieldtype": "Password", + "hidden": 1, + "label": "Dropbox Access Token" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 1, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2019-01-03 05:44:40.520943", - "modified_by": "Administrator", - "module": "Integrations", - "name": "Dropbox Settings", - "name_case": "", - "owner": "Administrator", + ], + "in_create": 1, + "issingle": 1, + "modified": "2019-08-22 16:26:44.468391", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Dropbox Settings", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 0, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 1, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 -} + ], + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index dd4768f8b3..2a036f4838 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -3,22 +3,25 @@ # For license information, please see license.txt from __future__ import unicode_literals +import dropbox +import json import frappe import os from frappe import _ from frappe.model.document import Document -import dropbox, json +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size +from frappe.integrations.utils import make_post_request +from frappe.utils import (cint, get_request_site_address, + get_files_path, get_backups_path, get_url, encode) from frappe.utils.backups import new_backup from frappe.utils.background_jobs import enqueue from six.moves.urllib.parse import urlparse, parse_qs -from frappe.integrations.utils import make_post_request from rq.timeouts import JobTimeoutException -from frappe.utils import (cint, split_emails, get_request_site_address, - get_files_path, get_backups_path, get_url, encode) from six import text_type ignore_list = [".DS_Store"] + class DropboxSettings(Document): def onload(self): if not self.app_access_key and frappe.conf.dropbox_access_key: @@ -48,10 +51,12 @@ def take_backup_to_dropbox(retry_count=0, upload_db_backup=True): did_not_upload, error_log = [], [] try: if cint(frappe.db.get_value("Dropbox Settings", None, "enabled")): + validate_file_size() + did_not_upload, error_log = backup_to_dropbox(upload_db_backup) if did_not_upload: raise Exception - send_email(True, "Dropbox") + send_email(True, "Dropbox", "Dropbox Settings", "send_notifications_to") except JobTimeoutException: if retry_count < 2: args = { @@ -66,34 +71,8 @@ def take_backup_to_dropbox(retry_count=0, upload_db_backup=True): else: file_and_error = [" - ".join(f) for f in zip(did_not_upload, error_log)] error_message = ("\n".join(file_and_error) + "\n" + frappe.get_traceback()) - frappe.errprint(error_message) - send_email(False, "Dropbox", error_message) -def send_email(success, service_name, error_status=None): - if success: - if frappe.db.get_value("Dropbox Settings", None, "send_email_for_successful_backup") == '0': - return - - subject = "Backup Upload Successful" - message ="""

Backup Uploaded Successfully

Hi there, this is just to inform you - that your backup was successfully uploaded to your %s account. So relax!

- """ % service_name - - else: - subject = "[Warning] Backup Upload Failed" - message ="""

Backup Upload Failed

Oops, your automated backup to %s - failed.

-

Error message:
-

%s
-

-

Please contact your system manager for more information.

- """ % (service_name, error_status) - - if not frappe.db: - frappe.connect() - - recipients = split_emails(frappe.db.get_value("Dropbox Settings", None, "send_notifications_to")) - frappe.sendmail(recipients=recipients, subject=subject, message=message) + send_email(False, "Dropbox", "Dropbox Settings", "send_notifications_to", error_message) def backup_to_dropbox(upload_db_backup=True): if not frappe.db: @@ -114,8 +93,12 @@ def backup_to_dropbox(upload_db_backup=True): dropbox_client = dropbox.Dropbox(dropbox_settings['access_token']) if upload_db_backup: - backup = new_backup(ignore_files=True) - filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_db)) + if frappe.flags.create_new_backup: + backup = new_backup(ignore_files=True) + filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_db)) + else: + filename = get_latest_backup_file() + upload_file_to_dropbox(filename, "/database", dropbox_client) # delete older databases diff --git a/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py new file mode 100644 index 0000000000..539fc417f2 --- /dev/null +++ b/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestDropboxSettings(unittest.TestCase): + pass diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py index 8078c702c0..60ee173bbf 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.py +++ b/frappe/integrations/doctype/google_drive/google_drive.py @@ -19,6 +19,7 @@ from apiclient.http import MediaFileUpload from frappe.utils import get_backups_path, get_bench_path from frappe.utils.backups import new_backup from frappe.integrations.doctype.google_settings.google_settings import get_auth_url +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size SCOPES = "https://www.googleapis.com/auth/drive" @@ -183,13 +184,16 @@ def upload_system_backup_to_google_drive(): check_for_folder_in_google_drive() account.load_from_db() - progress(1, "Backing up Data.") - backup = new_backup() - - fileurl_backup = os.path.basename(backup.backup_path_db) - fileurl_public_files = os.path.basename(backup.backup_path_files) - fileurl_private_files = os.path.basename(backup.backup_path_private_files) + validate_file_size() + if frappe.flags.create_new_backup: + set_progress(1, "Backing up Data.") + backup = new_backup() + fileurl_backup = os.path.basename(backup.backup_path_db) + fileurl_public_files = os.path.basename(backup.backup_path_files) + fileurl_private_files = os.path.basename(backup.backup_path_private_files) + else: + fileurl_backup, fileurl_public_files, fileurl_private_files = get_latest_backup_file(with_files=True) for fileurl in [fileurl_backup, fileurl_public_files, fileurl_private_files]: file_metadata = { @@ -203,15 +207,14 @@ def upload_system_backup_to_google_drive(): frappe.throw(_("Google Drive - Could not locate locate - {0}").format(e)) try: - progress(2, "Uploading backup to Google Drive.") + set_progress(2, "Uploading backup to Google Drive.") google_drive.files().create(body=file_metadata, media_body=media, fields="id").execute() except HttpError as e: - send_email(success=False, error=e) - frappe.msgprint(_("Google Drive - Could not upload backup - Error {0}").format(e)) + send_email(False, "Google Drive", "Google Drive", "email", error_status=e) - progress(3, "Uploading successful.") + set_progress(3, "Uploading successful.") frappe.db.set_value("Google Drive", None, "last_backup_on", frappe.utils.now_datetime()) - send_email(success=True) + send_email(True, "Google Drive", "Google Drive", "email") return _("Google Drive Backup Successful.") def daily_backup(): @@ -226,30 +229,5 @@ def get_absolute_path(filename): file_path = os.path.join(get_backups_path()[2:], filename) return "{0}/sites/{1}".format(get_bench_path(), file_path) -def progress(progress, message): +def set_progress(progress, message): frappe.publish_realtime("upload_to_google_drive", dict(progress=progress, total=3, message=message), user=frappe.session.user) - -def send_email(success, error=None): - if success: - if not frappe.db.get_single_value("Google Drive", "send_email_for_successful_backup"): - return - - subject = "Backup Upload Successful" - message = """

Backup Uploaded Successfully

Hi there, this is just to inform you - that your backup was successfully uploaded to Google Drive.

- """ - else: - subject = "[Warning] Backup Upload Failed" - message = """

Backup Upload Failed

Oops, your automated backup to Google Drive - failed.

-

Error message:
-

{0}
-

-

Please contact your system manager for more information.

- """.format(error) - - frappe.sendmail( - recipients=frappe.db.get_single_value("Google Drive", "email"), - subject=subject, - message=message - ) \ No newline at end of file diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index c0f12df04a..558f7117c0 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe -from frappe import _ +from frappe import _, safe_encode from frappe.model.document import Document @@ -19,7 +19,7 @@ class LDAPSettings(Document): else: frappe.throw(_("LDAP Search String needs to end with a placeholder, eg sAMAccountName={0}")) - def connect_to_ldap(self, base_dn, password): + def connect_to_ldap(self, base_dn, password, read_only=True): try: import ldap3 import ssl @@ -44,7 +44,7 @@ class LDAPSettings(Document): user=base_dn, password=password, auto_bind=bind_type, - read_only=True, + read_only=read_only, raise_exceptions=True) return conn @@ -170,6 +170,36 @@ class LDAPSettings(Document): else: frappe.throw(_("Invalid username or password")) + def reset_password(self, user, password, logout_sessions=False): + from ldap3 import HASHED_SALTED_SHA, MODIFY_REPLACE + from ldap3.utils.hashed import hashed + + search_filter = "({0}={1})".format(self.ldap_email_field, user) + + conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False), + read_only=False) + + if conn.search( + search_base=self.organizational_unit, + search_filter=search_filter, + attributes=self.get_ldap_attributes() + ): + if conn.entries and conn.entries[0]: + entry_dn = conn.entries[0].entry_dn + hashed_password = hashed(HASHED_SALTED_SHA, safe_encode(password)) + changes = {'userPassword': [(MODIFY_REPLACE, [hashed_password])]} + if conn.modify(entry_dn, changes=changes): + if logout_sessions: + from frappe.sessions import clear_sessions + clear_sessions(user=user, force=True) + frappe.msgprint(_("Password changed successfully.")) + else: + frappe.throw(_("Failed to change password.")) + else: + frappe.throw(_("No Entry for the User {0} found within LDAP!").format(user)) + else: + frappe.throw(_("No LDAP User found for email: {0}").format(user)) + def convert_ldap_entry_to_dict(self, user_entry): # support multiple email values @@ -211,3 +241,11 @@ def login(): # because of a GET request! frappe.db.commit() + + +@frappe.whitelist() +def reset_password(user, password, logout): + ldap = frappe.get_doc("LDAP Settings") + if not ldap.enabled: + frappe.throw(_("LDAP is not enabled.")) + ldap.reset_password(user, password, logout_sessions=int(logout)) diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json index 93fff995f0..bbdbf74a67 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json @@ -1,397 +1,110 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2017-09-04 20:57:20.129205", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "creation": "2017-09-04 20:57:20.129205", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "notify_email", + "send_email_for_successful_backup", + "frequency", + "access_key_id", + "secret_access_key", + "region", + "endpoint_url", + "bucket", + "backup_limit" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "enabled", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Enable Automatic Backup", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enable Automatic Backup" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "notify_email", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Send Notifications To", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "notify_email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Send Notifications To", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "1", - "description": "Note: By default emails for failed backups are sent.", - "fetch_if_empty": 0, - "fieldname": "send_email_for_successful_backup", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Send Email for Successful Backup", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "description": "Note: By default emails for failed backups are sent.", + "fieldname": "send_email_for_successful_backup", + "fieldtype": "Check", + "label": "Send Email for Successful Backup" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "frequency", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Backup Frequency", - "length": 0, - "no_copy": 0, - "options": "Daily\nWeekly\nMonthly\nNone", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "frequency", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Backup Frequency", + "options": "Daily\nWeekly\nMonthly\nNone", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "access_key_id", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Access Key ID", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "access_key_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Access Key ID", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "secret_access_key", - "fieldtype": "Password", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Secret Access Key", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "secret_access_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Secret Access Key", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "us-east-1", - "description": "See https://docs.aws.amazon.com/de_de/general/latest/gr/rande.html#s3_region for details.", - "fetch_if_empty": 0, - "fieldname": "region", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Region", - "length": 0, - "no_copy": 0, - "options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-north-1\nsa-east-1", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "us-east-1", + "description": "See https://docs.aws.amazon.com/de_de/general/latest/gr/rande.html#s3_region for details.", + "fieldname": "region", + "fieldtype": "Select", + "label": "Region", + "options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-north-1\nsa-east-1" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "endpoint_url", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Endpoint URL", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "endpoint_url", + "fieldtype": "Data", + "label": "Endpoint URL" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "bucket", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Bucket", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "bucket", + "fieldtype": "Data", + "label": "Bucket", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, - "fieldname": "backup_limit", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Backup Limit", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "backup_limit", + "fieldtype": "Int", + "label": "Backup Limit", + "reqd": 1 } - ], - "has_web_view": 0, - "hide_toolbar": 1, - "idx": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "menu_index": 0, - "modified": "2019-04-10 03:56:55.632017", - "modified_by": "Administrator", - "module": "Integrations", - "name": "S3 Backup Settings", - "name_case": "", - "owner": "Administrator", + ], + "hide_toolbar": 1, + "issingle": 1, + "modified": "2019-08-22 16:26:04.774571", + "modified_by": "Administrator", + "module": "Integrations", + "name": "S3 Backup Settings", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 0, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py index 55b9e63a4d..7e69da922c 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py @@ -8,12 +8,14 @@ import os.path import frappe import boto3 from frappe import _ +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size from frappe.model.document import Document -from frappe.utils import cint, split_emails +from frappe.utils import cint from frappe.utils.background_jobs import enqueue from rq.timeouts import JobTimeoutException from botocore.exceptions import ClientError + class S3BackupSettings(Document): def validate(self): @@ -49,7 +51,7 @@ class S3BackupSettings(Document): @frappe.whitelist() def take_backup(): - "Enqueue longjob for taking backup to s3" + """Enqueue longjob for taking backup to s3""" enqueue("frappe.integrations.doctype.s3_backup_settings.s3_backup_settings.take_backups_s3", queue='long', timeout=1500) frappe.msgprint(_("Queued for backup. It may take a few minutes to an hour.")) @@ -65,22 +67,21 @@ def take_backups_weekly(): def take_backups_monthly(): take_backups_if("Monthly") - def take_backups_if(freq): if cint(frappe.db.get_value("S3 Backup Settings", None, "enabled")): if frappe.db.get_value("S3 Backup Settings", None, "frequency") == freq: take_backups_s3() - @frappe.whitelist() def take_backups_s3(retry_count=0): try: + validate_file_size() backup_to_s3() - send_email(True, "S3 Backup Settings") + send_email(True, "Amazon S3", "S3 Backup Settings", "notify_email") except JobTimeoutException: if retry_count < 2: args = { - "retry_count" :retry_count + 1 + "retry_count": retry_count + 1 } enqueue("frappe.integrations.doctype.s3_backup_settings.s3_backup_settings.take_backups_s3", queue='long', timeout=1500, **args) @@ -89,31 +90,10 @@ def take_backups_s3(retry_count=0): except Exception: notify() + def notify(): error_message = frappe.get_traceback() - frappe.errprint(error_message) - send_email(False, "S3 Backup Settings", error_message) - -def send_email(success, service_name, error_status=None): - if success: - if frappe.db.get_value("S3 Backup Settings", None, "send_email_for_successful_backup") == '0': - return - - subject = "Backup Upload Successful" - message = """

Backup Uploaded Successfully!

Hi there, this is just to inform you - that your backup was successfully uploaded to your Amazon S3 bucket. So relax!

""" - - else: - subject = "[Warning] Backup Upload Failed" - message = """

Backup Upload Failed!

Oops, your automated backup to Amazon S3 failed. -

Error message: %s

Please contact your system manager - for more information.

""" % error_status - - if not frappe.db: - frappe.connect() - - recipients = split_emails(frappe.db.get_value("S3 Backup Settings", None, "notify_email")) - frappe.sendmail(recipients=recipients, subject=subject, message=message) + send_email(False, 'Amazon S3', "S3 Backup Settings", "notify_email", error_message) def backup_to_s3(): @@ -130,11 +110,15 @@ def backup_to_s3(): endpoint_url=doc.endpoint_url or 'https://s3.amazonaws.com' ) - backup = new_backup(ignore_files=False, backup_path_db=None, + if frappe.flags.create_new_backup: + backup = new_backup(ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=True) - db_filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_db)) - files_filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_files)) - private_files = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_private_files)) + db_filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_db)) + files_filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_files)) + private_files = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_private_files)) + else: + db_filename, files_filename, private_files = get_latest_backup_file(with_files=True) + folder = os.path.basename(db_filename)[:15] + '/' # for adding datetime to folder name @@ -143,8 +127,8 @@ def backup_to_s3(): upload_file_to_s3(files_filename, folder, conn, bucket) delete_old_backups(doc.backup_limit, bucket) -def upload_file_to_s3(filename, folder, conn, bucket): +def upload_file_to_s3(filename, folder, conn, bucket): destpath = os.path.join(folder, os.path.basename(filename)) try: print("Uploading file:", filename) @@ -156,7 +140,7 @@ def upload_file_to_s3(filename, folder, conn, bucket): def delete_old_backups(limit, bucket): - all_backups = list() + all_backups = [] doc = frappe.get_single("S3 Backup Settings") backup_limit = int(limit) diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py new file mode 100644 index 0000000000..c280a1d9dd --- /dev/null +++ b/frappe/integrations/offsite_backup_utils.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import glob +import os +from frappe.utils import split_emails, get_backups_path + + +def send_email(success, service_name, doctype, email_field, error_status=None): + recipients = get_recipients(service_name, email_field) + if not recipients: + frappe.log_error("No Email Recipient found for {0}".format(service_name), + "{0}: Failed to send backup status email".format(service_name)) + return + + if success: + if not frappe.db.get_value(doctype, None, "send_email_for_successful_backup"): + return + + subject = "Backup Upload Successful" + message = """ +

Backup Uploaded Successfully!

+

Hi there, this is just to inform you that your backup was successfully uploaded to your {0} bucket. So relax!

""".format(service_name) + + else: + subject = "[Warning] Backup Upload Failed" + message = """ +

Backup Upload Failed!

+

Oops, your automated backup to {0} failed.

+

Error message: {1}

+

Please contact your system manager for more information.

""".format(service_name, error_status) + + frappe.sendmail(recipients=recipients, subject=subject, message=message) + + +def get_recipients(service_name, email_field): + if not frappe.db: + frappe.connect() + + return split_emails(frappe.db.get_value(service_name, None, email_field)) + + +def get_latest_backup_file(with_files=False): + + def get_latest(file_ext): + file_list = glob.glob(os.path.join(get_backups_path(), file_ext)) + return max(file_list, key=os.path.getctime) + + latest_file = get_latest('*.sql.gz') + + if with_files: + latest_public_file_bak = get_latest('*-files.tar') + latest_private_file_bak = get_latest('*-private-files.tar') + return latest_file, latest_public_file_bak, latest_private_file_bak + + return latest_file + + +def get_file_size(file_path, unit): + if not unit: + unit = 'MB' + + file_size = os.path.getsize(file_path) + + memory_size_unit_mapper = {'KB': 1, 'MB': 2, 'GB': 3, 'TB': 4} + i = 0 + while i < memory_size_unit_mapper[unit]: + file_size = file_size / 1000.0 + i += 1 + + return file_size + + +def validate_file_size(): + frappe.flags.create_new_backup = True + latest_file = get_latest_backup_file() + file_size = get_file_size(latest_file, unit='GB') + + if file_size > 1: + frappe.flags.create_new_backup = False diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 811b007131..808affe47a 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies and contributors +# Copyright (c) 2019, Frappe Technologies and contributors # For license information, please see license.txt from __future__ import unicode_literals diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 1fe92d7a67..7af987f4bc 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -48,6 +48,7 @@ table_fields = ('Table', 'Table MultiSelect') core_doctypes_list = ('DocType', 'DocField', 'DocPerm', 'DocType Action', 'DocType Link', 'User', 'Role', 'Has Role', 'Page', 'Module Def', 'Print Format', 'Report', 'Customize Form', 'Customize Form Field', 'Property Setter', 'Custom Field', 'Custom Script') +data_field_options = ('Email', 'Phone') def copytables(srctype, src, srcfield, tartype, tar, tarfield, srcfields, tarfields=[]): if not tarfields: diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 569cea9d5f..4af502f844 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -544,6 +544,23 @@ class BaseDocument(object): frappe.throw(_('{0} {1} cannot be "{2}". It should be one of "{3}"').format(prefix, label, value, comma_options)) + def _validate_data_fields(self): + from frappe.core.doctype.user.user import STANDARD_USERS + + # data_field options defined in frappe.model.data_field_options + for data_field in self.meta.get_data_fields(): + data = self.get(data_field.fieldname) + data_field_options = data_field.get("options") + + if data_field_options == "Email": + if (self.owner in STANDARD_USERS) and (data in STANDARD_USERS): + return + for email_address in frappe.utils.split_emails(data): + frappe.utils.validate_email_address(email_address, throw=True) + + if data_field_options == "Phone": + frappe.utils.validate_phone_number(data, throw=True) + def _validate_constants(self): if frappe.flags.in_import or self.is_new() or self.flags.ignore_validate_constants: return diff --git a/frappe/model/document.py b/frappe/model/document.py index 41f946efd9..03b21ea667 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -468,6 +468,7 @@ class Document(BaseDocument): def _validate(self): self._validate_mandatory() + self._validate_data_fields() self._validate_selects() self._validate_length() self._extract_images_from_text_editor() @@ -477,6 +478,7 @@ class Document(BaseDocument): children = self.get_all_children() for d in children: + d._validate_data_fields() d._validate_selects() d._validate_length() d._extract_images_from_text_editor() @@ -978,7 +980,7 @@ class Document(BaseDocument): def reset_seen(self): """Clear _seen property and set current user as seen""" if getattr(self.meta, 'track_seen', False): - self.db_set('_seen', json.dumps([frappe.session.user]), update_modified=False) + frappe.db.set_value(self.doctype, self.name, "_seen", json.dumps([frappe.session.user]), update_modified=False) def notify_update(self): """Publish realtime that the current document is modified""" diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 1938a4a96c..9c71f8c0b1 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -68,7 +68,7 @@ def load_doctype_from_file(doctype): class Meta(Document): _metaclass = True default_fields = list(default_fields)[1:] - special_doctypes = ("DocField", "DocPerm", "Role", "DocType", "Module Def") + special_doctypes = ("DocField", "DocPerm", "Role", "DocType", "Module Def", 'DocType Action', 'DocType Link') def __init__(self, doctype): self._fields = {} @@ -128,6 +128,9 @@ class Meta(Document): def get_link_fields(self): return self.get("fields", {"fieldtype": "Link", "options":["!=", "[Select]"]}) + def get_data_fields(self): + return self.get("fields", {"fieldtype": "Data"}) + def get_dynamic_link_fields(self): if not hasattr(self, '_dynamic_link_fields'): self._dynamic_link_fields = self.get("fields", {"fieldtype": "Dynamic Link"}) @@ -165,7 +168,8 @@ class Meta(Document): def get_valid_columns(self): if not hasattr(self, "_valid_columns"): - if self.name in ("DocType", "DocField", "DocPerm", 'DocType Action', 'DocType Link', "Property Setter"): + table_exists = frappe.db.table_exists(self.name) + if self.name in self.special_doctypes and table_exists: self._valid_columns = get_table_columns(self.name) else: self._valid_columns = self.default_fields + \ @@ -290,17 +294,20 @@ class Meta(Document): return get_workflow_name(self.name) def add_custom_fields(self): - try: - self.extend("fields", frappe.db.sql("""SELECT * FROM `tabCustom Field` - WHERE dt = %s AND docstatus < 2""", (self.name,), as_dict=1, - update={"is_custom_field": 1})) - except Exception as e: - if frappe.db.is_table_missing(e): - return - else: - raise + if not frappe.db.table_exists('Custom Field'): + return + + custom_fields = frappe.db.sql(""" + SELECT * FROM `tabCustom Field` + WHERE dt = %s AND docstatus < 2 + """, (self.name,), as_dict=1, update={"is_custom_field": 1}) + + self.extend("fields", custom_fields) def apply_property_setters(self): + if not frappe.db.table_exists('Property Setter'): + return + property_setters = frappe.db.sql("""select * from `tabProperty Setter` where doc_type=%s""", (self.name,), as_dict=1) @@ -378,8 +385,9 @@ class Meta(Document): if custom_perms: self.permissions = [Document(d) for d in custom_perms] - def get_fieldnames_with_value(self): - return [df.fieldname for df in self.fields if df.fieldtype not in no_value_fields] + def get_fieldnames_with_value(self, with_field_meta=False): + return [df if with_field_meta else df.fieldname \ + for df in self.fields if df.fieldtype not in no_value_fields] def get_fields_to_check_permissions(self, user_permission_doctypes): diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index b134f2f8dc..4384e7c8f5 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -45,20 +45,29 @@ def get_transitions(doc, workflow = None, raise_exception=False): transitions = [] for transition in workflow.transitions: if transition.state == current_state and transition.allowed in roles: - if transition.condition: - # if condition, evaluate - # access to frappe.db.get_value and frappe.db.get_list - success = frappe.safe_eval(transition.condition, - dict(frappe = frappe._dict( - db = frappe._dict(get_value = frappe.db.get_value, get_list=frappe.db.get_list), - session = frappe.session - )), - dict(doc = doc)) - if not success: - continue + if not is_transition_condition_satisfied(transition, doc): + continue transitions.append(transition.as_dict()) return transitions +def get_workflow_safe_globals(): + # access to frappe.db.get_value and frappe.db.get_list + return dict( + frappe=frappe._dict( + db=frappe._dict( + get_value=frappe.db.get_value, + get_list=frappe.db.get_list + ), + session=frappe.session + ) + ) + +def is_transition_condition_satisfied(transition, doc): + if not transition.condition: + return True + else: + return frappe.safe_eval(transition.condition, get_workflow_safe_globals(), dict(doc=doc.as_dict())) + @frappe.whitelist() def apply_workflow(doc, action): '''Allow workflow action on the current doc''' @@ -185,7 +194,7 @@ def bulk_workflow_approval(docnames, doctype, action): from collections import defaultdict # dictionaries for logging - errored_transactions = defaultdict(list) + failed_transactions = defaultdict(list) successful_transactions = defaultdict(list) # WARN: message log is cleared @@ -206,7 +215,7 @@ def bulk_workflow_approval(docnames, doctype, action): if e.args: message += " : {0}".format(e.args[0]) message_dict = {"docname": docname, "message": message} - errored_transactions[docname].append(message_dict) + failed_transactions[docname].append(message_dict) frappe.db.rollback() frappe.log_error(frappe.get_traceback(), "Workflow {0} threw an error for {1} {2}".format(action, doctype, docname)) @@ -219,20 +228,20 @@ def bulk_workflow_approval(docnames, doctype, action): message_dict = {"docname": docname, "message": message.get("message")} if message.get("raise_exception", False): - errored_transactions[docname].append(message_dict) + failed_transactions[docname].append(message_dict) else: successful_transactions[docname].append(message_dict) else: successful_transactions[docname].append({"docname": docname, "message": None}) - if errored_transactions and successful_transactions: + if failed_transactions and successful_transactions: indicator = "orange" - elif errored_transactions: + elif failed_transactions: indicator = "red" else: indicator = "green" - print_workflow_log(errored_transactions, _("Errored Transactions"), doctype, indicator) + print_workflow_log(failed_transactions, _("Failed Transactions"), doctype, indicator) print_workflow_log(successful_transactions, _("Successful Transactions"), doctype, indicator) def print_workflow_log(messages, title, doctype, indicator): diff --git a/frappe/patches.txt b/frappe/patches.txt index a33b4d68b0..fc4f3ae998 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -270,3 +270,4 @@ execute:frappe.delete_doc_if_exists('DocType', 'GSuite Settings') execute:frappe.delete_doc_if_exists('DocType', 'GSuite Templates') execute:frappe.delete_doc_if_exists('DocType', 'GCalendar Account') execute:frappe.delete_doc_if_exists('DocType', 'GCalendar Settings') +frappe.patches.v12_0.remove_parent_and_parenttype_from_print_formats diff --git a/frappe/patches/v12_0/move_email_and_phone_to_child_table.py b/frappe/patches/v12_0/move_email_and_phone_to_child_table.py index b18a7487f3..4388d3c849 100644 --- a/frappe/patches/v12_0/move_email_and_phone_to_child_table.py +++ b/frappe/patches/v12_0/move_email_and_phone_to_child_table.py @@ -5,7 +5,11 @@ def execute(): SELECT `name`, `email_id`, `phone`, `mobile_no`, `modified_by`, `creation`, `modified` FROM `tabContact` + where not exists (select * from `tabContact Email` + where `tabContact Email`.parent=`tabContact`.name + and `tabContact Email`.email_id=`tabContact`.email_id) """, as_dict=True) + frappe.reload_doc("contacts", "doctype", "contact_email") frappe.reload_doc("contacts", "doctype", "contact_phone") frappe.reload_doc("contacts", "doctype", "contact") @@ -15,7 +19,6 @@ def execute(): for count, contact_detail in enumerate(contact_details): phone_counter = 1 is_primary = 1 - if contact_detail.email_id: email_values.append(( 1, diff --git a/frappe/patches/v12_0/remove_parent_and_parenttype_from_print_formats.py b/frappe/patches/v12_0/remove_parent_and_parenttype_from_print_formats.py new file mode 100644 index 0000000000..1a3c56da59 --- /dev/null +++ b/frappe/patches/v12_0/remove_parent_and_parenttype_from_print_formats.py @@ -0,0 +1,14 @@ +import frappe + +def execute(): + frappe.db.sql(""" + UPDATE + `tabPrint Format` + SET + `tabPrint Format`.`parent`='', + `tabPrint Format`.`parenttype`='', + `tabPrint Format`.parentfield='' + WHERE + `tabPrint Format`.parent != '' + OR `tabPrint Format`.parenttype != '' + """) \ No newline at end of file diff --git a/frappe/permissions.py b/frappe/permissions.py index a0d1677fac..0d766aec8d 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -307,7 +307,7 @@ def has_controller_permissions(doc, ptype, user=None): return None def get_doctypes_with_read(): - return list(set([p.parent for p in get_valid_perms()])) + return list(set([p.parent if type(p.parent) == str else p.parent.encode('UTF8') for p in get_valid_perms()])) def get_valid_perms(doctype=None, user=None): '''Get valid permissions for the current user from DocPerm and Custom DocPerm''' diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 2adb5435e3..c1ba41ab16 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -152,6 +152,7 @@ frappe.ui.form.Control = Class.extend({ () => me.set_model_value(value), () => { me.set_mandatory && me.set_mandatory(value); + me.set_invalid && me.set_invalid(); if(me.df.change || me.df.onchange) { // onchange event specified in df diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index 8a8ac271c7..0dbaaeb63c 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -179,6 +179,9 @@ frappe.ui.form.ControlInput = frappe.ui.form.Control.extend({ set_mandatory: function(value) { this.$wrapper.toggleClass("has-error", (this.df.reqd && is_null(value)) ? true : false); }, + set_invalid: function () { + this.$wrapper.toggleClass("has-error", (this.df.invalid ? true : false)); + }, set_bold: function() { if(this.$input) { this.$input.toggleClass("bold", !!(this.df.bold || this.df.reqd)); diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index 6dc8c3d387..a7f0050d65 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -87,56 +87,29 @@ frappe.ui.form.ControlData = frappe.ui.form.ControlInput.extend({ return val==null ? "" : val; }, validate: function(v) { + if (!v) { + return ''; + } if(this.df.is_filter) { return v; } if(this.df.options == 'Phone') { - if(v+''=='') { - return ''; - } - var v1 = ''; - // phone may start with + and must only have numbers later, '-' and ' ' are stripped - v = v.replace(/ /g, '').replace(/-/g, '').replace(/\(/g, '').replace(/\)/g, ''); - - // allow initial +,0,00 - if(v && v.substr(0,1)=='+') { - v1 = '+'; v = v.substr(1); - } - if(v && v.substr(0,2)=='00') { - v1 += '00'; v = v.substr(2); - } - if(v && v.substr(0,1)=='0') { - v1 += '0'; v = v.substr(1); - } - v1 += cint(v) + ''; - return v1; + this.df.invalid = !validate_phone(v); + return v; } else if(this.df.options == 'Email') { - if(v+''=='') { - return ''; - } - var email_list = frappe.utils.split_emails(v); if (!email_list) { - // invalid email return ''; } else { - var invalid_email = false; + let email_invalid = false; email_list.forEach(function(email) { if (!validate_email(email)) { - frappe.msgprint(__("Invalid Email: {0}", [email])); - invalid_email = true; + email_invalid = true; } }); - - if (invalid_email) { - // at least 1 invalid email - return ''; - } else { - // all good - return v; - } + this.df.invalid = email_invalid; + return v; } - } else { return v; } diff --git a/frappe/public/js/frappe/form/controls/markdown_editor.js b/frappe/public/js/frappe/form/controls/markdown_editor.js index ee00fef0f7..81e47a0924 100644 --- a/frappe/public/js/frappe/form/controls/markdown_editor.js +++ b/frappe/public/js/frappe/form/controls/markdown_editor.js @@ -6,6 +6,8 @@ frappe.ui.form.ControlMarkdownEditor = frappe.ui.form.ControlCode.extend({ this.ace_editor_target.wrap(`
`); this.markdown_container = this.$input_wrapper.find(`.${this.editor_class}-container`); + this.editor.getSession().setUseWrapMode(true); + this.showing_preview = false; this.preview_toggle_btn = $(``) .click(e => { diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index 593f987a9a..beec168dfd 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -561,10 +561,9 @@ frappe.ui.form.Timeline = class Timeline { } let updater_reference_link = null; - - if (!$.isEmptyObject(data.updater_reference)) { + let updater_reference = data.updater_reference; + if (!$.isEmptyObject(updater_reference)) { let label = updater_reference.label || __('via {0}', [updater_reference.doctype]); - let updater_reference = data.updater_reference; updater_reference_link = frappe.utils.get_form_link( updater_reference.doctype, updater_reference.docname, @@ -703,7 +702,8 @@ frappe.ui.form.Timeline = class Timeline { reference_doctype: this.frm.doctype, reference_name: this.frm.docname, content: comment, - comment_email: frappe.session.user + comment_email: frappe.session.user, + comment_by: frappe.session.user_fullname }, btn: btn, callback: function(r) { diff --git a/frappe/public/js/frappe/list/list_sidebar.html b/frappe/public/js/frappe/list/list_sidebar.html index f8e868da20..880a91cf81 100644 --- a/frappe/public/js/frappe/list/list_sidebar.html +++ b/frappe/public/js/frappe/list/list_sidebar.html @@ -10,6 +10,7 @@