Browse Source

Merge branch 'version-13-beta-pre-release' into version-13-beta

version-14
Saurabh 4 years ago
parent
commit
3dec12725c
100 changed files with 2093 additions and 1208 deletions
  1. +1
    -1
      .github/CONTRIBUTING.md
  2. +6
    -6
      .travis.yml
  3. +56
    -1
      cypress/integration/depends_on.js
  4. +38
    -3
      cypress/support/commands.js
  5. +25
    -4
      frappe/__init__.py
  6. +51
    -15
      frappe/app.py
  7. +7
    -8
      frappe/automation/doctype/auto_repeat/auto_repeat.js
  8. +15
    -1
      frappe/automation/doctype/auto_repeat/auto_repeat.json
  9. +101
    -39
      frappe/automation/doctype/auto_repeat/auto_repeat.py
  10. +49
    -3
      frappe/automation/doctype/auto_repeat/test_auto_repeat.py
  11. +0
    -0
      frappe/automation/doctype/auto_repeat_day/__init__.py
  12. +33
    -0
      frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json
  13. +10
    -0
      frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py
  14. +10
    -0
      frappe/cache_manager.py
  15. +21
    -0
      frappe/change_log/v13/v13_0_0-beta_10.md
  16. +5
    -0
      frappe/config/integrations.py
  17. +1
    -1
      frappe/core/doctype/comment/comment.py
  18. +10
    -1
      frappe/core/doctype/custom_docperm/custom_docperm.json
  19. +1
    -1
      frappe/core/doctype/data_import/importer.py
  20. +59
    -605
      frappe/core/doctype/docperm/docperm.json
  21. +29
    -13
      frappe/core/doctype/doctype/doctype.py
  22. +11
    -0
      frappe/core/doctype/document_naming_rule/document_naming_rule.py
  23. +1
    -1
      frappe/core/doctype/module_def/module_def.py
  24. +0
    -0
      frappe/core/doctype/module_profile/__init__.py
  25. +19
    -0
      frappe/core/doctype/module_profile/module_profile.js
  26. +60
    -0
      frappe/core/doctype/module_profile/module_profile.json
  27. +12
    -0
      frappe/core/doctype/module_profile/module_profile.py
  28. +32
    -0
      frappe/core/doctype/module_profile/test_module_profile.py
  29. +2
    -2
      frappe/core/doctype/report_filter/report_filter.json
  30. +2
    -2
      frappe/core/doctype/server_script/server_script.json
  31. +1
    -0
      frappe/core/doctype/server_script/server_script_utils.py
  32. +1
    -0
      frappe/core/doctype/server_script/test_server_script.py
  33. +2
    -2
      frappe/core/doctype/system_settings/system_settings.json
  34. +19
    -40
      frappe/core/doctype/user/user.js
  35. +14
    -3
      frappe/core/doctype/user/user.json
  36. +22
    -6
      frappe/core/doctype/user/user.py
  37. +49
    -3
      frappe/core/doctype/user_permission/test_user_permission.py
  38. +11
    -2
      frappe/core/doctype/user_permission/user_permission.js
  39. +37
    -251
      frappe/core/doctype/user_permission/user_permission.json
  40. +18
    -15
      frappe/core/doctype/user_permission/user_permission.py
  41. +24
    -1
      frappe/core/doctype/user_permission/user_permission_list.js
  42. +3
    -3
      frappe/core/doctype/version/version_view.html
  43. +1
    -1
      frappe/core/page/permission_manager/permission_manager.js
  44. +13
    -1
      frappe/core/page/permission_manager/permission_manager.py
  45. +9
    -5
      frappe/custom/doctype/customize_form/customize_form.py
  46. +6
    -0
      frappe/database/database.py
  47. +1
    -0
      frappe/desk/calendar.py
  48. +12
    -3
      frappe/desk/desktop.py
  49. +1
    -1
      frappe/desk/doctype/dashboard_chart/dashboard_chart.py
  50. +0
    -1
      frappe/desk/doctype/desk_page/desk_page.js
  51. +10
    -1
      frappe/desk/doctype/desk_page/desk_page.json
  52. +6
    -1
      frappe/desk/doctype/desk_page/desk_page.py
  53. +0
    -1
      frappe/desk/form/save.py
  54. +2
    -2
      frappe/desk/page/setup_wizard/install_fixtures.py
  55. +1
    -0
      frappe/desk/page/user_profile/user_profile.js
  56. +26
    -7
      frappe/desk/reportview.py
  57. +2
    -1
      frappe/desk/search.py
  58. +10
    -1
      frappe/email/doctype/auto_email_report/auto_email_report.py
  59. +1
    -1
      frappe/email/doctype/email_account/email_account.py
  60. +38
    -31
      frappe/email/doctype/newsletter/test_newsletter.py
  61. +12
    -2
      frappe/event_streaming/doctype/event_producer/event_producer.py
  62. +96
    -0
      frappe/geo/utils.py
  63. +1
    -1
      frappe/hooks.py
  64. +1
    -1
      frappe/integrations/desk_page/integrations/integrations.json
  65. +0
    -0
      frappe/integrations/doctype/connected_app/__init__.py
  66. +38
    -0
      frappe/integrations/doctype/connected_app/connected_app.js
  67. +166
    -0
      frappe/integrations/doctype/connected_app/connected_app.json
  68. +133
    -0
      frappe/integrations/doctype/connected_app/connected_app.py
  69. +162
    -0
      frappe/integrations/doctype/connected_app/test_connected_app.py
  70. +13
    -0
      frappe/integrations/doctype/connected_app/test_records.json
  71. +1
    -2
      frappe/integrations/doctype/oauth_client/test_records.json
  72. +0
    -0
      frappe/integrations/doctype/oauth_scope/__init__.py
  73. +30
    -0
      frappe/integrations/doctype/oauth_scope/oauth_scope.json
  74. +10
    -0
      frappe/integrations/doctype/oauth_scope/oauth_scope.py
  75. +0
    -0
      frappe/integrations/doctype/query_parameters/__init__.py
  76. +37
    -0
      frappe/integrations/doctype/query_parameters/query_parameters.json
  77. +10
    -0
      frappe/integrations/doctype/query_parameters/query_parameters.py
  78. +5
    -27
      frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json
  79. +14
    -44
      frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py
  80. +14
    -0
      frappe/integrations/doctype/social_login_key/test_social_login_key.py
  81. +0
    -0
      frappe/integrations/doctype/token_cache/__init__.py
  82. +18
    -0
      frappe/integrations/doctype/token_cache/test_records.json
  83. +37
    -0
      frappe/integrations/doctype/token_cache/test_token_cache.py
  84. +8
    -0
      frappe/integrations/doctype/token_cache/token_cache.js
  85. +110
    -0
      frappe/integrations/doctype/token_cache/token_cache.json
  86. +67
    -0
      frappe/integrations/doctype/token_cache/token_cache.py
  87. +1
    -1
      frappe/integrations/doctype/webhook/webhook.py
  88. +1
    -0
      frappe/integrations/oauth2.py
  89. +8
    -5
      frappe/model/base_document.py
  90. +10
    -4
      frappe/model/db_query.py
  91. +6
    -1
      frappe/model/delete_doc.py
  92. +4
    -2
      frappe/model/document.py
  93. +22
    -1
      frappe/model/meta.py
  94. +14
    -10
      frappe/model/rename_doc.py
  95. +10
    -8
      frappe/model/workflow.py
  96. +14
    -0
      frappe/patches/v13_0/website_theme_custom_scss.py
  97. +7
    -4
      frappe/permissions.py
  98. +1
    -1
      frappe/printing/doctype/print_format/print_format.js
  99. +3
    -3
      frappe/printing/doctype/print_format/print_format.json
  100. +2
    -0
      frappe/public/build.json

+ 1
- 1
.github/CONTRIBUTING.md View File

@@ -15,7 +15,7 @@ If your issue is not clear or does not meet the guidelines, then it will be clos
### General Issue Guidelines ### General Issue Guidelines


1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created. 1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created.
2. **Report each issue separately:** Don't club multiple, unreleated issues in one note.
2. **Report each issue separately:** Don't club multiple, unrelated issues in one note.
3. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs. 3. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs.


### Bug Report Guidelines ### Bug Report Guidelines


+ 6
- 6
.travis.yml View File

@@ -31,12 +31,12 @@ matrix:
- name: "Python 3.7 MariaDB" - name: "Python 3.7 MariaDB"
python: 3.7 python: 3.7
env: DB=mariadb TYPE=server env: DB=mariadb TYPE=server
script: bench --site test_site run-tests --coverage
script: bench --verbose --site test_site run-tests --coverage


- name: "Python 3.7 PostgreSQL" - name: "Python 3.7 PostgreSQL"
python: 3.7 python: 3.7
env: DB=postgres TYPE=server env: DB=postgres TYPE=server
script: bench --site test_site run-tests --coverage
script: bench --verbose --site test_site run-tests --coverage


- name: "Cypress" - name: "Cypress"
python: 3.7 python: 3.7
@@ -104,11 +104,11 @@ install:


- cd ./frappe-bench - cd ./frappe-bench


- sed -i 's/watch:/# watch:/g' Procfile
- sed -i 's/schedule:/# schedule:/g' Procfile
- sed -i 's/^watch:/# watch:/g' Procfile
- sed -i 's/^schedule:/# schedule:/g' Procfile


- if [ $TYPE == "server" ]; then sed -i 's/socketio:/# socketio:/g' Procfile; fi
- if [ $TYPE == "server" ]; then sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile; fi
- if [ $TYPE == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; fi
- if [ $TYPE == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi


- if [ $TYPE == "ui" ]; then bench setup requirements --node; fi - if [ $TYPE == "ui" ]; then bench setup requirements --node; fi




+ 56
- 1
cypress/integration/depends_on.js View File

@@ -3,7 +3,31 @@ context('Depends On', () => {
cy.login(); cy.login();
cy.visit('/desk#workspace/Website'); cy.visit('/desk#workspace/Website');
return cy.window().its('frappe').then(frappe => { return cy.window().its('frappe').then(frappe => {
return frappe.call('frappe.tests.ui_test_helpers.create_doctype', {
return frappe.xcall('frappe.tests.ui_test_helpers.create_child_doctype', {
name: 'Child Test Depends On',
fields: [
{
"label": "Child Test Field",
"fieldname": "child_test_field",
"fieldtype": "Data",
"in_list_view": 1,
},
{
"label": "Child Dependant Field",
"fieldname": "child_dependant_field",
"fieldtype": "Data",
"in_list_view": 1,
},
{
"label": "Child Display Dependant Field",
"fieldname": "child_display_dependant_field",
"fieldtype": "Data",
"in_list_view": 1,
},
]
});
}).then(frappe => {
return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
name: 'Test Depends On', name: 'Test Depends On',
fields: [ fields: [
{ {
@@ -24,6 +48,13 @@ context('Depends On', () => {
"fieldtype": "Data", "fieldtype": "Data",
'depends_on': "eval:doc.test_field=='Value'" 'depends_on': "eval:doc.test_field=='Value'"
}, },
{
"label": "Child Test Depends On Field",
"fieldname": "child_test_depends_on_field",
"fieldtype": "Table",
'read_only_depends_on': "eval:doc.test_field=='Some Other Value'",
'options': "Child Test Depends On"
},
] ]
}); });
}); });
@@ -48,6 +79,30 @@ context('Depends On', () => {
cy.get('body').click(); cy.get('body').click();
cy.get('.control-input [data-fieldname="dependant_field"]').should('not.be.disabled'); cy.get('.control-input [data-fieldname="dependant_field"]').should('not.be.disabled');
}); });
it('should set the table and its fields as read only depending on other fields value', () => {
cy.new_form('Test Depends On');
cy.fill_field('dependant_field', 'Some Value');
//cy.fill_field('test_field', 'Some Other Value');
cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table');
cy.get('@table').find('button.grid-add-row').click();
cy.get('@table').find('[data-idx="1"]').as('row1');
cy.get('@row1').find('.btn-open-row').click();
cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid');
//cy.get('@row1-form_in_grid').find('')
cy.fill_table_field('child_test_depends_on_field', '1', 'child_test_field', 'Some Value');
cy.fill_table_field('child_test_depends_on_field', '1', 'child_dependant_field', 'Some Other Value');

cy.get('@row1-form_in_grid').find('.octicon-triangle-up').click();

// set the table to read-only
cy.fill_field('test_field', 'Some Other Value');

// grid row form fields should be read-only
cy.get('@row1').find('.btn-open-row').click();

cy.get('@row1-form_in_grid').find('.control-input [data-fieldname="child_test_field"]').should('be.disabled');
cy.get('@row1-form_in_grid').find('.control-input [data-fieldname="child_dependant_field"]').should('be.disabled');
});
it('should display the field depending on other fields value', () => { it('should display the field depending on other fields value', () => {
cy.new_form('Test Depends On'); cy.new_form('Test Depends On');
cy.get('.control-input [data-fieldname="display_dependant_field"]').should('not.be.visible'); cy.get('.control-input [data-fieldname="display_dependant_field"]').should('not.be.visible');


+ 38
- 3
cypress/support/commands.js View File

@@ -160,7 +160,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => {


Cypress.Commands.add('create_records', doc => { Cypress.Commands.add('create_records', doc => {
return cy return cy
.call('frappe.tests.ui_test_helpers.create_if_not_exists', { doc })
.call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc})
.then(r => r.message); .then(r => r.message);
}); });


@@ -186,7 +186,7 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => {
if (fieldtype === 'Select') { if (fieldtype === 'Select') {
cy.get('@input').select(value); cy.get('@input').select(value);
} else { } else {
cy.get('@input').type(value, { waitForAnimations: false, force: true });
cy.get('@input').type(value, {waitForAnimations: false, force: true});
} }
return cy.get('@input'); return cy.get('@input');
}); });
@@ -204,8 +204,43 @@ Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => {
return cy.get(selector); return cy.get(selector);
}); });


Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => {
cy.get_table_field(tablefieldname, row_idx, fieldname, fieldtype).as('input');

if (['Date', 'Time', 'Datetime'].includes(fieldtype)) {
cy.get('@input').click().wait(200);
cy.get('.datepickers-container .datepicker.active').should('exist');
}
if (fieldtype === 'Time') {
cy.get('@input').clear().wait(200);
}

if (fieldtype === 'Select') {
cy.get('@input').select(value);
} else {
cy.get('@input').type(value, {waitForAnimations: false, force: true});
}
return cy.get('@input');
});

Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fieldtype = 'Data') => {
let selector = `.frappe-control[data-fieldname="${tablefieldname}"]`;
selector += ` [data-idx="${row_idx}"]`;
selector += ` .form-in-grid`;

if (fieldtype === 'Text Editor') {
selector += ` [data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`;
} else if (fieldtype === 'Code') {
selector += ` [data-fieldname="${fieldname}"] .ace_text-input`;
} else {
selector += ` .form-control[data-fieldname="${fieldname}"]`;
}

return cy.get(selector);
});

Cypress.Commands.add('awesomebar', text => { Cypress.Commands.add('awesomebar', text => {
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, { delay: 100 });
cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100});
}); });


Cypress.Commands.add('new_form', doctype => { Cypress.Commands.add('new_form', doctype => {


+ 25
- 4
frappe/__init__.py View File

@@ -23,11 +23,12 @@ if PY2:
reload(sys) reload(sys)
sys.setdefaultencoding("utf-8") sys.setdefaultencoding("utf-8")


__version__ = '13.0.0-beta.9'
__version__ = '13.0.0-beta.10'


__title__ = "Frappe Framework" __title__ = "Frappe Framework"


local = Local() local = Local()
controllers = {}


class _dict(dict): class _dict(dict):
"""dict like object that exposes keys as attributes""" """dict like object that exposes keys as attributes"""
@@ -149,6 +150,7 @@ def init(site, sites_path=None, new_site=False):
"new_site": new_site "new_site": new_site
}) })
local.rollback_observers = [] local.rollback_observers = []
local.before_commit = []
local.test_objects = {} local.test_objects = {}


local.site = site local.site = site
@@ -327,7 +329,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False,
:param is_minimizable: [optional] Allow users to minimize the modal :param is_minimizable: [optional] Allow users to minimize the modal
:param wide: [optional] Show wide modal :param wide: [optional] Show wide modal
""" """
from frappe.utils import encode
from frappe.utils import strip_html_tags


msg = safe_decode(msg) msg = safe_decode(msg)
out = _dict(message=msg) out = _dict(message=msg)
@@ -354,7 +356,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False,
out.as_list = 1 out.as_list = 1


if flags.print_messages and out.message: if flags.print_messages and out.message:
print(f"Message: {repr(out.message).encode('utf-8')}")
print(f"Message: {strip_html_tags(out.message)}")


if title: if title:
out.title = title out.title = title
@@ -628,6 +630,21 @@ def clear_cache(user=None, doctype=None):


local.role_permissions = {} local.role_permissions = {}


def only_has_select_perm(doctype, user=None, ignore_permissions=False):
if ignore_permissions:
return False

if not user:
user = local.session.user

import frappe.permissions
permissions = frappe.permissions.get_role_permissions(doctype, user=user)

if permissions.get('select') and not permissions.get('read'):
return True
else:
return False

def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False): def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False):
"""Raises `frappe.PermissionError` if not permitted. """Raises `frappe.PermissionError` if not permitted.


@@ -946,7 +963,11 @@ def get_installed_apps(sort=False, frappe_last=False):
connect() connect()


if not local.all_apps: if not local.all_apps:
local.all_apps = get_all_apps(True)
local.all_apps = cache().get_value('all_apps', get_all_apps)

#cache bench apps
if not cache().get_value('all_apps'):
cache().set_value('all_apps', local.all_apps)


installed = json.loads(db.get_global("installed_apps") or "[]") installed = json.loads(db.get_global("installed_apps") or "[]")




+ 51
- 15
frappe/app.py View File

@@ -7,8 +7,8 @@ import os
from six import iteritems from six import iteritems
import logging import logging


from werkzeug.wrappers import Request
from werkzeug.local import LocalManager from werkzeug.local import LocalManager
from werkzeug.wrappers import Request, Response
from werkzeug.exceptions import HTTPException, NotFound from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.middleware.profiler import ProfilerMiddleware from werkzeug.middleware.profiler import ProfilerMiddleware
from werkzeug.middleware.shared_data import SharedDataMiddleware from werkzeug.middleware.shared_data import SharedDataMiddleware
@@ -57,19 +57,22 @@ def application(request):
frappe.monitor.start() frappe.monitor.start()
frappe.rate_limiter.apply() frappe.rate_limiter.apply()


if frappe.local.form_dict.cmd:
if request.method == "OPTIONS":
response = Response()

elif frappe.form_dict.cmd:
response = frappe.handler.handle() response = frappe.handler.handle()


elif frappe.request.path.startswith("/api/"):
elif request.path.startswith("/api/"):
response = frappe.api.handle() response = frappe.api.handle()


elif frappe.request.path.startswith('/backups'):
elif request.path.startswith('/backups'):
response = frappe.utils.response.download_backup(request.path) response = frappe.utils.response.download_backup(request.path)


elif frappe.request.path.startswith('/private/files/'):
elif request.path.startswith('/private/files/'):
response = frappe.utils.response.download_private_file(request.path) response = frappe.utils.response.download_private_file(request.path)


