@@ -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 |
@@ -0,0 +1,6 @@ | |||
def function_name(input): | |||
# ruleid: frappe-codeinjection-eval | |||
eval(input) | |||
# ok: frappe-codeinjection-eval | |||
eval("1 + 1") |
@@ -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 |
@@ -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]) |
@@ -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 | |||
_('') |
@@ -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 |
@@ -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 |
@@ -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: | |||
@@ -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 |
@@ -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 |
@@ -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'); | |||
}); | |||
}); |
@@ -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.""" | |||
@@ -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 | |||
@@ -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) | |||
@@ -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 | |||
@@ -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 | |||
} | |||
} |
@@ -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. | |||
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")) | |||
filter = {"mobile_no": user_name} if login_with_mobile else {"name": user_name} | |||
login_with_username = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")) | |||
user = frappe.db.get_value("User", filters=filter, fieldname=['name', 'enabled'], as_dict=True) or {} | |||
if not user: | |||
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""" | |||
@@ -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 ''' | |||
@@ -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) | |||
@@ -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<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>", | |||
"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", | |||
@@ -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 = { | |||
@@ -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 | |||
@@ -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'] = '<a href="#user-profile/{}">{}</a>'.format(user_id, get_fullname(user_id)) | |||
user['formatted_name'] = '<a href="/app/user-profile/{}">{}</a>'.format(user_id, get_fullname(user_id)) | |||
return energy_point_users |
@@ -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 "<!-- signature-included -->" 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, | |||
@@ -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", | |||
] | |||
@@ -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() | |||
@@ -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() | |||
@@ -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] | |||
@@ -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) | |||
@@ -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; | |||
} |
@@ -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(); | |||
} | |||
if (row_idx > 1 && (row_idx - 1) % grid_pagination.page_length === 0) { | |||
grid_pagination.go_to_page(grid_pagination.page_index + 1); | |||
} | |||
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); | |||
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(); | |||
}, | |||
@@ -740,7 +740,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { | |||
${_value} | |||
</a>`; | |||
} 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 { | |||
</span>`; | |||
} else { | |||
html = `<a class="filterable ellipsis" | |||
data-filter="${fieldname},=,${value}"> | |||
data-filter="${fieldname},=,${frappe.utils.escape_html(value)}"> | |||
${format()} | |||
</a>`; | |||
} | |||
@@ -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('/'); | |||
@@ -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' | |||
}); | |||
} | |||
} | |||
} | |||
} |
@@ -68,7 +68,8 @@ | |||
</span> | |||
</a> | |||
<div class="dropdown-menu dropdown-menu-right" id="toolbar-help" role="menu"> | |||
<div class="divider documentation-links"></div> | |||
<div id="help-links"></div> | |||
<div class="dropdown-divider documentation-links"></div> | |||
{% for item in navbar_settings.help_dropdown %} | |||
{% if (!item.hidden) { %} | |||
{% if (item.route) { %} | |||
@@ -95,7 +95,8 @@ frappe.ui.toolbar.Toolbar = class { | |||
var link = links[i]; | |||
var url = link.url; | |||
$("<a>", { | |||
href: link.url, | |||
href: url, | |||
class: "dropdown-item", | |||
text: link.label, | |||
target: "_blank" | |||
}).appendTo($help_links); | |||
@@ -12,7 +12,7 @@ frappe.help.help_links['modules/Setup'] = [ | |||
{ label: 'Printing', url: 'http://frappe.github.io/erpnext/user/manual/en/setting-up/print/' }, | |||
] | |||
frappe.help.help_links['user'] = [ | |||
frappe.help.help_links['List/User'] = [ | |||
{ label: 'Adding Users', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/users-and-permissions/adding-users' }, | |||
{ label: 'Rename User', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/articles/rename-user' }, | |||
] | |||
@@ -25,19 +25,19 @@ frappe.help.help_links['user-permissions'] = [ | |||
{ label: 'User Permissions', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/users-and-permissions/user-permissions' }, | |||
] | |||
frappe.help.help_links['system-settings'] = [ | |||
frappe.help.help_links['Form/System Settings'] = [ | |||
{ label: 'System Settings', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/settings/system-settings' }, | |||
] | |||
frappe.help.help_links['email-account'] = [ | |||
frappe.help.help_links['List/Email Account'] = [ | |||
{ label: 'Email Account', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/email/email-account' }, | |||
] | |||
frappe.help.help_links['notification'] = [ | |||
frappe.help.help_links['List/Notification'] = [ | |||
{ label: 'Notification', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/email/email-alerts' }, | |||
] | |||
frappe.help.help_links['print-settings'] = [ | |||
frappe.help.help_links['Form/Print Settings'] = [ | |||
{ label: 'Print Settings', url: 'https://frappe.github.io/erpnext/user/manual/en/setting-up/print/print-settings' }, | |||
] | |||
@@ -220,8 +220,23 @@ Object.assign(frappe.utils, { | |||
}); | |||
return out.join(newline); | |||
}, | |||
escape_html: function(txt) { | |||
return $("<div></div>").text(txt || "").html(); | |||
let escape_html_mapping = { | |||
'&': '&', | |||
'<': '<', | |||
'>': '>', | |||
'"': '"', | |||
"'": ''', | |||
'/': '/', | |||
'`': '`', | |||
'=': '=' | |||
}; | |||
return String(txt).replace(/[&<>"'`=/]/g, function(char) { | |||
return escape_html_mapping[char]; | |||
}); | |||
}, | |||
html2text: function(html) { | |||
@@ -1272,4 +1287,27 @@ Object.assign(frappe.utils, { | |||
}); | |||
return names_for_mentions; | |||
}, | |||
print(doctype, docname, print_format, letterhead, lang_code) { | |||
let w = window.open( | |||
frappe.urllib.get_full_url( | |||
'/printview?doctype=' + | |||
encodeURIComponent(doctype) + | |||
'&name=' + | |||
encodeURIComponent(docname) + | |||
'&trigger_print=1' + | |||
'&format=' + | |||
encodeURIComponent(print_format) + | |||
'&no_letterhead=' + | |||
(letterhead ? '0' : '1') + | |||
'&letterhead=' + | |||
encodeURIComponent(letterhead) + | |||
(lang_code ? '&_lang=' + lang_code : '') | |||
) | |||
); | |||
if (!w) { | |||
frappe.msgprint(__('Please enable pop-ups')); | |||
return; | |||
} | |||
} | |||
}); |
@@ -718,7 +718,7 @@ frappe.views.CommunicationComposer = Class.extend({ | |||
if (!signature) { | |||
const res = await this.get_default_outgoing_email_account_signature(); | |||
signature = res.message.signature; | |||
signature = "<!-- signature-included -->" + res.message.signature; | |||
} | |||
if (signature && !frappe.utils.is_html(signature)) { | |||
@@ -208,6 +208,7 @@ export default class ChartWidget extends Widget { | |||
this.fetch(this.filters, true, this.args).then(data => { | |||
if (this.chart_doc.chart_type == "Report") { | |||
this.report_result = data; | |||
this.summary = data.report_summary; | |||
data = this.get_report_chart_data(data); | |||
} | |||
@@ -571,6 +572,13 @@ export default class ChartWidget extends Widget { | |||
axisOptions: { | |||
xIsSeries: this.chart_doc.timeseries, | |||
shortenYAxisNumbers: 1 | |||
}, | |||
tooltipOptions: { | |||
formatTooltipY: value => | |||
frappe.format(value, { | |||
fieldtype: this.report_result.chart.fieldtype, | |||
options: this.report_result.chart.options | |||
}, { always_show_decimals: true, inline: true }) | |||
} | |||
}; | |||
@@ -750,4 +758,4 @@ export default class ChartWidget extends Widget { | |||
this.dashboard_chart && this.dashboard_chart.draw(true); | |||
}); | |||
} | |||
} | |||
} |
@@ -36,7 +36,6 @@ | |||
border-bottom-left-radius: var(--border-radius); | |||
border-bottom-right-radius: var(--border-radius); | |||
overflow: hidden; | |||
position: initial !important; | |||
} | |||
.ql-snow { | |||
@@ -485,6 +485,19 @@ body { | |||
} | |||
} | |||
} | |||
@media (max-width: map-get($grid-breakpoints, "sm")) { | |||
.widget-body { | |||
flex-direction: column; | |||
.onboarding-steps-wrapper { | |||
min-width: none; | |||
} | |||
.onboarding-step-preview { | |||
padding-left: 0; | |||
padding-top: var(--padding-lg); | |||
} | |||
} | |||
} | |||
} | |||
&.shortcut-widget-box { | |||
@@ -733,3 +746,18 @@ body { | |||
height: 200px; | |||
} | |||
} | |||
[data-page-route="Workspaces"] { | |||
@media (min-width: map-get($grid-breakpoints, "lg")) { | |||
.layout-main { | |||
height: calc(100vh - var(--navbar-height) - var(--page-head-height) - 5px); | |||
.layout-side-section, .layout-main-section-wrapper { | |||
height: 100%; | |||
overflow-y: auto; | |||
} | |||
.desk-sidebar { | |||
margin-bottom: var(--margin-2xl); | |||
} | |||
} | |||
} | |||
} |
@@ -266,6 +266,11 @@ select.input-xs { | |||
.dropdown-divider { | |||
margin: 4px 0; | |||
} | |||
.divider { | |||
@extend .dropdown-divider; | |||
} | |||
a:not([href]):hover:active { | |||
color: $component-active-color; | |||
} | |||
@@ -225,7 +225,7 @@ body[data-route^="Module"] .main-menu { | |||
.divider { | |||
height: 1px; | |||
background-color: #d8dfe5; | |||
background-color: var(--border-color); | |||
opacity: 0.7; | |||
} | |||
@@ -117,13 +117,16 @@ def run_all_tests(app=None, verbose=False, profile=False, ui_tests=False, failfa | |||
test_suite = unittest.TestSuite() | |||
for app in apps: | |||
for path, folders, files in os.walk(frappe.get_pymodule_path(app)): | |||
for dontwalk in ('locals', '.git', 'public'): | |||
for dontwalk in ('locals', '.git', 'public', '__pycache__'): | |||
if dontwalk in folders: | |||
folders.remove(dontwalk) | |||
# for predictability | |||
folders.sort() | |||
files.sort() | |||
# print path | |||
for filename in files: | |||
filename = cstr(filename) | |||
if filename.startswith("test_") and filename.endswith(".py")\ | |||
and filename != 'test_runner.py': | |||
# print filename[:-3] | |||
@@ -4,7 +4,88 @@ from __future__ import unicode_literals | |||
import time | |||
import unittest | |||
import frappe | |||
from frappe.auth import LoginAttemptTracker | |||
from frappe.frappeclient import FrappeClient, AuthError | |||
class TestAuth(unittest.TestCase): | |||
def __init__(self, *args, **kwargs): | |||
super(TestAuth, self).__init__(*args, **kwargs) | |||
self.test_user_email = 'test_auth@test.com' | |||
self.test_user_name = 'test_auth_user' | |||
self.test_user_mobile = '+911234567890' | |||
self.test_user_password = 'pwd_012' | |||
def setUp(self): | |||
self.tearDown() | |||
self.add_user(self.test_user_email, self.test_user_password, | |||
username=self.test_user_name, mobile_no=self.test_user_mobile) | |||
def tearDown(self): | |||
frappe.delete_doc('User', self.test_user_email, force=True) | |||
def add_user(self, email, password, username=None, mobile_no=None): | |||
first_name = email.split('@', 1)[0] | |||
user = frappe.get_doc( | |||
dict(doctype='User', email=email, first_name=first_name, username=username, mobile_no=mobile_no) | |||
).insert() | |||
user.new_password = password | |||
user.save() | |||
frappe.db.commit() | |||
def set_system_settings(self, k, v): | |||
frappe.db.set_value("System Settings", "System Settings", k, v) | |||
frappe.db.commit() | |||
def test_allow_login_using_mobile(self): | |||
self.set_system_settings('allow_login_using_mobile_number', 1) | |||
self.set_system_settings('allow_login_using_user_name', 0) | |||
# Login by both email and mobile should work | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_mobile, self.test_user_password) | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_email, self.test_user_password) | |||
# login by username should fail | |||
with self.assertRaises(AuthError): | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_name, self.test_user_password) | |||
def test_allow_login_using_only_email(self): | |||
self.set_system_settings('allow_login_using_mobile_number', 0) | |||
self.set_system_settings('allow_login_using_user_name', 0) | |||
# Login by mobile number should fail | |||
with self.assertRaises(AuthError): | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_mobile, self.test_user_password) | |||
# login by username should fail | |||
with self.assertRaises(AuthError): | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_name, self.test_user_password) | |||
# Login by email should work | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_email, self.test_user_password) | |||
def test_allow_login_using_username(self): | |||
self.set_system_settings('allow_login_using_mobile_number', 0) | |||
self.set_system_settings('allow_login_using_user_name', 1) | |||
# Mobile login should fail | |||
with self.assertRaises(AuthError): | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_mobile, self.test_user_password) | |||
# Both email and username logins should work | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_email, self.test_user_password) | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_name, self.test_user_password) | |||
def test_allow_login_using_username_and_mobile(self): | |||
self.set_system_settings('allow_login_using_mobile_number', 1) | |||
self.set_system_settings('allow_login_using_user_name', 1) | |||
# Both email and username and mobile logins should work | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_mobile, self.test_user_password) | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_email, self.test_user_password) | |||
FrappeClient(frappe.get_site_config().host_name, self.test_user_name, self.test_user_password) | |||
class TestLoginAttemptTracker(unittest.TestCase): | |||
def test_account_lock(self): | |||
@@ -42,11 +42,27 @@ class TestFormLoad(unittest.TestCase): | |||
blog_post_property_setter = make_property_setter('Blog Post', 'published', 'permlevel', 1, 'Int') | |||
reset('Blog Post') | |||
# test field level permission before role level permissions are defined | |||
frappe.set_user(user.name) | |||
blog_doc = get_blog(blog.name) | |||
self.assertEqual(blog_doc.published, None) | |||
# this will be ignored because user does not | |||
# have write access on `published` field (or on permlevel 1 fields) | |||
blog_doc.published = 1 | |||
blog_doc.save() | |||
# since published field has higher permlevel | |||
self.assertEqual(blog_doc.published, 0) | |||
# test field level permission after role level permissions are defined | |||
frappe.set_user('Administrator') | |||
add('Blog Post', 'Website Manager', 1) | |||
update('Blog Post', 'Website Manager', 1, 'write', 1) | |||
frappe.set_user(user.name) | |||
blog_doc = get_blog(blog.name) | |||
self.assertEqual(blog_doc.name, blog.name) | |||
@@ -41,12 +41,12 @@ def create_todo_records(): | |||
frappe.get_doc({ | |||
"doctype": "ToDo", | |||
"date": add_to_date(now(), days=3), | |||
"date": add_to_date(now(), days=7), | |||
"description": "this is first todo" | |||
}).insert() | |||
frappe.get_doc({ | |||
"doctype": "ToDo", | |||
"date": add_to_date(now(), days=-3), | |||
"date": add_to_date(now(), days=-7), | |||
"description": "this is second todo" | |||
}).insert() | |||
frappe.get_doc({ | |||
@@ -403,6 +403,14 @@ def call_hook_method(hook, *args, **kwargs): | |||
return out | |||
def update_progress_bar(txt, i, l): | |||
if os.environ.get("CI"): | |||
if i == 0: | |||
sys.stdout.write(txt) | |||
sys.stdout.write(".") | |||
sys.stdout.flush() | |||
return | |||
if not getattr(frappe.local, 'request', None): | |||
lt = len(txt) | |||
try: | |||
@@ -107,10 +107,10 @@ def build_csv_response(data, filename): | |||
frappe.response["type"] = "csv" | |||
class UnicodeWriter: | |||
def __init__(self, encoding="utf-8"): | |||
def __init__(self, encoding="utf-8", quoting=csv.QUOTE_NONNUMERIC): | |||
self.encoding = encoding | |||
self.queue = StringIO() | |||
self.writer = csv.writer(self.queue, quoting=csv.QUOTE_NONNUMERIC) | |||
self.writer = csv.writer(self.queue, quoting=quoting) | |||
def writerow(self, row): | |||
if six.PY2: | |||
@@ -50,8 +50,12 @@ def update_controller_context(context, controller): | |||
context[prop] = getattr(module, prop) | |||
if hasattr(module, "get_context"): | |||
import inspect | |||
try: | |||
ret = module.get_context(context) | |||
if inspect.getargspec(module.get_context).args: | |||
ret = module.get_context(context) | |||
else: | |||
ret = module.get_context() | |||
if ret: | |||
context.update(ret) | |||
except (frappe.PermissionError, frappe.DoesNotExistError, frappe.Redirect): | |||
@@ -11,8 +11,12 @@ from frappe.website.render import render | |||
from frappe.utils import random_string | |||
from frappe.website.doctype.blog_post.blog_post import get_blog_list | |||
from frappe.website.website_generator import WebsiteGenerator | |||
from frappe.custom.doctype.customize_form.customize_form import reset_customization | |||
class TestBlogPost(unittest.TestCase): | |||
def setUp(self): | |||
reset_customization('Blog Post') | |||
def test_generator_view(self): | |||
pages = frappe.get_all('Blog Post', fields=['name', 'route'], | |||
filters={'published': 1, 'route': ('!=', '')}, limit =1) | |||
@@ -97,6 +101,7 @@ def make_test_blog(category_title="Test Blog Category"): | |||
doctype = 'Blogger', | |||
short_name='test-blogger', | |||
full_name='Test Blogger')).insert() | |||
test_blog = frappe.get_doc(dict( | |||
doctype = 'Blog Post', | |||
blog_category = category_name, | |||
@@ -28,7 +28,7 @@ | |||
"driver.js": "^0.9.8", | |||
"express": "^4.17.1", | |||
"fast-deep-equal": "^2.0.1", | |||
"frappe-charts": "^2.0.0-rc10", | |||
"frappe-charts": "^2.0.0-rc11", | |||
"frappe-datatable": "^1.15.3", | |||
"frappe-gantt": "^0.5.0", | |||
"fuse.js": "^3.4.6", | |||
@@ -46,7 +46,7 @@ | |||
"qz-tray": "^2.0.8", | |||
"redis": "^2.8.0", | |||
"showdown": "^1.9.1", | |||
"snyk": "^1.425.4", | |||
"snyk": "^1.465.0", | |||
"socket.io": "^2.4.0", | |||
"superagent": "^3.8.2", | |||
"touch": "^3.1.0", | |||
@@ -216,47 +216,6 @@ io.on('connection', function (socket) { | |||
'type' | |||
); | |||
}); | |||
socket.on('upload-accept-slice', (data) => { | |||
try { | |||
if (!socket.files[data.name]) { | |||
socket.files[data.name] = Object.assign({}, files_struct, data); | |||
socket.files[data.name].data = []; | |||
} | |||
//convert the ArrayBuffer to Buffer | |||
data.data = new Buffer(new Uint8Array(data.data)); | |||
//save the data | |||
socket.files[data.name].data.push(data.data); | |||
socket.files[data.name].slice++; | |||
if (socket.files[data.name].slice * 24576 >= socket.files[data.name].size) { | |||
// do something with the data | |||
var fileBuffer = Buffer.concat(socket.files[data.name].data); | |||
const file_url = path.join((socket.files[data.name].is_private ? 'private' : 'public'), | |||
'files', data.name); | |||
const file_path = path.join('sites', get_site_name(socket), file_url); | |||
fs.writeFile(file_path, fileBuffer, (err) => { | |||
delete socket.files[data.name]; | |||
if (err) return socket.emit('upload error'); | |||
socket.emit('upload-end', { | |||
file_url: '/' + file_url | |||
}); | |||
}); | |||
} else { | |||
socket.emit('upload-request-slice', { | |||
currentSlice: socket.files[data.name].slice | |||
}); | |||
} | |||
} catch (e) { | |||
log(e); | |||
socket.emit('upload-error', { | |||
error: e.message | |||
}); | |||
} | |||
}); | |||
}); | |||
subscriber.on("message", function (_channel, message) { | |||