Merge remote-tracking branch 'upstream/develop' into fix-note-2

This commit is contained in:
barredterra 2023-01-17 19:19:12 +01:00
commit df2b1ff456
136 changed files with 1697 additions and 1019 deletions

View file

@ -13,3 +13,9 @@ charset = utf-8
indent_style = tab
indent_size = 4
max_line_length = 99
# JSON files - mostly doctype schema files
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 2

View file

@ -69,6 +69,7 @@ ignore =
F841,
E713,
E712,
B028,
max-line-length = 200
exclude=,test_*.py

View file

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

View file

@ -4,8 +4,10 @@ import re
import shlex
import subprocess
import sys
import time
import urllib.request
from functools import lru_cache
from urllib.error import HTTPError
@lru_cache(maxsize=None)
@ -15,11 +17,30 @@ def fetch_pr_data(pr_number, repo, endpoint=""):
if endpoint:
api_url += f"/{endpoint}"
req = urllib.request.Request(api_url)
res = urllib.request.urlopen(req)
res = req(api_url)
return json.loads(res.read().decode("utf8"))
def req(url):
"Simple resilient request call to handle rate limits."
headers = None
token = os.environ.get("GITHUB_TOKEN")
if token:
headers = {"authorization": f"Bearer {token}"}
retries = 0
while True:
try:
req = urllib.request.Request(url, headers=headers)
return urllib.request.urlopen(req)
except HTTPError as exc:
if exc.code == 403 and retries < 5:
retries += 1
time.sleep(retries)
continue
raise
def get_files_list(pr_number, repo="frappe/frappe"):
return [change["filename"] for change in fetch_pr_data(pr_number, repo, "files")]

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 16
node-version: 18
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save

View file

@ -9,6 +9,7 @@ concurrency:
cancel-in-progress: true
permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette
contents: read
jobs:
@ -31,6 +32,7 @@ jobs:
TYPE: "server"
PR_NUMBER: ${{ github.event.number }}
REPO_NAME: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
name: Patch

View file

@ -12,6 +12,7 @@ concurrency:
permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette
contents: read
jobs:
@ -34,6 +35,7 @@ jobs:
TYPE: "server"
PR_NUMBER: ${{ github.event.number }}
REPO_NAME: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
name: Unit Tests

View file

@ -11,6 +11,7 @@ concurrency:
cancel-in-progress: true
permissions:
# Do not change this as GITHUB_TOKEN is being used by roulette
contents: read
jobs:
@ -33,6 +34,7 @@ jobs:
TYPE: "ui"
PR_NUMBER: ${{ github.event.number }}
REPO_NAME: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
test:
runs-on: ubuntu-latest

View file