elif frappe.local.request.method in ('GET', 'HEAD', 'POST'):
elif request.method in ('GET', 'HEAD', 'POST'):
response = frappe.website.render.render() response = frappe.website.render.render()


else: else:
@@ -88,13 +91,9 @@ def application(request):
rollback = after_request(rollback) rollback = after_request(rollback)


finally: finally:
if frappe.local.request.method in ("POST", "PUT") and frappe.db and rollback:
if request.method in ("POST", "PUT") and frappe.db and rollback:
frappe.db.rollback() frappe.db.rollback()


# set cookies
if response and hasattr(frappe.local, 'cookie_manager'):
frappe.local.cookie_manager.flush_cookies(response=response)

frappe.rate_limiter.update() frappe.rate_limiter.update()
frappe.monitor.stop(response) frappe.monitor.stop(response)
frappe.recorder.dump() frappe.recorder.dump()
@@ -110,9 +109,7 @@ def application(request):
"http_status_code": getattr(response, "status_code", "NOTFOUND") "http_status_code": getattr(response, "status_code", "NOTFOUND")
}) })


if response and hasattr(frappe.local, 'rate_limiter'):
response.headers.extend(frappe.local.rate_limiter.headers())

process_response(response)
frappe.destroy() frappe.destroy()


return response return response
@@ -134,7 +131,46 @@ def init_request(request):


make_form_dict(request) make_form_dict(request)


frappe.local.http_request = frappe.auth.HTTPRequest()
if request.method != "OPTIONS":
frappe.local.http_request = frappe.auth.HTTPRequest()

def process_response(response):
if not response:
return

# set cookies
if hasattr(frappe.local, 'cookie_manager'):
frappe.local.cookie_manager.flush_cookies(response=response)

# rate limiter headers
if hasattr(frappe.local, 'rate_limiter'):
response.headers.extend(frappe.local.rate_limiter.headers())

# CORS headers
if hasattr(frappe.local, 'conf') and frappe.conf.allow_cors:
set_cors_headers(response)

def set_cors_headers(response):
origin = frappe.request.headers.get('Origin')
if not origin:
return

allow_cors = frappe.conf.allow_cors
if allow_cors != "*":
if not isinstance(allow_cors, list):
allow_cors = [allow_cors]

if origin not in allow_cors:
return

response.headers.extend({
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': ('Authorization,DNT,X-Mx-ReqToken,'
'Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,'
'Cache-Control,Content-Type')
})


def make_form_dict(request): def make_form_dict(request):
import json import json


+ 7
- 8
frappe/automation/doctype/auto_repeat/auto_repeat.js View File

@@ -54,10 +54,12 @@ frappe.ui.form.on('Auto Repeat', {


toggle_submit_on_creation: function(frm) { toggle_submit_on_creation: function(frm) {
// submit on creation checkbox // submit on creation checkbox
frappe.model.with_doctype(frm.doc.reference_doctype, () => {
let meta = frappe.get_meta(frm.doc.reference_doctype);
frm.toggle_display('submit_on_creation', meta.is_submittable);
});
if (frm.doc.reference_doctype) {
frappe.model.with_doctype(frm.doc.reference_doctype, () => {
let meta = frappe.get_meta(frm.doc.reference_doctype);
frm.toggle_display('submit_on_creation', meta.is_submittable);
});
}
}, },


template: function(frm) { template: function(frm) {
@@ -100,10 +102,7 @@ frappe.ui.form.on('Auto Repeat', {


frappe.auto_repeat.render_schedule = function(frm) { frappe.auto_repeat.render_schedule = function(frm) {
if (!frm.is_dirty() && frm.doc.status !== 'Disabled') { if (!frm.is_dirty() && frm.doc.status !== 'Disabled') {
frappe.call({
method: "get_auto_repeat_schedule",
doc: frm.doc
}).done((r) => {
frm.call("get_auto_repeat_schedule").then(r => {
frm.dashboard.wrapper.empty(); frm.dashboard.wrapper.empty();
frm.dashboard.add_section( frm.dashboard.add_section(
frappe.render_template("auto_repeat_schedule", { frappe.render_template("auto_repeat_schedule", {


+ 15
- 1
frappe/automation/doctype/auto_repeat/auto_repeat.json View File

@@ -23,6 +23,8 @@
"repeat_on_last_day", "repeat_on_last_day",
"column_break_12", "column_break_12",
"next_schedule_date", "next_schedule_date",
"section_break_16",
"repeat_on_days",
"notification", "notification",
"notify_by_email", "notify_by_email",
"recipients", "recipients",
@@ -189,15 +191,27 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Repeat on Last Day of the Month" "label": "Repeat on Last Day of the Month"
}, },
{
"depends_on": "eval:doc.frequency==='Weekly';",
"fieldname": "repeat_on_days",
"fieldtype": "Table",
"label": "Repeat on Days",
"options": "Auto Repeat Day"
},
{ {
"default": "0", "default": "0",
"fieldname": "submit_on_creation", "fieldname": "submit_on_creation",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Submit on Creation" "label": "Submit on Creation"
},
{
"depends_on": "eval:doc.frequency==='Weekly';",
"fieldname": "section_break_16",
"fieldtype": "Section Break"
} }
], ],
"links": [], "links": [],
"modified": "2020-12-10 10:43:13.449172",
"modified": "2021-01-12 09:24:49.719611",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Automation", "module": "Automation",
"name": "Auto Repeat", "name": "Auto Repeat",


+ 101
- 39
frappe/automation/doctype/auto_repeat/auto_repeat.py View File

@@ -5,6 +5,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import frappe import frappe
from frappe import _ from frappe import _
from datetime import timedelta
from frappe.desk.form import assign_to from frappe.desk.form import assign_to
from frappe.utils.jinja import validate_template from frappe.utils.jinja import validate_template
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -13,9 +14,10 @@ from frappe.utils import cstr, getdate, split_emails, add_days, today, get_last_
from frappe.model.document import Document from frappe.model.document import Document
from frappe.core.doctype.communication.email import make from frappe.core.doctype.communication.email import make
from frappe.utils.background_jobs import get_jobs from frappe.utils.background_jobs import get_jobs
from frappe.automation.doctype.assignment_rule.assignment_rule import get_repeated


month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12} month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12}
week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6}


class AutoRepeat(Document): class AutoRepeat(Document):
def validate(self): def validate(self):
@@ -24,6 +26,7 @@ class AutoRepeat(Document):
self.validate_submit_on_creation() self.validate_submit_on_creation()
self.validate_dates() self.validate_dates()
self.validate_email_id() self.validate_email_id()
self.validate_auto_repeat_days()
self.set_dates() self.set_dates()
self.update_auto_repeat_id() self.update_auto_repeat_id()
self.unlink_if_applicable() self.unlink_if_applicable()
@@ -49,7 +52,7 @@ class AutoRepeat(Document):
if self.disabled: if self.disabled:
self.next_schedule_date = None self.next_schedule_date = None
else: else:
self.next_schedule_date = get_next_schedule_date(self.start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, self.end_date)
self.next_schedule_date = self.get_next_schedule_date(schedule_date=self.start_date)


def unlink_if_applicable(self): def unlink_if_applicable(self):
if self.status == 'Completed' or self.disabled: if self.status == 'Completed' or self.disabled:
@@ -88,6 +91,12 @@ class AutoRepeat(Document):
else: else:
frappe.throw(_("'Recipients' not specified")) frappe.throw(_("'Recipients' not specified"))


def validate_auto_repeat_days(self):
auto_repeat_days = self.get_auto_repeat_days()
if not len(set(auto_repeat_days)) == len(auto_repeat_days):
repeated_days = get_repeated(auto_repeat_days)
frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days)))

def update_auto_repeat_id(self): def update_auto_repeat_id(self):
#check if document is already on auto repeat #check if document is already on auto repeat
auto_repeat = frappe.db.get_value(self.reference_doctype, self.reference_document, "auto_repeat") auto_repeat = frappe.db.get_value(self.reference_doctype, self.reference_document, "auto_repeat")
@@ -113,7 +122,7 @@ class AutoRepeat(Document):
end_date = getdate(self.end_date) end_date = getdate(self.end_date)


if not self.end_date: if not self.end_date:
next_date = get_next_schedule_date(start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day)
next_date = self.get_next_schedule_date(schedule_date=start_date)
row = { row = {
"reference_document": self.reference_document, "reference_document": self.reference_document,
"frequency": self.frequency, "frequency": self.frequency,
@@ -122,8 +131,7 @@ class AutoRepeat(Document):
schedule_details.append(row) schedule_details.append(row)


if self.end_date: if self.end_date:
next_date = get_next_schedule_date(
start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, for_full_schedule=True)
next_date = self.get_next_schedule_date(schedule_date=start_date, for_full_schedule=True)


while (getdate(next_date) < getdate(end_date)): while (getdate(next_date) < getdate(end_date)):
row = { row = {
@@ -132,8 +140,7 @@ class AutoRepeat(Document):
"next_scheduled_date" : next_date "next_scheduled_date" : next_date
} }
schedule_details.append(row) schedule_details.append(row)
next_date = get_next_schedule_date(
next_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True)
next_date = self.get_next_schedule_date(schedule_date=next_date, for_full_schedule=True)


return schedule_details return schedule_details


@@ -211,6 +218,75 @@ class AutoRepeat(Document):
new_doc.set('from_date', from_date) new_doc.set('from_date', from_date)
new_doc.set('to_date', to_date) new_doc.set('to_date', to_date)


def get_next_schedule_date(self, schedule_date, for_full_schedule=False):
"""
Returns the next schedule date for auto repeat after a recurring document has been created.
Adds required offset to the schedule_date param and returns the next schedule date.

:param schedule_date: The date when the last recurring document was created.
:param for_full_schedule: If True, returns the immediate next schedule date, else the full schedule.
"""
if month_map.get(self.frequency):
month_count = month_map.get(self.frequency) + month_diff(schedule_date, self.start_date) - 1
else:
month_count = 0

day_count = 0
if month_count and self.repeat_on_last_day:
day_count = 31
next_date = get_next_date(self.start_date, month_count, day_count)
elif month_count and self.repeat_on_day:
day_count = self.repeat_on_day
next_date = get_next_date(self.start_date, month_count, day_count)
elif month_count:
next_date = get_next_date(self.start_date, month_count)
else:
days = self.get_days(schedule_date)
next_date = add_days(schedule_date, days)

# next schedule date should be after or on current date
if not for_full_schedule:
while getdate(next_date) < getdate(today()):
if month_count:
month_count += month_map.get(self.frequency, 0)
next_date = get_next_date(self.start_date, month_count, day_count)
else:
days = self.get_days(next_date)
next_date = add_days(next_date, days)

return next_date

def get_days(self, schedule_date):
if self.frequency == "Weekly":
days = self.get_offset_for_weekly_frequency(schedule_date)
else:
# daily frequency
days = 1

return days

def get_offset_for_weekly_frequency(self, schedule_date):
# if weekdays are not set, offset is 7 from current schedule date
if not self.repeat_on_days:
return 7

repeat_on_days = self.get_auto_repeat_days()
current_schedule_day = getdate(schedule_date).weekday()
weekdays = list(week_map.keys())

# if repeats on more than 1 day or
# start date's weekday is not in repeat days, then get next weekday
# else offset is 7
if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days:
weekday = get_next_weekday(current_schedule_day, repeat_on_days)
next_weekday_number = week_map.get(weekday, 0)
# offset for upcoming weekday
return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days
return 7

def get_auto_repeat_days(self):
return [d.day for d in self.get('repeat_on_days', [])]

def send_notification(self, new_doc): def send_notification(self, new_doc):
"""Notify concerned people about recurring document generation""" """Notify concerned people about recurring document generation"""
subject = self.subject or '' subject = self.subject or ''
@@ -291,42 +367,24 @@ class AutoRepeat(Document):
) )




def get_next_schedule_date(schedule_date, frequency, start_date, repeat_on_day=None, repeat_on_last_day=False, end_date=None, for_full_schedule=False):
if month_map.get(frequency):
month_count = month_map.get(frequency) + month_diff(schedule_date, start_date) - 1
else:
month_count = 0

day_count = 0
if month_count and repeat_on_last_day:
day_count = 31
next_date = get_next_date(start_date, month_count, day_count)
elif month_count and repeat_on_day:
day_count = repeat_on_day
next_date = get_next_date(start_date, month_count, day_count)
elif month_count:
next_date = get_next_date(start_date, month_count)
else:
days = 7 if frequency == 'Weekly' else 1
next_date = add_days(schedule_date, days)

# next schedule date should be after or on current date
if not for_full_schedule:
while getdate(next_date) < getdate(today()):
if month_count:
month_count += month_map.get(frequency)
next_date = get_next_date(start_date, month_count, day_count)
elif days:
next_date = add_days(next_date, days)

return next_date


def get_next_date(dt, mcount, day=None): def get_next_date(dt, mcount, day=None):
dt = getdate(dt) dt = getdate(dt)
dt += relativedelta(months=mcount, day=day) dt += relativedelta(months=mcount, day=day)
return dt return dt



def get_next_weekday(current_schedule_day, weekdays):
days = list(week_map.keys())
if current_schedule_day > 0:
days = days[(current_schedule_day + 1):] + days[:current_schedule_day]
else:
days = days[(current_schedule_day + 1):]

for entry in days:
if entry in weekdays:
return entry


#called through hooks #called through hooks
def make_auto_repeat_entry(): def make_auto_repeat_entry():
enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries' enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries'
@@ -337,6 +395,7 @@ def make_auto_repeat_entry():
data = get_auto_repeat_entries(date) data = get_auto_repeat_entries(date)
frappe.enqueue(enqueued_method, data=data) frappe.enqueue(enqueued_method, data=data)



def create_repeated_entries(data): def create_repeated_entries(data):
for d in data: for d in data:
doc = frappe.get_doc('Auto Repeat', d.name) doc = frappe.get_doc('Auto Repeat', d.name)
@@ -346,10 +405,11 @@ def create_repeated_entries(data):


if schedule_date == current_date and not doc.disabled: if schedule_date == current_date and not doc.disabled:
doc.create_documents() doc.create_documents()
schedule_date = get_next_schedule_date(schedule_date, doc.frequency, doc.start_date, doc.repeat_on_day, doc.repeat_on_last_day, doc.end_date)
schedule_date = doc.get_next_schedule_date(schedule_date=schedule_date)
if schedule_date and not doc.disabled: if schedule_date and not doc.disabled:
frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date) frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date)



def get_auto_repeat_entries(date=None): def get_auto_repeat_entries(date=None):
if not date: if not date:
date = getdate(today()) date = getdate(today())
@@ -358,6 +418,7 @@ def get_auto_repeat_entries(date=None):
['status', '=', 'Active'] ['status', '=', 'Active']
]) ])



#called through hooks #called through hooks
def set_auto_repeat_as_completed(): def set_auto_repeat_as_completed():
auto_repeat = frappe.get_all("Auto Repeat", filters = {'status': ['!=', 'Disabled']}) auto_repeat = frappe.get_all("Auto Repeat", filters = {'status': ['!=', 'Disabled']})
@@ -367,6 +428,7 @@ def set_auto_repeat_as_completed():
doc.status = 'Completed' doc.status = 'Completed'
doc.save() doc.save()



@frappe.whitelist() @frappe.whitelist()
def make_auto_repeat(doctype, docname, frequency = 'Daily', start_date = None, end_date = None): def make_auto_repeat(doctype, docname, frequency = 'Daily', start_date = None, end_date = None):
if not start_date: if not start_date:


+ 49
- 3
frappe/automation/doctype/auto_repeat/test_auto_repeat.py View File

@@ -7,10 +7,9 @@ import unittest


import frappe import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries
from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries, week_map
from frappe.utils import today, add_days, getdate, add_months from frappe.utils import today, add_days, getdate, add_months



def add_custom_fields(): def add_custom_fields():
df = dict( df = dict(
fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', insert_after='sender', fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', insert_after='sender',
@@ -42,6 +41,52 @@ class TestAutoRepeat(unittest.TestCase):


self.assertEqual(todo.get('description'), new_todo.get('description')) self.assertEqual(todo.get('description'), new_todo.get('description'))


def test_weekly_auto_repeat(self):
todo = frappe.get_doc(
dict(doctype='ToDo', description='test weekly todo', assigned_by='Administrator')).insert()

doc = make_auto_repeat(reference_doctype='ToDo',
frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7))

self.assertEqual(doc.next_schedule_date, today())
data = get_auto_repeat_entries(getdate(today()))
create_repeated_entries(data)
frappe.db.commit()

todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)

new_todo = frappe.db.get_value('ToDo',
{'auto_repeat': doc.name, 'name': ('!=', todo.name)}, 'name')

new_todo = frappe.get_doc('ToDo', new_todo)

self.assertEqual(todo.get('description'), new_todo.get('description'))

def test_weekly_auto_repeat_with_weekdays(self):
todo = frappe.get_doc(
dict(doctype='ToDo', description='test auto repeat with weekdays', assigned_by='Administrator')).insert()

weekdays = list(week_map.keys())
current_weekday = getdate().weekday()
days = [
{'day': weekdays[current_weekday]},
{'day': weekdays[(current_weekday + 2) % 7]}
]
doc = make_auto_repeat(reference_doctype='ToDo',
frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7), days=days)

self.assertEqual(doc.next_schedule_date, today())
data = get_auto_repeat_entries(getdate(today()))
create_repeated_entries(data)
frappe.db.commit()

todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)

doc.reload()
self.assertEqual(doc.next_schedule_date, add_days(getdate(), 2))

def test_monthly_auto_repeat(self): def test_monthly_auto_repeat(self):
start_date = today() start_date = today()
end_date = add_months(start_date, 12) end_date = add_months(start_date, 12)
@@ -144,7 +189,8 @@ def make_auto_repeat(**args):
'notify_by_email': args.notify or 0, 'notify_by_email': args.notify or 0,
'recipients': args.recipients or "", 'recipients': args.recipients or "",
'subject': args.subject or "", 'subject': args.subject or "",
'message': args.message or ""
'message': args.message or "",
'repeat_on_days': args.days or []
}).insert(ignore_permissions=True) }).insert(ignore_permissions=True)


return doc return doc


+ 0
- 0
frappe/automation/doctype/auto_repeat_day/__init__.py View File


+ 33
- 0
frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json View File

