diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index 08d1d1aa9c..f8ee3fa10b 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -32,9 +32,9 @@ if __name__ == "__main__":
if response.ok:
payload = response.json()
- title = payload.get("title", "").lower()
- head_sha = payload.get("head", {}).get("sha")
- body = payload.get("body", "").lower()
+ title = (payload.get("title") or "").lower()
+ head_sha = (payload.get("head") or {}).get("sha")
+ body = (payload.get("body") or "").lower()
if title.startswith("feat") and head_sha and "no-docs" not in body:
if docs_link_exists(body):
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
index e8627a01fb..82be4d06b5 100644
--- a/.github/workflows/patch-mariadb-tests.yml
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -1,6 +1,11 @@
name: Patch
-on: [pull_request, workflow_dispatch]
+on:
+ pull_request:
+ paths-ignore:
+ - '**.js'
+ - '**.md'
+ workflow_dispatch:
jobs:
test:
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
index 2476102e3d..8d5bd690a1 100644
--- a/.github/workflows/server-mariadb-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -2,9 +2,15 @@ name: Server
on:
pull_request:
+ paths-ignore:
+ - '**.js'
+ - '**.md'
workflow_dispatch:
push:
branches: [ develop ]
+ paths-ignore:
+ - '**.js'
+ - '**.md'
jobs:
test:
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
index 4325eebaad..8c97c7f84b 100644
--- a/.github/workflows/server-postgres-tests.yml
+++ b/.github/workflows/server-postgres-tests.yml
@@ -2,6 +2,9 @@ name: Server
on:
pull_request:
+ paths-ignore:
+ - '**.js'
+ - '**.md'
workflow_dispatch:
jobs:
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index f342c0709e..d76e5e77ea 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -2,6 +2,8 @@ name: UI
on:
pull_request:
+ paths-ignore:
+ - '**.md'
workflow_dispatch:
push:
branches: [ develop ]
diff --git a/.mergify.yml b/.mergify.yml
index c759c1e3ec..1a81a28594 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -1,4 +1,16 @@
pull_request_rules:
+ - name: Auto-close PRs on stable branch
+ conditions:
+ - or:
+ - base=version-13
+ - base=version-12
+ actions:
+ close:
+ comment:
+ message: |
+ @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
+ https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
+
- name: Automatic merge on CI success and review
conditions:
- status-success=Sider
diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js
new file mode 100644
index 0000000000..f18e48aadc
--- /dev/null
+++ b/cypress/integration/workspace.js
@@ -0,0 +1,90 @@
+context('Workspace 2.0', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ it('Navigate to page from sidebar', () => {
+ cy.visit('/app/build');
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.sidebar-item-container[item-name="Settings"]').first().click();
+ cy.location('pathname').should('eq', '/app/settings');
+ });
+
+ it('Create Private Page', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
+ cy.fill_field('title', 'Test Private Page', 'Data');
+ cy.fill_field('icon', 'edit', 'Icon');
+ cy.get_open_dialog().find('.modal-header').click();
+ cy.get_open_dialog().find('.btn-primary').click();
+
+ // check if sidebar item is added in pubic section
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');
+
+ cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click();
+ cy.wait(300);
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');
+
+ cy.wait(500);
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
+ });
+
+ it('Add New Block', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click();
+ cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click();
+ cy.get(".ce-block:last").find('h2').click({force: true}).type('Header');
+ cy.get(".ce-block:last").find('.ce-header').should('exist');
+
+ cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click();
+ cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click();
+ cy.get(".ce-block:last").find('.ce-paragraph').click({force: true}).type('Paragraph text');
+ cy.get(".ce-block:last").find('.ce-paragraph').should('exist');
+ });
+
+ it('Delete A Block', () => {
+ cy.get(".ce-block:last").find('.delete-paragraph').click();
+ cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist');
+ });
+
+ it('Shrink and Expand A Block', () => {
+ cy.get(".ce-block:last").find('.tune-btn').click();
+ cy.get('.ce-settings--opened .ce-shrink-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-11');
+ cy.get('.ce-settings--opened .ce-shrink-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-10');
+ cy.get('.ce-settings--opened .ce-shrink-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-9');
+ cy.get('.ce-settings--opened .ce-expand-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-10');
+ cy.get('.ce-settings--opened .ce-expand-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-11');
+ cy.get('.ce-settings--opened .ce-expand-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-12');
+ });
+
+ it('Change Header Text Size', () => {
+ cy.get('.ce-settings--opened .cdx-settings-button[data-level="3"]').click();
+ cy.get(".ce-block:last").find('.widget-head h3').should('exist');
+ cy.get('.ce-settings--opened .cdx-settings-button[data-level="4"]').click();
+ cy.get(".ce-block:last").find('.widget-head h4').should('exist');
+
+ cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click();
+ });
+
+ it('Delete Private Page', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
+
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click();
+ cy.wait(300);
+ cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click();
+ cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click();
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist');
+ });
+
+});
\ No newline at end of file
diff --git a/frappe/__init__.py b/frappe/__init__.py
index b4728f9ac3..6d79cbd760 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -28,7 +28,7 @@ from .exceptions import *
from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader)
from .utils.lazy_loader import lazy_import
-from frappe.query_builder import get_query_builder
+from frappe.query_builder import get_query_builder, patch_query_execute
# Lazy imports
faker = lazy_import('faker')
@@ -208,6 +208,7 @@ def init(site, sites_path=None, new_site=False):
local.qb = get_query_builder(local.conf.db_type or "mariadb")
setup_module_map()
+ patch_query_execute()
local.initialised = True
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js
index 896a10dfe0..80f2255f47 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.js
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js
@@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', {
refresh: function(frm) {
// auto repeat message
if (frm.is_new()) {
- let customize_form_link = `${__('Customize Form')}`;
+ let customize_form_link = `${__('Customize Form')}`;
frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link]));
}
diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json
index 4a0835657b..f556be1c07 100644
--- a/frappe/automation/workspace/tools/tools.json
+++ b/frappe/automation/workspace/tools/tools.json
@@ -1,22 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"ToDo\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Note\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"File\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Assignment Rule\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Auto Repeat\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Tools\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Automation\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Event Streaming\", \"col\": 4}}]",
"creation": "2020-03-02 14:53:24.980279",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
- "is_standard": 1,
+ "is_default": 0,
+ "is_standard": 0,
"label": "Tools",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Tools",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "To Do",
+ "link_count": 0,
"link_to": "ToDo",
"link_type": "DocType",
"onboard": 1,
@@ -35,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Calendar",
+ "link_count": 0,
"link_to": "Event",
"link_type": "DocType",
"onboard": 1,
@@ -45,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Note",
+ "link_count": 0,
"link_to": "Note",
"link_type": "DocType",
"onboard": 1,
@@ -55,6 +63,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Files",
+ "link_count": 0,
"link_to": "File",
"link_type": "DocType",
"onboard": 0,
@@ -65,6 +74,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Activity",
+ "link_count": 0,
"link_to": "activity",
"link_type": "Page",
"onboard": 0,
@@ -74,6 +84,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -82,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
+ "link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 1,
@@ -92,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
+ "link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
@@ -101,6 +114,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Automation",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -109,6 +123,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Assignment Rule",
+ "link_count": 0,
"link_to": "Assignment Rule",
"link_type": "DocType",
"onboard": 0,
@@ -119,6 +134,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Milestone",
+ "link_count": 0,
"link_to": "Milestone",
"link_type": "DocType",
"onboard": 0,
@@ -129,6 +145,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Auto Repeat",
+ "link_count": 0,
"link_to": "Auto Repeat",
"link_type": "DocType",
"onboard": 0,
@@ -138,6 +155,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Streaming",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -146,6 +164,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Producer",
+ "link_count": 0,
"link_to": "Event Producer",
"link_type": "DocType",
"onboard": 0,
@@ -156,6 +175,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Consumer",
+ "link_count": 0,
"link_to": "Event Consumer",
"link_type": "DocType",
"onboard": 0,
@@ -166,6 +186,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Update Log",
+ "link_count": 0,
"link_to": "Event Update Log",
"link_type": "DocType",
"onboard": 0,
@@ -176,6 +197,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Sync Log",
+ "link_count": 0,
"link_to": "Event Sync Log",
"link_type": "DocType",
"onboard": 0,
@@ -186,19 +208,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Document Type Mapping",
+ "link_count": 0,
"link_to": "Document Type Mapping",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:39.950350",
+ "modified": "2021-08-05 12:16:02.839180",
"modified_by": "Administrator",
"module": "Automation",
"name": "Tools",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 26,
"shortcuts": [
{
"label": "ToDo",
@@ -225,5 +254,6 @@
"link_to": "Auto Repeat",
"type": "DocType"
}
- ]
+ ],
+ "title": "Tools"
}
\ No newline at end of file
diff --git a/frappe/boot.py b/frappe/boot.py
index 0589e32ac8..c46709d3d7 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -105,8 +105,8 @@ def load_conf_settings(bootinfo):
if key in conf: bootinfo[key] = conf.get(key)
def load_desktop_data(bootinfo):
- from frappe.desk.desktop import get_desk_sidebar_items
- bootinfo.allowed_workspaces = get_desk_sidebar_items()
+ from frappe.desk.desktop import get_wspace_sidebar_items
+ bootinfo.allowed_workspaces = get_wspace_sidebar_items().get('pages')
bootinfo.module_page_map = get_controller("Workspace").get_module_page_map()
bootinfo.dashboards = frappe.get_all("Dashboard")
diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py
index c17ae583ed..2ee3b46b7c 100644
--- a/frappe/cache_manager.py
+++ b/frappe/cache_manager.py
@@ -145,10 +145,9 @@ def build_table_count_cache():
table_rows = frappe.qb.Field("table_rows").as_("count")
information_schema = frappe.qb.Schema("information_schema")
- query = frappe.qb.from_(information_schema.tables).select(table_name, table_rows)
-
- data = frappe.db.sql(query, as_dict=1)
-
+ data = (
+ frappe.qb.from_(information_schema.tables).select(table_name, table_rows)
+ ).run(as_dict=True)
counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data}
_cache.set_value("information_schema:counts", counts)
diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py
index e7f0f1a763..aa441b7d71 100644
--- a/frappe/config/__init__.py
+++ b/frappe/config/__init__.py
@@ -43,9 +43,13 @@ def get_all_empty_tables_by_module():
table_name = frappe.qb.Field("table_name")
information_schema = frappe.qb.Schema("information_schema")
- query = frappe.qb.from_(information_schema.tables).select(table_name).where(table_rows == 0)
+ empty_tables = (
+ frappe.qb.from_(information_schema.tables)
+ .select(table_name)
+ .where(table_rows == 0)
+ ).run()
- empty_tables = {r[0] for r in frappe.db.sql(query)}
+ empty_tables = {r[0] for r in empty_tables}
results = frappe.get_all("DocType", fields=["name", "module"])
empty_tables_by_module = {}
diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py
index 0ba0e309dd..5aa18cd9e3 100644
--- a/frappe/core/doctype/page/page.py
+++ b/frappe/core/doctype/page/page.py
@@ -109,6 +109,7 @@ class Page(Document):
if os.path.exists(fpath):
with open(fpath, 'r') as f:
self.script = render_include(f.read())
+ self.script += f"\n\n//# sourceURL={page_name}.js"
# css
fpath = os.path.join(path, page_name + '.css')
diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json
index aefda698b1..464052ba39 100644
--- a/frappe/core/workspace/build/build.json
+++ b/frappe/core/workspace/build/build.json
@@ -1,24 +1,28 @@
{
"cards_label": "Elements",
- "category": "Modules",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"DocType\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Workspace\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Report\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Elements\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Modules\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Models\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Views\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Scripting\", \"col\": 4}}]",
"creation": "2021-01-02 10:51:16.579957",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
"is_default": 0,
- "is_standard": 1,
+ "is_standard": 0,
"label": "Build",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Modules",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -28,6 +32,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Module Def",
+ "link_count": 0,
"link_to": "Module Def",
"link_type": "DocType",
"onboard": 0,
@@ -38,6 +43,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workspace",
+ "link_count": 0,
"link_to": "Workspace",
"link_type": "DocType",
"onboard": 0,
@@ -48,6 +54,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Module Onboarding",
+ "link_count": 0,
"link_to": "Module Onboarding",
"link_type": "DocType",
"onboard": 0,
@@ -58,6 +65,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Block Module",
+ "link_count": 0,
"link_to": "Block Module",
"link_type": "DocType",
"onboard": 0,
@@ -68,6 +76,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Models",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -77,6 +86,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "DocType",
+ "link_count": 0,
"link_to": "DocType",
"link_type": "DocType",
"onboard": 0,
@@ -87,6 +97,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"link_to": "Workflow",
"link_type": "DocType",
"onboard": 0,
@@ -97,6 +108,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Views",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -106,6 +118,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Report",
+ "link_count": 0,
"link_to": "Report",
"link_type": "DocType",
"onboard": 0,
@@ -116,6 +129,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format",
+ "link_count": 0,
"link_to": "Print Format",
"link_type": "DocType",
"onboard": 0,
@@ -126,6 +140,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workspace",
+ "link_count": 0,
"link_to": "Workspace",
"link_type": "DocType",
"onboard": 0,
@@ -136,6 +151,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard",
+ "link_count": 0,
"link_to": "Dashboard",
"link_type": "DocType",
"onboard": 0,
@@ -146,6 +162,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Scripting",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -155,6 +172,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Server Script",
+ "link_count": 0,
"link_to": "Server Script",
"link_type": "DocType",
"onboard": 0,
@@ -165,6 +183,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Client Script",
+ "link_count": 0,
"link_to": "Client Script",
"link_type": "DocType",
"onboard": 0,
@@ -175,6 +194,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Scheduled Job Type",
+ "link_count": 0,
"link_to": "Scheduled Job Type",
"link_type": "DocType",
"onboard": 0,
@@ -182,13 +202,19 @@
"type": "Link"
}
],
- "modified": "2021-02-04 13:48:48.493146",
+ "modified": "2021-08-05 12:15:55.793022",
"modified_by": "Administrator",
"module": "Core",
"name": "Build",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 5,
"shortcuts": [
{
"doc_view": "",
@@ -208,5 +234,6 @@
"link_to": "Report",
"type": "DocType"
}
- ]
+ ],
+ "title": "Build"
}
\ No newline at end of file
diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json
index fb26b73cfc..93a6c81c90 100644
--- a/frappe/core/workspace/settings/settings.json
+++ b/frappe/core/workspace/settings/settings.json
@@ -1,22 +1,27 @@
{
- "category": "Modules",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Settings\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"System Settings\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Print Settings\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Website Settings\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Data\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email / Notifications\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Website\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Core\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Printing\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Workflow\", \"col\": 4}}]",
"creation": "2020-03-02 15:09:40.527211",
"developer_mode_only": 0,
- "disable_user_customization": 1,
+ "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "setting",
"idx": 0,
- "is_standard": 1,
+ "is_default": 0,
+ "is_standard": 0,
"label": "Settings",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Data",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Import Data",
+ "link_count": 0,
"link_to": "Data Import",
"link_type": "DocType",
"onboard": 0,
@@ -35,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Export Data",
+ "link_count": 0,
"link_to": "Data Export",
"link_type": "DocType",
"onboard": 0,
@@ -45,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Bulk Update",
+ "link_count": 0,
"link_to": "Bulk Update",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +63,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Download Backups",
+ "link_count": 0,
"link_to": "backups",
"link_type": "Page",
"onboard": 0,
@@ -65,6 +74,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Deleted Documents",
+ "link_count": 0,
"link_to": "Deleted Document",
"link_type": "DocType",
"onboard": 0,
@@ -74,6 +84,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email / Notifications",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -82,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
+ "link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
@@ -92,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
+ "link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
@@ -102,6 +115,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Notification",
+ "link_count": 0,
"link_to": "Notification",
"link_type": "DocType",
"onboard": 0,
@@ -112,6 +126,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
+ "link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
@@ -122,6 +137,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Auto Email Report",
+ "link_count": 0,
"link_to": "Auto Email Report",
"link_type": "DocType",
"onboard": 0,
@@ -132,6 +148,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
+ "link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
@@ -142,6 +159,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Notification Settings",
+ "link_count": 0,
"link_to": "Notification Settings",
"link_type": "DocType",
"onboard": 0,
@@ -151,6 +169,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -159,6 +178,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Settings",
+ "link_count": 0,
"link_to": "Website Settings",
"link_type": "DocType",
"onboard": 1,
@@ -169,6 +189,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Theme",
+ "link_count": 0,
"link_to": "Website Theme",
"link_type": "DocType",
"onboard": 1,
@@ -179,6 +200,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Script",
+ "link_count": 0,
"link_to": "Website Script",
"link_type": "DocType",
"onboard": 0,
@@ -189,6 +211,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "About Us Settings",
+ "link_count": 0,
"link_to": "About Us Settings",
"link_type": "DocType",
"onboard": 0,
@@ -199,6 +222,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Contact Us Settings",
+ "link_count": 0,
"link_to": "Contact Us Settings",
"link_type": "DocType",
"onboard": 0,
@@ -208,6 +232,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Core",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -216,6 +241,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "System Settings",
+ "link_count": 0,
"link_to": "System Settings",
"link_type": "DocType",
"onboard": 0,
@@ -226,6 +252,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Error Log",
+ "link_count": 0,
"link_to": "Error Log",
"link_type": "DocType",
"onboard": 0,
@@ -236,6 +263,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Error Snapshot",
+ "link_count": 0,
"link_to": "Error Snapshot",
"link_type": "DocType",
"onboard": 0,
@@ -246,6 +274,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Domain Settings",
+ "link_count": 0,
"link_to": "Domain Settings",
"link_type": "DocType",
"onboard": 0,
@@ -255,6 +284,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Printing",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -263,6 +293,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format Builder",
+ "link_count": 0,
"link_to": "print-format-builder",
"link_type": "Page",
"onboard": 0,
@@ -273,6 +304,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Settings",
+ "link_count": 0,
"link_to": "Print Settings",
"link_type": "DocType",
"onboard": 0,
@@ -283,6 +315,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format",
+ "link_count": 0,
"link_to": "Print Format",
"link_type": "DocType",
"onboard": 0,
@@ -293,6 +326,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Style",
+ "link_count": 0,
"link_to": "Print Style",
"link_type": "DocType",
"onboard": 0,
@@ -302,6 +336,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -310,6 +345,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"link_to": "Workflow",
"link_type": "DocType",
"onboard": 0,
@@ -320,6 +356,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow State",
+ "link_count": 0,
"link_to": "Workflow State",
"link_type": "DocType",
"onboard": 0,
@@ -330,19 +367,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow Action",
+ "link_count": 0,
"link_to": "Workflow Action",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:40.235323",
+ "modified": "2021-08-05 12:16:03.456173",
"modified_by": "Administrator",
"module": "Core",
"name": "Settings",
+ "onboarding": "",
"owner": "Administrator",
- "pin_to_bottom": 1,
+ "parent_page": "",
+ "pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 29,
"shortcuts": [
{
"icon": "setting",
@@ -363,5 +407,6 @@
"type": "DocType"
}
],
- "shortcuts_label": "Settings"
+ "shortcuts_label": "Settings",
+ "title": "Settings"
}
\ No newline at end of file
diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json
index ba82461b57..09a835ea2c 100644
--- a/frappe/core/workspace/users/users.json
+++ b/frappe/core/workspace/users/users.json
@@ -1,23 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Permission Manager\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Profile\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Type\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Users\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Logs\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Permissions\", \"col\": 4}}]",
"creation": "2020-03-02 15:12:16.754449",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "users",
"idx": 0,
"is_default": 0,
- "is_standard": 1,
+ "is_standard": 0,
"label": "Users",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Users",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -26,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "User",
+ "link_count": 0,
"link_to": "User",
"link_type": "DocType",
"onboard": 0,
@@ -36,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role",
+ "link_count": 0,
"link_to": "Role",
"link_type": "DocType",
"onboard": 0,
@@ -46,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Profile",
+ "link_count": 0,
"link_to": "Role Profile",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +62,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Logs",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -63,6 +71,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Activity Log",
+ "link_count": 0,
"link_to": "Activity Log",
"link_type": "DocType",
"onboard": 0,
@@ -73,6 +82,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Access Log",
+ "link_count": 0,
"link_to": "Access Log",
"link_type": "DocType",
"onboard": 0,
@@ -82,6 +92,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Permissions",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -90,6 +101,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Permissions Manager",
+ "link_count": 0,
"link_to": "permission-manager",
"link_type": "Page",
"onboard": 0,
@@ -100,6 +112,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "User Permissions",
+ "link_count": 0,
"link_to": "User Permission",
"link_type": "DocType",
"onboard": 0,
@@ -110,6 +123,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Permission for Page and Report",
+ "link_count": 0,
"link_to": "Role Permission for Page and Report",
"link_type": "DocType",
"onboard": 0,
@@ -120,6 +134,7 @@
"hidden": 0,
"is_query_report": 1,
"label": "Permitted Documents For User",
+ "link_count": 0,
"link_to": "Permitted Documents For User",
"link_type": "Report",
"onboard": 0,
@@ -130,19 +145,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Document Share Report",
+ "link_count": 0,
"link_to": "Document Share Report",
"link_type": "Report",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2021-03-25 23:02:34.582569",
+ "modified": "2021-08-05 12:16:03.010204",
"modified_by": "Administrator",
"module": "Core",
"name": "Users",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 27,
"shortcuts": [
{
"label": "User",
@@ -170,5 +192,6 @@
"link_to": "User Type",
"type": "DocType"
}
- ]
+ ],
+ "title": "Users"
}
\ No newline at end of file
diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json
index cdc3b73366..136b1a57eb 100644
--- a/frappe/custom/workspace/customization/customization.json
+++ b/frappe/custom/workspace/customization/customization.json
@@ -1,23 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Customize Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Custom Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Client Script\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Server Script\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Dashboards\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Form Customization\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other\", \"col\": 4}}]",
"creation": "2020-03-02 15:15:03.839594",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "customization",
"idx": 0,
"is_default": 0,
- "is_standard": 1,
+ "is_standard": 0,
"label": "Customization",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Dashboards",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -26,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard",
+ "link_count": 0,
"link_to": "Dashboard",
"link_type": "DocType",
"onboard": 0,
@@ -36,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard Chart",
+ "link_count": 0,
"link_to": "Dashboard Chart",
"link_type": "DocType",
"onboard": 0,
@@ -46,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard Chart Source",
+ "link_count": 0,
"link_to": "Dashboard Chart Source",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +62,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Form Customization",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -63,6 +71,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Customize Form",
+ "link_count": 0,
"link_to": "Customize Form",
"link_type": "DocType",
"onboard": 0,
@@ -73,6 +82,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Custom Field",
+ "link_count": 0,
"link_to": "Custom Field",
"link_type": "DocType",
"onboard": 0,
@@ -83,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Client Script",
+ "link_count": 0,
"link_to": "Client Script",
"link_type": "DocType",
"onboard": 0,
@@ -93,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "DocType",
+ "link_count": 0,
"link_to": "DocType",
"link_type": "DocType",
"onboard": 0,
@@ -102,6 +114,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Other",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -110,19 +123,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Custom Translations",
+ "link_count": 0,
"link_to": "Translation",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2021-02-04 13:50:35.750463",
+ "modified": "2021-08-05 12:15:57.486112",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customization",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 8,
"shortcuts": [
{
"label": "Customize Form",
@@ -145,5 +165,6 @@
"link_to": "Server Script",
"type": "DocType"
}
- ]
+ ],
+ "title": "Customization"
}
\ No newline at end of file
diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py
index ca53e6cba4..e9036b98b0 100644
--- a/frappe/desk/desktop.py
+++ b/frappe/desk/desktop.py
@@ -6,6 +6,7 @@ import frappe
from json import loads, dumps
from frappe import _, DoesNotExistError, ValidationError, _dict
from frappe.boot import get_allowed_pages, get_allowed_reports
+from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from functools import wraps
from frappe.cache_manager import (
build_domain_restriced_doctype_cache,
@@ -27,18 +28,21 @@ def handle_not_exist(fn):
class Workspace:
- def __init__(self, page_name, minimal=False):
- self.page_name = page_name
+ def __init__(self, page, minimal=False):
+ self.page_name = page.get('name')
+ self.page_title = page.get('title')
+ self.public_page = page.get('public')
self.extended_links = []
self.extended_charts = []
self.extended_shortcuts = []
+ self.workspace_manager = "Workspace Manager" in frappe.get_roles()
self.user = frappe.get_user()
self.allowed_modules = self.get_cached('user_allowed_modules', self.get_allowed_modules)
- self.doc = self.get_page_for_user()
+ self.doc = frappe.get_cached_doc("Workspace", self.page_name)
- if self.doc.module and self.doc.module not in self.allowed_modules:
+ if self.doc and self.doc.module and self.doc.module not in self.allowed_modules and not self.workspace_manager:
raise frappe.PermissionError
self.can_read = self.get_cached('user_perm_can_read', self.get_can_read_items)
@@ -47,16 +51,17 @@ class Workspace:
self.allowed_reports = get_allowed_reports(cache=True)
if not minimal:
- self.onboarding_doc = self.get_onboarding_doc()
- self.onboarding = None
+ if self.doc.content:
+ self.onboarding_list = [x['data']['onboarding_name'] for x in loads(self.doc.content) if x['type'] == 'onboarding']
+ self.onboardings = []
self.table_counts = get_table_with_counts()
self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
def is_page_allowed(self):
- cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + self.extended_links
- shortcuts = self.doc.shortcuts + self.extended_shortcuts
+ cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module)
+ shortcuts = self.doc.shortcuts
for section in cards:
links = loads(section.get('links')) if isinstance(section.get('links'), str) else section.get('links')
@@ -74,8 +79,28 @@ class Workspace:
if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item):
return True
+ if not shortcuts and not self.doc.links:
+ return True
+
return False
+ def is_permitted(self):
+ """Returns true if Has Role is not set or the user is allowed."""
+ from frappe.utils import has_common
+
+ allowed = [d.role for d in frappe.get_all("Has Role", fields=["role"], filters={"parent": self.doc.name})]
+
+ custom_roles = get_custom_allowed_roles('page', self.doc.name)
+ allowed.extend(custom_roles)
+
+ if not allowed:
+ return True
+
+ roles = frappe.get_roles()
+
+ if has_common(roles, allowed):
+ return True
+
def get_cached(self, cache_key, fallback_fn):
_cache = frappe.cache()
@@ -101,39 +126,18 @@ class Workspace:
return self.user.allow_modules
- def get_page_for_user(self):
- filters = {
- 'extends': self.page_name,
- 'for_user': frappe.session.user
- }
- user_pages = frappe.get_all("Workspace", filters=filters, limit=1)
- if user_pages:
- return frappe.get_cached_doc("Workspace", user_pages[0])
-
- filters = {
- 'extends_another_page': 1,
- 'extends': self.page_name,
- 'is_default': 1
- }
- default_page = frappe.get_all("Workspace", filters=filters, limit=1)
- if default_page:
- return frappe.get_cached_doc("Workspace", default_page[0])
-
- self.get_pages_to_extend()
- return frappe.get_cached_doc("Workspace", self.page_name)
-
- def get_onboarding_doc(self):
+ def get_onboarding_doc(self, onboarding):
# Check if onboarding is enabled
if not frappe.get_system_settings("enable_onboarding"):
return None
- if not self.doc.onboarding:
+ if not self.onboarding_list:
return None
- if frappe.db.get_value("Module Onboarding", self.doc.onboarding, "is_complete"):
+ if frappe.db.get_value("Module Onboarding", onboarding, "is_complete"):
return None
- doc = frappe.get_doc("Module Onboarding", self.doc.onboarding)
+ doc = frappe.get_doc("Module Onboarding", onboarding)
# Check if user is allowed
allowed_roles = set(doc.get_allowed_roles())
@@ -197,14 +201,9 @@ class Workspace:
'items': self.get_shortcuts()
}
- if self.onboarding_doc:
- self.onboarding = {
- 'label': _(self.onboarding_doc.title),
- 'subtitle': _(self.onboarding_doc.subtitle),
- 'success': _(self.onboarding_doc.success_message),
- 'docs_url': self.onboarding_doc.documentation_url,
- 'items': self.get_onboarding_steps()
- }
+ self.onboardings = {
+ 'items': self.get_onboardings()
+ }
def _doctype_contains_a_record(self, name):
exists = self.table_counts.get(name, False)
@@ -333,9 +332,26 @@ class Workspace:
return items
@handle_not_exist
- def get_onboarding_steps(self):
+ def get_onboardings(self):
+ if self.onboarding_list:
+ for onboarding in self.onboarding_list:
+ onboarding_doc = self.get_onboarding_doc(onboarding)
+ if onboarding_doc:
+ item = {
+ 'label': _(onboarding),
+ 'title': _(onboarding_doc.title),
+ 'subtitle': _(onboarding_doc.subtitle),
+ 'success': _(onboarding_doc.success_message),
+ 'docs_url': onboarding_doc.documentation_url,
+ 'items': self.get_onboarding_steps(onboarding_doc)
+ }
+ self.onboardings.append(item)
+ return self.onboardings
+
+ @handle_not_exist
+ def get_onboarding_steps(self, onboarding_doc):
steps = []
- for doc in self.onboarding_doc.get_steps():
+ for doc in onboarding_doc.get_steps():
step = doc.as_dict().copy()
step.label = _(doc.title)
if step.action == "Create Entry":
@@ -352,19 +368,19 @@ def get_desktop_page(page):
on desk.
Args:
- page (string): page name
+ page (json): page data
Returns:
dict: dictionary of cards, charts and shortcuts to be displayed on website
"""
try:
- wspace = Workspace(page)
+ wspace = Workspace(loads(page))
wspace.build_workspace()
return {
'charts': wspace.charts,
'shortcuts': wspace.shortcuts,
'cards': wspace.cards,
- 'onboarding': wspace.onboarding,
+ 'onboardings': wspace.onboardings,
'allow_customization': not wspace.doc.disable_user_customization
}
except DoesNotExistError:
@@ -372,39 +388,45 @@ def get_desktop_page(page):
return {}
@frappe.whitelist()
-def get_desk_sidebar_items():
+def get_wspace_sidebar_items():
"""Get list of sidebar items for desk"""
+ has_access = "Workspace Manager" in frappe.get_roles()
# don't get domain restricted pages
blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules()
+ blocked_modules.append('Dummy Module')
filters = {
'restrict_to_domain': ['in', frappe.get_active_domains()],
- 'extends_another_page': 0,
- 'for_user': '',
'module': ['not in', blocked_modules]
}
- if not frappe.local.conf.developer_mode:
- filters['developer_mode_only'] = '0'
+ if has_access:
+ filters = []
- # pages sorted based on pinned to top and then by name
- order_by = "pin_to_top desc, pin_to_bottom asc, name asc"
- all_pages = frappe.get_all("Workspace", fields=["name", "category", "icon", "module"],
- filters=filters, order_by=order_by, ignore_permissions=True)
+ # pages sorted based on sequence id
+ order_by = "sequence_id asc"
+ fields = ["name", "title", "for_user", "parent_page", "content", "public", "module", "icon"]
+ all_pages = frappe.get_all("Workspace", fields=fields, filters=filters, order_by=order_by, ignore_permissions=True)
pages = []
+ private_pages = []
# Filter Page based on Permission
for page in all_pages:
try:
- wspace = Workspace(page.get('name'), True)
- if wspace.is_page_allowed():
- pages.append(page)
+ wspace = Workspace(page)
+ if wspace.is_permitted() and wspace.is_page_allowed() or has_access:
+ if page.public:
+ pages.append(page)
+ elif page.for_user == frappe.session.user:
+ private_pages.append(page)
page['label'] = _(page.get('name'))
except frappe.PermissionError:
pass
+ if private_pages:
+ pages.extend(private_pages)
- return pages
+ return {'pages': pages, 'has_access': has_access}
def get_table_with_counts():
counts = frappe.cache().get_value("information_schema:counts")
@@ -471,7 +493,7 @@ def get_custom_workspace_for_user(page):
"""
filters = {
'extends': page,
- 'for_user': frappe.session.user
+ 'for_user': frappe.session.user,
}
pages = frappe.get_list("Workspace", filters=filters)
if pages:
@@ -481,7 +503,6 @@ def get_custom_workspace_for_user(page):
doc.for_user = frappe.session.user
return doc
-
@frappe.whitelist()
def save_customization(page, config):
"""Save customizations as a separate doctype in Workspace per user
@@ -540,6 +561,80 @@ def save_customization(page, config):
return True
+def save_new_widget(doc, page, blocks, new_widgets):
+
+ widgets = _dict(loads(new_widgets))
+
+ if widgets.chart:
+ doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts"))
+ if widgets.shortcut:
+ doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts"))
+ if widgets.card:
+ doc.build_links_table_from_card(widgets.card)
+
+ # remove duplicate and unwanted widgets
+ if widgets:
+ clean_up(doc, blocks)
+
+ try:
+ doc.save(ignore_permissions=True)
+ except (ValidationError, TypeError) as e:
+ # Create a json string to log
+ json_config = dumps(widgets, sort_keys=True, indent=4)
+
+ # Error log body
+ log = \
+ """
+ page: {0}
+ config: {1}
+ exception: {2}
+ """.format(page, json_config, e)
+ frappe.log_error(log, _("Could not save customization"))
+ return False
+
+ return True
+def clean_up(original_page, blocks):
+ page_widgets = {}
+
+ for wid in ['shortcut', 'card', 'chart']:
+ # get list of widget's name from blocks
+ page_widgets[wid] = [x['data'][wid + '_name'] for x in loads(blocks) if x['type'] == wid]
+
+ # shortcut & chart cleanup
+ for wid in ['shortcut', 'chart']:
+ updated_widgets = []
+ original_page.get(wid+'s').reverse()
+
+ for w in original_page.get(wid+'s'):
+ if w.label in page_widgets[wid] and w.label not in [x.label for x in updated_widgets]:
+ updated_widgets.append(w)
+ original_page.set(wid+'s', updated_widgets)
+
+ # card cleanup
+ for i, v in enumerate(original_page.links):
+ if v.type == 'Card Break' and v.label not in page_widgets['card']:
+ del original_page.links[i : i+v.link_count+1]
+
+def new_widget(config, doctype, parentfield):
+ if not config:
+ return []
+ prepare_widget_list = []
+ for idx, widget in enumerate(config):
+ # Some cleanup
+ widget.pop("name", None)
+
+ # New Doc
+ doc = frappe.new_doc(doctype)
+ doc.update(widget)
+
+ # Manually Set IDX
+ doc.idx = idx + 1
+
+ # Set Parent Field
+ doc.parentfield = parentfield
+
+ prepare_widget_list.append(doc)
+ return prepare_widget_list
def prepare_widget(config, doctype, parentfield):
"""Create widget child table entries with parent details
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
index 3b4d5e7be5..635d32d969 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
@@ -223,7 +223,7 @@ frappe.ui.form.on('Dashboard Chart', {
if (['Date', 'Datetime'].includes(df.fieldtype)) {
date_fields.push({label: df.label, value: df.fieldname});
}
- if (['Int', 'Float', 'Currency', 'Percent'].includes(df.fieldtype)) {
+ if (['Int', 'Float', 'Currency', 'Percent', 'Duration'].includes(df.fieldtype)) {
value_fields.push({label: df.label, value: df.fieldname});
aggregate_function_fields.push({label: df.label, value: df.fieldname});
}
diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py
index 10bd8926ce..2336ff52f8 100644
--- a/frappe/desk/doctype/onboarding_step/onboarding_step.py
+++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py
@@ -2,10 +2,26 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
+from frappe import _
+import json
from frappe.model.document import Document
class OnboardingStep(Document):
def before_export(self, doc):
doc.is_complete = 0
doc.is_skipped = 0
+
+
+@frappe.whitelist()
+def get_onboarding_steps(ob_steps):
+ steps = []
+ for s in json.loads(ob_steps):
+ doc = frappe.get_doc('Onboarding Step', s.get('step'))
+ step = doc.as_dict().copy()
+ step.label = _(doc.title)
+ if step.action == "Create Entry":
+ step.is_submittable = frappe.db.get_value("DocType", step.reference_document, 'is_submittable', cache=True)
+ steps.append(step)
+
+ return steps
diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json
index 386267b699..e2ae38faf1 100644
--- a/frappe/desk/doctype/workspace/workspace.json
+++ b/frappe/desk/doctype/workspace/workspace.json
@@ -8,8 +8,11 @@
"engine": "InnoDB",
"field_order": [
"label",
+ "title",
+ "sequence_id",
"for_user",
"extends",
+ "parent_page",
"module",
"category",
"icon",
@@ -24,6 +27,9 @@
"pin_to_top",
"pin_to_bottom",
"hide_custom",
+ "public",
+ "content_section",
+ "content",
"section_break_2",
"charts_label",
"charts",
@@ -32,7 +38,8 @@
"shortcuts",
"section_break_18",
"cards_label",
- "links"
+ "links",
+ "roles"
],
"fields": [
{
@@ -199,7 +206,7 @@
},
{
"fieldname": "icon",
- "fieldtype": "Data",
+ "fieldtype": "Icon",
"label": "Icon"
},
{
@@ -209,16 +216,53 @@
"options": "Workspace Link"
},
{
- "default": "0",
- "depends_on": "extends_another_page",
- "description": "Sets the current page as default for all users",
- "fieldname": "is_default",
- "fieldtype": "Check",
- "label": "Is Default"
- }
+ "default": "0",
+ "depends_on": "extends_another_page",
+ "description": "Sets the current page as default for all users",
+ "fieldname": "is_default",
+ "fieldtype": "Check",
+ "label": "Is Default"
+ },
+ {
+ "default": "0",
+ "fieldname": "public",
+ "fieldtype": "Check",
+ "label": "Public"
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title"
+ },
+ {
+ "fieldname": "parent_page",
+ "fieldtype": "Data",
+ "label": "Parent Page"
+ },
+ {
+ "fieldname": "content_section",
+ "fieldtype": "Section Break",
+ "label": "Content"
+ },
+ {
+ "fieldname": "content",
+ "fieldtype": "Long Text",
+ "label": "Content"
+ },
+ {
+ "fieldname": "sequence_id",
+ "fieldtype": "Int",
+ "label": "Sequence Id"
+ },
+ {
+ "fieldname": "roles",
+ "fieldtype": "Table",
+ "label": "Roles",
+ "options": "Has Role"
+ }
],
"links": [],
- "modified": "2021-01-21 12:09:36.156614",
+ "modified": "2021-08-05 11:49:09.028243",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
@@ -232,7 +276,7 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "System Manager",
+ "role": "Workspace Manager",
"share": 1,
"write": 1
},
@@ -248,4 +292,4 @@
],
"sort_field": "modified",
"sort_order": "DESC"
-}
+}
\ No newline at end of file
diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py
index 1d49cde774..ccf9c02f6f 100644
--- a/frappe/desk/doctype/workspace/workspace.py
+++ b/frappe/desk/doctype/workspace/workspace.py
@@ -6,6 +6,7 @@ import frappe
from frappe import _
from frappe.modules.export_file import export_to_files
from frappe.model.document import Document
+from frappe.desk.desktop import save_new_widget
from frappe.desk.utils import validate_route_conflict
from json import loads
@@ -27,7 +28,7 @@ class Workspace(Document):
if disable_saving_as_standard():
return
- if frappe.conf.developer_mode and self.is_standard:
+ if frappe.conf.developer_mode and self.module and self.public:
export_to_files(record_list=[['Workspace', self.name]], record_module=self.module)
@staticmethod
@@ -98,6 +99,37 @@ class Workspace(Document):
"is_query_report": link.get('is_query_report')
})
+ def build_links_table_from_card(self, config):
+
+ for idx, card in enumerate(config):
+ links = loads(card.get('links'))
+
+ # remove duplicate before adding
+ for idx, link in enumerate(self.links):
+ if link.label == card.get('label') and link.type == 'Card Break':
+ del self.links[idx : idx + link.link_count + 1]
+
+ self.append('links', {
+ "label": card.get('label'),
+ "type": "Card Break",
+ "icon": card.get('icon'),
+ "hidden": card.get('hidden') or False,
+ "link_count": card.get('link_count'),
+ "idx": 1 if not self.links else self.links[-1].idx + 1
+ })
+
+ for link in links:
+ self.append('links', {
+ "label": link.get('label'),
+ "type": "Link",
+ "link_type": link.get('link_type'),
+ "link_to": link.get('link_to'),
+ "onboard": link.get('onboard'),
+ "only_for": link.get('only_for'),
+ "dependencies": link.get('dependencies'),
+ "is_query_report": link.get('is_query_report'),
+ "idx": self.links[-1].idx + 1
+ })
def disable_saving_as_standard():
return frappe.flags.in_install or \
@@ -123,3 +155,84 @@ def get_link_type(key):
def get_report_type(report):
report_type = frappe.get_value("Report", report, "report_type")
return report_type in ["Query Report", "Script Report", "Custom Report"]
+
+
+@frappe.whitelist()
+def save_page(title, icon, parent, public, sb_public_items, sb_private_items, deleted_pages, new_widgets, blocks, save):
+ save = frappe.parse_json(save)
+ public = frappe.parse_json(public)
+ if save:
+ doc = frappe.new_doc('Workspace')
+ doc.title = title
+ doc.icon = icon
+ doc.content = blocks
+ doc.parent_page = parent
+
+ if public:
+ doc.label = title
+ doc.public = 1
+ else:
+ doc.label = title + "-" + frappe.session.user
+ doc.for_user = frappe.session.user
+ doc.save(ignore_permissions=True)
+ else:
+ if public:
+ filters = {
+ 'public': public,
+ 'label': title
+ }
+ else:
+ filters = {
+ 'for_user': frappe.session.user,
+ 'label': title + "-" + frappe.session.user
+ }
+ pages = frappe.get_list("Workspace", filters=filters)
+ if pages:
+ doc = frappe.get_doc("Workspace", pages[0])
+
+ doc.content = blocks
+ doc.save(ignore_permissions=True)
+
+ if loads(new_widgets):
+ save_new_widget(doc, title, blocks, new_widgets)
+
+ if loads(sb_public_items) or loads(sb_private_items):
+ sort_pages(loads(sb_public_items), loads(sb_private_items))
+
+ if loads(deleted_pages):
+ return delete_pages(loads(deleted_pages))
+
+ return {"name": title, "public": public}
+
+def delete_pages(deleted_pages):
+ for page in deleted_pages:
+ if page.get("public") and "Workspace Manager" not in frappe.get_roles():
+ return {"name": page.get("title"), "public": 1}
+
+ if frappe.db.exists("Workspace", page.get("name")):
+ frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True)
+
+ return {"name": "Home", "public": 1}
+
+def sort_pages(sb_public_items, sb_private_items):
+ wspace_public_pages = get_page_list(['name', 'title'], {'public': 1})
+ wspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user})
+
+ if sb_private_items:
+ sort_page(wspace_private_pages, sb_private_items)
+
+ if sb_public_items and "Workspace Manager" in frappe.get_roles():
+ sort_page(wspace_public_pages, sb_public_items)
+
+def sort_page(wspace_pages, pages):
+ for seq, d in enumerate(pages):
+ for page in wspace_pages:
+ if page.title == d.get('title'):
+ doc = frappe.get_doc('Workspace', page.name)
+ doc.sequence_id = seq + 1
+ doc.parent_page = d.get('parent_page') or ""
+ doc.save(ignore_permissions=True)
+ break
+
+def get_page_list(fields, filters):
+ return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc')
diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json
index 53dadad83d..a7b217be9e 100644
--- a/frappe/desk/doctype/workspace_link/workspace_link.json
+++ b/frappe/desk/doctype/workspace_link/workspace_link.json
@@ -8,15 +8,16 @@
"type",
"label",
"icon",
- "only_for",
"hidden",
"link_details_section",
"link_type",
"link_to",
"column_break_7",
"dependencies",
+ "only_for",
"onboard",
- "is_query_report"
+ "is_query_report",
+ "link_count"
],
"fields": [
{
@@ -99,12 +100,19 @@
"fieldname": "is_query_report",
"fieldtype": "Check",
"label": "Is Query Report"
+ },
+ {
+ "depends_on": "eval:doc.type == \"Card Break\"",
+ "fieldname": "link_count",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Link Count"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-05-13 13:10:18.128512",
+ "modified": "2021-06-01 11:23:28.990593",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Link",
diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py
index 3c0ebf11c1..b42c9c89a0 100644
--- a/frappe/desk/query_report.py
+++ b/frappe/desk/query_report.py
@@ -177,11 +177,13 @@ def get_script(report_name):
if os.path.exists(script_path):
with open(script_path, "r") as f:
script = f.read()
+ script += f"\n\n//# sourceURL={scrub(report.name)}.js"
html_format = get_html_format(print_path)
if not script and report.javascript:
script = report.javascript
+ script += f"\n\n//# sourceURL={scrub(report.name)}__custom"
if not script:
script = "frappe.query_reports['%s']={}" % report_name
diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.json b/frappe/integrations/doctype/ldap_settings/ldap_settings.json
index 5d30a873fb..d915ae2ad6 100644
--- a/frappe/integrations/doctype/ldap_settings/ldap_settings.json
+++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"creation": "2016-09-22 04:16:48.829658",
"doctype": "DocType",
"document_type": "System",
@@ -6,18 +7,24 @@
"engine": "InnoDB",
"field_order": [
"enabled",
- "ldap_server_url",
+ "ldap_server_settings_section",
+ "ldap_directory_server",
"column_break_4",
+ "ldap_server_url",
+ "ldap_auth_section",
"base_dn",
+ "column_break_8",
"password",
- "section_break_5",
- "organizational_unit",
- "default_role",
+ "ldap_search_and_paths_section",
+ "ldap_search_path_user",
"ldap_search_string",
+ "column_break_12",
+ "ldap_search_path_group",
+ "ldap_user_creation_and_mapping_section",
"ldap_email_field",
"ldap_username_field",
- "column_break_11",
"ldap_first_name_field",
+ "column_break_19",
"ldap_middle_name_field",
"ldap_last_name_field",
"ldap_phone_field",
@@ -25,13 +32,18 @@
"ldap_security",
"ssl_tls_mode",
"require_trusted_certificate",
- "column_break_17",
+ "column_break_27",
"local_private_key_file",
"local_server_certificate_file",
"local_ca_certs_file",
+ "ldap_custom_settings_section",
+ "ldap_group_objectclass",
+ "column_break_33",
+ "ldap_group_member_attribute",
"ldap_group_mappings_section",
- "ldap_group_field",
- "ldap_groups"
+ "default_role",
+ "ldap_groups",
+ "ldap_group_field"
],
"fields": [
{
@@ -65,18 +77,6 @@
"label": "Password for Base DN",
"reqd": 1
},
- {
- "fieldname": "section_break_5",
- "fieldtype": "Section Break",
- "label": "LDAP User Creation and Mapping"
- },
- {
- "fieldname": "organizational_unit",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Organizational Unit for Users",
- "reqd": 1
- },
{
"fieldname": "default_role",
"fieldtype": "Link",
@@ -85,6 +85,7 @@
"reqd": 1
},
{
+ "description": "Must be enclosed in '()' and include '{0}', which is a placeholder for the user/login name. i.e. (&(objectclass=user)(uid={0}))",
"fieldname": "ldap_search_string",
"fieldtype": "Data",
"label": "LDAP Search String",
@@ -102,10 +103,6 @@
"label": "LDAP Username Field",
"reqd": 1
},
- {
- "fieldname": "column_break_11",
- "fieldtype": "Column Break"
- },
{
"fieldname": "ldap_first_name_field",
"fieldtype": "Data",
@@ -152,10 +149,6 @@
"options": "No\nYes",
"reqd": 1
},
- {
- "fieldname": "column_break_17",
- "fieldtype": "Column Break"
- },
{
"fieldname": "local_private_key_file",
"fieldtype": "Data",
@@ -177,6 +170,7 @@
"label": "LDAP Group Mappings"
},
{
+ "description": "NOTE: This box is due for depreciation. Please re-setup LDAP to work with the newer settings",
"fieldname": "ldap_group_field",
"fieldtype": "Data",
"label": "LDAP Group Field"
@@ -186,11 +180,93 @@
"fieldtype": "Table",
"label": "LDAP Group Mappings",
"options": "LDAP Group Mapping"
+ },
+ {
+ "fieldname": "ldap_server_settings_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Server Settings"
+ },
+ {
+ "fieldname": "ldap_auth_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Auth"
+ },
+ {
+ "fieldname": "column_break_8",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "ldap_search_and_paths_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Search and Paths"
+ },
+ {
+ "fieldname": "column_break_12",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "ldap_user_creation_and_mapping_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP User Creation and Mapping"
+ },
+ {
+ "fieldname": "column_break_19",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_27",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "These settings are required if 'Custom' LDAP Directory is used",
+ "fieldname": "ldap_custom_settings_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Custom Settings"
+ },
+ {
+ "fieldname": "column_break_33",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "string value, i.e. member",
+ "fieldname": "ldap_group_member_attribute",
+ "fieldtype": "Data",
+ "label": "LDAP Group Member attribute"
+ },
+ {
+ "description": "Please select the LDAP Directory being used",
+ "fieldname": "ldap_directory_server",
+ "fieldtype": "Select",
+ "label": "Directory Server",
+ "options": "\nActive Directory\nOpenLDAP\nCustom",
+ "reqd": 1
+ },
+ {
+ "description": "string value, i.e. group",
+ "fieldname": "ldap_group_objectclass",
+ "fieldtype": "Data",
+ "label": "Group Object Class"
+ },
+ {
+ "description": "Requires any valid fdn path. i.e. ou=users,dc=example,dc=com",
+ "fieldname": "ldap_search_path_user",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "LDAP search path for Users",
+ "reqd": 1
+ },
+ {
+ "description": "Requires any valid fdn path. i.e. ou=groups,dc=example,dc=com",
+ "fieldname": "ldap_search_path_group",
+ "fieldtype": "Data",
+ "label": "LDAP search path for Groups",
+ "reqd": 1
}
],
"in_create": 1,
"issingle": 1,
- "modified": "2019-07-15 06:48:16.562109",
+ "links": [],
+ "modified": "2021-07-27 11:51:43.328271",
"modified_by": "Administrator",
"module": "Integrations",
"name": "LDAP Settings",
diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py
index acc8b96679..7c89c31844 100644
--- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py
+++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py
@@ -13,10 +13,44 @@ class LDAPSettings(Document):
return
if not self.flags.ignore_mandatory:
- if self.ldap_search_string and self.ldap_search_string.endswith("={0}"):
- self.connect_to_ldap(base_dn=self.base_dn, password=self.get_password(raise_exception=False))
+
+ if self.ldap_search_string.count('(') == self.ldap_search_string.count(')') and \
+ self.ldap_search_string.startswith('(') and \
+ self.ldap_search_string.endswith(')') and \
+ self.ldap_search_string and \
+ "{0}" in self.ldap_search_string:
+
+ conn = self.connect_to_ldap(base_dn=self.base_dn, password=self.get_password(raise_exception=False))
+
+ try:
+ if conn.result['type'] == 'bindResponse' and self.base_dn:
+ import ldap3
+
+ conn.search(
+ search_base=self.ldap_search_path_user,
+ search_filter="(objectClass=*)",
+ attributes=self.get_ldap_attributes())
+
+ conn.search(
+ search_base=self.ldap_search_path_group,
+ search_filter="(objectClass=*)",
+ attributes=['cn'])
+
+ except ldap3.core.exceptions.LDAPAttributeError as ex:
+ frappe.throw(_("LDAP settings incorrect. validation response was: {0}").format(ex),
+ title=_("Misconfigured"))
+
+ except ldap3.core.exceptions.LDAPNoSuchObjectResult:
+ frappe.throw(_("Ensure the user and group search paths are correct."),
+ title=_("Misconfigured"))
+
+ if self.ldap_directory_server.lower() == 'custom':
+ if not self.ldap_group_member_attribute or not self.ldap_group_mappings_section:
+ frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'LDAP Group Mappings' are entered"),
+ title=_("Misconfigured"))
+
else:
- frappe.throw(_("LDAP Search String needs to end with a placeholder, eg sAMAccountName={0}"))
+ frappe.throw(_("LDAP Search String must be enclosed in '()' and needs to contian the user placeholder {0}, eg sAMAccountName={0}"))
def connect_to_ldap(self, base_dn, password, read_only=True):
try:
@@ -118,8 +152,8 @@ class LDAPSettings(Document):
user.insert(ignore_permissions=True)
# always add default role.
user.add_roles(self.default_role)
- if self.ldap_group_field:
- self.sync_roles(user, groups)
+ self.sync_roles(user, groups)
+
return user
def get_ldap_attributes(self):
@@ -142,6 +176,66 @@ class LDAPSettings(Document):
return ldap_attributes
+
+ def fetch_ldap_groups(self, user, conn):
+ import ldap3
+
+ if type(user) is not ldap3.abstract.entry.Entry:
+ raise TypeError("Invalid type, attribute {0} must be of type '{1}'".format('user', 'ldap3.abstract.entry.Entry'))
+
+ if type(conn) is not ldap3.core.connection.Connection:
+ raise TypeError("Invalid type, attribute {0} must be of type '{1}'".format('conn', 'ldap3.Connection'))
+
+ fetch_ldap_groups = None
+
+ ldap_object_class = None
+ ldap_group_members_attribute = None
+
+
+ if self.ldap_directory_server.lower() == 'active directory':
+
+ ldap_object_class = 'Group'
+ ldap_group_members_attribute = 'member'
+ user_search_str = user.entry_dn
+
+
+ elif self.ldap_directory_server.lower() == 'openldap':
+
+ ldap_object_class = 'posixgroup'
+ ldap_group_members_attribute = 'memberuid'
+ user_search_str = getattr(user, self.ldap_username_field).value
+
+ elif self.ldap_directory_server.lower() == 'custom':
+
+ ldap_object_class = self.ldap_group_objectclass
+ ldap_group_members_attribute = self.ldap_group_member_attribute
+ user_search_str = getattr(user, self.ldap_username_field).value
+
+ else:
+ # NOTE: depreciate this else path
+ # this path will be hit for everyone with preconfigured ldap settings. this must be taken into account so as not to break ldap for those users.
+
+ if self.ldap_group_field:
+
+ fetch_ldap_groups = getattr(user, self.ldap_group_field).values
+
+ if ldap_object_class is not None:
+ conn.search(
+ search_base=self.ldap_search_path_group,
+ search_filter="(&(objectClass={0})({1}={2}))".format(ldap_object_class,ldap_group_members_attribute, user_search_str),
+ attributes=['cn']) # Build search query
+
+ if len(conn.entries) >= 1:
+
+ fetch_ldap_groups = []
+ for group in conn.entries:
+ fetch_ldap_groups.append(group['cn'].value)
+
+ return fetch_ldap_groups
+
+
+
+
def authenticate(self, username, password):
if not self.enabled:
@@ -152,23 +246,33 @@ class LDAPSettings(Document):
conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False))
- conn.search(
- search_base=self.organizational_unit,
- search_filter="({0})".format(user_filter),
- attributes=ldap_attributes)
+ try:
+ import ldap3
- if len(conn.entries) == 1 and conn.entries[0]:
- user = conn.entries[0]
- # only try and connect as the user, once we have their fqdn entry.
- self.connect_to_ldap(base_dn=user.entry_dn, password=password)
+ conn.search(
+ search_base=self.ldap_search_path_user,
+ search_filter="{0}".format(user_filter),
+ attributes=ldap_attributes)
- groups = None
- if self.ldap_group_field:
- groups = getattr(user, self.ldap_group_field).values
- return self.create_or_update_user(self.convert_ldap_entry_to_dict(user), groups=groups)
- else:
+ if len(conn.entries) == 1 and conn.entries[0]:
+ user = conn.entries[0]
+
+ groups = self.fetch_ldap_groups(user, conn)
+
+ # only try and connect as the user, once we have their fqdn entry.
+ if user.entry_dn and password and conn.rebind(user=user.entry_dn, password=password):
+
+ return self.create_or_update_user(self.convert_ldap_entry_to_dict(user), groups=groups)
+
+ raise ldap3.core.exceptions.LDAPInvalidCredentialsResult # even though nothing foundor failed authentication raise invalid credentials
+
+ except ldap3.core.exceptions.LDAPInvalidFilterError:
+ frappe.throw(_("Please use a valid LDAP search filter"), title=_("Misconfigured"))
+
+ except ldap3.core.exceptions.LDAPInvalidCredentialsResult:
frappe.throw(_("Invalid username or password"))
+
def reset_password(self, user, password, logout_sessions=False):
from ldap3 import HASHED_SALTED_SHA, MODIFY_REPLACE
from ldap3.utils.hashed import hashed
@@ -179,7 +283,7 @@ class LDAPSettings(Document):
read_only=False)
if conn.search(
- search_base=self.organizational_unit,
+ search_base=self.ldap_search_path_user,
search_filter=search_filter,
attributes=self.get_ldap_attributes()
):
diff --git a/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json b/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json
new file mode 100644
index 0000000000..9777452af8
--- /dev/null
+++ b/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json
@@ -0,0 +1,338 @@
+{
+ "entries": [
+ {
+ "attributes": {
+ "cn": "base_dn_user",
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": "cn=base_dn_user,dc=unit,dc=testing",
+ "sn": "user_sn",
+ "userPassword": [
+ "my_password"
+ ]
+ },
+ "dn": "cn=base_dn_user,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "base_dn_user"
+ ],
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": [
+ "cn=base_dn_user,dc=unit,dc=testing"
+ ],
+ "sn": [
+ "user_sn"
+ ],
+ "userPassword": [
+ "my_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": "Posix User1",
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "givenname": "Posix",
+ "mail": "posix.user1@unit.testing",
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": "0421 123 456",
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": "posix.user",
+ "sn": "User1",
+ "telephonenumber": "08 8912 3456",
+ "userpassword": [
+ "posix_user_password"
+ ]
+ },
+ "dn": "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User1"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "givenname": [
+ "Posix"
+ ],
+ "mail": [
+ "posix.user1@unit.testing"
+ ],
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": [
+ "0421 123 456"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": [
+ "posix.user"
+ ],
+ "sn": [
+ "User1"
+ ],
+ "telephonenumber": [
+ "08 8912 3456"
+ ],
+ "userpassword": [
+ "posix_user_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": "Posix User2",
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "givenname": "Posix",
+ "homedirectory": "/home/users/posix.user2",
+ "mail": "posix.user2@unit.testing",
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": "0421 456 789",
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": "posix.user2",
+ "sn": "User2",
+ "telephonenumber": "08 8978 1234",
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ },
+ "dn": "cn=Posix User2,ou=Users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User2"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "givenname": [
+ "Posix"
+ ],
+ "homedirectory": [
+ "/home/users/posix.user2"
+ ],
+ "mail": [
+ "posix.user2@unit.testing"
+ ],
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": [
+ "0421 456 789"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": [
+ "posix.user2"
+ ],
+ "sn": [
+ "User2"
+ ],
+ "telephonenumber": [
+ "08 8978 1234"
+ ],
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users"
+ ]
+ },
+ "dn": "ou=Users,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "Member": [
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": "Enterprise Administrators",
+ "description": [
+ "group contains only posix.user2"
+ ],
+ "groupType": 2147483652,
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ },
+ "dn": "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "Member": [
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": [
+ "Enterprise Administrators"
+ ],
+ "description": [
+ "group contains only posix.user2"
+ ],
+ "groupType": [
+ "2147483652"
+ ],
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": "Domain Users",
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "groupType": 2147483652,
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ },
+ "dn": "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": [
+ "Domain Users"
+ ],
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "groupType": [
+ "2147483652"
+ ],
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=base_dn_user,dc=unit,dc=testing"
+ ],
+ "cn": "Domain Administrators",
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "groupType": 2147483652,
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ },
+ "dn": "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=base_dn_user,dc=unit,dc=testing"
+ ],
+ "cn": [
+ "Domain Administrators"
+ ],
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "groupType": [
+ "2147483652"
+ ],
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Groups"
+ ]
+ },
+ "dn": "ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Groups"
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json b/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json
new file mode 100644
index 0000000000..86a76c1abc
--- /dev/null
+++ b/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json
@@ -0,0 +1,400 @@
+{
+ "entries": [
+ {
+ "attributes": {
+ "cn": [
+ "base_dn_user"
+ ],
+ "objectClass": [
+ "simpleSecurityObject",
+ "organizationalRole",
+ "top"
+ ],
+ "sn": [
+ "user_sn"
+ ],
+ "userPassword": [
+ "my_password"
+ ]
+ },
+ "dn": "cn=base_dn_user,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "base_dn_user"
+ ],
+ "objectClass": [
+ "simpleSecurityObject",
+ "organizationalRole",
+ "top"
+ ],
+ "sn": [
+ "user_sn"
+ ],
+ "userPassword": [
+ "my_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Posix User2"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "gidnumber": 501,
+ "givenname": [
+ "Posix2"
+ ],
+ "homedirectory": "/home/users/posix.user2",
+ "mail": [
+ "posix.user2@unit.testing"
+ ],
+ "mobile": [
+ "0421 456 789"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User2"
+ ],
+ "telephonenumber": [
+ "08 8978 1234"
+ ],
+ "uid": [
+ "posix.user2"
+ ],
+ "uidnumber": 1000,
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ },
+ "dn": "cn=Posix User2,ou=users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User2"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "gidnumber": [
+ "501"
+ ],
+ "givenname": [
+ "Posix2"
+ ],
+ "homedirectory": [
+ "/home/users/posix.user2"
+ ],
+ "mail": [
+ "posix.user2@unit.testing"
+ ],
+ "mobile": [
+ "0421 456 789"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User2"
+ ],
+ "telephonenumber": [
+ "08 8978 1234"
+ ],
+ "uid": [
+ "posix.user2"
+ ],
+ "uidnumber": [
+ "1000"
+ ],
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Posix User1"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "gidnumber": 501,
+ "givenname": [
+ "Posix"
+ ],
+ "homedirectory": "/home/users/posix.user",
+ "mail": [
+ "posix.user1@unit.testing"
+ ],
+ "mobile": [
+ "0421 123 456"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User1"
+ ],
+ "telephonenumber": [
+ "08 8912 3456"
+ ],
+ "uid": [
+ "posix.user"
+ ],
+ "uidnumber": 1000,
+ "userpassword": [
+ "posix_user_password"
+ ]
+ },
+ "dn": "cn=Posix User1,ou=users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User1"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "gidnumber": [
+ "501"
+ ],
+ "givenname": [
+ "Posix"
+ ],
+ "homedirectory": [
+ "/home/users/posix.user"
+ ],
+ "mail": [
+ "posix.user1@unit.testing"
+ ],
+ "mobile": [
+ "0421 123 456"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User1"
+ ],
+ "telephonenumber": [
+ "08 8912 3456"
+ ],
+ "uid": [
+ "posix.user"
+ ],
+ "uidnumber": [
+ "1000"
+ ],
+ "userpassword": [
+ "posix_user_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "users"
+ ]
+ },
+ "dn": "ou=users,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "users"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "dc": "testing",
+ "o": [
+ "Testing"
+ ],
+ "objectClass": [
+ "top",
+ "organization",
+ "dcObject"
+ ]
+ },
+ "dn": "dc=unit,dc=testing",
+ "raw": {
+ "dc": [
+ "testing",
+ "unit"
+ ],
+ "o": [
+ "Testing"
+ ],
+ "objectClass": [
+ "top",
+ "organization",
+ "dcObject"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Users"
+ ],
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "gidnumber": 501,
+ "memberuid": [
+ "posix.user2",
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ },
+ "dn": "cn=Users,ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Users"
+ ],
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "gidnumber": [
+ "501"
+ ],
+ "memberuid": [
+ "posix.user2",
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Administrators"
+ ],
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "gidnumber": 500,
+ "memberuid": [
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ },
+ "dn": "cn=Administrators,ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Administrators"
+ ],
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "gidnumber": [
+ "500"
+ ],
+ "memberuid": [
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Group3"
+ ],
+ "description": [
+ "group3 Group3 contains only posix.user2 only"
+ ],
+ "gidnumber": 502,
+ "memberuid": [
+ "posix.user2"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ },
+ "dn": "cn=Group3,ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Group3"
+ ],
+ "description": [
+ "group3 Group3 contains only posix.user2 only"
+ ],
+ "gidnumber": [
+ "502"
+ ],
+ "memberuid": [
+ "posix.user2"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "groups"
+ ]
+ },
+ "dn": "ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "groups"
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py
index 113692b6c4..1b2a9b155f 100644
--- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py
+++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py
@@ -1,8 +1,684 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
# See license.txt
-# import frappe
+import frappe
import unittest
+import functools
+import ldap3
+import ssl
+import os
+
+from unittest import mock
+from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
+from ldap3 import Server, Connection, MOCK_SYNC, OFFLINE_SLAPD_2_4, OFFLINE_AD_2012_R2
+
+
+class LDAP_TestCase():
+ TEST_LDAP_SERVER = None # must match the 'LDAP Settings' field option
+ TEST_LDAP_SEARCH_STRING = None
+ LDAP_USERNAME_FIELD = None
+ DOCUMENT_GROUP_MAPPINGS = []
+ LDAP_SCHEMA = None
+ LDAP_LDIF_JSON = None
+ TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = None
+
+ def mock_ldap_connection(f):
+
+ @functools.wraps(f)
+ def wrapped(self, *args, **kwargs):
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.connect_to_ldap') as mock_connection:
+ mock_connection.return_value = self.connection
+
+ self.test_class = LDAPSettings(self.doc)
+
+ # Create a clean doc
+ localdoc = self.doc.copy()
+ frappe.get_doc(localdoc).save()
+
+ rv = f(self, *args, **kwargs)
+
+
+ # Clean-up
+ self.test_class = None
+
+ return rv
+
+ return wrapped
+
+ def clean_test_users():
+ try: # clean up test user 1
+ frappe.get_doc("User", 'posix.user1@unit.testing').delete()
+ except Exception:
+ pass
+
+ try: # clean up test user 2
+ frappe.get_doc("User", 'posix.user2@unit.testing').delete()
+ except Exception:
+ pass
+
+
+ @classmethod
+ def setUpClass(self, ldapServer='OpenLDAP'):
+
+ self.clean_test_users()
+ # Save user data for restoration in tearDownClass()
+ self.user_ldap_settings = frappe.get_doc('LDAP Settings')
+
+ # Create test user1
+ self.user1doc = {
+ 'username': 'posix.user',
+ 'email': 'posix.user1@unit.testing',
+ 'first_name': 'posix'
+ }
+ self.user1doc.update({
+ "doctype": "User",
+ "send_welcome_email": 0,
+ "language": "",
+ "user_type": "System User",
+ })
+
+ user = frappe.get_doc(self.user1doc)
+ user.insert(ignore_permissions=True)
+
+ # Create test user1
+ self.user2doc = {
+ 'username': 'posix.user2',
+ 'email': 'posix.user2@unit.testing',
+ 'first_name': 'posix'
+ }
+ self.user2doc.update({
+ "doctype": "User",
+ "send_welcome_email": 0,
+ "language": "",
+ "user_type": "System User",
+ })
+
+ user = frappe.get_doc(self.user2doc)
+ user.insert(ignore_permissions=True)
+
+
+ # Setup Mock OpenLDAP Directory
+ self.ldap_dc_path = 'dc=unit,dc=testing'
+ self.ldap_user_path = 'ou=users,' + self.ldap_dc_path
+ self.ldap_group_path = 'ou=groups,' + self.ldap_dc_path
+ self.base_dn = 'cn=base_dn_user,' + self.ldap_dc_path
+ self.base_password = 'my_password'
+ self.ldap_server = 'ldap://my_fake_server:389'
+
+
+ self.doc = {
+ "doctype": "LDAP Settings",
+ "enabled": True,
+ "ldap_directory_server": self.TEST_LDAP_SERVER,
+ "ldap_server_url": self.ldap_server,
+ "base_dn": self.base_dn,
+ "password": self.base_password,
+ "ldap_search_path_user": self.ldap_user_path,
+ "ldap_search_string": self.TEST_LDAP_SEARCH_STRING,
+ "ldap_search_path_group": self.ldap_group_path,
+ "ldap_user_creation_and_mapping_section": '',
+ "ldap_email_field": 'mail',
+ "ldap_username_field": self.LDAP_USERNAME_FIELD,
+ "ldap_first_name_field": 'givenname',
+ "ldap_middle_name_field": '',
+ "ldap_last_name_field": 'sn',
+ "ldap_phone_field": 'telephonenumber',
+ "ldap_mobile_field": 'mobile',
+ "ldap_security": '',
+ "ssl_tls_mode": '',
+ "require_trusted_certificate": 'No',
+ "local_private_key_file": '',
+ "local_server_certificate_file": '',
+ "local_ca_certs_file": '',
+ "ldap_group_objectclass": '',
+ "ldap_group_member_attribute": '',
+ "default_role": 'Newsletter Manager',
+ "ldap_groups": self.DOCUMENT_GROUP_MAPPINGS,
+ "ldap_group_field": ''}
+
+ self.server = Server(host=self.ldap_server, port=389, get_info=self.LDAP_SCHEMA)
+
+ self.connection = Connection(
+ self.server,
+ user=self.base_dn,
+ password=self.base_password,
+ read_only=True,
+ client_strategy=MOCK_SYNC)
+
+ self.connection.strategy.entries_from_json(os.path.abspath(os.path.dirname(__file__)) + '/' + self.LDAP_LDIF_JSON)
+
+ self.connection.bind()
+
+
+ @classmethod
+ def tearDownClass(self):
+ try:
+ frappe.get_doc('LDAP Settings').delete()
+
+ except Exception:
+ pass
+
+ try:
+ # return doc back to user data
+ self.user_ldap_settings.save()
+
+ except Exception:
+ pass
+
+ # Clean-up test users
+ self.clean_test_users()
+
+ # Clear OpenLDAP connection
+ self.connection = None
+
+
+ @mock_ldap_connection
+ def test_mandatory_fields(self):
+
+ mandatory_fields = [
+ 'ldap_server_url',
+ 'ldap_directory_server',
+ 'base_dn',
+ 'password',
+ 'ldap_search_path_user',
+ 'ldap_search_path_group',
+ 'ldap_search_string',
+ 'ldap_email_field',
+ 'ldap_username_field',
+ 'ldap_first_name_field',
+ 'require_trusted_certificate',
+ 'default_role'
+ ] # fields that are required to have ldap functioning need to be mandatory
+
+ for mandatory_field in mandatory_fields:
+
+ localdoc = self.doc.copy()
+ localdoc[mandatory_field] = ''
+
+ try:
+
+ frappe.get_doc(localdoc).save()
+
+ self.fail('Document LDAP Settings field [{0}] is not mandatory'.format(mandatory_field))
+
+ except frappe.exceptions.MandatoryError:
+ pass
+
+ except frappe.exceptions.ValidationError:
+ if mandatory_field == 'ldap_search_string':
+ # additional validation is done on this field, pass in this instance
+ pass
+
+
+ for non_mandatory_field in self.doc: # Ensure remaining fields have not been made mandatory
+
+ if non_mandatory_field == 'doctype' or non_mandatory_field in mandatory_fields:
+ continue
+
+ localdoc = self.doc.copy()
+ localdoc[non_mandatory_field] = ''
+
+ try:
+
+ frappe.get_doc(localdoc).save()
+
+ except frappe.exceptions.MandatoryError:
+ self.fail('Document LDAP Settings field [{0}] should not be mandatory'.format(non_mandatory_field))
+
+
+ @mock_ldap_connection
+ def test_validation_ldap_search_string(self):
+
+ invalid_ldap_search_strings = [
+ '',
+ 'uid={0}',
+ '(uid={0}',
+ 'uid={0})',
+ '(&(objectclass=posixgroup)(uid={0})',
+ '&(objectclass=posixgroup)(uid={0}))',
+ '(uid=no_placeholder)'
+ ] # ldap search string must be enclosed in '()' for ldap search to work for finding user and have the same number of opening and closing brackets.
+
+ for invalid_search_string in invalid_ldap_search_strings:
+
+ localdoc = self.doc.copy()
+ localdoc['ldap_search_string'] = invalid_search_string
+
+ try:
+ frappe.get_doc(localdoc).save()
+
+ self.fail("LDAP search string [{0}] should not validate".format(invalid_search_string))
+
+ except frappe.exceptions.ValidationError:
+ pass
+
+
+ def test_connect_to_ldap(self):
+
+ # setup a clean doc with ldap disabled so no validation occurs (this is tested seperatly)
+ local_doc = self.doc.copy()
+ local_doc['enabled'] = False
+ self.test_class = LDAPSettings(self.doc)
+
+ with mock.patch('ldap3.Server') as ldap3_server_method:
+
+ with mock.patch('ldap3.Connection') as ldap3_connection_method:
+ ldap3_connection_method.return_value = self.connection
+
+ with mock.patch('ldap3.Tls') as ldap3_Tls_method:
+
+ function_return = self.test_class.connect_to_ldap(base_dn=self.base_dn, password=self.base_password)
+
+ args, kwargs = ldap3_connection_method.call_args
+
+ prevent_connection_parameters = {
+ # prevent these parameters for security or lack of the und user from being able to configure
+ 'mode': {
+ 'IP_V4_ONLY': 'Locks the user to IPv4 without frappe providing a way to configure',
+ 'IP_V6_ONLY': 'Locks the user to IPv6 without frappe providing a way to configure'
+ },
+ 'auto_bind': {
+ 'NONE': 'ldap3.Connection must autobind with base_dn',
+ 'NO_TLS': 'ldap3.Connection must have TLS',
+ 'TLS_AFTER_BIND': '[Security] ldap3.Connection TLS bind must occur before bind'
+ }
+ }
+
+ for connection_arg in kwargs:
+
+ if connection_arg in prevent_connection_parameters and \
+ kwargs[connection_arg] in prevent_connection_parameters[connection_arg]:
+
+ self.fail('ldap3.Connection was called with {0}, failed reason: [{1}]'.format(
+ kwargs[connection_arg],
+ prevent_connection_parameters[connection_arg][kwargs[connection_arg]]))
+
+ if local_doc['require_trusted_certificate'] == 'Yes':
+ tls_validate = ssl.CERT_REQUIRED
+ tls_version = ssl.PROTOCOL_TLSv1
+ tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version)
+
+ self.assertTrue(kwargs['auto_bind'] == ldap3.AUTO_BIND_TLS_BEFORE_BIND,
+ 'Security: [ldap3.Connection] autobind TLS before bind with value ldap3.AUTO_BIND_TLS_BEFORE_BIND')
+
+ else:
+ tls_validate = ssl.CERT_NONE
+ tls_version = ssl.PROTOCOL_TLSv1
+ tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version)
+
+ self.assertTrue(kwargs['auto_bind'],
+ 'ldap3.Connection must autobind')
+
+
+ ldap3_Tls_method.assert_called_with(validate=tls_validate, version=tls_version)
+
+ ldap3_server_method.assert_called_with(host=self.doc['ldap_server_url'], tls=tls_configuration)
+
+ self.assertTrue(kwargs['password'] == self.base_password,
+ 'ldap3.Connection password does not match provided password')
+
+ self.assertTrue(kwargs['raise_exceptions'],
+ 'ldap3.Connection must raise exceptions for error handling')
+
+ self.assertTrue(kwargs['user'] == self.base_dn,
+ 'ldap3.Connection user does not match provided user')
+
+ ldap3_connection_method.assert_called_with(server=ldap3_server_method.return_value,
+ auto_bind=True,
+ password=self.base_password,
+ raise_exceptions=True,
+ read_only=True,
+ user=self.base_dn)
+
+ self.assertTrue(type(function_return) is ldap3.core.connection.Connection,
+ 'The return type must be of ldap3.Connection')
+
+ function_return = self.test_class.connect_to_ldap(base_dn=self.base_dn, password=self.base_password, read_only=False)
+
+ args, kwargs = ldap3_connection_method.call_args
+
+ self.assertFalse(kwargs['read_only'], 'connect_to_ldap() read_only parameter supplied as False but does not match the ldap3.Connection() read_only named parameter')
+
+
+
+
+ @mock_ldap_connection
+ def test_get_ldap_client_settings(self):
+
+ result = self.test_class.get_ldap_client_settings()
+
+ self.assertIsInstance(result, dict)
+
+ self.assertTrue(result['enabled'] == self.doc['enabled']) # settings should match doc
+
+ localdoc = self.doc.copy()
+ localdoc['enabled'] = False
+ frappe.get_doc(localdoc).save()
+
+ result = self.test_class.get_ldap_client_settings()
+
+ self.assertFalse(result['enabled']) # must match the edited doc
+
+
+ @mock_ldap_connection
+ def test_update_user_fields(self):
+
+ test_user_data = {
+ 'username': 'posix.user',
+ 'email': 'posix.user1@unit.testing',
+ 'first_name': 'posix',
+ 'middle_name': 'another',
+ 'last_name': 'user',
+ 'phone': '08 1234 5678',
+ 'mobile_no': '0421 123 456'
+ }
+
+ test_user = frappe.get_doc("User", test_user_data['email'])
+
+ self.test_class.update_user_fields(test_user, test_user_data)
+
+ updated_user = frappe.get_doc("User", test_user_data['email'])
+
+ self.assertTrue(updated_user.middle_name == test_user_data['middle_name'])
+ self.assertTrue(updated_user.last_name == test_user_data['last_name'])
+ self.assertTrue(updated_user.phone == test_user_data['phone'])
+ self.assertTrue(updated_user.mobile_no == test_user_data['mobile_no'])
+
+
+ @mock_ldap_connection
+ def test_sync_roles(self):
+
+ if self.TEST_LDAP_SERVER.lower() == 'openldap':
+ test_user_data = {
+ 'posix.user1': ['Users', 'Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'],
+ 'posix.user2': ['Users', 'Group3', 'default_role', 'frappe_default_all', 'frappe_default_guest']
+ }
+
+ elif self.TEST_LDAP_SERVER.lower() == 'active directory':
+ test_user_data = {
+ 'posix.user1': ['Domain Users', 'Domain Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'],
+ 'posix.user2': ['Domain Users', 'Enterprise Administrators', 'default_role', 'frappe_default_all', 'frappe_default_guest']
+ }
+
+
+ role_to_group_map = {
+ self.doc['ldap_groups'][0]['erpnext_role']: self.doc['ldap_groups'][0]['ldap_group'],
+ self.doc['ldap_groups'][1]['erpnext_role']: self.doc['ldap_groups'][1]['ldap_group'],
+ self.doc['ldap_groups'][2]['erpnext_role']: self.doc['ldap_groups'][2]['ldap_group'],
+ 'Newsletter Manager': 'default_role',
+ 'All': 'frappe_default_all',
+ 'Guest': 'frappe_default_guest',
+
+ }
+
+ # re-create user1 to ensure clean
+ frappe.get_doc("User", 'posix.user1@unit.testing').delete()
+ user = frappe.get_doc(self.user1doc)
+ user.insert(ignore_permissions=True)
+
+ for test_user in test_user_data:
+
+ test_user_doc = frappe.get_doc("User", test_user + '@unit.testing')
+ test_user_roles = frappe.get_roles(test_user + '@unit.testing')
+
+ self.assertTrue(len(test_user_roles) == 2,
+ 'User should only be a part of the All and Guest roles') # check default frappe roles
+
+ self.test_class.sync_roles(test_user_doc, test_user_data[test_user]) # update user roles
+
+ frappe.get_doc("User", test_user + '@unit.testing')
+ updated_user_roles = frappe.get_roles(test_user + '@unit.testing')
+
+ self.assertTrue(len(updated_user_roles) == len(test_user_data[test_user]),
+ 'syncing of the user roles failed. {0} != {1} for user {2}'.format(len(updated_user_roles), len(test_user_data[test_user]), test_user))
+
+ for user_role in updated_user_roles: # match each users role mapped to ldap groups
+
+ self.assertTrue(role_to_group_map[user_role] in test_user_data[test_user],
+ 'during sync_roles(), the user was given role {0} which should not have occured'.format(user_role))
+
+ @mock_ldap_connection
+ def test_create_or_update_user(self):
+
+ test_user_data = {
+ 'posix.user1': ['Users', 'Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'],
+ }
+
+ test_user = 'posix.user1'
+
+ frappe.get_doc("User", test_user + '@unit.testing').delete() # remove user 1
+
+ with self.assertRaises(frappe.exceptions.DoesNotExistError): # ensure user deleted so function can be tested
+ frappe.get_doc("User", test_user + '@unit.testing')
+
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.update_user_fields') \
+ as update_user_fields_method:
+
+ update_user_fields_method.return_value = None
+
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.sync_roles') as sync_roles_method:
+
+ sync_roles_method.return_value = None
+
+ # New user
+ self.test_class.create_or_update_user(self.user1doc, test_user_data[test_user])
+
+ self.assertTrue(sync_roles_method.called, 'User roles need to be updated for a new user')
+ self.assertFalse(update_user_fields_method.called,
+ 'User roles are not required to be updated for a new user, this will occur during logon')
+
+
+ # Existing user
+ self.test_class.create_or_update_user(self.user1doc, test_user_data[test_user])
+
+ self.assertTrue(sync_roles_method.called, 'User roles need to be updated for an existing user')
+ self.assertTrue(update_user_fields_method.called, 'User fields need to be updated for an existing user')
+
+
+ @mock_ldap_connection
+ def test_get_ldap_attributes(self):
+
+ method_return = self.test_class.get_ldap_attributes()
+
+ self.assertTrue(type(method_return) is list)
+
+
+
+ @mock_ldap_connection
+ def test_fetch_ldap_groups(self):
+
+ if self.TEST_LDAP_SERVER.lower() == 'openldap':
+ test_users = {
+ 'posix.user': ['Users', 'Administrators'],
+ 'posix.user2': ['Users', 'Group3']
+
+ }
+ elif self.TEST_LDAP_SERVER.lower() == 'active directory':
+ test_users = {
+ 'posix.user': ['Domain Users', 'Domain Administrators'],
+ 'posix.user2': ['Domain Users', 'Enterprise Administrators']
+
+ }
+
+ for test_user in test_users:
+
+ self.connection.search(
+ search_base=self.ldap_user_path,
+ search_filter=self.TEST_LDAP_SEARCH_STRING.format(test_user),
+ attributes=self.test_class.get_ldap_attributes())
+
+ method_return = self.test_class.fetch_ldap_groups(self.connection.entries[0], self.connection)
+
+ self.assertIsInstance(method_return, list)
+ self.assertTrue(len(method_return) == len(test_users[test_user]))
+
+ for returned_group in method_return:
+
+ self.assertTrue(returned_group in test_users[test_user])
+
+
+
+ @mock_ldap_connection
+ def test_authenticate(self):
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.fetch_ldap_groups') as \
+ fetch_ldap_groups_function:
+
+ fetch_ldap_groups_function.return_value = None
+
+ self.assertTrue(self.test_class.authenticate('posix.user', 'posix_user_password'))
+
+ self.assertTrue(fetch_ldap_groups_function.called,
+ 'As part of authentication function fetch_ldap_groups_function needs to be called')
+
+ invalid_users = [
+ {'prefix_posix.user': 'posix_user_password'},
+ {'posix.user_postfix': 'posix_user_password'},
+ {'posix.user': 'posix_user_password_postfix'},
+ {'posix.user': 'prefix_posix_user_password'},
+ {'posix.user': ''},
+ {'': 'posix_user_password'},
+ {'': ''}
+ ] # All invalid users should return 'invalid username or password'
+
+ for username, password in enumerate(invalid_users):
+
+ with self.assertRaises(frappe.exceptions.ValidationError) as display_massage:
+
+ self.test_class.authenticate(username, password)
+
+ self.assertTrue(str(display_massage.exception).lower() == 'invalid username or password',
+ 'invalid credentials passed authentication [user: {0}, password: {1}]'.format(username, password))
+
+
+ @mock_ldap_connection
+ def test_complex_ldap_search_filter(self):
+
+ ldap_search_filters = self.TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING
+
+ for search_filter in ldap_search_filters:
+
+ self.test_class.ldap_search_string = search_filter
+
+ if 'ACCESS:test3' in search_filter: # posix.user does not have str in ldap.description auth should fail
+
+ with self.assertRaises(frappe.exceptions.ValidationError) as display_massage:
+
+ self.test_class.authenticate('posix.user', 'posix_user_password')
+
+ self.assertTrue(str(display_massage.exception).lower() == 'invalid username or password')
+
+ else:
+ self.assertTrue(self.test_class.authenticate('posix.user', 'posix_user_password'))
+
+
+ def test_reset_password(self):
+
+ self.test_class = LDAPSettings(self.doc)
+
+ # Create a clean doc
+ localdoc = self.doc.copy()
+
+ localdoc['enabled'] = False
+ frappe.get_doc(localdoc).save()
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.connect_to_ldap') as connect_to_ldap:
+ connect_to_ldap.return_value = self.connection
+
+ with self.assertRaises(frappe.exceptions.ValidationError) as validation: # Fail if username string used
+ self.test_class.reset_password('posix.user', 'posix_user_password')
+
+ self.assertTrue(str(validation.exception) == 'No LDAP User found for email: posix.user')
+
+ try:
+ self.test_class.reset_password('posix.user1@unit.testing', 'posix_user_password') # Change Password
+
+ except Exception: # An exception from the tested class is ok, as long as the connection to LDAP was made writeable
+ pass
+
+ connect_to_ldap.assert_called_with(self.base_dn, self.base_password, read_only=False)
+
+
+ @mock_ldap_connection
+ def test_convert_ldap_entry_to_dict(self):
+
+ self.connection.search(
+ search_base=self.ldap_user_path,
+ search_filter=self.TEST_LDAP_SEARCH_STRING.format("posix.user"),
+ attributes=self.test_class.get_ldap_attributes())
+
+ test_ldap_entry = self.connection.entries[0]
+
+ method_return = self.test_class.convert_ldap_entry_to_dict(test_ldap_entry)
+
+ self.assertTrue(type(method_return) is dict) # must be dict
+ self.assertTrue(len(method_return) == 6) # there are 6 fields in mock_ldap for use
+
+
+
+class Test_OpenLDAP(LDAP_TestCase, unittest.TestCase):
+ TEST_LDAP_SERVER = 'OpenLDAP'
+ TEST_LDAP_SEARCH_STRING = '(uid={0})'
+ DOCUMENT_GROUP_MAPPINGS = [
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Administrators",
+ "erpnext_role": "System Manager"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Users",
+ "erpnext_role": "Blogger"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Group3",
+ "erpnext_role": "Accounts User"
+ }
+ ]
+ LDAP_USERNAME_FIELD = 'uid'
+ LDAP_SCHEMA = OFFLINE_SLAPD_2_4
+ LDAP_LDIF_JSON = 'test_data_ldif_openldap.json'
+
+ TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = [
+ '(uid={0})',
+ '(&(objectclass=posixaccount)(uid={0}))',
+ '(&(description=*ACCESS:test1*)(uid={0}))', # OpenLDAP has no member of group, use description to filter posix.user has equivilent of AD 'memberOf'
+ '(&(objectclass=posixaccount)(description=*ACCESS:test3*)(uid={0}))' # OpenLDAP has no member of group, use description to filter posix.user doesn't have. equivilent of AD 'memberOf'
+ ]
+
+
+class Test_ActiveDirectory(LDAP_TestCase, unittest.TestCase):
+ TEST_LDAP_SERVER = 'Active Directory'
+ TEST_LDAP_SEARCH_STRING = '(samaccountname={0})'
+ DOCUMENT_GROUP_MAPPINGS = [
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Domain Administrators",
+ "erpnext_role": "System Manager"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Domain Users",
+ "erpnext_role": "Blogger"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Enterprise Administrators",
+ "erpnext_role": "Accounts User"
+ }
+ ]
+ LDAP_USERNAME_FIELD = 'samaccountname'
+ LDAP_SCHEMA = OFFLINE_AD_2012_R2
+ LDAP_LDIF_JSON = 'test_data_ldif_activedirectory.json'
+
+ TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = [
+ '(samaccountname={0})',
+ '(&(objectclass=user)(samaccountname={0}))',
+ '(&(description=*ACCESS:test1*)(samaccountname={0}))', # OpenLDAP has no member of group, use description to filter posix.user has equivilent of AD 'memberOf'
+ '(&(objectclass=user)(description=*ACCESS:test3*)(samaccountname={0}))' # OpenLDAP has no member of group, use description to filter posix.user doesn't have. equivilent of AD 'memberOf'
+ ]
-class TestLDAPSettings(unittest.TestCase):
- pass
diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json
index db96304207..4167858db2 100644
--- a/frappe/integrations/workspace/integrations/integrations.json
+++ b/frappe/integrations/workspace/integrations/integrations.json
@@ -1,22 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Backup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Google Services\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Authentication\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Payments\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}]",
"creation": "2020-03-02 15:16:18.714190",
"developer_mode_only": 0,
- "disable_user_customization": 1,
+ "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "integration",
"idx": 0,
- "is_standard": 1,
+ "is_default": 0,
+ "is_standard": 0,
"label": "Integrations",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Backup",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dropbox Settings",
+ "link_count": 0,
"link_to": "Dropbox Settings",
"link_type": "DocType",
"onboard": 0,
@@ -35,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "S3 Backup Settings",
+ "link_count": 0,
"link_to": "S3 Backup Settings",
"link_type": "DocType",
"onboard": 0,
@@ -45,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Drive",
+ "link_count": 0,
"link_to": "Google Drive",
"link_type": "DocType",
"onboard": 0,
@@ -54,6 +62,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Services",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -62,6 +71,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Settings",
+ "link_count": 0,
"link_to": "Google Settings",
"link_type": "DocType",
"onboard": 0,
@@ -72,6 +82,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Contacts",
+ "link_count": 0,
"link_to": "Google Contacts",
"link_type": "DocType",
"onboard": 0,
@@ -82,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Calendar",
+ "link_count": 0,
"link_to": "Google Calendar",
"link_type": "DocType",
"onboard": 0,
@@ -92,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Drive",
+ "link_count": 0,
"link_to": "Google Drive",
"link_type": "DocType",
"onboard": 0,
@@ -101,6 +114,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Authentication",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -109,6 +123,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Social Login Key",
+ "link_count": 0,
"link_to": "Social Login Key",
"link_type": "DocType",
"onboard": 0,
@@ -119,6 +134,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "LDAP Settings",
+ "link_count": 0,
"link_to": "LDAP Settings",
"link_type": "DocType",
"onboard": 0,
@@ -129,6 +145,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "OAuth Client",
+ "link_count": 0,
"link_to": "OAuth Client",
"link_type": "DocType",
"onboard": 0,
@@ -139,6 +156,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "OAuth Provider Settings",
+ "link_count": 0,
"link_to": "OAuth Provider Settings",
"link_type": "DocType",
"onboard": 0,
@@ -148,6 +166,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Payments",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -156,6 +175,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Braintree Settings",
+ "link_count": 0,
"link_to": "Braintree Settings",
"link_type": "DocType",
"onboard": 0,
@@ -166,6 +186,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "PayPal Settings",
+ "link_count": 0,
"link_to": "PayPal Settings",
"link_type": "DocType",
"onboard": 0,
@@ -176,6 +197,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Razorpay Settings",
+ "link_count": 0,
"link_to": "Razorpay Settings",
"link_type": "DocType",
"onboard": 0,
@@ -186,6 +208,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Stripe Settings",
+ "link_count": 0,
"link_to": "Stripe Settings",
"link_type": "DocType",
"onboard": 0,
@@ -196,6 +219,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Paytm Settings",
+ "link_count": 0,
"link_to": "Paytm Settings",
"link_type": "DocType",
"onboard": 0,
@@ -205,6 +229,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Settings",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -213,6 +238,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Webhook",
+ "link_count": 0,
"link_to": "Webhook",
"link_type": "DocType",
"onboard": 0,
@@ -223,38 +249,37 @@
"hidden": 0,
"is_query_report": 0,
"label": "Slack Webhook URL",
+ "link_count": 0,
"link_to": "Slack Webhook URL",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Twilio Settings",
- "link_to": "Twilio Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Settings",
+ "link_count": 0,
"link_to": "SMS Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:39.706680",
+ "modified": "2021-08-05 12:16:00.355267",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Integrations",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
- "shortcuts": []
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 15,
+ "shortcuts": [],
+ "title": "Integrations"
}
\ No newline at end of file
diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py
index 362f4c79b3..1acd7ee670 100644
--- a/frappe/model/__init__.py
+++ b/frappe/model/__init__.py
@@ -72,7 +72,8 @@ data_field_options = (
'Email',
'Name',
'Phone',
- 'URL'
+ 'URL',
+ 'Barcode'
)
default_fields = (
diff --git a/frappe/patches.txt b/frappe/patches.txt
index 989b13e049..87919b0247 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -182,3 +182,4 @@ frappe.patches.v13_0.jinja_hook
frappe.patches.v13_0.update_notification_channel_if_empty
frappe.patches.v14_0.drop_data_import_legacy
frappe.patches.v14_0.rename_cancelled_documents
+frappe.patches.v14_0.update_workspace2
diff --git a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py
index 90766b5f64..5aaadd00e8 100644
--- a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py
+++ b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py
@@ -4,19 +4,17 @@ from frappe.query_builder.functions import GroupConcat, Coalesce
def execute():
frappe.reload_doc("desk", "doctype", "todo")
- ToDo = frappe.qb.Table("ToDo")
+ ToDo = frappe.qb.DocType("ToDo")
assignees = GroupConcat("owner").distinct().as_("assignees")
- query = (
+ assignments = (
frappe.qb.from_(ToDo)
.select(ToDo.name, ToDo.reference_type, assignees)
.where(Coalesce(ToDo.reference_type, "") != "")
.where(Coalesce(ToDo.reference_name, "") != "")
.where(ToDo.status != "Cancelled")
.groupby(ToDo.reference_type, ToDo.reference_name)
- )
-
- assignments = frappe.db.sql(query, as_dict=True)
+ ).run(as_dict=True)
for doc in assignments:
assignments = doc.assignees.split(",")
diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py
new file mode 100644
index 0000000000..2d7eb4cc76
--- /dev/null
+++ b/frappe/patches/v14_0/update_workspace2.py
@@ -0,0 +1,69 @@
+import frappe
+import json
+from frappe import _
+
+def execute():
+ frappe.reload_doc('desk', 'doctype', 'workspace', force=True)
+ order_by = "pin_to_top desc, pin_to_bottom asc, name asc"
+ for seq, wspace in enumerate(frappe.get_all('Workspace', order_by=order_by)):
+ doc = frappe.get_doc('Workspace', wspace.name)
+ content = create_content(doc)
+ update_wspace(doc, seq, content)
+ frappe.db.commit()
+
+def create_content(doc):
+ content = []
+ if doc.onboarding:
+ content.append({"type":"onboarding","data":{"onboarding_name":doc.onboarding,"col":12}})
+ if doc.charts:
+ invalid_links = []
+ for c in doc.charts:
+ if c.get_invalid_links()[0]:
+ invalid_links.append(c)
+ else:
+ content.append({"type":"chart","data":{"chart_name":c.label,"col":12}})
+ for l in invalid_links:
+ del doc.charts[doc.charts.index(l)]
+ if doc.shortcuts:
+ invalid_links = []
+ if doc.charts:
+ content.append({"type":"spacer","data":{"col":12}})
+ content.append({"type":"header","data":{"text":doc.shortcuts_label or _("Your Shortcuts"),"level":4,"col":12}})
+ for s in doc.shortcuts:
+ if s.get_invalid_links()[0]:
+ invalid_links.append(s)
+ else:
+ content.append({"type":"shortcut","data":{"shortcut_name":s.label,"col":4}})
+ for l in invalid_links:
+ del doc.shortcuts[doc.shortcuts.index(l)]
+ if doc.links:
+ invalid_links = []
+ content.append({"type":"spacer","data":{"col":12}})
+ content.append({"type":"header","data":{"text":doc.cards_label or _("Reports & Masters"),"level":4,"col":12}})
+ for l in doc.links:
+ if l.type == 'Card Break':
+ content.append({"type":"card","data":{"card_name":l.label,"col":4}})
+ if l.get_invalid_links()[0]:
+ invalid_links.append(l)
+ for l in invalid_links:
+ del doc.links[doc.links.index(l)]
+ return content
+
+def update_wspace(doc, seq, content):
+ if not doc.is_standard and not doc.public:
+ doc.sequence_id = seq + 1
+ doc.content = json.dumps(content)
+ doc.public = 0
+ doc.title = doc.extends
+ doc.extends = ''
+ doc.category = ''
+ doc.onboarding = ''
+ doc.extends_another_page = 0
+ doc.is_default = 0
+ doc.is_standard = 0
+ doc.developer_mode_only = 0
+ doc.disable_user_customization = 0
+ doc.pin_to_top = 0
+ doc.pin_to_bottom = 0
+ doc.hide_custom = 0
+ doc.save(ignore_permissions=True)
\ No newline at end of file
diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg
index 6108daa938..f216374526 100644
--- a/frappe/public/icons/timeless/symbol-defs.svg
+++ b/frappe/public/icons/timeless/symbol-defs.svg
@@ -57,6 +57,9 @@