瀏覽代碼

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

version-14
Saurabh 4 年之前
父節點
當前提交
0f1c99baa6
共有 100 個檔案被更改,包括 1916 行新增1733 行删除
  1. +14
    -0
      .editorconfig
  2. +5
    -0
      .github/ISSUE_TEMPLATE/config.yml
  3. +2
    -2
      .github/helper/documentation.py
  4. +2
    -1
      .github/workflows/docker-release.yml
  5. +3
    -2
      .github/workflows/docs-checker.yml
  6. +3
    -2
      .github/workflows/publish-assets-develop.yml
  7. +3
    -3
      .github/workflows/publish-assets-releases.yml
  8. +2
    -2
      .mergify.yml
  9. +12
    -5
      frappe/__init__.py
  10. +4
    -0
      frappe/app.py
  11. +2
    -1
      frappe/automation/doctype/assignment_rule/assignment_rule.js
  12. +6
    -1
      frappe/automation/doctype/assignment_rule/assignment_rule.py
  13. +14
    -0
      frappe/automation/doctype/auto_repeat/auto_repeat.js
  14. +10
    -1
      frappe/automation/doctype/auto_repeat/auto_repeat.json
  15. +10
    -1
      frappe/automation/doctype/auto_repeat/auto_repeat.py
  16. +51
    -0
      frappe/automation/doctype/auto_repeat/test_auto_repeat.py
  17. +1
    -1
      frappe/build.py
  18. +121
    -0
      frappe/change_log/v13/v13_0_0-beta_9.md
  19. +12
    -18
      frappe/chat/util/util.py
  20. +132
    -99
      frappe/commands/site.py
  21. +11
    -1
      frappe/commands/utils.py
  22. +0
    -6
      frappe/config/customization.py
  23. +2
    -2
      frappe/contacts/doctype/address/address.json
  24. +4
    -6
      frappe/core/doctype/communication/communication.py
  25. +9
    -1
      frappe/core/doctype/docfield/docfield.json
  26. +22
    -4
      frappe/core/doctype/doctype/doctype.py
  27. +71
    -38
      frappe/core/doctype/doctype/test_doctype.py
  28. +10
    -2
      frappe/core/doctype/doctype_action/doctype_action.json
  29. +19
    -2
      frappe/core/doctype/doctype_link/doctype_link.json
  30. +3
    -0
      frappe/core/doctype/domain_settings/domain_settings.js
  31. +40
    -4
      frappe/core/doctype/file/file.py
  32. +25
    -0
      frappe/core/doctype/file/test_file.py
  33. +4
    -2
      frappe/core/doctype/navbar_item/navbar_item.json
  34. +9
    -11
      frappe/core/doctype/prepared_report/prepared_report.py
  35. +5
    -4
      frappe/core/doctype/report/report.py
  36. +2
    -2
      frappe/core/doctype/role/role.py
  37. +3
    -3
      frappe/core/doctype/scheduled_job_type/scheduled_job_type.json
  38. +3
    -3
      frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
  39. +30
    -11
      frappe/core/doctype/server_script/server_script.js
  40. +4
    -3
      frappe/core/doctype/server_script/server_script.json
  41. +10
    -2
      frappe/core/doctype/server_script/server_script.py
  42. +9
    -2
      frappe/core/doctype/server_script/server_script_utils.py
  43. +25
    -0
      frappe/core/doctype/server_script/test_server_script.py
  44. +31
    -32
      frappe/core/doctype/system_settings/system_settings.js
  45. +8
    -1
      frappe/core/doctype/system_settings/system_settings.json
  46. +6
    -2
      frappe/core/doctype/user/user.py
  47. +9
    -1
      frappe/custom/doctype/custom_field/custom_field.json
  48. +0
    -20
      frappe/custom/doctype/custom_link/custom_link.js
  49. +0
    -10
      frappe/custom/doctype/custom_link/custom_link.py
  50. +67
    -23
      frappe/custom/doctype/customize_form/customize_form.js
  51. +72
    -20
      frappe/custom/doctype/customize_form/customize_form.json
  52. +285
    -173
      frappe/custom/doctype/customize_form/customize_form.py
  53. +73
    -5
      frappe/custom/doctype/customize_form/test_customize_form.py
  54. +14
    -5
      frappe/custom/doctype/customize_form_field/customize_form_field.json
  55. +0
    -65
      frappe/custom/doctype/package_document_type/package_document_type.json
  56. +0
    -0
      frappe/custom/doctype/package_publish_target/__init__.py
  57. +0
    -47
      frappe/custom/doctype/package_publish_target/package_publish_target.json
  58. +0
    -10
      frappe/custom/doctype/package_publish_target/package_publish_target.py
  59. +0
    -0
      frappe/custom/doctype/package_publish_tool/__init__.py
  60. +0
    -159
      frappe/custom/doctype/package_publish_tool/package_publish_tool.js
  61. +0
    -84
      frappe/custom/doctype/package_publish_tool/package_publish_tool.json
  62. +0
    -178
      frappe/custom/doctype/package_publish_tool/package_publish_tool.py
  63. +0
    -10
      frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py
  64. +113
    -338
      frappe/custom/doctype/property_setter/property_setter.json
  65. +16
    -13
      frappe/custom/doctype/property_setter/property_setter.py
  66. +8
    -12
      frappe/database/database.py
  67. +5
    -6
      frappe/database/db_manager.py
  68. +1
    -1
      frappe/database/mariadb/framework_mariadb.sql
  69. +14
    -4
      frappe/database/mariadb/setup_db.py
  70. +2
    -2
      frappe/database/postgres/database.py
  71. +53
    -12
      frappe/database/postgres/setup_db.py
  72. +1
    -1
      frappe/database/schema.py
  73. +19
    -0
      frappe/desk/doctype/dashboard/dashboard.py
  74. +27
    -84
      frappe/desk/doctype/dashboard_chart/dashboard_chart.py
  75. +14
    -24
      frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
  76. +8
    -1
      frappe/desk/doctype/notification_settings/notification_settings.js
  77. +13
    -32
      frappe/desk/doctype/notification_settings/notification_settings.json
  78. +11
    -2
      frappe/desk/doctype/number_card/number_card.py
  79. +1
    -1
      frappe/desk/form/document_follow.py
  80. +8
    -2
      frappe/desk/page/user_profile/user_profile.py
  81. +0
    -5
      frappe/email/doctype/email_group/email_group.js
  82. +10
    -2
      frappe/email/doctype/email_group/email_group.json
  83. +20
    -2
      frappe/email/doctype/email_template/email_template.json
  84. +24
    -3
      frappe/email/doctype/email_template/email_template.py
  85. +3
    -3
      frappe/email/doctype/newsletter/newsletter.py
  86. +5
    -10
      frappe/email/doctype/notification/notification.js
  87. +5
    -15
      frappe/email/doctype/notification/notification.json
  88. +3
    -19
      frappe/email/doctype/notification/notification.py
  89. +2
    -0
      frappe/email/receive.py
  90. +2
    -3
      frappe/email/smtp.py
  91. +25
    -0
      frappe/email/test_smtp.py
  92. +56
    -3
      frappe/event_streaming/doctype/event_consumer/event_consumer.py
  93. +9
    -2
      frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json
  94. +1
    -8
      frappe/event_streaming/doctype/event_producer/event_producer.json
  95. +32
    -13
      frappe/event_streaming/doctype/event_producer/event_producer.py
  96. +79
    -0
      frappe/event_streaming/doctype/event_producer/test_event_producer.py
  97. +8
    -2
      frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json
  98. +0
    -0
      frappe/event_streaming/doctype/event_producer_last_update/__init__.py
  99. +8
    -0
      frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.js
  100. +16
    -15
      frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.json

+ 14
- 0
.editorconfig 查看文件

@@ -0,0 +1,14 @@
# Root editor config file
root = true

# Common settings
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8

# python, js indentation settings
[{*.py,*.js}]
indent_style = tab
indent_size = 4

+ 5
- 0
.github/ISSUE_TEMPLATE/config.yml 查看文件

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Community Forum
url: https://discuss.erpnext.com/
about: For general QnA, discussions and community help.

+ 2
- 2
.github/helper/documentation.py 查看文件

@@ -21,8 +21,8 @@ def docs_link_exists(body):
if word.startswith('http') and uri_validator(word): if word.startswith('http') and uri_validator(word):
parsed_url = urlparse(word) parsed_url = urlparse(word)
if parsed_url.netloc == "github.com": if parsed_url.netloc == "github.com":
_, org, repo, _type, ref = parsed_url.path.split('/')
if org == "frappe" and repo in docs_repos:
parts = parsed_url.path.split('/')
if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos:
return True return True






+ 2
- 1
.github/workflows/docker-release.yml 查看文件

@@ -1,9 +1,10 @@
name: Trigger Docker build on release
name: 'Trigger Docker build on release'
on: on:
release: release:
types: [released] types: [released]
jobs: jobs:
curl: curl:
name: 'Trigger Docker build on release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: alpine:latest image: alpine:latest


+ 3
- 2
.github/workflows/docs-checker.yml 查看文件

@@ -1,10 +1,11 @@
name: 'Documentation Required'
name: 'Documentation Check'
on: on:
pull_request: pull_request:
types: [ opened, synchronize, reopened, edited ] types: [ opened, synchronize, reopened, edited ]


jobs: jobs:
build:
docs-required:
name: 'Documentation Required'
runs-on: ubuntu-latest runs-on: ubuntu-latest


steps: steps:


+ 3
- 2
.github/workflows/publish-assets-develop.yml 查看文件

@@ -1,11 +1,12 @@
name: Build and Publish Assets for Development
name: 'Frappe Assets'


on: on:
push: push:
branches: [ develop ] branches: [ develop ]


jobs: jobs:
build:
build-dev-and-publish:
name: 'Build and Publish Assets for Development'
runs-on: ubuntu-latest runs-on: ubuntu-latest


steps: steps:


+ 3
- 3
.github/workflows/publish-assets-releases.yml 查看文件

@@ -1,4 +1,4 @@
name: Build and Publish Assets built for Releases
name: 'Frappe Assets'


on: on:
release: release:
@@ -8,7 +8,8 @@ env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}


jobs: jobs:
build:
build-release-and-publish:
name: 'Build and Publish Assets built for Releases'
runs-on: ubuntu-latest runs-on: ubuntu-latest


steps: steps:
@@ -44,4 +45,3 @@ jobs:
asset_path: build/assets.tar.gz asset_path: build/assets.tar.gz
asset_name: assets.tar.gz asset_name: assets.tar.gz
asset_content_type: application/octet-stream asset_content_type: application/octet-stream


+ 2
- 2
.mergify.yml 查看文件

@@ -5,7 +5,7 @@ pull_request_rules:
- status-success=Semantic Pull Request - status-success=Semantic Pull Request
- status-success=Travis CI - Pull Request - status-success=Travis CI - Pull Request
- status-success=security/snyk (frappe) - status-success=security/snyk (frappe)
- label!=don't-merge
- label!=dont-merge
- label!=squash - label!=squash
- "#approved-reviews-by>=1" - "#approved-reviews-by>=1"
actions: actions:
@@ -17,7 +17,7 @@ pull_request_rules:
- status-success=Semantic Pull Request - status-success=Semantic Pull Request
- status-success=Travis CI - Pull Request - status-success=Travis CI - Pull Request
- status-success=security/snyk (frappe) - status-success=security/snyk (frappe)
- label!=don't-merge
- label!=dont-merge
- label=squash - label=squash
- "#approved-reviews-by>=1" - "#approved-reviews-by>=1"
actions: actions:


+ 12
- 5
frappe/__init__.py 查看文件

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


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


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


@@ -349,7 +349,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False,


if as_table and type(msg) in (list, tuple): if as_table and type(msg) in (list, tuple):
out.as_table = 1 out.as_table = 1
if as_list and type(msg) in (list, tuple) and len(msg) > 1: if as_list and type(msg) in (list, tuple) and len(msg) > 1:
out.as_list = 1 out.as_list = 1


@@ -797,11 +797,17 @@ def get_doc(*args, **kwargs):


return doc return doc


def get_last_doc(doctype):
def get_last_doc(doctype, filters=None, order_by="creation desc"):
"""Get last created document of this type.""" """Get last created document of this type."""
d = get_all(doctype, ["name"], order_by="creation desc", limit_page_length=1)
d = get_all(
doctype,
filters=filters,
limit_page_length=1,
order_by=order_by,
pluck="name"
)
if d: if d:
return get_doc(doctype, d[0].name)
return get_doc(doctype, d[0])
else: else:
raise DoesNotExistError raise DoesNotExistError


@@ -1155,6 +1161,7 @@ def make_property_setter(args, ignore_validate=False, validate_fields_for_doctyp
'doctype_or_field': args.doctype_or_field, 'doctype_or_field': args.doctype_or_field,
'doc_type': doctype, 'doc_type': doctype,
'field_name': args.fieldname, 'field_name': args.fieldname,
'row_name': args.row_name,
'property': args.property, 'property': args.property,
'value': args.value, 'value': args.value,
'property_type': args.property_type or "Data", 'property_type': args.property_type or "Data",


+ 4
- 0
frappe/app.py 查看文件

@@ -160,6 +160,10 @@ def handle_exception(e):
http_status_code = getattr(e, "http_status_code", 500) http_status_code = getattr(e, "http_status_code", 500)
return_as_message = False return_as_message = False


if frappe.conf.get('developer_mode'):
# don't fail silently
print(frappe.get_traceback())

if frappe.get_request_header('Accept') and (frappe.local.is_ajax or 'application/json' in frappe.get_request_header('Accept')): if frappe.get_request_header('Accept') and (frappe.local.is_ajax or 'application/json' in frappe.get_request_header('Accept')):
# handle ajax responses first # handle ajax responses first
# if the request is ajax, send back the trace or error message # if the request is ajax, send back the trace or error message


+ 2
- 1
frappe/automation/doctype/assignment_rule/assignment_rule.js 查看文件

@@ -57,7 +57,8 @@ frappe.ui.form.on('Assignment Rule', {
frm.set_fields_as_options( frm.set_fields_as_options(
'field', 'field',
doctype, doctype,
(df) => df.fieldtype == 'Link' && df.options == 'User',
(df) => ['Dynamic Link', 'Data'].includes(df.fieldtype)
|| (df.fieldtype == 'Link' && df.options == 'User'),
[{ label: 'Owner', value: 'owner' }] [{ label: 'Owner', value: 'owner' }]
); );
if (doctype) { if (doctype) {


+ 6
- 1
frappe/automation/doctype/assignment_rule/assignment_rule.py 查看文件

@@ -82,7 +82,7 @@ class AssignmentRule(Document):
elif self.rule == 'Load Balancing': elif self.rule == 'Load Balancing':
return self.get_user_load_balancing() return self.get_user_load_balancing()
elif self.rule == 'Based on Field': elif self.rule == 'Based on Field':
return doc.get(self.field)
return self.get_user_based_on_field(doc)


def get_user_round_robin(self): def get_user_round_robin(self):
''' '''
@@ -119,6 +119,11 @@ class AssignmentRule(Document):
# pick the first user # pick the first user
return sorted_counts[0].get('user') return sorted_counts[0].get('user')


def get_user_based_on_field(self, doc):
val = doc.get(self.field)
if frappe.db.exists('User', val):
return val

def safe_eval(self, fieldname, doc): def safe_eval(self, fieldname, doc):
try: try:
if self.get(fieldname): if self.get(fieldname):


+ 14
- 0
frappe/automation/doctype/auto_repeat/auto_repeat.js 查看文件

@@ -44,6 +44,20 @@ frappe.ui.form.on('Auto Repeat', {


// auto repeat schedule // auto repeat schedule
frappe.auto_repeat.render_schedule(frm); frappe.auto_repeat.render_schedule(frm);

frm.trigger('toggle_submit_on_creation');
},

reference_doctype: function(frm) {
frm.trigger('toggle_submit_on_creation');
},

toggle_submit_on_creation: function(frm) {
// 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);
});
}, },


template: function(frm) { template: function(frm) {


+ 10
- 1
frappe/automation/doctype/auto_repeat/auto_repeat.json 查看文件

@@ -1,4 +1,5 @@
{ {
"actions": [],
"allow_import": 1, "allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "format:AUT-AR-{#####}", "autoname": "format:AUT-AR-{#####}",
@@ -12,6 +13,7 @@
"section_break_3", "section_break_3",
"reference_doctype", "reference_doctype",
"reference_document", "reference_document",
"submit_on_creation",
"column_break_5", "column_break_5",
"start_date", "start_date",
"end_date", "end_date",
@@ -186,9 +188,16 @@
"fieldname": "repeat_on_last_day", "fieldname": "repeat_on_last_day",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Repeat on Last Day of the Month" "label": "Repeat on Last Day of the Month"
},
{
"default": "0",
"fieldname": "submit_on_creation",
"fieldtype": "Check",
"label": "Submit on Creation"
} }
], ],
"modified": "2019-07-17 11:30:51.412317",
"links": [],
"modified": "2020-12-10 10:43:13.449172",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Automation", "module": "Automation",
"name": "Auto Repeat", "name": "Auto Repeat",


+ 10
- 1
frappe/automation/doctype/auto_repeat/auto_repeat.py 查看文件

@@ -21,6 +21,7 @@ class AutoRepeat(Document):
def validate(self): def validate(self):
self.update_status() self.update_status()
self.validate_reference_doctype() self.validate_reference_doctype()
self.validate_submit_on_creation()
self.validate_dates() self.validate_dates()
self.validate_email_id() self.validate_email_id()
self.set_dates() self.set_dates()
@@ -60,6 +61,11 @@ class AutoRepeat(Document):
if not frappe.get_meta(self.reference_doctype).allow_auto_repeat: if not frappe.get_meta(self.reference_doctype).allow_auto_repeat:
frappe.throw(_("Enable Allow Auto Repeat for the doctype {0} in Customize Form").format(self.reference_doctype)) frappe.throw(_("Enable Allow Auto Repeat for the doctype {0} in Customize Form").format(self.reference_doctype))


def validate_submit_on_creation(self):
if self.submit_on_creation and not frappe.get_meta(self.reference_doctype).is_submittable:
frappe.throw(_('Cannot enable {0} for a non-submittable doctype').format(
frappe.bold('Submit on Creation')))

def validate_dates(self): def validate_dates(self):
if frappe.flags.in_patch: if frappe.flags.in_patch:
return return
@@ -150,6 +156,9 @@ class AutoRepeat(Document):
self.update_doc(new_doc, reference_doc) self.update_doc(new_doc, reference_doc)
new_doc.insert(ignore_permissions = True) new_doc.insert(ignore_permissions = True)


if self.submit_on_creation:
new_doc.submit()

return new_doc return new_doc


def update_doc(self, new_doc, reference_doc): def update_doc(self, new_doc, reference_doc):
@@ -160,7 +169,7 @@ class AutoRepeat(Document):
if new_doc.meta.get_field('auto_repeat'): if new_doc.meta.get_field('auto_repeat'):
new_doc.set('auto_repeat', self.name) new_doc.set('auto_repeat', self.name)


for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'remarks', 'owner']:
for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'user_remark', 'remarks', 'owner']:
if new_doc.meta.get_field(fieldname): if new_doc.meta.get_field(fieldname):
new_doc.set(fieldname, reference_doc.get(fieldname)) new_doc.set(fieldname, reference_doc.get(fieldname))




+ 51
- 0
frappe/automation/doctype/auto_repeat/test_auto_repeat.py 查看文件

@@ -111,6 +111,25 @@ class TestAutoRepeat(unittest.TestCase):
doc = make_auto_repeat(frequency='Daily', reference_document=todo.name, start_date=add_days(today(), -2)) doc = make_auto_repeat(frequency='Daily', reference_document=todo.name, start_date=add_days(today(), -2))
self.assertEqual(getdate(doc.next_schedule_date), current_date) self.assertEqual(getdate(doc.next_schedule_date), current_date)


def test_submit_on_creation(self):
doctype = 'Test Submittable DocType'
create_submittable_doctype(doctype)

current_date = getdate()
submittable_doc = frappe.get_doc(dict(doctype=doctype, test='test submit on creation')).insert()
submittable_doc.submit()
doc = make_auto_repeat(frequency='Daily', reference_doctype=doctype, reference_document=submittable_doc.name,
start_date=add_days(current_date, -1), submit_on_creation=1)

data = get_auto_repeat_entries(current_date)
create_repeated_entries(data)
docnames = frappe.db.get_all(doc.reference_doctype,
filters={'auto_repeat': doc.name},
fields=['docstatus'],
limit=1
)
self.assertEquals(docnames[0].docstatus, 1)



def make_auto_repeat(**args): def make_auto_repeat(**args):
args = frappe._dict(args) args = frappe._dict(args)
@@ -118,6 +137,7 @@ def make_auto_repeat(**args):
'doctype': 'Auto Repeat', 'doctype': 'Auto Repeat',
'reference_doctype': args.reference_doctype or 'ToDo', 'reference_doctype': args.reference_doctype or 'ToDo',
'reference_document': args.reference_document or frappe.db.get_value('ToDo', 'name'), 'reference_document': args.reference_document or frappe.db.get_value('ToDo', 'name'),
'submit_on_creation': args.submit_on_creation or 0,
'frequency': args.frequency or 'Daily', 'frequency': args.frequency or 'Daily',
'start_date': args.start_date or add_days(today(), -1), 'start_date': args.start_date or add_days(today(), -1),
'end_date': args.end_date or "", 'end_date': args.end_date or "",
@@ -128,3 +148,34 @@ def make_auto_repeat(**args):
}).insert(ignore_permissions=True) }).insert(ignore_permissions=True)


return doc return doc


def create_submittable_doctype(doctype):
if frappe.db.exists('DocType', doctype):
return
else:
doc = frappe.get_doc({
'doctype': 'DocType',
'__newname': doctype,
'module': 'Custom',
'custom': 1,
'is_submittable': 1,
'fields': [{
'fieldname': 'test',
'label': 'Test',
'fieldtype': 'Data'
}],
'permissions': [{
'role': 'System Manager',
'read': 1,
'write': 1,
'create': 1,
'delete': 1,
'submit': 1,
'cancel': 1,
'amend': 1
}]
}).insert()

doc.allow_auto_repeat = 1
doc.save()

+ 1
- 1
frappe/build.py 查看文件

@@ -105,7 +105,7 @@ def download_frappe_assets(verbose=True):
if frappe_head: if frappe_head:
try: try:
url = get_assets_link(frappe_head) url = get_assets_link(frappe_head)
click.secho("Retreiving assets...", fg="yellow")
click.secho("Retrieving assets...", fg="yellow")
prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head) prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
assets_archive = download_file(url, prefix) assets_archive = download_file(url, prefix)
print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url)) print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url))


+ 121
- 0
frappe/change_log/v13/v13_0_0-beta_9.md 查看文件

@@ -0,0 +1,121 @@
### Version 13.0.0 Beta 9 Release Notes

#### Features and Enhancements