@@ -0,0 +1,33 @@
{
"actions": [],
"creation": "2020-11-10 22:30:53.690228",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"day"
],
"fields": [
{
"fieldname": "day",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Day",
"options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-11-10 22:30:53.690228",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat Day",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 10
- 0
frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document

class AutoRepeatDay(Document):
pass

+ 10
- 0
frappe/cache_manager.py View File

@@ -72,6 +72,7 @@ def clear_document_cache():
frappe.cache().delete_key("document_cache") frappe.cache().delete_key("document_cache")


def clear_doctype_cache(doctype=None): def clear_doctype_cache(doctype=None):
clear_controller_cache(doctype)
cache = frappe.cache() cache = frappe.cache()


if getattr(frappe.local, 'meta_cache') and (doctype in frappe.local.meta_cache): if getattr(frappe.local, 'meta_cache') and (doctype in frappe.local.meta_cache):
@@ -104,6 +105,15 @@ def clear_doctype_cache(doctype=None):
# Clear all document's cache. To clear documents of a specific DocType document_cache should be restructured # Clear all document's cache. To clear documents of a specific DocType document_cache should be restructured
clear_document_cache() clear_document_cache()


def clear_controller_cache(doctype=None):
if not doctype:
del frappe.controllers
frappe.controllers = {}
return
for site_controllers in frappe.controllers.values():
site_controllers.pop(doctype, None)

def get_doctype_map(doctype, name, filters=None, order_by=None): def get_doctype_map(doctype, name, filters=None, order_by=None):
cache = frappe.cache() cache = frappe.cache()
cache_key = frappe.scrub(doctype) + '_map' cache_key = frappe.scrub(doctype) + '_map'


+ 21
- 0
frappe/change_log/v13/v13_0_0-beta_10.md View File

@@ -0,0 +1,21 @@
### Version 13.0.0 Beta 10 Release Notes

#### Features and Enhancements

- Option to hide child records for a nested DocType via User Permissions ([12209](https://github.com/frappe/frappe/pull/12209))
- Added option to grant only `Select` access to document ([12063](https://github.com/frappe/frappe/pull/12063))
- Introduced map view ([11202](https://github.com/frappe/frappe/pull/11202))
- Enabled image rendering from links in Print View ([12101](https://github.com/frappe/frappe/pull/12101))
- Introduced "Yesterday" and "Tomorrow" options for Timespan filter ([12179](https://github.com/frappe/frappe/pull/12179))

#### Fixes

- Fixed HTML download of Auto Email Report that used to break in some cases ([12202](https://github.com/frappe/frappe/pull/12202))
- Fixed reset customizations functionality ([12152](https://github.com/frappe/frappe/pull/12152))
- Fixed the rendering of percentage stat in Dashboard Chart ([12090](https://github.com/frappe/frappe/pull/12090))
- Fixed permission issues in Dashboard Chart ([12243](https://github.com/frappe/frappe/pull/12243))
- Fixed an issue with grid row index ([12188](https://github.com/frappe/frappe/pull/12188))
- Fixed an issue where fields used to get reordered after adding new columns ([12058](https://github.com/frappe/frappe/pull/12058))
- Fixed currency formatting in Print Format ([11897](https://github.com/frappe/frappe/pull/11897))
- Added a fieldlevel permission check for report data ([12163](https://github.com/frappe/frappe/pull/12163))
- Fixed an issue with percent precision ([12010](https://github.com/frappe/frappe/pull/12010))

+ 5
- 0
frappe/config/integrations.py View File

@@ -77,6 +77,11 @@ def get_data():
"name": "OAuth Provider Settings", "name": "OAuth Provider Settings",
"description": _("Settings for OAuth Provider"), "description": _("Settings for OAuth Provider"),
}, },
{
"type": "doctype",
"name": "Connected App",
"description": _("Connect to any OAuth Provider"),
},
] ]
}, },
{ {


+ 1
- 1
frappe/core/doctype/comment/comment.py View File

@@ -150,7 +150,7 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments):
try: try:
# use sql, so that we do not mess with the timestamp # use sql, so that we do not mess with the timestamp
frappe.db.sql("""update `tab{0}` set `_comments`=%s where name=%s""".format(reference_doctype), # nosec frappe.db.sql("""update `tab{0}` set `_comments`=%s where name=%s""".format(reference_doctype), # nosec
(json.dumps(_comments[-50:]), reference_name))
(json.dumps(_comments[-100:]), reference_name))


except Exception as e: except Exception as e:
if frappe.db.is_column_missing(e) and getattr(frappe.local, 'request', None): if frappe.db.is_column_missing(e) and getattr(frappe.local, 'request', None):


+ 10
- 1
frappe/core/doctype/custom_docperm/custom_docperm.json View File

@@ -1,4 +1,5 @@
{ {
"actions": [],
"allow_import": 1, "allow_import": 1,
"autoname": "hash", "autoname": "hash",
"creation": "2017-01-11 04:21:35.217943", "creation": "2017-01-11 04:21:35.217943",
@@ -13,6 +14,7 @@
"column_break_2", "column_break_2",
"permlevel", "permlevel",
"section_break_4", "section_break_4",
"select",
"read", "read",
"write", "write",
"create", "create",
@@ -211,9 +213,16 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Reference Document Type", "label": "Reference Document Type",
"read_only": 1 "read_only": 1
},
{
"default": "0",
"fieldname": "select",
"fieldtype": "Check",
"label": "Select"
} }
], ],
"modified": "2019-10-31 16:58:16.157079",
"links": [],
"modified": "2020-12-03 15:20:48.296730",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Custom DocPerm", "name": "Custom DocPerm",


+ 1
- 1
frappe/core/doctype/data_import/importer.py View File

@@ -751,7 +751,7 @@ class Row:
self.warnings.append( self.warnings.append(
{ {
"row": self.row_number, "row": self.row_number,
"message": _("{0} is a mandatory field asdadsf").format(id_field.label),
"message": _("{0} is a mandatory field").format(id_field.label),
} }
) )
return return


+ 59
- 605
frappe/core/doctype/docperm/docperm.json View File

@@ -1,775 +1,229 @@
{ {
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"actions": [],
"autoname": "hash", "autoname": "hash",
"beta": 0,
"creation": "2013-02-22 01:27:33", "creation": "2013-02-22 01:27:33",
"custom": 0,
"docstatus": 0,
"doctype": "DocType", "doctype": "DocType",
"document_type": "Setup", "document_type": "Setup",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"role_and_level",
"role",
"if_owner",
"column_break_2",
"permlevel",
"section_break_4",
"select",
"read",
"write",
"create",
"delete",
"column_break_8",
"submit",
"cancel",
"amend",
"additional_permissions",
"report",
"export",
"import",
"set_user_permissions",
"column_break_19",
"share",
"print",
"email"
],
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "role_and_level", "fieldname": "role_and_level",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Role and Level",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Role and Level"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "role", "fieldname": "role",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Role", "label": "Role",
"length": 0,
"no_copy": 0,
"oldfieldname": "role", "oldfieldname": "role",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "Role", "options": "Role",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "150px", "print_width": "150px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1, "reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "150px" "width": "150px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"description": "Apply this rule if the User is the Owner", "description": "Apply this rule if the User is the Owner",
"fieldname": "if_owner", "fieldname": "if_owner",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "If user is the owner",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "If user is the owner"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_2", "fieldname": "column_break_2",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldtype": "Column Break"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0", "default": "0",
"fieldname": "permlevel", "fieldname": "permlevel",
"fieldtype": "Int", "fieldtype": "Int",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Level", "label": "Level",
"length": 0,
"no_copy": 0,
"oldfieldname": "permlevel", "oldfieldname": "permlevel",
"oldfieldtype": "Int", "oldfieldtype": "Int",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "40px", "print_width": "40px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "40px" "width": "40px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "section_break_4", "fieldname": "section_break_4",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Permissions",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Permissions"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "read", "fieldname": "read",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Read", "label": "Read",
"length": 0,
"no_copy": 0,
"oldfieldname": "read", "oldfieldname": "read",
"oldfieldtype": "Check", "oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "write", "fieldname": "write",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Write", "label": "Write",
"length": 0,
"no_copy": 0,
"oldfieldname": "write", "oldfieldname": "write",
"oldfieldtype": "Check", "oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "create", "fieldname": "create",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Create", "label": "Create",
"length": 0,
"no_copy": 0,
"oldfieldname": "create", "oldfieldname": "create",
"oldfieldtype": "Check", "oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "delete", "fieldname": "delete",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Delete",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Delete"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_8", "fieldname": "column_break_8",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldtype": "Column Break"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"fieldname": "submit", "fieldname": "submit",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Submit", "label": "Submit",
"length": 0,
"no_copy": 0,
"oldfieldname": "submit", "oldfieldname": "submit",
"oldfieldtype": "Check", "oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"fieldname": "cancel", "fieldname": "cancel",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Cancel", "label": "Cancel",
"length": 0,
"no_copy": 0,
"oldfieldname": "cancel", "oldfieldname": "cancel",
"oldfieldtype": "Check", "oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"fieldname": "amend", "fieldname": "amend",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Amend", "label": "Amend",
"length": 0,
"no_copy": 0,
"oldfieldname": "amend", "oldfieldname": "amend",
"oldfieldtype": "Check", "oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "additional_permissions", "fieldname": "additional_permissions",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Additional Permissions",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Additional Permissions"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "report", "fieldname": "report",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Report", "label": "Report",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "32px", "print_width": "32px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0,
"width": "32px" "width": "32px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "export", "fieldname": "export",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Export",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Export"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"fieldname": "import", "fieldname": "import",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Import",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Import"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"description": "This role update User Permissions for a user", "description": "This role update User Permissions for a user",
"fieldname": "set_user_permissions", "fieldname": "set_user_permissions",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Set User Permissions",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Set User Permissions"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break_19", "fieldname": "column_break_19",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldtype": "Column Break"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "share", "fieldname": "share",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Share",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Share"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "print", "fieldname": "print",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Print",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Print"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fieldname": "email", "fieldname": "email",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Email",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Email"
},
{
"default": "0",
"fieldname": "select",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Select"
} }
], ],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 1, "idx": 1,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 1, "istable": 1,
"max_attachments": 0,
"modified": "2018-05-29 11:54:38.613936",
"links": [],
"modified": "2020-12-03 15:15:30.488212",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocPerm", "name": "DocPerm",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_order": "ASC",
"track_changes": 0,
"track_seen": 0
"sort_field": "modified",
"sort_order": "ASC"
} }

+ 29
- 13
frappe/core/doctype/doctype/doctype.py View File

@@ -5,7 +5,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import re, copy, os, shutil import re, copy, os, shutil
import json import json
from frappe.cache_manager import clear_user_cache
from frappe.cache_manager import clear_user_cache, clear_controller_cache


# imports - third party imports # imports - third party imports
import six import six
@@ -290,9 +290,15 @@ class DocType(Document):


self.update_fields_to_fetch() self.update_fields_to_fetch()


from frappe import conf
allow_doctype_export = frappe.flags.allow_doctype_export or (not frappe.flags.in_test and conf.get('developer_mode'))
if not self.custom and not frappe.flags.in_import and allow_doctype_export:
allow_doctype_export = (
not self.custom
and not frappe.flags.in_import
and (
frappe.conf.developer_mode
or frappe.flags.allow_doctype_export
)
)
if allow_doctype_export:
self.export_doc() self.export_doc()
self.make_controller_template() self.make_controller_template()


@@ -382,13 +388,10 @@ class DocType(Document):
if merge: if merge:
frappe.throw(_("DocType can not be merged")) frappe.throw(_("DocType can not be merged"))


# Do not rename and move files and folders for custom doctype
if not self.custom and not frappe.flags.in_test and not frappe.flags.in_patch:
self.rename_files_and_folders(old, new)

def after_rename(self, old, new, merge=False): def after_rename(self, old, new, merge=False):
"""Change table name using `RENAME TABLE` if table exists. Or update """Change table name using `RENAME TABLE` if table exists. Or update
`doctype` property for Single type.""" `doctype` property for Single type."""

if self.issingle: if self.issingle:
frappe.db.sql("""update tabSingles set doctype=%s where doctype=%s""", (new, old)) frappe.db.sql("""update tabSingles set doctype=%s where doctype=%s""", (new, old))
frappe.db.sql("""update tabSingles set value=%s frappe.db.sql("""update tabSingles set value=%s
@@ -398,6 +401,18 @@ class DocType(Document):
"mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`", "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`",
"postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`" "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`"
}) })
frappe.db.commit()

# Do not rename and move files and folders for custom doctype
if not self.custom:
if not frappe.flags.in_patch:
self.rename_files_and_folders(old, new)

clear_controller_cache(old)

def after_delete(self):
if not self.custom:
clear_controller_cache(self.name)


def rename_files_and_folders(self, old, new): def rename_files_and_folders(self, old, new):
# move files # move files
@@ -1000,10 +1015,10 @@ def validate_fields(meta):
check_sort_field(meta) check_sort_field(meta)
check_image_field(meta) check_image_field(meta)


def validate_permissions_for_doctype(doctype, for_remove=False):
def validate_permissions_for_doctype(doctype, for_remove=False, alert=False):
"""Validates if permissions are set correctly.""" """Validates if permissions are set correctly."""
doctype = frappe.get_doc("DocType", doctype) doctype = frappe.get_doc("DocType", doctype)
validate_permissions(doctype, for_remove)
validate_permissions(doctype, for_remove, alert=alert)


# save permissions # save permissions
for perm in doctype.get("permissions"): for perm in doctype.get("permissions"):
@@ -1026,9 +1041,10 @@ def clear_permissions_cache(doctype):
""", doctype): """, doctype):
frappe.clear_cache(user=user) frappe.clear_cache(user=user)


def validate_permissions(doctype, for_remove=False):
def validate_permissions(doctype, for_remove=False, alert=False):
permissions = doctype.get("permissions") permissions = doctype.get("permissions")
if not permissions:
# Some DocTypes may not have permissions by default, don't show alert for them
if not permissions and alert:
frappe.msgprint(_('No Permissions Specified'), alert=True, indicator='orange') frappe.msgprint(_('No Permissions Specified'), alert=True, indicator='orange')
issingle = issubmittable = isimportable = False issingle = issubmittable = isimportable = False
if doctype: if doctype:
@@ -1040,7 +1056,7 @@ def validate_permissions(doctype, for_remove=False):
return _("For {0} at level {1} in {2} in row {3}").format(d.role, d.permlevel, d.parent, d.idx) return _("For {0} at level {1} in {2} in row {3}").format(d.role, d.permlevel, d.parent, d.idx)


def check_atleast_one_set(d): def check_atleast_one_set(d):
if not d.read and not d.write and not d.submit and not d.cancel and not d.create:
if not d.select and not d.read and not d.write and not d.submit and not d.cancel and not d.create:
frappe.throw(_("{0}: No basic permissions set").format(get_txt(d))) frappe.throw(_("{0}: No basic permissions set").format(get_txt(d)))


def check_double(d): def check_double(d):


+ 11
- 0
frappe/core/doctype/document_naming_rule/document_naming_rule.py View File

@@ -6,8 +6,19 @@ from __future__ import unicode_literals
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.data import evaluate_filters from frappe.utils.data import evaluate_filters
from frappe import _


class DocumentNamingRule(Document): class DocumentNamingRule(Document):
def validate(self):
self.validate_fields_in_conditions()

def validate_fields_in_conditions(self):
if self.has_value_changed("document_type"):
docfields = [x.fieldname for x in frappe.get_meta(self.document_type).fields]
for condition in self.conditions:
if condition.field not in docfields:
frappe.throw(_("{0} is not a field of doctype {1}").format(frappe.bold(condition.field), frappe.bold(self.document_type)))

def apply(self, doc): def apply(self, doc):
''' '''
Apply naming rules for the given document. Will set `name` if the rule is matched. Apply naming rules for the given document. Will set `name` if the rule is matched.


+ 1
- 1
frappe/core/doctype/module_def/module_def.py View File

@@ -43,7 +43,7 @@ class ModuleDef(Document):
def on_trash(self): def on_trash(self):
"""Delete module name from modules.txt""" """Delete module name from modules.txt"""


if frappe.flags.in_uninstall or self.custom:
if not frappe.conf.get('developer_mode') or frappe.flags.in_uninstall or self.custom:
return return


modules = None modules = None


+ 0
- 0
frappe/core/doctype/module_profile/__init__.py View File


+ 19
- 0
frappe/core/doctype/module_profile/module_profile.js View File

@@ -0,0 +1,19 @@
// Copyright (c) 2020, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Module Profile', {
refresh: function(frm) {
if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) {
if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) {
let module_area = $('<div style="min-height: 300px">')
.appendTo(frm.fields_dict.module_html.wrapper);

frm.module_editor = new frappe.ModuleEditor(frm, module_area);
}
}

if (frm.module_editor) {
frm.module_editor.refresh();
}
}
});

+ 60
- 0
frappe/core/doctype/module_profile/module_profile.json View File

@@ -0,0 +1,60 @@
{
"actions": [],
"autoname": "field:module_profile_name",
"creation": "2020-12-22 22:00:30.614475",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"module_profile_name",
"module_html",
"block_modules"
],
"fields": [
{
"fieldname": "module_profile_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Module Profile Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "module_html",
"fieldtype": "HTML",
"label": "Module HTML"
},
{
"fieldname": "block_modules",
"fieldtype": "Table",
"hidden": 1,
"label": "Block Modules",
"options": "Block Module",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-01-03 15:36:52.622696",
"modified_by": "Administrator",
"module": "Core",
"name": "Module Profile",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 12
- 0
frappe/core/doctype/module_profile/module_profile.py View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
from frappe.model.document import Document

class ModuleProfile(Document):
def onload(self):
from frappe.config import get_modules_from_all_apps
self.set_onload('all_modules',
[m.get("module_name") for m in get_modules_from_all_apps()])

+ 32
- 0
frappe/core/doctype/module_profile/test_module_profile.py View File

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
# See license.txt
from __future__ import unicode_literals
import frappe
import unittest

class TestModuleProfile(unittest.TestCase):
def test_make_new_module_profile(self):
if not frappe.db.get_value('Module Profile', '_Test Module Profile'):
frappe.get_doc({
'doctype': 'Module Profile',
'module_profile_name': '_Test Module Profile',
'block_modules': [
{'module': 'Accounts'}
]
}).insert()

# add to user and check
if not frappe.db.get_value('User', 'test-for-module_profile@example.com'):
new_user = frappe.get_doc({
'doctype': 'User',
'email':'test-for-module_profile@example.com',
'first_name':'Test User'
}).insert()
else:
new_user = frappe.get_doc('User', 'test-for-module_profile@example.com')

new_user.module_profile = '_Test Module Profile'
new_user.save()

self.assertEqual(new_user.block_modules[0].module, 'Accounts')

+ 2
- 2
frappe/core/doctype/report_filter/report_filter.json View File

@@ -44,7 +44,7 @@
}, },
{ {
"fieldname": "options", "fieldname": "options",
"fieldtype": "Data",
"fieldtype": "Small Text",
"label": "Options" "label": "Options"
}, },
{ {
@@ -58,7 +58,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-17 16:15:46.937267",
"modified": "2020-12-05 19:20:00.503097",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Report Filter", "name": "Report Filter",


+ 2
- 2
frappe/core/doctype/server_script/server_script.json View File

@@ -47,7 +47,7 @@
"fieldname": "doctype_event", "fieldname": "doctype_event",
"fieldtype": "Select", "fieldtype": "Select",
"label": "DocType Event", "label": "DocType Event",
"options": "Before Insert\nBefore Save\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)"
"options": "Before Insert\nBefore Validate\nBefore Save\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)"
}, },
{ {
"depends_on": "eval:doc.script_type==='API'", "depends_on": "eval:doc.script_type==='API'",
@@ -88,7 +88,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-12-03 22:42:02.708148",
"modified": "2021-01-03 18:50:14.767595",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Server Script", "name": "Server Script",


+ 1
- 0
frappe/core/doctype/server_script/server_script_utils.py View File

@@ -6,6 +6,7 @@ import frappe
EVENT_MAP = { EVENT_MAP = {
'before_insert': 'Before Insert', 'before_insert': 'Before Insert',
'after_insert': 'After Insert', 'after_insert': 'After Insert',
'before_validate': 'Before Validate',
'validate': 'Before Save', 'validate': 'Before Save',
'on_update': 'After Save', 'on_update': 'After Save',
'before_submit': 'Before Submit', 'before_submit': 'Before Submit',


+ 1
- 0
frappe/core/doctype/server_script/test_server_script.py View File

@@ -81,6 +81,7 @@ class TestServerScript(unittest.TestCase):
def tearDownClass(cls): def tearDownClass(cls):
frappe.db.commit() frappe.db.commit()
frappe.db.sql('truncate `tabServer Script`') frappe.db.sql('truncate `tabServer Script`')
frappe.cache().delete_key('server_script_map')


def setUp(self): def setUp(self):
frappe.cache().delete_value('server_script_map') frappe.cache().delete_value('server_script_map')


+ 2
- 2
frappe/core/doctype/system_settings/system_settings.json View File

@@ -357,7 +357,7 @@
"collapsible": 1, "collapsible": 1,
"fieldname": "email", "fieldname": "email",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "EMail"
"label": "Email"
}, },
{ {
"description": "Your organization name and address for the email footer.", "description": "Your organization name and address for the email footer.",
@@ -490,4 +490,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "ASC", "sort_order": "ASC",
"track_changes": 1 "track_changes": 1
}
}

+ 19
- 40
frappe/core/doctype/user/user.js View File

@@ -37,6 +37,25 @@ frappe.ui.form.on('User', {
} }
}, },


module_profile: function(frm) {
if (frm.doc.module_profile) {
frappe.call({
"method": "frappe.core.doctype.user.user.get_module_profile",
args: {
module_profile: frm.doc.module_profile
},
callback: function(data) {
frm.set_value("block_modules", []);
$.each(data.message || [], function(i, v) {
let d = frm.add_child("block_modules");
d.module = v.module;
});
frm.module_editor && frm.module_editor.refresh();
}
});
}
},

onload: function(frm) { onload: function(frm) {
frm.can_edit_roles = has_access_to_edit_user(); frm.can_edit_roles = has_access_to_edit_user();


@@ -255,43 +274,3 @@ function get_roles_for_editing_user() {
.filter(perm => perm.permlevel >= 1 && perm.write) .filter(perm => perm.permlevel >= 1 && perm.write)
.map(perm => perm.role) || ['System Manager']; .map(perm => perm.role) || ['System Manager'];
} }

frappe.ModuleEditor = Class.extend({
init: function(frm, wrapper) {
this.wrapper = $('<div class="row module-block-list"></div>').appendTo(wrapper);
this.frm = frm;
this.make();
},
make: function() {
var me = this;
this.frm.doc.__onload.all_modules.forEach(function(m) {
$(repl('<div class="col-sm-6"><div class="checkbox">\
<label><input type="checkbox" class="block-module-check" data-module="%(module)s">\
%(module)s</label></div></div>', {module: m})).appendTo(me.wrapper);
});
this.bind();
},
refresh: function() {
var me = this;
this.wrapper.find(".block-module-check").prop("checked", true);
$.each(this.frm.doc.block_modules, function(i, d) {
me.wrapper.find(".block-module-check[data-module='"+ d.module +"']").prop("checked", false);
});
},
bind: function() {
var me = this;
this.wrapper.on("change", ".block-module-check", function() {
var module = $(this).attr('data-module');
if($(this).prop("checked")) {
// remove from block_modules
me.frm.doc.block_modules = $.map(me.frm.doc.block_modules || [], function(d) {
if (d.module != module) {
return d;
}
});
} else {
me.frm.add_child("block_modules", {"module": module});
}
});
}
});

+ 14
- 3
frappe/core/doctype/user/user.json View File

@@ -51,9 +51,9 @@
"send_me_a_copy", "send_me_a_copy",
"allowed_in_mentions", "allowed_in_mentions",
"email_signature", "email_signature",
"email_inbox",
"user_emails", "user_emails",
"sb_allow_modules", "sb_allow_modules",
"module_profile",
"modules_html", "modules_html",
"block_modules", "block_modules",
"home_settings", "home_settings",
@@ -577,6 +577,12 @@
"fieldtype": "Password", "fieldtype": "Password",
"label": "API Secret", "label": "API Secret",
"read_only": 1 "read_only": 1
},
{
"fieldname": "module_profile",
"fieldtype": "Link",
"label": "Module Profile",
"options": "Module Profile"
} }
], ],
"icon": "fa fa-user", "icon": "fa fa-user",
@@ -642,10 +648,15 @@
"group": "Activity", "group": "Activity",
"link_doctype": "ToDo", "link_doctype": "ToDo",
"link_fieldname": "owner" "link_fieldname": "owner"
},
{
"group": "Integrations",
"link_doctype": "Token Cache",
"link_fieldname": "user"
} }
], ],
"max_attachments": 5, "max_attachments": 5,
"modified": "2020-08-26 19:48:49.677800",
"modified": "2020-10-18 15:18:53.126800",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "User", "name": "User",
@@ -679,4 +690,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"title_field": "full_name", "title_field": "full_name",
"track_changes": 1 "track_changes": 1
}
}

