Merge branch 'develop' into feat-url-validation-option
This commit is contained in:
commit
9ca7891cf8
124 changed files with 1863 additions and 853 deletions
|
|
@ -144,6 +144,7 @@
|
||||||
"Cypress": true,
|
"Cypress": true,
|
||||||
"cy": true,
|
"cy": true,
|
||||||
"it": true,
|
"it": true,
|
||||||
|
"describe": true,
|
||||||
"expect": true,
|
"expect": true,
|
||||||
"describe": true,
|
"describe": true,
|
||||||
"context": true,
|
"context": true,
|
||||||
|
|
|
||||||
28
.github/helper/semgrep_rules/frappe_correctness.py
vendored
Normal file
28
.github/helper/semgrep_rules/frappe_correctness.py
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import frappe
|
||||||
|
from frappe import _, flt
|
||||||
|
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
def on_submit(self):
|
||||||
|
if self.value_of_goods == 0:
|
||||||
|
frappe.throw(_('Value of goods cannot be 0'))
|
||||||
|
# ruleid: frappe-modifying-after-submit
|
||||||
|
self.status = 'Submitted'
|
||||||
|
|
||||||
|
def on_submit(self): # noqa
|
||||||
|
if flt(self.per_billed) < 100:
|
||||||
|
self.update_billing_status()
|
||||||
|
else:
|
||||||
|
# todook: frappe-modifying-after-submit
|
||||||
|
self.status = "Completed"
|
||||||
|
self.db_set("status", "Completed")
|
||||||
|
|
||||||
|
class TestDoc(Document):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
#ruleid: frappe-modifying-child-tables-while-iterating
|
||||||
|
for item in self.child_table:
|
||||||
|
if item.value < 0:
|
||||||
|
self.remove(item)
|
||||||
135
.github/helper/semgrep_rules/frappe_correctness.yml
vendored
Normal file
135
.github/helper/semgrep_rules/frappe_correctness.yml
vendored
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# This file specifies rules for correctness according to how frappe doctype data model works.
|
||||||
|
|
||||||
|
rules:
|
||||||
|
- id: frappe-modifying-but-not-comitting
|
||||||
|
patterns:
|
||||||
|
- pattern: |
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = ...
|
||||||
|
- pattern-not: |
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = ...
|
||||||
|
...
|
||||||
|
self.db_set(..., self.$ATTR, ...)
|
||||||
|
- pattern-not: |
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = $SOME_VAR
|
||||||
|
...
|
||||||
|
self.db_set(..., $SOME_VAR, ...)
|
||||||
|
- pattern-not: |
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = $SOME_VAR
|
||||||
|
...
|
||||||
|
self.save()
|
||||||
|
- metavariable-regex:
|
||||||
|
metavariable: '$ATTR'
|
||||||
|
# this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me)
|
||||||
|
regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$'
|
||||||
|
- metavariable-regex:
|
||||||
|
metavariable: "$METHOD"
|
||||||
|
regex: "(on_submit|on_cancel)"
|
||||||
|
message: |
|
||||||
|
DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database.
|
||||||
|
languages: [python]
|
||||||
|
severity: ERROR
|
||||||
|
|
||||||
|
- id: frappe-modifying-but-not-comitting-other-method
|
||||||
|
patterns:
|
||||||
|
- pattern: |
|
||||||
|
class $DOCTYPE(...):
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ANOTHER_METHOD()
|
||||||
|
...
|
||||||
|
|
||||||
|
def $ANOTHER_METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = ...
|
||||||
|
- pattern-not: |
|
||||||
|
class $DOCTYPE(...):
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ANOTHER_METHOD()
|
||||||
|
...
|
||||||
|
|
||||||
|
def $ANOTHER_METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = ...
|
||||||
|
...
|
||||||
|
self.db_set(..., self.$ATTR, ...)
|
||||||
|
- pattern-not: |
|
||||||
|
class $DOCTYPE(...):
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ANOTHER_METHOD()
|
||||||
|
...
|
||||||
|
|
||||||
|
def $ANOTHER_METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = $SOME_VAR
|
||||||
|
...
|
||||||
|
self.db_set(..., $SOME_VAR, ...)
|
||||||
|
- pattern-not: |
|
||||||
|
class $DOCTYPE(...):
|
||||||
|
def $METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ANOTHER_METHOD()
|
||||||
|
...
|
||||||
|
self.save()
|
||||||
|
def $ANOTHER_METHOD(self, ...):
|
||||||
|
...
|
||||||
|
self.$ATTR = ...
|
||||||
|
- metavariable-regex:
|
||||||
|
metavariable: "$METHOD"
|
||||||
|
regex: "(on_submit|on_cancel)"
|
||||||
|
message: |
|
||||||
|
self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database.
|
||||||
|
languages: [python]
|
||||||
|
severity: ERROR
|
||||||
|
|
||||||
|
- id: frappe-print-function-in-doctypes
|
||||||
|
pattern: print(...)
|
||||||
|
message: |
|
||||||
|
Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement.
|
||||||
|
languages: [python]
|
||||||
|
severity: WARNING
|
||||||
|
paths:
|
||||||
|
exclude:
|
||||||
|
- test_*.py
|
||||||
|
include:
|
||||||
|
- "*/**/doctype/*"
|
||||||
|
|
||||||
|
- id: frappe-modifying-child-tables-while-iterating
|
||||||
|
pattern-either:
|
||||||
|
- pattern: |
|
||||||
|
for $ROW in self.$TABLE:
|
||||||
|
...
|
||||||
|
self.remove(...)
|
||||||
|
- pattern: |
|
||||||
|
for $ROW in self.$TABLE:
|
||||||
|
...
|
||||||
|
self.append(...)
|
||||||
|
message: |
|
||||||
|
Child table being modified while iterating on it.
|
||||||
|
languages: [python]
|
||||||
|
severity: ERROR
|
||||||
|
paths:
|
||||||
|
include:
|
||||||
|
- "*/**/doctype/*"
|
||||||
|
|
||||||
|
- id: frappe-same-key-assigned-twice
|
||||||
|
pattern-either:
|
||||||
|
- pattern: |
|
||||||
|
{..., $X: $A, ..., $X: $B, ...}
|
||||||
|
- pattern: |
|
||||||
|
dict(..., ($X, $A), ..., ($X, $B), ...)
|
||||||
|
- pattern: |
|
||||||
|
_dict(..., ($X, $A), ..., ($X, $B), ...)
|
||||||
|
message: |
|
||||||
|
key `$X` is uselessly assigned twice. This could be a potential bug.
|
||||||
|
languages: [python]
|
||||||
|
severity: ERROR
|
||||||
15
.github/helper/semgrep_rules/security.yml
vendored
15
.github/helper/semgrep_rules/security.yml
vendored
|
|
@ -12,3 +12,18 @@ rules:
|
||||||
exclude:
|
exclude:
|
||||||
- frappe/__init__.py
|
- frappe/__init__.py
|
||||||
- frappe/commands/utils.py
|
- frappe/commands/utils.py
|
||||||
|
|
||||||
|
- id: frappe-sqli-format-strings
|
||||||
|
patterns:
|
||||||
|
- pattern-inside: |
|
||||||
|
@frappe.whitelist()
|
||||||
|
def $FUNC(...):
|
||||||
|
...
|
||||||
|
- pattern-either:
|
||||||
|
- pattern: frappe.db.sql("..." % ...)
|
||||||
|
- pattern: frappe.db.sql(f"...", ...)
|
||||||
|
- pattern: frappe.db.sql("...".format(...), ...)
|
||||||
|
message: |
|
||||||
|
Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines
|
||||||
|
languages: [python]
|
||||||
|
severity: WARNING
|
||||||
|
|
|
||||||
3
.github/helper/semgrep_rules/translate.yml
vendored
3
.github/helper/semgrep_rules/translate.yml
vendored
|
|
@ -44,7 +44,8 @@ rules:
|
||||||
pattern-either:
|
pattern-either:
|
||||||
- pattern: _(...) + ... + _(...)
|
- pattern: _(...) + ... + _(...)
|
||||||
- pattern: _("..." + "...")
|
- pattern: _("..." + "...")
|
||||||
- pattern-regex: '_\([^\)]*\\\s*'
|
- pattern-regex: '_\([^\)]*\\\s*' # lines broken by `\`
|
||||||
|
- pattern-regex: '_\(\s*\n' # line breaks allowed by python for using ( )
|
||||||
message: |
|
message: |
|
||||||
Do not split strings inside translate function. Do not concatenate using translate functions.
|
Do not split strings inside translate function. Do not concatenate using translate functions.
|
||||||
Please refer: https://frappeframework.com/docs/user/en/translations
|
Please refer: https://frappeframework.com/docs/user/en/translations
|
||||||
|
|
|
||||||
31
.github/helper/semgrep_rules/ux.py
vendored
Normal file
31
.github/helper/semgrep_rules/ux.py
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import frappe
|
||||||
|
from frappe import msgprint, throw, _
|
||||||
|
|
||||||
|
|
||||||
|
# ruleid: frappe-missing-translate-function
|
||||||
|
throw("Error Occured")
|
||||||
|
|
||||||
|
# ruleid: frappe-missing-translate-function
|
||||||
|
frappe.throw("Error Occured")
|
||||||
|
|
||||||
|
# ruleid: frappe-missing-translate-function
|
||||||
|
frappe.msgprint("Useful message")
|
||||||
|
|
||||||
|
# ruleid: frappe-missing-translate-function
|
||||||
|
msgprint("Useful message")
|
||||||
|
|
||||||
|
|
||||||
|
# ok: frappe-missing-translate-function
|
||||||
|
translatedmessage = _("Hello")
|
||||||
|
|
||||||
|
# ok: frappe-missing-translate-function
|
||||||
|
throw(translatedmessage)
|
||||||
|
|
||||||
|
# ok: frappe-missing-translate-function
|
||||||
|
msgprint(translatedmessage)
|
||||||
|
|
||||||
|
# ok: frappe-missing-translate-function
|
||||||
|
msgprint(_("Helpful message"))
|
||||||
|
|
||||||
|
# ok: frappe-missing-translate-function
|
||||||
|
frappe.throw(_("Error occured"))
|
||||||
15
.github/helper/semgrep_rules/ux.yml
vendored
Normal file
15
.github/helper/semgrep_rules/ux.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
rules:
|
||||||
|
- id: frappe-missing-translate-function
|
||||||
|
pattern-either:
|
||||||
|
- patterns:
|
||||||
|
- pattern: frappe.msgprint("...", ...)
|
||||||
|
- pattern-not: frappe.msgprint(_("..."), ...)
|
||||||
|
- pattern-not: frappe.msgprint(__("..."), ...)
|
||||||
|
- patterns:
|
||||||
|
- pattern: frappe.throw("...", ...)
|
||||||
|
- pattern-not: frappe.throw(_("..."), ...)
|
||||||
|
- pattern-not: frappe.throw(__("..."), ...)
|
||||||
|
message: |
|
||||||
|
All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations
|
||||||
|
languages: [python, javascript, json]
|
||||||
|
severity: ERROR
|
||||||
7
.github/workflows/ci-tests.yml
vendored
7
.github/workflows/ci-tests.yml
vendored
|
|
@ -149,9 +149,10 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE}
|
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE}
|
||||||
cd ${GITHUB_WORKSPACE}
|
cd ${GITHUB_WORKSPACE}
|
||||||
pip install coveralls==2.2.0
|
pip install coveralls==3.0.1
|
||||||
pip install coverage==4.5.4
|
pip install coverage==5.5
|
||||||
coveralls
|
coveralls --service=github
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }}
|
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }}
|
||||||
|
COVERALLS_SERVICE_NAME: github
|
||||||
|
|
|
||||||
14
.github/workflows/semgrep.yml
vendored
14
.github/workflows/semgrep.yml
vendored
|
|
@ -14,9 +14,19 @@ jobs:
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: 3.8
|
||||||
- name: Run semgrep
|
|
||||||
|
- name: Setup semgrep
|
||||||
run: |
|
run: |
|
||||||
python -m pip install -q semgrep
|
python -m pip install -q semgrep
|
||||||
git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q
|
git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q
|
||||||
|
|
||||||
|
- name: Semgrep errors
|
||||||
|
run: |
|
||||||
files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
|
files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
|
||||||
[[ -d .github/helper/semgrep_rules ]] && semgrep --config=.github/helper/semgrep_rules --quiet --error $files
|
[[ -d .github/helper/semgrep_rules ]] && semgrep --severity ERROR --config=.github/helper/semgrep_rules --quiet --error $files
|
||||||
|
semgrep --config="r/python.lang.correctness" --quiet --error $files
|
||||||
|
|
||||||
|
- name: Semgrep warnings
|
||||||
|
run: |
|
||||||
|
files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
|
||||||
|
[[ -d .github/helper/semgrep_rules ]] && semgrep --severity WARNING --severity INFO --config=.github/helper/semgrep_rules --quiet $files
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ context('Form', () => {
|
||||||
});
|
});
|
||||||
it('create a new form', () => {
|
it('create a new form', () => {
|
||||||
cy.visit('/app/todo/new');
|
cy.visit('/app/todo/new');
|
||||||
cy.fill_field('description', 'this is a test todo', 'Text Editor').blur();
|
cy.fill_field('description', 'this is a test todo', 'Text Editor');
|
||||||
cy.wait(300);
|
cy.wait(300);
|
||||||
cy.get('.page-title').should('contain', 'Not Saved');
|
cy.get('.page-title').should('contain', 'Not Saved');
|
||||||
cy.intercept({
|
cy.intercept({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
context('Relative Timeframe', () => {
|
context('Relative Timeframe', () => {
|
||||||
beforeEach(() => {
|
|
||||||
cy.login();
|
|
||||||
});
|
|
||||||
before(() => {
|
before(() => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.visit('/app/website');
|
cy.visit('/app/website');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
context('Table MultiSelect', () => {
|
context('Table MultiSelect', () => {
|
||||||
beforeEach(() => {
|
before(() => {
|
||||||
cy.login();
|
cy.login();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from __future__ import unicode_literals, print_function
|
||||||
from six import iteritems, binary_type, text_type, string_types, PY2
|
from six import iteritems, binary_type, text_type, string_types, PY2
|
||||||
from werkzeug.local import Local, release_local
|
from werkzeug.local import Local, release_local
|
||||||
import os, sys, importlib, inspect, json
|
import os, sys, importlib, inspect, json
|
||||||
|
import typing
|
||||||
from past.builtins import cmp
|
from past.builtins import cmp
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ if PY2:
|
||||||
reload(sys)
|
reload(sys)
|
||||||
sys.setdefaultencoding("utf-8")
|
sys.setdefaultencoding("utf-8")
|
||||||
|
|
||||||
__version__ = '13.0.0-dev'
|
__version__ = '13.1.0'
|
||||||
|
|
||||||
__title__ = "Frappe Framework"
|
__title__ = "Frappe Framework"
|
||||||
|
|
||||||
|
|
@ -134,6 +135,14 @@ message_log = local("message_log")
|
||||||
|
|
||||||
lang = local("lang")
|
lang = local("lang")
|
||||||
|
|
||||||
|
# This if block is never executed when running the code. It is only used for
|
||||||
|
# telling static code analyzer where to find dynamically defined attributes.
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from frappe.database.mariadb.database import MariaDBDatabase
|
||||||
|
from frappe.database.postgres.database import PostgresDatabase
|
||||||
|
db: typing.Union[MariaDBDatabase, PostgresDatabase]
|
||||||
|
# end: static analysis hack
|
||||||
|
|
||||||
def init(site, sites_path=None, new_site=False):
|
def init(site, sites_path=None, new_site=False):
|
||||||
"""Initialize frappe for the current site. Reset thread locals `frappe.local`"""
|
"""Initialize frappe for the current site. Reset thread locals `frappe.local`"""
|
||||||
if getattr(local, "initialised", None):
|
if getattr(local, "initialised", None):
|
||||||
|
|
@ -966,7 +975,7 @@ def get_pymodule_path(modulename, *joins):
|
||||||
:param *joins: Join additional path elements using `os.path.join`."""
|
:param *joins: Join additional path elements using `os.path.join`."""
|
||||||
if not "public" in joins:
|
if not "public" in joins:
|
||||||
joins = [scrub(part) for part in joins]
|
joins = [scrub(part) for part in joins]
|
||||||
return os.path.join(os.path.dirname(get_module(scrub(modulename)).__file__), *joins)
|
return os.path.join(os.path.dirname(get_module(scrub(modulename)).__file__ or ''), *joins)
|
||||||
|
|
||||||
def get_module_list(app_name):
|
def get_module_list(app_name):
|
||||||
"""Get list of modules for given all via `app/modules.txt`."""
|
"""Get list of modules for given all via `app/modules.txt`."""
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ class LoginManager:
|
||||||
self.make_session()
|
self.make_session()
|
||||||
self.set_user_info()
|
self.set_user_info()
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
def login(self):
|
def login(self):
|
||||||
# clear cache
|
# clear cache
|
||||||
frappe.clear_cache(user = frappe.form_dict.get('usr'))
|
frappe.clear_cache(user = frappe.form_dict.get('usr'))
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ def get_bootinfo():
|
||||||
bootinfo.user_info = get_user_info()
|
bootinfo.user_info = get_user_info()
|
||||||
bootinfo.sid = frappe.session['sid']
|
bootinfo.sid = frappe.session['sid']
|
||||||
|
|
||||||
|
bootinfo.user_groups = frappe.get_all('User Group', pluck="name")
|
||||||
|
|
||||||
bootinfo.modules = {}
|
bootinfo.modules = {}
|
||||||
bootinfo.module_list = []
|
bootinfo.module_list = []
|
||||||
load_desktop_data(bootinfo)
|
load_desktop_data(bootinfo)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ global_cache_keys = ("app_hooks", "installed_apps", 'all_apps',
|
||||||
'scheduler_events', 'time_zone', 'webhooks', 'active_domains',
|
'scheduler_events', 'time_zone', 'webhooks', 'active_domains',
|
||||||
'active_modules', 'assignment_rule', 'server_script_map', 'wkhtmltopdf_version',
|
'active_modules', 'assignment_rule', 'server_script_map', 'wkhtmltopdf_version',
|
||||||
'domain_restricted_doctypes', 'domain_restricted_pages', 'information_schema:counts',
|
'domain_restricted_doctypes', 'domain_restricted_pages', 'information_schema:counts',
|
||||||
'sitemap_routes', 'db_tables') + doctype_map_keys
|
'sitemap_routes', 'db_tables', 'server_script_autocompletion_items') + doctype_map_keys
|
||||||
|
|
||||||
user_cache_keys = ("bootinfo", "user_recent", "roles", "user_doc", "lang",
|
user_cache_keys = ("bootinfo", "user_recent", "roles", "user_doc", "lang",
|
||||||
"defaults", "user_permissions", "home_page", "linked_with",
|
"defaults", "user_permissions", "home_page", "linked_with",
|
||||||
|
|
|
||||||
22
frappe/change_log/v13/v13_1_0.md
Normal file
22
frappe/change_log/v13/v13_1_0.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Version 13.1.0 Release Notes
|
||||||
|
|
||||||
|
### Features & Enhancements
|
||||||
|
|
||||||
|
- Automated mail notifications will be shown in timeline ([#12693](https://github.com/frappe/frappe/pull/12693))
|
||||||
|
- Introduced Client Script for List views ([#12590](https://github.com/frappe/frappe/pull/12590))
|
||||||
|
- Introduced language switcher for guest users on website navbar ([#12813](https://github.com/frappe/frappe/pull/12813))
|
||||||
|
- Option to give submit permission while sharing a document ([#12799](https://github.com/frappe/frappe/pull/12799))
|
||||||
|
- Added option to set `autoname` in Customize Form ([#12413](https://github.com/frappe/frappe/pull/12413))
|
||||||
|
- Virtual DocType ([#12121](https://github.com/frappe/frappe/pull/12121))
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Workspace fixes ([#12650](https://github.com/frappe/frappe/pull/12650)) ([#12655](https://github.com/frappe/frappe/pull/12655)) ([#12869](https://github.com/frappe/frappe/pull/12869))
|
||||||
|
- Fixed an issue where select options were not getting updated in Grid ([#12839](https://github.com/frappe/frappe/pull/12839))
|
||||||
|
- Webform Fixes ([#12630](https://github.com/frappe/frappe/pull/12630)) ([#12756](https://github.com/frappe/frappe/pull/12756)) ([#12819](https://github.com/frappe/frappe/pull/12819))
|
||||||
|
- Fixed timespan filter for next and last timespans ([#12509](https://github.com/frappe/frappe/pull/12509))
|
||||||
|
- System Notification fixes ([#12719](https://github.com/frappe/frappe/pull/12719))
|
||||||
|
- Design Fixes ([#12669](https://github.com/frappe/frappe/pull/12669)) ([#12591](https://github.com/frappe/frappe/pull/12591)) ([#12557](https://github.com/frappe/frappe/pull/12557)) ([#12751](https://github.com/frappe/frappe/pull/12751)) ([#12864](https://github.com/frappe/frappe/pull/12864))
|
||||||
|
- Fixed Multi-column paste in grid ([#12861](https://github.com/frappe/frappe/pull/12861))
|
||||||
|
- Fixed grid validation ([#12744](https://github.com/frappe/frappe/pull/12744))
|
||||||
|
- Fixed currency value formatting in dashboard chart ([#12613](https://github.com/frappe/frappe/pull/12613))
|
||||||
|
|
@ -62,11 +62,24 @@ def popen(command, *args, **kwargs):
|
||||||
if env:
|
if env:
|
||||||
env = dict(environ, **env)
|
env = dict(environ, **env)
|
||||||
|
|
||||||
|
def set_low_prio():
|
||||||
|
import psutil
|
||||||
|
if psutil.LINUX:
|
||||||
|
psutil.Process().nice(19)
|
||||||
|
psutil.Process().ionice(psutil.IOPRIO_CLASS_IDLE)
|
||||||
|
elif psutil.WINDOWS:
|
||||||
|
psutil.Process().nice(psutil.IDLE_PRIORITY_CLASS)
|
||||||
|
psutil.Process().ionice(psutil.IOPRIO_VERYLOW)
|
||||||
|
else:
|
||||||
|
psutil.Process().nice(19)
|
||||||
|
# ionice not supported
|
||||||
|
|
||||||
proc = subprocess.Popen(command,
|
proc = subprocess.Popen(command,
|
||||||
stdout=None if output else subprocess.PIPE,
|
stdout=None if output else subprocess.PIPE,
|
||||||
stderr=None if output else subprocess.PIPE,
|
stderr=None if output else subprocess.PIPE,
|
||||||
shell=shell,
|
shell=shell,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
|
preexec_fn=set_low_prio,
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,22 +18,33 @@ def _is_scheduler_enabled():
|
||||||
|
|
||||||
return enable_scheduler
|
return enable_scheduler
|
||||||
|
|
||||||
@click.command('trigger-scheduler-event')
|
|
||||||
@click.argument('event')
|
@click.command("trigger-scheduler-event", help="Trigger a scheduler event")
|
||||||
|
@click.argument("event")
|
||||||
@pass_context
|
@pass_context
|
||||||
def trigger_scheduler_event(context, event):
|
def trigger_scheduler_event(context, event):
|
||||||
"Trigger a scheduler event"
|
|
||||||
import frappe.utils.scheduler
|
import frappe.utils.scheduler
|
||||||
|
|
||||||
|
exit_code = 0
|
||||||
|
|
||||||
for site in context.sites:
|
for site in context.sites:
|
||||||
try:
|
try:
|
||||||
frappe.init(site=site)
|
frappe.init(site=site)
|
||||||
frappe.connect()
|
frappe.connect()
|
||||||
frappe.utils.scheduler.trigger(site, event, now=True)
|
try:
|
||||||
|
frappe.get_doc("Scheduled Job Type", {"method": event}).execute()
|
||||||
|
except frappe.DoesNotExistError:
|
||||||
|
click.secho(f"Event {event} does not exist!", fg="red")
|
||||||
|
exit_code = 1
|
||||||
finally:
|
finally:
|
||||||
frappe.destroy()
|
frappe.destroy()
|
||||||
|
|
||||||
if not context.sites:
|
if not context.sites:
|
||||||
raise SiteNotSpecifiedError
|
raise SiteNotSpecifiedError
|
||||||
|
|
||||||
|
sys.exit(exit_code)
|
||||||
|
|
||||||
|
|
||||||
@click.command('enable-scheduler')
|
@click.command('enable-scheduler')
|
||||||
@pass_context
|
@pass_context
|
||||||
def enable_scheduler(context):
|
def enable_scheduler(context):
|
||||||
|
|
|
||||||
|
|
@ -676,10 +676,8 @@ def start_ngrok(context):
|
||||||
frappe.init(site=site)
|
frappe.init(site=site)
|
||||||
|
|
||||||
port = frappe.conf.http_port or frappe.conf.webserver_port
|
port = frappe.conf.http_port or frappe.conf.webserver_port
|
||||||
public_url = ngrok.connect(port=port, options={
|
tunnel = ngrok.connect(addr=str(port), host_header=site)
|
||||||
'host_header': site
|
print(f'Public URL: {tunnel.public_url}')
|
||||||
})
|
|
||||||
print(f'Public URL: {public_url}')
|
|
||||||
print('Inspect logs at http://localhost:4040')
|
print('Inspect logs at http://localhost:4040')
|
||||||
|
|
||||||
ngrok_process = ngrok.get_ngrok_process()
|
ngrok_process = ngrok.get_ngrok_process()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import click
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.commands import get_site, pass_context
|
from frappe.commands import get_site, pass_context
|
||||||
from frappe.exceptions import SiteNotSpecifiedError
|
from frappe.exceptions import SiteNotSpecifiedError
|
||||||
from frappe.utils import get_bench_path, update_progress_bar
|
from frappe.utils import get_bench_path, update_progress_bar, cint
|
||||||
|
|
||||||
|
|
||||||
@click.command('build')
|
@click.command('build')
|
||||||
|
|
@ -567,11 +567,14 @@ def run_ui_tests(context, app, headless=False):
|
||||||
|
|
||||||
node_bin = subprocess.getoutput("npm bin")
|
node_bin = subprocess.getoutput("npm bin")
|
||||||
cypress_path = "{0}/cypress".format(node_bin)
|
cypress_path = "{0}/cypress".format(node_bin)
|
||||||
plugin_path = "{0}/cypress-file-upload".format(node_bin)
|
plugin_path = "{0}/../cypress-file-upload".format(node_bin)
|
||||||
|
|
||||||
# check if cypress in path...if not, install it.
|
# check if cypress in path...if not, install it.
|
||||||
if not (os.path.exists(cypress_path) or os.path.exists(plugin_path)) \
|
if not (
|
||||||
or not subprocess.getoutput("npm view cypress version").startswith("6."):
|
os.path.exists(cypress_path)
|
||||||
|
and os.path.exists(plugin_path)
|
||||||
|
and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6
|
||||||
|
):
|
||||||
# install cypress
|
# install cypress
|
||||||
click.secho("Installing Cypress...", fg="yellow")
|
click.secho("Installing Cypress...", fg="yellow")
|
||||||
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile")
|
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile")
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,6 @@ frappe.listview_settings['Communication'] = {
|
||||||
},
|
},
|
||||||
|
|
||||||
primary_action: function() {
|
primary_action: function() {
|
||||||
new frappe.views.CommunicationComposer({ doc: {} });
|
new frappe.views.CommunicationComposer();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@
|
||||||
"fieldname": "import_file",
|
"fieldname": "import_file",
|
||||||
"fieldtype": "Attach",
|
"fieldtype": "Attach",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Import File"
|
"label": "Import File",
|
||||||
|
"read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "import_preview",
|
"fieldname": "import_preview",
|
||||||
|
|
@ -156,10 +157,11 @@
|
||||||
"description": "Must be a publicly accessible Google Sheets URL",
|
"description": "Must be a publicly accessible Google Sheets URL",
|
||||||
"fieldname": "google_sheets_url",
|
"fieldname": "google_sheets_url",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Import from Google Sheets"
|
"label": "Import from Google Sheets",
|
||||||
|
"read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "eval:doc.google_sheets_url && !doc.__unsaved",
|
"depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)",
|
||||||
"fieldname": "refresh_google_sheet",
|
"fieldname": "refresh_google_sheet",
|
||||||
"fieldtype": "Button",
|
"fieldtype": "Button",
|
||||||
"label": "Refresh Google Sheet"
|
"label": "Refresh Google Sheet"
|
||||||
|
|
@ -167,7 +169,7 @@
|
||||||
],
|
],
|
||||||
"hide_toolbar": 1,
|
"hide_toolbar": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2020-06-24 14:33:03.173876",
|
"modified": "2021-04-11 01:50:42.074623",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "Data Import",
|
"name": "Data Import",
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@
|
||||||
"show_preview_popup",
|
"show_preview_popup",
|
||||||
"show_name_in_global_search",
|
"show_name_in_global_search",
|
||||||
"email_settings_sb",
|
"email_settings_sb",
|
||||||
|
"default_email_template",
|
||||||
|
"column_break_51",
|
||||||
"email_append_to",
|
"email_append_to",
|
||||||
"sender_field",
|
"sender_field",
|
||||||
"subject_field",
|
"subject_field",
|
||||||
|
|
@ -535,6 +537,16 @@
|
||||||
"fieldname": "is_virtual",
|
"fieldname": "is_virtual",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Is Virtual"
|
"label": "Is Virtual"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "default_email_template",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Default Email Template",
|
||||||
|
"options": "Email Template"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_51",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"icon": "fa fa-bolt",
|
"icon": "fa fa-bolt",
|
||||||
|
|
@ -616,7 +628,7 @@
|
||||||
"link_fieldname": "reference_doctype"
|
"link_fieldname": "reference_doctype"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2021-02-17 20:18:06.212232",
|
"modified": "2021-04-16 12:26:41.031135",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Core",
|
"module": "Core",
|
||||||
"name": "DocType",
|
"name": "DocType",
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,10 @@ def run_background(prepared_report):
|
||||||
custom_report_doc = report
|
custom_report_doc = report
|
||||||
reference_report = custom_report_doc.reference_report
|
reference_report = custom_report_doc.reference_report
|
||||||
report = frappe.get_doc("Report", reference_report)
|
report = frappe.get_doc("Report", reference_report)
|
||||||
report.custom_columns = custom_report_doc.json
|
if custom_report_doc.json:
|
||||||
|
data = json.loads(custom_report_doc.json)
|
||||||
|
if data:
|
||||||
|
report.custom_columns = data["columns"]
|
||||||
|
|
||||||
result = generate_report_result(
|
result = generate_report_result(
|
||||||
report=report,
|
report=report,
|
||||||
|
|
|
||||||
|
|
@ -325,9 +325,8 @@ def get_group_by_field(args, doctype):
|
||||||
if args['aggregate_function'] == 'count':
|
if args['aggregate_function'] == 'count':
|
||||||
group_by_field = 'count(*) as _aggregate_column'
|
group_by_field = 'count(*) as _aggregate_column'
|
||||||
else:
|
else:
|
||||||
group_by_field = '{0}(`tab{1}`.{2}) as _aggregate_column'.format(
|
group_by_field = '{0}({1}) as _aggregate_column'.format(
|
||||||
args.aggregate_function,
|
args.aggregate_function,
|
||||||
doctype,
|
|
||||||
args.aggregate_on
|
args.aggregate_on
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@
|
||||||
# Copyright (c) 2019, Frappe Technologies and contributors
|
# Copyright (c) 2019, Frappe Technologies and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
import json
|
||||||
|
from datetime import datetime
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
import frappe, json
|
|
||||||
from frappe.model.document import Document
|
|
||||||
from frappe.utils import now_datetime, get_datetime
|
|
||||||
from datetime import datetime
|
|
||||||
from croniter import croniter
|
from croniter import croniter
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import get_datetime, now_datetime
|
||||||
from frappe.utils.background_jobs import enqueue, get_jobs
|
from frappe.utils.background_jobs import enqueue, get_jobs
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ frappe.ui.form.on('Server Script', {
|
||||||
if (frm.doc.script_type != 'Scheduler Event') {
|
if (frm.doc.script_type != 'Scheduler Event') {
|
||||||
frm.dashboard.hide();
|
frm.dashboard.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
frm.call('get_autocompletion_items')
|
||||||
|
.then(r => r.message)
|
||||||
|
.then(items => {
|
||||||
|
frm.set_df_property('script', 'autocompletions', items);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setup_help(frm) {
|
setup_help(frm) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
from types import FunctionType, MethodType, ModuleType
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.utils.safe_exec import safe_exec
|
from frappe.utils.safe_exec import get_safe_globals, safe_exec, NamespaceDict
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,6 +123,51 @@ class ServerScript(Document):
|
||||||
if locals["conditions"]:
|
if locals["conditions"]:
|
||||||
return locals["conditions"]
|
return locals["conditions"]
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_autocompletion_items(self):
|
||||||
|
"""Generates a list of a autocompletion strings from the context dict
|
||||||
|
that is used while executing a Server Script.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Returns list of autocompletion items.
|
||||||
|
For e.g., ["frappe.utils.cint", "frappe.db.get_all", ...]
|
||||||
|
"""
|
||||||
|
def get_keys(obj):
|
||||||
|
out = []
|
||||||
|
for key in obj:
|
||||||
|
if key.startswith('_'):
|
||||||
|
continue
|
||||||
|
value = obj[key]
|
||||||
|
if isinstance(value, (NamespaceDict, dict)) and value:
|
||||||
|
if key == 'form_dict':
|
||||||
|
out.append(['form_dict', 7])
|
||||||
|
continue
|
||||||
|
for subkey, score in get_keys(value):
|
||||||
|
fullkey = f'{key}.{subkey}'
|
||||||
|
out.append([fullkey, score])
|
||||||
|
else:
|
||||||
|
if isinstance(value, type) and issubclass(value, Exception):
|
||||||
|
score = 0
|
||||||
|
elif isinstance(value, ModuleType):
|
||||||
|
score = 10
|
||||||
|
elif isinstance(value, (FunctionType, MethodType)):
|
||||||
|
score = 9
|
||||||
|
elif isinstance(value, type):
|
||||||
|
score = 8
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
score = 7
|
||||||
|
else:
|
||||||
|
score = 6
|
||||||
|
out.append([key, score])
|
||||||
|
return out
|
||||||
|
|
||||||
|
items = frappe.cache().get_value('server_script_autocompletion_items')
|
||||||
|
if not items:
|
||||||
|
items = get_keys(get_safe_globals())
|
||||||
|
items = [{'value': d[0], 'score': d[1]} for d in items]
|
||||||
|
frappe.cache().set_value('server_script_autocompletion_items', items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def setup_scheduler_events(script_name, frequency):
|
def setup_scheduler_events(script_name, frequency):
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,28 @@ class TestUser(unittest.TestCase):
|
||||||
self.assertEqual(extract_mentions(comment)[0], "test_user@example.com")
|
self.assertEqual(extract_mentions(comment)[0], "test_user@example.com")
|
||||||
self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com")
|
self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com")
|
||||||
|
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
'doctype': 'User Group',
|
||||||
|
'name': 'Team',
|
||||||
|
'user_group_members': [{
|
||||||
|
'user': 'test@example.com'
|
||||||
|
}, {
|
||||||
|
'user': 'test1@example.com'
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
doc.insert(ignore_if_duplicate=True)
|
||||||
|
|
||||||
|
comment = '''
|
||||||
|
<div>
|
||||||
|
Testing comment for
|
||||||
|
<span class="mention" data-id="Team" data-value="Team" data-is-group="true" data-denotation-char="@">
|
||||||
|
<span><span class="ql-mention-denotation-char">@</span>Team</span>
|
||||||
|
</span>
|
||||||
|
please check
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
self.assertListEqual(extract_mentions(comment), ['test@example.com', 'test1@example.com'])
|
||||||
|
|
||||||
def test_rate_limiting_for_reset_password(self):
|
def test_rate_limiting_for_reset_password(self):
|
||||||
# Allow only one reset request for a day
|
# Allow only one reset request for a day
|
||||||
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
|
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
|
||||||
|
|
|
||||||
|
|
@ -1018,8 +1018,16 @@ def extract_mentions(txt):
|
||||||
soup = BeautifulSoup(txt, 'html.parser')
|
soup = BeautifulSoup(txt, 'html.parser')
|
||||||
emails = []
|
emails = []
|
||||||
for mention in soup.find_all(class_='mention'):
|
for mention in soup.find_all(class_='mention'):
|
||||||
|
if mention.get('data-is-group') == 'true':
|
||||||
|
try:
|
||||||
|
user_group = frappe.get_cached_doc('User Group', mention['data-id'])
|
||||||
|
emails += [d.user for d in user_group.user_group_members]
|
||||||
|
except frappe.DoesNotExistError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
email = mention['data-id']
|
email = mention['data-id']
|
||||||
emails.append(email)
|
emails.append(email)
|
||||||
|
|
||||||
return emails
|
return emails
|
||||||
|
|
||||||
def handle_password_test_fail(result):
|
def handle_password_test_fail(result):
|
||||||
|
|
|
||||||
0
frappe/core/doctype/user_group/__init__.py
Normal file
0
frappe/core/doctype/user_group/__init__.py
Normal file
10
frappe/core/doctype/user_group/test_user_group.py
Normal file
10
frappe/core/doctype/user_group/test_user_group.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2021, Frappe Technologies and Contributors
|
||||||
|
# See license.txt
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
class TestUserGroup(unittest.TestCase):
|
||||||
|
pass
|
||||||
8
frappe/core/doctype/user_group/user_group.js
Normal file
8
frappe/core/doctype/user_group/user_group.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Copyright (c) 2021, Frappe Technologies and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on('User Group', {
|
||||||
|
// refresh: function(frm) {
|
||||||
|
|
||||||
|
// }
|
||||||
|
});
|
||||||
48
frappe/core/doctype/user_group/user_group.json
Normal file
48
frappe/core/doctype/user_group/user_group.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"autoname": "Prompt",
|
||||||
|
"creation": "2021-04-12 15:17:24.751710",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"user_group_members"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "user_group_members",
|
||||||
|
"fieldtype": "Table MultiSelect",
|
||||||
|
"label": "User Group Members",
|
||||||
|
"options": "User Group Member",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2021-04-15 16:12:31.455401",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Core",
|
||||||
|
"name": "User Group",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"read": 1,
|
||||||
|
"role": "All"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
15
frappe/core/doctype/user_group/user_group.py
Normal file
15
frappe/core/doctype/user_group/user_group.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
import frappe
|
||||||
|
|
||||||
|
class UserGroup(Document):
|
||||||
|
def after_insert(self):
|
||||||
|
frappe.publish_realtime('user_group_added', self.name)
|
||||||
|
|
||||||
|
def on_trash(self):
|
||||||
|
frappe.publish_realtime('user_group_deleted', self.name)
|
||||||
0
frappe/core/doctype/user_group_member/__init__.py
Normal file
0
frappe/core/doctype/user_group_member/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2021, Frappe Technologies and Contributors
|
||||||
|
# See license.txt
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
class TestUserGroupMember(unittest.TestCase):
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Copyright (c) 2021, Frappe Technologies and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
frappe.ui.form.on('User Group Member', {
|
||||||
|
// refresh: function(frm) {
|
||||||
|
|
||||||
|
// }
|
||||||
|
});
|
||||||
32
frappe/core/doctype/user_group_member/user_group_member.json
Normal file
32
frappe/core/doctype/user_group_member/user_group_member.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"creation": "2021-04-12 15:16:29.279107",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"editable_grid": 1,
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "user",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "User",
|
||||||
|
"options": "User",
|
||||||
|
"reqd": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"istable": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2021-04-12 15:17:18.773046",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Core",
|
||||||
|
"name": "User Group Member",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [],
|
||||||
|
"sort_field": "modified",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"track_changes": 1
|
||||||
|
}
|
||||||
10
frappe/core/doctype/user_group_member/user_group_member.py
Normal file
10
frappe/core/doctype/user_group_member/user_group_member.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
class UserGroupMember(Document):
|
||||||
|
pass
|
||||||
|
|
@ -40,6 +40,8 @@ class CustomField(Document):
|
||||||
frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt))
|
frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt))
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
|
from frappe.custom.doctype.customize_form.customize_form import CustomizeForm
|
||||||
|
|
||||||
meta = frappe.get_meta(self.dt, cached=False)
|
meta = frappe.get_meta(self.dt, cached=False)
|
||||||
fieldnames = [df.fieldname for df in meta.get("fields")]
|
fieldnames = [df.fieldname for df in meta.get("fields")]
|
||||||
|
|
||||||
|
|
@ -49,7 +51,11 @@ class CustomField(Document):
|
||||||
if self.insert_after and self.insert_after in fieldnames:
|
if self.insert_after and self.insert_after in fieldnames:
|
||||||
self.idx = fieldnames.index(self.insert_after) + 1
|
self.idx = fieldnames.index(self.insert_after) + 1
|
||||||
|
|
||||||
self._old_fieldtype = self.db_get('fieldtype')
|
old_fieldtype = self.db_get('fieldtype')
|
||||||
|
is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype)
|
||||||
|
|
||||||
|
if is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype):
|
||||||
|
frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype))
|
||||||
|
|
||||||
if not self.fieldname:
|
if not self.fieldname:
|
||||||
frappe.throw(_("Fieldname not set for Custom Field"))
|
frappe.throw(_("Fieldname not set for Custom Field"))
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@
|
||||||
"show_preview_popup",
|
"show_preview_popup",
|
||||||
"image_view",
|
"image_view",
|
||||||
"email_settings_section",
|
"email_settings_section",
|
||||||
|
"default_email_template",
|
||||||
|
"column_break_26",
|
||||||
"email_append_to",
|
"email_append_to",
|
||||||
"sender_field",
|
"sender_field",
|
||||||
"subject_field",
|
"subject_field",
|
||||||
|
|
@ -264,6 +266,16 @@
|
||||||
"label": "Actions",
|
"label": "Actions",
|
||||||
"options": "DocType Action"
|
"options": "DocType Action"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "default_email_template",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Default Email Template",
|
||||||
|
"options": "Email Template"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_26",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collapsible": 1,
|
"collapsible": 1,
|
||||||
"fieldname": "naming_section",
|
"fieldname": "naming_section",
|
||||||
|
|
@ -283,7 +295,7 @@
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2021-02-16 15:22:11.108256",
|
"modified": "2021-03-22 12:27:15.462727",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Custom",
|
"module": "Custom",
|
||||||
"name": "Customize Form",
|
"name": "Customize Form",
|
||||||
|
|
@ -304,4 +316,4 @@
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -401,22 +401,18 @@ class CustomizeForm(Document):
|
||||||
return property_value
|
return property_value
|
||||||
|
|
||||||
def validate_fieldtype_change(self, df, old_value, new_value):
|
def validate_fieldtype_change(self, df, old_value, new_value):
|
||||||
allowed = False
|
allowed = self.allow_fieldtype_change(old_value, new_value)
|
||||||
self.check_length_for_fieldtypes = []
|
if allowed:
|
||||||
for allowed_changes in ALLOWED_FIELDTYPE_CHANGE:
|
old_value_length = cint(frappe.db.type_map.get(old_value)[1])
|
||||||
if (old_value in allowed_changes and new_value in allowed_changes):
|
new_value_length = cint(frappe.db.type_map.get(new_value)[1])
|
||||||
allowed = True
|
|
||||||
old_value_length = cint(frappe.db.type_map.get(old_value)[1])
|
|
||||||
new_value_length = cint(frappe.db.type_map.get(new_value)[1])
|
|
||||||
|
|
||||||
# Ignore fieldtype check validation if new field type has unspecified maxlength
|
# Ignore fieldtype check validation if new field type has unspecified maxlength
|
||||||
# Changes like DATA to TEXT, where new_value_lenth equals 0 will not be validated
|
# Changes like DATA to TEXT, where new_value_lenth equals 0 will not be validated
|
||||||
if new_value_length and (old_value_length > new_value_length):
|
if new_value_length and (old_value_length > new_value_length):
|
||||||
self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value})
|
self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value})
|
||||||
self.validate_fieldtype_length()
|
self.validate_fieldtype_length()
|
||||||
else:
|
else:
|
||||||
self.flags.update_db = True
|
self.flags.update_db = True
|
||||||
break
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx))
|
frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx))
|
||||||
|
|
||||||
|
|
@ -458,6 +454,14 @@ class CustomizeForm(Document):
|
||||||
reset_customization(self.doc_type)
|
reset_customization(self.doc_type)
|
||||||
self.fetch_to_customize()
|
self.fetch_to_customize()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def allow_fieldtype_change(self, old_type: str, new_type: str) -> bool:
|
||||||
|
""" allow type change, if both old_type and new_type are in same field group.
|
||||||
|
field groups are defined in ALLOWED_FIELDTYPE_CHANGE variables.
|
||||||
|
"""
|
||||||
|
in_field_group = lambda group: (old_type in group) and (new_type in group)
|
||||||
|
return any(map(in_field_group, ALLOWED_FIELDTYPE_CHANGE))
|
||||||
|
|
||||||
def reset_customization(doctype):
|
def reset_customization(doctype):
|
||||||
setters = frappe.get_all("Property Setter", filters={
|
setters = frappe.get_all("Property Setter", filters={
|
||||||
'doc_type': doctype,
|
'doc_type': doctype,
|
||||||
|
|
@ -487,6 +491,7 @@ doctype_properties = {
|
||||||
'allow_auto_repeat': 'Check',
|
'allow_auto_repeat': 'Check',
|
||||||
'allow_import': 'Check',
|
'allow_import': 'Check',
|
||||||
'show_preview_popup': 'Check',
|
'show_preview_popup': 'Check',
|
||||||
|
'default_email_template': 'Data',
|
||||||
'email_append_to': 'Check',
|
'email_append_to': 'Check',
|
||||||
'subject_field': 'Data',
|
'subject_field': 'Data',
|
||||||
'sender_field': 'Data',
|
'sender_field': 'Data',
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import frappe
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import pymysql
|
import pymysql
|
||||||
from pymysql.times import TimeDelta
|
from pymysql.constants import ER, FIELD_TYPE
|
||||||
from pymysql.constants import ER, FIELD_TYPE
|
from pymysql.converters import conversions, escape_string
|
||||||
from pymysql.converters import conversions
|
|
||||||
|
|
||||||
from frappe.utils import get_datetime, cstr, UnicodeWithAttrs
|
import frappe
|
||||||
from frappe.database.database import Database
|
from frappe.database.database import Database
|
||||||
from six import PY2, binary_type, text_type, string_types
|
|
||||||
from frappe.database.mariadb.schema import MariaDBTable
|
from frappe.database.mariadb.schema import MariaDBTable
|
||||||
|
from frappe.utils import UnicodeWithAttrs, cstr, get_datetime
|
||||||
|
|
||||||
|
|
||||||
class MariaDBDatabase(Database):
|
class MariaDBDatabase(Database):
|
||||||
|
|
@ -72,22 +68,20 @@ class MariaDBDatabase(Database):
|
||||||
conversions.update({
|
conversions.update({
|
||||||
FIELD_TYPE.NEWDECIMAL: float,
|
FIELD_TYPE.NEWDECIMAL: float,
|
||||||
FIELD_TYPE.DATETIME: get_datetime,
|
FIELD_TYPE.DATETIME: get_datetime,
|
||||||
UnicodeWithAttrs: conversions[text_type]
|
UnicodeWithAttrs: conversions[str]
|
||||||
})
|
})
|
||||||
|
|
||||||
if PY2:
|
conn = pymysql.connect(
|
||||||
conversions.update({
|
user=self.user or '',
|
||||||
TimeDelta: conversions[binary_type]
|
password=self.password or '',
|
||||||
})
|
host=self.host,
|
||||||
|
port=self.port,
|
||||||
if usessl:
|
charset='utf8mb4',
|
||||||
conn = pymysql.connect(self.host, self.user or '', self.password or '',
|
use_unicode=True,
|
||||||
port=self.port, charset='utf8mb4', use_unicode = True, ssl=ssl_params,
|
ssl=ssl_params if usessl else None,
|
||||||
conv = conversions, local_infile = frappe.conf.local_infile)
|
conv=conversions,
|
||||||
else:
|
local_infile=frappe.conf.local_infile
|
||||||
conn = pymysql.connect(self.host, self.user or '', self.password or '',
|
)
|
||||||
port=self.port, charset='utf8mb4', use_unicode = True, conv = conversions,
|
|
||||||
local_infile = frappe.conf.local_infile)
|
|
||||||
|
|
||||||
# MYSQL_OPTION_MULTI_STATEMENTS_OFF = 1
|
# MYSQL_OPTION_MULTI_STATEMENTS_OFF = 1
|
||||||
# # self._conn.set_server_option(MYSQL_OPTION_MULTI_STATEMENTS_OFF)
|
# # self._conn.set_server_option(MYSQL_OPTION_MULTI_STATEMENTS_OFF)
|
||||||
|
|
@ -111,7 +105,7 @@ class MariaDBDatabase(Database):
|
||||||
def escape(s, percent=True):
|
def escape(s, percent=True):
|
||||||
"""Excape quotes and percent in given string."""
|
"""Excape quotes and percent in given string."""
|
||||||
# pymysql expects unicode argument to escape_string with Python 3
|
# pymysql expects unicode argument to escape_string with Python 3
|
||||||
s = frappe.as_unicode(pymysql.escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`")
|
s = frappe.as_unicode(escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`")
|
||||||
|
|
||||||
# NOTE separating % escape, because % escape should only be done when using LIKE operator
|
# NOTE separating % escape, because % escape should only be done when using LIKE operator
|
||||||
# or when you use python format string to generate query that already has a %s
|
# or when you use python format string to generate query that already has a %s
|
||||||
|
|
@ -260,7 +254,7 @@ class MariaDBDatabase(Database):
|
||||||
ADD INDEX `%s`(%s)""" % (table_name, index_name, ", ".join(fields)))
|
ADD INDEX `%s`(%s)""" % (table_name, index_name, ", ".join(fields)))
|
||||||
|
|
||||||
def add_unique(self, doctype, fields, constraint_name=None):
|
def add_unique(self, doctype, fields, constraint_name=None):
|
||||||
if isinstance(fields, string_types):
|
if isinstance(fields, str):
|
||||||
fields = [fields]
|
fields = [fields]
|
||||||
if not constraint_name:
|
if not constraint_name:
|
||||||
constraint_name = "unique_" + "_".join(fields)
|
constraint_name = "unique_" + "_".join(fields)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class Workspace:
|
||||||
for section in cards:
|
for section in cards:
|
||||||
links = loads(section.get('links')) if isinstance(section.get('links'), string_types) else section.get('links')
|
links = loads(section.get('links')) if isinstance(section.get('links'), string_types) else section.get('links')
|
||||||
for item in links:
|
for item in links:
|
||||||
if self.is_item_allowed(item.get('name'), item.get('type')):
|
if self.is_item_allowed(item.get('link_to'), item.get('link_type')):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _in_active_domains(item):
|
def _in_active_domains(item):
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ frappe.ui.form.on('Notification Settings', {
|
||||||
|
|
||||||
refresh: (frm) => {
|
refresh: (frm) => {
|
||||||
if (frappe.user.has_role('System Manager')) {
|
if (frappe.user.has_role('System Manager')) {
|
||||||
frm.add_custom_button('Go to Notification Settings List', () => {
|
frm.add_custom_button(__('Go to Notification Settings List'), () => {
|
||||||
frappe.set_route('List', 'Notification Settings');
|
frappe.set_route('List', 'Notification Settings');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,10 @@ def get_report_doc(report_name):
|
||||||
reference_report = custom_report_doc.reference_report
|
reference_report = custom_report_doc.reference_report
|
||||||
doc = frappe.get_doc("Report", reference_report)
|
doc = frappe.get_doc("Report", reference_report)
|
||||||
doc.custom_report = report_name
|
doc.custom_report = report_name
|
||||||
doc.custom_columns = custom_report_doc.json
|
if custom_report_doc.json:
|
||||||
|
data = json.loads(custom_report_doc.json)
|
||||||
|
if data:
|
||||||
|
doc.custom_columns = data["columns"]
|
||||||
doc.is_custom_report = True
|
doc.is_custom_report = True
|
||||||
|
|
||||||
if not doc.is_permitted():
|
if not doc.is_permitted():
|
||||||
|
|
@ -83,7 +86,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None)
|
||||||
|
|
||||||
if report.custom_columns:
|
if report.custom_columns:
|
||||||
# saved columns (with custom columns / with different column order)
|
# saved columns (with custom columns / with different column order)
|
||||||
columns = json.loads(report.custom_columns)
|
columns = report.custom_columns
|
||||||
|
|
||||||
# unsaved custom_columns
|
# unsaved custom_columns
|
||||||
if custom_columns:
|
if custom_columns:
|
||||||
|
|
@ -524,9 +527,12 @@ def save_report(reference_report, report_name, columns):
|
||||||
"report_type": "Custom Report",
|
"report_type": "Custom Report",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if docname:
|
if docname:
|
||||||
report = frappe.get_doc("Report", docname)
|
report = frappe.get_doc("Report", docname)
|
||||||
report.update({"json": columns})
|
existing_jd = json.loads(report.json)
|
||||||
|
existing_jd["columns"] = json.loads(columns)
|
||||||
|
report.update({"json": json.dumps(existing_jd, separators=(',', ':'))})
|
||||||
report.save()
|
report.save()
|
||||||
frappe.msgprint(_("Report updated successfully"))
|
frappe.msgprint(_("Report updated successfully"))
|
||||||
|
|
||||||
|
|
@ -536,7 +542,7 @@ def save_report(reference_report, report_name, columns):
|
||||||
{
|
{
|
||||||
"doctype": "Report",
|
"doctype": "Report",
|
||||||
"report_name": report_name,
|
"report_name": report_name,
|
||||||
"json": columns,
|
"json": f'{{"columns":{columns}}}',
|
||||||
"ref_doctype": report_doc.ref_doctype,
|
"ref_doctype": report_doc.ref_doctype,
|
||||||
"is_standard": "No",
|
"is_standard": "No",
|
||||||
"report_type": "Custom Report",
|
"report_type": "Custom Report",
|
||||||
|
|
|
||||||
|
|
@ -126,13 +126,14 @@ def setup_group_by(data):
|
||||||
if data.group_by:
|
if data.group_by:
|
||||||
if data.aggregate_function.lower() not in ('count', 'sum', 'avg'):
|
if data.aggregate_function.lower() not in ('count', 'sum', 'avg'):
|
||||||
frappe.throw(_('Invalid aggregate function'))
|
frappe.throw(_('Invalid aggregate function'))
|
||||||
if '`' in data.aggregate_on:
|
|
||||||
raise_invalid_field(data.aggregate_on)
|
|
||||||
data.fields.append('{aggregate_function}(`tab{doctype}`.`{aggregate_on}`) AS _aggregate_column'.format(**data))
|
|
||||||
if data.aggregate_on:
|
|
||||||
data.fields.append(data.aggregate_on)
|
|
||||||
|
|
||||||
data.pop('aggregate_on')
|
if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field):
|
||||||
|
data.fields.append('{aggregate_function}(`tab{aggregate_on_doctype}`.`{aggregate_on_field}`) AS _aggregate_column'.format(**data))
|
||||||
|
else:
|
||||||
|
raise_invalid_field(data.aggregate_on_field)
|
||||||
|
|
||||||
|
data.pop('aggregate_on_doctype')
|
||||||
|
data.pop('aggregate_on_field')
|
||||||
data.pop('aggregate_function')
|
data.pop('aggregate_function')
|
||||||
|
|
||||||
def raise_invalid_field(fieldname):
|
def raise_invalid_field(fieldname):
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ def get_all_nodes(doctype, label, parent, tree_method, **filters):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_children(doctype, parent=''):
|
def get_children(doctype, parent='', **filters):
|
||||||
return _get_children(doctype, parent)
|
return _get_children(doctype, parent)
|
||||||
|
|
||||||
def _get_children(doctype, parent='', ignore_permissions=False):
|
def _get_children(doctype, parent='', ignore_permissions=False):
|
||||||
|
|
@ -66,7 +66,7 @@ def add_node():
|
||||||
doc.save()
|
doc.save()
|
||||||
|
|
||||||
def make_tree_args(**kwarg):
|
def make_tree_args(**kwarg):
|
||||||
del kwarg['cmd']
|
kwarg.pop('cmd', None)
|
||||||
|
|
||||||
doctype = kwarg['doctype']
|
doctype = kwarg['doctype']
|
||||||
parent_field = 'parent_' + doctype.lower().replace(' ', '_')
|
parent_field = 'parent_' + doctype.lower().replace(' ', '_')
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,7 @@ def make_links(columns, data):
|
||||||
elif col.fieldtype == "Dynamic Link":
|
elif col.fieldtype == "Dynamic Link":
|
||||||
if col.options and row.get(col.fieldname) and row.get(col.options):
|
if col.options and row.get(col.fieldname) and row.get(col.options):
|
||||||
row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname])
|
row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname])
|
||||||
elif col.fieldtype == "Currency":
|
elif col.fieldtype == "Currency" and row.get(col.fieldname):
|
||||||
row[col.fieldname] = frappe.format_value(row[col.fieldname], col)
|
row[col.fieldname] = frappe.format_value(row[col.fieldname], col)
|
||||||
|
|
||||||
return columns, data
|
return columns, data
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,27 @@
|
||||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# MIT License. See license.txt
|
# MIT License. See license.txt
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
import datetime
|
||||||
import six
|
import email
|
||||||
from six import iteritems, text_type
|
import email.utils
|
||||||
from six.moves import range
|
import imaplib
|
||||||
import time, _socket, poplib, imaplib, email, email.utils, datetime, chardet, re
|
import poplib
|
||||||
from email_reply_parser import EmailReplyParser
|
import re
|
||||||
|
import time
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
|
|
||||||
|
import _socket
|
||||||
|
import chardet
|
||||||
|
import six
|
||||||
|
from email_reply_parser import EmailReplyParser
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _, safe_decode, safe_encode
|
from frappe import _, safe_decode, safe_encode
|
||||||
from frappe.utils import (extract_email_id, convert_utc_to_user_timezone, now,
|
from frappe.core.doctype.file.file import (MaxFileSizeReachedError,
|
||||||
cint, cstr, strip, markdown, parse_addr)
|
get_random_filename)
|
||||||
from frappe.core.doctype.file.file import get_random_filename, MaxFileSizeReachedError
|
from frappe.utils import (cint, convert_utc_to_user_timezone, cstr,
|
||||||
|
extract_email_id, markdown, now, parse_addr, strip)
|
||||||
|
|
||||||
|
|
||||||
class EmailSizeExceededError(frappe.ValidationError): pass
|
class EmailSizeExceededError(frappe.ValidationError): pass
|
||||||
class EmailTimeoutError(frappe.ValidationError): pass
|
class EmailTimeoutError(frappe.ValidationError): pass
|
||||||
|
|
@ -337,7 +346,7 @@ class EmailServer:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.imap.select("Inbox")
|
self.imap.select("Inbox")
|
||||||
for uid, operation in iteritems(uid_list):
|
for uid, operation in uid_list.items():
|
||||||
if not uid: continue
|
if not uid: continue
|
||||||
|
|
||||||
op = "+FLAGS" if operation == "Read" else "-FLAGS"
|
op = "+FLAGS" if operation == "Read" else "-FLAGS"
|
||||||
|
|
@ -473,7 +482,7 @@ class Email:
|
||||||
self.html_content += markdown(text_content)
|
self.html_content += markdown(text_content)
|
||||||
|
|
||||||
def get_charset(self, part):
|
def get_charset(self, part):
|
||||||
"""Detect chartset."""
|
"""Detect charset."""
|
||||||
charset = part.get_content_charset()
|
charset = part.get_content_charset()
|
||||||
if not charset:
|
if not charset:
|
||||||
charset = chardet.detect(safe_encode(cstr(part)))['encoding']
|
charset = chardet.detect(safe_encode(cstr(part)))['encoding']
|
||||||
|
|
@ -484,7 +493,7 @@ class Email:
|
||||||
charset = self.get_charset(part)
|
charset = self.get_charset(part)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return text_type(part.get_payload(decode=True), str(charset), "ignore")
|
return str(part.get_payload(decode=True), str(charset), "ignore")
|
||||||
except LookupError:
|
except LookupError:
|
||||||
return part.get_payload()
|
return part.get_payload()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
# License: The MIT License
|
# License: The MIT License
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
import frappe
|
||||||
from frappe.email.smtp import SMTPServer
|
from frappe.email.smtp import SMTPServer
|
||||||
|
from frappe.email.smtp import get_outgoing_email_account
|
||||||
|
|
||||||
class TestSMTP(unittest.TestCase):
|
class TestSMTP(unittest.TestCase):
|
||||||
def test_smtp_ssl_session(self):
|
def test_smtp_ssl_session(self):
|
||||||
|
|
@ -13,6 +15,57 @@ class TestSMTP(unittest.TestCase):
|
||||||
for port in [None, 0, 587, "587"]:
|
for port in [None, 0, 587, "587"]:
|
||||||
make_server(port, 0, 1)
|
make_server(port, 0, 1)
|
||||||
|
|
||||||
|
def test_get_email_account(self):
|
||||||
|
existing_email_accounts = frappe.get_all("Email Account", fields = ["name", "enable_outgoing", "default_outgoing", "append_to"])
|
||||||
|
unset_details = {
|
||||||
|
"enable_outgoing": 0,
|
||||||
|
"default_outgoing": 0,
|
||||||
|
"append_to": None
|
||||||
|
}
|
||||||
|
for email_account in existing_email_accounts:
|
||||||
|
frappe.db.set_value('Email Account', email_account['name'], unset_details)
|
||||||
|
|
||||||
|
# remove mail_server config so that test@example.com is not created
|
||||||
|
mail_server = frappe.conf.get('mail_server')
|
||||||
|
del frappe.conf['mail_server']
|
||||||
|
|
||||||
|
frappe.local.outgoing_email_account = {}
|
||||||
|
|
||||||
|
frappe.local.outgoing_email_account = {}
|
||||||
|
# lowest preference given to email account with default incoming enabled
|
||||||
|
create_email_account(email_id="default_outgoing_enabled@gmail.com", password="***", enable_outgoing = 1, default_outgoing=1)
|
||||||
|
self.assertEqual(get_outgoing_email_account().email_id, "default_outgoing_enabled@gmail.com")
|
||||||
|
|
||||||
|
frappe.local.outgoing_email_account = {}
|
||||||
|
# highest preference given to email account with append_to matching
|
||||||
|
create_email_account(email_id="append_to@gmail.com", password="***", enable_outgoing = 1, default_outgoing=1, append_to="Blog Post")
|
||||||
|
self.assertEqual(get_outgoing_email_account(append_to="Blog Post").email_id, "append_to@gmail.com")
|
||||||
|
|
||||||
|
# add back the mail_server
|
||||||
|
frappe.conf['mail_server'] = mail_server
|
||||||
|
for email_account in existing_email_accounts:
|
||||||
|
set_details = {
|
||||||
|
"enable_outgoing": email_account['enable_outgoing'],
|
||||||
|
"default_outgoing": email_account['default_outgoing'],
|
||||||
|
"append_to": email_account['append_to']
|
||||||
|
}
|
||||||
|
frappe.db.set_value('Email Account', email_account['name'], set_details)
|
||||||
|
|
||||||
|
def create_email_account(email_id, password, enable_outgoing, default_outgoing=0, append_to=None):
|
||||||
|
email_dict = {
|
||||||
|
"email_id": email_id,
|
||||||
|
"passsword": password,
|
||||||
|
"enable_outgoing":enable_outgoing ,
|
||||||
|
"default_outgoing":default_outgoing ,
|
||||||
|
"enable_incoming": 1,
|
||||||
|
"append_to":append_to,
|
||||||
|
"is_dummy_password": 1,
|
||||||
|
"smtp_server": "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
email_account = frappe.new_doc('Email Account')
|
||||||
|
email_account.update(email_dict)
|
||||||
|
email_account.save()
|
||||||
|
|
||||||
def make_server(port, ssl, tls):
|
def make_server(port, ssl, tls):
|
||||||
server = SMTPServer(
|
server = SMTPServer(
|
||||||
|
|
@ -22,4 +75,4 @@ def make_server(port, ssl, tls):
|
||||||
use_tls = tls
|
use_tls = tls
|
||||||
)
|
)
|
||||||
|
|
||||||
server.sess
|
server.sess
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,16 @@ has_website_permission = {
|
||||||
"Address": "frappe.contacts.doctype.address.address.has_website_permission"
|
"Address": "frappe.contacts.doctype.address.address.has_website_permission"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jinja = {
|
||||||
|
"methods": "frappe.utils.jinja_globals",
|
||||||
|
"filters": [
|
||||||
|
"frappe.utils.data.global_date_format",
|
||||||
|
"frappe.utils.markdown",
|
||||||
|
"frappe.website.utils.get_shade",
|
||||||
|
"frappe.website.utils.abs_url",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
standard_queries = {
|
standard_queries = {
|
||||||
"User": "frappe.core.doctype.user.user.user_query"
|
"User": "frappe.core.doctype.user.user.user_query"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,23 @@
|
||||||
# Copyright (c) 2015, Frappe Technologies and contributors
|
# Copyright (c) 2015, Frappe Technologies and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import dropbox
|
|
||||||
import json
|
import json
|
||||||
import frappe
|
|
||||||
import os
|
import os
|
||||||
from frappe import _
|
from urllib.parse import parse_qs, urlparse
|
||||||
from frappe.model.document import Document
|
|
||||||
from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site
|
import dropbox
|
||||||
from frappe.integrations.utils import make_post_request
|
|
||||||
from frappe.utils import (cint, get_request_site_address,
|
|
||||||
get_files_path, get_backups_path, get_url, encode)
|
|
||||||
from frappe.utils.backups import new_backup
|
|
||||||
from frappe.utils.background_jobs import enqueue
|
|
||||||
from six.moves.urllib.parse import urlparse, parse_qs
|
|
||||||
from rq.timeouts import JobTimeoutException
|
from rq.timeouts import JobTimeoutException
|
||||||
from six import text_type
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
from frappe.integrations.offsite_backup_utils import (get_chunk_site,
|
||||||
|
get_latest_backup_file, send_email, validate_file_size)
|
||||||
|
from frappe.integrations.utils import make_post_request
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import (cint, encode, get_backups_path, get_files_path,
|
||||||
|
get_request_site_address, get_url)
|
||||||
|
from frappe.utils.background_jobs import enqueue
|
||||||
|
from frappe.utils.backups import new_backup
|
||||||
|
|
||||||
ignore_list = [".DS_Store"]
|
ignore_list = [".DS_Store"]
|
||||||
|
|
||||||
|
|
@ -91,7 +92,10 @@ def backup_to_dropbox(upload_db_backup=True):
|
||||||
dropbox_settings['access_token'] = access_token['oauth2_token']
|
dropbox_settings['access_token'] = access_token['oauth2_token']
|
||||||
set_dropbox_access_token(access_token['oauth2_token'])
|
set_dropbox_access_token(access_token['oauth2_token'])
|
||||||
|
|
||||||
dropbox_client = dropbox.Dropbox(dropbox_settings['access_token'], timeout=None)
|
dropbox_client = dropbox.Dropbox(
|
||||||
|
oauth2_access_token=dropbox_settings['access_token'],
|
||||||
|
timeout=None
|
||||||
|
)
|
||||||
|
|
||||||
if upload_db_backup:
|
if upload_db_backup:
|
||||||
if frappe.flags.create_new_backup:
|
if frappe.flags.create_new_backup:
|
||||||
|
|
@ -127,7 +131,7 @@ def upload_from_folder(path, is_private, dropbox_folder, dropbox_client, did_not
|
||||||
else:
|
else:
|
||||||
response = frappe._dict({"entries": []})
|
response = frappe._dict({"entries": []})
|
||||||
|
|
||||||
path = text_type(path)
|
path = str(path)
|
||||||
|
|
||||||
for f in frappe.get_all("File", filters={"is_folder": 0, "is_private": is_private,
|
for f in frappe.get_all("File", filters={"is_folder": 0, "is_private": is_private,
|
||||||
"uploaded_to_dropbox": 0}, fields=['file_url', 'name', 'file_name']):
|
"uploaded_to_dropbox": 0}, fields=['file_url', 'name', 'file_name']):
|
||||||
|
|
@ -286,11 +290,11 @@ def get_redirect_url():
|
||||||
def get_dropbox_authorize_url():
|
def get_dropbox_authorize_url():
|
||||||
app_details = get_dropbox_settings(redirect_uri=True)
|
app_details = get_dropbox_settings(redirect_uri=True)
|
||||||
dropbox_oauth_flow = dropbox.DropboxOAuth2Flow(
|
dropbox_oauth_flow = dropbox.DropboxOAuth2Flow(
|
||||||
app_details["app_key"],
|
consumer_key=app_details["app_key"],
|
||||||
app_details["app_secret"],
|
redirect_uri=app_details["redirect_uri"],
|
||||||
app_details["redirect_uri"],
|
session={},
|
||||||
{},
|
csrf_token_session_key="dropbox-auth-csrf-token",
|
||||||
"dropbox-auth-csrf-token"
|
consumer_secret=app_details["app_secret"]
|
||||||
)
|
)
|
||||||
|
|
||||||
auth_url = dropbox_oauth_flow.start()
|
auth_url = dropbox_oauth_flow.start()
|
||||||
|
|
@ -307,13 +311,13 @@ def dropbox_auth_finish(return_access_token=False):
|
||||||
close = '<p class="text-muted">' + _('Please close this window') + '</p>'
|
close = '<p class="text-muted">' + _('Please close this window') + '</p>'
|
||||||
|
|
||||||
dropbox_oauth_flow = dropbox.DropboxOAuth2Flow(
|
dropbox_oauth_flow = dropbox.DropboxOAuth2Flow(
|
||||||
app_details["app_key"],
|
consumer_key=app_details["app_key"],
|
||||||
app_details["app_secret"],
|
redirect_uri=app_details["redirect_uri"],
|
||||||
app_details["redirect_uri"],
|
session={
|
||||||
{
|
|
||||||
'dropbox-auth-csrf-token': callback.state
|
'dropbox-auth-csrf-token': callback.state
|
||||||
},
|
},
|
||||||
"dropbox-auth-csrf-token"
|
csrf_token_session_key="dropbox-auth-csrf-token",
|
||||||
|
consumer_secret=app_details["app_secret"]
|
||||||
)
|
)
|
||||||
|
|
||||||
if callback.state or callback.code:
|
if callback.state or callback.code:
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,23 @@
|
||||||
# Copyright (c) 2019, Frappe Technologies and contributors
|
# Copyright (c) 2019, Frappe Technologies and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import frappe
|
|
||||||
import requests
|
|
||||||
import googleapiclient.discovery
|
|
||||||
import google.oauth2.credentials
|
|
||||||
|
|
||||||
from frappe import _
|
|
||||||
from frappe.model.document import Document
|
|
||||||
from frappe.utils import get_request_site_address
|
|
||||||
from googleapiclient.errors import HttpError
|
|
||||||
from frappe.utils.password import set_encrypted_password
|
|
||||||
from frappe.utils import add_days, get_datetime, get_weekdays, now_datetime, add_to_date, get_time_zone
|
|
||||||
from dateutil import parser
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from six.moves.urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import google.oauth2.credentials
|
||||||
|
import requests
|
||||||
|
from dateutil import parser
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
|
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import (add_days, add_to_date, get_datetime,
|
||||||
|
get_request_site_address, get_time_zone, get_weekdays, now_datetime)
|
||||||
|
from frappe.utils.password import set_encrypted_password
|
||||||
|
|
||||||
SCOPES = "https://www.googleapis.com/auth/calendar"
|
SCOPES = "https://www.googleapis.com/auth/calendar"
|
||||||
|
|
||||||
|
|
@ -171,7 +172,12 @@ def get_google_calendar_object(g_calendar):
|
||||||
}
|
}
|
||||||
|
|
||||||
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
|
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
|
||||||
google_calendar = googleapiclient.discovery.build("calendar", "v3", credentials=credentials)
|
google_calendar = build(
|
||||||
|
serviceName="calendar",
|
||||||
|
version="v3",
|
||||||
|
credentials=credentials,
|
||||||
|
static_discovery=False
|
||||||
|
)
|
||||||
|
|
||||||
check_google_calendar(account, google_calendar)
|
check_google_calendar(account, google_calendar)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@
|
||||||
# Copyright (c) 2019, Frappe Technologies and contributors
|
# Copyright (c) 2019, Frappe Technologies and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import frappe
|
|
||||||
import requests
|
|
||||||
import googleapiclient.discovery
|
|
||||||
import google.oauth2.credentials
|
|
||||||
|
|
||||||
from frappe.model.document import Document
|
import google.oauth2.credentials
|
||||||
from frappe import _
|
import requests
|
||||||
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
from frappe.utils import get_request_site_address
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
|
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import get_request_site_address
|
||||||
|
|
||||||
SCOPES = "https://www.googleapis.com/auth/contacts"
|
SCOPES = "https://www.googleapis.com/auth/contacts"
|
||||||
|
|
||||||
|
|
@ -118,7 +118,12 @@ def get_google_contacts_object(g_contact):
|
||||||
}
|
}
|
||||||
|
|
||||||
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
|
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
|
||||||
google_contacts = googleapiclient.discovery.build("people", "v1", credentials=credentials)
|
google_contacts = build(
|
||||||
|
serviceName="people",
|
||||||
|
version="v1",
|
||||||
|
credentials=credentials,
|
||||||
|
static_discovery=False
|
||||||
|
)
|
||||||
|
|
||||||
return google_contacts, account
|
return google_contacts, account
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,29 @@
|
||||||
# Copyright (c) 2019, Frappe Technologies and contributors
|
# Copyright (c) 2019, Frappe Technologies and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
|
||||||
import frappe
|
|
||||||
import requests
|
|
||||||
import googleapiclient.discovery
|
|
||||||
import google.oauth2.credentials
|
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from frappe import _
|
import google.oauth2.credentials
|
||||||
from googleapiclient.errors import HttpError
|
import requests
|
||||||
from frappe.model.document import Document
|
|
||||||
from frappe.utils import get_request_site_address
|
|
||||||
from frappe.utils.background_jobs import enqueue
|
|
||||||
from six.moves.urllib.parse import quote
|
|
||||||
from apiclient.http import MediaFileUpload
|
from apiclient.http import MediaFileUpload
|
||||||
from frappe.utils import get_backups_path, get_bench_path
|
from googleapiclient.discovery import build
|
||||||
from frappe.utils.backups import new_backup
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
|
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
|
||||||
from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size
|
from frappe.integrations.offsite_backup_utils import (get_latest_backup_file,
|
||||||
|
send_email, validate_file_size)
|
||||||
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import (get_backups_path, get_bench_path,
|
||||||
|
get_request_site_address)
|
||||||
|
from frappe.utils.background_jobs import enqueue
|
||||||
|
from frappe.utils.backups import new_backup
|
||||||
|
|
||||||
SCOPES = "https://www.googleapis.com/auth/drive"
|
SCOPES = "https://www.googleapis.com/auth/drive"
|
||||||
|
|
||||||
|
|
||||||
class GoogleDrive(Document):
|
class GoogleDrive(Document):
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
|
|
@ -126,7 +128,12 @@ def get_google_drive_object():
|
||||||
}
|
}
|
||||||
|
|
||||||
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
|
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
|
||||||
google_drive = googleapiclient.discovery.build("drive", "v3", credentials=credentials)
|
google_drive = build(
|
||||||
|
serviceName="drive",
|
||||||
|
version="v3",
|
||||||
|
credentials=credentials,
|
||||||
|
static_discovery=False
|
||||||
|
)
|
||||||
|
|
||||||
return google_drive, account
|
return google_drive, account
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class TestTokenCache(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.token_cache = frappe.get_last_doc('Token Cache')
|
self.token_cache = frappe.get_last_doc('Token Cache')
|
||||||
self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name})
|
self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name})
|
||||||
self.token_cache.save()
|
self.token_cache.save(ignore_permissions=True)
|
||||||
|
|
||||||
def test_get_auth_header(self):
|
def test_get_auth_header(self):
|
||||||
self.token_cache.get_auth_header()
|
self.token_cache.get_auth_header()
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ def run_webhooks(doc, method):
|
||||||
if webhooks is None:
|
if webhooks is None:
|
||||||
# query webhooks
|
# query webhooks
|
||||||
webhooks_list = frappe.get_all('Webhook',
|
webhooks_list = frappe.get_all('Webhook',
|
||||||
fields=["name", "`condition`", "webhook_docevent", "webhook_doctype"])
|
fields=["name", "`condition`", "webhook_docevent", "webhook_doctype"],
|
||||||
|
filters={"enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
# make webhooks map for cache
|
# make webhooks map for cache
|
||||||
webhooks = {}
|
webhooks = {}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,44 @@ from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get
|
||||||
|
|
||||||
|
|
||||||
class TestWebhook(unittest.TestCase):
|
class TestWebhook(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# delete any existing webhooks
|
||||||
|
frappe.db.sql("DELETE FROM tabWebhook")
|
||||||
|
# create test webhooks
|
||||||
|
cls.create_sample_webhooks()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_sample_webhooks(cls):
|
||||||
|
samples_webhooks_data = [
|
||||||
|
{
|
||||||
|
"webhook_doctype": "User",
|
||||||
|
"webhook_docevent": "after_insert",
|
||||||
|
"request_url": "https://httpbin.org/post",
|
||||||
|
"condition": "doc.email",
|
||||||
|
"enabled": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"webhook_doctype": "User",
|
||||||
|
"webhook_docevent": "after_insert",
|
||||||
|
"request_url": "https://httpbin.org/post",
|
||||||
|
"condition": "doc.first_name",
|
||||||
|
"enabled": False
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
cls.sample_webhooks = []
|
||||||
|
for wh_fields in samples_webhooks_data:
|
||||||
|
wh = frappe.new_doc("Webhook")
|
||||||
|
wh.update(wh_fields)
|
||||||
|
wh.insert()
|
||||||
|
cls.sample_webhooks.append(wh)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
# delete any existing webhooks
|
||||||
|
frappe.db.sql("DELETE FROM tabWebhook")
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# retrieve or create a User webhook for `after_insert`
|
# retrieve or create a User webhook for `after_insert`
|
||||||
webhook_fields = {
|
webhook_fields = {
|
||||||
|
|
@ -30,10 +68,37 @@ class TestWebhook(unittest.TestCase):
|
||||||
self.user.email = frappe.mock("email")
|
self.user.email = frappe.mock("email")
|
||||||
self.user.save()
|
self.user.save()
|
||||||
|
|
||||||
|
# Create another test user specific to this test
|
||||||
|
self.test_user = frappe.new_doc("User")
|
||||||
|
self.test_user.email = "user1@integration.webhooks.test.com"
|
||||||
|
self.test_user.first_name = "user1"
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.user.delete()
|
self.user.delete()
|
||||||
|
self.test_user.delete()
|
||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
|
def test_webhook_trigger_with_enabled_webhooks(self):
|
||||||
|
"""Test webhook trigger for enabled webhooks"""
|
||||||
|
|
||||||
|
frappe.cache().delete_value('webhooks')
|
||||||
|
frappe.flags.webhooks = None
|
||||||
|
|
||||||
|
# Insert the user to db
|
||||||
|
self.test_user.insert()
|
||||||
|
|
||||||
|
self.assertTrue("User" in frappe.flags.webhooks)
|
||||||
|
# only 1 hook (enabled) must be queued
|
||||||
|
self.assertEqual(
|
||||||
|
len(frappe.flags.webhooks.get("User")),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
self.assertTrue(self.test_user.email in frappe.flags.webhooks_executed)
|
||||||
|
self.assertEqual(
|
||||||
|
frappe.flags.webhooks_executed.get(self.test_user.email)[0],
|
||||||
|
self.sample_webhooks[0].name
|
||||||
|
)
|
||||||
|
|
||||||
def test_validate_doc_events(self):
|
def test_validate_doc_events(self):
|
||||||
"Test creating a submit-related webhook for a non-submittable DocType"
|
"Test creating a submit-related webhook for a non-submittable DocType"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"webhook_doctype",
|
"webhook_doctype",
|
||||||
"cb_doc_events",
|
"cb_doc_events",
|
||||||
"webhook_docevent",
|
"webhook_docevent",
|
||||||
|
"enabled",
|
||||||
"sb_condition",
|
"sb_condition",
|
||||||
"condition",
|
"condition",
|
||||||
"cb_condition",
|
"cb_condition",
|
||||||
|
|
@ -147,10 +148,16 @@
|
||||||
"fieldname": "webhook_secret",
|
"fieldname": "webhook_secret",
|
||||||
"fieldtype": "Password",
|
"fieldtype": "Password",
|
||||||
"label": "Webhook Secret"
|
"label": "Webhook Secret"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": "1",
|
||||||
|
"fieldname": "enabled",
|
||||||
|
"fieldtype": "Check",
|
||||||
|
"label": "Enabled"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2020-01-13 01:53:04.459968",
|
"modified": "2021-04-14 05:35:28.532049",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Integrations",
|
"module": "Integrations",
|
||||||
"name": "Webhook",
|
"name": "Webhook",
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ def get_token(*args, **kwargs):
|
||||||
}
|
}
|
||||||
|
|
||||||
id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header)
|
id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header)
|
||||||
out.update({"id_token": str(id_token_encoded)})
|
out.update({"id_token": frappe.safe_decode(id_token_encoded)})
|
||||||
|
|
||||||
frappe.local.response = out
|
frappe.local.response = out
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -334,3 +334,5 @@ frappe.patches.v13_0.delete_package_publish_tool
|
||||||
frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings
|
frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings
|
||||||
frappe.patches.v13_0.remove_twilio_settings
|
frappe.patches.v13_0.remove_twilio_settings
|
||||||
frappe.patches.v12_0.rename_uploaded_files_with_proper_name
|
frappe.patches.v12_0.rename_uploaded_files_with_proper_name
|
||||||
|
frappe.patches.v13_0.queryreport_columns
|
||||||
|
frappe.patches.v13_0.jinja_hook
|
||||||
|
|
|
||||||
13
frappe/patches/v13_0/jinja_hook.py
Normal file
13
frappe/patches/v13_0/jinja_hook.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# MIT License. See license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
import frappe
|
||||||
|
from click import secho
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
if frappe.get_hooks('jenv'):
|
||||||
|
print()
|
||||||
|
secho('WARNING: The hook "jenv" is deprecated. Follow the migration guide to use the new "jinja" hook.', fg='yellow')
|
||||||
|
secho('https://github.com/frappe/frappe/wiki/Migrating-to-Version-13', fg='yellow')
|
||||||
|
print()
|
||||||
22
frappe/patches/v13_0/queryreport_columns.py
Normal file
22
frappe/patches/v13_0/queryreport_columns.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# MIT License. See license.txt
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
import frappe
|
||||||
|
import json
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
"""Convert Query Report json to support other content"""
|
||||||
|
records = frappe.get_all('Report',
|
||||||
|
filters={
|
||||||
|
"json": ["!=", ""]
|
||||||
|
},
|
||||||
|
fields=["name", "json"]
|
||||||
|
)
|
||||||
|
for record in records:
|
||||||
|
jstr = record["json"]
|
||||||
|
data = json.loads(jstr)
|
||||||
|
if isinstance(data, list):
|
||||||
|
# double escape braces
|
||||||
|
jstr = f'{{"columns":{jstr}}}'
|
||||||
|
frappe.db.update('Report', record["name"], "json", jstr)
|
||||||
|
|
@ -51,6 +51,7 @@ frappe.Application = Class.extend({
|
||||||
this.set_fullwidth_if_enabled();
|
this.set_fullwidth_if_enabled();
|
||||||
this.add_browser_class();
|
this.add_browser_class();
|
||||||
this.setup_energy_point_listeners();
|
this.setup_energy_point_listeners();
|
||||||
|
this.setup_copy_doc_listener();
|
||||||
|
|
||||||
frappe.ui.keys.setup();
|
frappe.ui.keys.setup();
|
||||||
|
|
||||||
|
|
@ -113,7 +114,7 @@ frappe.Application = Class.extend({
|
||||||
dialog.get_close_btn().toggle(false);
|
dialog.get_close_btn().toggle(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setup_social_listeners();
|
this.setup_user_group_listeners();
|
||||||
|
|
||||||
// listen to build errors
|
// listen to build errors
|
||||||
this.setup_build_error_listener();
|
this.setup_build_error_listener();
|
||||||
|
|
@ -592,11 +593,12 @@ frappe.Application = Class.extend({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setup_social_listeners() {
|
setup_user_group_listeners() {
|
||||||
frappe.realtime.on('mention', (message) => {
|
frappe.realtime.on('user_group_added', (user_group) => {
|
||||||
if (frappe.get_route()[0] !== 'social') {
|
frappe.boot.user_groups && frappe.boot.user_groups.push(user_group);
|
||||||
frappe.show_alert(message);
|
});
|
||||||
}
|
frappe.realtime.on('user_group_deleted', (user_group) => {
|
||||||
|
frappe.boot.user_groups = (frappe.boot.user_groups || []).filter(el => el !== user_group);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -605,6 +607,39 @@ frappe.Application = Class.extend({
|
||||||
frappe.show_alert(message);
|
frappe.show_alert(message);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setup_copy_doc_listener() {
|
||||||
|
$('body').on('paste', (e) => {
|
||||||
|
try {
|
||||||
|
let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
|
||||||
|
let pasted_data = clipboard_data.getData('Text');
|
||||||
|
let doc = JSON.parse(pasted_data);
|
||||||
|
if (doc.doctype) {
|
||||||
|
e.preventDefault();
|
||||||
|
let sleep = (time) => {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, time));
|
||||||
|
};
|
||||||
|
|
||||||
|
frappe.dom.freeze(__('Creating {0}', [doc.doctype]) + '...');
|
||||||
|
// to avoid abrupt UX
|
||||||
|
// wait for activity feedback
|
||||||
|
sleep(500).then(() => {
|
||||||
|
let res = frappe.model.with_doctype(doc.doctype, () => {
|
||||||
|
let newdoc = frappe.model.copy_doc(doc);
|
||||||
|
newdoc.__newname = doc.name;
|
||||||
|
newdoc.idx = null;
|
||||||
|
newdoc.__run_link_triggers = false;
|
||||||
|
frappe.set_route('Form', newdoc.doctype, newdoc.name);
|
||||||
|
frappe.dom.unfreeze();
|
||||||
|
});
|
||||||
|
res && res.fail(frappe.dom.unfreeze);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
frappe.get_module = function(m, default_module) {
|
frappe.get_module = function(m, default_module) {
|
||||||
|
|
|
||||||
|
|
@ -159,9 +159,10 @@ frappe.ui.form.Control = Class.extend({
|
||||||
},
|
},
|
||||||
validate_and_set_in_model: function(value, e) {
|
validate_and_set_in_model: function(value, e) {
|
||||||
var me = this;
|
var me = this;
|
||||||
if(this.inside_change_event) {
|
if (this.inside_change_event || this.get_model_value() === value) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inside_change_event = true;
|
this.inside_change_event = true;
|
||||||
var set = function(value) {
|
var set = function(value) {
|
||||||
me.inside_change_event = false;
|
me.inside_change_event = false;
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,57 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
|
||||||
const input_value = this.get_input_value();
|
const input_value = this.get_input_value();
|
||||||
this.parse_validate_and_set_in_model(input_value);
|
this.parse_validate_and_set_in_model(input_value);
|
||||||
}, 300));
|
}, 300));
|
||||||
|
|
||||||
|
// setup autocompletion when it is set the first time
|
||||||
|
Object.defineProperty(this.df, 'autocompletions', {
|
||||||
|
get() {
|
||||||
|
return this._autocompletions || [];
|
||||||
|
},
|
||||||
|
set: (value) => {
|
||||||
|
this.setup_autocompletion();
|
||||||
|
this.df._autocompletions = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setup_autocompletion() {
|
||||||
|
if (this._autocompletion_setup) return;
|
||||||
|
|
||||||
|
const ace = window.ace;
|
||||||
|
const get_autocompletions = () => this.df.autocompletions;
|
||||||
|
|
||||||
|
ace.config.loadModule("ace/ext/language_tools", langTools => {
|
||||||
|
this.editor.setOptions({
|
||||||
|
enableBasicAutocompletion: true,
|
||||||
|
enableLiveAutocompletion: true
|
||||||
|
});
|
||||||
|
|
||||||
|
langTools.addCompleter({
|
||||||
|
getCompletions: function(editor, session, pos, prefix, callback) {
|
||||||
|
if (prefix.length === 0) {
|
||||||
|
callback(null, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let autocompletions = get_autocompletions();
|
||||||
|
if (autocompletions.length) {
|
||||||
|
callback(
|
||||||
|
null,
|
||||||
|
autocompletions.map(a => {
|
||||||
|
if (typeof a === 'string') {
|
||||||
|
a = { value: a };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: 'frappe',
|
||||||
|
value: a.value,
|
||||||
|
score: a.score
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this._autocompletion_setup = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
refresh_height() {
|
refresh_height() {
|
||||||
|
|
|
||||||
|
|
@ -462,9 +462,10 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({
|
||||||
if(this.frm && this.frm.fetch_dict[df.fieldname]) {
|
if(this.frm && this.frm.fetch_dict[df.fieldname]) {
|
||||||
fetch = this.frm.fetch_dict[df.fieldname].columns.join(', ');
|
fetch = this.frm.fetch_dict[df.fieldname].columns.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// if default and no fetch, no need to validate
|
// if default and no fetch, no need to validate
|
||||||
if (!fetch && df.__default_value && df.__default_value===value) return value;
|
if (!fetch && df.__default_value && df.__default_value===value) {
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
this.fetch_and_validate_link(resolve, df, doctype, docname, value, fetch);
|
this.fetch_and_validate_link(resolve, df, doctype, docname, value, fetch);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ frappe.ui.form.ControlMultiSelect = frappe.ui.form.ControlAutocomplete.extend({
|
||||||
let data;
|
let data;
|
||||||
if(this.df.get_data) {
|
if(this.df.get_data) {
|
||||||
data = this.df.get_data();
|
data = this.df.get_data();
|
||||||
this.set_data(data);
|
if (data) this.set_data(data);
|
||||||
} else {
|
} else {
|
||||||
data = this._super();
|
data = this._super();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class MentionBlot extends Embed {
|
||||||
node.dataset.id = data.id;
|
node.dataset.id = data.id;
|
||||||
node.dataset.value = data.value;
|
node.dataset.value = data.value;
|
||||||
node.dataset.denotationChar = data.denotationChar;
|
node.dataset.denotationChar = data.denotationChar;
|
||||||
|
node.dataset.isGroup = data.isGroup;
|
||||||
if (data.link) {
|
if (data.link) {
|
||||||
node.dataset.link = data.link;
|
node.dataset.link = data.link;
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +28,7 @@ class MentionBlot extends Embed {
|
||||||
value: domNode.dataset.value,
|
value: domNode.dataset.value,
|
||||||
link: domNode.dataset.link || null,
|
link: domNode.dataset.link || null,
|
||||||
denotationChar: domNode.dataset.denotationChar,
|
denotationChar: domNode.dataset.denotationChar,
|
||||||
|
isGroup: domNode.dataset.isGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ class Mention {
|
||||||
this.mentionList.childNodes[this.itemIndex].dataset.value,
|
this.mentionList.childNodes[this.itemIndex].dataset.value,
|
||||||
link: itemLink || null,
|
link: itemLink || null,
|
||||||
denotationChar: this.mentionList.childNodes[this.itemIndex].dataset.denotationChar,
|
denotationChar: this.mentionList.childNodes[this.itemIndex].dataset.denotationChar,
|
||||||
|
isGroup: this.mentionList.childNodes[this.itemIndex].dataset.isGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,6 +198,7 @@ class Mention {
|
||||||
li.dataset.index = i;
|
li.dataset.index = i;
|
||||||
li.dataset.id = data[i].id;
|
li.dataset.id = data[i].id;
|
||||||
li.dataset.value = data[i].value;
|
li.dataset.value = data[i].value;
|
||||||
|
li.dataset.isGroup = Boolean(data[i].is_group);
|
||||||
li.dataset.denotationChar = mentionChar;
|
li.dataset.denotationChar = mentionChar;
|
||||||
if (data[i].link) {
|
if (data[i].link) {
|
||||||
li.dataset.link = data[i].link;
|
li.dataset.link = data[i].link;
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,12 @@ frappe.ui.form.ControlTable = frappe.ui.form.Control.extend({
|
||||||
} else {
|
} else {
|
||||||
// no column header, map to the existing visible columns
|
// no column header, map to the existing visible columns
|
||||||
const visible_columns = grid_rows[0].get_visible_columns();
|
const visible_columns = grid_rows[0].get_visible_columns();
|
||||||
|
let target_column_matched = false;
|
||||||
visible_columns.forEach(column => {
|
visible_columns.forEach(column => {
|
||||||
if (column.fieldname === $(e.target).data('fieldname')) {
|
// consider all columns after the target column.
|
||||||
|
if (target_column_matched || column.fieldname === $(e.target).data('fieldname')) {
|
||||||
fieldnames.push(column.fieldname);
|
fieldnames.push(column.fieldname);
|
||||||
|
target_column_matched = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ frappe.ui.form.ControlTableMultiSelect = frappe.ui.form.ControlLink.extend({
|
||||||
this._rows_list = this.rows.map(row => row[link_field.fieldname]);
|
this._rows_list = this.rows.map(row => row[link_field.fieldname]);
|
||||||
return this.rows;
|
return this.rows;
|
||||||
},
|
},
|
||||||
|
get_model_value() {
|
||||||
|
let value = this._super();
|
||||||
|
return value ? value.filter(d => !d.__islocal) : value;
|
||||||
|
},
|
||||||
validate(value) {
|
validate(value) {
|
||||||
const rows = (value || []).slice();
|
const rows = (value || []).slice();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,7 @@ frappe.ui.form.Dashboard = class FormDashboard {
|
||||||
|
|
||||||
// bind links
|
// bind links
|
||||||
transactions_area_body.find(".badge-link").on('click', function() {
|
transactions_area_body.find(".badge-link").on('click', function() {
|
||||||
me.open_document_list($(this).parent());
|
me.open_document_list($(this).closest('.document-link'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// bind reports
|
// bind reports
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,7 @@ frappe.ui.form.Form = class FrappeForm {
|
||||||
grid_obj.grid.grid_pagination.go_to_page(1, true);
|
grid_obj.grid.grid_pagination.go_to_page(1, true);
|
||||||
});
|
});
|
||||||
frappe.ui.form.close_grid_form();
|
frappe.ui.form.close_grid_form();
|
||||||
|
this.viewers && this.viewers.parent.empty();
|
||||||
this.docname = docname;
|
this.docname = docname;
|
||||||
this.setup_docinfo_change_listener();
|
this.setup_docinfo_change_listener();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ frappe.ui.form.FormViewers = class FormViewers {
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
let users = this.frm.get_docinfo()['viewers'];
|
let users = this.frm.get_docinfo()['viewers'];
|
||||||
|
if (!users || !users.current || !users.current.length) {
|
||||||
|
this.parent.empty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let currently_viewing = users.current.filter(user => user != frappe.session.user);
|
let currently_viewing = users.current.filter(user => user != frappe.session.user);
|
||||||
let avatar_group = frappe.avatar_group(currently_viewing, 5, {'align': 'left', 'overlap': true});
|
let avatar_group = frappe.avatar_group(currently_viewing, 5, {'align': 'left', 'overlap': true});
|
||||||
this.parent.empty().append(avatar_group);
|
this.parent.empty().append(avatar_group);
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,10 @@ export default class Grid {
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.push(() => {
|
tasks.push(() => {
|
||||||
if (dirty) this.refresh();
|
if (dirty) {
|
||||||
|
this.refresh();
|
||||||
|
this.frm.script_manager.trigger(this.df.fieldname + "_delete", this.doctype);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
frappe.run_serially(tasks);
|
frappe.run_serially(tasks);
|
||||||
|
|
@ -210,6 +213,7 @@ export default class Grid {
|
||||||
this.frm.doc[this.df.fieldname] = [];
|
this.frm.doc[this.df.fieldname] = [];
|
||||||
$(this.parent).find('.rows').empty();
|
$(this.parent).find('.rows').empty();
|
||||||
this.grid_rows = [];
|
this.grid_rows = [];
|
||||||
|
this.frm.script_manager.trigger(this.df.fieldname + "_delete", this.doctype);
|
||||||
|
|
||||||
this.wrapper.find('.grid-heading-row .grid-row-check:checked:first').prop('checked', 0);
|
this.wrapper.find('.grid-heading-row .grid-row-check:checked:first').prop('checked', 0);
|
||||||
this.refresh();
|
this.refresh();
|
||||||
|
|
@ -383,6 +387,8 @@ export default class Grid {
|
||||||
this.wrapper.find('.grid-footer').toggle(false);
|
this.wrapper.find('.grid-footer').toggle(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.wrapper.find('.grid-add-row, .grid-add-multiple-rows').toggle(this.is_editable());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
truncate_rows() {
|
truncate_rows() {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export default class GridRow {
|
||||||
this.on_grid_fields = [];
|
this.on_grid_fields = [];
|
||||||
$.extend(this, opts);
|
$.extend(this, opts);
|
||||||
if (this.doc && this.parent_df.options) {
|
if (this.doc && this.parent_df.options) {
|
||||||
|
frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields);
|
||||||
this.docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
|
this.docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
|
||||||
}
|
}
|
||||||
this.columns = {};
|
this.columns = {};
|
||||||
|
|
@ -555,6 +556,12 @@ export default class GridRow {
|
||||||
this.grid_form.render();
|
this.grid_form.render();
|
||||||
this.row.toggle(false);
|
this.row.toggle(false);
|
||||||
// this.form_panel.toggle(true);
|
// this.form_panel.toggle(true);
|
||||||
|
|
||||||
|
let cannot_add_rows = this.grid.cannot_add_rows || (this.grid.df && this.grid.df.cannot_add_rows);
|
||||||
|
this.wrapper
|
||||||
|
.find('.grid-insert-row-below, .grid-insert-row, .grid-duplicate-row, .grid-append-row')
|
||||||
|
.toggle(!cannot_add_rows);
|
||||||
|
|
||||||
frappe.dom.freeze("", "dark");
|
frappe.dom.freeze("", "dark");
|
||||||
if (cur_frm) cur_frm.cur_grid = this;
|
if (cur_frm) cur_frm.cur_grid = this;
|
||||||
this.wrapper.addClass("grid-row-open");
|
this.wrapper.addClass("grid-row-open");
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export default class GridRowForm {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toggle_add_delete_button_display($parent) {
|
toggle_add_delete_button_display($parent) {
|
||||||
$parent.find(".row-actions")
|
$parent.find(".row-actions, .grid-append-row")
|
||||||
.toggle(this.row.grid.is_editable());
|
.toggle(this.row.grid.is_editable());
|
||||||
}
|
}
|
||||||
refresh_field(fieldname) {
|
refresh_field(fieldname) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
// MIT License. See license.txt
|
// MIT License. See license.txt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
frappe.ui.form.Attachments = Class.extend({
|
frappe.ui.form.Attachments = Class.extend({
|
||||||
init: function(opts) {
|
init: function(opts) {
|
||||||
$.extend(this, opts);
|
$.extend(this, opts);
|
||||||
|
|
@ -84,17 +82,9 @@ frappe.ui.form.Attachments = Class.extend({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let icon;
|
const icon = `<a href="/app/file/${fileid}">
|
||||||
// REDESIGN-TODO: set icon using frappe.utils.icon
|
${frappe.utils.icon(attachment.is_private ? 'lock' : 'unlock', 'sm ml-0')}
|
||||||
if (attachment.is_private) {
|
</a>`;
|
||||||
icon = `<div><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.07685 1.45015H8.02155C7.13255 1.44199 6.2766 1.78689 5.64159 2.40919C5.00596 3.0321 4.64377 3.88196 4.63464 4.77188L4.63462 4.77188V4.77701V5.12157H3.75C2.64543 5.12157 1.75 6.017 1.75 7.12157V12.5132C1.75 13.6177 2.64543 14.5132 3.75 14.5132H12.2885C13.393 14.5132 14.2885 13.6177 14.2885 12.5132V7.12157C14.2885 6.017 13.393 5.12157 12.2885 5.12157H11.4037V4.83708C11.4119 3.94809 11.067 3.09213 10.4447 2.45713C9.82175 1.8215 8.97189 1.4593 8.08198 1.45018L8.08198 1.45015H8.07685ZM10.4037 5.12157V4.8347V4.82972L10.4037 4.82972C10.4099 4.20495 10.1678 3.60329 9.73045 3.15705C9.29371 2.7114 8.69805 2.4572 8.07417 2.45015H8.01916H8.01418L8.01419 2.45013C7.38942 2.44391 6.78776 2.68609 6.34152 3.12341C5.89586 3.56015 5.64166 4.15581 5.63462 4.77969V5.12157H10.4037ZM3.75 6.12157C3.19772 6.12157 2.75 6.56929 2.75 7.12157V12.5132C2.75 13.0655 3.19772 13.5132 3.75 13.5132H12.2885C12.8407 13.5132 13.2885 13.0655 13.2885 12.5132V7.12157C13.2885 6.56929 12.8407 6.12157 12.2885 6.12157H3.75ZM8.01936 10.3908C8.33605 10.3908 8.59279 10.134 8.59279 9.81734C8.59279 9.50064 8.33605 9.24391 8.01936 9.24391C7.70266 9.24391 7.44593 9.50064 7.44593 9.81734C7.44593 10.134 7.70266 10.3908 8.01936 10.3908ZM9.59279 9.81734C9.59279 10.6863 8.88834 11.3908 8.01936 11.3908C7.15038 11.3908 6.44593 10.6863 6.44593 9.81734C6.44593 8.94836 7.15038 8.24391 8.01936 8.24391C8.88834 8.24391 9.59279 8.94836 9.59279 9.81734Z" fill="currentColor"/>
|
|
||||||
</svg></div>`;
|
|
||||||
} else {
|
|
||||||
icon = `<div><svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.07685 1.45014H8.02155C7.13255 1.44198 6.2766 1.78687 5.64159 2.40918C5.00596 3.03209 4.64377 3.88195 4.63464 4.77187L4.63462 4.77187V4.777V5.12156H3.75C2.64543 5.12156 1.75 6.01699 1.75 7.12156V12.5132C1.75 13.6177 2.64543 14.5132 3.75 14.5132H12.2885C13.393 14.5132 14.2885 13.6177 14.2885 12.5132V7.12156C14.2885 6.01699 13.393 5.12156 12.2885 5.12156H5.63462V4.77968C5.64166 4.1558 5.89586 3.56014 6.34152 3.12339C6.78776 2.68608 7.38942 2.4439 8.01419 2.45012L8.01418 2.45014H8.01916H8.07417C8.69805 2.45718 9.29371 2.71138 9.73045 3.15704C9.92373 3.35427 10.2403 3.35746 10.4375 3.16418C10.6347 2.9709 10.6379 2.65434 10.4447 2.45711C9.82175 1.82149 8.97189 1.45929 8.08198 1.45017L8.08198 1.45014H8.07685ZM3.75 6.12156C3.19772 6.12156 2.75 6.56927 2.75 7.12156V12.5132C2.75 13.0655 3.19772 13.5132 3.75 13.5132H12.2885C12.8407 13.5132 13.2885 13.0655 13.2885 12.5132V7.12156C13.2885 6.56927 12.8407 6.12156 12.2885 6.12156H3.75ZM8.01936 10.3908C8.33605 10.3908 8.59279 10.134 8.59279 9.81732C8.59279 9.50063 8.33605 9.2439 8.01936 9.2439C7.70266 9.2439 7.44593 9.50063 7.44593 9.81732C7.44593 10.134 7.70266 10.3908 8.01936 10.3908ZM9.59279 9.81732C9.59279 10.6863 8.88834 11.3908 8.01936 11.3908C7.15038 11.3908 6.44593 10.6863 6.44593 9.81732C6.44593 8.94835 7.15038 8.2439 8.01936 8.2439C8.88834 8.2439 9.59279 8.94835 9.59279 9.81732Z" fill="currentColor"/>
|
|
||||||
</svg></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$(`<li class="attachment-row">`)
|
$(`<li class="attachment-row">`)
|
||||||
.append(frappe.get_data_pill(
|
.append(frappe.get_data_pill(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<li class="attachment-row flex align-center">
|
|
||||||
<a class="close">×</a>
|
|
||||||
<a href="{{ file_path }}">
|
|
||||||
<i class="{{ icon }} fa-fw text-warning"></i>
|
|
||||||
</a>
|
|
||||||
<a href="{{ file_url }}" target="_blank" title="{{ file_name }}" class="ellipsis" style="max-width: calc(100% - 43px);">
|
|
||||||
<span>{{ file_name }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
|
|
@ -211,7 +211,6 @@ frappe.ui.form.Toolbar = class Toolbar {
|
||||||
|
|
||||||
make_viewers() {
|
make_viewers() {
|
||||||
if (this.frm.viewers) {
|
if (this.frm.viewers) {
|
||||||
this.frm.viewers.parent.empty();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.frm.viewers = new frappe.ui.form.FormViewers({
|
this.frm.viewers = new frappe.ui.form.FormViewers({
|
||||||
|
|
@ -278,13 +277,18 @@ frappe.ui.form.Toolbar = class Toolbar {
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy
|
// duplicate
|
||||||
if(in_list(frappe.boot.user.can_create, me.frm.doctype) && !me.frm.meta.allow_copy) {
|
if(in_list(frappe.boot.user.can_create, me.frm.doctype) && !me.frm.meta.allow_copy) {
|
||||||
this.page.add_menu_item(__("Duplicate"), function() {
|
this.page.add_menu_item(__("Duplicate"), function() {
|
||||||
me.frm.copy_doc();
|
me.frm.copy_doc();
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copy doc to clipboard
|
||||||
|
this.page.add_menu_item(__("Copy to Clipboard"), function() {
|
||||||
|
frappe.utils.copy_to_clipboard(JSON.stringify(me.frm.doc));
|
||||||
|
}, true);
|
||||||
|
|
||||||
// rename
|
// rename
|
||||||
if(this.can_rename()) {
|
if(this.can_rename()) {
|
||||||
this.page.add_menu_item(__("Rename"), function() {
|
this.page.add_menu_item(__("Rename"), function() {
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,9 @@ $.extend(frappe.model, {
|
||||||
// Use User Permission value when only when it has a single value
|
// Use User Permission value when only when it has a single value
|
||||||
user_default = user_defaults[0];
|
user_default = user_defaults[0];
|
||||||
}
|
}
|
||||||
} else if (!user_default) {
|
}
|
||||||
|
|
||||||
|
if (!user_default) {
|
||||||
user_default = frappe.defaults.get_user_default(df.fieldname);
|
user_default = frappe.defaults.get_user_default(df.fieldname);
|
||||||
} else if (
|
} else if (
|
||||||
!user_default &&
|
!user_default &&
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,14 @@ $.extend(frappe.meta, {
|
||||||
frappe.meta.docfield_list[df.parent].push(df);
|
frappe.meta.docfield_list[df.parent].push(df);
|
||||||
},
|
},
|
||||||
|
|
||||||
make_docfield_copy_for: function(doctype, docname) {
|
make_docfield_copy_for: function(doctype, docname, docfield_list=null) {
|
||||||
var c = frappe.meta.docfield_copy;
|
var c = frappe.meta.docfield_copy;
|
||||||
if(!c[doctype])
|
if(!c[doctype])
|
||||||
c[doctype] = {};
|
c[doctype] = {};
|
||||||
if(!c[doctype][docname])
|
if(!c[doctype][docname])
|
||||||
c[doctype][docname] = {};
|
c[doctype][docname] = {};
|
||||||
|
|
||||||
var docfield_list = frappe.meta.docfield_list[doctype] || [];
|
docfield_list = docfield_list || frappe.meta.docfield_list[doctype] || [];
|
||||||
for(var i=0, j=docfield_list.length; i<j; i++) {
|
for(var i=0, j=docfield_list.length; i<j; i++) {
|
||||||
var df = docfield_list[i];
|
var df = docfield_list[i];
|
||||||
c[doctype][docname][df.fieldname || df.label] = copy_dict(df);
|
c[doctype][docname][df.fieldname || df.label] = copy_dict(df);
|
||||||
|
|
|
||||||
|
|
@ -313,7 +313,8 @@ frappe.ui.GroupBy = class {
|
||||||
|
|
||||||
Object.assign(args, {
|
Object.assign(args, {
|
||||||
with_comment_count: false,
|
with_comment_count: false,
|
||||||
aggregate_on: this.aggregate_on || 'name',
|
aggregate_on_field: this.aggregate_on_field || 'name',
|
||||||
|
aggregate_on_doctype: this.aggregate_on_doctype || this.doctype,
|
||||||
aggregate_function: this.aggregate_function || 'count',
|
aggregate_function: this.aggregate_function || 'count',
|
||||||
group_by: this.report_view.group_by || null,
|
group_by: this.report_view.group_by || null,
|
||||||
order_by: '_aggregate_column desc',
|
order_by: '_aggregate_column desc',
|
||||||
|
|
|
||||||
|
|
@ -1285,6 +1285,16 @@ Object.assign(frappe.utils, {
|
||||||
value: frappe.boot.user_info[user].fullname,
|
value: frappe.boot.user_info[user].fullname,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frappe.boot.user_groups && frappe.boot.user_groups.map(group => {
|
||||||
|
names_for_mentions.push({
|
||||||
|
id: group,
|
||||||
|
value: group,
|
||||||
|
is_group: true,
|
||||||
|
link: frappe.utils.get_form_link('User Group', group)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return names_for_mentions;
|
return names_for_mentions;
|
||||||
},
|
},
|
||||||
print(doctype, docname, print_format, letterhead, lang_code) {
|
print(doctype, docname, print_format, letterhead, lang_code) {
|
||||||
|
|
|
||||||
|
|
@ -2,73 +2,55 @@
|
||||||
// MIT License. See license.txt
|
// MIT License. See license.txt
|
||||||
|
|
||||||
frappe.last_edited_communication = {};
|
frappe.last_edited_communication = {};
|
||||||
frappe.standard_replies = {};
|
const separator_element = '<div>---</div>';
|
||||||
frappe.separator_element = '<div>---</div>';
|
|
||||||
|
|
||||||
frappe.views.CommunicationComposer = Class.extend({
|
frappe.views.CommunicationComposer = class {
|
||||||
init: function(opts) {
|
constructor(opts) {
|
||||||
$.extend(this, opts);
|
$.extend(this, opts);
|
||||||
|
if (!this.doc) {
|
||||||
|
this.doc = this.frm && this.frm.doc || {};
|
||||||
|
}
|
||||||
|
|
||||||
this.make();
|
this.make();
|
||||||
},
|
}
|
||||||
make: function() {
|
|
||||||
var me = this;
|
make() {
|
||||||
|
const me = this;
|
||||||
|
|
||||||
this.dialog = new frappe.ui.Dialog({
|
this.dialog = new frappe.ui.Dialog({
|
||||||
title: (this.title || this.subject || __("New Email")),
|
title: (this.title || this.subject || __("New Email")),
|
||||||
no_submit_on_enter: true,
|
no_submit_on_enter: true,
|
||||||
fields: this.get_fields(),
|
fields: this.get_fields(),
|
||||||
primary_action_label: __("Send"),
|
primary_action_label: __("Send"),
|
||||||
size: 'large',
|
primary_action() {
|
||||||
primary_action: function() {
|
|
||||||
me.delete_saved_draft();
|
|
||||||
me.send_action();
|
me.send_action();
|
||||||
},
|
},
|
||||||
|
secondary_action_label: __("Discard"),
|
||||||
|
secondary_action() {
|
||||||
|
me.dialog.hide();
|
||||||
|
me.clear_cache();
|
||||||
|
},
|
||||||
|
size: 'large',
|
||||||
minimizable: true
|
minimizable: true
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dialog.sections[0].wrapper.addClass('to_section');
|
this.dialog.sections[0].wrapper.addClass('to_section');
|
||||||
|
|
||||||
['recipients', 'cc', 'bcc'].forEach(field => {
|
|
||||||
this.dialog.fields_dict[field].get_data = function() {
|
|
||||||
const data = me.dialog.fields_dict[field].get_value();
|
|
||||||
const txt = data.match(/[^,\s*]*$/)[0] || '';
|
|
||||||
let options = [];
|
|
||||||
|
|
||||||
frappe.call({
|
|
||||||
method: "frappe.email.get_contact_list",
|
|
||||||
args: {
|
|
||||||
txt: txt,
|
|
||||||
},
|
|
||||||
callback: (r) => {
|
|
||||||
options = r.message;
|
|
||||||
me.dialog.fields_dict[field].set_data(options);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.prepare();
|
this.prepare();
|
||||||
this.dialog.show();
|
this.dialog.show();
|
||||||
|
|
||||||
if (this.frm) {
|
if (this.frm) {
|
||||||
$(document).trigger('form-typing', [this.frm]);
|
$(document).trigger('form-typing', [this.frm]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.cc || this.bcc) {
|
get_fields() {
|
||||||
this.toggle_more_options(true);
|
const fields = [
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
get_fields: function() {
|
|
||||||
let contactList = [];
|
|
||||||
let fields = [
|
|
||||||
{
|
{
|
||||||
label: __("To"),
|
label: __("To"),
|
||||||
fieldtype: "MultiSelect",
|
fieldtype: "MultiSelect",
|
||||||
reqd: 0,
|
reqd: 0,
|
||||||
fieldname: "recipients",
|
fieldname: "recipients",
|
||||||
options: contactList
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldtype: "Button",
|
fieldtype: "Button",
|
||||||
|
|
@ -87,13 +69,11 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
label: __("CC"),
|
label: __("CC"),
|
||||||
fieldtype: "MultiSelect",
|
fieldtype: "MultiSelect",
|
||||||
fieldname: "cc",
|
fieldname: "cc",
|
||||||
options: contactList
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __("BCC"),
|
label: __("BCC"),
|
||||||
fieldtype: "MultiSelect",
|
fieldtype: "MultiSelect",
|
||||||
fieldname: "bcc",
|
fieldname: "bcc",
|
||||||
options: contactList
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: __("Email Template"),
|
label: __("Email Template"),
|
||||||
|
|
@ -163,78 +143,83 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (frappe.boot.email_accounts && email_accounts.length > 1) {
|
if (email_accounts.length > 1) {
|
||||||
fields = [
|
fields.unshift({
|
||||||
{
|
label: __("From"),
|
||||||
label: __("From"),
|
fieldtype: "Select",
|
||||||
fieldtype: "Select",
|
reqd: 1,
|
||||||
reqd: 1,
|
fieldname: "sender",
|
||||||
fieldname: "sender",
|
options: email_accounts.map(function(e) {
|
||||||
options: email_accounts.map(function(e) {
|
return e.email_id;
|
||||||
return e.email_id;
|
})
|
||||||
})
|
});
|
||||||
}
|
|
||||||
].concat(fields);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
},
|
}
|
||||||
|
|
||||||
toggle_more_options(show_options) {
|
toggle_more_options(show_options) {
|
||||||
show_options = show_options || this.dialog.fields_dict.more_options.df.hidden;
|
show_options = show_options || this.dialog.fields_dict.more_options.df.hidden;
|
||||||
this.dialog.set_df_property('more_options', 'hidden', !show_options);
|
this.dialog.set_df_property('more_options', 'hidden', !show_options);
|
||||||
let label = frappe.utils.icon(show_options ? 'up-line': 'down');
|
|
||||||
this.dialog.get_field('option_toggle_button').set_label(label);
|
|
||||||
},
|
|
||||||
|
|
||||||
prepare: function() {
|
const label = frappe.utils.icon(show_options ? 'up-line': 'down');
|
||||||
|
this.dialog.get_field('option_toggle_button').set_label(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare() {
|
||||||
|
this.setup_multiselect_queries();
|
||||||
this.setup_subject_and_recipients();
|
this.setup_subject_and_recipients();
|
||||||
this.setup_print_language();
|
this.setup_print_language();
|
||||||
this.setup_print();
|
this.setup_print();
|
||||||
this.setup_attach();
|
this.setup_attach();
|
||||||
this.setup_email();
|
this.setup_email();
|
||||||
this.setup_last_edited_communication();
|
|
||||||
this.setup_email_template();
|
this.setup_email_template();
|
||||||
|
this.setup_last_edited_communication();
|
||||||
|
this.set_values();
|
||||||
|
}
|
||||||
|
|
||||||
this.dialog.set_value("recipients", this.recipients || '');
|
setup_multiselect_queries() {
|
||||||
this.dialog.set_value("cc", this.cc || '');
|
['recipients', 'cc', 'bcc'].forEach(field => {
|
||||||
this.dialog.set_value("bcc", this.bcc || '');
|
this.dialog.fields_dict[field].get_data = () => {
|
||||||
|
const data = this.dialog.fields_dict[field].get_value();
|
||||||
|
const txt = data.match(/[^,\s*]*$/)[0] || '';
|
||||||
|
|
||||||
if(this.dialog.fields_dict.sender) {
|
frappe.call({
|
||||||
this.dialog.fields_dict.sender.set_value(this.sender || '');
|
method: "frappe.email.get_contact_list",
|
||||||
}
|
args: {txt},
|
||||||
this.dialog.fields_dict.subject.set_value(
|
callback: (r) => {
|
||||||
frappe.utils.html2text(this.subject) || ''
|
this.dialog.fields_dict[field].set_data(r.message);
|
||||||
);
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.setup_earlier_reply();
|
setup_subject_and_recipients() {
|
||||||
},
|
|
||||||
|
|
||||||
setup_subject_and_recipients: function() {
|
|
||||||
this.subject = this.subject || "";
|
this.subject = this.subject || "";
|
||||||
|
|
||||||
if(!this.forward && !this.recipients && this.last_email) {
|
if (!this.forward && !this.recipients && this.last_email) {
|
||||||
this.recipients = this.last_email.sender;
|
this.recipients = this.last_email.sender;
|
||||||
this.cc = this.last_email.cc;
|
this.cc = this.last_email.cc;
|
||||||
this.bcc = this.last_email.bcc;
|
this.bcc = this.last_email.bcc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!this.forward && !this.recipients) {
|
if (!this.forward && !this.recipients) {
|
||||||
this.recipients = this.frm && this.frm.timeline.get_recipient();
|
this.recipients = this.frm && this.frm.timeline.get_recipient();
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!this.subject && this.frm) {
|
if (!this.subject && this.frm) {
|
||||||
// get subject from last communication
|
// get subject from last communication
|
||||||
var last = this.frm.timeline.get_last_email();
|
const last = this.frm.timeline.get_last_email();
|
||||||
|
|
||||||
if(last) {
|
if (last) {
|
||||||
this.subject = last.subject;
|
this.subject = last.subject;
|
||||||
if(!this.recipients) {
|
if (!this.recipients) {
|
||||||
this.recipients = last.sender;
|
this.recipients = last.sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
// prepend "Re:"
|
// prepend "Re:"
|
||||||
if(strip(this.subject.toLowerCase().split(":")[0])!="re") {
|
if (strip(this.subject.toLowerCase().split(":")[0])!="re") {
|
||||||
this.subject = __("Re: {0}", [this.subject]);
|
this.subject = __("Re: {0}", [this.subject]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +236,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
// always add an identifier to catch a reply
|
// always add an identifier to catch a reply
|
||||||
// some email clients (outlook) may not send the message id to identify
|
// some email clients (outlook) may not send the message id to identify
|
||||||
// the thread. So as a backup we use the name of the document as identifier
|
// the thread. So as a backup we use the name of the document as identifier
|
||||||
let identifier = `#${this.frm.doc.name}`;
|
const identifier = `#${this.frm.doc.name}`;
|
||||||
if (!this.subject.includes(identifier)) {
|
if (!this.subject.includes(identifier)) {
|
||||||
this.subject = `${this.subject} (${identifier})`;
|
this.subject = `${this.subject} (${identifier})`;
|
||||||
}
|
}
|
||||||
|
|
@ -260,33 +245,25 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
if (this.frm && !this.recipients) {
|
if (this.frm && !this.recipients) {
|
||||||
this.recipients = this.frm.doc[this.frm.email_field];
|
this.recipients = this.frm.doc[this.frm.email_field];
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
setup_email_template: function() {
|
setup_email_template() {
|
||||||
var me = this;
|
const me = this;
|
||||||
|
|
||||||
this.dialog.fields_dict["email_template"].df.onchange = () => {
|
this.dialog.fields_dict["email_template"].df.onchange = () => {
|
||||||
var email_template = me.dialog.fields_dict.email_template.get_value();
|
const email_template = me.dialog.fields_dict.email_template.get_value();
|
||||||
|
if (!email_template) return;
|
||||||
|
|
||||||
var prepend_reply = function(reply) {
|
function prepend_reply(reply) {
|
||||||
if(me.reply_added===email_template) {
|
if (me.reply_added === email_template) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
var content_field = me.dialog.fields_dict.content;
|
|
||||||
var subject_field = me.dialog.fields_dict.subject;
|
|
||||||
var content = content_field.get_value() || "";
|
|
||||||
var subject = subject_field.get_value() || "";
|
|
||||||
|
|
||||||
var parts = content.split('<!-- salutation-ends -->');
|
const content_field = me.dialog.fields_dict.content;
|
||||||
|
const subject_field = me.dialog.fields_dict.subject;
|
||||||
|
|
||||||
if(parts.length===2) {
|
let content = content_field.get_value() || "";
|
||||||
content = [reply.message, "<br>", parts[1]];
|
content = content.split('<!-- salutation-ends -->')[1] || content;
|
||||||
} else {
|
|
||||||
content = [reply.message, "<br>", content];
|
|
||||||
}
|
|
||||||
|
|
||||||
content_field.set_value(content.join(''));
|
|
||||||
|
|
||||||
|
content_field.set_value(`${reply.message}<br>${content}`);
|
||||||
subject_field.set_value(reply.subject);
|
subject_field.set_value(reply.subject);
|
||||||
|
|
||||||
me.reply_added = email_template;
|
me.reply_added = email_template;
|
||||||
|
|
@ -296,86 +273,107 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
method: 'frappe.email.doctype.email_template.email_template.get_email_template',
|
method: 'frappe.email.doctype.email_template.email_template.get_email_template',
|
||||||
args: {
|
args: {
|
||||||
template_name: email_template,
|
template_name: email_template,
|
||||||
doc: me.frm.doc,
|
doc: me.doc,
|
||||||
_lang: me.dialog.get_value("language_sel")
|
_lang: me.dialog.get_value("language_sel")
|
||||||
},
|
},
|
||||||
callback: function(r) {
|
callback(r) {
|
||||||
prepend_reply(r.message);
|
prepend_reply(r.message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
},
|
}
|
||||||
|
|
||||||
setup_last_edited_communication: function() {
|
setup_last_edited_communication() {
|
||||||
var me = this;
|
if (this.frm) {
|
||||||
if (!this.doc){
|
this.doctype = this.frm.doctype;
|
||||||
if (cur_frm){
|
this.key = this.frm.docname;
|
||||||
this.doc = cur_frm.doctype;
|
|
||||||
}else{
|
|
||||||
this.doc = "Inbox";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur_frm && cur_frm.docname) {
|
|
||||||
this.key = cur_frm.docname;
|
|
||||||
} else {
|
} else {
|
||||||
this.key = "Inbox";
|
this.doctype = this.key = "Inbox";
|
||||||
}
|
}
|
||||||
if(this.last_email) {
|
|
||||||
|
if (this.last_email) {
|
||||||
this.key = this.key + ":" + this.last_email.name;
|
this.key = this.key + ":" + this.last_email.name;
|
||||||
}
|
}
|
||||||
if(this.subject){
|
|
||||||
|
if (this.subject) {
|
||||||
this.key = this.key + ":" + this.subject;
|
this.key = this.key + ":" + this.subject;
|
||||||
}
|
}
|
||||||
this.dialog.onhide = function() {
|
|
||||||
var last_edited_communication = me.get_last_edited_communication();
|
|
||||||
$.extend(last_edited_communication, {
|
|
||||||
sender: me.dialog.get_value("sender"),
|
|
||||||
recipients: me.dialog.get_value("recipients"),
|
|
||||||
cc: me.dialog.get_value("cc"),
|
|
||||||
bcc: me.dialog.get_value("bcc"),
|
|
||||||
subject: me.dialog.get_value("subject"),
|
|
||||||
content: me.dialog.get_value("content"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (me.frm) {
|
this.dialog.on_hide = () => {
|
||||||
$(document).trigger("form-stopped-typing", [me.frm]);
|
$.extend(
|
||||||
|
this.get_last_edited_communication(true),
|
||||||
|
this.dialog.get_values(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.frm) {
|
||||||
|
$(document).trigger("form-stopped-typing", [this.frm]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get_last_edited_communication(clear) {
|
||||||
|
if (!frappe.last_edited_communication[this.doctype]) {
|
||||||
|
frappe.last_edited_communication[this.doctype] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clear || !frappe.last_edited_communication[this.doctype][this.key]) {
|
||||||
|
frappe.last_edited_communication[this.doctype][this.key] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return frappe.last_edited_communication[this.doctype][this.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
async set_values() {
|
||||||
|
for (const fieldname of ["recipients", "cc", "bcc", "sender"]) {
|
||||||
|
await this.dialog.set_value(fieldname, this[fieldname] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = frappe.utils.html2text(this.subject) || '';
|
||||||
|
await this.dialog.set_value("subject", subject);
|
||||||
|
|
||||||
|
await this.set_values_from_last_edited_communication();
|
||||||
|
await this.set_content();
|
||||||
|
|
||||||
|
// set default email template for the first email in a document
|
||||||
|
if (this.frm && !this.is_a_reply && !this.content_set) {
|
||||||
|
const email_template = this.frm.meta.default_email_template || '';
|
||||||
|
await this.dialog.set_value("email_template", email_template);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fieldname of ['email_template', 'cc', 'bcc']) {
|
||||||
|
if (this.dialog.get_value(fieldname)) {
|
||||||
|
this.toggle_more_options(true);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.dialog.on_page_show = function() {
|
async set_values_from_last_edited_communication() {
|
||||||
if (!me.txt) {
|
if (this.txt) return;
|
||||||
var last_edited_communication = me.get_last_edited_communication();
|
|
||||||
if(last_edited_communication.content) {
|
|
||||||
me.dialog.set_value("sender", last_edited_communication.sender || "");
|
|
||||||
me.dialog.set_value("subject", last_edited_communication.subject || "");
|
|
||||||
me.dialog.set_value("recipients", last_edited_communication.recipients || "");
|
|
||||||
me.dialog.set_value("cc", last_edited_communication.cc || "");
|
|
||||||
me.dialog.set_value("bcc", last_edited_communication.bcc || "");
|
|
||||||
me.dialog.set_value("content", last_edited_communication.content || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const last_edited = this.get_last_edited_communication();
|
||||||
|
if (!last_edited.content) return;
|
||||||
|
|
||||||
|
// prevent re-triggering of email template
|
||||||
|
if (last_edited.email_template) {
|
||||||
|
const template_field = this.dialog.fields_dict.email_template;
|
||||||
|
await template_field.set_model_value(last_edited.email_template);
|
||||||
|
delete last_edited.email_template;
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
await this.dialog.set_values(last_edited);
|
||||||
|
this.content_set = true;
|
||||||
|
}
|
||||||
|
|
||||||
get_last_edited_communication: function() {
|
selected_format() {
|
||||||
if (!frappe.last_edited_communication[this.doc]) {
|
return (
|
||||||
frappe.last_edited_communication[this.doc] = {};
|
this.dialog.fields_dict.select_print_format.input.value
|
||||||
}
|
|| this.frm && this.frm.meta.default_print_format
|
||||||
|
|| "Standard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if(!frappe.last_edited_communication[this.doc][this.key]) {
|
get_print_format(format) {
|
||||||
frappe.last_edited_communication[this.doc][this.key] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return frappe.last_edited_communication[this.doc][this.key];
|
|
||||||
},
|
|
||||||
|
|
||||||
selected_format: function() {
|
|
||||||
return this.dialog.fields_dict.select_print_format.input.value || (this.frm && this.frm.meta.default_print_format) || "Standard";
|
|
||||||
},
|
|
||||||
|
|
||||||
get_print_format: function(format) {
|
|
||||||
if (!format) {
|
if (!format) {
|
||||||
format = this.selected_format();
|
format = this.selected_format();
|
||||||
}
|
}
|
||||||
|
|
@ -385,21 +383,18 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
} else {
|
} else {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
setup_print_language: function() {
|
setup_print_language() {
|
||||||
var doc = this.doc || cur_frm.doc;
|
const fields = this.dialog.fields_dict;
|
||||||
var fields = this.dialog.fields_dict;
|
|
||||||
|
|
||||||
//Load default print language from doctype
|
//Load default print language from doctype
|
||||||
this.lang_code = doc.language
|
this.lang_code = this.doc.language
|
||||||
|
|| this.get_print_format().default_print_language
|
||||||
if (!this.lang_code && this.get_print_format().default_print_language) {
|
|| frappe.boot.lang;
|
||||||
this.lang_code = this.get_print_format().default_print_language;
|
|
||||||
}
|
|
||||||
|
|
||||||
//On selection of language retrieve language code
|
//On selection of language retrieve language code
|
||||||
var me = this;
|
const me = this;
|
||||||
$(fields.language_sel.input).change(function(){
|
$(fields.language_sel.input).change(function(){
|
||||||
me.lang_code = this.value
|
me.lang_code = this.value
|
||||||
})
|
})
|
||||||
|
|
@ -412,11 +407,11 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
if (this.lang_code) {
|
if (this.lang_code) {
|
||||||
$(fields.language_sel.input).val(this.lang_code);
|
$(fields.language_sel.input).val(this.lang_code);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
setup_print: function() {
|
setup_print() {
|
||||||
// print formats
|
// print formats
|
||||||
var fields = this.dialog.fields_dict;
|
const fields = this.dialog.fields_dict;
|
||||||
|
|
||||||
// toggle print format
|
// toggle print format
|
||||||
$(fields.attach_document_print.input).click(function() {
|
$(fields.attach_document_print.input).click(function() {
|
||||||
|
|
@ -426,8 +421,8 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
// select print format
|
// select print format
|
||||||
$(fields.select_print_format.wrapper).toggle(false);
|
$(fields.select_print_format.wrapper).toggle(false);
|
||||||
|
|
||||||
if (cur_frm) {
|
if (this.frm) {
|
||||||
const print_formats = frappe.meta.get_print_formats(cur_frm.meta.name);
|
const print_formats = frappe.meta.get_print_formats(this.frm.meta.name);
|
||||||
$(fields.select_print_format.input)
|
$(fields.select_print_format.input)
|
||||||
.empty()
|
.empty()
|
||||||
.add_options(print_formats)
|
.add_options(print_formats)
|
||||||
|
|
@ -436,11 +431,11 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
$(fields.attach_document_print.wrapper).toggle(false);
|
$(fields.attach_document_print.wrapper).toggle(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
}
|
||||||
|
|
||||||
setup_attach: function() {
|
setup_attach() {
|
||||||
var fields = this.dialog.fields_dict;
|
const fields = this.dialog.fields_dict;
|
||||||
var attach = $(fields.select_attachments.wrapper);
|
const attach = $(fields.select_attachments.wrapper);
|
||||||
|
|
||||||
if (!this.attachments) {
|
if (!this.attachments) {
|
||||||
this.attachments = [];
|
this.attachments = [];
|
||||||
|
|
@ -483,9 +478,9 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
.find(".add-more-attachments button")
|
.find(".add-more-attachments button")
|
||||||
.on('click', () => new frappe.ui.FileUploader(args));
|
.on('click', () => new frappe.ui.FileUploader(args));
|
||||||
this.render_attachment_rows();
|
this.render_attachment_rows();
|
||||||
},
|
}
|
||||||
|
|
||||||
render_attachment_rows: function(attachment) {
|
render_attachment_rows(attachment) {
|
||||||
const select_attachments = this.dialog.fields_dict.select_attachments;
|
const select_attachments = this.dialog.fields_dict.select_attachments;
|
||||||
const attachment_rows = $(select_attachments.wrapper).find(".attach-list");
|
const attachment_rows = $(select_attachments.wrapper).find(".attach-list");
|
||||||
if (attachment) {
|
if (attachment) {
|
||||||
|
|
@ -509,7 +504,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
get_attachment_row(attachment, checked) {
|
get_attachment_row(attachment, checked) {
|
||||||
return $(`<p class="checkbox flex">
|
return $(`<p class="checkbox flex">
|
||||||
|
|
@ -526,56 +521,55 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
${frappe.utils.icon('link-url')}
|
${frappe.utils.icon('link-url')}
|
||||||
</a>
|
</a>
|
||||||
</p>`);
|
</p>`);
|
||||||
},
|
}
|
||||||
|
|
||||||
setup_email: function() {
|
setup_email() {
|
||||||
// email
|
// email
|
||||||
var fields = this.dialog.fields_dict;
|
const fields = this.dialog.fields_dict;
|
||||||
|
|
||||||
if(this.attach_document_print) {
|
if (this.attach_document_print) {
|
||||||
$(fields.attach_document_print.input).click();
|
$(fields.attach_document_print.input).click();
|
||||||
$(fields.select_print_format.wrapper).toggle(true);
|
$(fields.select_print_format.wrapper).toggle(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$(fields.send_me_a_copy.input).on('click', () => {
|
$(fields.send_me_a_copy.input).on('click', () => {
|
||||||
// update send me a copy (make it sticky)
|
// update send me a copy (make it sticky)
|
||||||
let val = fields.send_me_a_copy.get_value();
|
const val = fields.send_me_a_copy.get_value();
|
||||||
frappe.db.set_value('User', frappe.session.user, 'send_me_a_copy', val);
|
frappe.db.set_value('User', frappe.session.user, 'send_me_a_copy', val);
|
||||||
frappe.boot.user.send_me_a_copy = val;
|
frappe.boot.user.send_me_a_copy = val;
|
||||||
});
|
});
|
||||||
|
|
||||||
},
|
}
|
||||||
|
|
||||||
send_action: function() {
|
send_action() {
|
||||||
var me = this;
|
const me = this;
|
||||||
var btn = me.dialog.get_primary_btn();
|
const btn = me.dialog.get_primary_btn();
|
||||||
|
const form_values = this.get_values();
|
||||||
|
if (!form_values) return;
|
||||||
|
|
||||||
var form_values = this.get_values();
|
const selected_attachments =
|
||||||
if(!form_values) return;
|
|
||||||
|
|
||||||
var selected_attachments =
|
|
||||||
$.map($(me.dialog.wrapper).find("[data-file-name]:checked"), function (element) {
|
$.map($(me.dialog.wrapper).find("[data-file-name]:checked"), function (element) {
|
||||||
return $(element).attr("data-file-name");
|
return $(element).attr("data-file-name");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if(form_values.attach_document_print) {
|
if (form_values.attach_document_print) {
|
||||||
me.send_email(btn, form_values, selected_attachments, null, form_values.select_print_format || "");
|
me.send_email(btn, form_values, selected_attachments, null, form_values.select_print_format || "");
|
||||||
} else {
|
} else {
|
||||||
me.send_email(btn, form_values, selected_attachments);
|
me.send_email(btn, form_values, selected_attachments);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
get_values: function() {
|
get_values() {
|
||||||
var form_values = this.dialog.get_values();
|
const form_values = this.dialog.get_values();
|
||||||
|
|
||||||
// cc
|
// cc
|
||||||
for ( var i=0, l=this.dialog.fields.length; i < l; i++ ) {
|
for (let i = 0, l = this.dialog.fields.length; i < l; i++) {
|
||||||
var df = this.dialog.fields[i];
|
const df = this.dialog.fields[i];
|
||||||
|
|
||||||
if ( df.is_cc_checkbox ) {
|
if (df.is_cc_checkbox) {
|
||||||
// concat in cc
|
// concat in cc
|
||||||
if ( form_values[df.fieldname] ) {
|
if (form_values[df.fieldname]) {
|
||||||
form_values.cc = ( form_values.cc ? (form_values.cc + ", ") : "" ) + df.fieldname;
|
form_values.cc = ( form_values.cc ? (form_values.cc + ", ") : "" ) + df.fieldname;
|
||||||
form_values.bcc = ( form_values.bcc ? (form_values.bcc + ", ") : "" ) + df.fieldname;
|
form_values.bcc = ( form_values.bcc ? (form_values.bcc + ", ") : "" ) + df.fieldname;
|
||||||
}
|
}
|
||||||
|
|
@ -585,22 +579,27 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
return form_values;
|
return form_values;
|
||||||
},
|
}
|
||||||
|
|
||||||
save_as_draft: function() {
|
save_as_draft() {
|
||||||
if (this.dialog && this.frm) {
|
if (this.dialog && this.frm) {
|
||||||
let message = this.dialog.get_value('content');
|
let message = this.dialog.get_value('content');
|
||||||
message = message.split(frappe.separator_element)[0];
|
message = message.split(separator_element)[0];
|
||||||
localforage.setItem(this.frm.doctype + this.frm.docname, message).catch(e => {
|
localforage.setItem(this.frm.doctype + this.frm.docname, message).catch(e => {
|
||||||
if (e) {
|
if (e) {
|
||||||
// silently fail
|
// silently fail
|
||||||
console.log(e); // eslint-disable-line
|
console.log(e); // eslint-disable-line
|
||||||
console.warn('[Communication] localStorage is full. Cannot save message as draft'); // eslint-disable-line
|
console.warn('[Communication] IndexedDB is full. Cannot save message as draft'); // eslint-disable-line
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
clear_cache() {
|
||||||
|
this.delete_saved_draft();
|
||||||
|
this.get_last_edited_communication(true);
|
||||||
|
}
|
||||||
|
|
||||||
delete_saved_draft() {
|
delete_saved_draft() {
|
||||||
if (this.dialog && this.frm) {
|
if (this.dialog && this.frm) {
|
||||||
|
|
@ -608,28 +607,28 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
if (e) {
|
if (e) {
|
||||||
// silently fail
|
// silently fail
|
||||||
console.log(e); // eslint-disable-line
|
console.log(e); // eslint-disable-line
|
||||||
console.warn('[Communication] localStorage is full. Cannot save message as draft'); // eslint-disable-line
|
console.warn('[Communication] IndexedDB is full. Cannot save message as draft'); // eslint-disable-line
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
send_email: function(btn, form_values, selected_attachments, print_html, print_format) {
|
send_email(btn, form_values, selected_attachments, print_html, print_format) {
|
||||||
var me = this;
|
const me = this;
|
||||||
me.dialog.hide();
|
this.dialog.hide();
|
||||||
|
|
||||||
if(!form_values.recipients) {
|
if (!form_values.recipients) {
|
||||||
frappe.msgprint(__("Enter Email Recipient(s)"));
|
frappe.msgprint(__("Enter Email Recipient(s)"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!form_values.attach_document_print) {
|
if (!form_values.attach_document_print) {
|
||||||
print_html = null;
|
print_html = null;
|
||||||
print_format = null;
|
print_format = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if(cur_frm && !frappe.model.can_email(me.doc.doctype, cur_frm)) {
|
if (this.frm && !frappe.model.can_email(this.doc.doctype, this.frm)) {
|
||||||
frappe.msgprint(__("You are not allowed to send emails related to this document"));
|
frappe.msgprint(__("You are not allowed to send emails related to this document"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -650,28 +649,29 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
send_me_a_copy: form_values.send_me_a_copy,
|
send_me_a_copy: form_values.send_me_a_copy,
|
||||||
print_format: print_format,
|
print_format: print_format,
|
||||||
sender: form_values.sender,
|
sender: form_values.sender,
|
||||||
sender_full_name: form_values.sender?frappe.user.full_name():undefined,
|
sender_full_name: form_values.sender
|
||||||
|
? frappe.user.full_name()
|
||||||
|
: undefined,
|
||||||
email_template: form_values.email_template,
|
email_template: form_values.email_template,
|
||||||
attachments: selected_attachments,
|
attachments: selected_attachments,
|
||||||
_lang : me.lang_code,
|
_lang : me.lang_code,
|
||||||
read_receipt:form_values.send_read_receipt,
|
read_receipt:form_values.send_read_receipt,
|
||||||
print_letterhead: me.is_print_letterhead_checked(),
|
print_letterhead: me.is_print_letterhead_checked(),
|
||||||
},
|
},
|
||||||
btn: btn,
|
btn,
|
||||||
callback: function(r) {
|
callback(r) {
|
||||||
if(!r.exc) {
|
if (!r.exc) {
|
||||||
frappe.utils.play_sound("email");
|
frappe.utils.play_sound("email");
|
||||||
|
|
||||||
if(r.message["emails_not_sent_to"]) {
|
if (r.message["emails_not_sent_to"]) {
|
||||||
frappe.msgprint(__("Email not sent to {0} (unsubscribed / disabled)",
|
frappe.msgprint(__("Email not sent to {0} (unsubscribed / disabled)",
|
||||||
[ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) );
|
[ frappe.utils.escape_html(r.message["emails_not_sent_to"]) ]) );
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((frappe.last_edited_communication[me.doc] || {})[me.key]) {
|
me.clear_cache();
|
||||||
delete frappe.last_edited_communication[me.doc][me.key];
|
|
||||||
}
|
if (me.frm) {
|
||||||
if (cur_frm) {
|
me.frm.reload_doc();
|
||||||
cur_frm.reload_doc();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// try the success callback if it exists
|
// try the success callback if it exists
|
||||||
|
|
@ -679,7 +679,7 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
try {
|
try {
|
||||||
me.success(r);
|
me.success(r);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e); // eslint-disable-line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -691,113 +691,115 @@ frappe.views.CommunicationComposer = Class.extend({
|
||||||
try {
|
try {
|
||||||
me.error(r);
|
me.error(r);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e);
|
console.log(e); // eslint-disable-line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
|
|
||||||
is_print_letterhead_checked: function() {
|
is_print_letterhead_checked() {
|
||||||
if (this.frm && $(this.frm.wrapper).find('.form-print-wrapper').is(':visible')){
|
if (this.frm && $(this.frm.wrapper).find('.form-print-wrapper').is(':visible')){
|
||||||
return $(this.frm.wrapper).find('.print-letterhead').prop('checked') ? 1 : 0;
|
return $(this.frm.wrapper).find('.print-letterhead').prop('checked') ? 1 : 0;
|
||||||
} else {
|
} else {
|
||||||
return (frappe.model.get_doc(":Print Settings", "Print Settings") ||
|
return (frappe.model.get_doc(":Print Settings", "Print Settings") ||
|
||||||
{ with_letterhead: 1 }).with_letterhead ? 1 : 0;
|
{ with_letterhead: 1 }).with_letterhead ? 1 : 0;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
get_default_outgoing_email_account_signature: function() {
|
async set_content() {
|
||||||
return frappe.db.get_value('Email Account', { 'default_outgoing': 1, 'add_signature': 1 }, 'signature');
|
if (this.content_set) return;
|
||||||
},
|
|
||||||
|
|
||||||
setup_earlier_reply: async function() {
|
let message = this.txt || "";
|
||||||
let fields = this.dialog.fields_dict;
|
if (!message && this.frm) {
|
||||||
let signature = frappe.boot.user.email_signature || "";
|
const { doctype, docname } = this.frm;
|
||||||
|
message = await localforage.getItem(doctype + docname) || "";
|
||||||
if (!signature) {
|
|
||||||
const res = await this.get_default_outgoing_email_account_signature();
|
|
||||||
signature = "<!-- signature-included -->" + res.message.signature;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (signature && !frappe.utils.is_html(signature)) {
|
if (message) {
|
||||||
signature = signature.replace(/\n/g, "<br>");
|
this.content_set = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(this.txt) {
|
message += await this.get_signature();
|
||||||
this.message = this.txt + (this.message ? ("<br><br>" + this.message) : "");
|
if (this.real_name && !message.includes("<!-- salutation-ends -->")) {
|
||||||
} else {
|
message = `<p>${__('Dear')} ${this.real_name},</p>
|
||||||
// saved draft in localStorage
|
<!-- salutation-ends --><br>${message}`;
|
||||||
const { doctype, docname } = this.frm || {};
|
|
||||||
if (doctype && docname) {
|
|
||||||
this.message = await localforage.getItem(doctype + docname) || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(this.real_name) {
|
|
||||||
this.message = '<p>'+__('Dear') +' '
|
|
||||||
+ this.real_name + ",</p><!-- salutation-ends --><br>" + (this.message || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if(this.message && signature && this.message.includes(signature)) {
|
|
||||||
signature = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
let reply = (this.message || "") + (signature ? ("<br>" + signature) : "");
|
|
||||||
let content = '';
|
|
||||||
|
|
||||||
if (this.is_a_reply === 'undefined') {
|
|
||||||
this.is_a_reply = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.is_a_reply) {
|
if (this.is_a_reply) {
|
||||||
let last_email = this.last_email;
|
message += this.get_earlier_reply();
|
||||||
|
|
||||||
if (!last_email) {
|
|
||||||
last_email = this.frm && this.frm.timeline.get_last_email(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!last_email) return;
|
|
||||||
|
|
||||||
let last_email_content = last_email.original_comment || last_email.content;
|
|
||||||
|
|
||||||
// convert the email context to text as we are enclosing
|
|
||||||
// this inside <blockquote>
|
|
||||||
last_email_content = this.html2text(last_email_content).replace(/\n/g, '<br>');
|
|
||||||
|
|
||||||
// clip last email for a maximum of 20k characters
|
|
||||||
// to prevent the email content from getting too large
|
|
||||||
if (last_email_content.length > 20 * 1024) {
|
|
||||||
last_email_content += '<div>' + __('Message clipped') + '</div>' + last_email_content;
|
|
||||||
last_email_content = last_email_content.slice(0, 20 * 1024);
|
|
||||||
}
|
|
||||||
|
|
||||||
let communication_date = last_email.communication_date || last_email.creation;
|
|
||||||
content = `
|
|
||||||
${reply}
|
|
||||||
<div><br></div>
|
|
||||||
${frappe.separator_element || ''}
|
|
||||||
<p>${__("On {0}, {1} wrote:", [frappe.datetime.global_date_format(communication_date) , last_email.sender])}</p>
|
|
||||||
<blockquote>
|
|
||||||
${last_email_content}
|
|
||||||
</blockquote>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
content = reply;
|
|
||||||
}
|
}
|
||||||
fields.content.set_value(content);
|
|
||||||
},
|
|
||||||
|
|
||||||
html2text: function(html) {
|
await this.dialog.set_value("content", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_signature() {
|
||||||
|
let signature = frappe.boot.user.email_signature;
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
const response = await frappe.db.get_value(
|
||||||
|
'Email Account',
|
||||||
|
{'default_outgoing': 1, 'add_signature': 1},
|
||||||
|
'signature'
|
||||||
|
);
|
||||||
|
|
||||||
|
signature = response.message.signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signature) return "";
|
||||||
|
|
||||||
|
if (!frappe.utils.is_html(signature)) {
|
||||||
|
signature = signature.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<br>" + signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
get_earlier_reply() {
|
||||||
|
const last_email = (
|
||||||
|
this.last_email
|
||||||
|
|| this.frm && this.frm.timeline.get_last_email(true)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!last_email) return "";
|
||||||
|
let last_email_content = last_email.original_comment || last_email.content;
|
||||||
|
|
||||||
|
// convert the email context to text as we are enclosing
|
||||||
|
// this inside <blockquote>
|
||||||
|
last_email_content = this.html2text(last_email_content).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
// clip last email for a maximum of 20k characters
|
||||||
|
// to prevent the email content from getting too large
|
||||||
|
if (last_email_content.length > 20 * 1024) {
|
||||||
|
last_email_content += '<div>' + __('Message clipped') + '</div>' + last_email_content;
|
||||||
|
last_email_content = last_email_content.slice(0, 20 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
const communication_date = frappe.datetime.global_date_format(
|
||||||
|
last_email.communication_date || last_email.creation
|
||||||
|
);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div><br></div>
|
||||||
|
${separator_element || ''}
|
||||||
|
<p>
|
||||||
|
${__("On {0}, {1} wrote:", [communication_date, last_email.sender])}
|
||||||
|
</p>
|
||||||
|
<blockquote>
|
||||||
|
${last_email_content}
|
||||||
|
</blockquote>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html2text(html) {
|
||||||
// convert HTML to text and try and preserve whitespace
|
// convert HTML to text and try and preserve whitespace
|
||||||
var d = document.createElement( 'div' );
|
const d = document.createElement( 'div' );
|
||||||
d.innerHTML = html.replace(/<\/div>/g, '<br></div>') // replace end of blocks
|
d.innerHTML = html.replace(/<\/div>/g, '<br></div>') // replace end of blocks
|
||||||
.replace(/<\/p>/g, '<br></p>') // replace end of paragraphs
|
.replace(/<\/p>/g, '<br></p>') // replace end of paragraphs
|
||||||
.replace(/<br>/g, '\n');
|
.replace(/<br>/g, '\n');
|
||||||
let text = d.textContent;
|
|
||||||
|
|
||||||
// replace multiple empty lines with just one
|
// replace multiple empty lines with just one
|
||||||
return text.replace(/\n{3,}/g, '\n\n');
|
return d.textContent.replace(/\n{3,}/g, '\n\n');
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
|
||||||
|
|
@ -204,9 +204,7 @@ frappe.views.InboxView = class InboxView extends frappe.views.ListView {
|
||||||
};
|
};
|
||||||
frappe.new_doc('Email Account');
|
frappe.new_doc('Email Account');
|
||||||
} else {
|
} else {
|
||||||
new frappe.views.CommunicationComposer({
|
new frappe.views.CommunicationComposer();
|
||||||
doc: {}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@ frappe.provide("frappe.views");
|
||||||
store.on('change:cur_list', setup_restore_columns);
|
store.on('change:cur_list', setup_restore_columns);
|
||||||
store.on('change:columns', setup_restore_columns);
|
store.on('change:columns', setup_restore_columns);
|
||||||
store.on('change:empty_state', show_empty_state);
|
store.on('change:empty_state', show_empty_state);
|
||||||
|
fluxify.doAction('update_order');
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepare() {
|
function prepare() {
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,9 @@
|
||||||
// Other Colors
|
// Other Colors
|
||||||
--sidebar-select-color: var(--gray-200);
|
--sidebar-select-color: var(--gray-200);
|
||||||
|
|
||||||
|
--scrollbar-thumb-color: var(--gray-400);
|
||||||
|
--scrollbar-track-color: var(--gray-200);
|
||||||
|
|
||||||
--shadow-inset: inset 0px -1px 0px var(--gray-300);
|
--shadow-inset: inset 0px -1px 0px var(--gray-300);
|
||||||
--border-color: var(--gray-100);
|
--border-color: var(--gray-100);
|
||||||
--dark-border-color: var(--gray-300);
|
--dark-border-color: var(--gray-300);
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,10 @@
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
background-color: var(--fg-color);
|
background-color: var(--user-mention-bg-color);
|
||||||
|
a[href] {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// table
|
// table
|
||||||
|
|
@ -174,7 +177,7 @@
|
||||||
.ql-editor.read-mode {
|
.ql-editor.read-mode {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
.mention {
|
.mention {
|
||||||
background-color: var(--control-bg);
|
--user-mention-bg-color: var(--control-bg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,4 +193,8 @@
|
||||||
|
|
||||||
.mention>span {
|
.mention>span {
|
||||||
margin: 0 3px;
|
margin: 0 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention[data-is-group="true"] {
|
||||||
|
background-color: var(--group-mention-bg-color);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ $input-height: 28px !default;
|
||||||
--timeline-content-max-width: 700px;
|
--timeline-content-max-width: 700px;
|
||||||
--timeline-left-padding: calc(var(--padding-xl) + var(--timeline-item-icon-size) / 2);
|
--timeline-left-padding: calc(var(--padding-xl) + var(--timeline-item-icon-size) / 2);
|
||||||
|
|
||||||
|
// mentions
|
||||||
|
--user-mention-bg-color: var(--fg-color);
|
||||||
|
--group-mention-bg-color: var(--bg-purple);
|
||||||
|
|
||||||
// skeleton
|
// skeleton
|
||||||
--skeleton-bg: var(--gray-100);
|
--skeleton-bg: var(--gray-100);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,9 @@
|
||||||
|
|
||||||
--sidebar-select-color: var(--gray-800);
|
--sidebar-select-color: var(--gray-800);
|
||||||
|
|
||||||
|
--scrollbar-thumb-color: var(--gray-600);
|
||||||
|
--scrollbar-track-color: var(--gray-700);
|
||||||
|
|
||||||
--shadow-inset: var(--fg-color);
|
--shadow-inset: var(--fg-color);
|
||||||
--border-color: var(--gray-700);
|
--border-color: var(--gray-700);
|
||||||
--dark-border-color: var(--gray-600);
|
--dark-border-color: var(--gray-600);
|
||||||
|
|
@ -75,6 +78,8 @@
|
||||||
// input
|
// input
|
||||||
--input-disabled-bg: none;
|
--input-disabled-bg: none;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
.frappe-card {
|
.frappe-card {
|
||||||
.btn-default {
|
.btn-default {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
|
|
@ -99,7 +104,7 @@
|
||||||
.ql-editor {
|
.ql-editor {
|
||||||
color: var(--text-on-gray);
|
color: var(--text-on-gray);
|
||||||
&.read-mode {
|
&.read-mode {
|
||||||
span,
|
span:not(.mention),
|
||||||
p,
|
p,
|
||||||
u,
|
u,
|
||||||
strong {
|
strong {
|
||||||
|
|
|
||||||
|
|
@ -754,7 +754,28 @@ body {
|
||||||
.layout-side-section, .layout-main-section-wrapper {
|
.layout-side-section, .layout-main-section-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-right: 25px;
|
||||||
|
scrollbar-color: var(--gray-200) transparent;
|
||||||
|
[data-theme="dark"] & {
|
||||||
|
scrollbar-color: var(--gray-800) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--gray-200);
|
||||||
|
[data-theme="dark"] & {
|
||||||
|
background: var(--gray-800);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-side-section {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.desk-sidebar {
|
.desk-sidebar {
|
||||||
margin-bottom: var(--margin-2xl);
|
margin-bottom: var(--margin-2xl);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
@import "mobile";
|
@import "mobile";
|
||||||
@import "form";
|
@import "form";
|
||||||
@import "print_preview";
|
@import "print_preview";
|
||||||
|
@import "scrollbar";
|
||||||
@import "navbar";
|
@import "navbar";
|
||||||
@import "../common/modal";
|
@import "../common/modal";
|
||||||
@import "slides";
|
@import "slides";
|
||||||
|
|
|
||||||
29
frappe/public/scss/desk/scrollbar.scss
Normal file
29
frappe/public/scss/desk/scrollbar.scss
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/* Works on Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Works on Chrome, Edge, and Safari */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: var(--scrollbar-track-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar {
|
||||||
|
width: unset;
|
||||||
|
height: unset;
|
||||||
|
}
|
||||||
|
|
@ -77,6 +77,7 @@ $threshold: 34;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.document-email-link-container {
|
.document-email-link-container {
|
||||||
|
@extend .ellipsis;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: var(--padding-sm);
|
padding: var(--padding-sm);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
|
|
@ -141,4 +142,4 @@ $threshold: 34;
|
||||||
--icon-stroke: var(--text-color);
|
--icon-stroke: var(--text-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,13 @@
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: map-get($grid-breakpoints, "lg")) {
|
||||||
|
.page-content-wrapper .container {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb-container {
|
.breadcrumb-container {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding-top: 0.25rem;
|
padding-top: 0.25rem;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue