diff --git a/.github/helper/semgrep_rules/README.md b/.github/helper/semgrep_rules/README.md new file mode 100644 index 0000000000..670d8d280f --- /dev/null +++ b/.github/helper/semgrep_rules/README.md @@ -0,0 +1,38 @@ +# Semgrep linting + +## What is semgrep? +Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc. + +Example: + +To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc. + +You can read more such examples in `.github/helper/semgrep_rules` directory. + +# Why/when to use this? +We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us. + +## Running locally + +Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`. + +To run locally use following command: + +`semgrep --config=.github/helper/semgrep_rules [file/folder names]` + +## Testing +semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/ + +When writing new rules you should write few positive and few negative cases as shown in the guide and current tests. + +To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules` + + +## Reference + +If you are new to Semgrep read following pages to get started on writing/modifying rules: + +- https://semgrep.dev/docs/getting-started/ +- https://semgrep.dev/docs/writing-rules/rule-syntax +- https://semgrep.dev/docs/writing-rules/pattern-examples/ +- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py new file mode 100644 index 0000000000..f477d7c176 --- /dev/null +++ b/.github/helper/semgrep_rules/security.py @@ -0,0 +1,6 @@ +def function_name(input): + # ruleid: frappe-codeinjection-eval + eval(input) + +# ok: frappe-codeinjection-eval +eval("1 + 1") diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml new file mode 100644 index 0000000000..1937fc0e52 --- /dev/null +++ b/.github/helper/semgrep_rules/security.yml @@ -0,0 +1,14 @@ +rules: +- id: frappe-codeinjection-eval + patterns: + - pattern-not: eval("...") + - pattern: eval(...) + message: | + Detected the use of eval(). eval() can be dangerous if used to evaluate + dynamic content. Avoid it or use safe_eval(). + languages: [python] + severity: ERROR + paths: + exclude: + - frappe/__init__.py + - frappe/commands/utils.py diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js new file mode 100644 index 0000000000..7b92fe2dff --- /dev/null +++ b/.github/helper/semgrep_rules/translate.js @@ -0,0 +1,37 @@ +// ruleid: frappe-translation-empty-string +__("") +// ruleid: frappe-translation-empty-string +__('') + +// ok: frappe-translation-js-formatting +__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]); + +// ruleid: frappe-translation-js-formatting +__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`); + +// ok: frappe-translation-js-formatting +__('This is fine'); + + +// ok: frappe-translation-trailing-spaces +__('This is fine'); + +// ruleid: frappe-translation-trailing-spaces +__(' this is not ok '); +// ruleid: frappe-translation-trailing-spaces +__('this is not ok '); +// ruleid: frappe-translation-trailing-spaces +__(' this is not ok'); + +// ok: frappe-translation-js-splitting +__('You have {0} subscribers in your mailing list.', [subscribers.length]) + +// todoruleid: frappe-translation-js-splitting +__('You have') + subscribers.length + __('subscribers in your mailing list.') + +// ruleid: frappe-translation-js-splitting +__('You have' + 'subscribers in your mailing list.') + +// ruleid: frappe-translation-js-splitting +__('You have {0} subscribers' + + 'in your mailing list', [subscribers.length]) diff --git a/.github/helper/semgrep_rules/translate.py b/.github/helper/semgrep_rules/translate.py new file mode 100644 index 0000000000..bd6cd9126c --- /dev/null +++ b/.github/helper/semgrep_rules/translate.py @@ -0,0 +1,53 @@ +# Examples taken from https://frappeframework.com/docs/user/en/translations +# This file is used for testing the tests. + +from frappe import _ + +full_name = "Jon Doe" +# ok: frappe-translation-python-formatting +_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name) + +# ruleid: frappe-translation-python-formatting +_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name) +# ruleid: frappe-translation-python-formatting +_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name}) + +# ruleid: frappe-translation-python-formatting +_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name)) + + +subscribers = ["Jon", "Doe"] +# ok: frappe-translation-python-formatting +_('You have {0} subscribers in your mailing list.').format(len(subscribers)) + +# ruleid: frappe-translation-python-splitting +_('You have') + len(subscribers) + _('subscribers in your mailing list.') + +# ruleid: frappe-translation-python-splitting +_('You have {0} subscribers \ + in your mailing list').format(len(subscribers)) + +# ok: frappe-translation-python-splitting +_('You have {0} subscribers') \ + + 'in your mailing list' + +# ruleid: frappe-translation-trailing-spaces +msg = _(" You have {0} pending invoice ") +# ruleid: frappe-translation-trailing-spaces +msg = _("You have {0} pending invoice ") +# ruleid: frappe-translation-trailing-spaces +msg = _(" You have {0} pending invoice") + +# ok: frappe-translation-trailing-spaces +msg = ' ' + _("You have {0} pending invoices") + ' ' + +# ruleid: frappe-translation-python-formatting +_(f"can not format like this - {subscribers}") +# ruleid: frappe-translation-python-splitting +_(f"what" + f"this is also not cool") + + +# ruleid: frappe-translation-empty-string +_("") +# ruleid: frappe-translation-empty-string +_('') diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml new file mode 100644 index 0000000000..3737da5a7e --- /dev/null +++ b/.github/helper/semgrep_rules/translate.yml @@ -0,0 +1,63 @@ +rules: +- id: frappe-translation-empty-string + pattern-either: + - pattern: _("") + - pattern: __("") + message: | + Empty string is useless for translation. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python, javascript, json] + severity: ERROR + +- id: frappe-translation-trailing-spaces + pattern-either: + - pattern: _("=~/(^[ \t]+|[ \t]+$)/") + - pattern: __("=~/(^[ \t]+|[ \t]+$)/") + message: | + Trailing or leading whitespace not allowed in translate strings. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python, javascript, json] + severity: ERROR + +- id: frappe-translation-python-formatting + pattern-either: + - pattern: _("..." % ...) + - pattern: _("...".format(...)) + - pattern: _(f"...") + message: | + Only positional formatters are allowed and formatting should not be done before translating. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python] + severity: ERROR + +- id: frappe-translation-js-formatting + patterns: + - pattern: __(`...`) + - pattern-not: __("...") + message: | + Template strings are not allowed for text formatting. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [javascript, json] + severity: ERROR + +- id: frappe-translation-python-splitting + pattern-either: + - pattern: _(...) + ... + _(...) + - pattern: _("..." + "...") + - pattern-regex: '_\([^\)]*\\\s*' + message: | + Do not split strings inside translate function. Do not concatenate using translate functions. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [python] + severity: ERROR + +- id: frappe-translation-js-splitting + pattern-either: + - pattern-regex: '__\([^\)]*[\+\\]\s*' + - pattern: __('...' + '...') + - pattern: __('...') + __('...') + message: | + Do not split strings inside translate function. Do not concatenate using translate functions. + Please refer: https://frappeframework.com/docs/user/en/translations + languages: [javascript, json] + severity: ERROR diff --git a/.github/stale.yml b/.github/stale.yml index dd1ab9e9e7..2d776759e4 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,7 +1,7 @@ # Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale -daysUntilStale: 10 +daysUntilStale: 7 # Number of days of inactivity before a stale Issue or Pull Request is closed. # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. @@ -28,7 +28,7 @@ markComment: > you can always reopen the PR when you're ready. Thank you for contributing. # Limit the number of actions per hour, from 1-30. Default is 30 -limitPerRun: 30 +limitPerRun: 10 # Limit to only `issues` or `pulls` only: pulls diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 665e7b6c10..bfe2002f69 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1,6 +1,10 @@ name: CI -on: [pull_request, workflow_dispatch, push] +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + workflow_dispatch: + push: jobs: test: @@ -101,6 +105,7 @@ jobs: ${{ runner.os }}-yarn- - name: Cache cypress binary + if: matrix.TYPE == 'ui' uses: actions/cache@v2 with: path: ~/.cache @@ -129,6 +134,10 @@ jobs: DB: ${{ matrix.DB }} TYPE: ${{ matrix.TYPE }} + - name: Setup tmate session + if: contains(github.event.pull_request.labels.*.name, 'debug-gha') + uses: mxschmitt/action-tmate@v3 + - name: Run Tests run: cd ~/frappe-bench/ && ${{ matrix.RUN_COMMAND }} env: diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 321dfb567b..1d5694f521 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -19,4 +19,4 @@ jobs: python -m pip install -q semgrep git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - if [ -f .semgrep.yml ]; then semgrep --config=.semgrep.yml --quiet --error $files; fi + [[ -d .github/helper/semgrep_rules ]] && semgrep --config=.github/helper/semgrep_rules --quiet --error $files diff --git a/.semgrep.yml b/.semgrep.yml deleted file mode 100644 index 99d237251e..0000000000 --- a/.semgrep.yml +++ /dev/null @@ -1,29 +0,0 @@ -#Reference: https://semgrep.dev/docs/writing-rules/rule-syntax/ - -rules: -- id: eval - patterns: - - pattern-not: eval("...") - - pattern: eval(...) - message: | - Detected the use of eval(). eval() can be dangerous if used to evaluate - dynamic content. Avoid it or use safe_eval(). - languages: - - python - severity: ERROR - -# translations -- id: frappe-translation-syntax-python - pattern-either: - - pattern: _(f"...") # f-strings not allowed - - pattern: _("..." + "...") # concatenation not allowed - - pattern: _("") # empty string is meaningless - - pattern: _("..." % ...) # Only positional formatters are allowed. - - pattern: _("...".format(...)) # format should not be used before translating - - pattern: _("...") + ... + _("...") # don't split strings - message: | - Incorrect use of translation function detected. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: - - python - severity: ERROR diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index bdcf5d1ff0..faa72d63a5 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -45,6 +45,6 @@ context('Table MultiSelect', () => { cy.get(`.list-subject:contains("table multiselect")`).last().find('a').click(); cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value').as('existing_value'); cy.get('@existing_value').find('.btn-link-to-form').click(); - cy.location('pathname').should('contain', '/user/test%40erpnext.com'); + cy.location('pathname').should('contain', '/user/test@erpnext.com'); }); }); diff --git a/frappe/__init__.py b/frappe/__init__.py index 871d1b9e92..844a9238e3 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -854,8 +854,8 @@ def get_meta_module(doctype): import frappe.modules return frappe.modules.load_doctype_module(doctype) -def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, - for_reload=False, ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True): +def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reload=False, + ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True, delete_permanently=False): """Delete a document. Calls `frappe.model.delete_doc.delete_doc`. :param doctype: DocType of document to be delete. @@ -863,10 +863,11 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, :param force: Allow even if document is linked. Warning: This may lead to data integrity errors. :param ignore_doctypes: Ignore if child table is one of these. :param for_reload: Call `before_reload` trigger before deleting. - :param ignore_permissions: Ignore user permissions.""" + :param ignore_permissions: Ignore user permissions. + :param delete_permanently: Do not create a Deleted Document for the document.""" import frappe.model.delete_doc frappe.model.delete_doc.delete_doc(doctype, name, force, ignore_doctypes, for_reload, - ignore_permissions, flags, ignore_on_trash, ignore_missing) + ignore_permissions, flags, ignore_on_trash, ignore_missing, delete_permanently) def delete_doc_if_exists(doctype, name, force=0): """Delete document if exists.""" diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index cbcfa350f5..c0a82c594a 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -67,7 +67,7 @@ class DocType(Document): self.scrub_field_names() self.set_default_in_list_view() self.set_default_translatable() - self.validate_series() + validate_series(self) self.validate_document_type() validate_fields(self) @@ -238,44 +238,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 - if not name: name = self.name - - if not autoname and self.get("fields", {"fieldname":"naming_series"}): - self.autoname = "naming_series:" - elif self.autoname == "naming_series:" and not self.get("fields", {"fieldname":"naming_series"}): - frappe.throw(_("Invalid fieldname '{0}' in autoname").format(self.autoname)) - - # validate field name if autoname field:fieldname is used - # Create unique index on autoname field automatically. - if autoname and autoname.startswith('field:'): - field = autoname.split(":")[1] - if not field or field not in [ df.fieldname for df in self.fields ]: - frappe.throw(_("Invalid fieldname '{0}' in autoname").format(field)) - else: - for df in self.fields: - if df.fieldname == field: - df.unique = 1 - break - - if autoname and (not autoname.startswith('field:')) \ - and (not autoname.startswith('eval:')) \ - and (not autoname.lower() in ('prompt', 'hash')) \ - and (not autoname.startswith('naming_series:')) \ - and (not autoname.startswith('format:')): - - prefix = autoname.split('.')[0] - used_in = frappe.db.sql(""" - SELECT `name` - FROM `tabDocType` - WHERE `autoname` LIKE CONCAT(%s, '.%%') - AND `name`!=%s - """, (prefix, name)) - 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.""" try: @@ -666,6 +628,46 @@ class DocType(Document): validate_route_conflict(self.doctype, self.name) +def validate_series(dt, autoname=None, name=None): + """Validate if `autoname` property is correctly set.""" + if not autoname: + autoname = dt.autoname + if not name: + name = dt.name + + if not autoname and dt.get("fields", {"fieldname":"naming_series"}): + dt.autoname = "naming_series:" + elif dt.autoname == "naming_series:" and not dt.get("fields", {"fieldname":"naming_series"}): + frappe.throw(_("Invalid fieldname '{0}' in autoname").format(dt.autoname)) + + # validate field name if autoname field:fieldname is used + # Create unique index on autoname field automatically. + if autoname and autoname.startswith('field:'): + field = autoname.split(":")[1] + if not field or field not in [df.fieldname for df in dt.fields]: + frappe.throw(_("Invalid fieldname '{0}' in autoname").format(field)) + else: + for df in dt.fields: + if df.fieldname == field: + df.unique = 1 + break + + if autoname and (not autoname.startswith('field:')) \ + and (not autoname.startswith('eval:')) \ + and (not autoname.lower() in ('prompt', 'hash')) \ + and (not autoname.startswith('naming_series:')) \ + and (not autoname.startswith('format:')): + + prefix = autoname.split('.')[0] + used_in = frappe.db.sql(""" + SELECT `name` + FROM `tabDocType` + WHERE `autoname` LIKE CONCAT(%s, '.%%') + AND `name`!=%s + """, (prefix, name)) + if used_in: + frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0])) + def validate_links_table_fieldnames(meta): """Validate fieldnames in Links table""" if frappe.flags.in_patch: return diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index c237b8e436..0cf38508b8 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -718,7 +718,7 @@ def delete_file(path): os.remove(path) -def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_delete=False): +def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_delete=False, delete_permanently=False): """Remove file and File entry""" file_name = None if not (attached_to_doctype and attached_to_name): @@ -736,7 +736,7 @@ def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_ if not file_name: file_name = frappe.db.get_value("File", fid, "file_name") comment = doc.add_comment("Attachment Removed", _("Removed {0}").format(file_name)) - frappe.delete_doc("File", fid, ignore_permissions=ignore_permissions) + frappe.delete_doc("File", fid, ignore_permissions=ignore_permissions, delete_permanently=delete_permanently) return comment @@ -745,17 +745,18 @@ def get_max_file_size(): return cint(conf.get('max_file_size')) or 10485760 -def remove_all(dt, dn, from_delete=False): +def remove_all(dt, dn, from_delete=False, delete_permanently=False): """remove all files in a transaction""" try: for fid in frappe.db.sql_list("""select name from `tabFile` where attached_to_doctype=%s and attached_to_name=%s""", (dt, dn)): if from_delete: # If deleting a doc, directly delete files - frappe.delete_doc("File", fid, ignore_permissions=True) + frappe.delete_doc("File", fid, ignore_permissions=True, delete_permanently=delete_permanently) else: # Removes file and adds a comment in the document it is attached to - remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, from_delete=from_delete) + remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, + from_delete=from_delete, delete_permanently=delete_permanently) except Exception as e: if e.args[0]!=1054: raise # (temp till for patched) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 1d0d6ebb09..e947cee8ed 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -24,8 +24,6 @@ class PreparedReport(Document): def enqueue_report(self): enqueue(run_background, prepared_report=self.name, timeout=6000) - def on_trash(self): - remove_all("Prepared Report", self.name) def run_background(prepared_report): @@ -100,7 +98,7 @@ def delete_expired_prepared_reports(): def delete_prepared_reports(reports): reports = frappe.parse_json(reports) for report in reports: - frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True) + frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True, delete_permanently=True) def create_json_gz_file(data, dt, dn): # Storing data in CSV file causes information loss diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 13dbc32620..7116cd908c 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -1,5 +1,7 @@ { "actions": [], + "allow_read": 1, + "allow_workflow": 1, "creation": "2014-04-17 16:53:52.640856", "doctype": "DocType", "document_type": "System", @@ -67,8 +69,7 @@ "enable_prepared_report_auto_deletion", "prepared_report_expiry_period", "chat", - "enable_chat", - "use_socketio_to_upload_file" + "enable_chat" ], "fields": [ { @@ -394,12 +395,6 @@ "fieldtype": "Check", "label": "Enable Chat" }, - { - "default": "1", - "fieldname": "use_socketio_to_upload_file", - "fieldtype": "Check", - "label": "Use socketio to upload file" - }, { "fieldname": "column_break_21", "fieldtype": "Column Break" @@ -446,7 +441,7 @@ { "default": "30", "depends_on": "enable_prepared_report_auto_deletion", - "description": "System will automatically delete Prepared Reports after these many days since creation", + "description": "System will auto-delete Prepared Reports permanently after these many days since creation", "fieldname": "prepared_report_expiry_period", "fieldtype": "Int", "label": "Prepared Report Expiry Period (Days)" @@ -469,13 +464,6 @@ "fieldtype": "Data", "label": "App Name" }, - { - "default": "3", - "description": "Hourly rate limit for generating password reset links", - "fieldname": "password_reset_limit", - "fieldtype": "Int", - "label": "Password Reset Link Generation Limit" - }, { "default": "1", "fieldname": "strip_exif_metadata_from_uploaded_images", @@ -486,7 +474,7 @@ "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2020-12-30 18:52:22.161391", + "modified": "2021-03-25 17:54:32.668876", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -504,4 +492,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index c103ad7e4a..c7bc6c43c0 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -534,24 +534,36 @@ class User(Document): @classmethod def find_by_credentials(cls, user_name: str, password: str, validate_password: bool = True): """Find the user by credentials. - """ - login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")) - filter = {"mobile_no": user_name} if login_with_mobile else {"name": user_name} - user = frappe.db.get_value("User", filters=filter, fieldname=['name', 'enabled'], as_dict=True) or {} - if not user: + This is a login utility that needs to check login related system settings while finding the user. + 1. Find user by email ID by default + 2. If allow_login_using_mobile_number is set, you can use mobile number while finding the user. + 3. If allow_login_using_user_name is set, you can use username while finding the user. + """ + + login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")) + login_with_username = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")) + + or_filters = [{"name": user_name}] + if login_with_mobile: + or_filters.append({"mobile_no": user_name}) + if login_with_username: + or_filters.append({"username": user_name}) + + users = frappe.db.get_all('User', fields=['name', 'enabled'], or_filters=or_filters, limit=1) + if not users: return + user = users[0] user['is_authenticated'] = True if validate_password: try: - check_password(user_name, password) + check_password(user['name'], password) except frappe.AuthenticationError: user['is_authenticated'] = False return user - @frappe.whitelist() def get_timezones(): import pytz @@ -863,11 +875,12 @@ def reset_password(user): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def user_query(doctype, txt, searchfield, start, page_len, filters): - from frappe.desk.reportview import get_match_cond - + from frappe.desk.reportview import get_match_cond, get_filters_cond + conditions=[] user_type_condition = "and user_type = 'System User'" if filters and filters.get('ignore_user_type'): user_type_condition = '' + filters.pop('ignore_user_type') txt = "%{}%".format(txt) return frappe.db.sql("""SELECT `name`, CONCAT_WS(' ', first_name, middle_name, last_name) @@ -878,17 +891,22 @@ def user_query(doctype, txt, searchfield, start, page_len, filters): AND `name` NOT IN ({standard_users}) AND ({key} LIKE %(txt)s OR CONCAT_WS(' ', first_name, middle_name, last_name) LIKE %(txt)s) - {mcond} + {fcond} {mcond} ORDER BY CASE WHEN `name` LIKE %(txt)s THEN 0 ELSE 1 END, CASE WHEN concat_ws(' ', first_name, middle_name, last_name) LIKE %(txt)s THEN 0 ELSE 1 END, NAME asc - LIMIT %(page_len)s OFFSET %(start)s""".format( + LIMIT %(page_len)s OFFSET %(start)s + """.format( user_type_condition = user_type_condition, standard_users=", ".join([frappe.db.escape(u) for u in STANDARD_USERS]), - key=searchfield, mcond=get_match_cond(doctype)), - dict(start=start, page_len=page_len, txt=txt)) + key=searchfield, + fcond=get_filters_cond(doctype, filters, conditions), + mcond=get_match_cond(doctype) + ), + dict(start=start, page_len=page_len, txt=txt) + ) def get_total_users(): """Returns total no. of system users""" diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 7e0b4a49c6..2e9b832acc 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -2,8 +2,9 @@ # Copyright (c) 2017, Frappe Technologies and Contributors # See license.txt from __future__ import unicode_literals -from frappe.core.doctype.user_permission.user_permission import add_user_permissions +from frappe.core.doctype.user_permission.user_permission import add_user_permissions, remove_applicable from frappe.permissions import has_user_permission +from frappe.core.doctype.doctype.test_doctype import new_doctype import frappe import unittest @@ -17,6 +18,8 @@ class TestUserPermission(unittest.TestCase): 'nested_doc_user@example.com')""") frappe.delete_doc_if_exists("DocType", "Person") frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`") + frappe.delete_doc_if_exists("DocType", "Doc A") + frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabDoc A`") def test_default_user_permission_validation(self): user = create_user('test_default_permission@example.com') @@ -153,16 +156,98 @@ class TestUserPermission(unittest.TestCase): self.assertTrue(has_user_permission(frappe.get_doc("Person", parent_record.name), user.name)) self.assertFalse(has_user_permission(frappe.get_doc("Person", child_record.name), user.name)) -def create_user(email, role="System Manager"): + def test_user_perm_on_new_doc_with_field_default(self): + """Test User Perm impact on frappe.new_doc. with *field* default value""" + frappe.set_user('Administrator') + user = create_user("new_doc_test@example.com", "Blogger") + + # make a doctype "Doc A" with 'doctype' link field and default value ToDo + if not frappe.db.exists("DocType", "Doc A"): + doc = new_doctype("Doc A", + fields=[ + { + "label": "DocType", + "fieldname": "doc", + "fieldtype": "Link", + "options": "DocType", + "default": "ToDo" + } + ], unique=0) + doc.insert() + + # make User Perm on DocType 'ToDo' in Assignment Rule (unrelated doctype) + add_user_permissions(get_params(user, "DocType", "ToDo", applicable=["Assignment Rule"])) + frappe.set_user("new_doc_test@example.com") + + new_doc = frappe.new_doc("Doc A") + + # User perm is created on ToDo but for doctype Assignment Rule only + # it should not have impact on Doc A + self.assertEquals(new_doc.doc, "ToDo") + + frappe.set_user('Administrator') + remove_applicable(["Assignment Rule"], "new_doc_test@example.com", "DocType", "ToDo") + + def test_user_perm_on_new_doc_with_user_default(self): + """Test User Perm impact on frappe.new_doc. with *user* default value""" + from frappe.core.doctype.session_default_settings.session_default_settings import (clear_session_defaults, + set_session_default_values) + + frappe.set_user('Administrator') + user = create_user("user_default_test@example.com", "Blogger") + + # make a doctype "Doc A" with 'doctype' link field + if not frappe.db.exists("DocType", "Doc A"): + doc = new_doctype("Doc A", + fields=[ + { + "label": "DocType", + "fieldname": "doc", + "fieldtype": "Link", + "options": "DocType", + } + ], unique=0) + doc.insert() + + # create a 'DocType' session default field + if not frappe.db.exists("Session Default", {"ref_doctype": "DocType"}): + settings = frappe.get_single('Session Default Settings') + settings.append("session_defaults", { + "ref_doctype": "DocType" + }) + settings.save() + + # make User Perm on DocType 'ToDo' in Assignment Rule (unrelated doctype) + add_user_permissions(get_params(user, "DocType", "ToDo", applicable=["Assignment Rule"])) + + # User default Doctype value is ToDo via Session Defaults + frappe.set_user("user_default_test@example.com") + set_session_default_values({"doc": "ToDo"}) + + new_doc = frappe.new_doc("Doc A") + + # User perm is created on ToDo but for doctype Assignment Rule only + # it should not have impact on Doc A + self.assertEquals(new_doc.doc, "ToDo") + + frappe.set_user('Administrator') + clear_session_defaults() + remove_applicable(["Assignment Rule"], "user_default_test@example.com", "DocType", "ToDo") + +def create_user(email, *roles): ''' create user with role system manager ''' if frappe.db.exists('User', email): return frappe.get_doc('User', email) - else: - user = frappe.new_doc('User') - user.email = email - user.first_name = email.split("@")[0] - user.add_roles(role) - return user + + user = frappe.new_doc('User') + user.email = email + user.first_name = email.split("@")[0] + + if not roles: + roles = ('System Manager',) + + user.add_roles(*roles) + return user def get_params(user, doctype, docname, is_default=0, hide_descendants=0, applicable=None): ''' Return param to insert ''' diff --git a/frappe/core/doctype/version/test_version.py b/frappe/core/doctype/version/test_version.py index 97aa69fd9c..51b3c21f58 100644 --- a/frappe/core/doctype/version/test_version.py +++ b/frappe/core/doctype/version/test_version.py @@ -9,6 +9,7 @@ from frappe.core.doctype.version.version import get_diff class TestVersion(unittest.TestCase): def test_get_diff(self): + frappe.set_user('Administrator') test_records = make_test_objects('Event', reset = True) old_doc = frappe.get_doc("Event", test_records[0]) new_doc = copy.deepcopy(old_doc) diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index ff102b3c08..77f62b3ec3 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -23,6 +23,8 @@ "allow_import", "fields_section_break", "fields", + "naming_section", + "autoname", "view_settings_section", "title_field", "image_field", @@ -261,6 +263,18 @@ "fieldtype": "Table", "label": "Actions", "options": "DocType Action" + }, + { + "collapsible": 1, + "fieldname": "naming_section", + "fieldtype": "Section Break", + "label": "Naming" + }, + { + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "fieldname": "autoname", + "fieldtype": "Data", + "label": "Auto Name" } ], "hide_toolbar": 1, @@ -269,7 +283,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-09-24 14:16:49.594012", + "modified": "2021-02-16 15:22:11.108256", "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 50acab46b5..ad8d80e675 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -17,6 +17,7 @@ from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype, che from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.custom.doctype.property_setter.property_setter import delete_property_setter from frappe.model.docfield import supports_translation +from frappe.core.doctype.doctype.doctype import validate_series class CustomizeForm(Document): def on_update(self): @@ -135,7 +136,7 @@ class CustomizeForm(Document): def save_customization(self): if not self.doc_type: return - + validate_series(self, self.autoname, self.doc_type) self.flags.update_db = False self.flags.rebuild_doctype_for_global_search = False self.set_property_setters() @@ -485,7 +486,8 @@ doctype_properties = { 'show_preview_popup': 'Check', 'email_append_to': 'Check', 'subject_field': 'Data', - 'sender_field': 'Data' + 'sender_field': 'Data', + 'autoname': 'Data' } docfield_properties = { diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index f5d1ee0df5..3b4d5e7be5 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -204,7 +204,7 @@ frappe.ui.form.on('Dashboard Chart', { {label: __('Last Modified On'), value: 'modified'} ]; let value_fields = []; - let group_by_fields = []; + let group_by_fields = [{label: 'Created By', value: 'owner'}]; let aggregate_function_fields = []; let update_form = function() { // update select options diff --git a/frappe/desk/leaderboard.py b/frappe/desk/leaderboard.py index 2a981f061b..d651687256 100644 --- a/frappe/desk/leaderboard.py +++ b/frappe/desk/leaderboard.py @@ -47,6 +47,6 @@ def get_energy_point_leaderboard(date_range, company = None, field = None, limit for user in energy_point_users: user_id = user['name'] user['name'] = get_fullname(user['name']) - user['formatted_name'] = '{}'.format(user_id, get_fullname(user_id)) + user['formatted_name'] = '{}'.format(user_id, get_fullname(user_id)) return energy_point_users \ No newline at end of file diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 3fb1dfa0da..3dcdf00a8e 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -252,13 +252,17 @@ def get_formatted_html(subject, message, footer=None, print_html=None, if not email_account: email_account = get_outgoing_email_account(False, sender=sender) + signature = None + if "" not in message: + signature = get_signature(email_account) + rendered_email = frappe.get_template("templates/emails/standard.html").render({ "brand_logo": get_brand_logo(email_account) if with_container or header else None, "with_container": with_container, "site_url": get_url(), "header": get_header(header), "content": message, - "signature": get_signature(email_account), + "signature": signature, "footer": get_footer(email_account, footer), "title": subject, "print_html": print_html, diff --git a/frappe/hooks.py b/frappe/hooks.py index 177ac13a45..c06930afd8 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -38,7 +38,6 @@ app_include_js = [ ] app_include_css = [ "/assets/css/desk.min.css", - "/assets/css/list.min.css", "/assets/css/report.min.css", ] diff --git a/frappe/model/create_new.py b/frappe/model/create_new.py index e0087a9e40..dc4fd97e4c 100644 --- a/frappe/model/create_new.py +++ b/frappe/model/create_new.py @@ -60,7 +60,8 @@ def set_user_and_static_default_values(doc): # user permissions for link options doctype_user_permissions = user_permissions.get(df.options, []) # Allowed records for the reference doctype (link field) along with default doc - allowed_records, default_doc = filter_allowed_docs_for_doctype(doctype_user_permissions, df.parent, with_default_doc=True) + allowed_records, default_doc = filter_allowed_docs_for_doctype(doctype_user_permissions, + df.parent, with_default_doc=True) user_default_value = get_user_default_value(df, defaults, doctype_user_permissions, allowed_records, default_doc) if user_default_value is not None: @@ -83,11 +84,12 @@ def get_user_default_value(df, defaults, doctype_user_permissions, allowed_recor # 2 - Look in user defaults user_default = defaults.get(df.fieldname) - is_allowed_user_default = user_default and (not user_permissions_exist(df, doctype_user_permissions) - or user_default in allowed_records) + + allowed_by_user_permission = validate_value_via_user_permissions(df, doctype_user_permissions, + allowed_records, user_default=user_default) # is this user default also allowed as per user permissions? - if is_allowed_user_default: + if user_default and allowed_by_user_permission: return user_default def get_static_default_value(df, doctype_user_permissions, allowed_records): @@ -101,8 +103,8 @@ def get_static_default_value(df, doctype_user_permissions, allowed_records): elif not cstr(df.default).startswith(":"): # a simple default value - is_allowed_default_value = (not user_permissions_exist(df, doctype_user_permissions) - or (df.default in allowed_records)) + is_allowed_default_value = validate_value_via_user_permissions(df, doctype_user_permissions, + allowed_records) if df.fieldtype!="Link" or df.options=="User" or is_allowed_default_value: return df.default @@ -110,6 +112,19 @@ def get_static_default_value(df, doctype_user_permissions, allowed_records): elif (df.fieldtype == "Select" and df.options and df.options not in ("[Select]", "Loading...")): return df.options.split("\n")[0] +def validate_value_via_user_permissions(df, doctype_user_permissions, allowed_records, user_default=None): + is_valid = True + # If User Permission exists and allowed records is empty, + # that means there are User Perms, but none applicable to this new doctype. + + if user_permissions_exist(df, doctype_user_permissions) and allowed_records: + # If allowed records is not empty, + # check if this field value is allowed via User Permissions applied to this doctype. + value = user_default if user_default else df.default + is_valid = value in allowed_records + + return is_valid + def set_dynamic_default_values(doc, parent_doc, parentfield): # these values should not be cached user_permissions = get_user_permissions() diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index d0e0a6fb1a..ccdb8ca8b3 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -22,8 +22,8 @@ from frappe.exceptions import FileNotFoundError doctypes_to_skip = ("Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log", "File", "Version", "Document Follow", "Comment" , "View Log", "Tag Link", "Notification Log", "Email Queue") -def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reload=False, - ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True): +def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reload=False, ignore_permissions=False, + flags=None, ignore_on_trash=False, ignore_missing=True, delete_permanently=False): """ Deletes a doc(dt, dn) and validates if it is not submitted and not linked in a live record """ @@ -110,7 +110,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa doc.run_method("after_delete") # delete attachments - remove_all(doctype, name, from_delete=True) + remove_all(doctype, name, from_delete=True, delete_permanently=delete_permanently) if not for_reload: # Enqueued at the end, because it gets committed @@ -125,8 +125,13 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa # delete tag link entry delete_tags_for_document(doc) - if doc and not for_reload: + if for_reload: + delete_permanently = True + + if not delete_permanently: add_to_deleted_document(doc) + + if doc and not for_reload: if not frappe.flags.in_patch: try: doc.notify_update() diff --git a/frappe/model/document.py b/frappe/model/document.py index d426dadd06..50025597c4 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -590,9 +590,18 @@ class Document(BaseDocument): def apply_fieldlevel_read_permissions(self): """Remove values the user is not allowed to read (called when loading in desk)""" + + if frappe.session.user == "Administrator": + return + has_higher_permlevel = False - for p in self.get_permissions(): - if p.permlevel > 0: + + all_fields = self.meta.fields.copy() + for table_field in self.meta.get_table_fields(): + all_fields += frappe.get_meta(table_field.options).fields or [] + + for df in all_fields: + if df.permlevel > 0: has_higher_permlevel = True break @@ -616,6 +625,9 @@ class Document(BaseDocument): if self.flags.ignore_permissions or frappe.flags.in_install: return + if frappe.session.user == "Administrator": + return + has_access_to = self.get_permlevel_access() high_permlevel_fields = self.meta.get_high_permlevel_fields() @@ -636,13 +648,12 @@ class Document(BaseDocument): if not hasattr(self, "_has_access_to"): self._has_access_to = {} - if not self._has_access_to.get(permission_type): - self._has_access_to[permission_type] = [] - roles = frappe.get_roles() - for perm in self.get_permissions(): - if perm.role in roles and perm.permlevel > 0 and perm.get(permission_type): - if perm.permlevel not in self._has_access_to[permission_type]: - self._has_access_to[permission_type].append(perm.permlevel) + self._has_access_to[permission_type] = [] + roles = frappe.get_roles() + for perm in self.get_permissions(): + if perm.role in roles and perm.get(permission_type): + if perm.permlevel not in self._has_access_to[permission_type]: + self._has_access_to[permission_type].append(perm.permlevel) return self._has_access_to[permission_type] diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 5dc7ca2d4d..7f58c28397 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -454,7 +454,7 @@ class Meta(Document): has_access_to = [] roles = frappe.get_roles() for perm in self.get_permissions(parenttype): - if perm.role in roles and perm.permlevel > 0 and perm.get(permission_type): + if perm.role in roles and perm.get(permission_type): if perm.permlevel not in has_access_to: has_access_to.append(perm.permlevel) diff --git a/frappe/public/css/list.css b/frappe/public/css/list.css deleted file mode 100644 index 88ad147d33..0000000000 --- a/frappe/public/css/list.css +++ /dev/null @@ -1,527 +0,0 @@ -.frappe-list .result, -.frappe-list .no-result, -.frappe-list .freeze { - min-height: calc(100vh - 284px); -} -.freeze-row .level-left, -.freeze-row .level-right, -.freeze-row .list-row-col { - height: 100%; - width: 100%; -} -.freeze-row .list-row-col { - background-color: #d1d8dd; - border-radius: 2px; - animation: 2s breathe infinite; -} -@keyframes breathe { - 0% { - opacity: 0.2; - } - 50% { - opacity: 0.5; - } - 100% { - opacity: 0.2; - } -} -.sort-selector .dropdown:hover { - text-decoration: underline; -} -.filter-list { - position: relative; -} -.filter-list .sort-selector { - position: absolute; - top: 15px; - right: 15px; -} -.tag-filters-area { - padding: 15px 15px 0px; - border-bottom: 1px solid #d1d8dd; -} -.active-tag-filters { - padding-bottom: 4px; - padding-right: 120px; -} -@media (max-width: 767px) { - .active-tag-filters { - padding-right: 80px; - } -} -.active-tag-filters .btn { - margin-bottom: 10px; -} -.active-tag-filters .btn-group { - margin-left: 10px; - /*white-space: nowrap;*/ - font-size: 0; -} -.active-tag-filters .btn-group .btn-default { - background-color: transparent; - border: 1px solid #d1d8dd; - color: #8D99A6; - float: none; -} -.filter-box { - border-bottom: 1px solid #d1d8dd; - padding: 10px 15px 3px; -} -.filter-box .remove-filter { - margin-top: 6px; - margin-left: 15px; -} -.filter-box .filter-field { - padding-right: 15px; - width: calc(100% - 36px); -} -.filter-box .filter-field .frappe-control { - position: relative; -} -@media (min-width: 767px) { - .filter-box .row > div[class*="col-sm-"] { - padding-right: 0px; - } - .filter-field { - width: 65% !important; - } - .filter-field .frappe-control { - position: relative; - } -} -.list-row-container { - border-bottom: 1px solid #d1d8dd; - display: flex; - flex-direction: column; -} -.list-row { - padding: 12px 15px; - height: 40px; - cursor: pointer; - transition: color 0.2s; - -webkit-transition: color 0.2s; -} -.list-row:hover { - background-color: #F7FAFC; -} -.list-row:last-child { - border-bottom: 0px; -} -.list-row .level-left { - flex: 3; - width: 75%; -} -.list-row .level-right { - flex: 1; -} -.list-row-head { - background-color: #F7FAFC; - border-bottom: 1px solid #d1d8dd !important; -} -.list-row-head .list-subject { - font-weight: normal; -} -.list-row-head .checkbox-actions { - display: none; -} -.list-row-col { - flex: 1; - margin-right: 15px; -} -.list-subject { - flex: 2; - justify-content: start; -} -.list-subject .level-item { - margin-right: 8px; -} -.list-subject.seen { - font-weight: normal; -} -.list-row-activity { - justify-content: flex-end; - min-width: 120px; -} -.list-row-activity .avatar:not(.avatar-empty) { - margin: 0; -} -.list-row-activity > span { - display: inline-block; -} -.list-row-activity > span:not(:last-child) { - margin-right: 8px; -} -.list-row-activity .comment-count { - min-width: 35px; -} -.list-paging-area, -.footnote-area { - padding: 10px 15px; - border-top: 1px solid #d1d8dd; - overflow: auto; -} -.progress { - height: 10px; -} -.likes-count { - display: none; -} -.list-liked-by-me { - margin-bottom: 1px; -} -input.list-check-all, -input.list-row-checkbox { - margin-top: 0px; -} -.filterable { - cursor: pointer; -} -.listview-main-section .octicon-heart { - cursor: pointer; -} -.listview-main-section .page-form { - padding-left: 17px; -} -@media (max-width: 991px) { - .listview-main-section .page-form { - padding-left: 25px; - } -} -.listview-main-section .page-form .octicon-search { - float: left; - padding-top: 7px; - margin-left: -4px; - margin-right: -4px; -} -@media (max-width: 991px) { - .listview-main-section .page-form .octicon-search { - margin-left: -12px; - } -} -.like-action.octicon-heart { - color: #ff5858; -} -.list-comment-count { - display: inline-block; - width: 37px; - text-align: left; -} -.result.tags-shown .tag-row { - display: block; -} -.tag-row { - display: none; - margin-left: 50px; -} -.taggle_placeholder { - top: 0; - left: 5px; - font-size: 11px; - color: #8D99A6; -} -.taggle_list { - padding-left: 5px; - margin-bottom: 3px; -} -.taggle_list .taggle { - font-size: 11px; - padding: 2px 4px; - font-weight: normal; - background-color: #F0F4F7; - white-space: normal; -} -.taggle_list .taggle:hover { - padding: 2px 15px 2px 4px; - background: #cfdce5; - transition: all 0.2s; -} -.taggle_list li { - margin-bottom: 0; -} -.taggle_list li .awesomplete > ul > li { - width: 100%; -} -.taggle_list li .awesomplete > ul { - top: 15px; - z-index: 100; -} -.taggle_list .close { - right: 5px; - color: #36414C; - font-size: 11px; -} -.page-form .awesomplete > ul { - min-width: 300px; -} -.taggle_input { - padding: 0; - margin-top: 3px; - font-size: 11px; - max-width: 100px; -} -.image-view-container { - display: flex; - flex-wrap: wrap; -} -.image-view-container .image-view-row { - display: flex; - border-bottom: 1px solid #ebeff2; -} -.image-view-container .image-view-item { - flex: 0 0 25%; - padding: 15px; - border-bottom: 1px solid #EBEFF2; - border-right: 1px solid #EBEFF2; - max-width: 25%; -} -.image-view-container .image-view-item:nth-child(4n) { - border-right: none; -} -.image-view-container .image-view-item:nth-last-child(-n + 4):nth-child(4n + 1), -.image-view-container .image-view-item:nth-last-child(-n + 4):nth-child(4n + 1) ~ .image-view-item { - border-bottom: none; -} -.image-view-container .image-view-header { - margin-bottom: 10px; -} -.image-view-container .image-view-body:hover .zoom-view { - opacity: 0.7; -} -.image-view-container .image-view-body a { - text-decoration: none; -} -.image-view-container .image-field { - display: flex; - align-content: center; - align-items: center; - justify-content: center; - position: relative; - height: 200px; -} -.image-view-container .image-field img { - max-height: 100%; -} -.image-view-container .image-field.no-image { - background-color: #fafbfc; -} -.image-view-container .placeholder-text { - font-size: 72px; - color: #d1d8dd; -} -.image-view-container .zoom-view { - bottom: 10px !important; - right: 10px !important; - width: 36px; - height: 36px; - opacity: 0; - font-size: 16px; - color: #36414C; - position: absolute; -} -@media (max-width: 767px) { - .image-view-container .zoom-view { - opacity: 0.5; - } -} -@media (max-width: 991px) { - .image-view-container .image-view-item { - flex: 0 0 33.33333333%; - max-width: 33.33333333%; - } - .image-view-container .image-view-item:nth-child(3n) { - border-right: none; - } - .image-view-container .image-view-item:nth-last-child(-n + 3):nth-child(3n + 1), - .image-view-container .image-view-item:nth-last-child(-n + 3):nth-child(3n + 1) ~ .image-view-item { - border-bottom: none; - } - .image-view-container .image-view-item:nth-child(4n) { - border-right: 1px solid #EBEFF2; - } - .image-view-container .image-view-item:nth-last-child(-n + 4):nth-child(4n + 1), - .image-view-container .image-view-item:nth-last-child(-n + 4):nth-child(4n + 1) ~ .image-view-item { - border-bottom: 1px solid #EBEFF2; - } -} -.item-selector { - border: 1px solid #d1d8dd; -} -.item-selector .image-view-row { - width: 100%; -} -.item-selector .image-field { - height: 120px; -} -.item-selector .placeholder-text { - font-size: 48px; -} -.image-view-container.three-column .image-view-item { - flex: 0 0 33.33333333%; - max-width: 33.33333333%; -} -.image-view-container.three-column .image-view-item:nth-child(3n) { - border-right: none; -} -.image-view-container.three-column .image-view-item:nth-last-child(-n + 3):nth-child(3n + 1), -.image-view-container.three-column .image-view-item:nth-last-child(-n + 3):nth-child(3n + 1) ~ .image-view-item { - border-bottom: none; -} -.image-view-container.three-column .image-view-item:nth-child(4n) { - border-right: 1px solid #EBEFF2; -} -.image-view-container.three-column .image-view-item:nth-last-child(-n + 4):nth-child(4n + 1), -.image-view-container.three-column .image-view-item:nth-last-child(-n + 4):nth-child(4n + 1) ~ .image-view-item { - border-bottom: 1px solid #EBEFF2; -} -.pswp--svg .pswp__button, -.pswp--svg .pswp__button--arrow--left:before, -.pswp--svg .pswp__button--arrow--right:before { - background-image: url('/assets/frappe/images/default-skin.svg') !important; -} -.pswp--svg .pswp__button--arrow--left, -.pswp--svg .pswp__button--arrow--right { - background: none !important; -} -.pswp__bg { - background-color: #fff !important; -} -.pswp__more-items { - position: absolute; - bottom: 12px; - left: 50%; - transform: translateX(-50%); -} -.pswp__more-item { - display: inline-block; - margin: 5px; - height: 100px; - cursor: pointer; - border: 1px solid #d1d8dd; -} -.pswp__more-item img { - max-height: 100%; -} -.map-view-container { - display: flex; - flex-wrap: wrap; - width: 100%; - height: calc(100vh - 284px); - z-index: 0; -} -.list-paging-area .gantt-view-mode { - margin-left: 15px; - margin-right: 15px; -} -.gantt .details-container .heading { - margin-bottom: 10px; - font-size: 12px; -} -.gantt .details-container .avatar-small { - width: 16px; - height: 16px; -} -.gantt .details-container .standard-image { - display: block; -} -.inbox-attachment, -.inbox-link { - margin-right: 7px; -} -.select-inbox { - padding: 30px 30px; -} -.inbox-value { - padding-top: 2px; -} -.list-items { - width: 100%; -} -.list-item-container { - border-bottom: 1px solid #d1d8dd; -} -.list-item-container:last-child { - border-bottom: none; -} -.list-item-table { - border: 1px solid #d1d8dd; - border-radius: 3px; -} -.list-item { - display: flex; - align-items: center; - cursor: pointer; - height: 40px; - padding-left: 15px; - font-size: 12px; -} -.list-item:hover { - background-color: #F7FAFC; -} -@media (max-width: 767px) { - .list-item { - height: 50px; - padding-left: 10px; - font-size: 14px; - font-weight: normal; - } -} -.list-item--head { - background-color: #F7FAFC; - border-bottom: 1px solid #d1d8dd; - cursor: auto; -} -.list-item input[type=checkbox] { - margin: 0; - margin-right: 5px; - flex: 0 0 12px; -} -.list-item .liked-by, -.list-item .liked-by-filter-button { - display: inline-block; - width: 20px; - margin-right: 10px; -} -.list-item__content { - flex: 1; - margin-right: 15px; - display: flex; - align-items: center; -} -.list-item__content--flex-2 { - flex: 2; -} -.list-item__content--activity { - justify-content: flex-end; - margin-right: 5px; - min-width: 110px; -} -.list-item__content--activity .list-row-modified, -.list-item__content--activity .avatar-small { - margin-right: 10px; -} -.list-item__content--indicator span::before { - height: 12px; - width: 12px; -} -.list-item__content--id { - justify-content: flex-end; -} -.frappe-timestamp { - white-space: nowrap; -} -.file-grid { - display: flex; - flex-wrap: wrap; - align-content: flex-start; -} -.file-grid a { - height: 100%; -} -.file-wrapper { - width: 120px; - flex-direction: column; - align-items: center; -} -.file-title { - margin-top: 5px; -} diff --git a/frappe/public/js/frappe/form/controls/table.js b/frappe/public/js/frappe/form/controls/table.js index bde08e4cee..f974a90119 100644 --- a/frappe/public/js/frappe/form/controls/table.js +++ b/frappe/public/js/frappe/form/controls/table.js @@ -12,78 +12,95 @@ frappe.ui.form.ControlTable = frappe.ui.form.Control.extend({ parent: this.wrapper, control: this }); - if(this.frm) { + + if (this.frm) { this.frm.grids[this.frm.grids.length] = this; } - this.$wrapper.on('paste',':text', function(e) { - var cur_table_field =$(e.target).closest('div [data-fieldtype="Table"]').data('fieldname'); - var cur_field = $(e.target).data('fieldname'); - var cur_grid= cur_frm.get_field(cur_table_field).grid; - var cur_grid_rows = cur_grid.grid_rows; - var cur_doctype = cur_grid.doctype; - var cur_row_docname =$(e.target).closest('div .grid-row').data('name'); - var row_idx = locals[cur_doctype][cur_row_docname].idx; - var clipboardData, pastedData; - // Get pasted data via clipboard API - clipboardData = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData; - pastedData = clipboardData.getData('Text'); - if (!pastedData) return; - var data = frappe.utils.csv_to_array(pastedData,'\t'); - if (data.length === 1 & data[0].length === 1) return; - if (data.length > 100){ - data = data.slice(0, 100); - frappe.msgprint(__('For performance, only the first 100 rows were processed.')); - } - var fieldnames = []; - var get_field = function(name_or_label){ - var fieldname; - $.each(cur_grid.meta.fields,(ci,field)=>{ - name_or_label = name_or_label.toLowerCase() - if (field.fieldname.toLowerCase() === name_or_label || - (field.label && field.label.toLowerCase() === name_or_label)){ - fieldname = field.fieldname; - return false; - } + + this.$wrapper.on('paste', ':text', e => { + const table_field = this.df.fieldname; + const grid = this.grid; + const grid_pagination = grid.grid_pagination; + const grid_rows = grid.grid_rows; + const doctype = grid.doctype; + const row_docname = $(e.target).closest('.grid-row').data('name'); + + let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData; + let pasted_data = clipboard_data.getData('Text'); + + if (!pasted_data) return; + + let data = frappe.utils.csv_to_array(pasted_data, '\t'); + let fieldnames = []; + // for raw data with column header + if (this.get_field(data[0][0])) { + data[0].forEach(column => { + fieldnames.push(this.get_field(column)); }); - return fieldname; - } - if (get_field(data[0][0])){ // for raw data with column header - $.each(data[0], (ci, column)=>{fieldnames.push(get_field(column));}); data.shift(); - } - else{ // no column header, map to the existing visible columns - var visible_columns = cur_grid_rows[0].get_visible_columns(); - var find; - $.each(visible_columns, (ci, column)=>{ - if (column.fieldname === cur_field) find = true; - find && fieldnames.push(column.fieldname); - }) - } - $.each(data, function(i, row) { - var blank_row = true; - $.each(row, function(ci, value) { - if(value) { - blank_row = false; - return false; + } else { + // no column header, map to the existing visible columns + const visible_columns = grid_rows[0].get_visible_columns(); + visible_columns.forEach(column => { + if (column.fieldname === $(e.target).data('fieldname')) { + fieldnames.push(column.fieldname); } }); - if(!blank_row) { - if (row_idx > cur_frm.doc[cur_table_field].length){ - cur_grid.add_new_row(); + } + + let row_idx = locals[doctype][row_docname].idx; + data.forEach((row, i) => { + let blank_row = !row.filter(Boolean).length; + if (blank_row) return; + + setTimeout(() => { + if (row_idx > this.frm.doc[table_field].length) { + this.grid.add_new_row(); } - var cur_row = cur_grid_rows[row_idx - 1]; - row_idx ++; - var row_name = cur_row.doc.name; - $.each(row, function(ci, value) { - if (fieldnames[ci]) frappe.model.set_value(cur_doctype, row_name, fieldnames[ci], value); + if (row_idx > 1 && (row_idx - 1) % grid_pagination.page_length === 0) { + grid_pagination.go_to_page(grid_pagination.page_index + 1); + } + + const row_name = grid_rows[row_idx - 1].doc.name; + row.forEach((value, data_index) => { + if (fieldnames[data_index]) { + frappe.model.set_value(doctype, row_name, fieldnames[data_index], value); + } }); - frappe.show_progress(__('Processing'), i, data.length); - } + row_idx++; + + let progress = i + 1; + frappe.show_progress(__('Processing'), progress, data.length); + if (progress === data.length) { + frappe.hide_progress(); + } + }, 0); }); - frappe.hide_progress(); return false; // Prevent the default handler from running. }); }, + get_field(field_name) { + let fieldname; + this.grid.meta.fields.some(field => { + if (frappe.model.no_value_type.includes(field.fieldtype)) { + return false; + } + + field_name = field_name.toLowerCase(); + const is_field_matching = field_name => { + return ( + field.fieldname.toLowerCase() === field_name || + (field.label || '').toLowerCase() === field_name + ); + }; + + if (is_field_matching()) { + fieldname = field.fieldname; + return true; + } + }); + return fieldname; + }, refresh_input: function() { this.grid.refresh(); }, diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 6e6635caf6..48ae8b1d08 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -740,7 +740,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { ${_value} `; } else if ( - ["Text Editor", "Text", "Small Text", "HTML Editor"].includes( + ["Text Editor", "Text", "Small Text", "HTML Editor", "Markdown Editor"].includes( df.fieldtype ) ) { @@ -749,7 +749,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { `; } else { html = ` + data-filter="${fieldname},=,${frappe.utils.escape_html(value)}"> ${format()} `; } diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index c800f31d55..e4c0f0c55c 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -335,7 +335,10 @@ frappe.router = { return null; } else { a = String(a); - a = encodeURIComponent(a); + if (a && a.match(/[%'"\s\t]/)) { + // if special chars, then encode + a = encodeURIComponent(a); + } return a; } }).join('/'); diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js index e274ef952f..c9c98bd937 100644 --- a/frappe/public/js/frappe/socketio_client.js +++ b/frappe/public/js/frappe/socketio_client.js @@ -54,7 +54,6 @@ frappe.socketio = { frappe.socketio.setup_listeners(); frappe.socketio.setup_reconnect(); - frappe.socketio.uploader = new frappe.socketio.SocketIOUploader(); $(document).on('form-load form-rename', function(e, frm) { if (frm.is_new()) { @@ -257,114 +256,3 @@ frappe.realtime.publish = function(event, message) { } } -frappe.socketio.SocketIOUploader = class SocketIOUploader { - constructor() { - frappe.socketio.socket.on('upload-request-slice', (data) => { - var place = data.currentSlice * this.chunk_size, - slice = this.file.slice(place, - place + Math.min(this.chunk_size, this.file.size - place)); - - if (this.on_progress) { - // update progress - this.on_progress(place / this.file.size * 100); - } - - this.reader.readAsArrayBuffer(slice); - this.started = true; - this.keep_alive(); - }); - - frappe.socketio.socket.on('upload-end', (data) => { - this.reader = null; - this.file = null; - if (data.file_url.substr(0, 7)==='/public') { - data.file_url = data.file_url.substr(7); - } - this.callback(data); - }); - - frappe.socketio.socket.on('upload-error', (data) => { - this.disconnect(false); - frappe.msgprint({ - title: __('Upload Failed'), - message: data.error, - indicator: 'red' - }); - }); - - frappe.socketio.socket.on('disconnect', () => { - this.disconnect(); - }); - } - - start({file=null, is_private=0, filename='', callback=null, on_progress=null, - chunk_size=24576, fallback=null} = {}) { - - if (this.reader) { - frappe.throw(__('File Upload in Progress. Please try again in a few moments.')); - } - - function fallback_required() { - return !frappe.socketio.socket.connected - || !( !frappe.boot.sysdefaults || frappe.boot.sysdefaults.use_socketio_to_upload_file ); - } - - if (fallback_required()) { - return fallback ? fallback() : frappe.throw(__('Socketio is not connected. Cannot upload')); - } - - this.reader = new FileReader(); - this.file = file; - this.chunk_size = chunk_size; - this.callback = callback; - this.on_progress = on_progress; - this.fallback = fallback; - this.started = false; - - this.reader.onload = () => { - frappe.socketio.socket.emit('upload-accept-slice', { - is_private: is_private, - name: filename, - type: this.file.type, - size: this.file.size, - data: this.reader.result - }); - this.keep_alive(); - }; - - var slice = file.slice(0, this.chunk_size); - this.reader.readAsArrayBuffer(slice); - } - - keep_alive() { - if (this.next_check) { - clearTimeout (this.next_check); - } - this.next_check = setTimeout (() => { - if (!this.started) { - // upload never started, so try fallback - if (this.fallback) { - this.fallback(); - } else { - this.disconnect(); - } - } - this.disconnect(); - }, 3000); - } - - disconnect(with_message = true) { - if (this.reader) { - this.reader = null; - this.file = null; - frappe.hide_progress(); - if (with_message) { - frappe.msgprint({ - title: __('File Upload'), - message: __('File Upload Disconnected. Please try again.'), - indicator: 'red' - }); - } - } - } -} diff --git a/frappe/public/js/frappe/ui/toolbar/navbar.html b/frappe/public/js/frappe/ui/toolbar/navbar.html index 2f22bf5909..73767d2019 100644 --- a/frappe/public/js/frappe/ui/toolbar/navbar.html +++ b/frappe/public/js/frappe/ui/toolbar/navbar.html @@ -68,7 +68,8 @@