+ 22
- 6
frappe/core/doctype/user/user.py View File

@@ -75,6 +75,7 @@ class User(Document):
self.validate_user_email_inbox() self.validate_user_email_inbox()
ask_pass_update() ask_pass_update()
self.validate_roles() self.validate_roles()
self.validate_allowed_modules()
self.validate_user_image() self.validate_user_image()


if self.language == "Loading...": if self.language == "Loading...":
@@ -85,9 +86,18 @@ class User(Document):


def validate_roles(self): def validate_roles(self):
if self.role_profile_name: if self.role_profile_name:
role_profile = frappe.get_doc('Role Profile', self.role_profile_name)
self.set('roles', [])
self.append_roles(*[role.role for role in role_profile.roles])
role_profile = frappe.get_doc('Role Profile', self.role_profile_name)
self.set('roles', [])
self.append_roles(*[role.role for role in role_profile.roles])

def validate_allowed_modules(self):
if self.module_profile:
module_profile = frappe.get_doc('Module Profile', self.module_profile)
self.set('block_modules', [])
for d in module_profile.get('block_modules'):
self.append('block_modules', {
'module': d.module
})


def validate_user_image(self): def validate_user_image(self):
if self.user_image and len(self.user_image) > 2000: if self.user_image and len(self.user_image) > 2000:
@@ -98,16 +108,17 @@ class User(Document):
self.share_with_self() self.share_with_self()
clear_notifications(user=self.name) clear_notifications(user=self.name)
frappe.clear_cache(user=self.name) frappe.clear_cache(user=self.name)
now=frappe.flags.in_test or frappe.flags.in_install
self.send_password_notification(self.__new_password) self.send_password_notification(self.__new_password)
frappe.enqueue( frappe.enqueue(
'frappe.core.doctype.user.user.create_contact', 'frappe.core.doctype.user.user.create_contact',
user=self, user=self,
ignore_mandatory=True, ignore_mandatory=True,
now=frappe.flags.in_test or frappe.flags.in_install
now=now
) )
if self.name not in ('Administrator', 'Guest') and not self.user_image: if self.name not in ('Administrator', 'Guest') and not self.user_image:
frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name)
frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name, now=now)
# Set user selected timezone # Set user selected timezone
if self.time_zone: if self.time_zone:
frappe.defaults.set_default("time_zone", self.time_zone, self.name) frappe.defaults.set_default("time_zone", self.time_zone, self.name)
@@ -1041,6 +1052,11 @@ def get_role_profile(role_profile):
roles = frappe.get_doc('Role Profile', {'role_profile': role_profile}) roles = frappe.get_doc('Role Profile', {'role_profile': role_profile})
return roles.roles return roles.roles


@frappe.whitelist()
def get_module_profile(module_profile):
module_profile = frappe.get_doc('Module Profile', {'module_profile_name': module_profile})
return module_profile.get('block_modules')

def update_roles(role_profile): def update_roles(role_profile):
users = frappe.get_all('User', filters={'role_profile_name': role_profile}) users = frappe.get_all('User', filters={'role_profile_name': role_profile})
role_profile = frappe.get_doc('Role Profile', role_profile) role_profile = frappe.get_doc('Role Profile', role_profile)


+ 49
- 3
frappe/core/doctype/user_permission/test_user_permission.py View File

@@ -3,6 +3,7 @@
# See license.txt # See license.txt
from __future__ import unicode_literals from __future__ import unicode_literals
from frappe.core.doctype.user_permission.user_permission import add_user_permissions from frappe.core.doctype.user_permission.user_permission import add_user_permissions
from frappe.permissions import has_user_permission