@ -1,7 +1,7 @@
# Security Policy
The Frappe team and community take security issues in the Frappe Framework seriously. To report a security issue, fill out the form at [https://erpnext.com/security/report](https://erpnext.com/security/report).
The Frappe team and community take security issues in the Frappe Framework seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
You can help us make Frappe and consequently all Frappe dependent apps like [ERPNext](https://erpnext.com) more secure by following the [Reporting guidelines](https://erpnext.com/security).
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.

View file

@ -9,7 +9,7 @@ coverage:
target: auto
threshold: 0.5%
flags:
- server-mariadb
- server
patch:
default:
target: 85%
@ -17,7 +17,7 @@ coverage:
only_pulls: true
if_ci_failed: ignore
flags:
- server-mariadb
- server
comment:
layout: "diff, flags"
@ -25,11 +25,7 @@ comment:
show_critical_paths: true
flags:
server-mariadb:
paths:
- "**/*.py"
carryforward: true
server-postgres:
server:
paths:
- "**/*.py"
carryforward: true

View file

@ -19,7 +19,7 @@ context("Date Control", () => {
});
}
it("Selecting a date from the datepicker", () => {
it("Selecting a date from the datepicker & check prev & next button", () => {
cy.clear_dialogs();
cy.clear_datepickers();
@ -39,13 +39,7 @@ context("Date Control", () => {
// Verify if the selected date is set the date field
cy.window().its("cur_dialog.fields_dict.date.value").should("be.equal", "2020-01-15");
});
it("Checking next and previous button", () => {
cy.clear_dialogs();
cy.clear_datepickers();
get_dialog({ default: "2020-01-15" }).as("dialog");
cy.get_field("date", "Date").click();
//Clicking on the next button in the datepicker

View file

@ -87,7 +87,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "Options is required");
cy.hide_dialog();
cy.get(first_field).click();
cy.get(first_field).click({ force: true });
cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input")
.click()
@ -114,7 +114,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "In List View");
cy.hide_dialog();
cy.get(first_field).click();
cy.get(first_field).click({ force: true });
cy.get(".sidebar-container .field label .label-area").contains("In List View").click();
// validate In Global Search
@ -244,7 +244,7 @@ context("Form Builder", () => {
let first_field =
".tab-content.active .section-columns-container:first .column:first .field:first";
cy.get(".fields-container .field[title='Check']").drag(first_field, {
cy.get(".fields-container .field[title='Data']").drag(first_field, {
target: { x: 100, y: 10 },
});

View file

@ -1,6 +1,6 @@
context("Kanban Board", () => {
before(() => {
cy.login();
cy.login("frappe@example.com");
cy.visit("/app");
});
@ -96,4 +96,36 @@ context("Kanban Board", () => {
.first()
.should("not.contain", "ID:");
});
it("Checks if Kanban Board edits are blocked for non-System Manager and non-owner of the Board", () => {
// create admin kanban board
cy.call("frappe.tests.ui_test_helpers.create_todo", { description: "Frappe User ToDo" });
cy.switch_to_user("Administrator");
cy.call("frappe.tests.ui_test_helpers.create_admin_kanban");
// remove sys manager
cy.remove_role("frappe@example.com", "System Manager");
cy.switch_to_user("frappe@example.com");
cy.visit("/app/todo/view/kanban/Admin Kanban");
// Menu button should be hidden (dropdown for 'Save Filters' and 'Delete Kanban Board')
cy.get(".no-list-sidebar .menu-btn-group .btn-default[data-original-title='Menu']").should(
"have.length",
0
);
// Kanban Columns should be visible (read-only)
cy.get(".kanban .kanban-column").should("have.length", 2);
// User should be able to add card (has access to ToDo)
cy.get(".kanban .add-card").should("have.length", 2);
// Column actions should be hidden (dropdown for 'Archive' and indicators)
cy.get(".kanban .column-options").should("have.length", 0);
cy.add_role("frappe@example.com", "System Manager");
});
after(() => {
cy.call("logout");
});
});

View file

@ -1,16 +1,7 @@
context("Permissions API", () => {
before(() => {
cy.visit("/login");
cy.login("Administrator");
cy.call("frappe.tests.ui_test_helpers.add_remove_role", {
action: "remove",
user: "frappe@example.com",
role: "System Manager",
});
cy.call("logout");
cy.login("frappe@example.com");
cy.remove_role("frappe@example.com", "System Manager");
cy.visit("/app");
});
@ -44,14 +35,7 @@ context("Permissions API", () => {
});
after(() => {
cy.call("logout");
cy.login("Administrator");
cy.call("frappe.tests.ui_test_helpers.add_remove_role", {
action: "add",
user: "frappe@example.com",
role: "System Manager",
});
cy.add_role("frappe@example.com", "System Manager");
cy.call("logout");
});
});

View file

@ -190,6 +190,48 @@ context("Workspace 2.0", () => {
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
});
it("Hide/Unhide Workspaces", () => {
// hide
cy.intercept({
method: "POST",
url: "api/method/frappe.desk.doctype.workspace.workspace.hide_page",
}).as("hide_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="Duplicate Page"]')
.find(".sidebar-item-control .setting-btn")
.click();
cy.get('.sidebar-item-container[item-name="Duplicate Page"]')
.find('.dropdown-item[title="Hide Workspace"]')
.click({ force: true });
cy.wait(300);
cy.get('.standard-actions .btn-secondary[data-label="Discard"]').click();
cy.get('.sidebar-item-container[item-name="Duplicate Page"]').should("not.be.visible");
cy.wait("@hide_page");
// unhide
cy.intercept({
method: "POST",
url: "api/method/frappe.desk.doctype.workspace.workspace.unhide_page",
}).as("unhide_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="Duplicate Page"]')
.find('[title="Unhide Workspace"]')
.click({ force: true });
cy.wait(300);
cy.get('.standard-actions .btn-secondary[data-label="Discard"]').click();
cy.get('.sidebar-item-container[item-name="Duplicate Page"]').should("be.visible");
cy.wait("@unhide_page");
});
it("Delete Duplicate Page", () => {
cy.intercept({
method: "POST",

View file

@ -371,6 +371,45 @@ Cypress.Commands.add("update_doc", (doctype, docname, args) => {
});
});
Cypress.Commands.add("switch_to_user", (user) => {
cy.call("logout");
cy.login(user);
});
Cypress.Commands.add("add_role", (user, role) => {
cy.window()
.its("frappe")
.then((frappe) => {
const session_user = frappe.session.user;
add_remove_role("add", user, role, session_user);
});
});
Cypress.Commands.add("remove_role", (user, role) => {
cy.window()
.its("frappe")
.then((frappe) => {
const session_user = frappe.session.user;
add_remove_role("remove", user, role, session_user);
});
});
const add_remove_role = (action, user, role, session_user) => {
if (session_user !== "Administrator") {
cy.switch_to_user("Administrator");
}
cy.call("frappe.tests.ui_test_helpers.add_remove_role", {
action: action,
user: user,
role: role,
});
if (session_user !== "Administrator") {
cy.switch_to_user(session_user);
}
};
Cypress.Commands.add("open_list_filter", () => {
cy.get(".filter-section .filter-button").click();
cy.wait(300);

View file

@ -770,7 +770,12 @@ def is_whitelisted(method):
is_guest = session["user"] == "Guest"
if method not in whitelisted or is_guest and method not in guest_methods:
throw(_("Not permitted"), PermissionError)
summary = _("You are not permitted to access this resource.")
detail = _("Function {0} is not whitelisted.").format(
bold(f"{method.__module__}.{method.__name__}")
)
msg = f"<details><summary>{summary}</summary>{detail}</details>"
throw(msg, PermissionError, title="Method Not Allowed")
if is_guest and method not in xss_safe_methods:
# strictly sanitize form_dict
@ -1399,23 +1404,37 @@ def get_all_apps(with_internal_apps=True, sites_path=None):
@request_cache
def get_installed_apps(sort=False, frappe_last=False):
"""Get list of installed apps in current site."""
def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False):
"""
Get list of installed apps in current site.
:param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt
:param frappe_last: [DEPRECATED] Keep frappe last. Do not use this, reverse the app list instead.
:param ensure_on_bench: Only return apps that are present on bench.
"""
from frappe.utils.deprecations import deprecation_warning
if getattr(flags, "in_install_db", True):
return []
if not db:
connect()
if not local.all_apps:
local.all_apps = cache().get_value("all_apps", get_all_apps)
installed = json.loads(db.get_global("installed_apps") or "[]")
if sort:
if not local.all_apps:
local.all_apps = cache().get_value("all_apps", get_all_apps)
deprecation_warning("`sort` argument is deprecated and will be removed in v15.")
installed = [app for app in local.all_apps if app in installed]
if _ensure_on_bench:
all_apps = cache().get_value("all_apps", get_all_apps)
installed = [app for app in installed if app in all_apps]
if frappe_last:
deprecation_warning("`frappe_last` argument is deprecated and will be removed in v15.")
if "frappe" in installed:
installed.remove("frappe")
installed.append("frappe")
@ -1445,7 +1464,7 @@ def _load_app_hooks(app_name: str | None = None):
import types
hooks = {}
apps = [app_name] if app_name else get_installed_apps(sort=True)
apps = [app_name] if app_name else get_installed_apps(_ensure_on_bench=True)
for app in apps:
try:
@ -1855,9 +1874,6 @@ def get_list(doctype, *args, **kwargs):
# filter as a list of lists
frappe.get_list("ToDo", fields="*", filters = [["modified", ">", "2014-01-01"]])
# filter as a list of dicts
frappe.get_list("ToDo", fields="*", filters = {"description": ("like", "test%")})
"""
import frappe.model.db_query
@ -1882,9 +1898,6 @@ def get_all(doctype, *args, **kwargs):
# filter as a list of lists
frappe.get_all("ToDo", fields=["*"], filters = [["modified", ">", "2014-01-01"]])
# filter as a list of dicts
frappe.get_all("ToDo", fields=["*"], filters = {"description": ("like", "test%")})
"""
kwargs["ignore_permissions"] = True
if not "limit_page_length" in kwargs:
@ -1907,7 +1920,7 @@ def get_value(*args, **kwargs):
return db.get_value(*args, **kwargs)
def as_json(obj: dict | list, indent=1, separators=None) -> str:
def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> str:
from frappe.utils.response import json_handler
if separators is None:
@ -1915,13 +1928,24 @@ def as_json(obj: dict | list, indent=1, separators=None) -> str:
try:
return json.dumps(
obj, indent=indent, sort_keys=True, default=json_handler, separators=separators
obj,
indent=indent,
sort_keys=True,
default=json_handler,
separators=separators,
ensure_ascii=ensure_ascii,
)
except TypeError:
# this would break in case the keys are not all os "str" type - as defined in the JSON
# adding this to ensure keys are sorted (expected behaviour)
sorted_obj = dict(sorted(obj.items(), key=lambda kv: str(kv[0])))
return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=separators)
return json.dumps(
sorted_obj,
indent=indent,
default=json_handler,
separators=separators,
ensure_ascii=ensure_ascii,
)
def are_emails_muted():

View file

@ -303,6 +303,17 @@ def has_permission(doctype, docname, perm_type="read"):
return {"has_permission": frappe.has_permission(doctype, perm_type.lower(), docname)}
@frappe.whitelist()
def get_doc_permissions(doctype, docname):
"""Returns an evaluated document permissions dict like `{"read":1, "write":1}`
:param doctype: DocType of the document to be evaluated
:param docname: `name` of the document to be evaluated
"""
doc = frappe.get_doc(doctype, docname)
return {"permissions": frappe.permissions.get_doc_permissions(doc)}
@frappe.whitelist()
def get_password(doctype, name, fieldname):
"""Return a password type property. Only applicable for System Managers

View file

@ -109,7 +109,8 @@ def new_site(
"--with-public-files", help="Restores the public files of the site, given path to its tar file"
)
@click.option(
"--with-private-files", help="Restores the private files of the site, given path to its tar file"
"--with-private-files",
help="Restores the private files of the site, given path to its tar file",
)
@click.option(
"--force",
@ -191,7 +192,8 @@ def _restore(
fg="red",
)
click.secho(
"Use `bench partial-restore` to restore a partial backup to an existing site.", fg="yellow"
"Use `bench partial-restore` to restore a partial backup to an existing site.",
fg="yellow",
)
_backup.decryption_rollback()
sys.exit(1)
@ -222,7 +224,8 @@ def _restore(
fg="red",
)
click.secho(
"Use `bench partial-restore` to restore a partial backup to an existing site.", fg="yellow"
"Use `bench partial-restore` to restore a partial backup to an existing site.",
fg="yellow",
)
_backup.decryption_rollback()
sys.exit(1)
@ -324,7 +327,8 @@ def partial_restore(context, sql_file_path, verbose, encryption_key=None):
# Check for full backup file
if "Partial Backup" not in header:
click.secho(
"Full backup file detected.Use `bench restore` to restore a Frappe Site.", fg="red"
"Full backup file detected.Use `bench restore` to restore a Frappe Site.",
fg="red",
)
_backup.decryption_rollback()
sys.exit(1)
@ -355,7 +359,8 @@ def partial_restore(context, sql_file_path, verbose, encryption_key=None):
# Check for Full backup file.
if "Partial Backup" not in header:
click.secho(
"Full Backup file detected.Use `bench restore` to restore a Frappe Site.", fg="red"
"Full Backup file detected.Use `bench restore` to restore a Frappe Site.",
fg="red",
)
_backup.decryption_rollback()
sys.exit(1)
@ -391,7 +396,12 @@ def reinstall(
def _reinstall(
site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False
site,
admin_password=None,
db_root_username=None,
db_root_password=None,
yes=False,
verbose=False,
):
from frappe.installer import _new_site
from frappe.utils.synchronization import filelock
@ -719,7 +729,10 @@ def use(site, sites_path="."):
@click.option("--backup-path-private-files", default=None, help="Set path for saving private file")
@click.option("--backup-path-conf", default=None, help="Set path for saving config file")
@click.option(
"--ignore-backup-conf", default=False, is_flag=True, help="Ignore excludes/includes set in config"
"--ignore-backup-conf",
default=False,
is_flag=True,
help="Ignore excludes/includes set in config",
)
@click.option("--verbose", default=False, is_flag=True, help="Add verbosity")
@click.option("--compress", default=False, is_flag=True, help="Compress private and public files")
@ -774,7 +787,8 @@ def backup(
continue
if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key:
click.secho(
"Backup encryption is turned on. Please note the backup encryption key.", fg="yellow"
"Backup encryption is turned on. Please note the backup encryption key.",
fg="yellow",
)
odb.print_summary()
@ -1120,14 +1134,31 @@ def stop_recording(context):
@click.option(
"--bind-tls", is_flag=True, default=False, help="Returns a reference to the https tunnel."
)
@click.option(
"--use-default-authtoken",
is_flag=True,
default=False,
help="Use the auth token present in ngrok's config.",
)
@pass_context
def start_ngrok(context, bind_tls):
def start_ngrok(context, bind_tls, use_default_authtoken):
"""Start a ngrok tunnel to your local development server."""
from pyngrok import ngrok
site = get_site(context)
frappe.init(site=site)
ngrok_authtoken = frappe.conf.ngrok_authtoken
if not use_default_authtoken:
if not ngrok_authtoken:
click.echo(
f"\n{click.style('ngrok_authtoken', fg='yellow')} not found in site config.\n"
"Please register for a free ngrok account at: https://dashboard.ngrok.com/signup and place the obtained authtoken in the site config.",
)
sys.exit(1)
ngrok.set_auth_token(ngrok_authtoken)
port = frappe.conf.http_port or frappe.conf.webserver_port
tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls)
print(f"Public URL: {tunnel.public_url}")

View file

@ -13,7 +13,7 @@ def unzip_file(name: str):
@frappe.whitelist()
def get_attached_images(doctype: str, names: list[str]) -> frappe._dict:
def get_attached_images(doctype: str, names: list[str] | str) -> frappe._dict:
"""get list of image urls attached in form
returns {name: ['image.jpg', 'image.png']}"""

View file

@ -260,7 +260,7 @@ def export_json(doctype, path, filters=None, or_filters=None, name=None, order_b
path = os.path.join("..", path)
with open(path, "w") as outfile:
outfile.write(frappe.as_json(out))
outfile.write(frappe.as_json(out, ensure_ascii=False))
def export_csv(doctype, path):

View file

@ -546,15 +546,16 @@
{
"depends_on": "eval:!in_list([\"Tab Break\", \"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)",
"fieldname": "documentation_url",
"fieldtype": "Small Text",
"label": "Documentation URL"
"fieldtype": "Data",
"label": "Documentation URL",
"options": "URL"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-11-17 14:14:39.404696",
"modified": "2023-01-11 20:46:43.164926",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -29,3 +29,12 @@ class DocField(Document):
if self.fieldtype == "Select":
options = self.options or ""
return [d for d in options.split("\n") if d]
def __repr__(self):
unsaved = "unsaved" if not self.name else ""
doctype = self.__class__.__name__
docstatus = f" docstatus={self.docstatus}" if self.docstatus else ""
parent = f" parent={self.parent}" if getattr(self, "parent", None) else ""
return f"<{self.fieldtype}{doctype}: {self.fieldname}{docstatus}{parent}{unsaved}>"

View file

@ -195,10 +195,12 @@ class DocType(Document):
def set_default_in_list_view(self):
"""Set default in-list-view for first 4 mandatory fields"""
not_allowed_in_list_view = get_fields_not_allowed_in_list_view(self.meta)
if not [d.fieldname for d in self.fields if d.in_list_view]:
cnt = 0
for d in self.fields:
if d.reqd and not d.hidden and not d.fieldtype in table_fields:
if d.reqd and not d.hidden and not d.fieldtype in not_allowed_in_list_view:
d.in_list_view = 1
cnt += 1
if cnt == 4:
@ -1446,10 +1448,7 @@ def validate_fields(meta):
fields = meta.get("fields")
fieldname_list = [d.fieldname for d in fields]
not_allowed_in_list_view = list(copy.copy(no_value_fields))
not_allowed_in_list_view.append("Attach Image")
if meta.istable:
not_allowed_in_list_view.remove("Button")
not_allowed_in_list_view = get_fields_not_allowed_in_list_view(meta)
for d in fields:
if not d.permlevel:
@ -1490,6 +1489,14 @@ def validate_fields(meta):
check_image_field(meta)
def get_fields_not_allowed_in_list_view(meta) -> list[str]:
not_allowed_in_list_view = list(copy.copy(no_value_fields))
not_allowed_in_list_view.append("Attach Image")
if meta.istable:
not_allowed_in_list_view.remove("Button")
return not_allowed_in_list_view
def validate_permissions_for_doctype(doctype, for_remove=False, alert=False):
"""Validates if permissions are set correctly."""
doctype = frappe.get_doc("DocType", doctype)

View file

@ -722,6 +722,28 @@ class TestDocType(FrappeTestCase):
self.assertEqual(frappe.get_meta(doctype).get_field(field).default, "DELETETHIS")
frappe.delete_doc("DocType", doctype)
def test_not_in_list_view_for_not_allowed_mandatory_field(self):
doctype = new_doctype(
fields=[
{
"fieldname": "cover_image",
"fieldtype": "Attach Image",
"label": "Cover Image",
"reqd": 1, # mandatory
},
{
"fieldname": "book_name",
"fieldtype": "Data",
"label": "Book Name",
"reqd": 1, # mandatory
},
],
).insert()
self.assertFalse(doctype.fields[0].in_list_view)
self.assertTrue(doctype.fields[1].in_list_view)
frappe.delete_doc("DocType", doctype.name)
def new_doctype(
name: str | None = None,
@ -759,8 +781,7 @@ def new_doctype(
}
)
if fields:
for f in fields:
doc.append("fields", f)
if fields and len(fields) > 0:
doc.set("fields", fields)
return doc

View file

@ -85,8 +85,8 @@ class Domain(Document):
def set_default_portal_role(self):
"""Set default portal role based on domain"""
if self.data.get("default_portal_role"):
frappe.db.set_value(
"Portal Settings", None, "default_role", self.data.get("default_portal_role")
frappe.db.set_single_value(
"Portal Settings", "default_role", self.data.get("default_portal_role")
)
def setup_properties(self):

View file

@ -24,6 +24,8 @@ frappe.ui.form.on("File", {
preview_file: function (frm) {
let $preview = "";
let file_name = frm.doc.file_name.split("?")[0];
let file_extension = file_name.split(".").pop()?.toLowerCase();
if (frappe.utils.is_image_file(frm.doc.file_url)) {
$preview = $(`<div class="img_preview">
@ -40,7 +42,7 @@ frappe.ui.form.on("File", {
${__("Your browser does not support the video element.")}
</video>
</div>`);
} else if (frm.doc.file_name.split("?")[0].endsWith(".pdf")) {
} else if (file_extension === "pdf") {
$preview = $(`<div class="img_preview">
<object style="background:#323639;" width="100%">
<embed
@ -51,7 +53,7 @@ frappe.ui.form.on("File", {
>
</object>
</div>`);
} else if (frm.doc.file_name.split("?")[0].endsWith(".mp3")) {
} else if (file_extension === "mp3") {
$preview = $(`<div class="img_preview">
<audio width="480" height="60" controls>
<source src="${frm.doc.file_url}" type="audio/mpeg">

View file

@ -42,27 +42,26 @@ def _supports_log_clearing(doctype: str) -> bool:
class LogSettings(Document):
def validate(self):
self.validate_supported_doctypes()
self.validate_duplicates()
self._remove_unsupported_doctypes()
self._deduplicate_entries()
self.add_default_logtypes()
def validate_supported_doctypes(self):
for entry in self.logs_to_clear:
def _remove_unsupported_doctypes(self):
for entry in list(self.logs_to_clear):
if _supports_log_clearing(entry.ref_doctype):
continue
msg = _("{} does not support automated log clearing.").format(frappe.bold(entry.ref_doctype))
if frappe.conf.developer_mode:
msg += "<br>" + _("Implement `clear_old_logs` method to enable auto error clearing.")
frappe.throw(msg, title=_("DocType not supported by Log Settings."))
frappe.msgprint(msg, title=_("DocType not supported by Log Settings."))
self.remove(entry)
def validate_duplicates(self):
def _deduplicate_entries(self):
seen = set()
for entry in self.logs_to_clear:
for entry in list(self.logs_to_clear):
if entry.ref_doctype in seen:
frappe.throw(
_("{} appears more than once in configured log doctypes.").format(entry.ref_doctype)
)
self.remove(entry)
seen.add(entry.ref_doctype)
def add_default_logtypes(self):

View file

@ -50,11 +50,14 @@ class SystemSettings(Document):
social_login_enabled = frappe.db.exists("Social Login Key", {"enable_social_login": 1})
ldap_enabled = frappe.db.get_single_value("LDAP Settings", "enabled")
login_with_email_link_enabled = frappe.db.get_single_value(
"System Settings", "login_with_email_link"
)
if not (social_login_enabled or ldap_enabled):
if not (social_login_enabled or ldap_enabled or login_with_email_link_enabled):
frappe.throw(
_(
"Please enable atleast one Social Login Key or LDAP before disabling username/password based login."
"Please enable atleast one Social Login Key or LDAP or Login With Email Link before disabling username/password based login."
)
)

View file

@ -27,9 +27,9 @@ test_records = frappe.get_test_records("User")
class TestUser(FrappeTestCase):
def tearDown(self):
# disable password strength test
frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0)
frappe.db.set_value("System Settings", "System Settings", "minimum_password_score", "")
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 3)
frappe.db.set_single_value("System Settings", "enable_password_policy", 0)
frappe.db.set_single_value("System Settings", "minimum_password_score", "")
frappe.db.set_single_value("System Settings", "password_reset_limit", 3)
frappe.set_user("Administrator")
def test_user_type(self):
@ -111,7 +111,7 @@ class TestUser(FrappeTestCase):
self.assertEqual(frappe.db.get_value("User", "xxxtest@example.com"), None)
frappe.db.set_value("Website Settings", "Website Settings", "_test", "_test_val")
frappe.db.set_single_value("Website Settings", "_test", "_test_val")
self.assertEqual(frappe.db.get_value("Website Settings", None, "_test"), "_test_val")
self.assertEqual(
frappe.db.get_value("Website Settings", "Website Settings", "_test"), "_test_val"
@ -179,15 +179,15 @@ class TestUser(FrappeTestCase):
def test_password_strength(self):
# Test Password without Password Strength Policy
frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0)
frappe.db.set_single_value("System Settings", "enable_password_policy", 0)
# password policy is disabled, test_password_strength should be ignored
result = test_password_strength("test_password")
self.assertFalse(result.get("feedback", None))
# Test Password with Password Strenth Policy Set
frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 1)
frappe.db.set_value("System Settings", "System Settings", "minimum_password_score", 2)
frappe.db.set_single_value("System Settings", "enable_password_policy", 1)
frappe.db.set_single_value("System Settings", "minimum_password_score", 2)
# Score 1; should now fail
result = test_password_strength("bee2ve")
@ -275,7 +275,7 @@ class TestUser(FrappeTestCase):
def test_rate_limiting_for_reset_password(self):
# Allow only one reset request for a day
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
frappe.db.set_single_value("System Settings", "password_reset_limit", 1)
frappe.db.commit()
url = get_url()
@ -443,9 +443,7 @@ class TestUser(FrappeTestCase):
def test_reset_password_link_expiry(self):
new_password = "new_password"
# set the reset password expiry to 1 second
frappe.db.set_value(
"System Settings", "System Settings", "reset_password_link_expiry_duration", 1
)
frappe.db.set_single_value("System Settings", "reset_password_link_expiry_duration", 1)
frappe.set_user("testpassword@example.com")
test_user = frappe.get_doc("User", "testpassword@example.com")
test_user.reset_password()

View file

@ -305,12 +305,10 @@ class User(Document):
.from_(user_role_doctype)
.select(user_doctype.name)
.where(user_role_doctype.role == "System Manager")
.where(user_doctype.docstatus < 2)
.where(user_doctype.enabled == 1)
.where(user_role_doctype.parent == user_doctype.name)
.where(user_role_doctype.parent.notin(["Administrator", self.name]))
.limit(1)
.distinct()
).run()
def get_fullname(self):
@ -582,7 +580,7 @@ class User(Document):
if len(email_accounts) != len(set(email_accounts)):
frappe.throw(_("Email Account added multiple times"))
def get_social_login_userid(self, provider):
def get_social_login_userid(self, provider: str):
try:
for p in self.social_logins:
if p.provider == provider:

View file

@ -111,54 +111,59 @@ frappe.ui.form.on("Customize Form", {
frm.page.clear_icons();
if (frm.doc.doc_type) {
frm.page.set_title(__("Customize Form - {0}", [frm.doc.doc_type]));
frappe.customize_form.set_primary_action(frm);
frappe.model.with_doctype(frm.doc.doc_type).then(() => {
frm.page.set_title(__("Customize Form - {0}", [frm.doc.doc_type]));
frappe.customize_form.set_primary_action(frm);
if (!frm.is_new()) {
frm.add_custom_button(__("Try new form builder", [__(frm.doc.doc_type)]), () => {
frappe.set_route("form-builder", frm.doc.doc_type, "customize");
});
}
if (!frm.is_new()) {
frm.add_custom_button(
__("Try new form builder", [__(frm.doc.doc_type)]),
() => {
frappe.set_route("form-builder", frm.doc.doc_type, "customize");
}
);
}
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
function () {
frappe.set_route("List", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Go to {0} List", [__(frm.doc.doc_type)]),
function () {
frappe.set_route("List", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Reload"),
function () {
frm.script_manager.trigger("doc_type");
},
__("Actions")
);
frm.add_custom_button(
__("Reload"),
function () {
frm.script_manager.trigger("doc_type");
},
__("Actions")
);
frm.add_custom_button(
__("Reset to defaults"),
function () {
frappe.customize_form.confirm(__("Remove all customizations?"), frm);
},
__("Actions")
);
frm.add_custom_button(
__("Reset to defaults"),
function () {
frappe.customize_form.confirm(__("Remove all customizations?"), frm);
},
__("Actions")
);
frm.add_custom_button(
__("Set Permissions"),
function () {
frappe.set_route("permission-manager", frm.doc.doc_type);
},
__("Actions")
);
frm.add_custom_button(
__("Set Permissions"),
function () {
frappe.set_route("permission-manager", frm.doc.doc_type);
},
__("Actions")
);
const is_autoname_autoincrement = frm.doc.autoname === "autoincrement";
frm.set_df_property("naming_rule", "hidden", is_autoname_autoincrement);
frm.set_df_property("autoname", "read_only", is_autoname_autoincrement);
frm.toggle_display(
["queue_in_background"],
frappe.get_meta(frm.doc.doc_type).is_submittable || 0
);
const is_autoname_autoincrement = frm.doc.autoname === "autoincrement";
frm.set_df_property("naming_rule", "hidden", is_autoname_autoincrement);
frm.set_df_property("autoname", "read_only", is_autoname_autoincrement);
frm.toggle_display(
["queue_in_background"],
frappe.get_meta(frm.doc.doc_type).is_submittable || 0
);
});
}
frm.events.setup_export(frm);

View file

@ -32,7 +32,7 @@ from frappe.model.utils.link_count import flush_local_link_count
from frappe.query_builder.functions import Count
from frappe.utils import cast as cast_fieldtype
from frappe.utils import cint, get_datetime, get_table_name, getdate, now, sbool
from frappe.utils.deprecations import deprecated
from frappe.utils.deprecations import deprecated, deprecation_warning
IFNULL_PATTERN = re.compile(r"ifnull\(", flags=re.IGNORECASE)
INDEX_PATTERN = re.compile(r"\s*\([^)]+\)\s*")
@ -362,7 +362,7 @@ class Database:
self.sql(query, debug=debug)
def check_transaction_status(self, query):
"""Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are
"""Raises exception if more than 200,000 `INSERT`, `UPDATE` queries are
executed in one transaction. This is to ensure that writes are always flushed otherwise this
could cause the system to hang."""
self.check_implicit_commit(query)
@ -689,13 +689,30 @@ class Database:
def get_list(*args, **kwargs):
return frappe.get_list(*args, **kwargs)
@staticmethod
def _get_update_dict(
fieldname: str | dict, value: Any, *, modified: str, modified_by: str, update_modified: bool
) -> dict[str, Any]:
"""Create update dict that represents column-values to be updated."""
update_dict = fieldname if isinstance(fieldname, dict) else {fieldname: value}
if update_modified:
modified = modified or now()
modified_by = modified_by or frappe.session.user
update_dict.update({"modified": modified, "modified_by": modified_by})
return update_dict
def set_single_value(
self,
doctype: str,
fieldname: str | dict,
value: str | int | None = None,
*args,
**kwargs,
*,
modified=None,
modified_by=None,
update_modified=True,
debug=False,
):
"""Set field value of Single DocType.
@ -708,7 +725,23 @@ class Database:
# Update the `deny_multiple_sessions` field in System Settings DocType.
company = frappe.db.set_single_value("System Settings", "deny_multiple_sessions", True)
"""
return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs)
to_update = self._get_update_dict(
fieldname, value, modified=modified, modified_by=modified_by, update_modified=update_modified
)
frappe.db.delete(
"Singles", filters={"field": ("in", tuple(to_update)), "doctype": doctype}, debug=debug
)
singles_data = ((doctype, key, sbool(value)) for key, value in to_update.items())
frappe.qb.into("Singles").columns("doctype", "field", "value").insert(*singles_data).run(
debug=debug
)
frappe.clear_document_cache(doctype, doctype)
if doctype in self.value_cache:
del self.value_cache[doctype]
def get_single_value(self, doctype, fieldname, cache=True):
"""Get property of Single DocType. Cache locally by default
@ -834,40 +867,40 @@ class Database:
:param update_modified: default True. Set as false, if you don't want to update the timestamp.
:param debug: Print the query in the developer / js console.
"""
is_single_doctype = not (dn and dt != dn)
to_update = field if isinstance(field, dict) else {field: val}
if update_modified:
modified = modified or now()
modified_by = modified_by or frappe.session.user
to_update.update({"modified": modified, "modified_by": modified_by})
if is_single_doctype:
frappe.db.delete(
"Singles", filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug
if _is_single_doctype := not (dn and dt != dn):
deprecation_warning(
"Calling db.set_value on single doctype is deprecated. This behaviour will be removed in version 15. Use db.set_single_value instead."
)
self.set_single_value(
doctype=dt,
fieldname=field,
value=val,
debug=debug,
update_modified=update_modified,
modified=modified,
modified_by=modified_by,
)
return
singles_data = ((dt, key, sbool(value)) for key, value in to_update.items())
query = (
frappe.qb.into("Singles").columns("doctype", "field", "value").insert(*singles_data)
).run(debug=debug)
frappe.clear_document_cache(dt, dt)
to_update = self._get_update_dict(
field, val, modified=modified, modified_by=modified_by, update_modified=update_modified
)
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True)
if isinstance(dn, str):
frappe.clear_document_cache(dt, dn)
else:
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True)
# TODO: Fix this; doesn't work rn - gavin@frappe.io
# frappe.cache().hdel_keys(dt, "document_cache")
# Workaround: clear all document caches
frappe.cache().delete_value("document_cache")
if isinstance(dn, str):
frappe.clear_document_cache(dt, dn)
else:
# TODO: Fix this; doesn't work rn - gavin@frappe.io
# frappe.cache().hdel_keys(dt, "document_cache")
# Workaround: clear all document caches
frappe.cache().delete_value("document_cache")
for column, value in to_update.items():
query = query.set(column, value)
for column, value in to_update.items():
query = query.set(column, value)
query.run(debug=debug)
query.run(debug=debug)
if dt in self.value_cache:
del self.value_cache[dt]

View file

@ -1,3 +1,5 @@
from pymysql.constants.ER import DUP_ENTRY
import frappe
from frappe import _
from frappe.database.schema import DBTable
@ -115,17 +117,15 @@ class MariaDBTable(DBTable):
frappe.db.sql(query)
except Exception as e:
# sanitize
if e.args[0] == 1060:
frappe.throw(str(e))
elif e.args[0] == 1062:
if query := locals().get("query"): # this weirdness is to avoid potentially unbounded vars
print(f"Failed to alter schema using query: {query}")
if e.args[0] == DUP_ENTRY:
fieldname = str(e).split("'")[-2]
frappe.throw(
_("{0} field cannot be set as unique in {1}, as there are non-unique existing values").format(
fieldname, self.table_name
)
)
elif e.args[0] == 1067:
frappe.throw(str(e.args[1]))
else:
raise e
raise

View file

@ -379,7 +379,17 @@ def get_workspace_sidebar_items():
# pages sorted based on sequence id
order_by = "sequence_id asc"
fields = ["name", "title", "for_user", "parent_page", "content", "public", "module", "icon"]
fields = [
"name",
"title",
"for_user",
"parent_page",
"content",
"public",
"module",
"icon",
"is_hidden",
]
all_pages = frappe.get_all(
"Workspace", fields=fields, filters=filters, order_by=order_by, ignore_permissions=True
)
@ -391,7 +401,7 @@ def get_workspace_sidebar_items():
try:
workspace = Workspace(page, True)
if has_access or workspace.is_permitted():
if page.public:
if page.public and (has_access or not page.is_hidden):
pages.append(page)
elif page.for_user == frappe.session.user:
private_pages.append(page)

View file

@ -88,10 +88,15 @@ def update_order(board_name, order):
"""Save the order of cards in columns"""
board = frappe.get_doc("Kanban Board", board_name)
doctype = board.reference_doctype
updated_cards = []
if not frappe.has_permission(doctype, "write"):
# Return board data from db
return board, updated_cards
fieldname = board.field_name
order_dict = json.loads(order)
updated_cards = []
for col_name, cards in order_dict.items():
for card in cards:
column = frappe.get_value(doctype, {"name": card}, fieldname)
@ -103,8 +108,7 @@ def update_order(board_name, order):
if column.column_name == col_name:
column.order = json.dumps(cards)
board.save()
return board, updated_cards
return board.save(ignore_permissions=True), updated_cards
@frappe.whitelist()
@ -114,6 +118,9 @@ def update_order_for_single_card(
"""Save the order of cards in columns"""
board = frappe.get_doc("Kanban Board", board_name)
doctype = board.reference_doctype
frappe.has_permission(doctype, "write", throw=True)
fieldname = board.field_name
old_index = frappe.parse_json(old_index)
new_index = frappe.parse_json(new_index)
@ -130,7 +137,7 @@ def update_order_for_single_card(
# save updated order
board.columns[from_col_idx].order = frappe.as_json(from_col_order)
board.columns[to_col_idx].order = frappe.as_json(to_col_order)
board.save()
board.save(ignore_permissions=True)
# update changed value in doc
frappe.set_value(doctype, docname, fieldname, to_colname)
@ -151,13 +158,14 @@ def get_kanban_column_order_and_index(board, colname):
def add_card(board_name, docname, colname):
board = frappe.get_doc("Kanban Board", board_name)
frappe.has_permission(board.reference_doctype, "write", throw=True)
col_order, col_idx = get_kanban_column_order_and_index(board, colname)
col_order.insert(0, docname)
board.columns[col_idx].order = frappe.as_json(col_order)
board.save()
return board
return board.save(ignore_permissions=True)
@frappe.whitelist()

View file

@ -41,6 +41,10 @@ frappe.ui.form.on("Workspace", {
}
}
if (frappe.boot.developer_mode) {
frm.set_df_property("module", "read_only", 0);
}
frm.layout.show_message(message);
},

View file

@ -19,6 +19,7 @@
"restrict_to_domain",
"hide_custom",
"public",
"is_hidden",
"content",
"tab_break_2",
"charts",
@ -71,7 +72,8 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Module",
"options": "Module Def"
"options": "Module Def",
"read_only": 1
},
{
"fieldname": "column_break_3",
@ -173,11 +175,17 @@
"fieldtype": "Table",
"label": "Quick Lists",
"options": "Workspace Quick List"
},
{
"default": "0",
"fieldname": "is_hidden",
"fieldtype": "Check",
"label": "Is Hidden"
}
],
"in_create": 1,
"links": [],
"modified": "2022-08-16 18:01:42.632238",
"modified": "2023-01-07 19:37:39.512482",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
@ -195,15 +203,6 @@
"role": "Workspace Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1
}
],
"sort_field": "modified",

View file

@ -194,7 +194,7 @@ def save_page(title, public, new_widgets, blocks):
if not public:
filters = {"for_user": frappe.session.user, "label": title + "-" + frappe.session.user}
pages = frappe.get_list("Workspace", filters=filters)
pages = frappe.get_all("Workspace", filters=filters)
if pages:
doc = frappe.get_doc("Workspace", pages[0])
@ -209,12 +209,8 @@ def save_page(title, public, new_widgets, blocks):
@frappe.whitelist()
def update_page(name, title, icon, parent, public):
public = frappe.parse_json(public)
doc = frappe.get_doc("Workspace", name)
filters = {"parent_page": doc.title, "public": doc.public}
child_docs = frappe.get_list("Workspace", filters=filters)
if doc:
doc.title = title
doc.icon = icon
@ -230,6 +226,9 @@ def update_page(name, title, icon, parent, public):
rename_doc("Workspace", name, new_name, force=True, ignore_permissions=True)
# update new name and public in child pages
child_docs = frappe.get_all(
"Workspace", filters={"parent_page": doc.title, "public": doc.public}
)
if child_docs:
for child in child_docs:
child_doc = frappe.get_doc("Workspace", child.name)
@ -248,6 +247,32 @@ def update_page(name, title, icon, parent, public):
return {"name": title, "public": public, "label": new_name}
def hide_unhide_page(page_name: str, is_hidden: bool):
page = frappe.get_doc("Workspace", page_name)
if page.get("public") and not is_workspace_manager():
frappe.throw(
_("Need Workspace Manager role to hide/unhide public workspaces"), frappe.PermissionError
)
if not page.get("public") and page.get("for_user") != frappe.session.user:
frappe.throw(_("Cannot update private workspace of other users"), frappe.PermissionError)
page.is_hidden = int(is_hidden)
page.save(ignore_permissions=True)
return True
@frappe.whitelist()
def hide_page(page_name: str):
return hide_unhide_page(page_name, 1)
@frappe.whitelist()
def unhide_page(page_name: str):
return hide_unhide_page(page_name, 0)
@frappe.whitelist()
def duplicate_page(page_name, new_page):
if not loads(new_page):
@ -338,7 +363,7 @@ def last_sequence_id(doc):
if not doc_exists:
return 0
return frappe.db.get_list(
return frappe.get_all(
"Workspace",
fields=["sequence_id"],
filters={"public": doc.public, "for_user": doc.for_user},
@ -347,7 +372,7 @@ def last_sequence_id(doc):
def get_page_list(fields, filters):
return frappe.get_list("Workspace", fields=fields, filters=filters, order_by="sequence_id asc")
return frappe.get_all("Workspace", fields=fields, filters=filters, order_by="sequence_id asc")
def is_workspace_manager():

View file

@ -435,9 +435,6 @@ def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> di
continue
linkmeta = link_meta_bundle[0]
if not linkmeta.has_permission():
continue
if not linkmeta.get("issingle"):
fields = [
d.fieldname

View file

@ -6,6 +6,7 @@ import json
import frappe
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.desk.form.load import run_onload
from frappe.model.docstatus import DocStatus
from frappe.monitor import add_data_to_monitor
from frappe.utils.scheduler import is_scheduler_inactive
@ -17,8 +18,14 @@ def savedocs(doc, action):
set_local_name(doc)
# action
doc.docstatus = {"Save": 0, "Submit": 1, "Update": 1, "Cancel": 2}[action]
if doc.docstatus == 1:
doc.docstatus = {
"Save": DocStatus.draft(),
"Submit": DocStatus.submitted(),
"Update": DocStatus.submitted(),
"Cancel": DocStatus.cancelled(),
}[action]
if doc.docstatus.is_submitted():
if action == "Submit" and doc.meta.queue_in_background and not is_scheduler_inactive():
queue_submission(doc, action)
return

View file

@ -115,7 +115,6 @@ class Leaderboard {
default: frappe.defaults.get_default("company"),
reqd: 1,
change: (e) => {
this.options.selected_company = e.currentTarget.value;
this.make_request();
},
});
@ -182,7 +181,9 @@ class Leaderboard {
let $li = $(e.currentTarget);
let doctype = $li.find(".doctype-text").attr("doctype-value");
this.options.selected_company = frappe.defaults.get_default("company");
this.company_select.set_value(
frappe.defaults.get_default("company") || this.company_select.get_value()
);
this.options.selected_doctype = doctype;
this.options.selected_filter = this.filters[doctype];
this.options.selected_filter_item = this.filters[doctype][0];
@ -237,13 +238,16 @@ class Leaderboard {
}
get_leaderboard(notify) {
if (!this.options.selected_company) {
frappe.throw(__("Please select Company"));
let company = this.company_select.get_value();
if (!company && !this.leaderboard_config[this.options.selected_doctype].company_disabled) {
notify(this, null);
frappe.show_alert(__("Please select Company"));
return;
}
frappe
.call(this.leaderboard_config[this.options.selected_doctype].method, {
date_range: this.get_date_range(),
company: this.options.selected_company,
company: company,
field: this.options.selected_filter_item,
limit: this.leaderboard_limit,
})

View file

@ -244,7 +244,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
}
get_setup_slides_filtered_by_domain() {
var filtered_slides = [];
let filtered_slides = [];
frappe.setup.slides.forEach(function (slide) {
if (frappe.setup.domains) {
let active_domains = frappe.setup.domains;
@ -329,7 +329,7 @@ frappe.setup.SetupWizardSlide = class SetupWizardSlide extends frappe.ui.Slide {
}
set_init_values() {
var me = this;
let me = this;
// set values from frappe.setup.values
if (frappe.wizard.values && this.fields) {
this.fields.forEach(function (f) {
@ -348,7 +348,7 @@ frappe.setup.slides_settings = [
{
// Welcome (language) slide
name: "welcome",
title: __("Hello!"),
title: __("Welcome"),
fields: [
{
@ -418,16 +418,9 @@ frappe.setup.slides_settings = [
{
// Profile slide
name: "user",
title: __("The First User: You"),
title: __("Let's setup your account"),
icon: "fa fa-user",
fields: [
{
fieldtype: "Attach Image",
fieldname: "attach_user_image",
label: __("Attach Your Picture"),
is_private: 0,
align: "center",
},
{
fieldname: "full_name",
label: __("Full Name"),
@ -456,15 +449,6 @@ frappe.setup.slides_settings = [
[frappe.boot.user.first_name, frappe.boot.user.last_name].join(" ").trim()
);
}
var user_image = frappe.get_cookie("user_image");
var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper;
if (user_image) {
$attach_user_image.find(".missing-image").toggle(false);
$attach_user_image.find("img").attr("src", decodeURIComponent(user_image));
$attach_user_image.find(".img-container").toggle(true);
}
delete slide.form.fields_dict.email;
} else {
slide.form.fields_dict.email.df.reqd = 1;
@ -484,7 +468,7 @@ frappe.setup.slides_settings = [
let email = frappe.setup.data.email;
slide.form.fields_dict.email.set_input(email);
if (frappe.get_gravatar(email, 200)) {
var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper;
let $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper;
$attach_user_image.find(".missing-image").toggle(false);
$attach_user_image.find("img").attr("src", frappe.get_gravatar(email, 200));
$attach_user_image.find(".img-container").toggle(true);
@ -569,7 +553,7 @@ frappe.setup.utils = {
.on("change", function () {
clearTimeout(slide.language_call_timeout);
slide.language_call_timeout = setTimeout(() => {
var lang = $(this).val() || "English";
let lang = $(this).val() || "English";
frappe._messages = {};
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_messages",
@ -595,9 +579,9 @@ frappe.setup.utils = {
Bind a slide's country, timezone and currency fields
*/
slide.get_input("country").on("change", function () {
var country = slide.get_input("country").val();
var $timezone = slide.get_input("timezone");
var data = frappe.setup.data.regional_data;
let country = slide.get_input("country").val();
let $timezone = slide.get_input("timezone");
let data = frappe.setup.data.regional_data;
$timezone.empty();
@ -618,12 +602,12 @@ frappe.setup.utils = {
});
slide.get_input("currency").on("change", function () {
var currency = slide.get_input("currency").val();
let currency = slide.get_input("currency").val();
if (!currency) return;
frappe.model.with_doc("Currency", currency, function () {
frappe.provide("locals.:Currency." + currency);
var currency_doc = frappe.model.get_doc("Currency", currency);
var number_format = currency_doc.number_format;
let currency_doc = frappe.model.get_doc("Currency", currency);
let number_format = currency_doc.number_format;
if (number_format === "#.###") {
number_format = "#.###,##";
} else if (number_format === "#,###") {

View file

@ -267,10 +267,10 @@ def add_all_roles_to(name):
def disable_future_access():
frappe.db.set_default("desktop:home_page", "workspace")
frappe.db.set_value("System Settings", "System Settings", "setup_complete", 1)
frappe.db.set_single_value("System Settings", "setup_complete", 1)
# Enable onboarding after install
frappe.db.set_value("System Settings", "System Settings", "enable_onboarding", 1)
frappe.db.set_single_value("System Settings", "enable_onboarding", 1)
if not frappe.flags.in_test:
# remove all roles and add 'Administrator' to prevent future access

View file

@ -1,6 +1,8 @@
from datetime import datetime
import frappe
from frappe.query_builder import Interval, Order
from frappe.query_builder.functions import Date, Sum, UnixTimestamp
from frappe.utils import getdate
@ -11,21 +13,18 @@ def get_energy_points_heatmap_data(user, date):
except Exception:
date = getdate()
eps_log = frappe.qb.DocType("Energy Point Log")
return dict(
frappe.db.sql(
"""select unix_timestamp(date(creation)), sum(points)
from `tabEnergy Point Log`
where
date(creation) > subdate('{date}', interval 1 year) and
date(creation) < subdate('{date}', interval -1 year) and
user = %s and
type != 'Review'
group by date(creation)
order by creation asc""".format(
date=date
),
user,
)
frappe.qb.from_(eps_log)
.select(UnixTimestamp(Date(eps_log.creation)), Sum(eps_log.points))
.where(eps_log.user == user)
.where(eps_log["type"] != "Review")
.where(Date(eps_log.creation) > Date(date) - Interval(years=1))
.where(Date(eps_log.creation) < Date(date) + Interval(years=1))
.groupby(Date(eps_log.creation))
.orderby(Date(eps_log.creation), order=Order.asc)
.run()
)
@ -51,7 +50,7 @@ def get_user_rank(user):
month_start = datetime.today().replace(day=1)
monthly_rank = frappe.get_all(
"Energy Point Log",
group_by="user",
group_by="`tabEnergy Point Log`.`user`",
filters={"creation": [">", month_start], "type": ["!=", "Review"]},
fields=["user", "sum(points)"],
order_by="sum(points) desc",
@ -60,7 +59,7 @@ def get_user_rank(user):
all_time_rank = frappe.get_all(
"Energy Point Log",
group_by="user",
group_by="`tabEnergy Point Log`.`user`",
filters={"type": ["!=", "Review"]},
fields=["user", "sum(points)"],
order_by="sum(points) desc",

View file

@ -20,7 +20,7 @@ from frappe.utils import add_user_info, format_duration
@frappe.read_only()
def get():
args = get_form_params()
# If virtual doctype get data from controller het_list method
# If virtual doctype, get data from controller get_list method
if is_virtual_doctype(args.doctype):
controller = get_controller(args.doctype)
data = compress(controller.get_list(args))
@ -294,7 +294,7 @@ def save_report(name, doctype, report_settings):
if report.report_type != "Report Builder":
frappe.throw(_("Only reports of type Report Builder can be edited"))
if report.owner != frappe.session.user and not frappe.has_permission("Report", "write"):
if report.owner != frappe.session.user and not report.has_permission("write"):
frappe.throw(_("Insufficient Permissions for editing Report"), frappe.PermissionError)
else:
report = frappe.new_doc("Report")
@ -323,7 +323,7 @@ def delete_report(name):
if report.report_type != "Report Builder":
frappe.throw(_("Only reports of type Report Builder can be deleted"))
if report.owner != frappe.session.user and not frappe.has_permission("Report", "delete"):
if report.owner != frappe.session.user and not report.has_permission("delete"):
frappe.throw(_("Insufficient Permissions for deleting Report"), frappe.PermissionError)
report.delete(ignore_permissions=True)

View file

@ -67,27 +67,29 @@ frappe.email_defaults_pop = {
};
function oauth_access(frm) {
return frappe.call({
method: "frappe.email.oauth.oauth_access",
args: {
email_account: frm.doc.name,
service: frm.doc.service || "",
},
callback: function (r) {
if (!r.exc) {
window.open(r.message.url, "_self");
}
},
frappe.model.with_doc("Connected App", frm.doc.connected_app, () => {
const connected_app = frappe.get_doc("Connected App", frm.doc.connected_app);
return frappe.call({
doc: connected_app,
method: "initiate_web_application_flow",
args: {
success_uri: window.location.pathname,
user: frm.doc.connected_user,
},
callback: function (r) {
window.open(r.message, "_self");
},
});
});
}
function set_default_max_attachment_size(frm, field) {
if (frm.doc.__islocal && !frm.doc[field]) {
function set_default_max_attachment_size(frm) {
if (frm.doc.__islocal && !frm.doc["attachment_limit"]) {
frappe.call({
method: "frappe.core.api.file.get_max_file_size",
callback: function (r) {
if (!r.exc) {
frm.set_value(field, Number(r.message) / (1024 * 1024));
frm.set_value("attachment_limit", Number(r.message) / (1024 * 1024));
}
},
});
@ -104,8 +106,6 @@ frappe.ui.form.on("Email Account", {
frm.set_value(key, value);
});
}
frm.events.show_gmail_message_for_less_secure_apps(frm);
frm.events.toggle_auth_method(frm);
},
use_imap: function (frm) {
@ -133,12 +133,6 @@ frappe.ui.form.on("Email Account", {
},
onload: function (frm) {
if (frappe.utils.get_query_params().successful_authorization === "1") {
frappe.show_alert(__("Successfully Authorized"));
// FIXME: find better alternative
window.history.replaceState(null, "", window.location.pathname);
}
frm.set_df_property("append_to", "only_select", true);
frm.set_query(
"append_to",
@ -153,15 +147,13 @@ frappe.ui.form.on("Email Account", {
frm.add_child("imap_folder", { folder_name: "INBOX" });
frm.refresh_field("imap_folder");
}
frm.toggle_display(["auth_method"], frm.doc.service === "GMail");
set_default_max_attachment_size(frm, "attachment_limit");
set_default_max_attachment_size(frm);
frm.events.show_oauth_authorization_message(frm);
},
refresh: function (frm) {
frm.events.enable_incoming(frm);
frm.events.notify_if_unreplied(frm);
frm.events.show_gmail_message_for_less_secure_apps(frm);
frm.events.show_oauth_authorization_message(frm);
if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) {
delete frappe.route_flags.delete_user_from_locals;
@ -169,47 +161,31 @@ frappe.ui.form.on("Email Account", {
}
},
after_save(frm) {
if (frm.doc.auth_method === "OAuth" && !frm.doc.refresh_token) {
oauth_access(frm);
}
},
toggle_auth_method: function (frm) {
if (frm.doc.service !== "GMail") {
frm.toggle_display(["auth_method"], false);
frm.doc.auth_method = "Basic";
} else {
frm.toggle_display(["auth_method"], true);
}
},
show_gmail_message_for_less_secure_apps: function (frm) {
frm.dashboard.clear_headline();
let msg = __(
"GMail will only work if you enable 2-step authentication and use app-specific password OR use OAuth."
);
let cta = __("Read the step by step guide here.");
msg += ` <a target="_blank" href="https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/email/email_account_setup_with_gmail">${cta}</a>`;
if (frm.doc.service === "GMail") {
frm.dashboard.set_headline_alert(msg);
}
authorize_api_access: function (frm) {
oauth_access(frm);
},
show_oauth_authorization_message(frm) {
if (frm.doc.auth_method === "OAuth" && !frm.doc.refresh_token) {
let msg = __(
'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.'
);
frm.dashboard.clear_headline();
frm.dashboard.set_headline_alert(msg, "yellow");
if (frm.doc.auth_method === "OAuth" && frm.doc.connected_app) {
frappe.call({
method: "frappe.integrations.doctype.connected_app.connected_app.has_token",
args: {
connected_app: frm.doc.connected_app,
connected_user: frm.doc.connected_user,
},
callback: (r) => {
if (!r.message) {
let msg = __(
'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.'
);
frm.dashboard.clear_headline();
frm.dashboard.set_headline_alert(msg, "yellow");
}
},
});
}
},
authorize_api_access: function (frm) {
oauth_access(frm);
},
domain: frappe.utils.debounce((frm) => {
if (frm.doc.domain) {
frappe.call({

View file

@ -20,8 +20,8 @@
"awaiting_password",
"ascii_encode_password",
"column_break_10",
"refresh_token",
"access_token",
"connected_app",
"connected_user",
"login_id_is_different",
"login_id",
"mailbox_settings",
@ -203,7 +203,6 @@
"label": "Use SSL"
},
{
"default": "1",
"depends_on": "eval:!doc.domain && doc.enable_incoming",
"description": "Ignore attachments over this size",
"fetch_from": "domain.attachment_limit",
@ -577,25 +576,11 @@
"label": "IMAP Details"
},
{
"depends_on": "eval: doc.service === \"GMail\" && doc.auth_method === \"OAuth\" && !doc.__islocal && !doc.__unsaved",
"depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.__islocal && !doc.__unsaved",
"fieldname": "authorize_api_access",
"fieldtype": "Button",
"label": "Authorize API Access"
},
{
"fieldname": "refresh_token",
"fieldtype": "Small Text",
"hidden": 1,
"label": "Refresh Token",
"read_only": 1
},
{
"fieldname": "access_token",
"fieldtype": "Small Text",
"hidden": 1,
"label": "Access Token",
"read_only": 1
},
{
"default": "Basic",
"fieldname": "auth_method",
@ -610,12 +595,28 @@
"fieldname": "use_starttls",
"fieldtype": "Check",
"label": "Use STARTTLS"
},
{
"depends_on": "eval: doc.auth_method === \"OAuth\"",
"fieldname": "connected_app",
"fieldtype": "Link",
"label": "Connected App",
"mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"",
"options": "Connected App"
},
{
"depends_on": "eval: doc.auth_method === \"OAuth\"",
"fieldname": "connected_user",
"fieldtype": "Link",
"label": "Connected User",
"mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"",
"options": "User"
}
],
"icon": "fa fa-inbox",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-08-23 00:31:05.305462",
"modified": "2022-12-28 14:56:18.754804",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Account",

View file

@ -21,7 +21,6 @@ from frappe.utils import cint, comma_or, cstr, parse_addr, validate_email_addres
from frappe.utils.background_jobs import enqueue, get_jobs
from frappe.utils.error import raise_error_on_no_output
from frappe.utils.jinja import render_template
from frappe.utils.password import decrypt, encrypt
from frappe.utils.user import get_system_managers
@ -83,23 +82,16 @@ class EmailAccount(Document):
return
use_oauth = self.auth_method == "OAuth"
validate_oauth = use_oauth and not (self.is_new() and not self.get_oauth_token())
self.use_starttls = cint(self.use_imap and self.use_starttls and not self.use_ssl)
if getattr(self, "service", "") != "GMail" and use_oauth:
self.auth_method = "Basic"
use_oauth = False
if use_oauth:
# no need for awaiting password for oauth
self.awaiting_password = 0
self.password = None
elif self.refresh_token:
# clear access & refresh token
self.refresh_token = self.access_token = None
if not frappe.local.flags.in_install and not self.awaiting_password:
if self.refresh_token or self.password or self.smtp_server in ("127.0.0.1", "localhost"):
if validate_oauth or self.password or self.smtp_server in ("127.0.0.1", "localhost"):
if self.enable_incoming:
self.get_incoming_server()
self.no_failed = 0
@ -188,6 +180,7 @@ class EmailAccount(Document):
if frappe.cache().get_value("workers:no-internet") == True:
return None
oauth_token = self.get_oauth_token()
args = frappe._dict(
{
"email_account_name": self.email_account_name,
@ -196,14 +189,12 @@ class EmailAccount(Document):
"use_ssl": self.use_ssl,
"use_starttls": self.use_starttls,
"username": getattr(self, "login_id", None) or self.email_id,
"service": getattr(self, "service", ""),
"use_imap": self.use_imap,
"email_sync_rule": email_sync_rule,
"incoming_port": get_port(self),
"initial_sync_count": self.initial_sync_count or 100,
"use_oauth": self.auth_method == "OAuth",
"refresh_token": decrypt(self.refresh_token) if self.refresh_token else None,
"access_token": decrypt(self.access_token) if self.access_token else None,
"access_token": oauth_token.get_password("access_token") if oauth_token else None,
}
)
@ -392,8 +383,6 @@ class EmailAccount(Document):
},
"name": {"conf_names": ("email_sender_name",), "default": "Frappe"},
"auth_method": {"conf_names": ("auth_method"), "default": "Basic"},
"access_token": {"conf_names": ("mail_access_token")},
"refresh_token": {"conf_names": ("mail_refresh_token")},
"from_site_config": {"default": True},
}
@ -401,15 +390,13 @@ class EmailAccount(Document):
for doc_field_name, d in field_to_conf_name_map.items():
conf_names, default = d.get("conf_names") or [], d.get("default")
value = [frappe.conf.get(k) for k in conf_names if frappe.conf.get(k)]
if doc_field_name in ("refresh_token", "access_token"):
account_details[doc_field_name] = value and encrypt(value[0])
else:
account_details[doc_field_name] = (value and value[0]) or default
account_details[doc_field_name] = (value and value[0]) or default
return account_details
def sendmail_config(self):
oauth_token = self.get_oauth_token()
return {
"email_account": self.name,
"server": self.smtp_server,
@ -418,10 +405,8 @@ class EmailAccount(Document):
"password": self._password,
"use_ssl": cint(self.use_ssl_for_outgoing),
"use_tls": cint(self.use_tls),
"service": getattr(self, "service", ""),
"use_oauth": self.auth_method == "OAuth",
"refresh_token": decrypt(self.refresh_token) if self.refresh_token else None,
"access_token": decrypt(self.access_token) if self.access_token else None,
"access_token": oauth_token.get_password("access_token") if oauth_token else None,
}
def get_smtp_server(self):
@ -681,6 +666,11 @@ class EmailAccount(Document):
except Exception:
self.log_error("Unable to add to Sent folder")
def get_oauth_token(self):
if self.auth_method == "OAuth":
connected_app = frappe.get_doc("Connected App", self.connected_app)
return connected_app.get_active_token(self.connected_user)
@frappe.whitelist()
def get_append_to(
@ -776,25 +766,29 @@ def notify_unreplied():
def pull(now=False):
"""Will be called via scheduler, pull emails from all enabled Email accounts."""
from frappe.integrations.doctype.connected_app.connected_app import has_token
if frappe.cache().get_value("workers:no-internet") == True:
if test_internet():
frappe.cache().set_value("workers:no-internet", False)
else:
return
return
doctype = frappe.qb.DocType("Email Account")
email_accounts = (
frappe.qb.from_(doctype)
.select(doctype.name)
.select(doctype.name, doctype.auth_method, doctype.connected_app, doctype.connected_user)
.where(doctype.enable_incoming == 1)
.where(
(doctype.awaiting_password == 0)
| ((doctype.auth_method == "OAuth") & (doctype.refresh_token.isnotnull()))
)
.where(doctype.awaiting_password == 0)
.run(as_dict=1)
)
for email_account in email_accounts:
if email_account.auth_method == "OAuth" and not has_token(
email_account.connected_app, email_account.connected_user
):
# don't try to pull from accounts which dont have access token (for Oauth)
continue
if now:
pull_from_email_account(email_account.name)
@ -917,7 +911,7 @@ def remove_user_email_inbox(email_account):
@frappe.whitelist()
def set_email_password(email_account, password):
account = frappe.get_doc("Email Account", email_account)
if account.awaiting_password and not account.auth_method == "OAuth":
if account.awaiting_password and account.auth_method != "OAuth":
account.awaiting_password = 0
account.password = password
try:

View file

@ -57,18 +57,16 @@
],
"icon": "fa fa-comment",
"links": [],
"modified": "2022-01-04 14:12:50.321633",
"modified": "2023-01-02 03:56:48.437280",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Template",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"read": 1,
"role": "All",
"share": 1,
"write": 1
"role": "All"
},
{
"create": 1,
@ -85,5 +83,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -2,15 +2,8 @@ import base64
from imaplib import IMAP4
from poplib import POP3
from smtplib import SMTP
from urllib.parse import quote
import frappe
from frappe.integrations.google_oauth import GoogleOAuth
from frappe.utils.password import encrypt
class OAuthenticationError(Exception):
pass
class Oauth:
@ -20,46 +13,32 @@ class Oauth:
email_account: str,
email: str,
access_token: str,
refresh_token: str,
service: str,
mechanism: str = "XOAUTH2",
) -> None:
self.email_account = email_account
self.email = email
self.service = service
self._mechanism = mechanism
self._conn = conn
self._access_token = access_token
self._refresh_token = refresh_token
self._validate()
def _validate(self) -> None:
if self.service != "GMail":
raise NotImplementedError(
f"Service {self.service} currently doesn't have oauth implementation."
)
if not self._refresh_token:
if not self._access_token:
frappe.throw(
frappe._("Please Authorize OAuth."),
OAuthenticationError,
frappe._("OAuth Error"),
frappe._("Please Authorize OAuth for Email Account {}").format(self.email_account),
title=frappe._("OAuth Error"),
)
@property
def _auth_string(self) -> str:
return f"user={self.email}\1auth=Bearer {self._access_token}\1\1"
def connect(self, _retry: int = 0) -> None:
"""Connection method with retry on exception for Oauth"""
def connect(self) -> None:
try:
if isinstance(self._conn, POP3):
res = self._connect_pop()
if not res.startswith(b"+OK"):
raise
self._connect_pop()
elif isinstance(self._conn, IMAP4):
self._connect_imap()
@ -68,100 +47,29 @@ class Oauth:
# SMTP
self._connect_smtp()
except Exception as e:
# maybe the access token expired - refreshing
access_token = self._refresh_access_token()
except Exception:
frappe.log_error(
"Email Connection Error - Authentication Failed",
reference_doctype="Email Account",
reference_name=self.email_account,
)
# raising a bare exception here as we have a lot of exception handling present
# where the connect method is called from - hence just logging and raising.
raise
if not access_token or _retry > 0:
frappe.log_error(
"OAuth Error - Authentication Failed", str(e), "Email Account", self.email_account
)
# raising a bare exception here as we have a lot of exception handling present
# where the connect method is called from - hence just logging and raising.
raise
self._access_token = access_token
self.connect(_retry + 1)
def _connect_pop(self) -> bytes:
# poplib doesn't have AUTH command implementation
def _connect_pop(self) -> None:
# NOTE: poplib doesn't have AUTH command implementation
res = self._conn._shortcmd(
"AUTH {} {}".format(
self._mechanism, base64.b64encode(bytes(self._auth_string, "utf-8")).decode("utf-8")
)
)
return res
if not res.startswith(b"+OK"):
raise
def _connect_imap(self) -> None:
self._conn.authenticate(self._mechanism, lambda x: self._auth_string)
def _connect_smtp(self) -> None:
self._conn.auth(self._mechanism, lambda x: self._auth_string, initial_response_ok=False)
def _refresh_access_token(self) -> str:
"""Refreshes access token via calling `refresh_access_token` method of oauth service object"""
service_obj = self._get_service_object()
access_token = service_obj.refresh_access_token(self._refresh_token).get("access_token")
if access_token:
# set the new access token in db
frappe.db.set_value(
"Email Account",
self.email_account,
"access_token",
encrypt(access_token),
update_modified=False,
)
return access_token
def _get_service_object(self):
"""Get Oauth service object"""
return {
"GMail": GoogleOAuth("mail", validate=False),
}[self.service]
@frappe.whitelist(methods=["POST"])
def oauth_access(email_account: str, service: str):
"""Used as a default endpoint/caller for all oauth services.
Returns authorization url for redirection"""
if not service:
frappe.throw(frappe._("No Service is selected. Please select one and try again!"))
if service == "GMail":
return authorize_google_access(email_account)
raise NotImplementedError(f"Service {service} currently doesn't have oauth implementation.")
def authorize_google_access(email_account: str, code: str = None):
"""Facilitates google oauth for email.
This is invoked 2 times - first time when user clicks `Authorize API Access` for getting the authorization url
and second time for setting the refresh and access token in db when google redirects back with oauth code."""
doctype = "Email Account"
oauth_obj = GoogleOAuth("mail")
if not code:
return oauth_obj.get_authentication_url(
{
"redirect": f"/app/Form/{quote(doctype)}/{quote(email_account)}",
"success_query_param": "successful_authorization=1",
"email_account": email_account,
},
)
res = oauth_obj.authorize(code)
frappe.db.set_value(
doctype,
email_account,
{
"refresh_token": encrypt(res.get("refresh_token")),
"access_token": encrypt(res.get("access_token")),
},
update_modified=False,
)

View file

@ -109,8 +109,6 @@ class EmailServer:
self.settings.email_account,
self.settings.username,
self.settings.access_token,
self.settings.refresh_token,
self.settings.service,
).connect()
else:
@ -142,8 +140,6 @@ class EmailServer:
self.settings.email_account,
self.settings.username,
self.settings.access_token,
self.settings.refresh_token,
self.settings.service,
).connect()
else:

View file

@ -54,9 +54,7 @@ class SMTPServer:
use_tls=None,
use_ssl=None,
use_oauth=0,
refresh_token=None,
access_token=None,
service=None,
):
self.login = login
self.email_account = email_account
@ -66,9 +64,7 @@ class SMTPServer:
self.use_tls = use_tls
self.use_ssl = use_ssl
self.use_oauth = use_oauth
self.refresh_token = refresh_token
self.access_token = access_token
self.service = service
self._session = None
if not self.server:
@ -112,9 +108,7 @@ class SMTPServer:
self.secure_session(_session)
if self.use_oauth:
Oauth(
_session, self.email_account, self.login, self.access_token, self.refresh_token, self.service
).connect()
Oauth(_session, self.email_account, self.login, self.access_token).connect()
elif self.password:
res = _session.login(str(self.login or ""), str(self.password or ""))

View file

@ -5,6 +5,7 @@ import json
# all country info
import os
from functools import lru_cache
import frappe
from frappe.utils.momentjs import get_all_timezones
@ -27,8 +28,13 @@ def get_all():
return all_data
@frappe.whitelist()
@frappe.whitelist(allow_guest=True)
def get_country_timezone_info():
return _get_country_timezone_info()
@lru_cache(maxsize=2)
def _get_country_timezone_info():
return {"country_info": get_all(), "all_timezones": get_all_timezones()}

View file

@ -354,6 +354,7 @@ global_search_doctypes = {
}
override_whitelisted_methods = {
# Legacy File APIs
"frappe.core.doctype.file.file.download_file": "download_file",
"frappe.core.doctype.file.file.unzip_file": "frappe.core.api.file.unzip_file",
"frappe.core.doctype.file.file.get_attached_images": "frappe.core.api.file.get_attached_images",
@ -363,6 +364,14 @@ override_whitelisted_methods = {
"frappe.core.doctype.file.file.create_new_folder": "frappe.core.api.file.create_new_folder",
"frappe.core.doctype.file.file.move_file": "frappe.core.api.file.move_file",
"frappe.core.doctype.file.file.zip_files": "frappe.core.api.file.zip_files",
# Legacy (& Consistency) OAuth2 APIs
"frappe.www.login.login_via_google": "frappe.integrations.oauth2_logins.login_via_google",
"frappe.www.login.login_via_github": "frappe.integrations.oauth2_logins.login_via_github",
"frappe.www.login.login_via_facebook": "frappe.integrations.oauth2_logins.login_via_facebook",
"frappe.www.login.login_via_frappe": "frappe.integrations.oauth2_logins.login_via_frappe",
"frappe.www.login.login_via_office365": "frappe.integrations.oauth2_logins.login_via_office365",
"frappe.www.login.login_via_salesforce": "frappe.integrations.oauth2_logins.login_via_salesforce",
"frappe.www.login.login_via_fairlogin": "frappe.integrations.oauth2_logins.login_via_fairlogin",
}
ignore_links_on_delete = [
@ -381,4 +390,5 @@ ignore_links_on_delete = [
"Email Queue",
"Document Share Key",
"Integration Request",
"Unhandled Email",
]

View file

@ -14,6 +14,8 @@ if any((os.getenv("CI"), frappe.conf.developer_mode, frappe.conf.allow_tests)):
# Disable mandatory TLS in developer mode and tests
os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
class ConnectedApp(Document):
"""Connect to a remote oAuth Server. Retrieve and store user's access token
@ -57,7 +59,7 @@ class ConnectedApp(Document):
def initiate_web_application_flow(self, user=None, success_uri=None):
"""Return an authorization URL for the user. Save state in Token Cache."""
user = user or frappe.session.user
oauth = self.get_oauth2_session(init=True)
oauth = self.get_oauth2_session(user, init=True)
query_params = self.get_query_params()
authorization_url, state = oauth.authorization_url(self.authorization_uri, **query_params)
token_cache = self.get_token_cache(user)
@ -102,8 +104,27 @@ class ConnectedApp(Document):
def get_query_params(self):
return {param.key: param.value for param in self.query_parameters}
def get_active_token(self, user=None):
user = user or frappe.session.user
token_cache = self.get_token_cache(user)
if token_cache and token_cache.is_expired():
oauth_session = self.get_oauth2_session(user)
@frappe.whitelist(allow_guest=True)
try:
token = oauth_session.refresh_token(
body=f"redirect_uri={self.redirect_uri}",
token_url=self.token_uri,
)
except Exception:
self.log_error("Token Refresh Error")
return None
token_cache.update_data(token)
return token_cache
@frappe.whitelist(methods=["GET"], allow_guest=True)
def callback(code=None, state=None):
"""Handle client's code.
@ -111,8 +132,6 @@ def callback(code=None, state=None):
transmit a code that can be used by the local server to obtain an access
token.
"""
if frappe.request.method != "GET":
frappe.throw(_("Invalid request method: {}").format(frappe.request.method))
if frappe.session.user == "Guest":
frappe.local.response["type"] = "redirect"
@ -136,9 +155,16 @@ def callback(code=None, state=None):
code=code,
client_secret=connected_app.get_password("client_secret"),
include_client_id=True,
**query_params
**query_params,
)
token_cache.update_data(token)
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = token_cache.get("success_uri") or connected_app.get_url()
@frappe.whitelist()
def has_token(connected_app, connected_user=None):
app = frappe.get_doc("Connected App", connected_app)
token_cache = app.get_token_cache(connected_user or frappe.session.user)
return bool(token_cache and token_cache.get_password("access_token", False))

View file

@ -393,7 +393,7 @@ def dropbox_auth_finish(return_access_token=False):
def set_dropbox_access_token(access_token):
frappe.db.set_value("Dropbox Settings", None, "dropbox_access_token", access_token)
frappe.db.set_single_value("Dropbox Settings", "dropbox_access_token", access_token)
frappe.db.commit()

View file

@ -54,7 +54,7 @@ def authorize_access(reauthorize=False, code=None):
if not oauth_code or reauthorize:
if reauthorize:
frappe.db.set_value("Google Drive", None, "backup_folder_id", "")
frappe.db.set_single_value("Google Drive", "backup_folder_id", "")
return oauth_obj.get_authentication_url(
{
"redirect": f"/app/Form/{quote('Google Drive')}",
@ -62,8 +62,7 @@ def authorize_access(reauthorize=False, code=None):
)
r = oauth_obj.authorize(oauth_code)
frappe.db.set_value(
"Google Drive",
frappe.db.set_single_value(
"Google Drive",
{"authorization_code": oauth_code, "refresh_token": r.get("refresh_token")},
)
@ -95,7 +94,7 @@ def check_for_folder_in_google_drive():
try:
folder = google_drive.files().create(body=file_metadata, fields="id").execute()
frappe.db.set_value("Google Drive", None, "backup_folder_id", folder.get("id"))
frappe.db.set_single_value("Google Drive", "backup_folder_id", folder.get("id"))
frappe.db.commit()
except HttpError as e:
frappe.throw(
@ -120,7 +119,7 @@ def check_for_folder_in_google_drive():
for f in google_drive_folders.get("files"):
if f.get("name") == account.backup_folder_name:
frappe.db.set_value("Google Drive", None, "backup_folder_id", f.get("id"))
frappe.db.set_single_value("Google Drive", "backup_folder_id", f.get("id"))
frappe.db.commit()
backup_folder_exists = True
break
@ -170,7 +169,7 @@ def upload_system_backup_to_google_drive():
if not fileurl:
continue
file_metadata = {"name": fileurl, "parents": [account.backup_folder_id]}
file_metadata = {"name": os.path.basename(fileurl), "parents": [account.backup_folder_id]}
try:
media = MediaFileUpload(
@ -186,7 +185,7 @@ def upload_system_backup_to_google_drive():
send_email(False, "Google Drive", "Google Drive", "email", error_status=e)
set_progress(3, "Uploading successful.")
frappe.db.set_value("Google Drive", None, "last_backup_on", frappe.utils.now_datetime())
frappe.db.set_single_value("Google Drive", "last_backup_on", frappe.utils.now_datetime())
send_email(True, "Google Drive", "Google Drive", "email")
return _("Google Drive Backup Successful.")

View file

@ -17,24 +17,24 @@ class TestGoogleSettings(FrappeTestCase):
def test_picker_disabled(self):
"""Google Drive Picker should be disabled if it is not enabled in Google Settings."""
frappe.db.set_value("Google Settings", None, "enable", 1)
frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 0)
frappe.db.set_single_value("Google Settings", "enable", 1)
frappe.db.set_single_value("Google Settings", "google_drive_picker_enabled", 0)
settings = get_file_picker_settings()
self.assertEqual(settings, {})
def test_google_disabled(self):
"""Google Drive Picker should be disabled if Google integration is not enabled."""
frappe.db.set_value("Google Settings", None, "enable", 0)
frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 1)
frappe.db.set_single_value("Google Settings", "enable", 0)
frappe.db.set_single_value("Google Settings", "google_drive_picker_enabled", 1)
settings = get_file_picker_settings()
self.assertEqual(settings, {})
def test_picker_enabled(self):
"""If picker is enabled, get_file_picker_settings should return the credentials."""
frappe.db.set_value("Google Settings", None, "enable", 1)
frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 1)
frappe.db.set_single_value("Google Settings", "enable", 1)
frappe.db.set_single_value("Google Settings", "google_drive_picker_enabled", 1)
settings = get_file_picker_settings()
self.assertEqual(True, settings.get("enabled", False))

View file

@ -71,6 +71,7 @@
},
{
"default": "https://s3.amazonaws.com",
"description": "Only change this if you want to use other S3 compatible object storage backends.",
"fieldname": "endpoint_url",
"fieldtype": "Data",
"label": "Endpoint URL"
@ -129,7 +130,7 @@
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2020-12-07 15:30:55.047689",
"modified": "2023-01-11 15:38:20.333833",
"modified_by": "Administrator",
"module": "Integrations",
"name": "S3 Backup Settings",
@ -149,5 +150,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -86,10 +86,11 @@
}
],
"links": [],
"modified": "2020-11-13 13:35:53.714352",
"modified": "2023-01-01 21:01:24.405729",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Token Cache",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
@ -106,5 +107,5 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
"states": []
}

View file

@ -3,6 +3,8 @@
from datetime import datetime, timedelta
import pytz
import frappe
from frappe import _
from frappe.model.document import Document
@ -50,16 +52,18 @@ class TokenCache(Document):
return self
def get_expires_in(self):
expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(self.expires_in)
return (datetime.now() - expiry_time).total_seconds()
modified = frappe.utils.get_datetime(self.modified)
expiry_utc = modified.astimezone(pytz.utc) + timedelta(seconds=self.expires_in)
now_utc = datetime.utcnow().replace(tzinfo=pytz.utc)
return cint((expiry_utc - now_utc).total_seconds())
def is_expired(self):
return self.get_expires_in() < 0
def get_json(self):
return {
"access_token": self.get_password("access_token", ""),
"refresh_token": self.get_password("refresh_token", ""),
"access_token": self.get_password("access_token", False),
"refresh_token": self.get_password("refresh_token", False),
"expires_in": self.get_expires_in(),
"token_type": self.token_type,
}

View file

@ -115,6 +115,7 @@ def enqueue_webhook(doc, webhook) -> None:
webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name"))
headers = get_webhook_headers(doc, webhook)
data = get_webhook_data(doc, webhook)
r = None
for i in range(3):
try:

View file

@ -1,4 +1,4 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import json
@ -9,42 +9,42 @@ from frappe.utils.oauth import login_via_oauth2, login_via_oauth2_id_token
@frappe.whitelist(allow_guest=True)
def login_via_google(code, state):
def login_via_google(code: str, state: str):
login_via_oauth2("google", code, state, decoder=decoder_compat)
@frappe.whitelist(allow_guest=True)
def login_via_github(code, state):
def login_via_github(code: str, state: str):
login_via_oauth2("github", code, state)
@frappe.whitelist(allow_guest=True)
def login_via_facebook(code, state):
def login_via_facebook(code: str, state: str):
login_via_oauth2("facebook", code, state, decoder=decoder_compat)
@frappe.whitelist(allow_guest=True)
def login_via_frappe(code, state):
def login_via_frappe(code: str, state: str):
login_via_oauth2("frappe", code, state, decoder=decoder_compat)
@frappe.whitelist(allow_guest=True)
def login_via_office365(code, state):
def login_via_office365(code: str, state: str):
login_via_oauth2_id_token("office_365", code, state, decoder=decoder_compat)
@frappe.whitelist(allow_guest=True)
def login_via_salesforce(code, state):
def login_via_salesforce(code: str, state: str):
login_via_oauth2("salesforce", code, state, decoder=decoder_compat)
@frappe.whitelist(allow_guest=True)
def login_via_fairlogin(code, state):
def login_via_fairlogin(code: str, state: str):
login_via_oauth2("fairlogin", code, state, decoder=decoder_compat)
@frappe.whitelist(allow_guest=True)
def custom(code, state):
def custom(code: str, state: str):
"""
Callback for processing code and state for user added providers

View file

@ -975,8 +975,14 @@ class BaseDocument:
)
if self_value != db_value:
frappe.throw(
_("Not allowed to change {0} after submission").format(df.label),
_("{0} Not allowed to change {1} after submission from {2} to {3}").format(
f"Row #{self.idx}:" if self.get("parent") else "",
frappe.bold(_(df.label)),
frappe.bold(db_value),
frappe.bold(self_value),
),
frappe.UpdateAfterSubmitError,
title=_("Cannot Update After Submit"),
)
def _sanitize_content(self):

View file

@ -9,6 +9,7 @@ import frappe.defaults
import frappe.model.meta
from frappe import _, get_module_path
from frappe.desk.doctype.tag.tag import delete_tags_for_document
from frappe.model.docstatus import DocStatus
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.model.naming import revert_series_if_last
from frappe.model.utils import is_virtual_doctype
@ -265,7 +266,7 @@ def check_if_doc_is_linked(doc, method="Delete"):
# don't check for communication and todo!
continue
if method != "Delete" and (method != "Cancel" or item.docstatus != 1):
if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()):
# don't raise exception if not
# linked to a non-cancelled doc when deleting or to a submitted doc when cancelling
continue
@ -302,13 +303,12 @@ def check_if_doc_is_dynamically_linked(doc, method="Delete"):
refdoc.get(df.options) == doc.doctype
and refdoc.get(df.fieldname) == doc.name
and (
(method == "Delete" and refdoc.docstatus < 2)
or (method == "Cancel" and refdoc.docstatus == 1)
# linked to an non-cancelled doc when deleting
(method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled())
# linked to a submitted doc when cancelling
or (method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted())
)
):
# raise exception only if
# linked to an non-cancelled doc when deleting
# or linked to a submitted doc when cancelling
raise_link_exists_exception(doc, df.parent, df.parent)
else:
# dynamic link in table
@ -321,14 +321,11 @@ def check_if_doc_is_dynamically_linked(doc, method="Delete"):
(doc.doctype, doc.name),
as_dict=True,
):
if (method == "Delete" and refdoc.docstatus < 2) or (
method == "Cancel" and refdoc.docstatus == 1
# linked to an non-cancelled doc when deleting
# or linked to a submitted doc when cancelling
if (method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled()) or (
method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted()
):
# raise exception only if
# linked to an non-cancelled doc when deleting
# or linked to a submitted doc when cancelling
reference_doctype = refdoc.parenttype if meta.istable else df.parent
reference_docname = refdoc.parent if meta.istable else refdoc.name
at_position = f"at Row: {refdoc.idx}" if meta.istable else ""

View file

@ -6,58 +6,5 @@
import frappe
def rename(doctype, fieldname, newname):
"""rename docfield"""
df = frappe.db.sql(
"""select * from tabDocField where parent=%s and fieldname=%s""", (doctype, fieldname), as_dict=1
)
if not df:
return
df = df[0]
if frappe.db.get_value("DocType", doctype, "issingle"):
update_single(df, newname)
else:
update_table(df, newname)
update_parent_field(df, newname)
def update_single(f, new):
"""update in tabSingles"""
frappe.db.begin()
frappe.db.sql(
"""update tabSingles set field=%s where doctype=%s and field=%s""",
(new, f["parent"], f["fieldname"]),
)
frappe.db.commit()
def update_table(f, new):
"""update table"""
query = get_change_column_query(f, new)
if query:
frappe.db.sql(query)
def update_parent_field(f, new):
"""update 'parentfield' in tables"""
if f["fieldtype"] in frappe.model.table_fields:
frappe.db.begin()
frappe.db.sql(
"""update `tab{}` set parentfield={} where parentfield={}""".format(f["options"], "%s", "%s"),
(new, f["fieldname"]),
)
frappe.db.commit()
def get_change_column_query(f, new):
"""generate change fieldname query"""
desc = frappe.db.sql("desc `tab%s`" % f["parent"])
for d in desc:
if d[0] == f["fieldname"]:
return "alter table `tab{}` change `{}` `{}` {}".format(f["parent"], f["fieldname"], new, d[1])
def supports_translation(fieldtype):
return fieldtype in ["Data", "Select", "Text", "Small Text", "Text Editor"]

View file

@ -178,6 +178,7 @@ class Document(BaseDocument):
"*",
as_dict=True,
order_by="idx asc",
for_update=self.flags.for_update,
)
or []
)

View file

@ -220,3 +220,4 @@ frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings
frappe.patches.v14_0.update_attachment_comment
frappe.patches.v15_0.set_contact_full_name
execute:frappe.delete_doc("Page", "activity", force=1)
frappe.patches.v14_0.disable_email_accounts_with_oauth

View file

@ -6,4 +6,4 @@ import frappe
def execute():
frappe.reload_doc("core", "doctype", "system_settings")
frappe.db.set_value("System Settings", None, "allow_login_after_fail", 60)
frappe.db.set_single_value("System Settings", "allow_login_after_fail", 60)

View file

@ -6,4 +6,4 @@ def execute():
frappe.reload_doctype("Dropbox Settings")
check_dropbox_enabled = cint(frappe.db.get_single_value("Dropbox Settings", "enabled"))
if check_dropbox_enabled == 1:
frappe.db.set_value("Dropbox Settings", None, "file_backup", 1)
frappe.db.set_single_value("Dropbox Settings", "file_backup", 1)

View file

@ -6,4 +6,4 @@ import frappe
def execute():
frappe.reload_doc("core", "doctype", "system_settings", force=1)
frappe.db.set_value("System Settings", None, "password_reset_limit", 3)
frappe.db.set_single_value("System Settings", "password_reset_limit", 3)

View file

@ -5,4 +5,4 @@ def execute():
frappe.reload_doctype("System Settings")
# setting first_day_of_the_week value as "Monday" to avoid breaking change
# because before the configuration was introduced, system used to consider "Monday" as start of the week
frappe.db.set_value("System Settings", "System Settings", "first_day_of_the_week", "Monday")
frappe.db.set_single_value("System Settings", "first_day_of_the_week", "Monday")

View file

@ -0,0 +1,36 @@
import frappe
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
def execute():
if not frappe.get_value("Email Account", {"auth_method": "OAuth"}):
return
# Setting awaiting password to 1 for email accounts where Oauth is enabled.
# This is done so that people can resetup their email accounts with connected app mechanism.
frappe.db.set_value("Email Account", {"auth_method": "OAuth"}, "awaiting_password", 1)
message = "Email Accounts with auth method as OAuth have been disabled.\
Please re-setup your OAuth based email accounts with the connected app mechanism to re-enable them."
if sysmanagers := get_system_managers():
make_notification_logs(
{
"type": "Alert",
"subject": frappe._(message),
},
sysmanagers,
)
def get_system_managers():
user_doctype = frappe.qb.DocType("User").as_("user")
user_role_doctype = frappe.qb.DocType("Has Role").as_("user_role")
return (
frappe.qb.from_(user_doctype)
.from_(user_role_doctype)
.select(user_doctype.email)
.where(user_role_doctype.role == "System Manager")
.where(user_doctype.enabled == 1)
.where(user_role_doctype.parent == user_doctype.name)
).run(pluck=True)

View file

@ -2,8 +2,7 @@ import frappe
def execute():
frappe.db.set_value(
"System Settings",
frappe.db.set_single_value(
"System Settings",
{"document_share_key_expiry": 30, "allow_older_web_view_links": 1},
)

View file

@ -3,4 +3,4 @@ import frappe
def execute():
days = frappe.db.get_single_value("Website Settings", "auto_account_deletion")
frappe.db.set_value("Website Settings", None, "auto_account_deletion", days * 24)
frappe.db.set_single_value("Website Settings", "auto_account_deletion", days * 24)

View file

@ -27,28 +27,12 @@ rights = (
)
def check_admin_or_system_manager(user=None):
from frappe.utils.commands import warn
warn(
"The function check_admin_or_system_manager will be deprecated in version 15."
'Please use frappe.only_for("System Manager") instead.',
category=PendingDeprecationWarning,
)
if not user:
user = frappe.session.user
if ("System Manager" not in frappe.get_roles(user)) and (user != "Administrator"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
def print_has_permission_check_logs(func):
def inner(*args, **kwargs):
frappe.flags["has_permission_check_logs"] = []
result = func(*args, **kwargs)
self_perm_check = True if not kwargs.get("user") else kwargs.get("user") == frappe.session.user
raise_exception = False if kwargs.get("raise_exception") is False else True
raise_exception = kwargs.get("raise_exception", True)
# print only if access denied
# and if user is checking his own permission

View file

@ -48,6 +48,16 @@
<path d="M9 3L13 5.99999L9 9" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-unhide">
<path stroke="none" fill-rule="evenodd" clip-rule="evenodd" d="M2.10756 9.53547C1.93501 9.82126 1.93501 10.1787 2.10756 10.4645C3.75635 13.1955 6.60531 15 9.84351 15C13.0817 15 15.9307 13.1955 17.5795 10.4645C17.752 10.1787 17.752 9.82127 17.5795 9.53548C15.9307 6.80451 13.0817 5 9.84351 5C6.60531 5 3.75635 6.8045 2.10756 9.53547ZM10 13C11.6569 13 13 11.6569 13 10C13 8.34315 11.6569 7 10 7C8.34315 7 7 8.34315 7 10C7 11.6569 8.34315 13 10 13Z" fill="var(--icon-stroke)"/>
<circle cx="10" cy="10" r="1" stroke="none" fill="var(--icon-stroke)"/>
</symbol>
<symbol viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-hide">
<rect stroke="none" x="3.02185" y="3.89151" width="1.26078" height="18.4481" rx="0.630391" transform="rotate(-45 3.02185 3.89151)" fill="var(--icon-stroke)"/>
<path stroke="none" fill-rule="evenodd" clip-rule="evenodd" d="M5.02016 6.99831C4.84611 6.82426 4.57032 6.80165 4.37821 6.95554C3.49472 7.66323 2.73193 8.53749 2.12941 9.53547C1.95686 9.82126 1.95686 10.1787 2.12941 10.4645C3.7782 13.1955 6.62716 15 9.86536 15C10.5301 15 11.1784 14.924 11.8032 14.7795C12.1655 14.6957 12.2727 14.2508 12.0098 13.9879L11.1052 13.0833C10.9747 12.9529 10.7837 12.9083 10.6027 12.9438C10.4148 12.9807 10.2206 13 10.0219 13C8.365 13 7.02185 11.6569 7.02185 10C7.02185 9.80128 7.04117 9.60707 7.07804 9.41915C7.11355 9.23815 7.06896 9.04711 6.93853 8.91668L5.02016 6.99831ZM12.1967 12.8433C11.9793 12.6259 12.011 12.2666 12.2202 12.0414C12.7176 11.506 13.0219 10.7885 13.0219 10C13.0219 8.34315 11.6787 7 10.0219 7C9.23334 7 8.51587 7.30421 7.98043 7.80167C7.75522 8.0109 7.3959 8.04255 7.17854 7.82518L5.98518 6.63183C5.75274 6.39939 5.80413 6.00935 6.10001 5.86613C7.24996 5.3095 8.52428 5 9.86536 5C13.1036 5 15.9525 6.80451 17.6013 9.53548C17.7739 9.82127 17.7739 10.1787 17.6013 10.4645C16.6787 11.9927 15.3803 13.2307 13.8482 14.0249C13.6613 14.1218 13.4343 14.0809 13.2854 13.932L12.1967 12.8433Z" fill="var(--icon-stroke)"/>
</symbol>
<symbol viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-sidebar-collapse">
<path d="M12 6L6 12L12 18" stroke="var(--icon-stroke)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 6L12 12L18 18" stroke="var(--icon-stroke)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -1,6 +1,7 @@
<script setup>
import draggable from "vuedraggable";
import Field from "./Field.vue";
import EditableInput from "./EditableInput.vue";
import { ref } from "vue";
import { useStore } from "../store";
import { move_children_to_parent } from "../utils";
@ -55,6 +56,7 @@ function remove_column() {
// remove column
columns.splice(index, 1);
store.selected_field = null;
}
function move_columns_to_section() {
@ -74,25 +76,43 @@ function move_columns_to_section() {
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div class="column-actions" :hidden="store.read_only">
<button
v-if="section.columns.indexOf(column)"
class="btn btn-xs btn-icon"
:title="__('Move the current column & the following columns to a new section')"
@click="move_columns_to_section"
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-icon"
:title="__('Remove Column')"
@click="remove_column"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
<div
:class="[
'column-header',
column.df.label ? 'has-label' : '',
]"
:hidden="!column.df.label && store.read_only"
>
<div class="column-label">
<EditableInput
:text="column.df.label"
:placeholder="__('Column Title')"
v-model="column.df.label"
/>
</div>
<div class="column-actions">
<button
v-if="section.columns.indexOf(column)"
class="btn btn-xs btn-icon"
:title="__('Move the current column & the following columns to a new section')"
@click="move_columns_to_section"
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" :title="__('Add Column')" @click="add_column">
<div v-html="frappe.utils.icon('add', 'sm')"></div>
</button>
<button
class="btn btn-xs btn-icon"
:title="__('Remove Column')"
@click.stop="remove_column"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
</div>
<div v-if="column.df.description" class="column-description">
{{ column.df.description }}
</div>
<draggable
class="column-container"
@ -140,7 +160,7 @@ function move_columns_to_section() {
}
&.selected {
.column-actions {
.column-header {
display: flex;
}
@ -149,6 +169,48 @@ function move_columns_to_section() {
}
}
.column-header {
display: none;
align-items: center;
justify-content: space-between;
padding-bottom: 0.5rem;
padding-left: 0.3rem;
&.has-label {
display: flex;
}
.column-label {
:deep(span) {
font-weight: 600;
color: var(--heading-color);
}
}
.column-actions {
display: flex;
justify-content: flex-end;
.btn.btn-icon {
padding: 2px;
box-shadow: none;
opacity: 0;
&:hover {
opacity: 1;
background-color: var(--fg-color);
}
}
}
}
.column-description {
margin-bottom: 10px;
margin-left: 0.3rem;
font-size: var(--text-xs);
color: var(--text-muted);
}
&:first-child {
margin-left: 0px;
}
@ -162,22 +224,5 @@ function move_columns_to_section() {
min-height: 2rem;
border-radius: var(--border-radius);
}
.column-actions {
display: none;
justify-content: flex-end;
padding-bottom: 0.5rem;
.btn.btn-icon {
padding: 2px;
box-shadow: none;
opacity: 0;
&:hover {
opacity: 1;
background-color: var(--fg-color);
}
}
}
}
</style>

View file

@ -53,11 +53,11 @@ function focus_on_label() {
@blur="editing = false"
@click.stop
/>
<span v-else-if="text">{{ text }}</span>
<span v-else-if="text" v-html="text" ></span>
<i v-else class="text-muted">
{{ empty_label }}
</i>
<span class="hidden-span" ref="hidden_text">{{ text }}</span>
<span class="hidden-span" ref="hidden_text" v-html="text"></span>
<span class="hidden-span" ref="hidden_placeholder">{{ placeholder }}</span>
</div>
</template>

View file

@ -2,7 +2,7 @@
import EditableInput from "./EditableInput.vue";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { move_children_to_parent } from "../utils";
import { move_children_to_parent, clone_field } from "../utils";
let props = defineProps(["column", "field"]);
let store = useStore();
@ -19,6 +19,7 @@ function remove_field() {
}
let index = props.column.fields.indexOf(props.field);
props.column.fields.splice(index, 1);
store.selected_field = null;
}
function move_fields_to_column() {
@ -27,6 +28,20 @@ function move_fields_to_column() {
);
move_children_to_parent(props, "column", "field", current_section);
}
function duplicate_field() {
let duplicate_field = clone_field(props.field);
if (duplicate_field.df.label) {
duplicate_field.df.label = duplicate_field.df.label + " Copy";
}
duplicate_field.df.fieldname = "";
// push duplicate_field after props.field in the same column
let index = props.column.fields.indexOf(props.field);
props.column.fields.splice(index + 1, 0, duplicate_field);
store.selected_field = duplicate_field.df;
}
</script>
<template>
@ -48,13 +63,16 @@ function move_fields_to_column() {
:data-fieldtype="field.df.fieldtype"
>
<template #label>
<EditableInput
:class="{ reqd: field.df.reqd }"
:text="field.df.label"
:placeholder="__('Label')"
:empty_label="`${__('No Label')} (${field.df.fieldtype})`"
v-model="field.df.label"
/>
<div class="field-label">
<EditableInput
:text="field.df.label"
:placeholder="__('Label')"
:empty_label="`${__('No Label')} (${field.df.fieldtype})`"
v-model="field.df.label"
/>
<div class="reqd-asterisk" v-if="field.df.reqd">*</div>
<div class="help-icon" v-if="field.df.documentation_url" v-html="frappe.utils.icon('help', 'sm')"></div>
</div>
</template>
<template #actions>
<div class="field-actions" :hidden="store.read_only">
@ -75,7 +93,10 @@ function move_fields_to_column() {
>
<div v-html="frappe.utils.icon('move', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" @click="remove_field">
<button class="btn btn-xs btn-icon" @click.stop="duplicate_field">
<div v-html="frappe.utils.icon('duplicate', 'sm')"></div>
</button>
<button class="btn btn-xs btn-icon" @click.stop="remove_field">
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
@ -106,12 +127,30 @@ function move_fields_to_column() {
}
}
:deep(.form-control:read-only:focus) {
box-shadow: none;
}
:deep(.field-controls) {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
.field-label {
display: flex;
align-items: center;
.reqd-asterisk {
margin-left: 3px;
color: var(--red-400);
}
.help-icon {
margin-left: 3px;
color: var(--text-muted);
cursor: pointer;
}
}
.field-actions {
flex: none;

View file

@ -25,6 +25,10 @@ let docfield_df = computed(() => {
return false;
}
if (df.fieldname === "reqd" && store.selected_field.fieldtype === "Check") {
return false;
}
if (df.fieldname === "options") {
df.fieldtype = "Small Text";
df.options = "";

View file

@ -3,15 +3,11 @@ import SearchBox from "./SearchBox.vue";
import draggable from "vuedraggable";
import { ref, computed } from "vue";
import { useStore } from "../store";
import { clone_field } from "../utils";
let store = useStore();
let search_text = ref("");
function clone_field(field) {
field.df.name = frappe.utils.get_random(8);
return JSON.parse(JSON.stringify(field));
}
let fields = computed(() => {
let fields = frappe.model.all_fieldtypes
.filter(df => {

View file

@ -3,7 +3,7 @@ import Sidebar from "./Sidebar.vue";
import Tabs from "./Tabs.vue";
import { computed, onMounted, watch, ref } from "vue";
import { useStore } from "../store";
import { onClickOutside } from "@vueuse/core";
import { onClickOutside, useMagicKeys, whenever } from "@vueuse/core";
let store = useStore();
@ -14,6 +14,14 @@ let should_render = computed(() => {
let container = ref(null);
onClickOutside(container, () => store.selected_field = null);
// cmd/ctrl + s to save the form
const { meta_s, ctrl_s } = useMagicKeys();
whenever(() => meta_s.value || ctrl_s.value, () => {
if (store.dirty) {
store.save_changes();
}
});
function setup_change_doctype_dialog() {
store.page.$title_area.on("click", () => {
let dialog = new frappe.ui.Dialog({
@ -109,7 +117,7 @@ onMounted(() => {
font-size: var(--text-sm);
cursor: pointer;
&:has(.drop-it-here) {
&:not(.hovered) {
position: relative;
background-color: transparent;
height: 60px;
@ -169,10 +177,6 @@ onMounted(() => {
}
}
.reqd::after {
content: " *";
color: var(--red-400);
}
.description,
.time-zone {
font-size: var(--text-sm);
@ -210,6 +214,10 @@ onMounted(() => {
}
}
.section-description {
padding-left: 15px;
}
.section-columns {
margin-top: 8px;
@ -219,6 +227,14 @@ onMounted(() => {
padding-right: 15px;
margin: 0;
.column-header {
padding-left: 0;
}
.column-description {
margin-left: 0;
}
.field {
margin: 0;
margin-bottom: 1rem;
@ -252,7 +268,7 @@ onMounted(() => {
}
}
.form-main:not(:has(.tab-header)) :deep(.tab-contents) {
.form-main > :deep(div:first-child:not(.tab-header)) {
max-height: calc(100vh - 160px);
}
}

View file

@ -50,6 +50,7 @@ function remove_section() {
// remove section
sections.splice(index, 1);
store.selected_field = null;
}
function select_section() {
@ -122,12 +123,13 @@ function move_sections_to_tab() {
<button
class="btn btn-xs btn-section"
:title="__('Remove section')"
@click="remove_section"
@click.stop="remove_section"
>
<div v-html="frappe.utils.icon('remove', 'sm')"></div>
</button>
</div>
</div>
<div v-if="section.df.description" class="section-description">{{ section.df.description }}</div>
<div class="section-columns" :class="{ hidden: section.df.collapsible && collapsed }">
<draggable
class="section-columns-container"
@ -206,6 +208,7 @@ function move_sections_to_tab() {
:deep(span) {
font-weight: 600;
color: var(--heading-color);
}
.collapse-indicator {
@ -228,6 +231,12 @@ function move_sections_to_tab() {
}
}
.section-description {
margin-bottom: 10px;
font-size: var(--text-xs);
color: var(--text-muted);
}
.section-columns-container {
display: flex;
min-height: 2rem;

View file

@ -43,7 +43,7 @@ if (props.df.fieldtype === "Icon") {
type="text"
:style="{ height: df.fieldtype == 'Table MultiSelect' ? '42px' : '' }"
:placeholder="placeholder"
disabled
readonly
/>
<input
v-else
@ -58,7 +58,7 @@ if (props.df.fieldtype === "Icon") {
class="mt-2 form-control"
type="text"
:style="{ height: '110px' }"
disabled
readonly
/>
<!-- description -->

View file

@ -77,7 +77,7 @@ watch(
</div>
<!-- link input -->
<input class="form-control" type="text" disabled />
<input class="form-control" type="text" readonly />
<!-- description -->
<div v-if="df.description" class="mt-2 description" v-html="df.description" />

View file

@ -94,7 +94,7 @@ watch(() => props.df.options, () => {
<!-- select input -->
<div class="select-input">
<input class="form-control" disabled />
<input class="form-control" readonly />
<div class="select-icon" v-html="frappe.utils.icon('select', 'sm')"></div>
</div>

View file

@ -8,10 +8,13 @@ let store = useStore();
let props = defineProps(["df", "value", "modelValue"]);
let emit = defineEmits(["update:modelValue"]);
let slots = useSlots();
let height = "300px";
if (props.df.fieldtype == "Small Text") {
height = "150px";
}
let height = computed(() => {
if (props.df.fieldtype == "Small Text") {
return "150px";
}
return "300px";
});
let doctype = ref("");
let fieldname = ref("");
@ -110,7 +113,7 @@ watch([() => doctype.value, () => fieldname.value], ([doctype_value, fieldname_v
:style="{ height: height, maxHeight: df.max_height ?? '' }"
class="form-control"
type="text"
disabled
readonly
/>
<textarea
v-else

View file

@ -34,6 +34,10 @@ onMounted(() => {
:deep(.quill) {
.ql-toolbar {
pointer-events: none;
.ql-formats {
margin-right: 12px;
}
}
.ql-container p {
cursor: pointer;

View file

@ -45,18 +45,26 @@ class FormBuilder {
this.store.read_only = this.store.preview;
this.read_only = true;
});
this.customize_form_btn = this.page.add_button(__("For Customize Form"), () => {
frappe.set_route("form-builder", this.doctype, "customize");
});
this.doctype_form_btn = this.page.add_button(__("For DocType Form"), () => {
frappe.set_route("form-builder", this.doctype);
});
this.reset_changes_btn = this.page.add_button(__("Reset Changes"), () => {
this.store.reset_changes();
});
this.go_to_doctype_btn = this.page.add_menu_item(__("Go to Doctype"), () =>
this.go_to_doctype_list_btn = this.page.add_button(
__("Go to {0} List", [__(this.doctype)]),
() => {
window.open(`/app/${frappe.router.slug(this.doctype)}`);
}
);
this.customize_form_btn = this.page.add_menu_item(__("Switch to Customize Form"), () => {
frappe.set_route("form-builder", this.doctype, "customize");
});
this.doctype_form_btn = this.page.add_menu_item(__("Switch to DocType Form"), () => {
frappe.set_route("form-builder", this.doctype);
});
this.go_to_doctype_btn = this.page.add_menu_item(__("Go to DocType"), () =>
frappe.set_route("Form", "DocType", this.doctype)
);
this.go_to_customize_form_btn = this.page.add_menu_item(__("Go to Customize Form"), () =>
@ -121,9 +129,7 @@ class FormBuilder {
? __("Go to {0}", [__(this.doctype)])
: __("Go to {0} List", [__(this.doctype)]);
this.page.add_menu_item(label, () => {
window.open(`/app/${frappe.router.slug(this.doctype)}`);
});
this.go_to_doctype_list_btn.text(label);
}
// toggle preview btn text

View file

@ -226,8 +226,13 @@ export const useStore = defineStore("form-builder-store", {
}
section.columns.forEach((column, k) => {
// do not consider first column
if (k > 0 || column.fields.length == 0) {
// do not consider first column if label is not set
if (
(k == 0 &&
this.is_df_updated(column.df, this.get_df("Column Break"))) ||
k > 0 ||
column.fields.length == 0
) {
idx++;
column.df.idx = idx;
fields.push(column.df);

View file

@ -279,7 +279,7 @@ export function scrub_field_names(fields) {
if (d.fieldtype) {
if (!d.fieldname) {
if (d.label) {
d.fieldname = d.label.trim().toLowerCase().replace(" ", "_");
d.fieldname = d.label.trim().toLowerCase().replaceAll(" ", "_");
if (d.fieldname.endsWith("?")) {
d.fieldname = d.fieldname.slice(0, -1);
}
@ -295,7 +295,7 @@ export function scrub_field_names(fields) {
}
} else {
d.fieldname =
d.fieldtype.toLowerCase().replace(" ", "_") +
d.fieldtype.toLowerCase().replaceAll(" ", "_") +
"_" +
frappe.utils.get_random(4);
}
@ -318,3 +318,9 @@ export function scrub_field_names(fields) {
return fields;
}
export function clone_field(field) {
let cloned_field = JSON.parse(JSON.stringify(field));
cloned_field.df.name = frappe.utils.get_random(8);
return cloned_field;
}

View file

@ -168,7 +168,6 @@ frappe.Application = class Application {
}
set_route() {
frappe.flags.setting_original_route = true;
if (frappe.boot && localStorage.getItem("session_last_route")) {
frappe.set_route(localStorage.getItem("session_last_route"));
localStorage.removeItem("session_last_route");
@ -176,7 +175,6 @@ frappe.Application = class Application {
// route to home page
frappe.router.route();
}
frappe.after_ajax(() => (frappe.flags.setting_original_route = false));
frappe.router.on("change", () => {
$(".tooltip").hide();
});
@ -449,6 +447,7 @@ frappe.Application = class Application {
}
},
});
dialog.get_field("password").disable_password_checks();
dialog.set_primary_action(__("Login"), () => {
dialog.set_message(__("Authenticating..."));
frappe.call({

View file

@ -19,12 +19,20 @@ export default class Column {
this.form = this.wrapper.find("form").on("submit", () => false);
if (this.df.description) {
$(`
<p class="col-sm-12 form-column-description">
${__(this.df.description)}
</p>
`).prependTo(this.wrapper);
}
if (this.df.label) {
$(`
<label class="control-label">
<label class="column-label">
${__(this.df.label)}
</label>
`).appendTo(this.wrapper);
`).prependTo(this.wrapper);
}
}

View file

@ -2,6 +2,7 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
static input_type = "password";
make() {
super.make();
this.enable_password_checks = true;
}
make_input() {
var me = this;
@ -23,7 +24,15 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
}, 500);
});
}
disable_password_checks() {
this.enable_password_checks = false;
}
get_password_strength(value) {
if (!this.enable_password_checks) {
return;
}
var me = this;
frappe.call({
type: "POST",
@ -32,13 +41,9 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
new_password: value || "",
},
callback: function (r) {
if (r.message && r.message.entropy) {
var score = r.message.score,
feedback = r.message.feedback;
feedback.crack_time_display = r.message.crack_time_display;
var indicators = ["grey", "red", "orange", "yellow", "green"];
if (r.message) {
let score = r.message.score;
var indicators = ["red", "red", "orange", "yellow", "green"];
me.set_strength_indicator(indicators[score]);
}
},
@ -47,6 +52,6 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co
set_strength_indicator(color) {
var message = __("Include symbols, numbers and capital letters in the password");
this.indicator.removeClass().addClass("password-strength-indicator indicator " + color);
this.message.html(message).removeClass("hidden");
this.message.html(message).toggleClass("hidden", color == "green");
}
};

View file

@ -407,7 +407,7 @@ export default class Grid {
this.toggle_checkboxes(this.display_status !== "Read");
// sortable
if (this.frm && this.is_sortable() && !this.sortable_setup_done) {
if (this.is_sortable() && !this.sortable_setup_done) {
this.make_sortable($rows);
this.sortable_setup_done = true;
}
@ -553,17 +553,18 @@ export default class Grid {
let idx = $(event.item).closest(".grid-row").attr("data-idx") - 1;
let doc = this.data[idx % this.grid_pagination.page_length];
this.renumber_based_on_dom();
this.frm.script_manager.trigger(
this.df.fieldname + "_move",
this.df.options,
doc.name
);
this.frm &&
this.frm.script_manager.trigger(
this.df.fieldname + "_move",
this.df.options,
doc.name
);
this.refresh();
this.frm.dirty();
this.frm && this.frm.dirty();
},
});
$(this.frm.wrapper).trigger("grid-make-sortable", [this.frm]);
this.frm && $(this.frm.wrapper).trigger("grid-make-sortable", [this.frm]);
}
get_data(filter_field) {
@ -825,11 +826,11 @@ export default class Grid {
let $item = $(item);
let index =
(this.grid_pagination.page_index - 1) * this.grid_pagination.page_length + i;
let d = locals[this.doctype][$item.attr("data-name")];
let d = this.grid_rows_by_docname[$item.attr("data-name")].doc;
d.idx = index + 1;
$item.attr("data-idx", d.idx);
this.frm.doc[this.df.fieldname][index] = d;
if (this.frm) this.frm.doc[this.df.fieldname][index] = d;
this.data[index] = d;
this.grid_rows[index] = this.grid_rows_by_docname[d.name];
});

View file

@ -213,7 +213,12 @@ frappe.ui.form.ScriptManager = class ScriptManager {
df.read_only == 1 ||
df.is_virtual == 1;
if (is_read_only_field && df.fetch_from && df.fetch_from.indexOf(".") != -1) {
if (
is_read_only_field &&
df.fetch_from &&
(!df.fetch_if_empty || (df.fetch_if_empty && !me.frm.doc[df.fieldname])) &&
df.fetch_from.indexOf(".") != -1
) {
var parts = df.fetch_from.split(".");
me.frm.add_fetch(parts[0], parts[1], df.fieldname, df.parent);
}

View file

@ -85,7 +85,7 @@ export default class Tab {
set_active() {
this.tab_link.find(".nav-link").tab("show");
this.wrapper.addClass("show");
this.frm.active_tab = this;
this.frm?.set_active_tab?.(this);
}
is_active() {

View file

@ -191,12 +191,16 @@ frappe.views.ListViewSelect = class ListViewSelect {
);
});
this.page.add_custom_menu_item(
kanban_switcher,
__("Create New Kanban Board"),
() => frappe.views.KanbanView.show_kanban_dialog(this.doctype),
true
);
let perms = this.list_view.board_perms;
let can_create = perms ? perms.create : true;
if (can_create) {
this.page.add_custom_menu_item(
kanban_switcher,
__("Create New Kanban Board"),
() => frappe.views.KanbanView.show_kanban_dialog(this.doctype),
true
);
}
}
get_page_name() {

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