- Permission Query via Server Script ([#12034](https://github.com/frappe/frappe/pull/12034))
- Option to strip EXIF data from image files before uploading ([#12014](https://github.com/frappe/frappe/pull/12014))
- Provision to open child table in customize form ([#12030](https://github.com/frappe/frappe/pull/12030))
- Added date format support DD-Mon-YY ([#12056](https://github.com/frappe/frappe/pull/12056))
- Partial Backups & Restores ([#11104](https://github.com/frappe/frappe/pull/11104))
- Non negative check for Int, Float and Currency fields ([#11818](https://github.com/frappe/frappe/pull/11818))
- Allow html in email templates ([#12018](https://github.com/frappe/frappe/pull/12018))
- Web form fields support for property depends on fields (read-on… ([#11927](https://github.com/frappe/frappe/pull/11927))
- Conditional Events in Event Streaming ([#10868](https://github.com/frappe/frappe/pull/10868))
- Added filters and order_by fields in frappe.get_last_doc API ([#11870](https://github.com/frappe/frappe/pull/11870))
- Check if auto_repeat field is already present ([#11970](https://github.com/frappe/frappe/pull/11970))
- as_raw update to display content inline not always download as attachment ([#11569](https://github.com/frappe/frappe/pull/11569))
- Reset scroll position on list paging ([#11673](https://github.com/frappe/frappe/pull/11673))
- Validate sql file before restoring site ([#11878](https://github.com/frappe/frappe/pull/11878))
- Allow custom freeze message in Open Mapped Doc ([#11976](https://github.com/frappe/frappe/pull/11976))
- Show absolute value in print format ([#12019](https://github.com/frappe/frappe/pull/12019))
- Add links and actions to customize form and cleanup code ([#11565](https://github.com/frappe/frappe/pull/11565))
- Add module field in get_desk_sidebar_items ([#11781](https://github.com/frappe/frappe/pull/11781))

#### Fixes

- Maximum attachment limit validation ([#11940](https://github.com/frappe/frappe/pull/11940))
- Define chunk size based on backup file size to avoid timeout issues ([#11526](https://github.com/frappe/frappe/pull/11526))
- Fetch doc from db in get_transitions ([#11883](https://github.com/frappe/frappe/pull/11883))
- Remove Package Publish Tool ([#11863](https://github.com/frappe/frappe/pull/11863))
- Make strings translatable ([#11825](https://github.com/frappe/frappe/pull/11825))
- Clear cache after updating defaults manually ([#11830](https://github.com/frappe/frappe/pull/11830))
- Doctype query in create new shortcut ([#11864](https://github.com/frappe/frappe/pull/11864))
- Ace editor fixes ([#11920](https://github.com/frappe/frappe/pull/11920))
- Shorten number card percentage stat ([#11846](https://github.com/frappe/frappe/pull/11846))
- Error page rendering shouldn't fail when recorder is active ([#11806](https://github.com/frappe/frappe/pull/11806))
- Rename Doc considers invalid table name on renaming record ([#11743](https://github.com/frappe/frappe/pull/11743))
- Use modified by or owner to send notification from ([#11981](https://github.com/frappe/frappe/pull/11981))
- Bust cache by passing build_version to link and script src ([#11903](https://github.com/frappe/frappe/pull/11903))
- Customize options in desk page rtl layout ([#11772](https://github.com/frappe/frappe/pull/11772))
- Use sql_ddl to avoid exception triggered while running drop sql ([#11831](https://github.com/frappe/frappe/pull/11831))
- Render template for HTML content type ([#12035](https://github.com/frappe/frappe/pull/12035))
- Display style removed from emails ([#11963](https://github.com/frappe/frappe/pull/11963))
- Handle FileAlreadyAttachedException while pulling email ([#11713](https://github.com/frappe/frappe/pull/11713))
- Skip Email Account and Email Domain in Document Follow ([#11973](https://github.com/frappe/frappe/pull/11973))
- URI encode in case white spaces exist in docname ([#11783](https://github.com/frappe/frappe/pull/11783))
- Don't run a query when table is missing ([#11801](https://github.com/frappe/frappe/pull/11801))
- Submit on Creation configuration in Auto Repeat ([#12069](https://github.com/frappe/frappe/pull/12069))
- Add missing space in description ([#11787](https://github.com/frappe/frappe/pull/11787))
- Query to handle user value having special characters ([#11694](https://github.com/frappe/frappe/pull/11694))
- TypeError on saving report with child table ([#11925](https://github.com/frappe/frappe/pull/11925))
- Translate Kanban board title ([#11985](https://github.com/frappe/frappe/pull/11985))
- Set label as State/Province in Address instead of just State ([#11748](https://github.com/frappe/frappe/pull/11748))
- Allow "Default Print Language" for custom Print Format ([#11800](https://github.com/frappe/frappe/pull/11800))
- Validate SQL files better ([#11930](https://github.com/frappe/frappe/pull/11930))
- Validate links table data ([#11884](https://github.com/frappe/frappe/pull/11884))
- Use html.unescape for Python 3.9 compatibility. ([#12005](https://github.com/frappe/frappe/pull/12005))
- Use set_header to set Message-Id in header ([#11778](https://github.com/frappe/frappe/pull/11778))
- Typo when creating an SMTP server without port ([#11996](https://github.com/frappe/frappe/pull/11996))
- Add rename_doc utils for external API usages ([#12011](https://github.com/frappe/frappe/pull/12011))
- Treats the scrollbar as an overlay ([#11790](https://github.com/frappe/frappe/pull/11790))
- Dashboard not visible bug ([#11918](https://github.com/frappe/frappe/pull/11918))
- Total Row in Checkbox Column Reports ([#11872](https://github.com/frappe/frappe/pull/11872))
- Remove scrolling on focusout event for touchscreen devices ([#11888](https://github.com/frappe/frappe/pull/11888))
- Add default to Web Template Field ([#11780](https://github.com/frappe/frappe/pull/11780))
- Order of HTML closing tags ([#11923](https://github.com/frappe/frappe/pull/11923))
- Show total text with value if first column is numeric ([#11813](https://github.com/frappe/frappe/pull/11813))
- Milestone not created for fields updated after submission ([#11793](https://github.com/frappe/frappe/pull/11793))
- Cint seconds before operations ([#12067](https://github.com/frappe/frappe/pull/12067))
- Validate email id before passing to formataddr ([#11720](https://github.com/frappe/frappe/pull/11720))
- Use frappe.utils.shorten_number ([#12039](https://github.com/frappe/frappe/pull/12039))
- Mandatory depends on in grid form ([#11834](https://github.com/frappe/frappe/pull/11834))
- Calculate chart data from beginning of period ([#11794](https://github.com/frappe/frappe/pull/11794))
- Move unnecessary compileall outside migrate ([#11833](https://github.com/frappe/frappe/pull/11833))
- PDF generation shouldn't fail in background jobs and tests ([#11792](https://github.com/frappe/frappe/pull/11792))
- Throw exception if template not found ([#11843](https://github.com/frappe/frappe/pull/11843))
- Security upgrade snyk from 1.398.1 to 1.425.4 ([#11990](https://github.com/frappe/frappe/pull/11990))
- Show custom message for invalid login credentials ([#11853](https://github.com/frappe/frappe/pull/11853))
- Remove twilio integration ([#11841](https://github.com/frappe/frappe/pull/11841))
- Navbar logo height and width ([#11822](https://github.com/frappe/frappe/pull/11822))
- Allow all Data, Link, Dynamic Link fields to be set in based on field ([#11922](https://github.com/frappe/frappe/pull/11922))
- Error on trying to check semantic version ([#11916](https://github.com/frappe/frappe/pull/11916))
- Notification settings ([#11862](https://github.com/frappe/frappe/pull/11862))
- Manage private images via get_local_image ([#11935](https://github.com/frappe/frappe/pull/11935))
- Filter dashboards, dashboard charts, number cards by modules ([#12057](https://github.com/frappe/frappe/pull/12057))
- Label for _assign field ([#12038](https://github.com/frappe/frappe/pull/12038))
- Bypass validation if force is passed ([#11915](https://github.com/frappe/frappe/pull/11915))
- Max slices for aggregate charts ([#11808](https://github.com/frappe/frappe/pull/11808))
- Delete prepared reports in batches ([#11869](https://github.com/frappe/frappe/pull/11869))
- Allow empty type for Web Templates that are not sections ([#11628](https://github.com/frappe/frappe/pull/11628))
- Allow other github links in same PR ([#11982](https://github.com/frappe/frappe/pull/11982))
- Don't set context.no_cache ([#11799](https://github.com/frappe/frappe/pull/11799))
- Remove telephony related code ([#12017](https://github.com/frappe/frappe/pull/12017))
- Don't throw if filter is invalid ([#11866](https://github.com/frappe/frappe/pull/11866))
- Show 0 instead of undefined when value not set in dashboard ([#11816](https://github.com/frappe/frappe/pull/11816))
- Disable chart form condition ([#11844](https://github.com/frappe/frappe/pull/11844))
- Enable disable save when navigating between docs ([#11867](https://github.com/frappe/frappe/pull/11867))
- Don't enqueue a job if it is being executed ([#11655](https://github.com/frappe/frappe/pull/11655))
- Clear localstorage if quota exceeds ([#12002](https://github.com/frappe/frappe/pull/12002))
- Uninstall app enhancements ([#11911](https://github.com/frappe/frappe/pull/11911))
- Make route and action readonly for standard navbar items ([#11842](https://github.com/frappe/frappe/pull/11842))
- Show scrollbar for datatable ([#11910](https://github.com/frappe/frappe/pull/11910))
- Open console even if Frappe imports failed ([#11832](https://github.com/frappe/frappe/pull/11832))
- Set user selected timezone in user defaults ([#11902](https://github.com/frappe/frappe/pull/11902))
- Email password prompt missing field name for submit button ([#11840](https://github.com/frappe/frappe/pull/11840))
- Add semicolons to end unicode ([#11993](https://github.com/frappe/frappe/pull/11993))
- Replace target field in Top Bar Item table with a checkbox ([#11763](https://github.com/frappe/frappe/pull/11763))
- shorten_number function ([#12050](https://github.com/frappe/frappe/pull/12050))
- Not able to save Domain Settings ([#11984](https://github.com/frappe/frappe/pull/11984))
- uninstall-app enhancements ([#11969](https://github.com/frappe/frappe/pull/11969))
- Remove @ from relevance query ([#11837](https://github.com/frappe/frappe/pull/11837))
- Login Code Size too Small on Mobile ([#11742](https://github.com/frappe/frappe/pull/11742))
- Add namespaces to build_summary_item, generate_route, short… ([#11868](https://github.com/frappe/frappe/pull/11868))
- allow "only image" in comments ([#11914](https://github.com/frappe/frappe/pull/11914))
- oauth2 ([#11966](https://github.com/frappe/frappe/pull/11966))
- Server scripts enhancements ([#12008](https://github.com/frappe/frappe/pull/12008))
- Multiple y rows in charts ([#12031](https://github.com/frappe/frappe/pull/12031))
- Update child values for existing rows ([#11737](https://github.com/frappe/frappe/pull/11737))
- Auto contact creation in email account ([#11732](https://github.com/frappe/frappe/pull/11732))
- Display web template after save ([#11809](https://github.com/frappe/frappe/pull/11809))
- Create auto_repeat field only if docfield/custom field does not exist ([#11827](https://github.com/frappe/frappe/pull/11827))
- frappe.utils.formatdate not working in the jinja template ([#11871](https://github.com/frappe/frappe/pull/11871))

+ 12
- 18
frappe/chat/util/util.py 查看文件

@@ -1,27 +1,21 @@
from __future__ import unicode_literals from __future__ import unicode_literals


# imports - third-party imports
import requests

# imports - compatibility imports
import six

# imports - standard imports # imports - standard imports
from collections import Sequence, MutableSequence, Mapping, MutableMapping
if six.PY2:
from urlparse import urlparse # PY2
else:
from urllib.parse import urlparse # PY3
import json import json
from collections.abc import MutableMapping, MutableSequence, Sequence

# imports - third-party imports
import requests
from urllib.parse import urlparse


# imports - module imports # imports - module imports
from frappe.model.document import Document
from frappe.exceptions import DuplicateEntryError
from frappe import _dict
import frappe import frappe
from frappe.exceptions import DuplicateEntryError
from frappe.model.document import Document


session = frappe.session session = frappe.session



def get_user_doc(user = None): def get_user_doc(user = None):
if isinstance(user, Document): if isinstance(user, Document):
return user return user
@@ -38,12 +32,12 @@ def squashify(what):
return what return what


def safe_json_loads(*args): def safe_json_loads(*args):
results = [ ]
results = []


for arg in args: for arg in args:
try: try:
arg = json.loads(arg) arg = json.loads(arg)
except Exception as e:
except Exception:
pass pass


results.append(arg) results.append(arg)
@@ -81,7 +75,7 @@ def dictify(arg):
for i, a in enumerate(arg): for i, a in enumerate(arg):
arg[i] = dictify(a) arg[i] = dictify(a)
elif isinstance(arg, MutableMapping): elif isinstance(arg, MutableMapping):
arg = _dict(arg)
arg = frappe._dict(arg)


return arg return arg


@@ -113,4 +107,4 @@ def get_emojis():
emojis = resp.json() emojis = resp.json()
redis.hset('frappe_emojis', 'emojis', emojis) redis.hset('frappe_emojis', 'emojis', emojis)


return dictify(emojis)
return dictify(emojis)

+ 132
- 99
frappe/commands/site.py 查看文件

@@ -9,7 +9,7 @@ import click
import frappe import frappe
from frappe.commands import get_site, pass_context from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import get_site_path, touch_file
from frappe.installer import _new_site




@click.command('new-site') @click.command('new-site')
@@ -42,57 +42,6 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin
if len(frappe.utils.get_sites()) == 1: if len(frappe.utils.get_sites()) == 1:
use(site) use(site)


def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None,
admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False,
no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None,
db_port=None, new_site=False):
"""Install a new Frappe site"""

if not force and os.path.exists(site):
print('Site {0} already exists'.format(site))
sys.exit(1)

if no_mariadb_socket and not db_type == "mariadb":
print('--no-mariadb-socket requires db_type to be set to mariadb.')
sys.exit(1)

if not db_name:
import hashlib
db_name = '_' + hashlib.sha1(site.encode()).hexdigest()[:16]

from frappe.commands.scheduler import _is_scheduler_enabled
from frappe.installer import install_db, make_site_dirs
from frappe.installer import install_app as _install_app
import frappe.utils.scheduler

frappe.init(site=site)

try:

# enable scheduler post install?
enable_scheduler = _is_scheduler_enabled()
except Exception:
enable_scheduler = False

make_site_dirs()

installing = touch_file(get_site_path('locks', 'installing.lock'))

install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name,
admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall,
db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket)
apps_to_install = ['frappe'] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or [])
for app in apps_to_install:
_install_app(app, verbose=verbose, set_as_patched=not source_sql)

os.remove(installing)

frappe.utils.scheduler.toggle_scheduler(enable_scheduler)
frappe.db.commit()

scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled"
print("*** Scheduler is", scheduler_status, "***")



@click.command('restore') @click.command('restore')
@click.argument('sql-file-path') @click.argument('sql-file-path')
@@ -103,36 +52,45 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N
@click.option('--install-app', multiple=True, help='Install app after installation') @click.option('--install-app', multiple=True, help='Install app after installation')
@click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file') @click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file')
@click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file') @click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file')
@click.option('--force', is_flag=True, default=False, help='Ignore the site downgrade warning, if applicable')
@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended')
@pass_context @pass_context
def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None):
"Restore site database from an sql file" "Restore site database from an sql file"
from frappe.installer import extract_sql_gzip, extract_files, is_downgrade
force = context.force or force
from frappe.installer import (
extract_sql_from_archive,
extract_files,
is_downgrade,
is_partial,
validate_database_sql
)


# Extract the gzip file if user has passed *.sql.gz file instead of *.sql file
if not os.path.exists(sql_file_path):
base_path = '..'
sql_file_path = os.path.join(base_path, sql_file_path)
if not os.path.exists(sql_file_path):
print('Invalid path {0}'.format(sql_file_path[3:]))
sys.exit(1)
elif sql_file_path.startswith(os.sep):
base_path = os.sep
else:
base_path = '.'
force = context.force or force
decompressed_file_name = extract_sql_from_archive(sql_file_path)

# check if partial backup
if is_partial(decompressed_file_name):
click.secho(
"Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.",
fg="red"
)
click.secho(
"Use `bench partial-restore` to restore a partial backup to an existing site.",
fg="yellow"
)
sys.exit(1)


if sql_file_path.endswith('sql.gz'):
decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path))
else:
decompressed_file_name = sql_file_path
# check if valid SQL file
validate_database_sql(decompressed_file_name, _raise=not force)


site = get_site(context) site = get_site(context)
frappe.init(site=site) frappe.init(site=site)


# dont allow downgrading to older versions of frappe without force # dont allow downgrading to older versions of frappe without force
if not force and is_downgrade(decompressed_file_name, verbose=True): if not force and is_downgrade(decompressed_file_name, verbose=True):
warn_message = "This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
warn_message = (
"This is not recommended and may lead to unexpected behaviour. "
"Do you want to continue anyway?"
)
click.confirm(warn_message, abort=True) click.confirm(warn_message, abort=True)


_new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
@@ -142,22 +100,39 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas


# Extract public and/or private files to the restored site, if user has given the path # Extract public and/or private files to the restored site, if user has given the path
if with_public_files: if with_public_files:
with_public_files = os.path.join(base_path, with_public_files)
public = extract_files(site, with_public_files, 'public')
public = extract_files(site, with_public_files)
os.remove(public) os.remove(public)


if with_private_files: if with_private_files:
with_private_files = os.path.join(base_path, with_private_files)
private = extract_files(site, with_private_files, 'private')
private = extract_files(site, with_private_files)
os.remove(private) os.remove(private)


# Removing temporarily created file # Removing temporarily created file
if decompressed_file_name != sql_file_path: if decompressed_file_name != sql_file_path:
os.remove(decompressed_file_name) os.remove(decompressed_file_name)


success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "")
success_message = "Site {0} has been restored{1}".format(
site,
" with files" if (with_public_files or with_private_files) else ""
)
click.secho(success_message, fg="green") click.secho(success_message, fg="green")



@click.command('partial-restore')
@click.argument('sql-file-path')
@click.option("--verbose", "-v", is_flag=True)
@pass_context
def partial_restore(context, sql_file_path, verbose):
from frappe.installer import partial_restore
verbose = context.verbose or verbose

site = get_site(context)
frappe.init(site=site)
frappe.connect(site=site)
partial_restore(sql_file_path, verbose)
frappe.destroy()


@click.command('reinstall') @click.command('reinstall')
@click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--admin-password', help='Administrator Password for reinstalled site')
@click.option('--mariadb-root-username', help='Root username for MariaDB') @click.option('--mariadb-root-username', help='Root username for MariaDB')
@@ -222,15 +197,51 @@ def install_app(context, apps):
sys.exit(exit_code) sys.exit(exit_code)




@click.command('list-apps')
@click.command("list-apps")
@pass_context @pass_context
def list_apps(context): def list_apps(context):
"List apps in site" "List apps in site"
site = get_site(context)
frappe.init(site=site)
frappe.connect()
print("\n".join(frappe.get_installed_apps()))
frappe.destroy()

def fix_whitespaces(text):
if site == context.sites[-1]:
text = text.rstrip()
if len(context.sites) == 1:
text = text.lstrip()
return text

for site in context.sites:
frappe.init(site=site)
frappe.connect()
site_title = (
click.style(f"{site}", fg="green") if len(context.sites) > 1 else ""
)
apps = frappe.get_single("Installed Applications").installed_applications

if apps:
name_len, ver_len = [
max([len(x.get(y)) for x in apps])
for y in ["app_name", "app_version"]
]
template = "{{0:{0}}} {{1:{1}}} {{2}}".format(name_len, ver_len)

installed_applications = [
template.format(app.app_name, app.app_version, app.git_branch)
for app in apps
]
applications_summary = "\n".join(installed_applications)
summary = f"{site_title}\n{applications_summary}\n"

else:
applications_summary = "\n".join(frappe.get_installed_apps())
summary = f"{site_title}\n{applications_summary}\n"

summary = fix_whitespaces(summary)

if applications_summary and summary:
print(summary)

frappe.destroy()



@click.command('add-system-manager') @click.command('add-system-manager')
@click.argument('email') @click.argument('email')
@@ -265,14 +276,12 @@ def disable_user(context, email):
user.save(ignore_permissions=True) user.save(ignore_permissions=True)
frappe.db.commit() frappe.db.commit()



@click.command('migrate') @click.command('migrate')
@click.option('--skip-failing', is_flag=True, help="Skip patches that fail to run") @click.option('--skip-failing', is_flag=True, help="Skip patches that fail to run")
@click.option('--skip-search-index', is_flag=True, help="Skip search indexing for web documents") @click.option('--skip-search-index', is_flag=True, help="Skip search indexing for web documents")
@pass_context @pass_context
def migrate(context, skip_failing=False, skip_search_index=False): def migrate(context, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations" "Run patches, sync schema and rebuild files/translations"
import compileall
import re import re
from frappe.migrate import migrate from frappe.migrate import migrate


@@ -291,9 +300,6 @@ def migrate(context, skip_failing=False, skip_search_index=False):
if not context.sites: if not context.sites:
raise SiteNotSpecifiedError raise SiteNotSpecifiedError


print("Compiling Python files...")
compileall.compile_dir('../apps', quiet=1, rx=re.compile('.*node_modules.*'))

@click.command('migrate-to') @click.command('migrate-to')
@click.argument('frappe_provider') @click.argument('frappe_provider')
@pass_context @pass_context
@@ -310,15 +316,16 @@ def migrate_to(context, frappe_provider):


@click.command('run-patch') @click.command('run-patch')
@click.argument('module') @click.argument('module')
@click.option('--force', is_flag=True)
@pass_context @pass_context
def run_patch(context, module):
def run_patch(context, module, force):
"Run a particular patch" "Run a particular patch"
import frappe.modules.patch_handler import frappe.modules.patch_handler
for site in context.sites: for site in context.sites:
frappe.init(site=site) frappe.init(site=site)
try: try:
frappe.connect() frappe.connect()
frappe.modules.patch_handler.run_single(module, force=context.force)
frappe.modules.patch_handler.run_single(module, force=force or context.force)
finally: finally:
frappe.destroy() frappe.destroy()
if not context.sites: if not context.sites:
@@ -383,16 +390,20 @@ def use(site, sites_path='.'):


@click.command('backup') @click.command('backup')
@click.option('--with-files', default=False, is_flag=True, help="Take backup with files") @click.option('--with-files', default=False, is_flag=True, help="Take backup with files")
@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas")
@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas")
@click.option('--backup-path', default=None, help="Set path for saving all the files in this operation") @click.option('--backup-path', default=None, help="Set path for saving all the files in this operation")
@click.option('--backup-path-db', default=None, help="Set path for saving database file") @click.option('--backup-path-db', default=None, help="Set path for saving database file")
@click.option('--backup-path-files', default=None, help="Set path for saving public file") @click.option('--backup-path-files', default=None, help="Set path for saving public file")
@click.option('--backup-path-private-files', default=None, help="Set path for saving private file") @click.option('--backup-path-private-files', default=None, help="Set path for saving private file")
@click.option('--backup-path-conf', default=None, help="Set path for saving config file") @click.option('--backup-path-conf', default=None, help="Set path for saving config file")
@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config")
@click.option('--verbose', default=False, is_flag=True, help="Add verbosity") @click.option('--verbose', default=False, is_flag=True, help="Add verbosity")
@click.option('--compress', default=False, is_flag=True, help="Compress private and public files") @click.option('--compress', default=False, is_flag=True, help="Compress private and public files")
@pass_context @pass_context
def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None,
backup_path_private_files=None, backup_path_conf=None, verbose=False, compress=False):
backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False,
compress=False, include="", exclude=""):
"Backup" "Backup"
from frappe.utils.backups import scheduled_backup from frappe.utils.backups import scheduled_backup
verbose = verbose or context.verbose verbose = verbose or context.verbose
@@ -402,11 +413,27 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
try: try:
frappe.init(site=site) frappe.init(site=site)
frappe.connect() frappe.connect()
odb = scheduled_backup(ignore_files=not with_files, backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, force=True, verbose=verbose, compress=compress)
odb = scheduled_backup(
ignore_files=not with_files,
backup_path=backup_path,
backup_path_db=backup_path_db,
backup_path_files=backup_path_files,
backup_path_private_files=backup_path_private_files,
backup_path_conf=backup_path_conf,
ignore_conf=ignore_backup_conf,
include_doctypes=include,
exclude_doctypes=exclude,
compress=compress,
verbose=verbose,
force=True
)
except Exception: except Exception:
click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red")
if verbose:
print(frappe.get_traceback())
exit_code = 1 exit_code = 1
continue continue

odb.print_summary() odb.print_summary()
click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green") click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green")
frappe.destroy() frappe.destroy()
@@ -479,13 +506,14 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
if force: if force:
pass pass
else: else:
click.echo("="*80)
click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site))
click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n"))
click.echo("Fix the issue and try again.")
click.echo(
"Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site)
)
messages = [
"=" * 80,
"Error: The operation has stopped because backup of {0}'s database failed.".format(site),
"Reason: {0}\n".format(str(err)),
"Fix the issue and try again.",
"Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site)
]
click.echo("\n".join(messages))
sys.exit(1) sys.exit(1)


drop_user_and_database(frappe.conf.db_name, root_login, root_password) drop_user_and_database(frappe.conf.db_name, root_login, root_password)
@@ -615,8 +643,10 @@ def browse(context, site):
@click.command('start-recording') @click.command('start-recording')
@pass_context @pass_context
def start_recording(context): def start_recording(context):
import frappe.recorder
for site in context.sites: for site in context.sites:
frappe.init(site=site) frappe.init(site=site)
frappe.set_user("Administrator")
frappe.recorder.start() frappe.recorder.start()
if not context.sites: if not context.sites:
raise SiteNotSpecifiedError raise SiteNotSpecifiedError
@@ -625,8 +655,10 @@ def start_recording(context):
@click.command('stop-recording') @click.command('stop-recording')
@pass_context @pass_context
def stop_recording(context): def stop_recording(context):
import frappe.recorder
for site in context.sites: for site in context.sites:
frappe.init(site=site) frappe.init(site=site)
frappe.set_user("Administrator")
frappe.recorder.stop() frappe.recorder.stop()
if not context.sites: if not context.sites:
raise SiteNotSpecifiedError raise SiteNotSpecifiedError
@@ -697,5 +729,6 @@ commands = [
stop_recording, stop_recording,
add_to_hosts, add_to_hosts,
start_ngrok, start_ngrok,
build_search_index
build_search_index,
partial_restore
] ]

+ 11
- 1
frappe/commands/utils.py 查看文件

@@ -460,11 +460,21 @@ def console(context):
frappe.init(site=site) frappe.init(site=site)
frappe.connect() frappe.connect()
frappe.local.lang = frappe.db.get_default("lang") frappe.local.lang = frappe.db.get_default("lang")

import IPython import IPython
all_apps = frappe.get_installed_apps() all_apps = frappe.get_installed_apps()
failed_to_import = []

for app in all_apps: for app in all_apps:
locals()[app] = __import__(app)
try:
locals()[app] = __import__(app)
except ModuleNotFoundError:
failed_to_import.append(app)

print("Apps in this namespace:\n{}".format(", ".join(all_apps))) print("Apps in this namespace:\n{}".format(", ".join(all_apps)))
if failed_to_import:
print("\nFailed to import:\n{}".format(", ".join(failed_to_import)))

IPython.embed(display_banner="", header="", colors="neutral") IPython.embed(display_banner="", header="", colors="neutral")






+ 0
- 6
frappe/config/customization.py 查看文件

@@ -54,12 +54,6 @@ def get_data():
"label": _("Custom Translations"), "label": _("Custom Translations"),
"name": "Translation", "name": "Translation",
"description": _("Add your own translations") "description": _("Add your own translations")
},
{
"type": "doctype",
"label": _("Package"),
"name": "Package",
"description": _("Import and Export Packages.")
} }
] ]
} }


+ 2
- 2
frappe/contacts/doctype/address/address.json 查看文件

@@ -75,7 +75,7 @@
{ {
"fieldname": "state", "fieldname": "state",
"fieldtype": "Data", "fieldtype": "Data",
"label": "State"
"label": "State/Province"
}, },
{ {
"fieldname": "country", "fieldname": "country",
@@ -148,7 +148,7 @@
"icon": "fa fa-map-marker", "icon": "fa fa-map-marker",
"idx": 5, "idx": 5,
"links": [], "links": [],
"modified": "2020-10-14 17:38:08.971776",
"modified": "2020-10-21 16:14:37.284830",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Contacts", "module": "Contacts",
"name": "Address", "name": "Address",


+ 4
- 6
frappe/core/doctype/communication/communication.py 查看文件

@@ -260,10 +260,8 @@ class Communication(Document):
# Timeline Links # Timeline Links
def set_timeline_links(self): def set_timeline_links(self):
contacts = [] contacts = []
if (self.email_account and frappe.db.get_value("Email Account", self.email_account, "create_contact")) or \
frappe.flags.in_test:

contacts = get_contacts([self.sender, self.recipients, self.cc, self.bcc])
create_contact_enabled = self.email_account and frappe.db.get_value("Email Account", self.email_account, "create_contact")
contacts = get_contacts([self.sender, self.recipients, self.cc, self.bcc], auto_create_contact=create_contact_enabled)


for contact_name in contacts: for contact_name in contacts:
self.add_link('Contact', contact_name) self.add_link('Contact', contact_name)
@@ -342,7 +340,7 @@ def get_permission_query_conditions_for_communication(user):
return """`tabCommunication`.email_account in ({email_accounts})"""\ return """`tabCommunication`.email_account in ({email_accounts})"""\
.format(email_accounts=','.join(email_accounts)) .format(email_accounts=','.join(email_accounts))


def get_contacts(email_strings):
def get_contacts(email_strings, auto_create_contact=False):
email_addrs = [] email_addrs = []


for email_string in email_strings: for email_string in email_strings:
@@ -357,7 +355,7 @@ def get_contacts(email_strings):
email = get_email_without_link(email) email = get_email_without_link(email)
contact_name = get_contact_name(email) contact_name = get_contact_name(email)


if not contact_name and email:
if not contact_name and email and auto_create_contact:
email_parts = email.split("@") email_parts = email.split("@")
first_name = frappe.unscrub(email_parts[0]) first_name = frappe.unscrub(email_parts[0])




+ 9
- 1
frappe/core/doctype/docfield/docfield.json 查看文件

@@ -13,6 +13,7 @@
"fieldname", "fieldname",
"precision", "precision",
"length", "length",
"non_negative",
"hide_days", "hide_days",
"hide_seconds", "hide_seconds",
"reqd", "reqd",
@@ -473,13 +474,20 @@
"fieldname": "hide_border", "fieldname": "hide_border",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Hide Border" "label": "Hide Border"
},
{
"default": "0",
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)",
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
} }
], ],
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-28 11:28:21.252853",
"modified": "2020-10-29 06:09:26.454990",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocField", "name": "DocField",


+ 22
- 4
frappe/core/doctype/doctype/doctype.py 查看文件

@@ -56,7 +56,8 @@ class DocType(Document):
- Check fieldnames (duplication etc) - Check fieldnames (duplication etc)
- Clear permission table for child tables - Clear permission table for child tables
- Add `amended_from` and `amended_by` if Amendable - Add `amended_from` and `amended_by` if Amendable
- Add custom field `auto_repeat` if Repeatable"""
- Add custom field `auto_repeat` if Repeatable
- Check if links point to valid fieldnames"""


self.check_developer_mode() self.check_developer_mode()


@@ -88,6 +89,7 @@ class DocType(Document):
self.make_repeatable() self.make_repeatable()
self.validate_nestedset() self.validate_nestedset()
self.validate_website() self.validate_website()
self.validate_links_table_fieldnames()


if not self.is_new(): if not self.is_new():
self.before_update = frappe.get_doc('DocType', self.name) self.before_update = frappe.get_doc('DocType', self.name)
@@ -392,7 +394,10 @@ class DocType(Document):
frappe.db.sql("""update tabSingles set value=%s frappe.db.sql("""update tabSingles set value=%s
where doctype=%s and field='name' and value = %s""", (new, new, old)) where doctype=%s and field='name' and value = %s""", (new, new, old))
else: else:
frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new))
frappe.db.multisql({
"mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`",
"postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`"
})


def rename_files_and_folders(self, old, new): def rename_files_and_folders(self, old, new):
# move files # move files
@@ -657,6 +662,19 @@ class DocType(Document):
if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags): if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags):
frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError) frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError)


def validate_links_table_fieldnames(self):
"""Validate fieldnames in Links table"""
if frappe.flags.in_patch: return
if frappe.flags.in_fixtures: return
if not self.links: return

for index, link in enumerate(self.links):
meta = frappe.get_meta(link.link_doctype)
if not meta.get_field(link.link_fieldname):
message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype))
frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname"))




def validate_fields_for_doctype(doctype): def validate_fields_for_doctype(doctype):
doc = frappe.get_doc("DocType", doctype) doc = frappe.get_doc("DocType", doctype)
@@ -753,8 +771,8 @@ def validate_fields(meta):
def check_illegal_default(d): def check_illegal_default(d):
if d.fieldtype == "Check" and not d.default: if d.fieldtype == "Check" and not d.default:
d.default = '0' d.default = '0'
if d.fieldtype == "Check" and d.default not in ('0', '1'):
frappe.throw(_("Default for 'Check' type of field must be either '0' or '1'"))
if d.fieldtype == "Check" and cint(d.default) not in (0, 1):
frappe.throw(_("Default for 'Check' type of field {0} must be either '0' or '1'").format(frappe.bold(d.fieldname)))
if d.fieldtype == "Select" and d.default: if d.fieldtype == "Select" and d.default:
if not d.options: if not d.options:
frappe.throw(_("Options for {0} must be set before setting the default value.").format(frappe.bold(d.fieldname))) frappe.throw(_("Options for {0} must be set before setting the default value.").format(frappe.bold(d.fieldname)))


+ 71
- 38
frappe/core/doctype/doctype/test_doctype.py 查看文件

@@ -12,41 +12,22 @@ from frappe.core.doctype.doctype.doctype import UniqueFieldnameError, IllegalMan




class TestDocType(unittest.TestCase): class TestDocType(unittest.TestCase):
def new_doctype(self, name, unique=0, depends_on=''):
return frappe.get_doc({
"doctype": "DocType",
"module": "Core",
"custom": 1,
"fields": [{
"label": "Some Field",
"fieldname": "some_fieldname",
"fieldtype": "Data",
"unique": unique,
"depends_on": depends_on,
}],
"permissions": [{
"role": "System Manager",
"read": 1,
}],
"name": name
})

def test_validate_name(self): def test_validate_name(self):
self.assertRaises(frappe.NameError, self.new_doctype("_Some DocType").insert)
self.assertRaises(frappe.NameError, self.new_doctype("8Some DocType").insert)
self.assertRaises(frappe.NameError, self.new_doctype("Some (DocType)").insert)
self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert)
self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert)
self.assertRaises(frappe.NameError, new_doctype("Some (DocType)").insert)
for name in ("Some DocType", "Some_DocType"): for name in ("Some DocType", "Some_DocType"):
if frappe.db.exists("DocType", name): if frappe.db.exists("DocType", name):
frappe.delete_doc("DocType", name) frappe.delete_doc("DocType", name)


doc = self.new_doctype(name).insert()
doc = new_doctype(name).insert()
doc.delete() doc.delete()


def test_doctype_unique_constraint_dropped(self): def test_doctype_unique_constraint_dropped(self):
if frappe.db.exists("DocType", "With_Unique"): if frappe.db.exists("DocType", "With_Unique"):
frappe.delete_doc("DocType", "With_Unique") frappe.delete_doc("DocType", "With_Unique")


dt = self.new_doctype("With_Unique", unique=1)
dt = new_doctype("With_Unique", unique=1)
dt.insert() dt.insert()


doc1 = frappe.new_doc("With_Unique") doc1 = frappe.new_doc("With_Unique")
@@ -67,7 +48,7 @@ class TestDocType(unittest.TestCase):
doc2.delete() doc2.delete()


def test_validate_search_fields(self): def test_validate_search_fields(self):
doc = self.new_doctype("Test Search Fields")
doc = new_doctype("Test Search Fields")
doc.search_fields = "some_fieldname" doc.search_fields = "some_fieldname"
doc.insert() doc.insert()
self.assertEqual(doc.name, "Test Search Fields") self.assertEqual(doc.name, "Test Search Fields")
@@ -85,7 +66,7 @@ class TestDocType(unittest.TestCase):
self.assertRaises(frappe.ValidationError, doc.save) self.assertRaises(frappe.ValidationError, doc.save)


def test_depends_on_fields(self): def test_depends_on_fields(self):
doc = self.new_doctype("Test Depends On", depends_on="eval:doc.__islocal == 0")
doc = new_doctype("Test Depends On", depends_on="eval:doc.__islocal == 0")
doc.insert() doc.insert()


# check if the assignment operation is allowed in depends_on # check if the assignment operation is allowed in depends_on
@@ -261,7 +242,7 @@ class TestDocType(unittest.TestCase):
frappe.flags.allow_doctype_export = 0 frappe.flags.allow_doctype_export = 0


def test_unique_field_name_for_two_fields(self): def test_unique_field_name_for_two_fields(self):
doc = self.new_doctype('Test Unique Field')
doc = new_doctype('Test Unique Field')
field_1 = doc.append('fields', {}) field_1 = doc.append('fields', {})
field_1.fieldname = 'some_fieldname_1' field_1.fieldname = 'some_fieldname_1'
field_1.fieldtype = 'Data' field_1.fieldtype = 'Data'
@@ -273,7 +254,7 @@ class TestDocType(unittest.TestCase):
self.assertRaises(UniqueFieldnameError, doc.insert) self.assertRaises(UniqueFieldnameError, doc.insert)


def test_fieldname_is_not_name(self): def test_fieldname_is_not_name(self):
doc = self.new_doctype('Test Name Field')
doc = new_doctype('Test Name Field')
field_1 = doc.append('fields', {}) field_1 = doc.append('fields', {})
field_1.label = 'Name' field_1.label = 'Name'
field_1.fieldtype = 'Data' field_1.fieldtype = 'Data'
@@ -283,7 +264,7 @@ class TestDocType(unittest.TestCase):
self.assertRaises(InvalidFieldNameError, doc.save) self.assertRaises(InvalidFieldNameError, doc.save)


def test_illegal_mandatory_validation(self): def test_illegal_mandatory_validation(self):
doc = self.new_doctype('Test Illegal mandatory')
doc = new_doctype('Test Illegal mandatory')
field_1 = doc.append('fields', {}) field_1 = doc.append('fields', {})
field_1.fieldname = 'some_fieldname_1' field_1.fieldname = 'some_fieldname_1'
field_1.fieldtype = 'Section Break' field_1.fieldtype = 'Section Break'
@@ -292,7 +273,7 @@ class TestDocType(unittest.TestCase):
self.assertRaises(IllegalMandatoryError, doc.insert) self.assertRaises(IllegalMandatoryError, doc.insert)


def test_link_with_wrong_and_no_options(self): def test_link_with_wrong_and_no_options(self):
doc = self.new_doctype('Test link')
doc = new_doctype('Test link')
field_1 = doc.append('fields', {}) field_1 = doc.append('fields', {})
field_1.fieldname = 'some_fieldname_1' field_1.fieldname = 'some_fieldname_1'
field_1.fieldtype = 'Link' field_1.fieldtype = 'Link'
@@ -304,7 +285,7 @@ class TestDocType(unittest.TestCase):
self.assertRaises(WrongOptionsDoctypeLinkError, doc.insert) self.assertRaises(WrongOptionsDoctypeLinkError, doc.insert)


def test_hidden_and_mandatory_without_default(self): def test_hidden_and_mandatory_without_default(self):
doc = self.new_doctype('Test hidden and mandatory')
doc = new_doctype('Test hidden and mandatory')
field_1 = doc.append('fields', {}) field_1 = doc.append('fields', {})
field_1.fieldname = 'some_fieldname_1' field_1.fieldname = 'some_fieldname_1'
field_1.fieldtype = 'Data' field_1.fieldtype = 'Data'
@@ -314,7 +295,7 @@ class TestDocType(unittest.TestCase):
self.assertRaises(HiddenAndMandatoryWithoutDefaultError, doc.insert) self.assertRaises(HiddenAndMandatoryWithoutDefaultError, doc.insert)


def test_field_can_not_be_indexed_validation(self): def test_field_can_not_be_indexed_validation(self):
doc = self.new_doctype('Test index')
doc = new_doctype('Test index')
field_1 = doc.append('fields', {}) field_1 = doc.append('fields', {})
field_1.fieldname = 'some_fieldname_1' field_1.fieldname = 'some_fieldname_1'
field_1.fieldtype = 'Long Text' field_1.fieldtype = 'Long Text'
@@ -327,14 +308,14 @@ class TestDocType(unittest.TestCase):
from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs


#create doctype #create doctype
link_doc = self.new_doctype('Test Linked Doctype')
link_doc = new_doctype('Test Linked Doctype')
link_doc.is_submittable = 1 link_doc.is_submittable = 1
for data in link_doc.get('permissions'): for data in link_doc.get('permissions'):
data.submit = 1 data.submit = 1
data.cancel = 1 data.cancel = 1
link_doc.insert() link_doc.insert()


doc = self.new_doctype('Test Doctype')
doc = new_doctype('Test Doctype')
doc.is_submittable = 1 doc.is_submittable = 1
field_2 = doc.append('fields', {}) field_2 = doc.append('fields', {})
field_2.label = 'Test Linked Doctype' field_2.label = 'Test Linked Doctype'
@@ -377,12 +358,12 @@ class TestDocType(unittest.TestCase):
doc.delete() doc.delete()
frappe.db.commit() frappe.db.commit()


def test_ignore_cancelation_of_linked_doctype_during_cancell(self):
def test_ignore_cancelation_of_linked_doctype_during_cancel(self):
import json import json
from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs


#create linked doctype #create linked doctype
link_doc = self.new_doctype('Test Linked Doctype 1')
link_doc = new_doctype('Test Linked Doctype 1')
link_doc.is_submittable = 1 link_doc.is_submittable = 1
for data in link_doc.get('permissions'): for data in link_doc.get('permissions'):
data.submit = 1 data.submit = 1
@@ -390,7 +371,7 @@ class TestDocType(unittest.TestCase):
link_doc.insert() link_doc.insert()


#create first parent doctype #create first parent doctype
test_doc_1 = self.new_doctype('Test Doctype 1')
test_doc_1 = new_doctype('Test Doctype 1')
test_doc_1.is_submittable = 1 test_doc_1.is_submittable = 1


field_2 = test_doc_1.append('fields', {}) field_2 = test_doc_1.append('fields', {})
@@ -405,7 +386,7 @@ class TestDocType(unittest.TestCase):
test_doc_1.insert() test_doc_1.insert()


#crete second parent doctype #crete second parent doctype
doc = self.new_doctype('Test Doctype 2')
doc = new_doctype('Test Doctype 2')
doc.is_submittable = 1 doc.is_submittable = 1


field_2 = doc.append('fields', {}) field_2 = doc.append('fields', {})
@@ -469,3 +450,55 @@ class TestDocType(unittest.TestCase):
doc.delete() doc.delete()
test_doc_1.delete() test_doc_1.delete()
frappe.db.commit() frappe.db.commit()

def test_links_table_fieldname_validation(self):
doc = new_doctype("Test Links Table Validation")

# check valid data
doc.append("links", {
'link_doctype': "User",
'link_fieldname': "first_name"
})
doc.validate_links_table_fieldnames() # no error
doc.links = [] # reset links table

# check invalid doctype
doc.append("links", {
'link_doctype': "User2",
'link_fieldname': "first_name"
})
self.assertRaises(frappe.DoesNotExistError, doc.validate_links_table_fieldnames)
doc.links = [] # reset links table

# check invalid fieldname
doc.append("links", {
'link_doctype': "User",
'link_fieldname': "a_field_that_does_not_exists"
})
self.assertRaises(InvalidFieldNameError, doc.validate_links_table_fieldnames)


def new_doctype(name, unique=0, depends_on='', fields=None):
doc = frappe.get_doc({
"doctype": "DocType",
"module": "Core",
"custom": 1,
"fields": [{
"label": "Some Field",
"fieldname": "some_fieldname",
"fieldtype": "Data",
"unique": unique,
"depends_on": depends_on,
}],
"permissions": [{
"role": "System Manager",
"read": 1,
}],
"name": name
})

if fields:
for f in fields:
doc.append('fields', f)

return doc

+ 10
- 2
frappe/core/doctype/doctype_action/doctype_action.json 查看文件

@@ -9,7 +9,8 @@
"action_type", "action_type",
"action", "action",
"group", "group",
"hidden"
"hidden",
"custom"
], ],
"fields": [ "fields": [
{ {
@@ -48,12 +49,19 @@
"fieldname": "hidden", "fieldname": "hidden",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Hidden" "label": "Hidden"
},
{
"default": "0",
"fieldname": "custom",
"fieldtype": "Check",
"hidden": 1,
"label": "Custom"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-21 14:44:03.845315",
"modified": "2020-09-24 14:19:05.549835",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocType Action", "name": "DocType Action",


+ 19
- 2
frappe/core/doctype/doctype_link/doctype_link.json 查看文件

@@ -7,7 +7,9 @@
"field_order": [ "field_order": [
"link_doctype", "link_doctype",
"link_fieldname", "link_fieldname",
"group"
"group",
"hidden",
"custom"
], ],
"fields": [ "fields": [
{ {
@@ -30,10 +32,25 @@
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Group" "label": "Group"
},
{
"default": "0",
"fieldname": "hidden",
"fieldtype": "Check",
"label": "Hidden"
},
{
"default": "0",
"fieldname": "custom",
"fieldtype": "Check",
"hidden": 1,
"label": "Custom"
} }
], ],
"index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"modified": "2019-09-24 11:41:25.291377",
"links": [],
"modified": "2020-09-24 14:19:25.189511",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocType Link", "name": "DocType Link",


+ 3
- 0
frappe/core/doctype/domain_settings/domain_settings.js 查看文件

@@ -18,6 +18,9 @@ frappe.ui.form.on('Domain Settings', {
checked: active_domains.includes(domain) checked: active_domains.includes(domain)
}; };
}); });
},
on_change: () => {
frm.dirty();
} }
}, },
render_input: true render_input: true


+ 40
- 4
frappe/core/doctype/file/file.py 查看文件

@@ -30,7 +30,7 @@ import frappe
from frappe import _, conf from frappe import _, conf
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip
from frappe.utils.image import strip_exif_data


class MaxFileSizeReachedError(frappe.ValidationError): class MaxFileSizeReachedError(frappe.ValidationError):
pass pass
@@ -93,6 +93,7 @@ class File(Document):
self.set_is_private() self.set_is_private()
self.set_file_name() self.set_file_name()
self.validate_duplicate_entry() self.validate_duplicate_entry()
self.validate_attachment_limit()
self.validate_folder() self.validate_folder()


if not self.file_url and not self.flags.ignore_file_validate: if not self.file_url and not self.flags.ignore_file_validate:
@@ -140,6 +141,26 @@ class File(Document):
if self.file_url and (self.is_private != self.file_url.startswith('/private')): if self.file_url and (self.is_private != self.file_url.startswith('/private')):
frappe.throw(_('Invalid file URL. Please contact System Administrator.')) frappe.throw(_('Invalid file URL. Please contact System Administrator.'))


def validate_attachment_limit(self):
attachment_limit = 0
if self.attached_to_doctype and self.attached_to_name:
attachment_limit = cint(frappe.get_meta(self.attached_to_doctype).max_attachments)

if attachment_limit:
current_attachment_count = len(frappe.get_all('File', filters={
'attached_to_doctype': self.attached_to_doctype,
'attached_to_name': self.attached_to_name,
}, limit=attachment_limit + 1))

if current_attachment_count >= attachment_limit:
frappe.throw(
_("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format(
frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name
),
exc=frappe.exceptions.AttachmentLimitReached,
title=_('Attachment Limit Reached')
)

def set_folder_name(self): def set_folder_name(self):
"""Make parent folders if not exists based on reference doctype and name""" """Make parent folders if not exists based on reference doctype and name"""
if self.attached_to_doctype and not self.folder: if self.attached_to_doctype and not self.folder:
@@ -435,6 +456,7 @@ class File(Document):
def save_file(self, content=None, decode=False, ignore_existing_file_check=False): def save_file(self, content=None, decode=False, ignore_existing_file_check=False):
file_exists = False file_exists = False
self.content = content self.content = content
if decode: if decode:
if isinstance(content, text_type): if isinstance(content, text_type):
self.content = content.encode("utf-8") self.content = content.encode("utf-8")
@@ -445,10 +467,19 @@ class File(Document):


if not self.is_private: if not self.is_private:
self.is_private = 0 self.is_private = 0
self.file_size = self.check_max_file_size()
self.content_hash = get_content_hash(self.content)
self.content_type = mimetypes.guess_type(self.file_name)[0] self.content_type = mimetypes.guess_type(self.file_name)[0]
self.file_size = self.check_max_file_size()
if (
self.content_type and "image" in self.content_type
and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images")
):
self.content = strip_exif_data(self.content, self.content_type)


self.content_hash = get_content_hash(self.content)
duplicate_file = None duplicate_file = None


# check if a file exists with the same content hash and is also in the same folder (public or private) # check if a file exists with the same content hash and is also in the same folder (public or private)
@@ -612,7 +643,12 @@ def get_extension(filename, extn, content):
return extn return extn


def get_local_image(file_url): def get_local_image(file_url):
file_path = frappe.get_site_path("public", file_url.lstrip("/"))
if file_url.startswith("/private"):
file_url_path = (file_url.lstrip("/"), )
else:
file_url_path = ("public", file_url.lstrip("/"))

file_path = frappe.get_site_path(*file_url_path)


try: try:
image = Image.open(file_path) image = Image.open(file_path)


+ 25
- 0
frappe/core/doctype/file/test_file.py 查看文件

@@ -160,6 +160,31 @@ class TestSameContent(unittest.TestCase):
def test_saved_content(self): def test_saved_content(self):
self.assertFalse(os.path.exists(get_files_path(self.dup_filename))) self.assertFalse(os.path.exists(get_files_path(self.dup_filename)))


def test_attachment_limit(self):
doctype, docname = make_test_doc()
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
limit_property = make_property_setter('ToDo', None, 'max_attachments', 1, 'int', for_doctype=True)
file1 = frappe.get_doc({
"doctype": "File",
"file_name": 'test-attachment',
"attached_to_doctype": doctype,
"attached_to_name": docname,
"content": 'test'
})

file1.insert()

file2 = frappe.get_doc({
"doctype": "File",
"file_name": 'test-attachment',
"attached_to_doctype": doctype,
"attached_to_name": docname,
"content": 'test2'
})

self.assertRaises(frappe.exceptions.AttachmentLimitReached, file2.insert)
limit_property.delete()
frappe.clear_cache(doctype='ToDo')


def tearDown(self): def tearDown(self):
# File gets deleted on rollback, so blank # File gets deleted on rollback, so blank


+ 4
- 2
frappe/core/doctype/navbar_item/navbar_item.json 查看文件

@@ -2,7 +2,6 @@
"actions": [], "actions": [],
"creation": "2020-08-01 23:38:41.783206", "creation": "2020-08-01 23:38:41.783206",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"item_label", "item_label",
@@ -30,6 +29,7 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Item Type", "label": "Item Type",
"options": "Route\nAction\nSeparator", "options": "Route\nAction\nSeparator",
"read_only_depends_on": "eval:doc.is_standard",
"show_days": 1, "show_days": 1,
"show_seconds": 1 "show_seconds": 1
}, },
@@ -59,6 +59,7 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Route", "label": "Route",
"mandatory_depends_on": "eval:doc.item_type == 'Route'", "mandatory_depends_on": "eval:doc.item_type == 'Route'",
"read_only_depends_on": "eval:doc.is_standard",
"show_days": 1, "show_days": 1,
"show_seconds": 1 "show_seconds": 1
}, },
@@ -68,13 +69,14 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Action", "label": "Action",
"mandatory_depends_on": "eval:doc.item_type == 'Action'", "mandatory_depends_on": "eval:doc.item_type == 'Action'",
"read_only_depends_on": "eval:doc.is_standard",
"show_days": 1, "show_days": 1,
"show_seconds": 1 "show_seconds": 1
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-06 16:32:49.597060",
"modified": "2020-11-02 10:57:37.709262",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Navbar Item", "name": "Navbar Item",


+ 9
- 11
frappe/core/doctype/prepared_report/prepared_report.py 查看文件

@@ -89,20 +89,18 @@ def delete_expired_prepared_reports():
'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)] 'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)]
}) })


args = {
'reports': prepared_reports_to_delete,
'limit': 50
}
enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args)
batches = frappe.utils.create_batch(prepared_reports_to_delete, 100)
for batch in batches:
args = {
'reports': batch,
}
enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args)


@frappe.whitelist() @frappe.whitelist()
def delete_prepared_reports(reports, limit=None):
def delete_prepared_reports(reports):
reports = frappe.parse_json(reports) reports = frappe.parse_json(reports)
for index, doc in enumerate(reports):
if limit and index == limit:
return
frappe.delete_doc('Prepared Report', doc['name'], ignore_permissions=True)
for report in reports:
frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True)


def create_json_gz_file(data, dt, dn): def create_json_gz_file(data, dt, dn):
# Storing data in CSV file causes information loss # Storing data in CSV file causes information loss


+ 5
- 4
frappe/core/doctype/report/report.py 查看文件

@@ -49,8 +49,8 @@ class Report(Document):
self.export_doc() self.export_doc()


def on_trash(self): def on_trash(self):
if (self.is_standard == 'Yes'
and not cint(getattr(frappe.local.conf, 'developer_mode', 0))
if (self.is_standard == 'Yes'
and not cint(getattr(frappe.local.conf, 'developer_mode', 0))
and not frappe.flags.in_patch): and not frappe.flags.in_patch):
frappe.throw(_("You are not allowed to delete Standard Report")) frappe.throw(_("You are not allowed to delete Standard Report"))
delete_custom_role('report', self.name) delete_custom_role('report', self.name)
@@ -61,8 +61,9 @@ class Report(Document):
def set_doctype_roles(self): def set_doctype_roles(self):
if not self.get('roles') and self.is_standard == 'No': if not self.get('roles') and self.is_standard == 'No':
meta = frappe.get_meta(self.ref_doctype) meta = frappe.get_meta(self.ref_doctype)
roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0]
self.set('roles', roles)
if not meta.istable:
roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0]
self.set('roles', roles)


def is_permitted(self): def is_permitted(self):
"""Returns true if Has Role is not set or the user is allowed.""" """Returns true if Has Role is not set or the user is allowed."""


+ 2
- 2
frappe/core/doctype/role/role.py 查看文件

@@ -37,7 +37,7 @@ class Role(Document):
def get_info_based_on_role(role, field='email'): def get_info_based_on_role(role, field='email'):
''' Get information of all users that have been assigned this role ''' ''' Get information of all users that have been assigned this role '''
users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"}, users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"},
fields=["parent"])
fields=["parent as user_name"])


return get_user_info(users, field) return get_user_info(users, field)


@@ -45,7 +45,7 @@ def get_user_info(users, field='email'):
''' Fetch details about users for the specified field ''' ''' Fetch details about users for the specified field '''
info_list = [] info_list = []
for user in users: for user in users:
user_info, enabled = frappe.db.get_value("User", user.parent, [field, "enabled"])
user_info, enabled = frappe.db.get_value("User", user.get("user_name"), [field, "enabled"])
if enabled and user_info not in ["admin@example.com", "guest@example.com"]: if enabled and user_info not in ["admin@example.com", "guest@example.com"]:
info_list.append(user_info) info_list.append(user_info)
return info_list return info_list


+ 3
- 3
frappe/core/doctype/scheduled_job_type/scheduled_job_type.json 查看文件

@@ -36,7 +36,7 @@
}, },
{ {
"default": "0", "default": "0",
"depends_on": "eval:doc.queue==='All'",
"depends_on": "eval:doc.frequency==='All'",
"fieldname": "create_log", "fieldname": "create_log",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Create Log" "label": "Create Log"
@@ -49,7 +49,7 @@
}, },
{ {
"allow_in_quick_entry": 1, "allow_in_quick_entry": 1,
"depends_on": "eval:doc.queue==='Cron'",
"depends_on": "eval:doc.frequency==='Cron'",
"fieldname": "cron_format", "fieldname": "cron_format",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Cron Format", "label": "Cron Format",
@@ -81,7 +81,7 @@
"link_fieldname": "scheduled_job_type" "link_fieldname": "scheduled_job_type"
} }
], ],
"modified": "2020-04-05 17:27:33.480562",
"modified": "2020-10-07 10:39:24.519460",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Scheduled Job Type", "name": "Scheduled Job Type",


+ 3
- 3
frappe/core/doctype/scheduled_job_type/scheduled_job_type.py 查看文件

@@ -20,9 +20,9 @@ class ScheduledJobType(Document):
# force logging for all events other than continuous ones (ALL) # force logging for all events other than continuous ones (ALL)
self.create_log = 1 self.create_log = 1


def enqueue(self):
def enqueue(self, force=False):
# enqueue event if last execution is done # enqueue event if last execution is done
if self.is_event_due():
if self.is_event_due() or force:
if frappe.flags.enqueued_jobs: if frappe.flags.enqueued_jobs:
frappe.flags.enqueued_jobs.append(self.method) frappe.flags.enqueued_jobs.append(self.method)


@@ -114,7 +114,7 @@ class ScheduledJobType(Document):
def execute_event(doc): def execute_event(doc):
frappe.only_for('System Manager') frappe.only_for('System Manager')
doc = json.loads(doc) doc = json.loads(doc)
frappe.get_doc('Scheduled Job Type', doc.get('name')).enqueue()
frappe.get_doc('Scheduled Job Type', doc.get('name')).enqueue(force=True)




def run_scheduled_job(job_type): def run_scheduled_job(job_type):


+ 30
- 11
frappe/core/doctype/server_script/server_script.js 查看文件

@@ -48,29 +48,33 @@ frappe.ui.form.on('Server Script', {


setup_help(frm) { setup_help(frm) {
frm.get_field('help_html').html(` frm.get_field('help_html').html(`
<h3>Examples</h3>
<h4>DocType Event</h4> <h4>DocType Event</h4>
<pre><code>
<p>Add logic for standard doctype events like Before Insert, After Submit, etc.</p>
<pre>
<code>
# set property # set property
if "test" in doc.description: if "test" in doc.description:
doc.status = 'Closed'
doc.status = 'Closed'




# validate # validate
if "validate" in doc.description: if "validate" in doc.description:
raise frappe.ValidationError
raise frappe.ValidationError


# auto create another document # auto create another document
if doc.allocted_to:
frappe.get_doc(dict(
doctype = 'ToDo'
owner = doc.allocated_to,
description = doc.subject
)).insert()
</code></pre>
if doc.allocated_to:
frappe.get_doc(dict(
doctype = 'ToDo'
owner = doc.allocated_to,
description = doc.subject
)).insert()
</code>
</pre>

<hr> <hr>


<h4>API Call</h4> <h4>API Call</h4>
<p>Respond to <code>/api/method/&lt;method-name&gt;</code> calls, just like whitelisted methods</p>
<pre><code> <pre><code>
# respond to API # respond to API


@@ -79,6 +83,21 @@ if frappe.form_dict.message == "ping":
else: else:
frappe.response['message'] = "ok" frappe.response['message'] = "ok"
</code></pre> </code></pre>

<hr>

<h4>Permission Query</h4>
<p>Add conditions to the where clause of list queries.</p>
<pre><code>
# generate dynamic conditions and set it in the conditions variable
tenant_id = frappe.db.get_value(...)
conditions = 'tenant_id = {}'.format(tenant_id)

# resulting select query
select name from \`tabPerson\`
where tenant_id = 2
order by creation desc
</code></pre>
`); `);
} }




+ 4
- 3
frappe/core/doctype/server_script/server_script.json 查看文件

@@ -24,17 +24,18 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_list_view": 1, "in_list_view": 1,
"label": "Script Type", "label": "Script Type",
"options": "DocType Event\nScheduler Event\nAPI",
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "script", "fieldname": "script",
"fieldtype": "Code", "fieldtype": "Code",
"label": "Script", "label": "Script",
"options": "Python",
"reqd": 1 "reqd": 1
}, },
{ {
"depends_on": "eval:doc.script_type==='DocType Event'",
"depends_on": "eval:['DocType Event', 'Permission Query'].includes(doc.script_type)",
"fieldname": "reference_doctype", "fieldname": "reference_doctype",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
@@ -87,7 +88,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-08-24 16:44:41.060350",
"modified": "2020-12-03 22:42:02.708148",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Server Script", "name": "Server Script",


+ 10
- 2
frappe/core/doctype/server_script/server_script.py 查看文件

@@ -4,6 +4,8 @@


from __future__ import unicode_literals from __future__ import unicode_literals


import ast

import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.safe_exec import safe_exec from frappe.utils.safe_exec import safe_exec
@@ -11,9 +13,9 @@ from frappe import _




class ServerScript(Document): class ServerScript(Document):
@staticmethod
def validate():
def validate(self):
frappe.only_for('Script Manager', True) frappe.only_for('Script Manager', True)
ast.parse(self.script)


@staticmethod @staticmethod
def on_update(): def on_update():
@@ -41,6 +43,12 @@ class ServerScript(Document):
# wrong report type! # wrong report type!
raise frappe.DoesNotExistError raise frappe.DoesNotExistError


def get_permission_query_conditions(self, user):
locals = {"user": user, "conditions": ""}
safe_exec(self.script, None, locals)
if locals["conditions"]:
return locals["conditions"]

@frappe.whitelist() @frappe.whitelist()
def setup_scheduler_events(script_name, frequency): def setup_scheduler_events(script_name, frequency):
method = frappe.scrub('{0}-{1}'.format(script_name, frequency)) method = frappe.scrub('{0}-{1}'.format(script_name, frequency))


+ 9
- 2
frappe/core/doctype/server_script/server_script_utils.py 查看文件

@@ -50,6 +50,9 @@ def get_server_script_map():
# }, # },
# '_api': { # '_api': {
# '[path]': '[server script]' # '[path]': '[server script]'
# },
# 'permission_query': {
# 'DocType': '[server script]'
# } # }
# } # }
if frappe.flags.in_patch and not frappe.db.table_exists('Server Script'): if frappe.flags.in_patch and not frappe.db.table_exists('Server Script'):
@@ -57,16 +60,20 @@ def get_server_script_map():


script_map = frappe.cache().get_value('server_script_map') script_map = frappe.cache().get_value('server_script_map')
if script_map is None: if script_map is None:
script_map = {}
script_map = {
'permission_query': {}
}
enabled_server_scripts = frappe.get_all('Server Script', enabled_server_scripts = frappe.get_all('Server Script',
fields=('name', 'reference_doctype', 'doctype_event','api_method', 'script_type'), fields=('name', 'reference_doctype', 'doctype_event','api_method', 'script_type'),
filters={'disabled': 0}) filters={'disabled': 0})
for script in enabled_server_scripts: for script in enabled_server_scripts:
if script.script_type == 'DocType Event': if script.script_type == 'DocType Event':
script_map.setdefault(script.reference_doctype, {}).setdefault(script.doctype_event, []).append(script.name) script_map.setdefault(script.reference_doctype, {}).setdefault(script.doctype_event, []).append(script.name)
elif script.script_type == 'Permission Query':
script_map['permission_query'][script.reference_doctype] = script.name
else: else:
script_map.setdefault('_api', {})[script.api_method] = script.name script_map.setdefault('_api', {})[script.api_method] = script.name


frappe.cache().set_value('server_script_map', script_map) frappe.cache().set_value('server_script_map', script_map)


return script_map
return script_map

+ 25
- 0
frappe/core/doctype/server_script/test_server_script.py 查看文件

@@ -45,6 +45,22 @@ frappe.response['message'] = 'hello'
allow_guest = 1, allow_guest = 1,
script = ''' script = '''
frappe.flags = 'hello' frappe.flags = 'hello'
'''
),
dict(
name='test_permission_query',
script_type = 'Permission Query',
reference_doctype = 'ToDo',
script = '''
conditions = '1 = 1'
'''),
dict(
name='test_invalid_namespace_method',
script_type = 'DocType Event',
doctype_event = 'Before Insert',
reference_doctype = 'Note',
script = '''
frappe.method_that_doesnt_exist("do some magic")
''' '''
) )
] ]
@@ -85,3 +101,12 @@ class TestServerScript(unittest.TestCase):


def test_api_return(self): def test_api_return(self):
self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')

def test_permission_query(self):
self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1))
self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))

def test_attribute_error(self):
"""Raise AttributeError if method not found in Namespace"""
note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"})
self.assertRaises(AttributeError, note.insert)

+ 31
- 32
frappe/core/doctype/system_settings/system_settings.js 查看文件

@@ -1,37 +1,36 @@
frappe.ui.form.on("System Settings", "refresh", function(frm) {
frappe.call({
method: "frappe.core.doctype.system_settings.system_settings.load",
callback: function(data) {
frappe.all_timezones = data.message.timezones;
frm.set_df_property("time_zone", "options", frappe.all_timezones);
frappe.ui.form.on("System Settings", {
refresh: function(frm) {
frappe.call({
method: "frappe.core.doctype.system_settings.system_settings.load",
callback: function(data) {
frappe.all_timezones = data.message.timezones;
frm.set_df_property("time_zone", "options", frappe.all_timezones);


$.each(data.message.defaults, function(key, val) {
frm.set_value(key, val);
frappe.sys_defaults[key] = val;
})
$.each(data.message.defaults, function(key, val) {
frm.set_value(key, val);
frappe.sys_defaults[key] = val;
});
}
});
},
enable_password_policy: function(frm) {
if (frm.doc.enable_password_policy == 0) {
frm.set_value("minimum_password_score", "");
} else {
frm.set_value("minimum_password_score", "2");
} }
});
});

frappe.ui.form.on("System Settings", "enable_password_policy", function(frm) {
if(frm.doc.enable_password_policy == 0){
frm.set_value("minimum_password_score", "");
} else {
frm.set_value("minimum_password_score", "2");
}
});

frappe.ui.form.on("System Settings", "enable_two_factor_auth", function(frm) {
if(frm.doc.enable_two_factor_auth == 0){
frm.set_value("bypass_2fa_for_retricted_ip_users", 0);
frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
}
});

frappe.ui.form.on("System Settings", "enable_prepared_report_auto_deletion", function(frm) {
if (frm.doc.enable_prepared_report_auto_deletion) {
if (!frm.doc.prepared_report_expiry_period) {
frm.set_value('prepared_report_expiry_period', 7);
},
enable_two_factor_auth: function(frm) {
if (frm.doc.enable_two_factor_auth == 0) {
frm.set_value("bypass_2fa_for_retricted_ip_users", 0);
frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
}
},
enable_prepared_report_auto_deletion: function(frm) {
if (frm.doc.enable_prepared_report_auto_deletion) {
if (!frm.doc.prepared_report_expiry_period) {
frm.set_value('prepared_report_expiry_period', 7);
}
} }
} }
}); });

+ 8
- 1
frappe/core/doctype/system_settings/system_settings.json 查看文件

@@ -37,6 +37,7 @@
"allow_login_using_mobile_number", "allow_login_using_mobile_number",
"allow_login_using_user_name", "allow_login_using_user_name",
"allow_error_traceback", "allow_error_traceback",
"strip_exif_metadata_from_uploaded_images",
"password_settings", "password_settings",
"logout_on_password_reset", "logout_on_password_reset",
"force_user_to_reset_password", "force_user_to_reset_password",
@@ -460,12 +461,18 @@
"fieldname": "prepared_report_section", "fieldname": "prepared_report_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Prepared Report" "label": "Prepared Report"
},
{
"default": "1",
"fieldname": "strip_exif_metadata_from_uploaded_images",
"fieldtype": "Check",
"label": "Strip EXIF tags from uploaded images"
} }
], ],
"icon": "fa fa-cog", "icon": "fa fa-cog",
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2020-08-12 14:35:45.214327",
"modified": "2020-11-30 18:52:22.161391",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "System Settings", "name": "System Settings",


+ 6
- 2
frappe/core/doctype/user/user.py 查看文件

@@ -13,7 +13,7 @@ from frappe.utils.user import get_system_managers
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import frappe.permissions import frappe.permissions
import frappe.share import frappe.share
import frappe.defaults
from frappe.website.utils import is_signup_enabled from frappe.website.utils import is_signup_enabled
from frappe.utils.background_jobs import enqueue from frappe.utils.background_jobs import enqueue


@@ -107,6 +107,10 @@ class User(Document):
) )
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)
# Set user selected timezone
if self.time_zone:
frappe.defaults.set_default("time_zone", self.time_zone, self.name)


def has_website_permission(self, ptype, user, verbose=False): def has_website_permission(self, ptype, user, verbose=False):
"""Returns true if current user is the session user""" """Returns true if current user is the session user"""
@@ -1129,4 +1133,4 @@ def check_password_reset_limit(user, rate_limit):
frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later.")) frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later."))


def get_generated_link_count(user): def get_generated_link_count(user):
return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
return cint(frappe.cache().hget("password_reset_link_count", user)) or 0

+ 9
- 1
frappe/custom/doctype/custom_field/custom_field.json 查看文件

@@ -30,6 +30,7 @@
"mandatory_depends_on", "mandatory_depends_on",
"read_only_depends_on", "read_only_depends_on",
"properties", "properties",
"non_negative",
"reqd", "reqd",
"unique", "unique",
"read_only", "read_only",
@@ -403,13 +404,20 @@
"fieldname": "hide_border", "fieldname": "hide_border",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Hide Border" "label": "Hide Border"
},
{
"default": "0",
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)",
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
} }
], ],
"icon": "fa fa-glass", "icon": "fa fa-glass",
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-08-28 11:28:44.377753",
"modified": "2020-10-29 06:14:43.073329",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Custom Field", "name": "Custom Field",


+ 0
- 20
frappe/custom/doctype/custom_link/custom_link.js 查看文件

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

frappe.ui.form.on('Custom Link', {
refresh: function(frm) {
frm.set_query("document_type", function () {
return {
filters: {
custom: 0,
istable: 0,
module: ['not in', ["Email", "Core", "Custom", "Event Streaming", "Social", "Data Migration", "Geo", "Desk"]]
}
};
});

frm.add_custom_button(__('Go to {0} List', [frm.doc.document_type]), function() {
frappe.set_route('List', frm.doc.document_type);
});
}
});

+ 0
- 10
frappe/custom/doctype/custom_link/custom_link.py 查看文件

@@ -1,10 +0,0 @@
# -*- 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 CustomLink(Document):
pass

+ 67
- 23
frappe/custom/doctype/customize_form/customize_form.js 查看文件

@@ -5,6 +5,7 @@ frappe.provide("frappe.customize_form");


frappe.ui.form.on("Customize Form", { frappe.ui.form.on("Customize Form", {
onload: function(frm) { onload: function(frm) {
frm.disable_save();
frm.set_query("doc_type", function() { frm.set_query("doc_type", function() {
return { return {
translate_values: false, translate_values: false,
@@ -27,7 +28,7 @@ frappe.ui.form.on("Customize Form", {
}); });


$(frm.wrapper).on("grid-row-render", function(e, grid_row) { $(frm.wrapper).on("grid-row-render", function(e, grid_row) {
if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") {
if (grid_row.doc && grid_row.doc.fieldtype=="Section Break") {
$(grid_row.row).css({"font-weight": "bold"}); $(grid_row.row).css({"font-weight": "bold"});
} }
}); });
@@ -40,19 +41,25 @@ frappe.ui.form.on("Customize Form", {
frm.trigger("setup_sortable"); frm.trigger("setup_sortable");
}); });


if (localStorage['customize_doctype']) {
// set default value from customize form
frm.set_value('doc_type', localStorage['customize_doctype']);
}

}, },


doc_type: function(frm) { doc_type: function(frm) {
if(frm.doc.doc_type) {
if (frm.doc.doc_type) {
return frm.call({ return frm.call({
method: "fetch_to_customize", method: "fetch_to_customize",
doc: frm.doc, doc: frm.doc,
freeze: true, freeze: true,
callback: function(r) { callback: function(r) {
if(r) {
if(r._server_messages && r._server_messages.length) {
if (r) {
if (r._server_messages && r._server_messages.length) {
frm.set_value("doc_type", ""); frm.set_value("doc_type", "");
} else { } else {
localStorage['customize_doctype'] = frm.doc.doc_type;
frm.refresh(); frm.refresh();
frm.trigger("setup_sortable"); frm.trigger("setup_sortable");
} }
@@ -69,11 +76,16 @@ frappe.ui.form.on("Customize Form", {
frm.doc.fields.forEach(function(f, i) { frm.doc.fields.forEach(function(f, i) {
var data_row = frm.page.body.find('[data-fieldname="fields"] [data-idx="'+ f.idx +'"] .data-row'); var data_row = frm.page.body.find('[data-fieldname="fields"] [data-idx="'+ f.idx +'"] .data-row');


if(f.is_custom_field) {
if (f.is_custom_field) {
data_row.addClass("highlight"); data_row.addClass("highlight");
} else { } else {
f._sortable = false; f._sortable = false;
} }
if (f.fieldtype == "Table") {
frm.add_custom_button(f.options, function() {
frm.set_value('doc_type', f.options);
}, __('Customize Child Table'));
}
}); });
frm.fields_dict.fields.grid.refresh(); frm.fields_dict.fields.grid.refresh();
}, },
@@ -82,26 +94,26 @@ frappe.ui.form.on("Customize Form", {
frm.disable_save(); frm.disable_save();
frm.page.clear_icons(); frm.page.clear_icons();


if(frm.doc.doc_type) {
if (frm.doc.doc_type) {
frappe.customize_form.set_primary_action(frm); frappe.customize_form.set_primary_action(frm);


frm.add_custom_button(__('Go to {0} List', [frm.doc.doc_type]), function() { frm.add_custom_button(__('Go to {0} List', [frm.doc.doc_type]), function() {
frappe.set_route('List', frm.doc.doc_type); frappe.set_route('List', frm.doc.doc_type);
});
}, __('Actions'));


frm.add_custom_button(__('Refresh Form'), function() {
frm.add_custom_button(__('Reload'), function() {
frm.script_manager.trigger("doc_type"); frm.script_manager.trigger("doc_type");
}, "fa fa-refresh", "btn-default");
}, __('Actions'));


frm.add_custom_button(__('Reset to defaults'), function() { frm.add_custom_button(__('Reset to defaults'), function() {
frappe.customize_form.confirm(__('Remove all customizations?'), frm); frappe.customize_form.confirm(__('Remove all customizations?'), frm);
}, "fa fa-eraser", "btn-default");
}, __('Actions'));


frm.add_custom_button(__('Set Permissions'), function() { frm.add_custom_button(__('Set Permissions'), function() {
frappe.set_route('permission-manager', frm.doc.doc_type); frappe.set_route('permission-manager', frm.doc.doc_type);
}, "fa fa-lock", "btn-default");
}, __('Actions'));


if(frappe.boot.developer_mode) {
if (frappe.boot.developer_mode) {
frm.add_custom_button(__('Export Customizations'), function() { frm.add_custom_button(__('Export Customizations'), function() {
frappe.prompt( frappe.prompt(
[ [
@@ -124,34 +136,36 @@ frappe.ui.form.on("Customize Form", {
}); });
}, },
__("Select Module")); __("Select Module"));
});
}, __('Actions'));
} }
} }


// sort order select // sort order select
if(frm.doc.doc_type) {
if (frm.doc.doc_type) {
var fields = $.map(frm.doc.fields, var fields = $.map(frm.doc.fields,
function(df) { return frappe.model.is_value_type(df.fieldtype) ? df.fieldname : null; });
function(df) {
return frappe.model.is_value_type(df.fieldtype) ? df.fieldname : null;
});
fields = ["", "name", "modified"].concat(fields); fields = ["", "name", "modified"].concat(fields);
frm.set_df_property("sort_field", "options", fields); frm.set_df_property("sort_field", "options", fields);
} }


if(frappe.route_options && frappe.route_options.doc_type) {
if (frappe.route_options && frappe.route_options.doc_type) {
setTimeout(function() { setTimeout(function() {
frm.set_value("doc_type", frappe.route_options.doc_type); frm.set_value("doc_type", frappe.route_options.doc_type);
frappe.route_options = null; frappe.route_options = null;
}, 1000); }, 1000);
} }

} }
}); });


// can't delete standard fields
frappe.ui.form.on("Customize Form Field", { frappe.ui.form.on("Customize Form Field", {
before_fields_remove: function(frm, doctype, name) { before_fields_remove: function(frm, doctype, name) {
var row = frappe.get_doc(doctype, name); var row = frappe.get_doc(doctype, name);
if(!(row.is_custom_field || row.__islocal)) {
if (!(row.is_custom_field || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); frappe.msgprint(__("Cannot delete standard field. You can hide it if you want"));
throw "cannot delete custom field";
throw "cannot delete standard field";
} }
}, },
fields_add: function(frm, cdt, cdn) { fields_add: function(frm, cdt, cdn) {
@@ -160,16 +174,46 @@ frappe.ui.form.on("Customize Form Field", {
} }
}); });


// can't delete standard links
frappe.ui.form.on("DocType Link", {
before_links_remove: function(frm, doctype, name) {
let row = frappe.get_doc(doctype, name);
if (!(row.custom || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard link. You can hide it if you want"));
throw "cannot delete standard link";
}
},
links_add: function(frm, cdt, cdn) {
let f = frappe.model.get_doc(cdt, cdn);
f.custom = 1;
}
});

// can't delete standard actions
frappe.ui.form.on("DocType Action", {
before_actions_remove: function(frm, doctype, name) {
let row = frappe.get_doc(doctype, name);
if (!(row.custom || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard action. You can hide it if you want"));
throw "cannot delete standard action";
}
},
actions_add: function(frm, cdt, cdn) {
let f = frappe.model.get_doc(cdt, cdn);
f.custom = 1;
}
});

frappe.customize_form.set_primary_action = function(frm) { frappe.customize_form.set_primary_action = function(frm) {
frm.page.set_primary_action(__("Update"), function() { frm.page.set_primary_action(__("Update"), function() {
if(frm.doc.doc_type) {
if (frm.doc.doc_type) {
return frm.call({ return frm.call({
doc: frm.doc, doc: frm.doc,
freeze: true, freeze: true,
btn: frm.page.btn_primary, btn: frm.page.btn_primary,
method: "save_customization", method: "save_customization",
callback: function(r) { callback: function(r) {
if(!r.exc) {
if (!r.exc) {
frappe.customize_form.clear_locals_and_refresh(frm); frappe.customize_form.clear_locals_and_refresh(frm);
frm.script_manager.trigger("doc_type"); frm.script_manager.trigger("doc_type");
} }
@@ -180,7 +224,7 @@ frappe.customize_form.set_primary_action = function(frm) {
}; };


frappe.customize_form.confirm = function(msg, frm) { frappe.customize_form.confirm = function(msg, frm) {
if(!frm.doc.doc_type) return;
if (!frm.doc.doc_type) return;


var d = new frappe.ui.Dialog({ var d = new frappe.ui.Dialog({
title: 'Reset To Defaults', title: 'Reset To Defaults',
@@ -192,7 +236,7 @@ frappe.customize_form.confirm = function(msg, frm) {
doc: frm.doc, doc: frm.doc,
method: "reset_to_defaults", method: "reset_to_defaults",
callback: function(r) { callback: function(r) {
if(r.exc) {
if (r.exc) {
frappe.msgprint(r.exc); frappe.msgprint(r.exc);
} else { } else {
d.hide(); d.hide();


+ 72
- 20
frappe/custom/doctype/customize_form/customize_form.json 查看文件

@@ -10,8 +10,9 @@
"doc_type", "doc_type",
"properties", "properties",
"label", "label",
"default_print_format",
"max_attachments", "max_attachments",
"search_fields",
"column_break_5",
"allow_copy", "allow_copy",
"istable", "istable",
"editable_grid", "editable_grid",
@@ -20,22 +21,27 @@
"track_views", "track_views",
"allow_auto_repeat", "allow_auto_repeat",
"allow_import", "allow_import",
"show_preview_popup",
"image_view",
"column_break_5",
"fields_section_break",
"fields",
"view_settings_section",
"title_field", "title_field",
"image_field", "image_field",
"search_fields",
"section_break_8",
"sort_field",
"column_break_10",
"sort_order",
"section_break_23",
"default_print_format",
"column_break_29",
"show_preview_popup",
"image_view",
"email_settings_section",
"email_append_to", "email_append_to",
"sender_field", "sender_field",
"subject_field", "subject_field",
"fields_section_break",
"fields"
"document_actions_section",
"actions",
"document_links_section",
"links",
"section_break_8",
"sort_field",
"column_break_10",
"sort_order"
], ],
"fields": [ "fields": [
{ {
@@ -130,9 +136,11 @@
"label": "Search Fields" "label": "Search Fields"
}, },
{ {
"collapsible": 1,
"depends_on": "doc_type", "depends_on": "doc_type",
"fieldname": "section_break_8", "fieldname": "section_break_8",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "List Settings"
}, },
{ {
"fieldname": "sort_field", "fieldname": "sort_field",
@@ -161,7 +169,8 @@
"fieldname": "fields", "fieldname": "fields",
"fieldtype": "Table", "fieldtype": "Table",
"label": "Fields", "label": "Fields",
"options": "Customize Form Field"
"options": "Customize Form Field",
"reqd": 1
}, },
{ {
"default": "0", "default": "0",
@@ -200,24 +209,67 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow document creation via Email" "label": "Allow document creation via Email"
}, },
{
"depends_on": "doc_type",
"fieldname": "section_break_23",
"fieldtype": "Section Break"
},
{ {
"default": "0", "default": "0",
"fieldname": "show_preview_popup", "fieldname": "show_preview_popup",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Show Preview Popup" "label": "Show Preview Popup"
},
{
"collapsible": 1,
"depends_on": "doc_type",
"fieldname": "view_settings_section",
"fieldtype": "Section Break",
"label": "View Settings"
},
{
"fieldname": "column_break_29",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"collapsible_depends_on": "email_append_to",
"depends_on": "doc_type",
"fieldname": "email_settings_section",
"fieldtype": "Section Break",
"label": "Email Settings"
},
{
"collapsible": 1,
"collapsible_depends_on": "links",
"depends_on": "doc_type",
"fieldname": "document_links_section",
"fieldtype": "Section Break",
"label": "Document Links"
},
{
"fieldname": "links",
"fieldtype": "Table",
"label": "Links",
"options": "DocType Link"
},
{
"collapsible": 1,
"collapsible_depends_on": "actions",
"depends_on": "doc_type",
"fieldname": "document_actions_section",
"fieldtype": "Section Break",
"label": "Document Actions"
},
{
"fieldname": "actions",
"fieldtype": "Table",
"label": "Actions",
"options": "DocType Action"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
"icon": "fa fa-glass", "icon": "fa fa-glass",
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2020-04-10 12:16:01.320411",
"modified": "2020-09-24 14:16:49.594012",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form", "name": "Customize Form",


+ 285
- 173
frappe/custom/doctype/customize_form/customize_form.py 查看文件

@@ -6,6 +6,7 @@ from __future__ import unicode_literals
Customize Form is a Single DocType used to mask the Property Setter Customize Form is a Single DocType used to mask the Property Setter
Thus providing a better UI from user perspective Thus providing a better UI from user perspective
""" """
import json
import frappe import frappe
import frappe.translate import frappe.translate
from frappe import _ from frappe import _
@@ -14,80 +15,9 @@ from frappe.model.document import Document
from frappe.model import no_value_fields, core_doctypes_list from frappe.model import no_value_fields, core_doctypes_list
from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype, check_email_append_to from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype, check_email_append_to
from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter
from frappe.model.docfield import supports_translation from frappe.model.docfield import supports_translation


doctype_properties = {
'search_fields': 'Data',
'title_field': 'Data',
'image_field': 'Data',
'sort_field': 'Data',
'sort_order': 'Data',
'default_print_format': 'Data',
'allow_copy': 'Check',
'istable': 'Check',
'quick_entry': 'Check',
'editable_grid': 'Check',
'max_attachments': 'Int',
'track_changes': 'Check',
'track_views': 'Check',
'allow_auto_repeat': 'Check',
'allow_import': 'Check',
'show_preview_popup': 'Check',
'email_append_to': 'Check',
'subject_field': 'Data',
'sender_field': 'Data'
}

docfield_properties = {
'idx': 'Int',
'label': 'Data',
'fieldtype': 'Select',
'options': 'Text',
'fetch_from': 'Small Text',
'fetch_if_empty': 'Check',
'permlevel': 'Int',
'width': 'Data',
'print_width': 'Data',
'reqd': 'Check',
'unique': 'Check',
'ignore_user_permissions': 'Check',
'in_list_view': 'Check',
'in_standard_filter': 'Check',
'in_global_search': 'Check',
'in_preview': 'Check',
'bold': 'Check',
'hidden': 'Check',
'collapsible': 'Check',
'collapsible_depends_on': 'Data',
'print_hide': 'Check',
'print_hide_if_no_value': 'Check',
'report_hide': 'Check',
'allow_on_submit': 'Check',
'translatable': 'Check',
'mandatory_depends_on': 'Data',
'read_only_depends_on': 'Data',
'depends_on': 'Data',
'description': 'Text',
'default': 'Text',
'precision': 'Select',
'read_only': 'Check',
'length': 'Int',
'columns': 'Int',
'remember_last_selected_value': 'Check',
'allow_bulk_edit': 'Check',
'auto_repeat': 'Link',
'allow_in_quick_entry': 'Check',
'hide_border': 'Check',
'hide_days': 'Check',
'hide_seconds': 'Check'
}

allowed_fieldtype_change = (('Currency', 'Float', 'Percent'), ('Small Text', 'Data'),
('Text', 'Data'), ('Text', 'Text Editor', 'Code', 'Signature', 'HTML Editor'), ('Data', 'Select'),
('Text', 'Small Text'), ('Text', 'Data', 'Barcode'), ('Code', 'Geolocation'), ('Table', 'Table MultiSelect'))

allowed_fieldtype_for_options_change = ('Read Only', 'HTML', 'Select', 'Data')

class CustomizeForm(Document): class CustomizeForm(Document):
def on_update(self): def on_update(self):
frappe.db.sql("delete from tabSingles where doctype='Customize Form'") frappe.db.sql("delete from tabSingles where doctype='Customize Form'")
@@ -100,30 +30,54 @@ class CustomizeForm(Document):


meta = frappe.get_meta(self.doc_type) meta = frappe.get_meta(self.doc_type)


self.validate_doctype(meta)

# load the meta properties on the customize (self) object
self.load_properties(meta)

# load custom translation
translation = self.get_name_translation()
self.label = translation.translated_text if translation else ''

self.create_auto_repeat_custom_field_if_required(meta)

# NOTE doc (self) is sent to clientside by run_method

def validate_doctype(self, meta):
'''
Check if the doctype is allowed to be customized.
'''
if self.doc_type in core_doctypes_list: if self.doc_type in core_doctypes_list:
return frappe.msgprint(_("Core DocTypes cannot be customized."))
frappe.throw(_("Core DocTypes cannot be customized."))


if meta.issingle: if meta.issingle:
return frappe.msgprint(_("Single DocTypes cannot be customized."))
frappe.throw(_("Single DocTypes cannot be customized."))


if meta.custom: if meta.custom:
return frappe.msgprint(_("Only standard DocTypes are allowed to be customized from Customize Form."))
frappe.throw(_("Only standard DocTypes are allowed to be customized from Customize Form."))


def load_properties(self, meta):
'''
Load the customize object (this) with the metadata properties
'''
# doctype properties # doctype properties
for property in doctype_properties:
self.set(property, meta.get(property))
for prop in doctype_properties:
self.set(prop, meta.get(prop))


for d in meta.get("fields"): for d in meta.get("fields"):
new_d = {"fieldname": d.fieldname, "is_custom_field": d.get("is_custom_field"), "name": d.name} new_d = {"fieldname": d.fieldname, "is_custom_field": d.get("is_custom_field"), "name": d.name}
for property in docfield_properties:
new_d[property] = d.get(property)
for prop in docfield_properties:
new_d[prop] = d.get(prop)
self.append("fields", new_d) self.append("fields", new_d)


# load custom translation
translation = self.get_name_translation()
self.label = translation.translated_text if translation else ''
for fieldname in ('links', 'actions'):
for d in meta.get(fieldname):
self.append(fieldname, d)


#If allow_auto_repeat is set, add auto_repeat custom field.
def create_auto_repeat_custom_field_if_required(self, meta):
'''
Create auto repeat custom field if it's not already present
'''
if self.allow_auto_repeat: if self.allow_auto_repeat:
all_fields = [df.fieldname for df in meta.fields] all_fields = [df.fieldname for df in meta.fields]


@@ -140,7 +94,6 @@ class CustomizeForm(Document):
read_only=1, no_copy=1, print_hide=1 read_only=1, no_copy=1, print_hide=1
)) ))


# NOTE doc is sent to clientside by run_method


def get_name_translation(self): def get_name_translation(self):
'''Get translation object if exists of current doctype name in the default language''' '''Get translation object if exists of current doctype name in the default language'''
@@ -205,72 +158,142 @@ class CustomizeForm(Document):


def set_property_setters(self): def set_property_setters(self):
meta = frappe.get_meta(self.doc_type) meta = frappe.get_meta(self.doc_type)
# doctype property setters


for property in doctype_properties:
if self.get(property) != meta.get(property):
self.make_property_setter(property=property, value=self.get(property),
property_type=doctype_properties[property])
# doctype
self.set_property_setters_for_doctype(meta)


# docfield
for df in self.get("fields"): for df in self.get("fields"):
meta_df = meta.get("fields", {"fieldname": df.fieldname}) meta_df = meta.get("fields", {"fieldname": df.fieldname})

if not meta_df or meta_df[0].get("is_custom_field"): if not meta_df or meta_df[0].get("is_custom_field"):
continue continue
self.set_property_setters_for_docfield(meta, df, meta_df)

# action and links
self.set_property_setters_for_actions_and_links(meta)

def set_property_setters_for_doctype(self, meta):
for prop, prop_type in doctype_properties.items():
if self.get(prop) != meta.get(prop):
self.make_property_setter(prop, self.get(prop), prop_type)

def set_property_setters_for_docfield(self, meta, df, meta_df):
for prop, prop_type in docfield_properties.items():
if prop != "idx" and (df.get(prop) or '') != (meta_df[0].get(prop) or ''):
if not self.allow_property_change(prop, meta_df, df):
continue

self.make_property_setter(prop, df.get(prop), prop_type,
fieldname=df.fieldname)

def allow_property_change(self, prop, meta_df, df):
if prop == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))

elif prop == "allow_on_submit" and df.get(prop):
if not frappe.db.get_value("DocField",
{"parent": self.doc_type, "fieldname": df.fieldname}, "allow_on_submit"):
frappe.msgprint(_("Row {0}: Not allowed to enable Allow on Submit for standard fields")\
.format(df.idx))
return False

elif prop == "reqd" and \
((frappe.db.get_value("DocField",
{"parent":self.doc_type,"fieldname":df.fieldname}, "reqd") == 1) \
and (df.get(prop) == 0)):
frappe.msgprint(_("Row {0}: Not allowed to disable Mandatory for standard fields")\
.format(df.idx))
return False

elif prop == "in_list_view" and df.get(prop) \
and df.fieldtype!="Attach Image" and df.fieldtype in no_value_fields:
frappe.msgprint(_("'In List View' not allowed for type {0} in row {1}")
.format(df.fieldtype, df.idx))
return False

elif prop == "precision" and cint(df.get("precision")) > 6 \
and cint(df.get("precision")) > cint(meta_df[0].get("precision")):
self.flags.update_db = True

elif prop == "unique":
self.flags.update_db = True

elif (prop == "read_only" and cint(df.get("read_only"))==0
and frappe.db.get_value("DocField", {"parent": self.doc_type,
"fieldname": df.fieldname}, "read_only")==1):
# if docfield has read_only checked and user is trying to make it editable, don't allow it
frappe.msgprint(_("You cannot unset 'Read Only' for field {0}").format(df.label))
return False

elif prop == "options" and df.get("fieldtype") not in ALLOWED_OPTIONS_CHANGE:
frappe.msgprint(_("You can't set 'Options' for field {0}").format(df.label))
return False

elif prop == 'translatable' and not supports_translation(df.get('fieldtype')):
frappe.msgprint(_("You can't set 'Translatable' for field {0}").format(df.label))
return False

elif (prop == 'in_global_search' and
df.in_global_search != meta_df[0].get("in_global_search")):
self.flags.rebuild_doctype_for_global_search = True

return True

def set_property_setters_for_actions_and_links(self, meta):
'''
Apply property setters or create custom records for DocType Action and DocType Link
'''
for doctype, fieldname, field_map in (
('DocType Link', 'links', doctype_link_properties),
('DocType Action', 'actions', doctype_action_properties)
):
has_custom = False
items = []
for i, d in enumerate(self.get(fieldname) or []):
d.idx = i
if frappe.db.exists(doctype, d.name) and not d.custom:
# check property and apply property setter
original = frappe.get_doc(doctype, d.name)
for prop, prop_type in field_map.items():
if d.get(prop) != original.get(prop):
self.make_property_setter(prop, d.get(prop), prop_type,
apply_on=doctype, row_name=d.name)
items.append(d.name)
else:
# custom - just insert/update
d.parent = self.doc_type
d.custom = 1
d.save(ignore_permissions=True)
has_custom = True
items.append(d.name)

self.update_order_property_setter(has_custom, fieldname)
self.clear_removed_items(doctype, items)

def update_order_property_setter(self, has_custom, fieldname):
'''
We need to maintain the order of the link/actions if the user has shuffled them.
So we create a new property (ex `links_order`) to keep a list of items.
'''
property_name = '{}_order'.format(fieldname)
if has_custom:
# save the order of the actions and links
self.make_property_setter(property_name,
json.dumps([d.name for d in self.get(fieldname)]), 'Small Text')
else:
frappe.db.delete('Property Setter', dict(property=property_name,
doc_type=self.doc_type))


for property in docfield_properties:
if property != "idx" and (df.get(property) or '') != (meta_df[0].get(property) or ''):
if property == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(property), df.get(property))

elif property == "allow_on_submit" and df.get(property):
if not frappe.db.get_value("DocField",
{"parent": self.doc_type, "fieldname": df.fieldname}, "allow_on_submit"):
frappe.msgprint(_("Row {0}: Not allowed to enable Allow on Submit for standard fields")\
.format(df.idx))
continue

elif property == "reqd" and \
((frappe.db.get_value("DocField",
{"parent":self.doc_type,"fieldname":df.fieldname}, "reqd") == 1) \
and (df.get(property) == 0)):
frappe.msgprint(_("Row {0}: Not allowed to disable Mandatory for standard fields")\
.format(df.idx))
continue

elif property == "in_list_view" and df.get(property) \
and df.fieldtype!="Attach Image" and df.fieldtype in no_value_fields:
frappe.msgprint(_("'In List View' not allowed for type {0} in row {1}")
.format(df.fieldtype, df.idx))
continue

elif property == "precision" and cint(df.get("precision")) > 6 \
and cint(df.get("precision")) > cint(meta_df[0].get("precision")):
self.flags.update_db = True

elif property == "unique":
self.flags.update_db = True

elif (property == "read_only" and cint(df.get("read_only"))==0
and frappe.db.get_value("DocField", {"parent": self.doc_type, "fieldname": df.fieldname}, "read_only")==1):
# if docfield has read_only checked and user is trying to make it editable, don't allow it
frappe.msgprint(_("You cannot unset 'Read Only' for field {0}").format(df.label))
continue

elif property == "options" and df.get("fieldtype") not in allowed_fieldtype_for_options_change:
frappe.msgprint(_("You can't set 'Options' for field {0}").format(df.label))
continue

elif property == 'translatable' and not supports_translation(df.get('fieldtype')):
frappe.msgprint(_("You can't set 'Translatable' for field {0}").format(df.label))
continue

elif (property == 'in_global_search' and
df.in_global_search != meta_df[0].get("in_global_search")):
self.flags.rebuild_doctype_for_global_search = True

self.make_property_setter(property=property, value=df.get(property),
property_type=docfield_properties[property], fieldname=df.fieldname)

def clear_removed_items(self, doctype, items):
'''
Clear rows that do not appear in `items`. These have been removed by the user.
'''
if items:
frappe.db.delete(doctype, dict(parent=self.doc_type, custom=1,
name=('not in', items)))
else:
frappe.db.delete(doctype, dict(parent=self.doc_type, custom=1))


def update_custom_fields(self): def update_custom_fields(self):
for i, df in enumerate(self.get("fields")): for i, df in enumerate(self.get("fields")):
@@ -288,8 +311,8 @@ class CustomizeForm(Document):


d.dt = self.doc_type d.dt = self.doc_type


for property in docfield_properties:
d.set(property, df.get(property))
for prop in docfield_properties:
d.set(prop, df.get(prop))


if i!=0: if i!=0:
d.insert_after = self.fields[i-1].fieldname d.insert_after = self.fields[i-1].fieldname
@@ -307,12 +330,12 @@ class CustomizeForm(Document):


custom_field = frappe.get_doc("Custom Field", meta_df[0].name) custom_field = frappe.get_doc("Custom Field", meta_df[0].name)
changed = False changed = False
for property in docfield_properties:
if df.get(property) != custom_field.get(property):
if property == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(property), df.get(property))
for prop in docfield_properties:
if df.get(prop) != custom_field.get(prop):
if prop == "fieldtype":
self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop))


custom_field.set(property, df.get(property))
custom_field.set(prop, df.get(prop))
changed = True changed = True


# check and update `insert_after` property # check and update `insert_after` property
@@ -338,32 +361,28 @@ class CustomizeForm(Document):
if df.get("is_custom_field"): if df.get("is_custom_field"):
frappe.delete_doc("Custom Field", df.name) frappe.delete_doc("Custom Field", df.name)


def make_property_setter(self, property, value, property_type, fieldname=None):
self.delete_existing_property_setter(property, fieldname)
def make_property_setter(self, prop, value, property_type, fieldname=None,
apply_on=None, row_name = None):
delete_property_setter(self.doc_type, prop, fieldname)


property_value = self.get_existing_property_value(property, fieldname)
property_value = self.get_existing_property_value(prop, fieldname)


if property_value==value: if property_value==value:
return return


if not apply_on:
apply_on = "DocField" if fieldname else "DocType"

# create a new property setter # create a new property setter
# ignore validation becuase it will be done at end
frappe.make_property_setter({ frappe.make_property_setter({
"doctype": self.doc_type, "doctype": self.doc_type,
"doctype_or_field": "DocField" if fieldname else "DocType",
"doctype_or_field": apply_on,
"fieldname": fieldname, "fieldname": fieldname,
"property": property,
"row_name": row_name,
"property": prop,
"value": value, "value": value,
"property_type": property_type "property_type": property_type
}, ignore_validate=True)

def delete_existing_property_setter(self, property, fieldname=None):
# first delete existing property setter
existing_property_setter = frappe.db.get_value("Property Setter", {"doc_type": self.doc_type,
"property": property, "field_name['']": fieldname or ''})

if existing_property_setter:
frappe.db.sql("delete from `tabProperty Setter` where name=%s", existing_property_setter)
})


def get_existing_property_value(self, property_name, fieldname=None): def get_existing_property_value(self, property_name, fieldname=None):
# check if there is any need to make property setter! # check if there is any need to make property setter!
@@ -371,20 +390,17 @@ class CustomizeForm(Document):
property_value = frappe.db.get_value("DocField", {"parent": self.doc_type, property_value = frappe.db.get_value("DocField", {"parent": self.doc_type,
"fieldname": fieldname}, property_name) "fieldname": fieldname}, property_name)
else: else:
try:
if frappe.db.has_column("DocType", property_name):
property_value = frappe.db.get_value("DocType", self.doc_type, property_name) property_value = frappe.db.get_value("DocType", self.doc_type, property_name)
except Exception as e:
if frappe.db.is_column_missing(e):
property_value = None
else:
raise
else:
property_value = None


return property_value return property_value


def validate_fieldtype_change(self, df, old_value, new_value): def validate_fieldtype_change(self, df, old_value, new_value):
allowed = False allowed = False
self.check_length_for_fieldtypes = [] self.check_length_for_fieldtypes = []
for allowed_changes in allowed_fieldtype_change:
for allowed_changes in ALLOWED_FIELDTYPE_CHANGE:
if (old_value in allowed_changes and new_value in allowed_changes): if (old_value in allowed_changes and new_value in allowed_changes):
allowed = True allowed = True
old_value_length = cint(frappe.db.type_map.get(old_value)[1]) old_value_length = cint(frappe.db.type_map.get(old_value)[1])
@@ -444,4 +460,100 @@ def reset_customization(doctype):
and `field_name`!='naming_series' and `field_name`!='naming_series'
and `property`!='options' and `property`!='options'
""", doctype) """, doctype)
frappe.clear_cache(doctype=doctype)
frappe.clear_cache(doctype=doctype)

doctype_properties = {
'search_fields': 'Data',
'title_field': 'Data',
'image_field': 'Data',
'sort_field': 'Data',
'sort_order': 'Data',
'default_print_format': 'Data',
'allow_copy': 'Check',
'istable': 'Check',
'quick_entry': 'Check',
'editable_grid': 'Check',
'max_attachments': 'Int',
'track_changes': 'Check',
'track_views': 'Check',
'allow_auto_repeat': 'Check',
'allow_import': 'Check',
'show_preview_popup': 'Check',
'email_append_to': 'Check',
'subject_field': 'Data',
'sender_field': 'Data'
}

docfield_properties = {
'idx': 'Int',
'label': 'Data',
'fieldtype': 'Select',
'options': 'Text',
'fetch_from': 'Small Text',
'fetch_if_empty': 'Check',
'permlevel': 'Int',
'width': 'Data',
'print_width': 'Data',
'non_negative': 'Check',
'reqd': 'Check',
'unique': 'Check',
'ignore_user_permissions': 'Check',
'in_list_view': 'Check',
'in_standard_filter': 'Check',
'in_global_search': 'Check',
'in_preview': 'Check',
'bold': 'Check',
'hidden': 'Check',
'collapsible': 'Check',
'collapsible_depends_on': 'Data',
'print_hide': 'Check',
'print_hide_if_no_value': 'Check',
'report_hide': 'Check',
'allow_on_submit': 'Check',
'translatable': 'Check',
'mandatory_depends_on': 'Data',
'read_only_depends_on': 'Data',
'depends_on': 'Data',
'description': 'Text',
'default': 'Text',
'precision': 'Select',
'read_only': 'Check',
'length': 'Int',
'columns': 'Int',
'remember_last_selected_value': 'Check',
'allow_bulk_edit': 'Check',
'auto_repeat': 'Link',
'allow_in_quick_entry': 'Check',
'hide_border': 'Check',
'hide_days': 'Check',
'hide_seconds': 'Check'
}

doctype_link_properties = {
'link_doctype': 'Link',
'link_fieldname': 'Data',
'group': 'Data',
'hidden': 'Check'
}

doctype_action_properties = {
'label': 'Link',
'action_type': 'Select',
'action': 'Small Text',
'group': 'Data',
'hidden': 'Check'
}


ALLOWED_FIELDTYPE_CHANGE = (
('Currency', 'Float', 'Percent'),
('Small Text', 'Data'),
('Text', 'Data'),
('Text', 'Text Editor', 'Code', 'Signature', 'HTML Editor'),
('Data', 'Select'),
('Text', 'Small Text'),
('Text', 'Data', 'Barcode'),
('Code', 'Geolocation'),
('Table', 'Table MultiSelect'))

ALLOWED_OPTIONS_CHANGE = ('Read Only', 'HTML', 'Select', 'Data')

+ 73
- 5
frappe/custom/doctype/customize_form/test_customize_form.py 查看文件

@@ -5,6 +5,7 @@ from __future__ import unicode_literals
import frappe, unittest, json import frappe, unittest, json
from frappe.test_runner import make_test_records_for_doctype from frappe.test_runner import make_test_records_for_doctype
from frappe.core.doctype.doctype.doctype import InvalidFieldNameError from frappe.core.doctype.doctype.doctype import InvalidFieldNameError
from frappe.core.doctype.doctype.test_doctype import new_doctype


test_dependencies = ["Custom Field", "Property Setter"] test_dependencies = ["Custom Field", "Property Setter"]
class TestCustomizeForm(unittest.TestCase): class TestCustomizeForm(unittest.TestCase):
@@ -24,6 +25,7 @@ class TestCustomizeForm(unittest.TestCase):


def setUp(self): def setUp(self):
self.insert_custom_field() self.insert_custom_field()
frappe.db.delete('Property Setter', dict(doc_type='Event'))
frappe.db.commit() frappe.db.commit()
frappe.clear_cache(doctype="Event") frappe.clear_cache(doctype="Event")


@@ -185,9 +187,75 @@ class TestCustomizeForm(unittest.TestCase):
d.run_method("save_customization") d.run_method("save_customization")


def test_core_doctype_customization(self): def test_core_doctype_customization(self):
d = self.get_customize_form('User')
e = self.get_customize_form('Custom Field')
self.assertRaises(frappe.ValidationError, self.get_customize_form, 'User')


# core doctype is invalid, hence no attributes are set
self.assertEquals(d.get("fields"), [])
self.assertEquals(e.get("fields"), [])
def test_custom_link(self):
try:
# create a dummy doctype linked to Event
testdt_name = 'Test Link for Event'
testdt = new_doctype(testdt_name, fields=[
dict(fieldtype='Link', fieldname='event', options='Event')
]).insert()

testdt_name1 = 'Test Link for Event 1'
testdt1 = new_doctype(testdt_name1, fields=[
dict(fieldtype='Link', fieldname='event', options='Event')
]).insert()

# add a custom link
d = self.get_customize_form("Event")

d.append('links', dict(link_doctype=testdt_name, link_fieldname='event', group='Tests'))
d.append('links', dict(link_doctype=testdt_name1, link_fieldname='event', group='Tests'))

d.run_method("save_customization")

frappe.clear_cache()
event = frappe.get_meta('Event')

# check links exist
self.assertTrue([d.name for d in event.links if d.link_doctype == testdt_name])
self.assertTrue([d.name for d in event.links if d.link_doctype == testdt_name1])

# check order
order = json.loads(event.links_order)
self.assertListEqual(order, [d.name for d in event.links])

# remove the link
d = self.get_customize_form("Event")
d.links = []
d.run_method("save_customization")

frappe.clear_cache()
event = frappe.get_meta('Event')
self.assertFalse([d.name for d in (event.links or []) if d.link_doctype == testdt_name])
finally:
testdt.delete()
testdt1.delete()

def test_custom_action(self):
test_route = '#List/DocType'

# create a dummy action (route)
d = self.get_customize_form("Event")
d.append('actions', dict(label='Test Action', action_type='Route', action=test_route))
d.run_method("save_customization")

frappe.clear_cache()
event = frappe.get_meta('Event')

# check if added to meta
action = [d for d in event.actions if d.label=='Test Action']
self.assertEqual(len(action), 1)
self.assertEqual(action[0].action, test_route)

# clear the action
d = self.get_customize_form("Event")
d.actions = []
d.run_method("save_customization")

frappe.clear_cache()
event = frappe.get_meta('Event')

action = [d for d in event.actions if d.label=='Test Action']
self.assertEqual(len(action), 0)

+ 14
- 5
frappe/custom/doctype/customize_form_field/customize_form_field.json 查看文件

@@ -11,8 +11,7 @@
"label", "label",
"fieldtype", "fieldtype",
"fieldname", "fieldname",
"hide_seconds",
"hide_days",
"non_negative",
"reqd", "reqd",
"unique", "unique",
"in_list_view", "in_list_view",
@@ -23,6 +22,7 @@
"allow_in_quick_entry", "allow_in_quick_entry",
"translatable", "translatable",
"column_break_7", "column_break_7",
"default",
"precision", "precision",
"length", "length",
"options", "options",
@@ -47,8 +47,9 @@
"column_break_33", "column_break_33",
"read_only_depends_on", "read_only_depends_on",
"display", "display",
"default",
"in_filter", "in_filter",
"hide_seconds",
"hide_days",
"column_break_21", "column_break_21",
"description", "description",
"print_hide", "print_hide",
@@ -100,6 +101,7 @@
"depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", "depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)",
"fieldname": "reqd", "fieldname": "reqd",
"fieldtype": "Check", "fieldtype": "Check",
"in_list_view": 1,
"label": "Mandatory", "label": "Mandatory",
"oldfieldname": "reqd", "oldfieldname": "reqd",
"oldfieldtype": "Check", "oldfieldtype": "Check",
@@ -283,7 +285,7 @@
}, },
{ {
"fieldname": "default", "fieldname": "default",
"fieldtype": "Text",
"fieldtype": "Small Text",
"label": "Default", "label": "Default",
"oldfieldname": "default", "oldfieldname": "default",
"oldfieldtype": "Text" "oldfieldtype": "Text"
@@ -413,13 +415,20 @@
"fieldname": "hide_border", "fieldname": "hide_border",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Hide Border" "label": "Hide Border"
},
{
"default": "0",
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)",
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
} }
], ],
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-28 11:28:59.084060",
"modified": "2020-10-29 06:11:57.661039",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form Field", "name": "Customize Form Field",


+ 0
- 65
frappe/custom/doctype/package_document_type/package_document_type.json 查看文件

@@ -1,65 +0,0 @@
{
"actions": [],
"creation": "2020-05-14 16:45:47.196395",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"document_type",
"column_break_2",
"attachments",
"overwrite",
"section_break_4",
"filters_json"
],
"fields": [
{
"fieldname": "document_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"reqd": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "attachments",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Include Attachments"
},
{
"default": "0",
"fieldname": "overwrite",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Overwrite"
},
{
"fieldname": "section_break_4",
"fieldtype": "Section Break"
},
{
"fieldname": "filters_json",
"fieldtype": "Code",
"label": "Filters",
"options": "JSON"
}
],
"istable": 1,
"links": [],
"modified": "2020-05-14 16:45:47.196395",
"modified_by": "Administrator",
"module": "Custom",
"name": "Package Document Type",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 0
- 0
frappe/custom/doctype/package_publish_target/__init__.py 查看文件


+ 0
- 47
frappe/custom/doctype/package_publish_target/package_publish_target.json 查看文件

@@ -1,47 +0,0 @@
{
"actions": [],
"creation": "2020-05-13 16:04:32.724663",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"instance_url",
"username",
"password"
],
"fields": [
{
"fieldname": "instance_url",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Site URL",
"reqd": 1
},
{
"fieldname": "username",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Username",
"reqd": 1
},
{
"fieldname": "password",
"fieldtype": "Password",
"in_list_view": 1,
"label": "Password",
"reqd": 1
}
],
"istable": 1,
"links": [],
"modified": "2020-05-15 17:35:16.282235",
"modified_by": "Administrator",
"module": "Custom",
"name": "Package Publish Target",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 0
- 10
frappe/custom/doctype/package_publish_target/package_publish_target.py 查看文件

@@ -1,10 +0,0 @@
# -*- 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 PackagePublishTarget(Document):
pass

+ 0
- 0
frappe/custom/doctype/package_publish_tool/__init__.py 查看文件


+ 0
- 159
frappe/custom/doctype/package_publish_tool/package_publish_tool.js 查看文件

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

frappe.ui.form.on('Package Publish Tool', {
refresh: function(frm) {
frm.set_query("document_type", "package_details", function () {
return {
filters: {
"istable": 0,
}
};
});

frappe.realtime.on("package", (data) => {
frm.dashboard.show_progress(data.prefix, data.progress / data.total * 100, __("{0}", [data.message]));
if ((data.progress+1) != data.total) {
frm.dashboard.show_progress(data.prefix, data.progress / data.total * 100, __("{0}", [data.message]));
} else {
frm.dashboard.hide_progress();
}
});

frm.trigger("show_instructions");
frm.trigger("last_deployed_on");
frm.trigger("set_dirty_trigger");
frm.trigger("set_deploy_primary_action");
},
last_deployed_on: function(frm) {
if (frm.doc.last_deployed_on) {
frm.trigger("show_indicator");
}
},
show_indicator: function(frm) {
let pretty_date = frappe.datetime.prettyDate(frm.doc.last_deployed_on);
frm.page.set_indicator(__("Last published {0}", [pretty_date]), "blue");
},
set_dirty_trigger: function(frm) {
$(frm.wrapper).on("dirty", function() {
frm.page.set_primary_action(__('Save'), () => frm.save());
});
},
set_deploy_primary_action: function(frm) {
if (frm.doc.package_details.length && frm.doc.instances.length) {
frm.page.set_primary_action(__("Publish"), function () {
frappe.show_alert({
message: __("Publishing documents..."),
indicator: "green"
});

frappe.call({
method: "frappe.custom.doctype.package_publish_tool.package_publish_tool.deploy_package",
callback: function() {
frm.reload_doc();
frappe.msgprint(__("Documents have been published."));
}
});
});
}
},
show_instructions: function(frm) {
let field = frm.get_field("html_info");
field.html(`
<p class="text-muted text-medium">
Package Publish Tool let's you copy documents from your site to any other remote site.
Follow the steps below to publish.
</p>
<ol class="text-muted small">
<li>Add Document Types that you want to copy from the table below. You can also add filters by expanding the row.</li>
<li>Add the Sites URL where you want to copy these documents, and enter the Username and Password.</li>
<li>Click on Save. Now, you can click on Publish and the documents will be copied.</li>
</ol>
`);
}
});

frappe.ui.form.on('Package Document Type', {
form_render: function (frm, cdt, cdn) {
function _show_filters(filters, table) {
table.find('tbody').empty();

if (filters.length > 0) {
filters.forEach(filter => {
const filter_row =
$(`<tr>
<td>${filter[1]}</td>
<td>${filter[2] || ""}</td>
<td>${filter[3]}</td>
</tr>`);

table.find('tbody').append(filter_row);
});
} else {
const filter_row = $(`<tr><td colspan="3" class="text-muted text-center">
${__("Click to Set Filters")}</td></tr>`);
table.find('tbody').append(filter_row);
}
}

let row = frappe.get_doc(cdt, cdn);

let wrapper = $(`[data-fieldname="filters_json"]`).empty();
let table = $(`<table class="table table-bordered" style="cursor:pointer; margin:0px;">
<thead>
<tr>
<th style="width: 33%">${__('Filter')}</th>
<th style="width: 33%">${__('Condition')}</th>
<th>${__('Value')}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>`).appendTo(wrapper);
$(`<p class="text-muted small">${__("Click table to edit")}</p>`).appendTo(wrapper);

let filters = JSON.parse(row.filters_json || '[]');
_show_filters(filters, table);

table.on('click', () => {
if (!row.document_type) {
frappe.msgprint(__("Select Document Type."));
return;
}

frappe.model.with_doctype(row.document_type, function() {
let dialog = new frappe.ui.Dialog({
title: __('Set Filters'),
fields: [
{
fieldtype: 'HTML',
label: 'Filters',
fieldname: 'filter_area',
}
],
primary_action: function() {
let values = filter_group.get_filters();
let flt = [];
if (values) {
values.forEach(function(value) {
flt.push([value[0], value[1], value[2], value[3]]);
});
}
row.filters_json = JSON.stringify(flt);
_show_filters(flt, table);
dialog.hide();
},
primary_action_label: "Set"
});

let filter_group = new frappe.ui.FilterGroup({
parent: dialog.get_field('filter_area').$wrapper,
doctype: row.document_type,
on_change: () => {},
});
filter_group.add_filters_to_filter_group(filters);
dialog.show();
});
});
},
});

+ 0
- 84
frappe/custom/doctype/package_publish_tool/package_publish_tool.json 查看文件

@@ -1,84 +0,0 @@
{
"actions": [],
"creation": "2020-05-13 15:54:38.082657",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"html_info",
"sb_00",
"package_details",
"sb_01",
"instances",
"last_deployed_on"
],
"fields": [
{
"description": "Click on the row for accessing filters.",
"fieldname": "package_details",
"fieldtype": "Table",
"label": "Document Types",
"options": "Package Document Type",
"reqd": 1
},
{
"fieldname": "instances",
"fieldtype": "Table",
"label": "Sites",
"options": "Package Publish Target",
"reqd": 1
},
{
"fieldname": "html_info",
"fieldtype": "HTML"
},
{
"fieldname": "last_deployed_on",
"fieldtype": "Datetime",
"hidden": 1,
"label": "Last Deployed On",
"read_only": 1
},
{
"fieldname": "sb_00",
"fieldtype": "Section Break"
},
{
"fieldname": "sb_01",
"fieldtype": "Section Break"
}
],
"issingle": 1,
"links": [],
"modified": "2020-05-15 17:31:37.060199",
"modified_by": "Administrator",
"module": "Custom",
"name": "Package Publish Tool",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "All",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

+ 0
- 178
frappe/custom/doctype/package_publish_tool/package_publish_tool.py 查看文件

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

from __future__ import unicode_literals
import frappe
import json
import datetime
import base64
from frappe.model.document import Document
from frappe.utils.file_manager import save_file, get_file
from frappe import _
from six import string_types
from frappe.frappeclient import FrappeClient
from frappe.utils import get_datetime_str, get_datetime
from frappe.utils.password import get_decrypted_password

class PackagePublishTool(Document):
pass

@frappe.whitelist()
def deploy_package():
package, doc = export_package()

file_name = "Package-" + get_datetime_str(get_datetime())

length = len(doc.instances)
for idx, instance in enumerate(doc.instances):
frappe.publish_realtime("package", {"progress": idx, "total": length, "message": instance.instance_url, "prefix": _("Deploying")},
user=frappe.session.user)

install_package_to_remote(package, instance)

frappe.db.set_value("Package Publish Tool", "Package Publish Tool", "last_deployed_on", frappe.utils.now_datetime())

def install_package_to_remote(package, instance):
try:
connection = FrappeClient(instance.instance_url, instance.username, get_decrypted_password(instance.doctype, instance.name))
except Exception:
frappe.log_error(frappe.get_traceback())
frappe.throw(_("Couldn't connect to site {0}. Please check Error Logs.").format(instance.instance_url))

try:
connection.post_request({
"cmd": "frappe.custom.doctype.package_publish_tool.package_publish_tool.import_package",
"package": json.dumps(package)
})
except Exception:
frappe.log_error(frappe.get_traceback())
frappe.throw(_("Error while installing package to site {0}. Please check Error Logs.").format(instance.instance_url))

@frappe.whitelist()
def export_package():
"""Export package as JSON."""
package_doc = frappe.get_single("Package Publish Tool")
package = []

for doctype in package_doc.package_details:
filters = []

if doctype.get("filters_json"):
filters = json.loads(doctype.get("filters_json"))

docs = frappe.get_all(doctype.get("document_type"), filters=filters)
length = len(docs)

for idx, doc in enumerate(docs):
frappe.publish_realtime("package", {
"progress":idx, "total":length,
"message":doctype.get("document_type"),
"prefix": _("Exporting")
},
user=frappe.session.user)

document = frappe.get_doc(doctype.get("document_type"), doc.name).as_dict()
attachments = []

if doctype.attachments:
filters = {
"attached_to_doctype": document.get("doctype"),
"attached_to_name": document.get("name")
}

for f in frappe.get_list("File", filters=filters):
fname, fcontents = get_file(f.name)
attachments.append({
"fname": fname,
"content": base64.b64encode(fcontents).decode('ascii')
})

document.update({
"__attachments": attachments,
"__overwrite": True if doctype.overwrite else False
})

package.append(document)

return post_process(package), package_doc

@frappe.whitelist()
def import_package(package=None):
"""Import package from JSON."""
frappe.only_for("System Manager")
if isinstance(package, string_types):
package = json.loads(package)

for doc in package:
modified = doc.pop("modified")
overwrite = doc.pop("__overwrite")
attachments = doc.pop("__attachments")
exists = frappe.db.exists(doc.get("doctype"), doc.get("name"))

if not exists:
d = frappe.get_doc(doc).insert(ignore_permissions=True, ignore_if_duplicate=True)
if attachments:
add_attachment(attachments, d)
else:
docname = doc.pop("name")
document = frappe.get_doc(doc.get("doctype"), docname)

if overwrite:
update_document(document, doc, attachments)

else:
if frappe.utils.get_datetime(document.modified) < frappe.utils.get_datetime(modified):
update_document(document, doc, attachments)

def update_document(document, doc, attachments):
document.update(doc)
document.save()
if attachments:
add_attachment(attachments, document)

def add_attachment(attachments, doc):
for attachment in attachments:
save_file(attachment.get("fname"), base64.b64decode(attachment.get("content")), doc.get("doctype"), doc.get("name"))

def post_process(package):
"""Remove the keys from Document and Child Document. Convert datetime, date, time to str."""
del_keys = ('modified_by', 'creation', 'owner', 'idx', 'docstatus')
child_del_keys = ('modified_by', 'creation', 'owner', 'idx', 'docstatus', 'name')

for doc in package:
for key in del_keys:
if key in doc:
del doc[key]

for key, value in doc.items():
stringified_value = get_stringified_value(value)
if stringified_value:
doc[key] = stringified_value

if not isinstance(value, list):
continue

for child in value:
for child_key in child_del_keys:
if child_key in child:
del child[child_key]

for child_key, child_value in child.items():
stringified_value = get_stringified_value(child_value)
if stringified_value:
child[child_key] = stringified_value

return package

def get_stringified_value(value):
if isinstance(value, datetime.datetime):
return frappe.utils.get_datetime_str(value)

if isinstance(value, datetime.date):
return frappe.utils.get_date_str(value)

if isinstance(value, datetime.timedelta):
return frappe.utils.get_time_str(value)

return None

+ 0
- 10
frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py 查看文件

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

# import frappe
import unittest

class TestPackagePublishTool(unittest.TestCase):
pass

+ 113
- 338
frappe/custom/doctype/property_setter/property_setter.json 查看文件

@@ -1,358 +1,133 @@
{ {
"allow_copy": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2013-01-10 16:34:04",
"custom": 0,
"description": "Property Setter overrides a standard DocType or Field property",
"docstatus": 0,
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 0,
"engine": "InnoDB",
"actions": [],
"creation": "2013-01-10 16:34:04",
"description": "Property Setter overrides a standard DocType or Field property",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"help",
"sb0",
"doctype_or_field",
"doc_type",
"field_name",
"row_name",
"column_break0",
"property",
"property_type",
"value",
"default_value"
],
"fields": [ "fields": [
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "help",
"fieldtype": "HTML",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Help",
"length": 0,
"no_copy": 0,
"options": "<div class=\"alert\">Please don't update it as it can mess up your form. Use the Customize Form View and Custom Fields to set properties!</div>",
"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,
"unique": 0
},
"fieldname": "help",
"fieldtype": "HTML",
"label": "Help",
"options": "<div class=\"alert\">Please don't update it as it can mess up your form. Use the Customize Form View and Custom Fields to set properties!</div>"
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "sb0",
"fieldtype": "Section Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 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,
"unique": 0
},
"fieldname": "sb0",
"fieldtype": "Section Break"
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:doc.__islocal",
"fieldname": "doctype_or_field",
"fieldtype": "Select",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "DocType or Field",
"length": 0,
"no_copy": 0,
"options": "\nDocField\nDocType",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
"fieldname": "doctype_or_field",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Applied On",
"options": "\nDocField\nDocType\nDocType Link\nDocType Action",
"read_only_depends_on": "eval:!doc.__islocal",
"reqd": 1
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"description": "New value to be set",
"fieldname": "value",
"fieldtype": "Text",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Set Value",
"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,
"unique": 0
},
"description": "New value to be set",
"fieldname": "value",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Set Value"
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "column_break0",
"fieldtype": "Column Break",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 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,
"unique": 0
},
"fieldname": "column_break0",
"fieldtype": "Column Break"
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "doc_type",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 1,
"label": "DocType",
"length": 0,
"no_copy": 0,
"options": "DocType",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 1,
"set_only_once": 0,
"unique": 0
},
"fieldname": "doc_type",
"fieldtype": "Link",
"in_standard_filter": 1,
"label": "DocType",
"options": "DocType",
"reqd": 1,
"search_index": 1
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"depends_on": "eval:doc.doctype_or_field=='DocField'",
"description": "ID (name) of the entity whose property is to be set",
"fieldname": "field_name",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 1,
"label": "Field Name",
"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": 1,
"set_only_once": 0,
"unique": 0
},
"depends_on": "eval:doc.doctype_or_field=='DocField'",
"description": "ID (name) of the entity whose property is to be set",
"fieldname": "field_name",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Field Name",
"search_index": 1
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "property",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 1,
"label": "Property",
"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": 1,
"search_index": 1,
"set_only_once": 0,
"unique": 0
},
"fieldname": "property",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Property",
"reqd": 1,
"search_index": 1
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "property_type",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Property Type",
"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,
"unique": 0
},
"fieldname": "property_type",
"fieldtype": "Data",
"label": "Property Type"
},
{ {
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "default_value",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Default Value",
"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,
"unique": 0
"fieldname": "default_value",
"fieldtype": "Data",
"label": "Default Value"
},
{
"description": "For DocType Link / DocType Action",
"fieldname": "row_name",
"fieldtype": "Data",
"label": "Row Name"
} }
],
"hide_heading": 0,
"hide_toolbar": 0,
"icon": "fa fa-glass",
"idx": 1,
"image_view": 0,
"in_create": 0,

"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2016-12-29 14:39:50.172883",
"modified_by": "Administrator",
"module": "Custom",
"name": "Property Setter",
"owner": "Administrator",
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-09-24 14:42:38.599684",
"modified_by": "Administrator",
"module": "Custom",
"name": "Property Setter",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"is_custom": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1,
"write": 1 "write": 1
},
},
{ {
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"is_custom": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1 "write": 1
} }
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"search_fields": "doc_type,property",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
],
"search_fields": "doc_type,property",
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
} }

+ 16
- 13
frappe/custom/doctype/property_setter/property_setter.py 查看文件

@@ -11,13 +11,16 @@ not_allowed_fieldtype_change = ['naming_series']


class PropertySetter(Document): class PropertySetter(Document):
def autoname(self): def autoname(self):
self.name = self.doc_type + "-" \
+ (self.field_name and (self.field_name + "-") or "") \
+ self.property
self.name = '{doctype}-{field}-{property}'.format(
doctype = self.doc_type,
field = self.field_name or self.row_name or 'main',
property = self.property
)


def validate(self): def validate(self):
self.validate_fieldtype_change() self.validate_fieldtype_change()
self.delete_property_setter()
if self.is_new():
delete_property_setter(self.doc_type, self.property, self.field_name)


# clear cache # clear cache
frappe.clear_cache(doctype = self.doc_type) frappe.clear_cache(doctype = self.doc_type)
@@ -27,15 +30,6 @@ class PropertySetter(Document):
self.property == 'fieldtype': self.property == 'fieldtype':
frappe.throw(_("Field type cannot be changed for {0}").format(self.field_name)) frappe.throw(_("Field type cannot be changed for {0}").format(self.field_name))


def delete_property_setter(self):
"""delete other property setters on this, if this is new"""
if self.get('__islocal'):
frappe.db.sql("""delete from `tabProperty Setter` where
doctype_or_field = %(doctype_or_field)s
and doc_type = %(doc_type)s
and coalesce(field_name,'') = coalesce(%(field_name)s, '')
and property = %(property)s""", self.get_valid_dict())

def get_property_list(self, dt): def get_property_list(self, dt):
return frappe.db.get_all('DocField', return frappe.db.get_all('DocField',
fields=['fieldname', 'label', 'fieldtype'], fields=['fieldname', 'label', 'fieldtype'],
@@ -89,3 +83,12 @@ def make_property_setter(doctype, fieldname, property, value, property_type, for
property_setter.flags.validate_fields_for_doctype = validate_fields_for_doctype property_setter.flags.validate_fields_for_doctype = validate_fields_for_doctype
property_setter.insert() property_setter.insert()
return property_setter return property_setter

def delete_property_setter(doc_type, property, field_name=None):
"""delete other property setters on this, if this is new"""
filters = dict(doc_type = doc_type, property=property)
if field_name:
filters['field_name'] = field_name

frappe.db.delete('Property Setter', filters)


+ 8
- 12
frappe/database/database.py 查看文件

@@ -319,8 +319,7 @@ class Database(object):
nres.append(nr) nres.append(nr)
return nres return nres


@staticmethod
def build_conditions(filters):
def build_conditions(self, filters):
"""Convert filters sent as dict, lists to SQL conditions. filter's key """Convert filters sent as dict, lists to SQL conditions. filter's key
is passed by map function, build conditions like: is passed by map function, build conditions like:


@@ -341,18 +340,12 @@ class Database(object):
value = filters.get(key) value = filters.get(key)
values[key] = value values[key] = value
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
# value is a tuble like ("!=", 0)
# value is a tuple like ("!=", 0)
_operator = value[0] _operator = value[0]
values[key] = value[1] values[key] = value[1]
if isinstance(value[1], (tuple, list)): if isinstance(value[1], (tuple, list)):
# value is a list in tuple ("in", ("A", "B")) # value is a list in tuple ("in", ("A", "B"))
inner_list = []
for i, v in enumerate(value[1]):
inner_key = "{0}_{1}".format(key, i)
values[inner_key] = v
inner_list.append("%({0})s".format(inner_key))

_rhs = " ({0})".format(", ".join(inner_list))
_rhs = " ({0})".format(", ".join([self.escape(v) for v in value[1]]))
del values[key] del values[key]


if _operator not in ["=", "!=", ">", ">=", "<", "<=", "like", "in", "not in", "not like"]: if _operator not in ["=", "!=", ">", ">=", "<", "<=", "like", "in", "not in", "not like"]:
@@ -787,6 +780,9 @@ class Database(object):
"""Returns True if table for given doctype exists.""" """Returns True if table for given doctype exists."""
return ("tab" + doctype) in self.get_tables() return ("tab" + doctype) in self.get_tables()


def has_table(self, doctype):
return self.table_exists(doctype)

def get_tables(self): def get_tables(self):
tables = frappe.cache().get_value('db_tables') tables = frappe.cache().get_value('db_tables')
if not tables: if not tables:
@@ -959,13 +955,13 @@ class Database(object):
query = sql_dict.get(current_dialect) query = sql_dict.get(current_dialect)
return self.sql(query, values, **kwargs) return self.sql(query, values, **kwargs)


def delete(self, doctype, conditions):
def delete(self, doctype, conditions, debug=False):
if conditions: if conditions:
conditions, values = self.build_conditions(conditions) conditions, values = self.build_conditions(conditions)
return self.sql("DELETE FROM `tab{doctype}` where {conditions}".format( return self.sql("DELETE FROM `tab{doctype}` where {conditions}".format(
doctype=doctype, doctype=doctype,
conditions=conditions conditions=conditions
), values)
), values, debug=debug)
else: else:
frappe.throw(_('No conditions provided')) frappe.throw(_('No conditions provided'))




+ 5
- 6
frappe/database/db_manager.py 查看文件

@@ -3,7 +3,6 @@ import frappe




class DbManager: class DbManager:

def __init__(self, db): def __init__(self, db):
""" """
Pass root_conn here for access to all databases. Pass root_conn here for access to all databases.
@@ -66,10 +65,10 @@ class DbManager:
esc = make_esc('$ ') esc = make_esc('$ ')


from distutils.spawn import find_executable from distutils.spawn import find_executable
pipe = find_executable('pv')
if pipe:
pipe = '{pipe} {source} |'.format(
pipe=pipe,
pv = find_executable('pv')
if pv:
pipe = '{pv} {source} |'.format(
pv=pv,
source=source source=source
) )
source = '' source = ''
@@ -78,7 +77,7 @@ class DbManager:
source = '< {source}'.format(source=source) source = '< {source}'.format(source=source)


if pipe: if pipe:
print('Creating Database...')
print('Restoring Database file...')


command = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}' command = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}'
command = command.format( command = command.format(


+ 1
- 1
frappe/database/mariadb/framework_mariadb.sql 查看文件

@@ -233,7 +233,7 @@ CREATE TABLE `tabDocType` (


DROP TABLE IF EXISTS `tabSeries`; DROP TABLE IF EXISTS `tabSeries`;
CREATE TABLE `tabSeries` ( CREATE TABLE `tabSeries` (
`name` varchar(100) DEFAULT NULL,
`name` varchar(100),
`current` int(10) NOT NULL DEFAULT 0, `current` int(10) NOT NULL DEFAULT 0,
PRIMARY KEY(`name`) PRIMARY KEY(`name`)
) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;


+ 14
- 4
frappe/database/mariadb/setup_db.py 查看文件

@@ -1,7 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals


import frappe import frappe
import os, sys
import os
from frappe.database.db_manager import DbManager from frappe.database.db_manager import DbManager


expected_settings_10_2_earlier = { expected_settings_10_2_earlier = {
@@ -86,6 +86,8 @@ def drop_user_and_database(db_name, root_login, root_password):
dbman.drop_database(db_name) dbman.drop_database(db_name)


def bootstrap_database(db_name, verbose, source_sql=None): def bootstrap_database(db_name, verbose, source_sql=None):
import sys

frappe.connect(db_name=db_name) frappe.connect(db_name=db_name)
if not check_database_settings(): if not check_database_settings():
print('Database settings do not match expected values; stopping database setup.') print('Database settings do not match expected values; stopping database setup.')
@@ -94,9 +96,17 @@ def bootstrap_database(db_name, verbose, source_sql=None):
import_db_from_sql(source_sql, verbose) import_db_from_sql(source_sql, verbose)


frappe.connect(db_name=db_name) frappe.connect(db_name=db_name)
if not 'tabDefaultValue' in frappe.db.get_tables():
print('''Database not installed, this can due to lack of permission, or that the database name exists.
Check your mysql root password, or use --force to reinstall''')
if 'tabDefaultValue' not in frappe.db.get_tables():
from click import secho

secho(
"Table 'tabDefaultValue' missing in the restored site. "
"Database not installed correctly, this can due to lack of "
"permission, or that the database name exists. Check your mysql"
" root password, validity of the backup file or use --force to"
" reinstall",
fg="red"
)
sys.exit(1) sys.exit(1)


def import_db_from_sql(source_sql=None, verbose=False): def import_db_from_sql(source_sql=None, verbose=False):


+ 2
- 2
frappe/database/postgres/database.py 查看文件

@@ -140,11 +140,11 @@ class PostgresDatabase(Database):


@staticmethod @staticmethod
def is_table_missing(e): def is_table_missing(e):
return e.pgcode == '42P01'
return getattr(e, 'pgcode', None) == '42P01'


@staticmethod @staticmethod
def is_missing_column(e): def is_missing_column(e):
return e.pgcode == '42703'
return getattr(e, 'pgcode', None) == '42703'


@staticmethod @staticmethod
def is_access_denied(e): def is_access_denied(e):


+ 53
- 12
frappe/database/postgres/setup_db.py 查看文件

@@ -1,5 +1,7 @@
import frappe, subprocess, os
from six.moves import input
import os

import frappe



def setup_database(force, source_sql=None, verbose=False): def setup_database(force, source_sql=None, verbose=False):
root_conn = get_root_connection() root_conn = get_root_connection()
@@ -10,24 +12,62 @@ def setup_database(force, source_sql=None, verbose=False):
root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name, root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name,
frappe.conf.db_password)) frappe.conf.db_password))
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name)) root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name))
root_conn.close()

bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql)
frappe.connect()

def bootstrap_database(db_name, verbose, source_sql=None):
frappe.connect(db_name=db_name)
import_db_from_sql(source_sql, verbose)
frappe.connect(db_name=db_name)

if 'tabDefaultValue' not in frappe.db.get_tables():
import sys
from click import secho

secho(
"Table 'tabDefaultValue' missing in the restored site. "
"This may be due to incorrect permissions or the result of a restore from a bad backup file. "
"Database not installed correctly.",
fg="red"
)
sys.exit(1)

def import_db_from_sql(source_sql=None, verbose=False):
from shutil import which
from subprocess import run, PIPE


# we can't pass psql password in arguments in postgresql as mysql. So # we can't pass psql password in arguments in postgresql as mysql. So
# set password connection parameter in environment variable # set password connection parameter in environment variable
subprocess_env = os.environ.copy() subprocess_env = os.environ.copy()
subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password) subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password)

# bootstrap db # bootstrap db
if not source_sql: if not source_sql:
source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql')


subprocess.check_output([
'psql', frappe.conf.db_name,
'-h', frappe.conf.db_host or 'localhost',
'-p', str(frappe.conf.db_port or '5432'),
'-U', frappe.conf.db_name,
'-f', source_sql
], env=subprocess_env)
pv = which('pv')


frappe.connect()
_command = (
f"psql {frappe.conf.db_name} "
f"-h {frappe.conf.db_host or 'localhost'} -p {str(frappe.conf.db_port or '5432')} "
f"-U {frappe.conf.db_name}"
)

if pv:
command = f"{pv} {source_sql} | " + _command
else:
command = _command + f" -f {source_sql}"

print("Restoring Database file...")
if verbose:
print(command)

restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE)

if verbose:
print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}")


def setup_help_database(help_db_name): def setup_help_database(help_db_name):
root_conn = get_root_connection() root_conn = get_root_connection()
@@ -38,19 +78,20 @@ def setup_help_database(help_db_name):
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name)) root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name))


def get_root_connection(root_login=None, root_password=None): def get_root_connection(root_login=None, root_password=None):
import getpass
if not frappe.local.flags.root_connection: if not frappe.local.flags.root_connection:
if not root_login: if not root_login:
root_login = frappe.conf.get("root_login") or None root_login = frappe.conf.get("root_login") or None


if not root_login: if not root_login:
from six.moves import input
root_login = input("Enter postgres super user: ") root_login = input("Enter postgres super user: ")


if not root_password: if not root_password:
root_password = frappe.conf.get("root_password") or None root_password = frappe.conf.get("root_password") or None


if not root_password: if not root_password:
root_password = getpass.getpass("Postgres super user password: ")
from getpass import getpass
root_password = getpass("Postgres super user password: ")


frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password)




+ 1
- 1
frappe/database/schema.py 查看文件

@@ -186,7 +186,7 @@ class DbColumn:
column_def += ' not null default {0}'.format(default_value) column_def += ' not null default {0}'.format(default_value)


elif self.default and (self.default not in frappe.db.DEFAULT_SHORTCUTS) \ elif self.default and (self.default not in frappe.db.DEFAULT_SHORTCUTS) \
and not self.default.startswith(":") and column_def not in ('text', 'longtext'):
and not cstr(self.default).startswith(":") and column_def not in ('text', 'longtext'):
column_def += " default {}".format(frappe.db.escape(self.default)) column_def += " default {}".format(frappe.db.escape(self.default))


if self.unique and (column_def not in ('text', 'longtext')): if self.unique and (column_def not in ('text', 'longtext')):


+ 19
- 0
frappe/desk/doctype/dashboard/dashboard.py 查看文件

@@ -5,6 +5,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from frappe.model.document import Document from frappe.model.document import Document
from frappe.modules.export_file import export_to_files from frappe.modules.export_file import export_to_files
from frappe.config import get_modules_from_all_apps_for_user
import frappe import frappe
from frappe import _ from frappe import _
import json import json
@@ -42,6 +43,24 @@ class Dashboard(Document):
except ValueError as error: except ValueError as error:
frappe.throw(_("Invalid json added in the custom options: {0}").format(error)) frappe.throw(_("Invalid json added in the custom options: {0}").format(error))



def get_permission_query_conditions(user):
if not user:
user = frappe.session.user

if user == 'Administrator':
return

roles = frappe.get_roles(user)
if "System Manager" in roles:
return None

allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
module_condition = '`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL'.format(
allowed_modules=','.join(allowed_modules))

return module_condition

@frappe.whitelist() @frappe.whitelist()
def get_permitted_charts(dashboard_name): def get_permitted_charts(dashboard_name):
permitted_charts = [] permitted_charts = []


+ 27
- 84
frappe/desk/doctype/dashboard_chart/dashboard_chart.py 查看文件

@@ -7,17 +7,18 @@ import frappe
from frappe import _ from frappe import _
import datetime import datetime
import json import json
from frappe.utils.dashboard import cache_source, get_from_date_from_timespan
from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate,\
get_datetime, cint, now_datetime
from frappe.utils.dashboard import cache_source
from frappe.utils import nowdate, getdate, get_datetime, cint, now_datetime
from frappe.utils.dateutils import\
get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain
from frappe.model.naming import append_number_if_name_exists from frappe.model.naming import append_number_if_name_exists
from frappe.boot import get_allowed_reports from frappe.boot import get_allowed_reports
from frappe.config import get_modules_from_all_apps_for_user
from frappe.model.document import Document from frappe.model.document import Document
from frappe.modules.export_file import export_to_files from frappe.modules.export_file import export_to_files




def get_permission_query_conditions(user): def get_permission_query_conditions(user):

if not user: if not user:
user = frappe.session.user user = frappe.session.user


@@ -30,9 +31,11 @@ def get_permission_query_conditions(user):


doctype_condition = False doctype_condition = False
report_condition = False report_condition = False
module_condition = False


allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
allowed_reports = [frappe.db.escape(key) if type(key) == str else key.encode('UTF8') for key in get_allowed_reports()] allowed_reports = [frappe.db.escape(key) if type(key) == str else key.encode('UTF8') for key in get_allowed_reports()]
allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]


if allowed_doctypes: if allowed_doctypes:
doctype_condition = '`tabDashboard Chart`.`document_type` in ({allowed_doctypes})'.format( doctype_condition = '`tabDashboard Chart`.`document_type` in ({allowed_doctypes})'.format(
@@ -40,18 +43,24 @@ def get_permission_query_conditions(user):
if allowed_reports: if allowed_reports:
report_condition = '`tabDashboard Chart`.`report_name` in ({allowed_reports})'.format( report_condition = '`tabDashboard Chart`.`report_name` in ({allowed_reports})'.format(
allowed_reports=','.join(allowed_reports)) allowed_reports=','.join(allowed_reports))
if allowed_modules:
module_condition = '''`tabDashboard Chart`.`module` in ({allowed_modules})
or `tabDashboard Chart`.`module` is NULL'''.format(
allowed_modules=','.join(allowed_modules))


return ''' return '''
(`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
and {doctype_condition})
or
(`tabDashboard Chart`.`chart_type` = 'Report'
and {report_condition})
'''.format(
doctype_condition=doctype_condition,
report_condition=report_condition
)

((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
and {doctype_condition})
or
(`tabDashboard Chart`.`chart_type` = 'Report'
and {report_condition}))
and
({module_condition})
'''.format(
doctype_condition=doctype_condition,
report_condition=report_condition,
module_condition=module_condition
)


def has_permission(doc, ptype, user): def has_permission(doc, ptype, user):
roles = frappe.get_roles(user) roles = frappe.get_roles(user)
@@ -156,6 +165,7 @@ def add_chart_to_dashboard(args):
def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
if not from_date: if not from_date:
from_date = get_from_date_from_timespan(to_date, timespan) from_date = get_from_date_from_timespan(to_date, timespan)
from_date = get_period_beginning(from_date, timegrain)
if not to_date: if not to_date:
to_date = now_datetime() to_date = now_datetime()


@@ -185,7 +195,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
result = get_result(data, timegrain, from_date, to_date) result = get_result(data, timegrain, from_date, to_date)


chart_config = { chart_config = {
"labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result],
"labels": [get_period(r[0], timegrain) for r in result],
"datasets": [{ "datasets": [{
"name": chart.name, "name": chart.name,
"values": [r[1] for r in result] "values": [r[1] for r in result]
@@ -279,16 +289,8 @@ def get_aggregate_function(chart_type):




def get_result(data, timegrain, from_date, to_date): def get_result(data, timegrain, from_date, to_date):
start_date = getdate(from_date)
end_date = getdate(to_date)

result = [[start_date, 0.0]]

while start_date < end_date:
next_date = get_next_expected_date(start_date, timegrain)
result.append([next_date, 0.0])
start_date = next_date

dates = get_dates_from_timegrain(from_date, to_date, timegrain)
result = [[date, 0] for date in dates]
data_index = 0 data_index = 0
if data: if data:
for i, d in enumerate(result): for i, d in enumerate(result):
@@ -298,65 +300,6 @@ def get_result(data, timegrain, from_date, to_date):


return result return result


def get_next_expected_date(date, timegrain):
next_date = None
# given date is always assumed to be the period ending date
next_date = get_period_ending(add_to_date(date, days=1), timegrain)
return getdate(next_date)

def get_period_ending(date, timegrain):
date = getdate(date)
if timegrain == 'Daily':
pass
elif timegrain == 'Weekly':
date = get_week_ending(date)
elif timegrain == 'Monthly':
date = get_month_ending(date)
elif timegrain == 'Quarterly':
date = get_quarter_ending(date)
elif timegrain == 'Yearly':
date = get_year_ending(date)

return getdate(date)

def get_week_ending(date):
# week starts on monday
from datetime import timedelta
start = date - timedelta(days = date.weekday())
end = start + timedelta(days=6)

return end

def get_month_ending(date):
month_of_the_year = int(date.strftime('%m'))
# first day of next month (note month starts from 1)

date = add_to_date('{}-01-01'.format(date.year), months = month_of_the_year)
# last day of this month
return add_to_date(date, days=-1)

def get_quarter_ending(date):
date = getdate(date)

# find the earliest quarter ending date that is after
# the given date
for month in (3, 6, 9, 12):
quarter_end_month = getdate('{}-{}-01'.format(date.year, month))
quarter_end_date = getdate(get_last_day(quarter_end_month))
if date <= quarter_end_date:
date = quarter_end_date
break

return date

def get_year_ending(date):
''' returns year ending of the given date '''

# first day of next year (note year starts from 1)
date = add_to_date('{}-01-01'.format(date.year), months = 12)
# last day of this month
return add_to_date(date, days=-1)

@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters): def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters):


+ 14
- 24
frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py 查看文件

@@ -5,8 +5,8 @@ from __future__ import unicode_literals


import unittest, frappe import unittest, frappe
from frappe.utils import getdate, formatdate, get_last_day from frappe.utils import getdate, formatdate, get_last_day
from frappe.desk.doctype.dashboard_chart.dashboard_chart import (get,
get_period_ending)
from frappe.utils.dateutils import get_period_ending, get_period
from frappe.desk.doctype.dashboard_chart.dashboard_chart import get


from datetime import datetime from datetime import datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -53,15 +53,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1) cur_date = datetime.now() - relativedelta(years=1)


result = get(chart_name='Test Dashboard Chart', refresh=1) result = get(chart_name='Test Dashboard Chart', refresh=1)
self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))


if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
cur_date += relativedelta(months=1)

for idx in range(1, 13):
for idx in range(13):
month = get_last_day(cur_date) month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d')) month = formatdate(month.strftime('%Y-%m-%d'))
self.assertEqual(result.get('labels')[idx], month)
self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1) cur_date += relativedelta(months=1)


frappe.db.rollback() frappe.db.rollback()
@@ -87,15 +83,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1) cur_date = datetime.now() - relativedelta(years=1)


result = get(chart_name ='Test Empty Dashboard Chart', refresh=1) result = get(chart_name ='Test Empty Dashboard Chart', refresh=1)
self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))


if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
cur_date += relativedelta(months=1)

for idx in range(1, 13):
for idx in range(13):
month = get_last_day(cur_date) month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d')) month = formatdate(month.strftime('%Y-%m-%d'))
self.assertEqual(result.get('labels')[idx], month)
self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1) cur_date += relativedelta(months=1)


frappe.db.rollback() frappe.db.rollback()
@@ -124,15 +116,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1) cur_date = datetime.now() - relativedelta(years=1)


result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1) result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1)
self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))


if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
cur_date += relativedelta(months=1)

for idx in range(1, 13):
for idx in range(13):
month = get_last_day(cur_date) month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d')) month = formatdate(month.strftime('%Y-%m-%d'))
self.assertEqual(result.get('labels')[idx], month)
self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1) cur_date += relativedelta(months=1)


# only 1 data point with value # only 1 data point with value
@@ -183,13 +171,12 @@ class TestDashboardChart(unittest.TestCase):
timeseries = 1 timeseries = 1
)).insert() )).insert()


result = get(chart_name ='Test Daily Dashboard Chart', refresh = 1)
result = get(chart_name = 'Test Daily Dashboard Chart', refresh = 1)


self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0]) self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0])
self.assertEqual( self.assertEqual(
result.get('labels'), result.get('labels'),
[formatdate('2019-01-06'), formatdate('2019-01-07'), formatdate('2019-01-08'),\
formatdate('2019-01-09'), formatdate('2019-01-10'), formatdate('2019-01-11')]
['06-01-19', '07-01-19', '08-01-19', '09-01-19', '10-01-19', '11-01-19']
) )


frappe.db.rollback() frappe.db.rollback()
@@ -218,7 +205,10 @@ class TestDashboardChart(unittest.TestCase):
result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1)


self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0])
self.assertEqual(result.get('labels'), [formatdate('2018-12-30'), formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')])
self.assertEqual(
result.get('labels'),
['30-12-18', '06-01-19', '13-01-19', '20-01-19']
)


frappe.db.rollback() frappe.db.rollback()




+ 8
- 1
frappe/desk/doctype/notification_settings/notification_settings.js 查看文件

@@ -2,12 +2,19 @@
// For license information, please see license.txt // For license information, please see license.txt


frappe.ui.form.on('Notification Settings', { frappe.ui.form.on('Notification Settings', {
onload: () => {
onload: (frm) => {
frappe.breadcrumbs.add({ frappe.breadcrumbs.add({
label: __('Settings'), label: __('Settings'),
route: '#modules/Settings', route: '#modules/Settings',
type: 'Custom' type: 'Custom'
}); });
frm.set_query('subscribed_documents', () => {
return {
filters: {
istable: 0
}
};
});
}, },


refresh: (frm) => { refresh: (frm) => {


+ 13
- 32
frappe/desk/doctype/notification_settings/notification_settings.json 查看文件

@@ -22,68 +22,52 @@
"default": "1", "default": "1",
"fieldname": "enabled", "fieldname": "enabled",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enabled",
"show_days": 1,
"show_seconds": 1
"label": "Enabled"
}, },
{ {
"fieldname": "subscribed_documents", "fieldname": "subscribed_documents",
"fieldtype": "Table MultiSelect", "fieldtype": "Table MultiSelect",
"label": "Subscribed Documents",
"options": "Notification Subscribed Document",
"show_days": 1,
"show_seconds": 1
"label": "Open Documents",
"options": "Notification Subscribed Document"
}, },
{ {
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Email Settings",
"show_days": 1,
"show_seconds": 1
"label": "Email Settings"
}, },
{ {
"default": "1", "default": "1",
"fieldname": "enable_email_notifications", "fieldname": "enable_email_notifications",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Email Notifications",
"show_days": 1,
"show_seconds": 1
"label": "Enable Email Notifications"
}, },
{ {
"default": "1", "default": "1",
"depends_on": "enable_email_notifications", "depends_on": "enable_email_notifications",
"fieldname": "enable_email_mention", "fieldname": "enable_email_mention",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Mentions",
"show_days": 1,
"show_seconds": 1
"label": "Mentions"
}, },
{ {
"default": "1", "default": "1",
"depends_on": "enable_email_notifications", "depends_on": "enable_email_notifications",
"fieldname": "enable_email_assignment", "fieldname": "enable_email_assignment",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Assignments",
"show_days": 1,
"show_seconds": 1
"label": "Assignments"
}, },
{ {
"default": "1", "default": "1",
"depends_on": "enable_email_notifications", "depends_on": "enable_email_notifications",
"fieldname": "enable_email_energy_point", "fieldname": "enable_email_energy_point",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Energy Points",
"show_days": 1,
"show_seconds": 1
"label": "Energy Points"
}, },
{ {
"default": "1", "default": "1",
"depends_on": "enable_email_notifications", "depends_on": "enable_email_notifications",
"fieldname": "enable_email_share", "fieldname": "enable_email_share",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Document Share",
"show_days": 1,
"show_seconds": 1
"label": "Document Share"
}, },
{ {
"default": "__user", "default": "__user",
@@ -92,23 +76,20 @@
"hidden": 1, "hidden": 1,
"label": "User", "label": "User",
"options": "User", "options": "User",
"read_only": 1,
"show_days": 1,
"show_seconds": 1
"read_only": 1
}, },
{ {
"default": "0", "default": "0",
"fieldname": "seen", "fieldname": "seen",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1, "hidden": 1,
"label": "Seen",
"show_days": 1,
"show_seconds": 1
"label": "Seen"
} }
], ],
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-05-31 22:16:40.798019",
"modified": "2020-11-04 12:54:57.989317",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Notification Settings", "name": "Notification Settings",


+ 11
- 2
frappe/desk/doctype/number_card/number_card.py 查看文件

@@ -8,6 +8,7 @@ from frappe.model.document import Document
from frappe.utils import cint from frappe.utils import cint
from frappe.model.naming import append_number_if_name_exists from frappe.model.naming import append_number_if_name_exists
from frappe.modules.export_file import export_to_files from frappe.modules.export_file import export_to_files
from frappe.config import get_modules_from_all_apps_for_user


class NumberCard(Document): class NumberCard(Document):
def autoname(self): def autoname(self):
@@ -33,16 +34,24 @@ def get_permission_query_conditions(user=None):
return None return None


doctype_condition = False doctype_condition = False
module_condition = False


allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]


if allowed_doctypes: if allowed_doctypes:
doctype_condition = '`tabNumber Card`.`document_type` in ({allowed_doctypes})'.format( doctype_condition = '`tabNumber Card`.`document_type` in ({allowed_doctypes})'.format(
allowed_doctypes=','.join(allowed_doctypes)) allowed_doctypes=','.join(allowed_doctypes))
if allowed_modules:
module_condition = '''`tabNumber Card`.`module` in ({allowed_modules})
or `tabNumber Card`.`module` is NULL'''.format(
allowed_modules=','.join(allowed_modules))


return ''' return '''
{doctype_condition}
'''.format(doctype_condition=doctype_condition)
{doctype_condition}
and
{module_condition}
'''.format(doctype_condition=doctype_condition, module_condition=module_condition)


def has_permission(doc, ptype, user): def has_permission(doc, ptype, user):
roles = frappe.get_roles(user) roles = frappe.get_roles(user)


+ 1
- 1
frappe/desk/form/document_follow.py 查看文件

@@ -21,7 +21,7 @@ def follow_document(doctype, doc_name, user, force=False):
avoided for some doctype avoided for some doctype
follow only if track changes are set to 1 follow only if track changes are set to 1
''' '''
if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment")
if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment", "Email Account", "Email Domain")
or doctype in log_types): or doctype in log_types):
return return




+ 8
- 2
frappe/desk/page/user_profile/user_profile.py 查看文件

@@ -1,17 +1,23 @@
import frappe import frappe
from datetime import datetime from datetime import datetime
from frappe.utils import getdate


@frappe.whitelist() @frappe.whitelist()
def get_energy_points_heatmap_data(user, date): def get_energy_points_heatmap_data(user, date):
try:
date = getdate(date)
except Exception:
date = getdate()

return dict(frappe.db.sql("""select unix_timestamp(date(creation)), sum(points) return dict(frappe.db.sql("""select unix_timestamp(date(creation)), sum(points)
from `tabEnergy Point Log` from `tabEnergy Point Log`
where where
date(creation) > subdate('{date}', interval 1 year) and date(creation) > subdate('{date}', interval 1 year) and
date(creation) < subdate('{date}', interval -1 year) and date(creation) < subdate('{date}', interval -1 year) and
user = '{user}' and
user = %s and
type != 'Review' type != 'Review'
group by date(creation) group by date(creation)
order by creation asc""".format(user = user, date = date)))
order by creation asc""".format(date = date), user))




@frappe.whitelist() @frappe.whitelist()


+ 0
- 5
frappe/email/doctype/email_group/email_group.js 查看文件

@@ -3,11 +3,6 @@


frappe.ui.form.on("Email Group", "refresh", function(frm) { frappe.ui.form.on("Email Group", "refresh", function(frm) {
if(!frm.is_new()) { if(!frm.is_new()) {
frm.add_custom_button(__("View Subscribers"), function() {
frappe.route_options = {"email_group": frm.doc.name};
frappe.set_route("List", "Email Group Member");
}, __("View"));

frm.add_custom_button(__("Import Subscribers"), function() { frm.add_custom_button(__("Import Subscribers"), function() {
frappe.prompt({fieldtype:"Select", options: frm.doc.__onload.import_types, frappe.prompt({fieldtype:"Select", options: frm.doc.__onload.import_types,
label:__("Import Email From"), fieldname:"doctype", reqd:1}, label:__("Import Email From"), fieldname:"doctype", reqd:1},


+ 10
- 2
frappe/email/doctype/email_group/email_group.json 查看文件

@@ -5,6 +5,7 @@
"creation": "2015-03-18 06:08:32.729800", "creation": "2015-03-18 06:08:32.729800",
"doctype": "DocType", "doctype": "DocType",
"document_type": "Setup", "document_type": "Setup",
"engine": "InnoDB",
"field_order": [ "field_order": [
"title", "title",
"total_subscribers", "total_subscribers",
@@ -41,8 +42,15 @@
"options": "Email Template" "options": "Email Template"
} }
], ],
"links": [],
"modified": "2020-02-21 14:12:48.884738",
"index_web_pages_for_search": 1,
"links": [
{
"group": "Members",
"link_doctype": "Email Group Member",
"link_fieldname": "email_group"
}
],
"modified": "2020-09-24 16:41:55.286377",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Email Group", "name": "Email Group",


+ 20
- 2
frappe/email/doctype/email_template/email_template.json 查看文件

@@ -1,4 +1,5 @@
{ {
"actions": [],
"allow_import": 1, "allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "Prompt", "autoname": "Prompt",
@@ -8,6 +9,8 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"subject", "subject",
"use_html",
"response_html",
"response", "response",
"owner", "owner",
"section_break_4", "section_break_4",
@@ -22,11 +25,12 @@
"reqd": 1 "reqd": 1
}, },
{ {
"depends_on": "eval:!doc.use_html",
"fieldname": "response", "fieldname": "response",
"fieldtype": "Text Editor", "fieldtype": "Text Editor",
"in_list_view": 1, "in_list_view": 1,
"label": "Response", "label": "Response",
"reqd": 1
"mandatory_depends_on": "eval:!doc.use_html"
}, },
{ {
"default": "user", "default": "user",
@@ -45,10 +49,24 @@
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Email Reply Help", "label": "Email Reply Help",
"options": "<h4>Email Reply Example</h4>\n\n<pre>Order Overdue\n\nTransaction {{ name }} has exceeded Due Date. Please take necessary action.\n\nDetails\n\n- Customer: {{ customer }}\n- Amount: {{ grand_total }}\n</pre>\n\n<h4>How to get fieldnames</h4>\n\n<p>The fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup &gt; Customize Form View and selecting the document type (e.g. Sales Invoice)</p>\n\n<h4>Templating</h4>\n\n<p>Templates are compiled using the Jinja Templating Language. To learn more about Jinja, <a class=\"strong\" href=\"http://jinja.pocoo.org/docs/dev/templates/\">read this documentation.</a></p>\n" "options": "<h4>Email Reply Example</h4>\n\n<pre>Order Overdue\n\nTransaction {{ name }} has exceeded Due Date. Please take necessary action.\n\nDetails\n\n- Customer: {{ customer }}\n- Amount: {{ grand_total }}\n</pre>\n\n<h4>How to get fieldnames</h4>\n\n<p>The fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup &gt; Customize Form View and selecting the document type (e.g. Sales Invoice)</p>\n\n<h4>Templating</h4>\n\n<p>Templates are compiled using the Jinja Templating Language. To learn more about Jinja, <a class=\"strong\" href=\"http://jinja.pocoo.org/docs/dev/templates/\">read this documentation.</a></p>\n"
},
{
"default": "0",
"fieldname": "use_html",
"fieldtype": "Check",
"label": "Use HTML"
},
{
"depends_on": "eval:doc.use_html",
"fieldname": "response_html",
"fieldtype": "Code",
"label": "Response ",
"options": "HTML"
} }
], ],
"icon": "fa fa-comment", "icon": "fa fa-comment",
"modified": "2019-10-30 14:15:00.956347",
"links": [],
"modified": "2020-11-30 14:12:50.321633",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Email Template", "name": "Email Template",


+ 24
- 3
frappe/email/doctype/email_template/email_template.py 查看文件

@@ -9,7 +9,29 @@ from six import string_types


class EmailTemplate(Document): class EmailTemplate(Document):
def validate(self): def validate(self):
validate_template(self.response)
if self.use_html:
validate_template(self.response_html)
else:
validate_template(self.response)

def get_formatted_subject(self, doc):
return frappe.render_template(self.subject, doc)

def get_formatted_response(self, doc):
if self.use_html:
return frappe.render_template(self.response_html, doc)

return frappe.render_template(self.response, doc)

def get_formatted_email(self, doc):
if isinstance(doc, string_types):
doc = json.loads(doc)

return {
"subject" : self.get_formatted_subject(doc),
"message" : self.get_formatted_response(doc)
}



@frappe.whitelist() @frappe.whitelist()
def get_email_template(template_name, doc): def get_email_template(template_name, doc):
@@ -18,5 +40,4 @@ def get_email_template(template_name, doc):
doc = json.loads(doc) doc = json.loads(doc)


email_template = frappe.get_doc("Email Template", template_name) email_template = frappe.get_doc("Email Template", template_name)
return {"subject" : frappe.render_template(email_template.subject, doc),
"message" : frappe.render_template(email_template.response, doc)}
return email_template.get_formatted_email(doc)

+ 3
- 3
frappe/email/doctype/newsletter/newsletter.py 查看文件

@@ -85,11 +85,11 @@ class Newsletter(WebsiteGenerator):
self.db_set("scheduled_to_send", len(self.recipients)) self.db_set("scheduled_to_send", len(self.recipients))


def get_message(self): def get_message(self):

if self.content_type == "HTML":
return frappe.render_template(self.message_html, {"doc": self.as_dict()})
return { return {
'Rich Text': self.message, 'Rich Text': self.message,
'Markdown': markdown(self.message_md),
'HTML': self.message_html
'Markdown': markdown(self.message_md)
}[self.content_type or 'Rich Text'] }[self.content_type or 'Rich Text']


def get_recipients(self): def get_recipients(self):


+ 5
- 10
frappe/email/doctype/notification/notification.js 查看文件

@@ -97,14 +97,7 @@ frappe.notification = {
}, },
setup_example_message: function(frm) { setup_example_message: function(frm) {
let template = ''; let template = '';
if (frm.doc.channel === 'WhatsApp') {
template = `<h5 style='display: inline-block'>Warning:</h5> Only Use Pre-Approved WhatsApp for Business Template
<h5>Message Example</h5>

<pre>
Your appointment is coming up on {{ doc.date }} at {{ doc.time }}
</pre>`;
} else if (frm.doc.channel === 'Email') {
if (frm.doc.channel === 'Email') {
template = `<h5>Message Example</h5> template = `<h5>Message Example</h5>


<pre>&lt;h3&gt;Order Overdue&lt;/h3&gt; <pre>&lt;h3&gt;Order Overdue&lt;/h3&gt;
@@ -124,7 +117,7 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
&lt;/ul&gt; &lt;/ul&gt;
</pre> </pre>
`; `;
} else {
} else if (in_list(['Slack', 'System Notification', 'SMS'], frm.doc.channel)) {
template = `<h5>Message Example</h5> template = `<h5>Message Example</h5>


<pre>*Order Overdue* <pre>*Order Overdue*
@@ -142,7 +135,9 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
• Amount: {{ doc.grand_total }} • Amount: {{ doc.grand_total }}
</pre>`; </pre>`;
} }
frm.set_df_property('message_examples', 'options', template);
if (template) {
frm.set_df_property('message_examples', 'options', template);
}


} }
}; };


+ 5
- 15
frappe/email/doctype/notification/notification.json 查看文件

@@ -10,7 +10,6 @@
"enabled", "enabled",
"column_break_2", "column_break_2",
"channel", "channel",
"twilio_number",
"slack_webhook_url", "slack_webhook_url",
"filters", "filters",
"subject", "subject",
@@ -61,7 +60,7 @@
"fieldname": "channel", "fieldname": "channel",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Channel", "label": "Channel",
"options": "Email\nSlack\nSystem Notification\nWhatsApp\nSMS",
"options": "Email\nSlack\nSystem Notification\nSMS",
"reqd": 1, "reqd": 1,
"set_only_once": 1 "set_only_once": 1
}, },
@@ -80,14 +79,14 @@
"label": "Filters" "label": "Filters"
}, },
{ {
"depends_on": "eval: !in_list(['SMS', 'WhatsApp'], doc.channel)",
"depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)",
"description": "To add dynamic subject, use jinja tags like\n\n<div><pre><code>{{ doc.name }} Delivered</code></pre></div>", "description": "To add dynamic subject, use jinja tags like\n\n<div><pre><code>{{ doc.name }} Delivered</code></pre></div>",
"fieldname": "subject", "fieldname": "subject",
"fieldtype": "Data", "fieldtype": "Data",
"ignore_xss_filter": 1, "ignore_xss_filter": 1,
"in_list_view": 1, "in_list_view": 1,
"label": "Subject", "label": "Subject",
"mandatory_depends_on": "eval:!in_list(['SMS', 'WhatsApp'], doc.channel)"
"mandatory_depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)"
}, },
{ {
"fieldname": "document_type", "fieldname": "document_type",
@@ -208,7 +207,7 @@
"label": "Value To Be Set" "label": "Value To Be Set"
}, },
{ {
"depends_on": "eval:in_list(['Email', 'SMS', 'WhatsApp'], doc.channel)",
"depends_on": "eval:doc.channel !=\"Slack\"",
"fieldname": "column_break_5", "fieldname": "column_break_5",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Recipients" "label": "Recipients"
@@ -263,15 +262,6 @@
"label": "Print Format", "label": "Print Format",
"options": "Print Format" "options": "Print Format"
}, },
{
"depends_on": "eval: doc.channel==='WhatsApp'",
"description": "To use WhatsApp for Business, initialize <a href=\"#Form/Twilio Settings\">Twilio Settings</a>.",
"fieldname": "twilio_number",
"fieldtype": "Link",
"label": "Twilio Number",
"mandatory_depends_on": "eval: doc.channel==='WhatsApp'",
"options": "Twilio Number Group"
},
{ {
"default": "0", "default": "0",
"depends_on": "eval: doc.channel !== 'System Notification'", "depends_on": "eval: doc.channel !== 'System Notification'",
@@ -291,7 +281,7 @@
"icon": "fa fa-envelope", "icon": "fa fa-envelope",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-09-03 10:33:23.084590",
"modified": "2020-11-24 14:25:43.245677",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Notification", "name": "Notification",


+ 3
- 19
frappe/email/doctype/notification/notification.py 查看文件

@@ -14,7 +14,6 @@ from frappe.utils.safe_exec import get_safe_globals
from frappe.modules.utils import export_module_json, get_doc_module from frappe.modules.utils import export_module_json, get_doc_module
from six import string_types from six import string_types
from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message
from frappe.integrations.doctype.twilio_settings.twilio_settings import send_whatsapp_message
from frappe.core.doctype.sms_settings.sms_settings import send_sms from frappe.core.doctype.sms_settings.sms_settings import send_sms
from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification


@@ -29,7 +28,7 @@ class Notification(Document):
self.name = self.subject self.name = self.subject


def validate(self): def validate(self):
if self.channel not in ('WhatsApp', 'SMS'):
if self.channel in ("Email", "Slack", "System Notification"):
validate_template(self.subject) validate_template(self.subject)


validate_template(self.message) validate_template(self.message)
@@ -43,7 +42,6 @@ class Notification(Document):
self.validate_forbidden_types() self.validate_forbidden_types()
self.validate_condition() self.validate_condition()
self.validate_standard() self.validate_standard()
self.validate_twilio_settings()
frappe.cache().hdel('notifications', self.document_type) frappe.cache().hdel('notifications', self.document_type)


def on_update(self): def on_update(self):
@@ -70,11 +68,6 @@ def get_context(context):
if self.is_standard and not frappe.conf.developer_mode: if self.is_standard and not frappe.conf.developer_mode:
frappe.throw(_('Cannot edit Standard Notification. To edit, please disable this and duplicate it')) frappe.throw(_('Cannot edit Standard Notification. To edit, please disable this and duplicate it'))


def validate_twilio_settings(self):
if self.enabled and self.channel == "WhatsApp" \
and not frappe.db.get_single_value("Twilio Settings", "enabled"):
frappe.throw(_("Please enable Twilio settings to send WhatsApp messages"))

def validate_condition(self): def validate_condition(self):
temp_doc = frappe.new_doc(self.document_type) temp_doc = frappe.new_doc(self.document_type)
if self.condition: if self.condition:
@@ -137,9 +130,6 @@ def get_context(context):
if self.channel == 'Slack': if self.channel == 'Slack':
self.send_a_slack_msg(doc, context) self.send_a_slack_msg(doc, context)


if self.channel == 'WhatsApp':
self.send_whatsapp_msg(doc, context)

if self.channel == 'SMS': if self.channel == 'SMS':
self.send_sms(doc, context) self.send_sms(doc, context)


@@ -191,6 +181,7 @@ def get_context(context):
'document_type': doc.doctype, 'document_type': doc.doctype,
'document_name': doc.name, 'document_name': doc.name,
'subject': subject, 'subject': subject,
'from_user': doc.modified_by or doc.owner,
'email_content': frappe.render_template(self.message, context), 'email_content': frappe.render_template(self.message, context),
'attached_file': attachments and json.dumps(attachments[0]) 'attached_file': attachments and json.dumps(attachments[0])
} }
@@ -230,13 +221,6 @@ def get_context(context):
reference_doctype=doc.doctype, reference_doctype=doc.doctype,
reference_name=doc.name) reference_name=doc.name)


def send_whatsapp_msg(self, doc, context):
send_whatsapp_message(
sender=self.twilio_number,
receiver_list=self.get_receiver_list(doc, context),
message=frappe.render_template(self.message, context),
)

def send_sms(self, doc, context): def send_sms(self, doc, context):
send_sms( send_sms(
receiver_list=self.get_receiver_list(doc, context), receiver_list=self.get_receiver_list(doc, context),
@@ -302,7 +286,7 @@ def get_context(context):


# For sending messages to the owner's mobile phone number # For sending messages to the owner's mobile phone number
if recipient.receiver_by_document_field == 'owner': if recipient.receiver_by_document_field == 'owner':
receiver_list.append(get_user_info(doc.get('owner'), 'mobile_no'))
receiver_list += get_user_info([dict(user_name=doc.get('owner'))], 'mobile_no')
# For sending messages to the number specified in the receiver field # For sending messages to the number specified in the receiver field
elif recipient.receiver_by_document_field: elif recipient.receiver_by_document_field:
receiver_list.append(doc.get(recipient.receiver_by_document_field)) receiver_list.append(doc.get(recipient.receiver_by_document_field))


+ 2
- 0
frappe/email/receive.py 查看文件

@@ -536,6 +536,8 @@ class Email:
except MaxFileSizeReachedError: except MaxFileSizeReachedError:
# WARNING: bypass max file size exception # WARNING: bypass max file size exception
pass pass
except frappe.FileAlreadyAttachedException:
pass
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
# same file attached twice?? # same file attached twice??
pass pass


+ 2
- 3
frappe/email/smtp.py 查看文件

@@ -210,10 +210,9 @@ class SMTPServer:
try: try:
if self.use_ssl: if self.use_ssl:
if not self.port: if not self.port:
self.smtp_port = 465
self.port = 465


self._sess = smtplib.SMTP_SSL((self.server or "").encode('utf-8'),
cint(self.port) or None)
self._sess = smtplib.SMTP_SSL((self.server or ""), cint(self.port))
else: else:
if self.use_tls and not self.port: if self.use_tls and not self.port:
self.port = 587 self.port = 587


+ 25
- 0
frappe/email/test_smtp.py 查看文件

@@ -0,0 +1,25 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# License: The MIT License

import unittest
from frappe.email.smtp import SMTPServer

class TestSMTP(unittest.TestCase):
def test_smtp_ssl_session(self):
for port in [None, 0, 465, "465"]:
make_server(port, 1, 0)

def test_smtp_tls_session(self):
for port in [None, 0, 587, "587"]:
make_server(port, 0, 1)


def make_server(port, ssl, tls):
server = SMTPServer(
server = "smtp.gmail.com",
port = port,
use_ssl = ssl,
use_tls = tls
)

server.sess

+ 56
- 3
frappe/event_streaming/doctype/event_consumer/event_consumer.py 查看文件

@@ -31,10 +31,12 @@ class EventConsumer(Document):
self.update_consumer_status() self.update_consumer_status()
else: else:
frappe.db.set_value(self.doctype, self.name, 'incoming_change', 0) frappe.db.set_value(self.doctype, self.name, 'incoming_change', 0)
frappe.cache().delete_value('event_consumer_document_type_map') frappe.cache().delete_value('event_consumer_document_type_map')


def on_trash(self): def on_trash(self):
for i in frappe.get_all('Event Update Log Consumer', {'consumer': self.name}):
frappe.delete_doc('Event Update Log Consumer', i.name)
frappe.cache().delete_value('event_consumer_document_type_map') frappe.cache().delete_value('event_consumer_document_type_map')


def update_consumer_status(self): def update_consumer_status(self):
@@ -88,8 +90,9 @@ def register_consumer(data):


for entry in consumer_doctypes: for entry in consumer_doctypes:
consumer.append('consumer_doctypes', { consumer.append('consumer_doctypes', {
'ref_doctype': entry,
'status': 'Pending'
'ref_doctype': entry.get('doctype'),
'status': 'Pending',
'condition': entry.get('condition')
}) })


consumer.insert() consumer.insert()
@@ -153,3 +156,53 @@ def notify(consumer):
jobs = get_jobs() jobs = get_jobs()
if not jobs or enqueued_method not in jobs[frappe.local.site] and not consumer.flags.notifed: if not jobs or enqueued_method not in jobs[frappe.local.site] and not consumer.flags.notifed:
frappe.enqueue(enqueued_method, queue='long', enqueue_after_commit=True, **{'consumer': consumer}) frappe.enqueue(enqueued_method, queue='long', enqueue_after_commit=True, **{'consumer': consumer})


def has_consumer_access(consumer, update_log):
"""Checks if consumer has completely satisfied all the conditions on the doc"""

if isinstance(consumer, str):
consumer = frappe.get_doc('Event Consumer', consumer)

if not frappe.db.exists(update_log.ref_doctype, update_log.docname):
# Delete Log
# Check if the last Update Log of this document was read by this consumer
last_update_log = frappe.get_all(
'Event Update Log',
filters={
'ref_doctype': update_log.ref_doctype,
'docname': update_log.docname,
'creation': ['<', update_log.creation]
},
order_by='creation desc',
limit_page_length=1
)
if not len(last_update_log):
return False

last_update_log = frappe.get_doc('Event Update Log', last_update_log[0].name)
return len([x for x in last_update_log.consumers if x.consumer == consumer.name])

doc = frappe.get_doc(update_log.ref_doctype, update_log.docname)
try:
for dt_entry in consumer.consumer_doctypes:
if dt_entry.ref_doctype != update_log.ref_doctype:
continue

if not dt_entry.condition:
return True

condition: str = dt_entry.condition
if condition.startswith('cmd:'):
cmd = condition.split('cmd:')[1].strip()
args = {
'consumer': consumer,
'doc': doc,
'update_log': update_log
}
return frappe.call(cmd, **args)
else:
return frappe.safe_eval(condition, frappe._dict(doc=doc))
except Exception as e:
frappe.log_error(title='has_consumer_access error', message=e)
return False

+ 9
- 2
frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json 查看文件

@@ -7,7 +7,8 @@
"field_order": [ "field_order": [
"ref_doctype", "ref_doctype",
"status", "status",
"unsubscribed"
"unsubscribed",
"condition"
], ],
"fields": [ "fields": [
{ {
@@ -37,11 +38,17 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Unsubscribed", "label": "Unsubscribed",
"read_only": 1 "read_only": 1
},
{
"fieldname": "condition",
"fieldtype": "Code",
"label": "Condition",
"read_only": 1
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-14 12:38:40.918620",
"modified": "2020-11-07 09:26:49.894294",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Event Streaming", "module": "Event Streaming",
"name": "Event Consumer Document Type", "name": "Event Consumer Document Type",


+ 1
- 8
frappe/event_streaming/doctype/event_producer/event_producer.json 查看文件

@@ -13,7 +13,6 @@
"api_secret", "api_secret",
"column_break_6", "column_break_6",
"user", "user",
"last_update",
"incoming_change" "incoming_change"
], ],
"fields": [ "fields": [
@@ -25,12 +24,6 @@
"reqd": 1, "reqd": 1,
"unique": 1 "unique": 1
}, },
{
"fieldname": "last_update",
"fieldtype": "Data",
"label": "Last Update",
"read_only": 1
},
{ {
"description": "API Key of the user(Event Subscriber) on the producer site", "description": "API Key of the user(Event Subscriber) on the producer site",
"fieldname": "api_key", "fieldname": "api_key",
@@ -77,7 +70,7 @@
} }
], ],
"links": [], "links": [],
"modified": "2020-09-08 18:50:57.687979",
"modified": "2020-10-26 13:00:15.361316",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Event Streaming", "module": "Event Streaming",
"name": "Event Producer", "name": "Event Producer",


+ 32
- 13
frappe/event_streaming/doctype/event_producer/event_producer.py 查看文件

@@ -79,18 +79,36 @@ class EventProducer(Document):
) )
if response: if response:
response = json.loads(response) response = json.loads(response)
self.last_update = response['last_update']
self.set_last_update(response['last_update'])
else: else:
frappe.throw(_('Failed to create an Event Consumer or an Event Consumer for the current site is already registered.')) frappe.throw(_('Failed to create an Event Consumer or an Event Consumer for the current site is already registered.'))


def set_last_update(self, last_update):
last_update_doc_name = frappe.db.get_value('Event Producer Last Update', dict(event_producer=self.name))
if not last_update_doc_name:
frappe.get_doc(dict(
doctype = 'Event Producer Last Update',
event_producer = self.producer_url,
last_update = last_update
)).insert(ignore_permissions=True)
else:
frappe.db.set_value('Event Producer Last Update', last_update_doc_name, 'last_update', last_update)

def get_last_update(self):
return frappe.db.get_value('Event Producer Last Update', dict(event_producer=self.name), 'last_update')

def get_request_data(self): def get_request_data(self):
consumer_doctypes = [] consumer_doctypes = []
for entry in self.producer_doctypes: for entry in self.producer_doctypes:
if entry.has_mapping: if entry.has_mapping:
# if mapping, subscribe to remote doctype on consumer's site # if mapping, subscribe to remote doctype on consumer's site
consumer_doctypes.append(frappe.db.get_value('Document Type Mapping', entry.mapping, 'remote_doctype'))
dt = frappe.db.get_value('Document Type Mapping', entry.mapping, 'remote_doctype')
else: else:
consumer_doctypes.append(entry.ref_doctype)
dt = entry.ref_doctype
consumer_doctypes.append({
"doctype": dt,
"condition": entry.condition
})


user_key = frappe.db.get_value('User', self.user, 'api_key') user_key = frappe.db.get_value('User', self.user, 'api_key')
user_secret = get_decrypted_password('User', self.user, 'api_secret') user_secret = get_decrypted_password('User', self.user, 'api_secret')
@@ -131,7 +149,8 @@ class EventProducer(Document):
event_consumer.consumer_doctypes.append({ event_consumer.consumer_doctypes.append({
'ref_doctype': ref_doctype, 'ref_doctype': ref_doctype,
'status': get_approval_status(config, ref_doctype), 'status': get_approval_status(config, ref_doctype),
'unsubscribed': entry.unsubscribe
'unsubscribed': entry.unsubscribe,
'condition': entry.condition
}) })
event_consumer.user = self.user event_consumer.user = self.user
event_consumer.incoming_change = True event_consumer.incoming_change = True
@@ -184,7 +203,7 @@ def pull_from_node(event_producer):
"""pull all updates after the last update timestamp from event producer site""" """pull all updates after the last update timestamp from event producer site"""
event_producer = frappe.get_doc('Event Producer', event_producer) event_producer = frappe.get_doc('Event Producer', event_producer)
producer_site = get_producer_site(event_producer.producer_url) producer_site = get_producer_site(event_producer.producer_url)
last_update = event_producer.last_update
last_update = event_producer.get_last_update()


(doctypes, mapping_config, naming_config) = get_config(event_producer.producer_doctypes) (doctypes, mapping_config, naming_config) = get_config(event_producer.producer_doctypes)


@@ -239,7 +258,7 @@ def sync(update, producer_site, event_producer, in_retry=False):
return 'Failed' return 'Failed'
log_event_sync(update, event_producer.name, 'Failed', frappe.get_traceback()) log_event_sync(update, event_producer.name, 'Failed', frappe.get_traceback())


event_producer.db_set('last_update', update.creation)
event_producer.set_last_update(update.creation)
frappe.db.commit() frappe.db.commit()




@@ -333,13 +352,13 @@ def set_delete(update):


def get_updates(producer_site, last_update, doctypes): def get_updates(producer_site, last_update, doctypes):
"""Get all updates generated after the last update timestamp""" """Get all updates generated after the last update timestamp"""
docs = producer_site.get_list(
doctype='Event Update Log',
filters={'ref_doctype': ('in', doctypes), 'creation': ('>', last_update)},
fields=['update_type', 'ref_doctype', 'docname', 'data', 'name', 'creation']
)
docs.reverse()
return [frappe._dict(d) for d in docs]
docs = producer_site.post_request({
'cmd': 'frappe.event_streaming.doctype.event_update_log.event_update_log.get_update_logs_for_consumer',
'event_consumer': get_url(),
'doctypes': frappe.as_json(doctypes),
'last_update': last_update
})
return [frappe._dict(d) for d in (docs or [])]




def get_local_doc(update): def get_local_doc(update):


+ 79
- 0
frappe/event_streaming/doctype/event_producer/test_event_producer.py 查看文件

@@ -152,6 +152,82 @@ class TestEventProducer(unittest.TestCase):


reset_configuration(producer_url) reset_configuration(producer_url)


def test_conditional_events(self):
producer = get_remote_site()
# Add Condition
event_producer = frappe.get_doc('Event Producer', producer_url)
note_producer_entry = [
x for x in event_producer.producer_doctypes if x.ref_doctype == 'Note'
][0]
note_producer_entry.condition = 'doc.public == 1'
event_producer.save()

# Make test doc
producer_note1 = frappe._dict(doctype='Note', public=0, title='test conditional sync')
delete_on_remote_if_exists(producer, 'Note', {'title': producer_note1['title']})
producer_note1 = producer.insert(producer_note1)

# Make Update
producer_note1['content'] = 'Test Conditional Sync Content'
producer_note1 = producer.update(producer_note1)

self.pull_producer_data()

# Check if synced here
self.assertFalse(frappe.db.exists('Note', producer_note1.name))

# Lets satisfy the condition
producer_note1['public'] = 1
producer_note1 = producer.update(producer_note1)

self.pull_producer_data()

# it should sync now
self.assertTrue(frappe.db.exists('Note', producer_note1.name))
local_note = frappe.get_doc('Note', producer_note1.name)
self.assertEqual(local_note.content, producer_note1.content)

reset_configuration(producer_url)

def test_conditional_events_with_cmd(self):
producer = get_remote_site()
# Add Condition
event_producer = frappe.get_doc('Event Producer', producer_url)
note_producer_entry = [
x for x in event_producer.producer_doctypes if x.ref_doctype == 'Note'
][0]
note_producer_entry.condition = 'cmd: frappe.event_streaming.doctype.event_producer.test_event_producer.can_sync_note'
event_producer.save()

# Make test doc
producer_note1 = frappe._dict(doctype='Note', public=0, title='test conditional sync cmd')
delete_on_remote_if_exists(producer, 'Note', {'title': producer_note1['title']})
producer_note1 = producer.insert(producer_note1)

# Make Update
producer_note1['content'] = 'Test Conditional Sync Content'
producer_note1 = producer.update(producer_note1)

self.pull_producer_data()

# Check if synced here
self.assertFalse(frappe.db.exists('Note', producer_note1.name))

# Lets satisfy the condition
producer_note1['public'] = 1
producer_note1 = producer.update(producer_note1)

self.pull_producer_data()

# it should sync now
self.assertTrue(frappe.db.exists('Note', producer_note1.name))
local_note = frappe.get_doc('Note', producer_note1.name)
self.assertEqual(local_note.content, producer_note1.content)

reset_configuration(producer_url)

def test_update_log(self): def test_update_log(self):
producer = get_remote_site() producer = get_remote_site()
producer_doc = insert_into_producer(producer, 'test update log') producer_doc = insert_into_producer(producer, 'test update log')
@@ -221,6 +297,8 @@ class TestEventProducer(unittest.TestCase):


reset_configuration(producer_url) reset_configuration(producer_url)


def can_sync_note(consumer, doc, update_log):
return doc.public == 1


def setup_event_producer_for_inner_mapping(): def setup_event_producer_for_inner_mapping():
event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True) event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True)
@@ -322,6 +400,7 @@ def create_event_producer(producer_url):
def reset_configuration(producer_url): def reset_configuration(producer_url):
event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True) event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True)
event_producer.producer_doctypes = [] event_producer.producer_doctypes = []
event_producer.conditions = []
event_producer.producer_url = producer_url event_producer.producer_url = producer_url
event_producer.append('producer_doctypes', { event_producer.append('producer_doctypes', {
'ref_doctype': 'ToDo', 'ref_doctype': 'ToDo',


+ 8
- 2
frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json 查看文件

@@ -10,7 +10,8 @@
"use_same_name", "use_same_name",
"unsubscribe", "unsubscribe",
"has_mapping", "has_mapping",
"mapping"
"mapping",
"condition"
], ],
"fields": [ "fields": [
{ {
@@ -63,11 +64,16 @@
"fieldtype": "Check", "fieldtype": "Check",
"in_list_view": 1, "in_list_view": 1,
"label": "Unsubscribe" "label": "Unsubscribe"
},
{
"fieldname": "condition",
"fieldtype": "Code",
"label": "Condition"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-08-14 11:38:01.278996",
"modified": "2020-11-07 09:26:58.463868",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Event Streaming", "module": "Event Streaming",
"name": "Event Producer Document Type", "name": "Event Producer Document Type",


frappe/custom/doctype/custom_link/__init__.py → frappe/event_streaming/doctype/event_producer_last_update/__init__.py 查看文件


+ 8
- 0
frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.js 查看文件

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

frappe.ui.form.on('Event Producer Last Update', {
// refresh: function(frm) {

// }
});

frappe/custom/doctype/custom_link/custom_link.json → frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.json 查看文件

@@ -1,36 +1,36 @@
{ {
"actions": [], "actions": [],
"autoname": "field:document_type",
"creation": "2020-04-08 15:16:44.342509",
"autoname": "field:event_producer",
"creation": "2020-10-26 12:53:11.940177",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"document_type",
"links"
"event_producer",
"last_update"
], ],
"fields": [ "fields": [
{ {
"fieldname": "document_type",
"fieldtype": "Link",
"fieldname": "event_producer",
"fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"label": "Event Producer",
"reqd": 1, "reqd": 1,
"unique": 1 "unique": 1
}, },
{ {
"fieldname": "links",
"fieldtype": "Table",
"label": "Links",
"options": "DocType Link"
"fieldname": "last_update",
"fieldtype": "Data",
"label": "Last Update"
} }
], ],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2020-04-08 16:42:59.402671",
"modified": "2020-10-26 13:22:27.056599",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom",
"name": "Custom Link",
"module": "Event Streaming",
"name": "Event Producer Last Update",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -46,6 +46,7 @@
"write": 1 "write": 1
} }
], ],
"read_only": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1

部分文件因文件數量過多而無法顯示

Loading…
取消
儲存