import frappe import frappe
import unittest import unittest
@@ -10,7 +11,12 @@ import unittest
class TestUserPermission(unittest.TestCase): class TestUserPermission(unittest.TestCase):
def setUp(self): def setUp(self):
frappe.db.sql("""DELETE FROM `tabUser Permission` frappe.db.sql("""DELETE FROM `tabUser Permission`
WHERE `user` in ('test_bulk_creation_update@example.com', 'test_user_perm1@example.com')""")
WHERE `user` in (
'test_bulk_creation_update@example.com',
'test_user_perm1@example.com',
'nested_doc_user@example.com')""")
frappe.delete_doc_if_exists("DocType", "Person")
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`")


def test_default_user_permission_validation(self): def test_default_user_permission_validation(self):
user = create_user('test_default_permission@example.com') user = create_user('test_default_permission@example.com')
@@ -108,6 +114,45 @@ class TestUserPermission(unittest.TestCase):
self.assertIsNone(removed_applicable_second) self.assertIsNone(removed_applicable_second)
self.assertEquals(is_created, 1) self.assertEquals(is_created, 1)


def test_user_perm_for_nested_doctype(self):
"""Test if descendants' visibility is controlled for a nested DocType."""
from frappe.core.doctype.doctype.test_doctype import new_doctype

user = create_user("nested_doc_user@example.com", "Blogger")
if not frappe.db.exists("DocType", "Person"):
doc = new_doctype("Person",
fields=[
{
"label": "Person Name",
"fieldname": "person_name",
"fieldtype": "Data"
}
], unique=0)
doc.is_tree = 1
doc.insert()

parent_record = frappe.get_doc(
{"doctype": "Person", "person_name": "Parent", "is_group": 1}
).insert()

child_record = frappe.get_doc(
{"doctype": "Person", "person_name": "Child", "is_group": 0, "parent_person": parent_record.name}
).insert()

add_user_permissions(get_params(user, "Person", parent_record.name))

# check if adding perm on a group record, makes child record visible
self.assertTrue(has_user_permission(frappe.get_doc("Person", parent_record.name), user.name))
self.assertTrue(has_user_permission(frappe.get_doc("Person", child_record.name), user.name))

frappe.db.set_value("User Permission", {"allow": "Person", "for_value": parent_record.name}, "hide_descendants", 1)
frappe.cache().delete_value("user_permissions")

# check if adding perm on a group record with hide_descendants enabled,
# hides child records
self.assertTrue(has_user_permission(frappe.get_doc("Person", parent_record.name), user.name))
self.assertFalse(has_user_permission(frappe.get_doc("Person", child_record.name), user.name))

def create_user(email, role="System Manager"): def create_user(email, role="System Manager"):
''' create user with role system manager ''' ''' create user with role system manager '''
if frappe.db.exists('User', email): if frappe.db.exists('User', email):
@@ -119,7 +164,7 @@ def create_user(email, role="System Manager"):
user.add_roles(role) user.add_roles(role)
return user return user


def get_params(user, doctype, docname, is_default=0, applicable=None):
def get_params(user, doctype, docname, is_default=0, hide_descendants=0, applicable=None):
''' Return param to insert ''' ''' Return param to insert '''
param = { param = {
"user": user.name, "user": user.name,
@@ -127,7 +172,8 @@ def get_params(user, doctype, docname, is_default=0, applicable=None):
"docname":docname, "docname":docname,
"is_default": is_default, "is_default": is_default,
"apply_to_all_doctypes": 1, "apply_to_all_doctypes": 1,
"applicable_doctypes": []
"applicable_doctypes": [],
"hide_descendants": hide_descendants
} }
if applicable: if applicable:
param.update({"apply_to_all_doctypes": 0}) param.update({"apply_to_all_doctypes": 0})


+ 11
- 2
frappe/core/doctype/user_permission/user_permission.js View File

@@ -26,11 +26,15 @@ frappe.ui.form.on('User Permission', {
() => frappe.set_route('query-report', 'Permitted Documents For User', () => frappe.set_route('query-report', 'Permitted Documents For User',
{ user: frm.doc.user })); { user: frm.doc.user }));
frm.trigger('set_applicable_for_constraint'); frm.trigger('set_applicable_for_constraint');
frm.trigger('toggle_hide_descendants');
}, },


allow: frm => { allow: frm => {
if(frm.doc.for_value) {
frm.set_value('for_value', null);
if (frm.doc.allow) {
if (frm.doc.for_value) {
frm.set_value('for_value', null);
}
frm.trigger('toggle_hide_descendants');
} }
}, },


@@ -43,6 +47,11 @@ frappe.ui.form.on('User Permission', {
if (frm.doc.apply_to_all_doctypes) { if (frm.doc.apply_to_all_doctypes) {
frm.set_value('applicable_for', null); frm.set_value('applicable_for', null);
} }
},

toggle_hide_descendants: frm => {
let show = frappe.boot.nested_set_doctypes.includes(frm.doc.allow);
frm.toggle_display('hide_descendants', show);
} }






+ 37
- 251
frappe/core/doctype/user_permission/user_permission.json View File

@@ -1,330 +1,116 @@
{ {
"allow_copy": 0,
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"actions": [],
"allow_import": 1, "allow_import": 1,
"allow_rename": 0,
"beta": 0,
"creation": "2017-07-17 14:25:27.881871", "creation": "2017-07-17 14:25:27.881871",
"custom": 0,
"docstatus": 0,
"doctype": "DocType", "doctype": "DocType",
"document_type": "",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [
"user",
"allow",
"column_break_3",
"for_value",
"is_default",
"advanced_control_section",
"apply_to_all_doctypes",
"applicable_for",
"column_break_9",
"hide_descendants"
],
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_if_empty": 0,
"fieldname": "user", "fieldname": "user",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "User", "label": "User",
"length": 0,
"no_copy": 0,
"options": "User", "options": "User",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1, "reqd": 1,
"search_index": 1,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"search_index": 1
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_if_empty": 0,
"fieldname": "allow", "fieldname": "allow",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Allow", "label": "Allow",
"length": 0,
"no_copy": 0,
"options": "DocType", "options": "DocType",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"reqd": 1
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_if_empty": 0,
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"fieldtype": "Column Break"
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_if_empty": 0,
"fieldname": "for_value", "fieldname": "for_value",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"hidden": 0,
"ignore_user_permissions": 1, "ignore_user_permissions": 1,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "For Value", "label": "For Value",
"length": 0,
"no_copy": 0,
"options": "allow", "options": "allow",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"reqd": 1
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_if_empty": 0,
"default": "0",
"fieldname": "is_default", "fieldname": "is_default",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Is Default",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Is Default"
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_if_empty": 0,
"fieldname": "advanced_control_section", "fieldname": "advanced_control_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Advanced Control",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Advanced Control"
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "1", "default": "1",
"fetch_if_empty": 0,
"fieldname": "apply_to_all_doctypes", "fieldname": "apply_to_all_doctypes",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Apply To All Document Types",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"label": "Apply To All Document Types"
}, },
{ {
"allow_bulk_edit": 0,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:!doc.apply_to_all_doctypes", "depends_on": "eval:!doc.apply_to_all_doctypes",
"fetch_if_empty": 0,
"fieldname": "applicable_for", "fieldname": "applicable_for",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Applicable For", "label": "Applicable For",
"length": 0,
"no_copy": 0,
"options": "DocType",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
"options": "DocType"
},
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Hide descendant records of <b>For Value</b>.",
"fieldname": "hide_descendants",
"fieldtype": "Check",
"hidden": 1,
"label": "Hide Descendants"
} }
], ],
"has_web_view": 0,
"hide_toolbar": 0,
"idx": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2019-04-16 19:17:23.644724",
"links": [],
"modified": "2021-01-21 18:14:10.839381",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "User Permission", "name": "User Permission",
"name_case": "",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0,
"cancel": 0,
"create": 1, "create": 1,
"delete": 1, "delete": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "System Manager", "role": "System Manager",
"set_user_permissions": 0,
"share": 1, "share": 1,
"submit": 0,
"write": 1 "write": 1
} }
], ],
"quick_entry": 0,
"read_only": 0,
"show_name_in_global_search": 0,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"title_field": "user", "title_field": "user",
"track_changes": 1,
"track_seen": 0,
"track_views": 0
"track_changes": 1
} }

+ 18
- 15
frappe/core/doctype/user_permission/user_permission.py View File

@@ -49,7 +49,8 @@ class UserPermission(Document):
'name': ['!=', self.name] 'name': ['!=', self.name]
}, or_filters={ }, or_filters={
'applicable_for': cstr(self.applicable_for), 'applicable_for': cstr(self.applicable_for),
'apply_to_all_doctypes': 1
'apply_to_all_doctypes': 1,
'hide_descendants': cstr(self.hide_descendants)
}, limit=1) }, limit=1)
if overlap_exists: if overlap_exists:
ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name) ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name)
@@ -91,13 +92,13 @@ def get_user_permissions(user=None):


try: try:
for perm in frappe.get_all('User Permission', for perm in frappe.get_all('User Permission',
fields=['allow', 'for_value', 'applicable_for', 'is_default'],
fields=['allow', 'for_value', 'applicable_for', 'is_default', 'hide_descendants'],
filters=dict(user=user)): filters=dict(user=user)):


meta = frappe.get_meta(perm.allow) meta = frappe.get_meta(perm.allow)
add_doc_to_perm(perm, perm.for_value, perm.is_default) add_doc_to_perm(perm, perm.for_value, perm.is_default)


if meta.is_nested_set():
if meta.is_nested_set() and not perm.hide_descendants:
decendants = frappe.db.get_descendants(perm.allow, perm.for_value) decendants = frappe.db.get_descendants(perm.allow, perm.for_value)
for doc in decendants: for doc in decendants:
add_doc_to_perm(perm, doc, False) add_doc_to_perm(perm, doc, False)
@@ -172,8 +173,8 @@ def check_applicable_doc_perm(user, doctype, docname):
"allow": doctype, "allow": doctype,
"for_value":docname, "for_value":docname,
}) })
for d in data:
applicable.append(d.applicable_for)
for permission in data:
applicable.append(permission.applicable_for)
return applicable return applicable




@@ -194,7 +195,8 @@ def add_user_permissions(data):
data = json.loads(data) data = json.loads(data)
data = frappe._dict(data) data = frappe._dict(data)


d = check_applicable_doc_perm(data.user, data.doctype, data.docname)
# get all doctypes on whom this permission is applied
perm_applied_docs = check_applicable_doc_perm(data.user, data.doctype, data.docname)
exists = frappe.db.exists("User Permission", { exists = frappe.db.exists("User Permission", {
"user": data.user, "user": data.user,
"allow": data.doctype, "allow": data.doctype,
@@ -202,26 +204,27 @@ def add_user_permissions(data):
"apply_to_all_doctypes": 1 "apply_to_all_doctypes": 1
}) })
if data.apply_to_all_doctypes == 1 and not exists: if data.apply_to_all_doctypes == 1 and not exists:
remove_applicable(d, data.user, data.doctype, data.docname)
insert_user_perm(data.user, data.doctype, data.docname, data.is_default, apply_to_all = 1)
remove_applicable(perm_applied_docs, data.user, data.doctype, data.docname)
insert_user_perm(data.user, data.doctype, data.docname, data.is_default, data.hide_descendants, apply_to_all=1)
return 1 return 1
elif len(data.applicable_doctypes) > 0 and data.apply_to_all_doctypes != 1: elif len(data.applicable_doctypes) > 0 and data.apply_to_all_doctypes != 1:
remove_apply_to_all(data.user, data.doctype, data.docname) remove_apply_to_all(data.user, data.doctype, data.docname)
update_applicable(d, data.applicable_doctypes, data.user, data.doctype, data.docname)
update_applicable(perm_applied_docs, data.applicable_doctypes, data.user, data.doctype, data.docname)
for applicable in data.applicable_doctypes : for applicable in data.applicable_doctypes :
if applicable not in d:
insert_user_perm(data.user, data.doctype, data.docname, data.is_default, applicable = applicable)
if applicable not in perm_applied_docs:
insert_user_perm(data.user, data.doctype, data.docname, data.is_default, data.hide_descendants, applicable=applicable)
elif exists: elif exists:
insert_user_perm(data.user, data.doctype, data.docname, data.is_default, applicable = applicable)
insert_user_perm(data.user, data.doctype, data.docname, data.is_default, data.hide_descendants, applicable=applicable)
return 1 return 1
return 0 return 0


def insert_user_perm(user, doctype, docname, is_default=0, apply_to_all=None, applicable=None):
def insert_user_perm(user, doctype, docname, is_default=0, hide_descendants=0, apply_to_all=None, applicable=None):
user_perm = frappe.new_doc("User Permission") user_perm = frappe.new_doc("User Permission")
user_perm.user = user user_perm.user = user
user_perm.allow = doctype user_perm.allow = doctype
user_perm.for_value = docname user_perm.for_value = docname
user_perm.is_default = is_default user_perm.is_default = is_default
user_perm.hide_descendants = hide_descendants
if applicable: if applicable:
user_perm.applicable_for = applicable user_perm.applicable_for = applicable
user_perm.apply_to_all_doctypes = 0 user_perm.apply_to_all_doctypes = 0
@@ -229,8 +232,8 @@ def insert_user_perm(user, doctype, docname, is_default=0, apply_to_all=None, ap
user_perm.apply_to_all_doctypes = 1 user_perm.apply_to_all_doctypes = 1
user_perm.insert() user_perm.insert()


def remove_applicable(d, user, doctype, docname):
for applicable_for in d:
def remove_applicable(perm_applied_docs, user, doctype, docname):
for applicable_for in perm_applied_docs:
frappe.db.sql("""DELETE FROM `tabUser Permission` frappe.db.sql("""DELETE FROM `tabUser Permission`
WHERE `user`=%s WHERE `user`=%s
AND `applicable_for`=%s AND `applicable_for`=%s


+ 24
- 1
frappe/core/doctype/user_permission/user_permission_list.js View File

@@ -19,6 +19,7 @@ frappe.listview_settings['User Permission'] = {
dialog.set_df_property("is_default", "hidden", 1); dialog.set_df_property("is_default", "hidden", 1);
dialog.set_df_property("apply_to_all_doctypes", "hidden", 1); dialog.set_df_property("apply_to_all_doctypes", "hidden", 1);
dialog.set_df_property("applicable_doctypes", "hidden", 1); dialog.set_df_property("applicable_doctypes", "hidden", 1);
dialog.set_df_property("hide_descendants", "hidden", 1);
} }
}, },
{ {
@@ -54,6 +55,10 @@ frappe.listview_settings['User Permission'] = {
} }
} }
}, },
{
fieldtype: "Section Break",
hide_border: 1
},
{ {
fieldname: 'is_default', fieldname: 'is_default',
label: __('Is Default'), label: __('Is Default'),
@@ -74,6 +79,19 @@ frappe.listview_settings['User Permission'] = {
} }
} }
}, },
{
fieldtype: "Column Break"
},
{
fieldname: 'hide_descendants',
label: __('Hide Descendants'),
fieldtype: 'Check',
hidden: 1
},
{
fieldtype: "Section Break",
hide_border: 1
},
{ {
label: __("Applicable Document Types"), label: __("Applicable Document Types"),
fieldname: "applicable_doctypes", fieldname: "applicable_doctypes",
@@ -214,6 +232,9 @@ frappe.listview_settings['User Permission'] = {
dialog.set_df_property("is_default", "hidden", 0); dialog.set_df_property("is_default", "hidden", 0);
dialog.set_df_property("apply_to_all_doctypes", "hidden", 0); dialog.set_df_property("apply_to_all_doctypes", "hidden", 0);
dialog.set_value("apply_to_all_doctypes", "checked", 1); dialog.set_value("apply_to_all_doctypes", "checked", 1);
let show = frappe.boot.nested_set_doctypes.includes(dialog.get_value("doctype"));
dialog.set_df_property("hide_descendants", "hidden", !show);
dialog.refresh();
}, },


on_docname_change: function(dialog, options, applicable) { on_docname_change: function(dialog, options, applicable) {
@@ -233,6 +254,7 @@ frappe.listview_settings['User Permission'] = {
dialog.set_df_property("applicable_doctypes", "options", options); dialog.set_df_property("applicable_doctypes", "options", options);
dialog.set_df_property("applicable_doctypes", "hidden", 1); dialog.set_df_property("applicable_doctypes", "hidden", 1);
} }
dialog.refresh();
}, },


on_apply_to_all_doctypes_change: function(dialog, options) { on_apply_to_all_doctypes_change: function(dialog, options) {
@@ -243,5 +265,6 @@ frappe.listview_settings['User Permission'] = {
dialog.set_df_property("applicable_doctypes", "options", options); dialog.set_df_property("applicable_doctypes", "options", options);
dialog.set_df_property("applicable_doctypes", "hidden", 1); dialog.set_df_property("applicable_doctypes", "hidden", 1);
} }
dialog.refresh_sections();
} }
};
};

+ 3
- 3
frappe/core/doctype/version/version_view.html View File

@@ -21,7 +21,7 @@
<td class="danger">{{ item[1] }}</td> <td class="danger">{{ item[1] }}</td>
<td class="success">{{ item[2] }}</td> <td class="success">{{ item[2] }}</td>
</tr> </tr>
{% endif %}
{% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
@@ -58,7 +58,7 @@
</table> </table>
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %}
</tbody> </tbody>
</table> </table>


@@ -93,4 +93,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
{% endif %} {% endif %}
</div>
</div>

+ 1
- 1
frappe/core/page/permission_manager/permission_manager.js View File

@@ -269,7 +269,7 @@ frappe.PermissionEngine = Class.extend({
.css({"margin-top": "15px"}); .css({"margin-top": "15px"});
}, },


rights: ["read", "write", "create", "delete", "submit", "cancel", "amend",
rights: ["select", "read", "write", "create", "delete", "submit", "cancel", "amend",
"print", "email", "report", "import", "export", "set_user_permissions", "share"], "print", "email", "report", "import", "export", "set_user_permissions", "share"],


set_show_users: function(cell, role) { set_show_users: function(cell, role) {


+ 13
- 1
frappe/core/page/permission_manager/permission_manager.py View File

@@ -77,6 +77,18 @@ def add(parent, role, permlevel):


@frappe.whitelist() @frappe.whitelist()
def update(doctype, role, permlevel, ptype, value=None): def update(doctype, role, permlevel, ptype, value=None):
"""Update role permission params
Args:
doctype (str): Name of the DocType to update params for
role (str): Role to be updated for, eg "Website Manager".
permlevel (int): perm level the provided rule applies to
ptype (str): permission type, example "read", "delete", etc.
value (None, optional): value for ptype, None indicates False
Returns:
str: Refresh flag is permission is updated successfully
"""
frappe.only_for("System Manager") frappe.only_for("System Manager")
out = update_permission_property(doctype, role, permlevel, ptype, value) out = update_permission_property(doctype, role, permlevel, ptype, value)
return 'refresh' if out else None return 'refresh' if out else None
@@ -92,7 +104,7 @@ def remove(doctype, role, permlevel):
if not frappe.get_all('Custom DocPerm', dict(parent=doctype)): if not frappe.get_all('Custom DocPerm', dict(parent=doctype)):
frappe.throw(_('There must be atleast one permission rule.'), title=_('Cannot Remove')) frappe.throw(_('There must be atleast one permission rule.'), title=_('Cannot Remove'))


validate_permissions_for_doctype(doctype, for_remove=True)
validate_permissions_for_doctype(doctype, for_remove=True, alert=True)


@frappe.whitelist() @frappe.whitelist()
def reset(doctype): def reset(doctype):


+ 9
- 5
frappe/custom/doctype/customize_form/customize_form.py View File

@@ -455,11 +455,15 @@ class CustomizeForm(Document):
self.fetch_to_customize() self.fetch_to_customize()


def reset_customization(doctype): def reset_customization(doctype):
frappe.db.sql("""
DELETE FROM `tabProperty Setter` WHERE doc_type=%s
and `field_name`!='naming_series'
and `property`!='options'
""", doctype)
setters = frappe.get_all("Property Setter", filters={
'doc_type': doctype,
'field_name': ['!=', 'naming_series'],
'property': ['!=', 'options']
}, pluck='name')

for setter in setters:
frappe.delete_doc("Property Setter", setter)

frappe.clear_cache(doctype=doctype) frappe.clear_cache(doctype=doctype)


doctype_properties = { doctype_properties = {


+ 6
- 0
frappe/database/database.py View File

@@ -746,6 +746,9 @@ class Database(object):


def commit(self): def commit(self):
"""Commit current transaction. Calls SQL `COMMIT`.""" """Commit current transaction. Calls SQL `COMMIT`."""
for method in frappe.local.before_commit:
frappe.call(method[0], *(method[1] or []), **(method[2] or {}))

self.sql("commit") self.sql("commit")


frappe.local.rollback_observers = [] frappe.local.rollback_observers = []
@@ -753,6 +756,9 @@ class Database(object):
enqueue_jobs_after_commit() enqueue_jobs_after_commit()
flush_local_link_count() flush_local_link_count()


def add_before_commit(self, method, args=None, kwargs=None):
frappe.local.before_commit.append([method, args, kwargs])

@staticmethod @staticmethod
def flush_realtime_log(): def flush_realtime_log():
for args in frappe.local.realtime_log: for args in frappe.local.realtime_log:


+ 1
- 0
frappe/desk/calendar.py View File

@@ -29,6 +29,7 @@ def get_event_conditions(doctype, filters=None):
def get_events(doctype, start, end, field_map, filters=None, fields=None): def get_events(doctype, start, end, field_map, filters=None, fields=None):


field_map = frappe._dict(json.loads(field_map)) field_map = frappe._dict(json.loads(field_map))
fields = frappe.parse_json(fields)


doc_meta = frappe.get_meta(doctype) doc_meta = frappe.get_meta(doctype)
for d in doc_meta.fields: for d in doc_meta.fields:


+ 12
- 3
frappe/desk/desktop.py View File

@@ -108,9 +108,18 @@ class Workspace:
'extends': self.page_name, 'extends': self.page_name,
'for_user': frappe.session.user 'for_user': frappe.session.user
} }
pages = frappe.get_all("Desk Page", filters=filters, limit=1)
if pages:
return frappe.get_cached_doc("Desk Page", pages[0])
user_pages = frappe.get_all("Desk Page", filters=filters, limit=1)
if user_pages:
return frappe.get_cached_doc("Desk Page", user_pages[0])

filters = {
'extends_another_page': 1,
'extends': self.page_name,
'is_default': 1
}
default_page = frappe.get_all("Desk Page", filters=filters, limit=1)
if default_page:
return frappe.get_cached_doc("Desk Page", default_page[0])


self.get_pages_to_extend() self.get_pages_to_extend()
return frappe.get_cached_doc("Desk Page", self.page_name) return frappe.get_cached_doc("Desk Page", self.page_name)


+ 1
- 1
frappe/desk/doctype/dashboard_chart/dashboard_chart.py View File

@@ -73,7 +73,7 @@ def has_permission(doc, ptype, user):
if doc.report_name in allowed_reports: if doc.report_name in allowed_reports:
return True return True
else: else:
allowed_doctypes = [frappe.permissions.get_doctypes_with_read()]
allowed_doctypes = frappe.permissions.get_doctypes_with_read()
if doc.document_type in allowed_doctypes: if doc.document_type in allowed_doctypes:
return True return True




+ 0
- 1
frappe/desk/doctype/desk_page/desk_page.js View File

@@ -5,7 +5,6 @@ frappe.ui.form.on('Desk Page', {
refresh: function(frm) { refresh: function(frm) {
frm.enable_save(); frm.enable_save();
frm.get_field("is_standard").toggle(frappe.boot.developer_mode); frm.get_field("is_standard").toggle(frappe.boot.developer_mode);
frm.get_field("extends_another_page").toggle(frappe.boot.developer_mode);
frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode); frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode);


if (frm.doc.for_user) { if (frm.doc.for_user) {


+ 10
- 1
frappe/desk/doctype/desk_page/desk_page.json View File

@@ -16,6 +16,7 @@
"onboarding", "onboarding",
"column_break_3", "column_break_3",
"extends_another_page", "extends_another_page",
"is_default",
"is_standard", "is_standard",
"developer_mode_only", "developer_mode_only",
"disable_user_customization", "disable_user_customization",
@@ -197,10 +198,18 @@
"fieldname": "hide_custom", "fieldname": "hide_custom",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Hide Custom DocTypes and Reports" "label": "Hide Custom DocTypes and Reports"
},
{
"default": "0",
"depends_on": "extends_another_page",
"description": "Sets the current page as default for all users",
"fieldname": "is_default",
"fieldtype": "Check",
"label": "Is Default"
} }
], ],
"links": [], "links": [],
"modified": "2020-05-18 19:17:27.206646",
"modified": "2021-01-21 12:09:36.156614",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Desk Page", "name": "Desk Page",


+ 6
- 1
frappe/desk/doctype/desk_page/desk_page.py View File

@@ -15,6 +15,11 @@ class DeskPage(Document):
if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()): if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()):
frappe.throw(_("You need to be in developer mode to edit this document")) frappe.throw(_("You need to be in developer mode to edit this document"))


if self.is_default and self.name and frappe.db.exists("Desk Page", {
"name": ["!=", self.name], 'is_default': 1, 'extends': self.extends
}):
frappe.throw(_("You can only have one default page that extends a particular standard page."))

def validate_cards_json(self): def validate_cards_json(self):
for card in self.cards: for card in self.cards:
try: try:
@@ -45,4 +50,4 @@ def disable_saving_as_standard():
frappe.flags.in_patch or \ frappe.flags.in_patch or \
frappe.flags.in_test or \ frappe.flags.in_test or \
frappe.flags.in_fixtures or \ frappe.flags.in_fixtures or \
frappe.flags.in_migrate
frappe.flags.in_migrate

+ 0
- 1
frappe/desk/form/save.py View File

@@ -42,7 +42,6 @@ def cancel(doctype=None, name=None, workflow_state_fieldname=None, workflow_stat


except Exception: except Exception:
frappe.errprint(frappe.utils.get_traceback()) frappe.errprint(frappe.utils.get_traceback())
frappe.msgprint(frappe._("Did not cancel"))
raise raise


def send_updated_docs(doc): def send_updated_docs(doc):


+ 2
- 2
frappe/desk/page/setup_wizard/install_fixtures.py View File

@@ -18,14 +18,14 @@ def install():


@frappe.whitelist() @frappe.whitelist()
def update_genders(): def update_genders():
default_genders = [_("Male"), _("Female"), _("Other"),_("Transgender"), _("Genderqueer"), _("Non-Conforming"),_("Prefer not to say")]
default_genders = ["Male", "Female", "Other","Transgender", "Genderqueer", "Non-Conforming","Prefer not to say"]
records = [{'doctype': 'Gender', 'gender': d} for d in default_genders] records = [{'doctype': 'Gender', 'gender': d} for d in default_genders]
for record in records: for record in records:
frappe.get_doc(record).insert(ignore_permissions=True, ignore_if_duplicate=True) frappe.get_doc(record).insert(ignore_permissions=True, ignore_if_duplicate=True)


@frappe.whitelist() @frappe.whitelist()
def update_salutations(): def update_salutations():
default_salutations = [_("Mr"), _("Ms"), _('Mx'), _("Dr"), _("Mrs"), _("Madam"), _("Miss"), _("Master"), _("Prof")]
default_salutations = ["Mr", "Ms", 'Mx', "Dr", "Mrs", "Madam", "Miss", "Master", "Prof"]
records = [{'doctype': 'Salutation', 'salutation': d} for d in default_salutations] records = [{'doctype': 'Salutation', 'salutation': d} for d in default_salutations]
for record in records: for record in records:
doc = frappe.new_doc(record.get("doctype")) doc = frappe.new_doc(record.get("doctype"))


+ 1
- 0
frappe/desk/page/user_profile/user_profile.js View File

@@ -76,6 +76,7 @@ class UserProfile {
fieldname: 'user', fieldname: 'user',
options: 'User', options: 'User',
label: __('User'), label: __('User'),
reqd: 1
} }
], ],
primary_action_label: __('Go'), primary_action_label: __('Go'),


+ 26
- 7
frappe/desk/reportview.py View File

@@ -54,6 +54,12 @@ def get_form_params():


fields = data["fields"] fields = data["fields"]


if ((isinstance(fields, string_types) and fields == "*")
or (isinstance(fields, (list, tuple)) and len(fields) == 1 and fields[0] == "*")):
parenttype = data.doctype
data["fields"] = frappe.db.get_table_columns(parenttype)
fields = data["fields"]

for field in fields: for field in fields:
key = field.split(" as ")[0] key = field.split(" as ")[0]


@@ -61,21 +67,24 @@ def get_form_params():
if key.startswith('sum('): continue if key.startswith('sum('): continue
if key.startswith('avg('): continue if key.startswith('avg('): continue


if "." in key:
parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`")
else:
parenttype = data.doctype
fieldname = field.strip("`")
parenttype, fieldname = get_parent_dt_and_field(key, data)


df = frappe.get_meta(parenttype).get_field(fieldname)
if fieldname == "*":
# * inside list is not allowed with other fields
fields.remove(field)

meta = frappe.get_meta(parenttype)
df = meta.get_field(fieldname)


fieldname = df.fieldname if df else None
report_hide = df.report_hide if df else None report_hide = df.report_hide if df else None


# remove the field from the query if the report hide flag is set and current view is Report # remove the field from the query if the report hide flag is set and current view is Report
if report_hide and is_report: if report_hide and is_report:
fields.remove(field) fields.remove(field)


if df and fieldname in [df.fieldname for df in meta.get_high_permlevel_fields()]:
if df.get('permlevel') not in meta.get_permlevel_access(parenttype=data.doctype) and field in fields:
fields.remove(field)


# queries must always be server side # queries must always be server side
data.query = None data.query = None
@@ -83,6 +92,16 @@ def get_form_params():


return data return data


def get_parent_dt_and_field(field, data):
if "." in field:
parenttype, fieldname = field.split(".")[0][4:-1], field.split(".")[1].strip("`")
else:
parenttype = data.doctype
fieldname = field.strip("`")

return parenttype, fieldname


def compress(data, args = {}): def compress(data, args = {}):
"""separate keys and values""" """separate keys and values"""
from frappe.desk.query_report import add_total_row from frappe.desk.query_report import add_total_row


+ 2
- 1
frappe/desk/search.py View File

@@ -150,7 +150,8 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
# 2 is the index of _relevance column # 2 is the index of _relevance column
order_by = "_relevance, {0}, `tab{1}`.idx desc".format(order_by_based_on_meta, doctype) order_by = "_relevance, {0}, `tab{1}`.idx desc".format(order_by_based_on_meta, doctype)


ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype))
ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read'
ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))


if doctype in UNTRANSLATED_DOCTYPES: if doctype in UNTRANSLATED_DOCTYPES:
page_length = None page_length = None


+ 10
- 1
frappe/email/doctype/auto_email_report/auto_email_report.py View File

@@ -81,7 +81,7 @@ class AutoEmailReport(Document):


if self.format == 'HTML': if self.format == 'HTML':
columns, data = make_links(columns, data) columns, data = make_links(columns, data)
columns = update_field_types(columns)
return self.get_html_table(columns, data) return self.get_html_table(columns, data)


elif self.format == 'XLSX': elif self.format == 'XLSX':
@@ -236,5 +236,14 @@ 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":
row[col.fieldname] = frappe.format_value(row[col.fieldname], col)


return columns, data return columns, data

def update_field_types(columns):
for col in columns:
if col.fieldtype in ("Link", "Dynamic Link", "Currency") and col.options != "Currency":
col.fieldtype = "Data"
col.options = ""
return columns

+ 1
- 1
frappe/email/doctype/email_account/email_account.py View File

@@ -210,7 +210,7 @@ class EmailAccount(Document):
elif not in_receive and any(map(lambda t: t in message, auth_error_codes)): elif not in_receive and any(map(lambda t: t in message, auth_error_codes)):
self.throw_invalid_credentials_exception() self.throw_invalid_credentials_exception()
else: else:
frappe.throw(e)
frappe.throw(cstr(e))


except socket.error: except socket.error:
if in_receive: if in_receive:


+ 38
- 31
frappe/email/doctype/newsletter/test_newsletter.py View File

@@ -2,58 +2,66 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals from __future__ import unicode_literals


import frappe, unittest
from frappe.utils import getdate, add_days

from frappe.email.doctype.newsletter.newsletter import confirmed_unsubscribe, send_scheduled_email
from six.moves.urllib.parse import unquote
import unittest
from random import choice

import frappe
from frappe.email.doctype.newsletter.newsletter import (
confirmed_unsubscribe,
send_scheduled_email,
)
from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
from frappe.email.queue import flush
from frappe.utils import add_days, getdate


test_dependencies = ["Email Group"] test_dependencies = ["Email Group"]
emails = [
"test_subscriber1@example.com",
"test_subscriber2@example.com",
"test_subscriber3@example.com",
"test1@example.com",
]


emails = ["test_subscriber1@example.com", "test_subscriber2@example.com",
"test_subscriber3@example.com", "test1@example.com"]


class TestNewsletter(unittest.TestCase): class TestNewsletter(unittest.TestCase):
def setUp(self): def setUp(self):
frappe.set_user("Administrator") frappe.set_user("Administrator")
frappe.db.sql('delete from `tabEmail Group Member`')
frappe.db.sql("delete from `tabEmail Group Member`")

if not frappe.db.exists("Email Group", "_Test Email Group"):
frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert()


group_exist=frappe.db.exists("Email Group", "_Test Email Group")
if len(group_exist) == 0:
for email in emails:
frappe.get_doc({ frappe.get_doc({
"doctype": "Email Group",
"title": "_Test Email Group"
"doctype": "Email Group Member",
"email": email,
"email_group": "_Test Email Group"
}).insert() }).insert()
for email in emails:
frappe.get_doc({
"doctype": "Email Group Member",
"email": email,
"email_group": "_Test Email Group"
}).insert()


def test_send(self): def test_send(self):
name = self.send_newsletter()
self.send_newsletter()


email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")]
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4) self.assertEqual(len(email_queue_list), 4)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
self.assertTrue(email in recipients)
recipients = set([e.recipients[0].recipient for e in email_queue_list])
self.assertTrue(set(emails).issubset(recipients))


def test_unsubscribe(self): def test_unsubscribe(self):
# test unsubscribe
name = self.send_newsletter() name = self.send_newsletter()
from frappe.email.queue import flush
to_unsubscribe = choice(emails)
group = frappe.get_all("Newsletter Email Group", filters={"parent": name}, fields=["email_group"])

flush(from_test=True) flush(from_test=True)
to_unsubscribe = unquote(frappe.local.flags.signed_query_string.split("email=")[1].split("&")[0])
group = frappe.get_all("Newsletter Email Group", filters={"parent" : name}, fields=["email_group"])
confirmed_unsubscribe(to_unsubscribe, group[0].email_group) confirmed_unsubscribe(to_unsubscribe, group[0].email_group)


name = self.send_newsletter() name = self.send_newsletter()

email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")]
email_queue_list = [
frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")
]
self.assertEqual(len(email_queue_list), 3) self.assertEqual(len(email_queue_list), 3)
recipients = [e.recipients[0].recipient for e in email_queue_list] recipients = [e.recipients[0].recipient for e in email_queue_list]

for email in emails: for email in emails:
if email != to_unsubscribe: if email != to_unsubscribe:
self.assertTrue(email in recipients) self.assertTrue(email in recipients)
@@ -86,7 +94,6 @@ class TestNewsletter(unittest.TestCase):
def test_portal(self): def test_portal(self):
self.send_newsletter(1) self.send_newsletter(1)
frappe.set_user("test1@example.com") frappe.set_user("test1@example.com")
from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
newsletters = get_newsletter_list("Newsletter", None, None, 0) newsletters = get_newsletter_list("Newsletter", None, None, 0)
self.assertEqual(len(newsletters), 1) self.assertEqual(len(newsletters), 1)


@@ -106,4 +113,4 @@ class TestNewsletter(unittest.TestCase):
self.assertEqual(len(email_queue_list), 4) self.assertEqual(len(email_queue_list), 4)
recipients = [e.recipients[0].recipient for e in email_queue_list] recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails: for email in emails:
self.assertTrue(email in recipients)
self.assertTrue(email in recipients)

+ 12
- 2
frappe/event_streaming/doctype/event_producer/event_producer.py View File

@@ -295,7 +295,7 @@ def set_update(update, producer_site):
if data.changed: if data.changed:
local_doc.update(data.changed) local_doc.update(data.changed)
if data.removed: if data.removed:
update_row_removed(local_doc, data.removed)
local_doc = update_row_removed(local_doc, data.removed)
if data.row_changed: if data.row_changed:
update_row_changed(local_doc, data.row_changed) update_row_changed(local_doc, data.row_changed)
if data.added: if data.added:
@@ -318,7 +318,17 @@ def update_row_removed(local_doc, removed):
for tablename, rownames in iteritems(removed): for tablename, rownames in iteritems(removed):
table = local_doc.get_table_field_doctype(tablename) table = local_doc.get_table_field_doctype(tablename)
for row in rownames: for row in rownames:
frappe.db.delete(table, row)
table_rows = local_doc.get(tablename)
child_table_row = get_child_table_row(table_rows, row)
table_rows.remove(child_table_row)
local_doc.set(tablename, table_rows)
return local_doc


def get_child_table_row(table_rows, row):
for entry in table_rows:
if entry.get('name') == row:
return entry




def update_row_changed(local_doc, changed): def update_row_changed(local_doc, changed):


+ 96
- 0
frappe/geo/utils.py View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals

import frappe

from pymysql import InternalError


@frappe.whitelist()
def get_coords(doctype, filters, type):
'''Get a geojson dict representing a doctype.'''
filters_sql = get_coords_conditions(doctype, filters)[4:]

coords = None
if type == 'location_field':
coords = return_location(doctype, filters_sql)
elif type == 'coordinates':
coords = return_coordinates(doctype, filters_sql)

out = convert_to_geojson(type, coords)
return out

def convert_to_geojson(type, coords):
'''Converts GPS coordinates to geoJSON string.'''
geojson = {"type": "FeatureCollection", "features": None}

if type == 'location_field':
geojson['features'] = merge_location_features_in_one(coords)
elif type == 'coordinates':
geojson['features'] = create_gps_markers(coords)

return geojson


def merge_location_features_in_one(coords):
'''Merging all features from location field.'''
geojson_dict = []
for element in coords:
geojson_loc = frappe.parse_json(element['location'])
if not geojson_loc:
continue
for coord in geojson_loc['features']:
coord['properties']['name'] = element['name']
geojson_dict.append(coord.copy())

return geojson_dict


def create_gps_markers(coords):
'''Build Marker based on latitude and longitude.'''
geojson_dict = []
for i in coords:
node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}}
node['properties']['name'] = i.name
node['geometry']['coordinates'] = [i.latitude, i.longitude]
geojson_dict.append(node.copy())

return geojson_dict


def return_location(doctype, filters_sql):
'''Get name and location fields for Doctype.'''
if filters_sql:
try:
coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True)
except InternalError:
frappe.msgprint(frappe._('This Doctype does not contain location fields'), raise_exception=True)
return
else:
coords = frappe.get_all(doctype, fields=['name', 'location'])
return coords


def return_coordinates(doctype, filters_sql):
'''Get name, latitude and longitude fields for Doctype.'''
if filters_sql:
try:
coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True)
except InternalError:
frappe.msgprint(frappe._('This Doctype does not contain latitude and longitude fields'), raise_exception=True)
return
else:
coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude'])
return coords


def get_coords_conditions(doctype, filters=None):
'''Returns SQL conditions with user permissions and filters for event queries.'''
from frappe.desk.reportview import get_filters_cond
if not frappe.has_permission(doctype):
frappe.throw(frappe._("Not Permitted"), frappe.PermissionError)

return get_filters_cond(doctype, filters, [], with_match_conditions=True)

+ 1
- 1
frappe/hooks.py View File

@@ -18,7 +18,7 @@ app_email = "info@frappe.io"


docs_app = "frappe_io" docs_app = "frappe_io"


translator_url = "https://translatev2.erpnext.com"
translator_url = "https://translate.erpnext.com"


before_install = "frappe.utils.install.before_install" before_install = "frappe.utils.install.before_install"
after_install = "frappe.utils.install.after_install" after_install = "frappe.utils.install.after_install"


+ 1
- 1
frappe/integrations/desk_page/integrations/integrations.json View File

@@ -13,7 +13,7 @@
{ {
"hidden": 0, "hidden": 0,
"label": "Authentication", "label": "Authentication",
"links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n]"
"links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n ,\n {\n \"description\": \"Connect to any OAuth Provider\",\n \"label\": \"Connected App\",\n \"name\": \"Connected App\",\n \"type\": \"doctype\"\n }\n]"
}, },
{ {
"hidden": 0, "hidden": 0,


+ 0
- 0
frappe/integrations/doctype/connected_app/__init__.py View File


+ 38
- 0
frappe/integrations/doctype/connected_app/connected_app.js View File

@@ -0,0 +1,38 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Connected App', {
refresh: frm => {
frm.add_custom_button(__('Get OpenID Configuration'), async () => {
if (!frm.doc.openid_configuration) {
frappe.msgprint(__('Please enter OpenID Configuration URL'));
} else {
try {
const response = await fetch(frm.doc.openid_configuration);
const oidc = await response.json();
frm.set_value('authorization_uri', oidc.authorization_endpoint);
frm.set_value('token_uri', oidc.token_endpoint);
frm.set_value('userinfo_uri', oidc.userinfo_endpoint);
frm.set_value('introspection_uri', oidc.introspection_endpoint);
frm.set_value('revocation_uri', oidc.revocation_endpoint);
} catch (error) {
frappe.msgprint(__('Please check OpenID Configuration URL'));
}
}
});

if (!frm.is_new()) {
frm.add_custom_button(__('Connect to {}', [frm.doc.provider_name]), async () => {
frappe.call({
method: 'initiate_web_application_flow',
doc: frm.doc,
callback: function(r) {
window.open(r.message, '_blank');
}
});
});
}

frm.toggle_display('sb_client_credentials_section', !frm.is_new());
}
});

+ 166
- 0
frappe/integrations/doctype/connected_app/connected_app.json View File

@@ -0,0 +1,166 @@
{
"actions": [],
"beta": 1,
"creation": "2019-01-24 15:51:06.362222",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"provider_name",
"cb_00",
"openid_configuration",
"sb_client_credentials_section",
"client_id",
"redirect_uri",
"cb_01",
"client_secret",
"sb_scope_section",
"scopes",
"sb_endpoints_section",
"authorization_uri",
"token_uri",
"revocation_uri",
"cb_02",
"userinfo_uri",
"introspection_uri",
"section_break_18",
"query_parameters"
],
"fields": [
{
"fieldname": "provider_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Provider Name",
"reqd": 1
},
{
"fieldname": "cb_00",
"fieldtype": "Column Break"
},
{
"fieldname": "openid_configuration",
"fieldtype": "Data",
"label": "OpenID Configuration"
},
{
"collapsible": 1,
"fieldname": "sb_client_credentials_section",
"fieldtype": "Section Break",
"label": "Client Credentials"
},
{
"fieldname": "client_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Client Id"
},
{
"fieldname": "redirect_uri",
"fieldtype": "Data",
"label": "Redirect URI",
"read_only": 1
},
{
"fieldname": "cb_01",
"fieldtype": "Column Break"
},
{
"fieldname": "client_secret",
"fieldtype": "Password",
"label": "Client Secret"
},
{
"collapsible": 1,
"fieldname": "sb_scope_section",
"fieldtype": "Section Break",
"label": "Scopes"
},
{
"collapsible": 1,
"fieldname": "sb_endpoints_section",
"fieldtype": "Section Break",
"label": "Endpoints"
},
{
"fieldname": "cb_02",
"fieldtype": "Column Break"
},
{
"fieldname": "scopes",
"fieldtype": "Table",
"label": "Scopes",
"options": "OAuth Scope"
},
{
"fieldname": "authorization_uri",
"fieldtype": "Data",
"label": "Authorization URI"
},
{
"fieldname": "token_uri",
"fieldtype": "Data",
"label": "Token URI"
},
{
"fieldname": "revocation_uri",
"fieldtype": "Data",
"label": "Revocation URI"
},
{
"fieldname": "userinfo_uri",
"fieldtype": "Data",
"label": "Userinfo URI"
},
{
"fieldname": "introspection_uri",
"fieldtype": "Data",
"label": "Introspection URI"
},
{
"fieldname": "section_break_18",
"fieldtype": "Section Break",
"label": "Extra Parameters"
},
{
"fieldname": "query_parameters",
"fieldtype": "Table",
"label": "Query Parameters",
"options": "Query Parameters"
}
],
"links": [
{
"link_doctype": "Token Cache",
"link_fieldname": "connected_app"
}
],
"modified": "2020-11-16 16:29:50.277405",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Connected App",
"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",
"title_field": "provider_name",
"track_changes": 1
}

+ 133
- 0
frappe/integrations/doctype/connected_app/connected_app.py View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt

import os
from urllib.parse import urljoin
from urllib.parse import urlencode

import frappe
from frappe import _
from frappe.model.document import Document
from requests_oauthlib import OAuth2Session

if any((os.getenv('CI'), frappe.conf.developer_mode, frappe.conf.allow_tests)):
# Disable mandatory TLS in developer mode and tests
os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1'

class ConnectedApp(Document):
"""Connect to a remote oAuth Server. Retrieve and store user's access token
in a Token Cache.
"""

def validate(self):
base_url = frappe.utils.get_url()
callback_path = '/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/' + self.name
self.redirect_uri = urljoin(base_url, callback_path)

def get_oauth2_session(self, user=None, init=False):
token = None
token_updater = None

if not init:
user = user or frappe.session.user
token_cache = self.get_user_token(user)
token = token_cache.get_json()
token_updater = token_cache.update_data

return OAuth2Session(
client_id=self.client_id,
token=token,
token_updater=token_updater,
auto_refresh_url=self.token_uri,
redirect_uri=self.redirect_uri,
scope=self.get_scopes()
)

def initiate_web_application_flow(self, user=None, success_uri=None):
"""Return an authorization URL for the user. Save state in Token Cache."""
user = user or frappe.session.user
oauth = self.get_oauth2_session(init=True)
query_params = self.get_query_params()
authorization_url, state = oauth.authorization_url(self.authorization_uri, **query_params)
token_cache = self.get_token_cache(user)

if not token_cache:
token_cache = frappe.new_doc('Token Cache')
token_cache.user = user
token_cache.connected_app = self.name

token_cache.success_uri = success_uri
token_cache.state = state
token_cache.save(ignore_permissions=True)
frappe.db.commit()

return authorization_url

def get_user_token(self, user=None, success_uri=None):
"""Return an existing user token or initiate a Web Application Flow."""
user = user or frappe.session.user
token_cache = self.get_token_cache(user)

if token_cache:
return token_cache

redirect = self.initiate_web_application_flow(user, success_uri)
frappe.local.response['type'] = 'redirect'
frappe.local.response['location'] = redirect
return redirect

def get_token_cache(self, user):
token_cache = None
token_cache_name = self.name + '-' + user

if frappe.db.exists('Token Cache', token_cache_name):
token_cache = frappe.get_doc('Token Cache', token_cache_name)

return token_cache

def get_scopes(self):
return [row.scope for row in self.scopes]

def get_query_params(self):
return {param.key: param.value for param in self.query_parameters}


@frappe.whitelist(allow_guest=True)
def callback(code=None, state=None):
"""Handle client's code.

Called during the oauthorization flow by the remote oAuth2 server to
transmit a code that can be used by the local server to obtain an access
token.
"""
if frappe.request.method != 'GET':
frappe.throw(_('Invalid request method: {}').format(frappe.request.method))

if frappe.session.user == 'Guest':
frappe.local.response['type'] = 'redirect'
frappe.local.response['location'] = '/login?' + urlencode({'redirect-to': frappe.request.url})
return

path = frappe.request.path[1:].split('/')
if len(path) != 4 or not path[3]:
frappe.throw(_('Invalid Parameters.'))

connected_app = frappe.get_doc('Connected App', path[3])
token_cache = frappe.get_doc('Token Cache', connected_app.name + '-' + frappe.session.user)

if state != token_cache.state:
frappe.throw(_('Invalid state.'))

oauth_session = connected_app.get_oauth2_session(init=True)
query_params = connected_app.get_query_params()
token = oauth_session.fetch_token(connected_app.token_uri,
code=code,
client_secret=connected_app.get_password('client_secret'),
include_client_id=True,
**query_params
)
token_cache.update_data(token)

frappe.local.response['type'] = 'redirect'
frappe.local.response['location'] = token_cache.get('success_uri') or connected_app.get_url()

+ 162
- 0
frappe/integrations/doctype/connected_app/test_connected_app.py View File

@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# See license.txt
from __future__ import unicode_literals

import unittest
import requests
from urllib.parse import urljoin

import frappe
from frappe.integrations.doctype.social_login_key.test_social_login_key import create_or_update_social_login_key


def get_user(usr, pwd):
user = frappe.new_doc('User')
user.email = usr
user.enabled = 1
user.first_name = "_Test"
user.new_password = pwd
user.roles = []
user.append('roles', {
'doctype': 'Has Role',
'parentfield': 'roles',
'role': 'System Manager'
})
user.insert()

return user


def get_connected_app():
doctype = 'Connected App'
connected_app = frappe.new_doc(doctype)
connected_app.provider_name = 'frappe'
connected_app.scopes = []
connected_app.append('scopes', {'scope': 'all'})
connected_app.insert()

return connected_app


def get_oauth_client():
oauth_client = frappe.new_doc('OAuth Client')
oauth_client.app_name = '_Test Connected App'
oauth_client.redirect_uris = 'to be replaced'
oauth_client.default_redirect_uri = 'to be replaced'
oauth_client.grant_type = 'Authorization Code'
oauth_client.response_type = 'Code'
oauth_client.skip_authorization = 1
oauth_client.insert()

return oauth_client


class TestConnectedApp(unittest.TestCase):

def setUp(self):
"""Set up a Connected App that connects to our own oAuth provider.

Frappe comes with it's own oAuth2 provider that we can test against. The
client credentials can be obtained from an "OAuth Client". All depends
on "Social Login Key" so we create one as well.

The redirect URIs from "Connected App" and "OAuth Client" have to match.
Frappe's "Authorization URL" and "Access Token URL" (actually they're
just endpoints) are stored in "Social Login Key" so we get them from
there.
"""
self.user_name = 'test-connected-app@example.com'
self.user_password = 'Eastern_43A1W'

self.user = get_user(self.user_name, self.user_password)
self.connected_app = get_connected_app()
self.oauth_client = get_oauth_client()
social_login_key = create_or_update_social_login_key()
self.base_url = social_login_key.get('base_url')

frappe.db.commit()
self.connected_app.reload()
self.oauth_client.reload()

redirect_uri = self.connected_app.get('redirect_uri')
self.oauth_client.update({
'redirect_uris': redirect_uri,
'default_redirect_uri': redirect_uri
})
self.oauth_client.save()

self.connected_app.update({
'authorization_uri': urljoin(self.base_url, social_login_key.get('authorize_url')),
'client_id': self.oauth_client.get('client_id'),
'client_secret': self.oauth_client.get('client_secret'),
'token_uri': urljoin(self.base_url, social_login_key.get('access_token_url'))
})
self.connected_app.save()

frappe.db.commit()
self.connected_app.reload()
self.oauth_client.reload()

def test_web_application_flow(self):
"""Simulate a logged in user who opens the authorization URL."""
def login():
return session.get(urljoin(self.base_url, '/api/method/login'), params={
'usr': self.user_name,
'pwd': self.user_password
})

session = requests.Session()

# first login of a new user on a new site fails with "401 UNAUTHORIZED"
# when anybody fixes that, the two lines below can be removed
first_login = login()
self.assertEqual(first_login.status_code, 401)

second_login = login()
self.assertEqual(second_login.status_code, 200)

authorization_url = self.connected_app.initiate_web_application_flow(user=self.user_name)

auth_response = session.get(authorization_url)
self.assertEqual(auth_response.status_code, 200)

callback_response = session.get(auth_response.url)
self.assertEqual(callback_response.status_code, 200)

self.token_cache = self.connected_app.get_token_cache(self.user_name)
token = self.token_cache.get_password('access_token')
self.assertNotEqual(token, None)

oauth2_session = self.connected_app.get_oauth2_session(self.user_name)
resp = oauth2_session.get(urljoin(self.base_url, '/api/method/frappe.auth.get_logged_user'))
self.assertEqual(resp.json().get('message'), self.user_name)

def tearDown(self):
def delete_if_exists(attribute):
doc = getattr(self, attribute, None)
if doc:
doc.delete()

delete_if_exists('token_cache')
delete_if_exists('connected_app')

if getattr(self, 'oauth_client', None):
tokens = frappe.get_all('OAuth Bearer Token', filters={
'client': self.oauth_client.name
})
for token in tokens:
doc = frappe.get_doc('OAuth Bearer Token', token.name)
doc.delete()

codes = frappe.get_all('OAuth Authorization Code', filters={
'client': self.oauth_client.name
})
for code in codes:
doc = frappe.get_doc('OAuth Authorization Code', code.name)
doc.delete()

delete_if_exists('user')
delete_if_exists('oauth_client')

frappe.db.commit()

+ 13
- 0
frappe/integrations/doctype/connected_app/test_records.json View File

@@ -0,0 +1,13 @@
[
{
"doctype": "Connected App",
"provider_name": "frappe",
"client_id": "test_client_id",
"client_secret": "test_client_secret",
"scopes": [
{
"scope": "all"
}
]
}
]

+ 1
- 2
frappe/integrations/doctype/oauth_client/test_records.json View File

@@ -1,7 +1,6 @@
[ [
{ {
"app_name": "_Test OAuth Client",
"client_id": "test_client_id",
"app_name": "_Test OAuth Client",
"client_secret": "test_client_secret", "client_secret": "test_client_secret",
"default_redirect_uri": "http://localhost", "default_redirect_uri": "http://localhost",
"docstatus": 0, "docstatus": 0,


+ 0
- 0
frappe/integrations/doctype/oauth_scope/__init__.py View File


+ 30
- 0
frappe/integrations/doctype/oauth_scope/oauth_scope.json View File

@@ -0,0 +1,30 @@
{
"actions": [],
"creation": "2020-07-15 22:08:14.616585",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"scope"
],
"fields": [
{
"fieldname": "scope",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Scope"
}
],
"istable": 1,
"links": [],
"modified": "2020-07-15 22:15:18.930632",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Scope",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 10
- 0
frappe/integrations/doctype/oauth_scope/oauth_scope.py View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document

class OAuthScope(Document):
pass

+ 0
- 0
frappe/integrations/doctype/query_parameters/__init__.py View File


+ 37
- 0
frappe/integrations/doctype/query_parameters/query_parameters.json View File

@@ -0,0 +1,37 @@
{
"actions": [],
"creation": "2020-11-16 14:54:37.226914",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"key",
"value"
],
"fields": [
{
"fieldname": "key",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Key",
"reqd": 1
},
{
"fieldname": "value",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Value",
"reqd": 1
}
],
"istable": 1,
"links": [],
"modified": "2020-11-16 15:18:35.887149",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Query Parameters",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC"
}

+ 10
- 0
frappe/integrations/doctype/query_parameters/query_parameters.py View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document

class QueryParameters(Document):
pass

+ 5
- 27
frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json View File

@@ -18,12 +18,9 @@
"bucket", "bucket",
"endpoint_url", "endpoint_url",
"column_break_13", "column_break_13",
"region",
"backup_details_section", "backup_details_section",
"frequency", "frequency",
"backup_files",
"column_break_18",
"backup_limit"
"backup_files"
], ],
"fields": [ "fields": [
{ {
@@ -42,7 +39,7 @@
}, },
{ {
"default": "1", "default": "1",
"description": "Note: By default emails for failed backups are sent.",
"description": "By default, emails are only sent for failed backups.",
"fieldname": "send_email_for_successful_backup", "fieldname": "send_email_for_successful_backup",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Send Email for Successful Backup" "label": "Send Email for Successful Backup"
@@ -73,14 +70,7 @@
"reqd": 1 "reqd": 1
}, },
{ {
"default": "us-east-1",
"description": "See https://docs.aws.amazon.com/general/latest/gr/s3.html for details.",
"fieldname": "region",
"fieldtype": "Select",
"label": "Region",
"options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\naf-south-1\nap-east-1\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-south-1\neu-north-1\nme-south-1\nsa-east-1"
},
{
"default": "https://s3.amazonaws.com",
"fieldname": "endpoint_url", "fieldname": "endpoint_url",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Endpoint URL" "label": "Endpoint URL"
@@ -92,14 +82,6 @@
"mandatory_depends_on": "enabled", "mandatory_depends_on": "enabled",
"reqd": 1 "reqd": 1
}, },
{
"description": "Set to 0 for no limit on the number of backups taken",
"fieldname": "backup_limit",
"fieldtype": "Int",
"label": "Backup Limit",
"mandatory_depends_on": "enabled",
"reqd": 1
},
{ {
"depends_on": "enabled", "depends_on": "enabled",
"fieldname": "api_access_section", "fieldname": "api_access_section",
@@ -142,16 +124,12 @@
"fieldname": "backup_files", "fieldname": "backup_files",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Backup Files" "label": "Backup Files"
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2020-07-27 17:27:21.400000",
"modified": "2020-12-07 15:30:55.047689",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Integrations", "module": "Integrations",
"name": "S3 Backup Settings", "name": "S3 Backup Settings",
@@ -172,4 +150,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1
}
}

+ 14
- 44
frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py View File

@@ -24,6 +24,7 @@ class S3BackupSettings(Document):


if not self.endpoint_url: if not self.endpoint_url:
self.endpoint_url = 'https://s3.amazonaws.com' self.endpoint_url = 'https://s3.amazonaws.com'

conn = boto3.client( conn = boto3.client(
's3', 's3',
aws_access_key_id=self.access_key_id, aws_access_key_id=self.access_key_id,
@@ -31,25 +32,21 @@ class S3BackupSettings(Document):
endpoint_url=self.endpoint_url endpoint_url=self.endpoint_url
) )


bucket_lower = str(self.bucket)

try:
conn.list_buckets()

except ClientError:
frappe.throw(_("Invalid Access Key ID or Secret Access Key."))

try: try:
# Head_bucket returns a 200 OK if the bucket exists and have access to it. # Head_bucket returns a 200 OK if the bucket exists and have access to it.
conn.head_bucket(Bucket=bucket_lower)
# Requires ListBucket permission
conn.head_bucket(Bucket=self.bucket)
except ClientError as e: except ClientError as e:
error_code = e.response['Error']['Code'] error_code = e.response['Error']['Code']
bucket_name = frappe.bold(self.bucket)
if error_code == '403': if error_code == '403':
frappe.throw(_("Do not have permission to access {0} bucket.").format(bucket_lower))
else: # '400'-Bad request or '404'-Not Found return
# try to create bucket
conn.create_bucket(Bucket=bucket_lower, CreateBucketConfiguration={
'LocationConstraint': self.region})
msg = _("Do not have permission to access bucket {0}.").format(bucket_name)
elif error_code == '404':
msg = _("Bucket {0} not found.").format(bucket_name)
else:
msg = e.args[0]

frappe.throw(msg)




@frappe.whitelist() @frappe.whitelist()
@@ -70,11 +67,13 @@ def take_backups_weekly():
def take_backups_monthly(): def take_backups_monthly():
take_backups_if("Monthly") take_backups_if("Monthly")



def take_backups_if(freq): def take_backups_if(freq):
if cint(frappe.db.get_value("S3 Backup Settings", None, "enabled")): if cint(frappe.db.get_value("S3 Backup Settings", None, "enabled")):
if frappe.db.get_value("S3 Backup Settings", None, "frequency") == freq: if frappe.db.get_value("S3 Backup Settings", None, "frequency") == freq:
take_backups_s3() take_backups_s3()



@frappe.whitelist() @frappe.whitelist()
def take_backups_s3(retry_count=0): def take_backups_s3(retry_count=0):
try: try:
@@ -146,42 +145,13 @@ def backup_to_s3():
if files_filename: if files_filename:
upload_file_to_s3(files_filename, folder, conn, bucket) upload_file_to_s3(files_filename, folder, conn, bucket)


delete_old_backups(doc.backup_limit, bucket)



def upload_file_to_s3(filename, folder, conn, bucket): def upload_file_to_s3(filename, folder, conn, bucket):
destpath = os.path.join(folder, os.path.basename(filename)) destpath = os.path.join(folder, os.path.basename(filename))
try: try:
print("Uploading file:", filename) print("Uploading file:", filename)
conn.upload_file(filename, bucket, destpath)
conn.upload_file(filename, bucket, destpath) # Requires PutObject permission


except Exception as e: except Exception as e:
frappe.log_error() frappe.log_error()
print("Error uploading: %s" % (e)) print("Error uploading: %s" % (e))


def delete_old_backups(limit, bucket):
all_backups = []
doc = frappe.get_single("S3 Backup Settings")
backup_limit = int(limit)

s3 = boto3.resource(
's3',
aws_access_key_id=doc.access_key_id,
aws_secret_access_key=doc.get_password('secret_access_key'),
endpoint_url=doc.endpoint_url or 'https://s3.amazonaws.com'
)

bucket = s3.Bucket(bucket)
objects = bucket.meta.client.list_objects_v2(Bucket=bucket.name, Delimiter='/')
if objects:
for obj in objects.get('CommonPrefixes'):
all_backups.append(obj.get('Prefix'))

oldest_backup = sorted(all_backups)[0] if all_backups else ''

if len(all_backups) > backup_limit:
print("Deleting Backup: {0}".format(oldest_backup))
for obj in bucket.objects.filter(Prefix=oldest_backup):
# delete all keys that are inside the oldest_backup
s3.Object(bucket.name, obj.key).delete()

+ 14
- 0
frappe/integrations/doctype/social_login_key/test_social_login_key.py View File

@@ -22,3 +22,17 @@ def make_social_login_key(**kwargs):
kwargs["provider_name"] = "Test OAuth2 Provider" kwargs["provider_name"] = "Test OAuth2 Provider"
doc = frappe.get_doc(kwargs) doc = frappe.get_doc(kwargs)
return doc return doc

def create_or_update_social_login_key():
# used in other tests (connected app, oauth20)
try:
social_login_key = frappe.get_doc("Social Login Key", "frappe")
except frappe.DoesNotExistError:
social_login_key = frappe.new_doc("Social Login Key")
social_login_key.get_social_login_provider("Frappe", initialize=True)
social_login_key.base_url = frappe.utils.get_url()
social_login_key.enable_social_login = 0
social_login_key.save()
frappe.db.commit()

return social_login_key

+ 0
- 0
frappe/integrations/doctype/token_cache/__init__.py View File


+ 18
- 0
frappe/integrations/doctype/token_cache/test_records.json View File

@@ -0,0 +1,18 @@
[
{
"doctype": "Token Cache",
"user": "test@example.com",
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"token_type": "Bearer",
"expires_in": 1000,
"scopes": [
{
"scope": "all"
},
{
"scope": "openid"
}
]
}
]

+ 37
- 0
frappe/integrations/doctype/token_cache/test_token_cache.py View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# See license.txt
from __future__ import unicode_literals

import unittest
import frappe

test_dependencies = ['User', 'Connected App', 'Token Cache']

class TestTokenCache(unittest.TestCase):

def setUp(self):
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.save()

def test_get_auth_header(self):
self.token_cache.get_auth_header()

def test_update_data(self):
self.token_cache.update_data({
'access_token': 'new-access-token',
'refresh_token': 'new-refresh-token',
'token_type': 'bearer',
'expires_in': 2000,
'scope': 'new scope'
})

def test_get_expires_in(self):
self.token_cache.get_expires_in()

def test_is_expired(self):
self.token_cache.is_expired()

def get_json(self):
self.token_cache.get_json()

+ 8
- 0
frappe/integrations/doctype/token_cache/token_cache.js View File

@@ -0,0 +1,8 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Token Cache', {
// refresh: function(frm) {

// }
});

+ 110
- 0
frappe/integrations/doctype/token_cache/token_cache.json View File

@@ -0,0 +1,110 @@
{
"actions": [],
"autoname": "format:{connected_app}-{user}",
"beta": 1,
"creation": "2019-01-24 16:56:55.631096",
"doctype": "DocType",
"document_type": "System",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user",
"connected_app",
"provider_name",
"access_token",
"refresh_token",
"expires_in",
"state",
"scopes",
"success_uri",
"token_type"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"read_only": 1
},
{
"fieldname": "connected_app",
"fieldtype": "Link",
"label": "Connected App",
"options": "Connected App",
"read_only": 1
},
{
"fieldname": "access_token",
"fieldtype": "Password",
"label": "Access Token",
"read_only": 1
},
{
"fieldname": "refresh_token",
"fieldtype": "Password",
"label": "Refresh Token",
"read_only": 1
},
{
"fieldname": "expires_in",
"fieldtype": "Int",
"label": "Expires In",
"read_only": 1
},
{
"fieldname": "state",
"fieldtype": "Data",
"label": "State",
"read_only": 1
},
{
"fieldname": "scopes",
"fieldtype": "Table",
"label": "Scopes",
"options": "OAuth Scope",
"read_only": 1
},
{
"fieldname": "success_uri",
"fieldtype": "Data",
"label": "Success URI",
"read_only": 1
},
{
"fieldname": "token_type",
"fieldtype": "Data",
"label": "Token Type",
"read_only": 1
},
{
"fetch_from": "connected_app.provider_name",
"fieldname": "provider_name",
"fieldtype": "Data",
"label": "Provider Name",
"read_only": 1
}
],
"links": [],
"modified": "2020-11-13 13:35:53.714352",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Token Cache",
"owner": "Administrator",
"permissions": [
{
"delete": 1,
"read": 1,
"role": "System Manager"
},
{
"delete": 1,
"if_owner": 1,
"read": 1,
"role": "All"
}
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 67
- 0
frappe/integrations/doctype/token_cache/token_cache.py View File

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
from datetime import datetime, timedelta

import frappe
from frappe import _
from frappe.utils import cstr, cint
from frappe.model.document import Document

class TokenCache(Document):

def get_auth_header(self):
if self.access_token:
headers = {'Authorization': 'Bearer ' + self.get_password('access_token')}
return headers

raise frappe.exceptions.DoesNotExistError

def update_data(self, data):
"""
Store data returned by authorization flow.

Params:
data - Dict with access_token, refresh_token, expires_in and scope.
"""
token_type = cstr(data.get('token_type', '')).lower()
if token_type not in ['bearer', 'mac']:
frappe.throw(_('Received an invalid token type.'))
# 'Bearer' or 'MAC'
token_type = token_type.title() if token_type == 'bearer' else token_type.upper()

self.token_type = token_type
self.access_token = cstr(data.get('access_token', ''))
self.refresh_token = cstr(data.get('refresh_token', ''))
self.expires_in = cint(data.get('expires_in', 0))

new_scopes = data.get('scope')
if new_scopes:
if isinstance(new_scopes, str):
new_scopes = new_scopes.split(' ')
if isinstance(new_scopes, list):
self.scopes = None
for scope in new_scopes:
self.append('scopes', {'scope': scope})

self.state = None
self.save(ignore_permissions=True)
frappe.db.commit()
return self

def get_expires_in(self):
expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(self.expires_in)
return (datetime.now() - expiry_time).total_seconds()

def is_expired(self):
return self.get_expires_in() < 0

def get_json(self):
return {
'access_token': self.get_password('access_token', ''),
'refresh_token': self.get_password('refresh_token', ''),
'expires_in': self.get_expires_in(),
'token_type': self.token_type
}

+ 1
- 1
frappe/integrations/doctype/webhook/webhook.py View File

@@ -85,7 +85,7 @@ def enqueue_webhook(doc, webhook):


for i in range(3): for i in range(3):
try: try:
r = requests.post(webhook.request_url, data=json.dumps(data), headers=headers, timeout=5)
r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5)
r.raise_for_status() r.raise_for_status()
frappe.logger().debug({"webhook_success": r.text}) frappe.logger().debug({"webhook_success": r.text})
break break


+ 1
- 0
frappe/integrations/oauth2.py View File

@@ -20,6 +20,7 @@ def get_oauth_server():
return frappe.local.oauth_server return frappe.local.oauth_server


def sanitize_kwargs(param_kwargs): def sanitize_kwargs(param_kwargs):
"""Remove 'data' and 'cmd' keys, if present."""
arguments = param_kwargs arguments = param_kwargs
arguments.pop('data', None) arguments.pop('data', None)
arguments.pop('cmd', None) arguments.pop('cmd', None)


+ 8
- 5
frappe/model/base_document.py View File

@@ -48,7 +48,7 @@ def get_controller(doctype):
else: else:
class_overrides = frappe.get_hooks('override_doctype_class') class_overrides = frappe.get_hooks('override_doctype_class')
if class_overrides and class_overrides.get(doctype): if class_overrides and class_overrides.get(doctype):
import_path = frappe.get_hooks('override_doctype_class').get(doctype)[-1]
import_path = class_overrides[doctype][-1]
module_path, classname = import_path.rsplit('.', 1) module_path, classname = import_path.rsplit('.', 1)
module = frappe.get_module(module_path) module = frappe.get_module(module_path)
if not hasattr(module, classname): if not hasattr(module, classname):
@@ -69,10 +69,13 @@ def get_controller(doctype):


if frappe.local.dev_server: if frappe.local.dev_server:
return _get_controller() return _get_controller()

key = '{}:doctype_classes'.format(frappe.local.site)
return frappe.cache().hget(key, doctype, generator=_get_controller, shared=True)

site_controllers = frappe.controllers.setdefault(frappe.local.site, {})
if doctype not in site_controllers:
site_controllers[doctype] = _get_controller()
return site_controllers[doctype]
class BaseDocument(object): class BaseDocument(object):
ignore_in_getter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns") ignore_in_getter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns")




+ 10
- 4
frappe/model/db_query.py View File

@@ -40,7 +40,10 @@ class DatabaseQuery(object):
ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False, ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False,
update=None, add_total_row=None, user_settings=None, reference_doctype=None, update=None, add_total_row=None, user_settings=None, reference_doctype=None,
return_query=False, strict=True, pluck=None, ignore_ddl=False): return_query=False, strict=True, pluck=None, ignore_ddl=False):
if not ignore_permissions and not frappe.has_permission(self.doctype, "read", user=user):
if not ignore_permissions and \
not frappe.has_permission(self.doctype, "select", user=user) and \
not frappe.has_permission(self.doctype, "read", user=user):

frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype)) frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype))
raise frappe.PermissionError(self.doctype) raise frappe.PermissionError(self.doctype)


@@ -315,7 +318,10 @@ class DatabaseQuery(object):
def append_table(self, table_name): def append_table(self, table_name):
self.tables.append(table_name) self.tables.append(table_name)
doctype = table_name[4:-1] doctype = table_name[4:-1]
if (not self.flags.ignore_permissions) and (not frappe.has_permission(doctype)):
ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read'

if (not self.flags.ignore_permissions) and\
(not frappe.has_permission(doctype, ptype=ptype)):
frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(doctype)) frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(doctype))
raise frappe.PermissionError(doctype) raise frappe.PermissionError(doctype)


@@ -576,7 +582,7 @@ class DatabaseQuery(object):
self.shared = frappe.share.get_shared(self.doctype, self.user) self.shared = frappe.share.get_shared(self.doctype, self.user)


if (not meta.istable and if (not meta.istable and
not role_permissions.get("read") and
not (role_permissions.get("select") or role_permissions.get("read")) and
not self.flags.ignore_permissions and not self.flags.ignore_permissions and
not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype)): not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype)):
only_if_shared = True only_if_shared = True
@@ -591,7 +597,7 @@ class DatabaseQuery(object):
self.match_conditions.append("`tab{0}`.`owner` = {1}".format(self.doctype, self.match_conditions.append("`tab{0}`.`owner` = {1}".format(self.doctype,
frappe.db.escape(self.user, percent=False))) frappe.db.escape(self.user, percent=False)))
# add user permission only if role has read perm # add user permission only if role has read perm
elif role_permissions.get("read"):
elif role_permissions.get("read") or role_permissions.get("select"):
# get user permissions # get user permissions
user_permissions = frappe.permissions.get_user_permissions(self.user) user_permissions = frappe.permissions.get_user_permissions(self.user)
self.add_user_permissions(user_permissions) self.add_user_permissions(user_permissions)


+ 6
- 1
frappe/model/delete_doc.py View File

@@ -76,7 +76,12 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa


delete_from_table(doctype, name, ignore_doctypes, None) delete_from_table(doctype, name, ignore_doctypes, None)


if not (for_reload or frappe.flags.in_migrate or frappe.flags.in_install or frappe.flags.in_uninstall or frappe.flags.in_test):
if frappe.conf.developer_mode and not doc.custom and not (
for_reload
or frappe.flags.in_migrate
or frappe.flags.in_install
or frappe.flags.in_uninstall
):
try: try:
delete_controllers(name, doc.module) delete_controllers(name, doc.module)
except (FileNotFoundError, OSError, KeyError): except (FileNotFoundError, OSError, KeyError):


+ 4
- 2
frappe/model/document.py View File

@@ -939,15 +939,17 @@ class Document(BaseDocument):
self.load_doc_before_save() self.load_doc_before_save()
self.reset_seen() self.reset_seen()


# before_validate method should be executed before ignoring validations
if self._action in ("save", "submit"):
self.run_method("before_validate")

if self.flags.ignore_validate: if self.flags.ignore_validate:
return return


if self._action=="save": if self._action=="save":
self.run_method("before_validate")
self.run_method("validate") self.run_method("validate")
self.run_method("before_save") self.run_method("before_save")
elif self._action=="submit": elif self._action=="submit":
self.run_method("before_validate")
self.run_method("validate") self.run_method("validate")
self.run_method("before_submit") self.run_method("before_submit")
elif self._action=="cancel": elif self._action=="cancel":


+ 22
- 1
frappe/model/meta.py View File

@@ -68,7 +68,7 @@ def load_doctype_from_file(doctype):
class Meta(Document): class Meta(Document):
_metaclass = True _metaclass = True
default_fields = list(default_fields)[1:] default_fields = list(default_fields)[1:]
special_doctypes = ("DocField", "DocPerm", "Role", "DocType", "Module Def", 'DocType Action', 'DocType Link')
special_doctypes = ("DocField", "DocPerm", "DocType", "Module Def", 'DocType Action', 'DocType Link')


def __init__(self, doctype): def __init__(self, doctype):
self._fields = {} self._fields = {}
@@ -450,6 +450,25 @@ class Meta(Document):


return self.high_permlevel_fields return self.high_permlevel_fields


def get_permlevel_access(self, permission_type='read', parenttype=None):
has_access_to = []
roles = frappe.get_roles()
for perm in self.get_permissions(parenttype):
if perm.role in roles and perm.permlevel > 0 and perm.get(permission_type):
if perm.permlevel not in has_access_to:
has_access_to.append(perm.permlevel)

return has_access_to

def get_permissions(self, parenttype=None):
if self.istable and parenttype:
# use parent permissions
permissions = frappe.get_meta(parenttype).permissions
else:
permissions = self.get('permissions', [])

return permissions

def get_dashboard_data(self): def get_dashboard_data(self):
'''Returns dashboard setup related to this doctype. '''Returns dashboard setup related to this doctype.


@@ -484,6 +503,8 @@ class Meta(Document):
if not data.transactions: if not data.transactions:
# init groups # init groups
data.transactions = [] data.transactions = []

if not data.non_standard_fieldnames:
data.non_standard_fieldnames = {} data.non_standard_fieldnames = {}


for link in dashboard_links: for link in dashboard_links:


+ 14
- 10
frappe/model/rename_doc.py View File

@@ -21,8 +21,16 @@ def update_document_title(doctype, docname, title_field=None, old_title=None, ne
docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge) docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge)


if old_title and new_title and not old_title == new_title: if old_title and new_title and not old_title == new_title:
frappe.db.set_value(doctype, docname, title_field, new_title)
frappe.msgprint(_('Saved'), alert=True, indicator='green')
try:
frappe.db.set_value(doctype, docname, title_field, new_title)
frappe.msgprint(_('Saved'), alert=True, indicator='green')
except Exception as e:
if frappe.db.is_duplicate_entry(e):
frappe.throw(
_("{0} {1} already exists").format(doctype, frappe.bold(docname)),
title=_("Duplicate Name"),
exc=frappe.DuplicateEntryError
)


return docname return docname


@@ -49,9 +57,7 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F
old_doc = frappe.get_doc(doctype, old) old_doc = frappe.get_doc(doctype, old)
out = old_doc.run_method("before_rename", old, new, merge) or {} out = old_doc.run_method("before_rename", old, new, merge) or {}
new = (out.get("new") or new) if isinstance(out, dict) else (out or new) new = (out.get("new") or new) if isinstance(out, dict) else (out or new)

if doctype != "DocType":
new = validate_rename(doctype, new, meta, merge, force, ignore_permissions)
new = validate_rename(doctype, new, meta, merge, force, ignore_permissions)


if not merge: if not merge:
rename_parent_and_child(doctype, old, new, meta) rename_parent_and_child(doctype, old, new, meta)
@@ -250,6 +256,7 @@ def update_link_field_values(link_fields, old, new, doctype):
pass pass
else: else:
parent = field['parent'] parent = field['parent']
docfield = field["fieldname"]


# Handles the case where one of the link fields belongs to # Handles the case where one of the link fields belongs to
# the DocType being renamed. # the DocType being renamed.
@@ -261,11 +268,8 @@ def update_link_field_values(link_fields, old, new, doctype):
if parent == new and doctype == "DocType": if parent == new and doctype == "DocType":
parent = old parent = old


frappe.db.sql("""
update `tab{table_name}` set `{fieldname}`=%s
where `{fieldname}`=%s""".format(
table_name=parent,
fieldname=field['fieldname']), (new, old))
frappe.db.set_value(parent, {docfield: old}, docfield, new)

# update cached link_fields as per new # update cached link_fields as per new
if doctype=='DocType' and field['parent'] == old: if doctype=='DocType' and field['parent'] == old:
field['parent'] = new field['parent'] = new


+ 10
- 8
frappe/model/workflow.py View File

@@ -53,14 +53,17 @@ def get_transitions(doc, workflow = None, raise_exception=False):
return transitions return transitions


def get_workflow_safe_globals(): def get_workflow_safe_globals():
# access to frappe.db.get_value and frappe.db.get_list
# access to frappe.db.get_value, frappe.db.get_list, and date time utils.
return dict( return dict(
frappe=frappe._dict( frappe=frappe._dict(
db=frappe._dict(
get_value=frappe.db.get_value,
get_list=frappe.db.get_list
db=frappe._dict(get_value=frappe.db.get_value, get_list=frappe.db.get_list),
session=frappe.session,
utils=frappe._dict(
now_datetime=frappe.utils.now_datetime,
add_to_date=frappe.utils.add_to_date,
get_datetime=frappe.utils.get_datetime,
now=frappe.utils.now,
), ),
session=frappe.session
) )
) )


@@ -117,9 +120,8 @@ def apply_workflow(doc, action):
return doc return doc


@frappe.whitelist() @frappe.whitelist()
def can_cancel_document(doc):
doc = frappe.get_doc(frappe.parse_json(doc))
workflow = get_workflow(doc.doctype)
def can_cancel_document(doctype):
workflow = get_workflow(doctype)
for state_doc in workflow.states: for state_doc in workflow.states:
if state_doc.doc_status == '2': if state_doc.doc_status == '2':
for transition in workflow.transitions: for transition in workflow.transitions:


+ 14
- 0
frappe/patches/v13_0/website_theme_custom_scss.py View File

@@ -2,9 +2,23 @@ import frappe


def execute(): def execute():
frappe.reload_doctype('Website Theme') frappe.reload_doctype('Website Theme')
frappe.reload_doc('website', 'doctype', 'website_theme_ignore_app')
frappe.reload_doc('website', 'doctype', 'color')

for theme in frappe.get_all('Website Theme'): for theme in frappe.get_all('Website Theme'):
doc = frappe.get_doc('Website Theme', theme.name) doc = frappe.get_doc('Website Theme', theme.name)
if not doc.get('custom_scss') and doc.theme_scss: if not doc.get('custom_scss') and doc.theme_scss:
# move old theme to new theme # move old theme to new theme
doc.custom_scss = doc.theme_scss doc.custom_scss = doc.theme_scss

if doc.background_color:
setup_color_record(doc.background_color)

doc.save() doc.save()

def setup_color_record(color):
frappe.get_doc({
"doctype": "Color",
"__newname": color,
"color": color,
}).save()

+ 7
- 4
frappe/permissions.py View File

@@ -7,7 +7,7 @@ import frappe, copy, json
from frappe import _, msgprint from frappe import _, msgprint
from frappe.utils import cint from frappe.utils import cint
import frappe.share import frappe.share
rights = ("read", "write", "create", "delete", "submit", "cancel", "amend",
rights = ("select", "read", "write", "create", "delete", "submit", "cancel", "amend",
"print", "email", "report", "import", "export", "set_user_permissions", "share") "print", "email", "report", "import", "export", "set_user_permissions", "share")


# TODO: # TODO:
@@ -73,6 +73,7 @@ def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, ra


role_permissions = get_role_permissions(meta, user=user) role_permissions = get_role_permissions(meta, user=user)
perm = role_permissions.get(ptype) perm = role_permissions.get(ptype)

if not perm: if not perm:
push_perm_check_log(_('User {0} does not have doctype access via role permission for document {1}').format(frappe.bold(user), frappe.bold(doctype))) push_perm_check_log(_('User {0} does not have doctype access via role permission for document {1}').format(frappe.bold(user), frappe.bold(doctype)))


@@ -192,9 +193,9 @@ def get_role_permissions(doctype_meta, user=None):
and ptype != 'create'): and ptype != 'create'):
perms['if_owner'][ptype] = 1 perms['if_owner'][ptype] = 1
# has no access if not owner # has no access if not owner
# only provide read access so that user is able to at-least access list
# only provide select or read access so that user is able to at-least access list
# (and the documents will be filtered based on owner sin further checks) # (and the documents will be filtered based on owner sin further checks)
perms[ptype] = 1 if ptype == 'read' else 0
perms[ptype] = 1 if ptype in ['select', 'read'] else 0


frappe.local.role_permissions[cache_key] = perms frappe.local.role_permissions[cache_key] = perms


@@ -397,7 +398,8 @@ def set_user_permission_if_allowed(doctype, name, user, with_message=False):
if get_role_permissions(frappe.get_meta(doctype), user).set_user_permissions!=1: if get_role_permissions(frappe.get_meta(doctype), user).set_user_permissions!=1:
add_user_permission(doctype, name, user) add_user_permission(doctype, name, user)


def add_user_permission(doctype, name, user, ignore_permissions=False, applicable_for=None, is_default=0):
def add_user_permission(doctype, name, user, ignore_permissions=False, applicable_for=None,
is_default=0, hide_descendants=0):
'''Add user permission''' '''Add user permission'''
from frappe.core.doctype.user_permission.user_permission import user_permission_exists from frappe.core.doctype.user_permission.user_permission import user_permission_exists


@@ -412,6 +414,7 @@ def add_user_permission(doctype, name, user, ignore_permissions=False, applicabl
for_value=name, for_value=name,
is_default=is_default, is_default=is_default,
applicable_for=applicable_for, applicable_for=applicable_for,
hide_descendants=hide_descendants,
)).insert(ignore_permissions=ignore_permissions) )).insert(ignore_permissions=ignore_permissions)


def remove_user_permission(doctype, name, user): def remove_user_permission(doctype, name, user):


+ 1
- 1
frappe/printing/doctype/print_format/print_format.js View File

@@ -66,7 +66,7 @@ frappe.ui.form.on("Print Format", {
hide_absolute_value_field: function (frm) { hide_absolute_value_field: function (frm) {
// TODO: make it work with frm.doc.doc_type // TODO: make it work with frm.doc.doc_type
// Problem: frm isn't updated in some random cases // Problem: frm isn't updated in some random cases
const doctype = locals[frm.doc.doctype][frm.doc.name];
const doctype = locals[frm.doc.doctype][frm.doc.name].doc_type;
if (doctype) { if (doctype) {
frappe.model.with_doctype(doctype, () => { frappe.model.with_doctype(doctype, () => {
const meta = frappe.get_meta(doctype); const meta = frappe.get_meta(doctype);


+ 3
- 3
frappe/printing/doctype/print_format/print_format.json View File

@@ -201,17 +201,17 @@
{ {
"default": "0", "default": "0",
"depends_on": "doc_type", "depends_on": "doc_type",
"description": "If checked, negative numberic values of Currency, Quantity or Count would be shown as positive",
"description": "If checked, negative numeric values of Currency, Quantity or Count would be shown as positive",
"fieldname": "absolute_value", "fieldname": "absolute_value",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Show absolute values"
"label": "Show Absolute Values"
} }
], ],
"icon": "fa fa-print", "icon": "fa fa-print",
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-12-10 18:58:55.598269",
"modified": "2020-12-14 11:38:49.132061",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Printing", "module": "Printing",
"name": "Print Format", "name": "Print Format",


+ 2
- 0
frappe/public/build.json View File

@@ -161,6 +161,7 @@
"public/js/frappe/router_history.js", "public/js/frappe/router_history.js",
"public/js/frappe/defaults.js", "public/js/frappe/defaults.js",
"public/js/frappe/roles_editor.js", "public/js/frappe/roles_editor.js",
"public/js/frappe/module_editor.js",
"public/js/frappe/microtemplate.js", "public/js/frappe/microtemplate.js",


"public/js/frappe/ui/page.html", "public/js/frappe/ui/page.html",
@@ -307,6 +308,7 @@
"public/js/frappe/views/calendar/calendar.js", "public/js/frappe/views/calendar/calendar.js",
"public/js/frappe/views/dashboard/dashboard_view.js", "public/js/frappe/views/dashboard/dashboard_view.js",
"public/js/frappe/views/image/image_view.js", "public/js/frappe/views/image/image_view.js",
"public/js/frappe/views/map/map_view.js",
"public/js/frappe/views/kanban/kanban_view.js", "public/js/frappe/views/kanban/kanban_view.js",
"public/js/frappe/views/inbox/inbox_view.js", "public/js/frappe/views/inbox/inbox_view.js",
"public/js/frappe/views/file/file_view.js", "public/js/frappe/views/file/file_view.js",


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save