Merge branch 'develop' of https://github.com/frappe/frappe into qb-fixes

This commit is contained in:
Aradhya 2022-10-20 22:25:44 +05:30
commit 4a573e252c
236 changed files with 5744 additions and 14141 deletions

View file

@ -13,5 +13,6 @@
"root_login": "root",
"root_password": "travis",
"host_name": "http://test_site:8000",
"monitor": 1,
"server_script_enabled": true
}

View file

@ -1,7 +1,7 @@
import sys
import requests
from urllib.parse import urlparse
import requests
docs_repos = [
"frappe_docs",

View file

@ -11,7 +11,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout Actions
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
repository: "frappe/backport"
path: ./actions

View file

@ -118,7 +118,8 @@ jobs:
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
BUILD_NUMBER: ${{ matrix.container }}
TOTAL_BUILDS: 2
- name: Upload coverage data
uses: actions/upload-artifact@v3

View file

@ -121,7 +121,8 @@ jobs:
env:
SITE: test_site
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
BUILD_NUMBER: ${{ matrix.container }}
TOTAL_BUILDS: 2
- name: Upload coverage data
uses: actions/upload-artifact@v3

View file

@ -37,7 +37,7 @@ jobs:
test:
runs-on: ubuntu-latest
needs: checkrun
if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.repository_owner == 'frappe' }}
timeout-minutes: 60
strategy:
@ -120,6 +120,13 @@ jobs:
TYPE: ui
DB: mariadb
- name: Verify yarn.lock
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: |
cd ~/frappe-bench/apps/frappe
yarn install --immutable --immutable-cache --check-cache
git diff --exit-code yarn.lock
- name: Instrument Source Code
run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe
@ -167,7 +174,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Download artifacts
uses: actions/download-artifact@v3

View file

@ -22,6 +22,7 @@ coverage:
comment:
layout: "diff, flags"
require_changes: true
show_critical_paths: true
flags:
server-mariadb:
@ -40,3 +41,24 @@ flags:
paths:
- "**/*.py"
carryforward: true
profiling:
critical_files_paths:
- /frappe/api.py
- /frappe/app.py
- /frappe/auth.py
- /frappe/boot.py
- /frappe/client.py
- /frappe/handler.py
- /frappe/migrate.py
- /frappe/sessions.py
- /frappe/utils/*
- /frappe/desk/reportview.py
- /frappe/desk/form/*
- /frappe/model/*
- /frappe/core/doctype/doctype/*
- /frappe/core/doctype/data_import/*
- /frappe/core/doctype/user/*
- /frappe/core/doctype/user/*
- /frappe/query_builder/*
- /frappe/database/*

View file

@ -17,7 +17,7 @@ context("Dashboard links", () => {
.window()
.its("frappe")
.then((frappe) => {
return frappe.xcall("frappe.tests.ui_test_helpers.update_child_table", {
frappe.call("frappe.tests.ui_test_helpers.update_child_table", {
name: child_table_doctype_name,
});
});

View file

@ -24,6 +24,7 @@ context("Folder Navigation", () => {
it("Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct", () => {
//Navigating inside the Attachments folder
cy.wait(500);
cy.get('[title="Attachments"] > span').click();
//To check if the URL formed after visiting the attachments folder is correct
@ -36,6 +37,7 @@ context("Folder Navigation", () => {
cy.click_modal_primary_button("Create");
//Navigating inside the added folder in the Attachments folder
cy.wait(500);
cy.get('[title="Test Folder"] > span').click();
//To check if the URL is correct after visiting the Test Folder
@ -51,7 +53,12 @@ context("Folder Navigation", () => {
cy.click_modal_primary_button("Upload");
//To check if the added file is present in the Test Folder
cy.get("span.level-item > span").should("contain", "Test Folder");
cy.visit("/app/file/view/home/Attachments");
cy.wait(500);
cy.get("span.level-item > a > span").should("contain", "Test Folder");
cy.visit("/app/file/view/home/Attachments/Test%20Folder");
cy.wait(500);
cy.get(".list-row-container").eq(0).should("contain.text", "72402.jpg");
cy.get(".list-row-checkbox").eq(0).click();

View file

@ -72,14 +72,14 @@ context("Timeline", () => {
cy.click_listview_row_item(0);
//To check if the submission of the documemt is visible in the timeline content
cy.get(".timeline-content").should("contain", "Frappe submitted this document");
cy.get(".timeline-content").should("contain", "You submitted this document");
cy.get('[id="page-Custom Submittable DocType"] .page-actions')
.findByRole("button", { name: "Cancel" })
.click();
cy.get_open_dialog().findByRole("button", { name: "Yes" }).click();
//To check if the cancellation of the documemt is visible in the timeline content
cy.get(".timeline-content").should("contain", "Frappe cancelled this document");
cy.get(".timeline-content").should("contain", "You cancelled this document");
//Deleting the document
cy.visit("/app/custom-submittable-doctype");

View file

@ -0,0 +1,231 @@
context("View", () => {
before(() => {
cy.login();
cy.visit("/app/website");
});
it("Route to ToDo List View", () => {
cy.visit("/app/todo/view/list");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("List");
});
});
it("Route to ToDo Report View", () => {
cy.visit("/app/todo/view/report");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
it("Route to ToDo Dashboard View", () => {
cy.visit("/app/todo/view/dashboard");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Dashboard");
});
});
it("Route to ToDo Gantt View", () => {
cy.visit("/app/todo/view/gantt");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Gantt");
});
});
it("Route to ToDo Kanban View", () => {
cy.call("frappe.tests.ui_test_helpers.create_kanban").then(() => {
cy.visit("/app/note/view/kanban/_Note _Kanban");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Kanban");
});
});
});
it("Route to ToDo Calendar View", () => {
cy.visit("/app/todo/view/calendar");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Calendar");
});
});
it("Route to Custom Tree View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_tree_doctype").then(() => {
cy.visit("/app/custom-tree/view/tree");
cy.wait(500);
cy.window()
.its("cur_tree")
.then((list) => {
expect(list.view_name).to.equal("Tree");
});
});
});
it("Route to Custom Image View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_image_doctype").then(() => {
cy.visit("app/custom-image/view/image");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Image");
});
});
});
it("Route to Communication Inbox View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_inbox").then(() => {
cy.visit("app/communication/view/inbox");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Inbox");
});
});
});
it("Route to File View", () => {
cy.visit("app/file");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("File");
expect(list.current_folder).to.equal("Home");
});
cy.visit("app/file/view/home/Attachments");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("File");
expect(list.current_folder).to.equal("Home/Attachments");
});
});
it("Re-route to default view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("app/event");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Route to default view from app/{doctype}", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("/app/event");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Route to default view from app/{doctype}/view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("/app/event/view");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Force Route to default view from app/{doctype}", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", {
view: "Report",
force_reroute: true,
}).then(() => {
cy.visit("/app/event");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Force Route to default view from app/{doctype}/view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", {
view: "Report",
force_reroute: true,
}).then(() => {
cy.visit("/app/event/view");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Force Route to default view from app/{doctype}/view", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", {
view: "Report",
force_reroute: true,
}).then(() => {
cy.visit("/app/event/view/list");
cy.wait(500);
cy.window()
.its("cur_list")
.then((list) => {
expect(list.view_name).to.equal("Report");
});
});
});
it("Validate Route History for Default View", () => {
cy.call("frappe.tests.ui_test_helpers.setup_default_view", { view: "Report" }).then(() => {
cy.visit("/app/event");
cy.visit("/app/event/view/list");
cy.location("pathname").should("eq", "/app/event/view/list");
cy.go("back");
cy.location("pathname").should("eq", "/app/event");
});
});
it("Route to Form", () => {
cy.call("frappe.tests.ui_test_helpers.create_note").then(() => {
cy.visit("/app/note/Routing Test");
cy.window()
.its("cur_frm")
.then((frm) => {
expect(frm.doc.title).to.equal("Routing Test");
});
});
});
it("Route to Settings Workspace", () => {
cy.visit("/app/settings");
cy.get(".title-text").should("contain", "Settings");
});
});

View file

@ -42,7 +42,7 @@ context("Workspace Blocks", () => {
cy.wait("@new_page");
});
it("Quick List Block", () => {
it.skip("Quick List Block", () => {
cy.create_records([
{
doctype: "ToDo",
@ -71,7 +71,6 @@ context("Workspace Blocks", () => {
url: "api/method/frappe.desk.form.load.getdoctype?**",
}).as("get_doctype");
cy.visit("/app/tools");
cy.get(".codex-editor__redactor .ce-block");
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
@ -79,10 +78,8 @@ context("Workspace Blocks", () => {
cy.get(".ce-block").first().click({ force: true }).type("{enter}");
cy.get(".block-list-container .block-list-item").contains("Quick List").click();
cy.get_open_dialog().find(".modal-header").click();
cy.fill_field("label", "ToDo", "Data");
cy.fill_field("document_type", "ToDo", "Link").blur();
cy.fill_field("label", "ToDo", "Data").blur();
cy.wait("@get_doctype");
cy.get_open_dialog().find(".filter-edit-area").should("contain", "No filters selected");
@ -122,6 +119,7 @@ context("Workspace Blocks", () => {
cy.get_open_dialog()
.find(".filter-field .input-with-feedback")
.focus()
.type("{selectall}Approved");
cy.get_open_dialog().find(".modal-header").click();
cy.get_open_dialog().find(".btn-primary").click();

View file

@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => {
Cypress.Commands.add("save", () => {
cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call");
cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: false, force: true });
cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true });
cy.wait("@save_call");
});
Cypress.Commands.add("hide_dialog", () => {

View file

@ -3,11 +3,12 @@ const path = require("path");
const fs = require("fs");
const glob = require("fast-glob");
const esbuild = require("esbuild");
const vue = require("esbuild-vue");
const vue = require("esbuild-plugin-vue3");
const yargs = require("yargs");
const cliui = require("cliui")();
const chalk = require("chalk");
const html_plugin = require("./frappe-html");
const vue_style_plugin = require("./frappe-vue-style");
const rtlcss = require("rtlcss");
const postCssPlugin = require("@frappe/esbuild-plugin-postcss2").default;
const ignore_assets = require("./ignore-assets");
@ -218,7 +219,7 @@ function get_files_to_build(files) {
}
function build_files({ files, outdir }) {
let build_plugins = [html_plugin, build_cleanup_plugin, vue()];
let build_plugins = [vue(), html_plugin, build_cleanup_plugin, vue_style_plugin];
return esbuild.build(get_build_options(files, outdir, build_plugins));
}
@ -254,6 +255,8 @@ function get_build_options(files, outdir, plugins) {
nodePaths: NODE_PATHS,
define: {
"process.env.NODE_ENV": JSON.stringify(PRODUCTION ? "production" : "development"),
__VUE_OPTIONS_API__: JSON.stringify(true),
__VUE_PROD_DEVTOOLS__: JSON.stringify(false),
},
plugins: plugins,
watch: get_watch_config(),

View file

@ -0,0 +1,59 @@
const fs = require("fs");
const path = require("path");
const { sites_path } = require("./utils");
module.exports = {
name: "frappe-vue-style",
setup(build) {
build.initialOptions.write = false;
build.onEnd((result) => {
let files = get_files(result.metafile.outputs);
let keys = Object.keys(files);
for (let out of result.outputFiles) {
let asset_path = "/" + path.relative(sites_path, out.path);
let dir = path.dirname(out.path);
if (out.path.endsWith(".js") && keys.includes(asset_path)) {
let name = out.path.split(".bundle.")[0];
name = path.basename(name);
let index = result.outputFiles.findIndex((f) => {
return f.path.endsWith(".css") && f.path.includes(`/${name}.bundle.`);
});
let css_data = JSON.stringify(result.outputFiles[index].text);
let modified = `frappe.dom.set_style(${css_data});\n${out.text}`;
out.contents = Buffer.from(modified);
result.outputFiles.splice(index, 1);
if (result.outputFiles[index - 1].path.endsWith(".css.map")) {
result.outputFiles.splice(index - 1, 1);
}
}
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFile(out.path, out.contents, (err) => {
err && console.error(err);
});
}
});
},
};
function get_files(files) {
let result = {};
for (let file in files) {
let info = files[file];
let asset_path = "/" + path.relative(sites_path, file);
if (info && info.entryPoint && Object.keys(info.inputs).length !== 0) {
for (let input in info.inputs) {
if (input.includes(".vue?type=style")) {
let bundle_css = path.basename(info.entryPoint).replace(".js", ".css");
result[asset_path] = bundle_css;
break;
}
}
}
}
return result;
}

View file

@ -452,7 +452,7 @@ def msgprint(
if as_list and type(msg) in (list, tuple):
out.as_list = 1
if sys.stdin.isatty():
if sys.stdin and sys.stdin.isatty():
msg = _strip_html_tags(out.message)
if flags.print_messages and out.message:

View file

@ -3,6 +3,7 @@
import base64
import binascii
import json
from typing import Literal
from urllib.parse import urlencode, urlparse
import frappe
@ -49,106 +50,148 @@ def handle():
if len(parts) > 3:
name = parts[3]
if call == "method":
frappe.local.form_dict.cmd = doctype
return frappe.handler.handle()
return _RESTAPIHandler(call, doctype, name).get_response()
elif call == "resource":
if "run_method" in frappe.local.form_dict:
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(doctype, name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
class _RESTAPIHandler:
def __init__(self, call: Literal["method", "resource"], doctype: str | None, name: str | None):
self.call = call
self.doctype = doctype
self.name = name
if frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
def get_response(self):
"""Prepare and get response based on URL and form body.
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
else:
if name:
if frappe.local.request.method == "GET":
doc = frappe.get_doc(doctype, name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.local.response.update({"data": doc})
if frappe.local.request.method == "PUT":
data = get_request_form_data()
doc = frappe.get_doc(doctype, name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
if frappe.local.request.method == "DELETE":
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(doctype, name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
elif doctype:
if frappe.local.request.method == "GET":
# set fields for frappe.get_list
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
if frappe.local.request.method == "POST":
# fetch data from from dict
data = get_request_form_data()
data.update({"doctype": doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
else:
Note: most methods of this class directly operate on the response local.
"""
match self.call:
case "method":
return self.handle_method()
case "resource":
self.handle_resource()
case _:
raise frappe.DoesNotExistError
else:
raise frappe.DoesNotExistError
return build_response("json")
return build_response("json")
def handle_method(self):
frappe.local.form_dict.cmd = self.doctype
return frappe.handler.handle()
def handle_resource(self):
if self.doctype and self.name:
self.handle_document_resource()
elif self.doctype:
self.handle_doctype_resource()
else:
raise frappe.DoesNotExistError
def handle_document_resource(self):
if "run_method" in frappe.local.form_dict:
self.execute_doc_method()
return
match frappe.local.request.method:
case "GET":
self.get_doc()
case "PUT":
self.update_doc()
case "DELETE":
self.delete_doc()
case _:
raise frappe.DoesNotExistError
def handle_doctype_resource(self):
match frappe.local.request.method:
case "GET":
self.get_doc_list()
case "POST":
self.create_doc()
case _:
raise frappe.DoesNotExistError
def execute_doc_method(self):
method = frappe.local.form_dict.pop("run_method")
doc = frappe.get_doc(self.doctype, self.name)
doc.is_whitelisted(method)
if frappe.local.request.method == "GET":
if not doc.has_permission("read"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
elif frappe.local.request.method == "POST":
if not doc.has_permission("write"):
frappe.throw(_("Not permitted"), frappe.PermissionError)
frappe.local.response.update({"data": doc.run_method(method, **frappe.local.form_dict)})
frappe.db.commit()
def get_doc(self):
doc = frappe.get_doc(self.doctype, self.name)
if not doc.has_permission("read"):
raise frappe.PermissionError
frappe.local.response.update({"data": doc})
def update_doc(self):
data = get_request_form_data()
doc = frappe.get_doc(self.doctype, self.name, for_update=True)
if "flags" in data:
del data["flags"]
# Not checking permissions here because it's checked in doc.save
doc.update(data)
frappe.local.response.update({"data": doc.save().as_dict()})
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
frappe.db.commit()
def delete_doc(self):
# Not checking permissions here because it's checked in delete_doc
frappe.delete_doc(self.doctype, self.name, ignore_missing=False)
frappe.local.response.http_status_code = 202
frappe.local.response.message = "ok"
frappe.db.commit()
def get_doc_list(self):
if frappe.local.form_dict.get("fields"):
frappe.local.form_dict["fields"] = json.loads(frappe.local.form_dict["fields"])
# set limit of records for frappe.get_list
frappe.local.form_dict.setdefault(
"limit_page_length",
frappe.local.form_dict.limit or frappe.local.form_dict.limit_page_length or 20,
)
# convert strings to native types - only as_dict and debug accept bool
for param in ["as_dict", "debug"]:
param_val = frappe.local.form_dict.get(param)
if param_val is not None:
frappe.local.form_dict[param] = sbool(param_val)
# evaluate frappe.get_list
data = frappe.call(frappe.client.get_list, self.doctype, **frappe.local.form_dict)
# set frappe.get_list result to response
frappe.local.response.update({"data": data})
def create_doc(self):
data = get_request_form_data()
data.update({"doctype": self.doctype})
# insert document from request data
doc = frappe.get_doc(data).insert()
# set response data
frappe.local.response.update({"data": doc.as_dict()})
# commit for POST requests
frappe.db.commit()
def get_request_form_data():

View file

@ -101,6 +101,7 @@ def get_bootinfo():
bootinfo.app_logo_url = get_app_logo()
bootinfo.link_title_doctypes = get_link_title_doctypes()
bootinfo.translated_doctypes = get_translated_doctypes()
bootinfo.subscription_expiry = add_subscription_expiry()
return bootinfo
@ -428,3 +429,10 @@ def load_currency_docs(bootinfo):
)
bootinfo.docs += currency_docs
def add_subscription_expiry():
try:
return frappe.conf.subscription["expiry"]
except Exception:
return ""

View file

@ -129,12 +129,18 @@ def clear_doctype_cache(doctype=None):
clear_single(doctype)
# clear all parent doctypes
for dt in frappe.get_all(
"DocField", "parent", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
):
clear_single(dt.parent)
# clear all parent doctypes
if not frappe.flags.in_install:
for dt in frappe.get_all(
"Custom Field", "dt", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
):
clear_single(dt.dt)
# clear all notifications
delete_notification_count_for(doctype)

View file

@ -270,7 +270,7 @@ def delete(doctype, name):
:param doctype: DocType of the document to be deleted
:param name: name of the document to be deleted"""
frappe.delete_doc(doctype, name, ignore_missing=False)
delete_doc(doctype, name)
@frappe.whitelist(methods=["POST", "PUT"])
@ -462,3 +462,24 @@ def insert_doc(doc) -> "Document":
return parent
return frappe.get_doc(doc).insert()
def delete_doc(doctype, name):
"""Deletes document
if doctype is a child table, then deletes the child record using the parent doc
so that the parent doc's `on_update` is called
"""
if frappe.is_table(doctype):
values = frappe.db.get_value(doctype, name, ["parenttype", "parent", "parentfield"])
if not values:
raise frappe.DoesNotExistError
parenttype, parent, parentfield = values
parent = frappe.get_doc(parenttype, parent)
for row in parent.get(parentfield):
if row.name == name:
parent.remove(row)
parent.save()
break
else:
frappe.delete_doc(doctype, name, ignore_missing=False)

View file

@ -819,9 +819,16 @@ def run_tests(
@click.option("--total-builds", help="Total number of builds", default=1)
@click.option("--with-coverage", is_flag=True, help="Build coverage file")
@click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests")
@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests")
@pass_context
def run_parallel_tests(
context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False
context,
app,
build_number,
total_builds,
with_coverage=False,
use_orchestrator=False,
dry_run=False,
):
from traceback_with_variables import activate_by_import
@ -834,7 +841,13 @@ def run_parallel_tests(
else:
from frappe.parallel_test_runner import ParallelTestRunner
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
ParallelTestRunner(
app,
site=site,
build_number=build_number,
total_builds=total_builds,
dry_run=dry_run,
)
@click.command(

View file

@ -1,11 +1,5 @@
import frappe
from frappe import _
from frappe.desk.moduleview import (
config_exists,
get_data,
get_module_link_items_from_list,
get_onboard_items,
)
def get_modules_from_all_apps_for_user(user: str = None) -> list[dict]:
@ -25,9 +19,6 @@ def get_modules_from_all_apps_for_user(user: str = None) -> list[dict]:
if module_name in empty_tables_by_module:
module["onboard_present"] = 1
# Set defaults links
module["links"] = get_onboard_items(module["app"], frappe.scrub(module_name))[:5]
return allowed_modules_list

View file

@ -228,11 +228,12 @@ def get_company_address(company):
def address_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_match_cond
doctype = "Address"
link_doctype = filters.pop("link_doctype")
link_name = filters.pop("link_name")
condition = ""
meta = frappe.get_meta("Address")
meta = frappe.get_meta(doctype)
for fieldname, value in filters.items():
if meta.get_field(fieldname) or fieldname in frappe.db.DEFAULT_COLUMNS:
condition += f" and {fieldname}={frappe.db.escape(value)}"

View file

@ -210,8 +210,9 @@ def update_contact(doc, method):
def contact_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_match_cond
doctype = "Contact"
if (
not frappe.get_meta("Contact").get_field(searchfield)
not frappe.get_meta(doctype).get_field(searchfield)
and searchfield not in frappe.db.DEFAULT_COLUMNS
):
return []

View file

@ -2,6 +2,7 @@
"actions": [],
"allow_import": 1,
"creation": "2013-01-29 10:47:14",
"default_view": "Inbox",
"description": "Keeps track of all communications",
"doctype": "DocType",
"document_type": "Setup",
@ -198,7 +199,6 @@
"label": "More Information"
},
{
"bold": 0,
"default": "Now",
"fieldname": "communication_date",
"fieldtype": "Datetime",
@ -395,7 +395,7 @@
"icon": "fa fa-comment",
"idx": 1,
"links": [],
"modified": "2022-03-30 11:24:25.728637",
"modified": "2022-05-09 00:13:45.310564",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
@ -454,8 +454,9 @@
"sender_field": "sender",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"subject_field": "subject",
"title_field": "subject",
"track_changes": 1,
"track_seen": 1
}
}

View file

@ -19,6 +19,7 @@ from frappe.core.doctype.communication.mixins import CommunicationEmailMixin
from frappe.core.utils import get_parent_doc
from frappe.model.document import Document
from frappe.utils import (
cstr,
parse_addr,
split_emails,
strip_html,
@ -175,7 +176,7 @@ class Communication(Document, CommunicationEmailMixin):
if html_signature:
_signature = html_signature.renderContents()
if (_signature or signature) not in self.content:
if (cstr(_signature) or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'
def before_save(self):

View file

@ -165,7 +165,8 @@ class CommunicationEmailMixin:
)
if self.sent_or_received == "Sent" and self._outgoing_email_account:
self.db_set("email_account", self._outgoing_email_account.name)
if frappe.db.exists("Email Account", self._outgoing_email_account.name):
self.db_set("email_account", self._outgoing_email_account.name)
return self._outgoing_email_account

View file

@ -27,6 +27,9 @@ frappe.ui.form.on("Data Export", {
reset_filter_and_field(frm);
}
},
export_without_main_header: (frm) => {
frm.refresh();
},
});
const can_export = (frm) => {
@ -58,8 +61,9 @@ const export_data = (frm) => {
select_columns: JSON.stringify(columns),
filters: frm.filter_list.get_filters().map((filter) => filter.slice(1, 4)),
file_type: frm.doc.file_type,
template: true,
template: !frm.doc.export_without_main_header,
with_data: 1,
export_without_column_meta: frm.doc.export_without_main_header ? true : false,
};
};

View file

@ -6,6 +6,7 @@
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"export_without_main_header",
"column_break_2",
"file_type",
"section_break",
@ -47,12 +48,19 @@
"fieldname": "fields_multicheck",
"fieldtype": "HTML",
"label": "Fields Multicheck"
},
{
"default": "0",
"description": "Export the data without any header notes and column descriptions",
"fieldname": "export_without_main_header",
"fieldtype": "Check",
"label": "Export without main header"
}
],
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2022-08-03 12:20:53.658574",
"modified": "2022-09-28 03:51:02.404681",
"modified_by": "Administrator",
"module": "Core",
"name": "Data Export",

View file

@ -37,6 +37,7 @@ def export_data(
file_type="CSV",
template=False,
filters=None,
export_without_column_meta=False,
):
_doctype = doctype
if isinstance(_doctype, list):
@ -48,6 +49,15 @@ def export_data(
filters=filters,
method=parent_doctype,
)
template_bool = template
if isinstance(template, str):
template_bool = template.lower() == "true"
export_without_column_meta_bool = export_without_column_meta
if isinstance(export_without_column_meta, str):
export_without_column_meta_bool = export_without_column_meta.lower() == "true"
exporter = DataExporter(
doctype=doctype,
parent_doctype=parent_doctype,
@ -55,8 +65,9 @@ def export_data(
with_data=with_data,
select_columns=select_columns,
file_type=file_type,
template=template,
template=template_bool,
filters=filters,
export_without_column_meta=export_without_column_meta_bool,
)
exporter.build_response()
@ -72,6 +83,7 @@ class DataExporter:
file_type="CSV",
template=False,
filters=None,
export_without_column_meta=False,
):
self.doctype = doctype
self.parent_doctype = parent_doctype
@ -81,6 +93,7 @@ class DataExporter:
self.file_type = file_type
self.template = template
self.filters = filters
self.export_without_column_meta = export_without_column_meta
self.data_keys = get_data_keys()
self.prepare_args()
@ -117,7 +130,10 @@ class DataExporter:
if self.template:
self.add_main_header()
self.writer.writerow([""])
# No need of empty row at the start
if not self.export_without_column_meta:
self.writer.writerow([""])
self.tablerow = [self.data_keys.doctype]
self.labelrow = [_("Column Labels:")]
self.fieldrow = [self.data_keys.columns]
@ -310,12 +326,18 @@ class DataExporter:
return ""
def add_field_headings(self):
self.writer.writerow(self.tablerow)
if not self.export_without_column_meta:
self.writer.writerow(self.tablerow)
# Just include Labels in the first row
self.writer.writerow(self.labelrow)
self.writer.writerow(self.fieldrow)
self.writer.writerow(self.mandatoryrow)
self.writer.writerow(self.typerow)
self.writer.writerow(self.inforow)
if not self.export_without_column_meta:
self.writer.writerow(self.fieldrow)
self.writer.writerow(self.mandatoryrow)
self.writer.writerow(self.typerow)
self.writer.writerow(self.inforow)
if self.template:
self.writer.writerow([self.data_keys.data_separator])

View file

@ -449,7 +449,7 @@ class ImportFile:
continue
if not header:
header = Header(i, row, self.doctype, self.raw_data, self.column_to_field_map)
header = Header(i, row, self.doctype, self.raw_data[1:], self.column_to_field_map)
else:
row_obj = Row(i, row, self.doctype, header, self.import_type)
data.append(row_obj)
@ -572,12 +572,15 @@ class ImportFile:
######
def read_file(self, file_path):
def read_file(self, file_path: str):
extn = os.path.splitext(file_path)[1][1:]
file_content = None
with open(file_path, mode="rb") as f:
file_content = f.read()
file_name = frappe.db.get_value("File", {"file_url": file_path})
if file_name:
file = frappe.get_doc("File", file_name)
file_content = file.get_content()
return file_content, extn
@ -981,9 +984,12 @@ class Column:
if self.skip_import:
return
if not any(self.column_values):
return
if self.df.fieldtype == "Link":
# find all values that dont exist
values = list({cstr(v) for v in self.column_values[1:] if v})
values = list({cstr(v) for v in self.column_values if v})
exists = [
cstr(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})
]
@ -1022,7 +1028,7 @@ class Column:
elif self.df.fieldtype == "Select":
options = get_select_options(self.df)
if options:
values = {cstr(v) for v in self.column_values[1:] if v}
values = {cstr(v) for v in self.column_values if v}
invalid = values - set(options)
if invalid:
valid_values = ", ".join(frappe.bold(o) for o in options)

View file

@ -55,6 +55,7 @@ frappe.ui.form.on("DocType", {
if (frm.is_new()) {
frm.events.set_default_permission(frm);
frm.set_value("default_view", "List");
} else {
frm.toggle_enable("engine", 0);
}
@ -66,12 +67,14 @@ frappe.ui.form.on("DocType", {
frm.cscript.autoname(frm);
frm.cscript.set_naming_rule_description(frm);
frm.trigger("setup_default_views");
},
istable: (frm) => {
if (frm.doc.istable && frm.is_new()) {
frm.set_value("autoname", "autoincrement");
frm.set_value("allow_rename", 0);
frm.set_value("default_view", null);
} else if (!frm.doc.istable && !frm.is_new()) {
frm.events.set_default_permission(frm);
}
@ -82,6 +85,18 @@ frappe.ui.form.on("DocType", {
frm.add_child("permissions", { role: "System Manager" });
}
},
is_tree: (frm) => {
frm.trigger("setup_default_views");
},
is_calendar_and_gantt: (frm) => {
frm.trigger("setup_default_views");
},
setup_default_views: (frm) => {
frappe.model.set_default_views_for_doctype(frm.doc.name, frm);
},
});
frappe.ui.form.on("DocField", {
@ -171,6 +186,10 @@ frappe.ui.form.on("DocField", {
fieldtype: function (frm) {
frm.trigger("max_attachments");
},
fields_add: (frm) => {
frm.trigger("setup_default_views");
},
});
extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({ frm: cur_frm }));

View file

@ -14,6 +14,7 @@
"istable",
"issingle",
"is_tree",
"is_calendar_and_gantt",
"editable_grid",
"quick_entry",
"cb01",
@ -53,6 +54,8 @@
"default_print_format",
"sort_field",
"sort_order",
"default_view",
"force_re_route_to_default_view",
"column_break_29",
"document_type",
"icon",
@ -320,7 +323,8 @@
"depends_on": "eval:!doc.istable",
"fieldname": "title_field",
"fieldtype": "Data",
"label": "Title Field"
"label": "Title Field",
"mandatory_depends_on": "eval:doc.show_title_field_in_link"
},
{
"depends_on": "eval:!doc.istable",
@ -605,6 +609,24 @@
"fieldname": "make_attachments_public",
"fieldtype": "Check",
"label": "Make Attachments Public by Default"
},
{
"fieldname": "default_view",
"fieldtype": "Select",
"label": "Default View"
},
{
"default": "0",
"fieldname": "force_re_route_to_default_view",
"fieldtype": "Check",
"label": "Force Re-route to Default View"
},
{
"default": "0",
"description": "Enables Calendar and Gantt views.",
"fieldname": "is_calendar_and_gantt",
"fieldtype": "Check",
"label": "Is Calendar and Gantt"
}
],
"icon": "fa fa-bolt",
@ -687,7 +709,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-08-24 06:42:27.779699",
"modified": "2022-10-12 14:13:27.315351",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -670,6 +670,18 @@ class TestDocType(FrappeTestCase):
self.assertEqual(test_json.test_json_field["hello"], "world")
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_custom_field_deletion(self):
"""Custom child tables whose doctype doesn't exist should be auto deleted."""
doctype = new_doctype(custom=0).insert().name
child = new_doctype(custom=0, istable=1).insert().name
field = "abc"
create_custom_fields({doctype: [{"fieldname": field, "fieldtype": "Table", "options": child}]})
frappe.delete_doc("DocType", child)
self.assertFalse(frappe.get_meta(doctype).get_field(field))
@patch.dict(frappe.conf, {"developer_mode": 1})
def test_delete_doctype_with_customization(self):
from frappe.custom.doctype.property_setter.property_setter import make_property_setter

View file

@ -2,6 +2,7 @@
"actions": [],
"allow_import": 1,
"creation": "2012-12-12 11:19:22",
"default_view": "File",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
@ -169,10 +170,11 @@
"read_only": 1
}
],
"force_re_route_to_default_view": 1,
"icon": "fa fa-file",
"idx": 1,
"links": [],
"modified": "2022-09-13 15:50:15.508250",
"modified": "2022-09-13 15:50:15.508251",
"modified_by": "Administrator",
"module": "Core",
"name": "File",

View file

@ -422,7 +422,6 @@ class File(Document):
return os.path.exists(self.get_full_path())
def get_content(self) -> bytes:
"""Returns [`file_name`, `content`] for given file name `fname`"""
if self.is_folder:
frappe.throw(_("Cannot get file contents of a Folder"))

View file

@ -8,6 +8,7 @@ import frappe
from frappe.desk.form.load import get_attachments
from frappe.desk.query_report import generate_report_result
from frappe.model.document import Document
from frappe.monitor import add_data_to_monitor
from frappe.utils import gzip_compress, gzip_decompress
from frappe.utils.background_jobs import enqueue
@ -25,6 +26,8 @@ def run_background(prepared_report):
instance = frappe.get_doc("Prepared Report", prepared_report)
report = frappe.get_doc("Report", instance.ref_report_doctype)
add_data_to_monitor(report=instance.ref_report_doctype)
try:
report.custom_columns = []

View file

@ -8,26 +8,28 @@ frappe.ui.form.on("Report", {
}
let doc = frm.doc;
frm.add_custom_button(
__("Show Report"),
function () {
switch (doc.report_type) {
case "Report Builder":
frappe.set_route("List", doc.ref_doctype, "Report", doc.name);
break;
case "Query Report":
frappe.set_route("query-report", doc.name);
break;
case "Script Report":
frappe.set_route("query-report", doc.name);
break;
case "Custom Report":
frappe.set_route("query-report", doc.name);
break;
}
},
"fa fa-table"
);
if (!doc.__islocal) {
frm.add_custom_button(
__("Show Report"),
function () {
switch (doc.report_type) {
case "Report Builder":
frappe.set_route("List", doc.ref_doctype, "Report", doc.name);
break;
case "Query Report":
frappe.set_route("query-report", doc.name);
break;
case "Script Report":
frappe.set_route("query-report", doc.name);
break;
case "Custom Report":
frappe.set_route("query-report", doc.name);
break;
}
},
"fa fa-table"
);
}
if (doc.is_standard === "Yes" && frm.perm[0].write) {
frm.add_custom_button(

View file

@ -23,8 +23,10 @@ class RQWorker(Document):
start = cint(args.get("start")) or 0
page_length = cint(args.get("page_length")) or 20
workers = get_workers()[start : start + page_length]
return [serialize_worker(worker) for worker in workers]
workers = get_workers()
valid_workers = [w for w in workers if w.pid][start : start + page_length]
return [serialize_worker(worker) for worker in valid_workers]
@staticmethod
def get_count(args) -> int:

View file

@ -237,7 +237,7 @@ class User(Document):
)
def share_with_self(self):
frappe.share.add(
frappe.share.add_docshare(
self.doctype, self.name, self.name, write=1, share=1, flags={"ignore_share_permission": True}
)
@ -901,6 +901,7 @@ def reset_password(user):
def user_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_filters_cond, get_match_cond
doctype = "User"
conditions = []
user_type_condition = "and user_type != 'Website User'"

View file

@ -22,7 +22,7 @@ class Recorder {
}
show() {
if (!this.view || this.view.$route.name == "recorder-detail") return;
this.view.$router.replace({ name: "recorder-detail" });
if (!this.route || this.route.name == "RecorderDetail") return;
this.router?.replace({ name: "RecorderDetail" });
}
}

View file

@ -0,0 +1,7 @@
// Copyright (c) 2022, Frappe Technologies and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Database Storage Usage By Tables"] = {
filters: [],
};

View file

@ -0,0 +1,28 @@
{
"add_total_row": 1,
"columns": [],
"creation": "2022-10-19 02:25:24.326791",
"disable_prepared_report": 0,
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": "abc",
"modified": "2022-10-19 02:59:00.365307",
"modified_by": "Administrator",
"module": "Core",
"name": "Database Storage Usage By Tables",
"owner": "Administrator",
"prepared_report": 0,
"query": "",
"ref_doctype": "Error Log",
"report_name": "Database Storage Usage By Tables",
"report_type": "Script Report",
"roles": [
{
"role": "System Manager"
}
]
}

View file

@ -0,0 +1,40 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
COLUMNS = [
{"label": "Table", "fieldname": "table", "fieldtype": "Data", "width": 200},
{"label": "Size (MB)", "fieldname": "size", "fieldtype": "Float"},
{"label": "Data (MB)", "fieldname": "data_size", "fieldtype": "Float"},
{"label": "Index (MB)", "fieldname": "index_size", "fieldtype": "Float"},
]
def execute(filters=None):
frappe.only_for("System Manager")
data = frappe.db.multisql(
{
"mariadb": """
SELECT table_name AS `table`,
round(((data_length + index_length) / 1024 / 1024), 2) `size`,
round((data_length / 1024 / 1024), 2) as data_size,
round((index_length / 1024 / 1024), 2) as index_size
FROM information_schema.TABLES
ORDER BY (data_length + index_length) DESC;
""",
"postgres": """
SELECT
table_name as "table",
round(pg_total_relation_size(quote_ident(table_name)) / 1024 / 1024, 2) as "size",
round(pg_relation_size(quote_ident(table_name)) / 1024 / 1024, 2) as "data_size",
round(pg_indexes_size(quote_ident(table_name)) / 1024 / 1024, 2) as "index_size"
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY 2 DESC;
""",
},
as_dict=1,
)
return COLUMNS, data

View file

@ -0,0 +1,15 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
from frappe.core.report.database_storage_usage_by_tables.database_storage_usage_by_tables import (
execute,
)
from frappe.tests.utils import FrappeTestCase
class TestDBUsageReport(FrappeTestCase):
def test_basic_query(self):
_, data = execute()
tables = [d.table for d in data]
self.assertFalse({"tabUser", "tabDocField"}.difference(tables))

View file

@ -102,6 +102,20 @@ class CustomField(Document):
# delete property setter entries
frappe.db.delete("Property Setter", {"doc_type": self.dt, "field_name": self.fieldname})
# update doctype layouts
doctype_layouts = frappe.get_all(
"DocType Layout", filters={"document_type": self.dt}, pluck="name"
)
for layout in doctype_layouts:
layout_doc = frappe.get_doc("DocType Layout", layout)
for field in layout_doc.fields:
if field.fieldname == self.fieldname:
layout_doc.remove(field)
layout_doc.save()
break
frappe.clear_cache(doctype=self.dt)
def validate_insert_after(self, meta):

View file

@ -72,6 +72,7 @@ frappe.ui.form.on("Customize Form", {
} else {
frm.refresh();
frm.trigger("setup_sortable");
frm.trigger("setup_default_views");
}
}
localStorage["customize_doctype"] = frm.doc.doc_type;
@ -82,8 +83,12 @@ frappe.ui.form.on("Customize Form", {
}
},
is_calendar_and_gantt: function (frm) {
frm.trigger("setup_default_views");
},
setup_sortable: function (frm) {
frm.doc.fields.forEach(function (f, i) {
frm.doc.fields.forEach(function (f) {
if (!f.is_custom_field) {
f._sortable = false;
}
@ -178,6 +183,7 @@ frappe.ui.form.on("Customize Form", {
fieldname: "module",
options: "Module Def",
label: __("Module to Export"),
reqd: 1,
},
{
fieldtype: "Check",
@ -221,6 +227,10 @@ frappe.ui.form.on("Customize Form", {
frm.set_df_property("sort_field", "options", fields);
}
},
setup_default_views(frm) {
frappe.model.set_default_views_for_doctype(frm.doc.doc_type, frm);
},
});
// can't delete standard fields
@ -236,6 +246,7 @@ frappe.ui.form.on("Customize Form Field", {
var f = frappe.model.get_doc(cdt, cdn);
f.is_system_generated = false;
f.is_custom_field = true;
frm.trigger("setup_default_views");
},
});

View file

@ -13,6 +13,7 @@
"search_fields",
"column_break_5",
"istable",
"is_calendar_and_gantt",
"editable_grid",
"quick_entry",
"track_changes",
@ -35,6 +36,8 @@
"show_title_field_in_link",
"translated_doctype",
"default_print_format",
"default_view",
"force_re_route_to_default_view",
"column_break_29",
"show_preview_popup",
"email_settings_section",
@ -337,6 +340,25 @@
"fieldname": "make_attachments_public",
"fieldtype": "Check",
"label": "Make Attachments Public by Default"
},
{
"fieldname": "default_view",
"fieldtype": "Select",
"label": "Default View"
},
{
"default": "0",
"depends_on": "default_view",
"fieldname": "force_re_route_to_default_view",
"fieldtype": "Check",
"label": "Force Re-route to Default View"
},
{
"default": "0",
"description": "Enables Calendar and Gantt views.",
"fieldname": "is_calendar_and_gantt",
"fieldtype": "Check",
"label": "Is Calendar and Gantt"
}
],
"hide_toolbar": 1,
@ -345,7 +367,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-08-24 06:57:47.966331",
"modified": "2022-08-30 11:45:16.772277",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -586,6 +586,10 @@ doctype_properties = {
"naming_rule": "Data",
"autoname": "Data",
"show_title_field_in_link": "Check",
"translate_link_fields": "Check",
"is_calendar_and_gantt": "Check",
"default_view": "Select",
"force_re_route_to_default_view": "Check",
"translated_doctype": "Check",
}

View file

@ -54,7 +54,7 @@ class TestCustomizeForm(FrappeTestCase):
d = self.get_customize_form("Event")
self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")), 36)
self.assertEqual(len(d.get("fields")), 38)
d = self.get_customize_form("Event")
self.assertEqual(d.doc_type, "Event")

View file

@ -2,31 +2,104 @@
// For license information, please see license.txt
frappe.ui.form.on("DocType Layout", {
refresh: function (frm) {
frm.trigger("document_type");
frm.events.set_button(frm);
onload_post_render(frm) {
// disallow users from manually adding/deleting rows; this doctype should only
// be used for managing layout, and docfields and custom fields should be used
// to manage other field metadata (hidden, etc.)
frm.set_df_property("fields", "cannot_add_rows", true);
frm.set_df_property("fields", "cannot_delete_rows", true);
$(frm.wrapper).on("grid-move-row", (e, frm) => {
// refresh the layout after moving a row
frm.dirty();
});
},
document_type(frm) {
frm.set_fields_as_options("fields", frm.doc.document_type, null, [], "fieldname").then(
() => {
// child table empty? then show all fields as default
if (frm.doc.document_type) {
if (!(frm.doc.fields || []).length) {
for (let f of frappe.get_doc("DocType", frm.doc.document_type).fields) {
frm.add_child("fields", { fieldname: f.fieldname, label: f.label });
}
}
}
refresh(frm) {
frm.events.add_buttons(frm);
},
async document_type(frm) {
if (frm.doc.document_type) {
// refreshing the doctype fields resets the new name input field;
// once the fields are set, reset the name to the original input
if (frm.is_new()) {
const document_name = frm.doc.__newname || frm.doc.name;
}
);
frm.set_value("fields", []);
await frm.events.sync_fields(frm, false);
if (frm.is_new()) {
frm.doc.__newname = document_name;
frm.refresh_field("__newname");
}
}
},
set_button(frm) {
add_buttons(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__("Go to {0} List", [frm.doc.name]), () => {
window.open(`/app/${frappe.router.slug(frm.doc.name)}`);
});
frm.add_custom_button(__("Sync {0} Fields", [frm.doc.name]), async () => {
await frm.events.sync_fields(frm, true);
});
}
},
async sync_fields(frm, notify) {
frappe.dom.freeze("Fetching fields...");
const response = await frm.call({ doc: frm.doc, method: "sync_fields" });
frm.refresh_field("fields");
frappe.dom.unfreeze();
if (!response.message) {
frappe.msgprint(__("No changes to sync"));
return;
}
frm.dirty();
if (notify) {
const addedFields = response.message.added;
const removedFields = response.message.removed;
const getChangedMessage = (fields) => {
let changes = "";
for (const field of fields) {
if (field.label) {
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()} (${
field.label
})</li>`;
} else {
changes += `<li>Row #${field.idx}: ${field.fieldname.bold()}</li>`;
}
}
return changes;
};
let message = "";
if (addedFields.length) {
message += `The following fields have been added:<br><br><ul>${getChangedMessage(
addedFields
)}</ul>`;
}
if (removedFields.length) {
message += `The following fields have been removed:<br><br><ul>${getChangedMessage(
removedFields
)}</ul>`;
}
if (message) {
frappe.msgprint({
message: __(message),
indicator: "green",
title: __("Synced Fields"),
});
}
}
},
});

View file

@ -1,7 +1,7 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "Prompt",
"autoname": "prompt",
"creation": "2020-11-16 17:05:35.306846",
"doctype": "DocType",
"editable_grid": 1,
@ -19,7 +19,8 @@
"in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"reqd": 1
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "fields",
@ -42,10 +43,11 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-12-10 15:01:04.352184",
"modified": "2022-09-01 03:22:33.973058",
"modified_by": "Administrator",
"module": "Custom",
"name": "DocType Layout",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -68,5 +70,6 @@
"route": "doctype-layout",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -1,11 +1,77 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
from typing import TYPE_CHECKING
import frappe
from frappe.desk.utils import slug
from frappe.model.document import Document
if TYPE_CHECKING:
from frappe.core.doctype.docfield.docfield import DocField
class DocTypeLayout(Document):
def validate(self):
if not self.route:
self.route = slug(self.name)
@frappe.whitelist()
def sync_fields(self):
doctype_fields = frappe.get_meta(self.document_type).fields
if self.is_new():
added_fields = [field.fieldname for field in doctype_fields]
removed_fields = []
else:
doctype_fieldnames = {field.fieldname for field in doctype_fields}
layout_fieldnames = {field.fieldname for field in self.fields}
added_fields = list(doctype_fieldnames - layout_fieldnames)
removed_fields = list(layout_fieldnames - doctype_fieldnames)
if not (added_fields or removed_fields):
return
added = self.add_fields(added_fields, doctype_fields)
removed = self.remove_fields(removed_fields)
for index, field in enumerate(self.fields):
field.idx = index + 1
return {"added": added, "removed": removed}
def add_fields(self, added_fields: list[str], doctype_fields: list["DocField"]) -> list[dict]:
added = []
for field in added_fields:
field_details = next((f for f in doctype_fields if f.fieldname == field), None)
if not field_details:
continue
# remove 'doctype' data from the DocField to allow adding it to the layout
row = self.append("fields", field_details.as_dict(no_default_fields=True))
row_data = row.as_dict()
if field_details.get("insert_after"):
insert_after = next(
(f for f in self.fields if f.fieldname == field_details.insert_after),
None,
)
# initialize new row to just after the insert_after field
if insert_after:
self.fields.insert(insert_after.idx, row)
self.fields.pop()
row_data = {"idx": insert_after.idx + 1, "fieldname": row.fieldname, "label": row.label}
added.append(row_data)
return added
def remove_fields(self, removed_fields: list[str]) -> list[dict]:
removed = []
for field in removed_fields:
field_details = next((f for f in self.fields if f.fieldname == field), None)
if field_details:
self.remove(field_details)
removed.append(field_details.as_dict())
return removed

View file

@ -28,7 +28,6 @@ from frappe.database.utils import (
from frappe.exceptions import DoesNotExistError, ImplicitCommitError
from frappe.model.utils.link_count import flush_local_link_count
from frappe.query_builder.functions import Count
from frappe.query_builder.utils import DocType
from frappe.utils import cast as cast_fieldtype
from frappe.utils import get_datetime, get_table_name, getdate, now, sbool
@ -856,7 +855,7 @@ class Database:
:param modified_by: Set this user as `modified_by`.
: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.
:param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit.
:param for_update: [DEPRECATED] This function now performs updates in single query, locking is not required.
"""
is_single_doctype = not (dn and dt != dn)
to_update = field if isinstance(field, dict) else {field: val}
@ -878,19 +877,11 @@ class Database:
frappe.clear_document_cache(dt, dt)
else:
table = DocType(dt)
if for_update:
docnames = tuple(
self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True)
) or (NullValue(),)
query = frappe.qb.update(table).where(table.name.isin(docnames))
for docname in docnames:
frappe.clear_document_cache(dt, docname)
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

View file

@ -301,6 +301,7 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
where table_name="{table_name}"
and column_name=columns.column_name
and NON_UNIQUE=1
and Seq_in_index = 1
limit 1
), 0) as 'index',
column_key = 'UNI' as 'unique'
@ -319,6 +320,37 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
)
)
def get_column_index(
self, table_name: str, fieldname: str, unique: bool = False
) -> frappe._dict | None:
"""Check if column exists for a specific fields in specified order.
This differs from db.has_index because it doesn't rely on index name but columns inside an
index.
"""
indexes = self.sql(
f"""SHOW INDEX FROM `{table_name}`
WHERE Column_name = "{fieldname}"
AND Seq_in_index = 1
AND Non_unique={int(not unique)}
""",
as_dict=True,
)
# Same index can be part of clustered index which contains more fields
# We don't want those.
for index in indexes:
clustered_index = self.sql(
f"""SHOW INDEX FROM `{table_name}`
WHERE Key_name = "{index.Key_name}"
AND Seq_in_index = 2
""",
as_dict=True,
)
if not clustered_index:
return index
def add_index(self, doctype: str, fields: list, index_name: str = None):
"""Creates an index with given fields if not already created.
Index name will be `fieldname1_fieldname2_index`"""

View file

@ -83,45 +83,23 @@ class MariaDBTable(DBTable):
for col in self.add_index:
# if index key does not exists
if not frappe.db.has_index(self.table_name, col.fieldname + "_index"):
if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
add_index_query.append(f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)")
for col in self.drop_index + self.drop_unique:
if col.fieldname != "name": # primary key
current_column = self.current_columns.get(col.fieldname.lower())
unique_constraint_changed = current_column.unique != col.unique
if unique_constraint_changed and not col.unique:
# nosemgrep
unique_index_record = frappe.db.sql(
"""
SHOW INDEX FROM `{}`
WHERE Key_name=%s
AND Non_unique=0
""".format(
self.table_name
),
(col.fieldname),
as_dict=1,
)
if unique_index_record:
drop_index_query.append(f"DROP INDEX `{unique_index_record[0].Key_name}`")
index_constraint_changed = current_column.index != col.set_index
# if index key exists
if index_constraint_changed and not col.set_index:
# nosemgrep
index_record = frappe.db.sql(
"""
SHOW INDEX FROM `{}`
WHERE Key_name=%s
AND Non_unique=1
""".format(
self.table_name
),
(col.fieldname + "_index"),
as_dict=1,
)
if index_record:
drop_index_query.append(f"DROP INDEX `{index_record[0].Key_name}`")
if col.fieldname == "name":
continue
current_column = self.current_columns.get(col.fieldname.lower())
unique_constraint_changed = current_column.unique != col.unique
if unique_constraint_changed and not col.unique:
if unique_index := frappe.db.get_column_index(self.table_name, col.fieldname, unique=True):
drop_index_query.append(f"DROP INDEX `{unique_index.Key_name}`")
index_constraint_changed = current_column.index != col.set_index
if index_constraint_changed and not col.set_index:
if index_record := frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
drop_index_query.append(f"DROP INDEX `{index_record.Key_name}`")
try:
for query_parts in [add_column_query, modify_column_query, add_index_query, drop_index_query]:

View file

@ -5,6 +5,7 @@ from functools import cached_property
from types import BuiltinFunctionType
from typing import TYPE_CHECKING, Callable
import sqlparse
from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder
import frappe
@ -15,6 +16,7 @@ from frappe.query_builder import Criterion, Field, Order, Table, functions
from frappe.query_builder.functions import Function, SqlFunctions
from frappe.query_builder.utils import PseudoColumn
from frappe.utils import cstr
from frappe.utils.data import MARIADB_SPECIFIC_COMMENT
if TYPE_CHECKING:
from frappe.query_builder import DocType
@ -538,7 +540,8 @@ class Engine:
def get_fieldnames_from_child_table(self, doctype, fields):
# Hacky and flaky implementation of implicit joins.
# convert child_table.fieldname to `tabChild DocType`.`fieldname`
for idx, field in enumerate(fields, start=0):
_fields = []
for field in fields:
if "." in field and "tab" not in field:
alias = None
if " as " in field:
@ -552,12 +555,63 @@ class Engine:
field = f"`tab{self.linked_doctype}`.`{linked_fieldname}`"
if alias:
field = f"{field} as {alias}"
fields[idx] = field
_fields.append(field)
return _fields
def sanitize_fields(self, fields: str | list | tuple):
is_mariadb = frappe.db.db_type == "mariadb"
def _sanitize_field(field: str):
if not isinstance(field, str):
return field
stripped_field = sqlparse.format(field, strip_comments=True, keyword_case="lower")
if is_mariadb:
return MARIADB_SPECIFIC_COMMENT.sub("", stripped_field)
return stripped_field
if isinstance(fields, (list, tuple)):
return [_sanitize_field(field) for field in fields]
elif isinstance(fields, str):
return _sanitize_field(fields)
return fields
def get_list_fields(self, fields: list) -> list:
updated_fields = []
if issubclass(type(fields), Criterion) or "*" in fields:
return fields
# fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields)
for field in fields:
if not isinstance(field, Criterion) and field:
if " as " in field:
field, reference = field.split(" as ")
if "`" in field:
updated_fields.append(PseudoColumn(f"{field} as {reference}"))
else:
updated_fields.append(Field(field.strip()).as_(reference))
elif "`" in str(field):
updated_fields.append(PseudoColumn(field.strip()))
else:
updated_fields.append(Field(field))
return updated_fields
def get_string_fields(self, fields: str) -> Field:
if fields == "*":
return fields
if "`" in fields:
fields = PseudoColumn(fields)
if " as " in str(fields):
fields, reference = str(fields).split(" as ")
if "`" in str(fields):
fields = PseudoColumn(f"{fields} as {reference}")
else:
fields = Field(fields).as_(reference)
return fields
def set_fields(self, fields, **kwargs) -> list:
fields = kwargs.get("pluck") if kwargs.get("pluck") else fields or "name"
fields = self.sanitize_fields(fields)
if isinstance(fields, list) and None in fields and Field not in fields:
return None
function_objects = []
@ -581,39 +635,9 @@ class Engine:
is_list, is_str = True, False
if is_str:
if fields == "*":
return fields
if "`" in fields:
fields = PseudoColumn(fields)
if " as " in str(fields):
fields, reference = str(fields).split(" as ")
if "`" in str(fields):
fields = PseudoColumn(f"{fields} as {reference}")
else:
fields = Field(fields).as_(reference)
fields = self.get_string_fields(fields)
if not is_str and fields:
if issubclass(type(fields), Criterion):
return fields
updated_fields = []
if "*" in fields:
return fields
# fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields)
for field in fields:
if not isinstance(field, Criterion) and field:
if " as " in field:
field, reference = field.split(" as ")
if "`" in field:
updated_fields.append(PseudoColumn(f"{field} as {reference}"))
else:
updated_fields.append(Field(field.strip()).as_(reference))
elif "`" in str(field):
updated_fields.append(PseudoColumn(field.strip()))
else:
updated_fields.append(Field(field))
fields = updated_fields
fields = self.get_list_fields(fields)
# Need to check instance again since fields modified.
if not isinstance(fields, (list, tuple, set)):
@ -636,29 +660,18 @@ class Engine:
and (f"`tab{table}`" not in str(field))
):
has_join = True
join_table = table_from_string(str(field))
# check for already joined tables
if joined_tables.get("left_join") != join_table:
if self.fieldname:
criterion = criterion.left_join(join_table).on(
getattr(join_table, "name") == getattr(frappe.qb.DocType(table), self.fieldname)
)
joined_tables["left_join"] = join_table
else:
criterion = criterion.left_join(join_table).on(
getattr(join_table, "parent") == getattr(frappe.qb.DocType(table), "name")
)
joined_tables["left_join"] = join_table
if has_join:
for idx, field in enumerate(fields):
if not is_pypika_function_object(field):
field = field if isinstance(field, str) else field.get_sql()
if not TABLE_PATTERN.search(str(field)):
fields[idx] = getattr(frappe.qb.DocType(table), field)
else:
field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args]
fields[idx] = field
if has_join:
def _update_pypika_fields(field):
if not is_pypika_function_object(field):
field = field if isinstance(field, str) else field.get_sql()
if not TABLE_PATTERN.search(str(field)):
return getattr(frappe.qb.DocType(table), field)
else:
field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args]
return field
fields = [_update_pypika_fields(field) for field in fields]
if len(self.tables) > 1:
primary_table = self.tables.pop(table)

View file

@ -215,14 +215,13 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
group_by="_unit",
order_by="_unit asc",
as_list=True,
ignore_ifnull=True,
)
result = get_result(data, timegrain, from_date, to_date, chart.chart_type)
return {
"labels": [
format_date(get_period(r[0], timegrain))
format_date(get_period(r[0], timegrain), parse_day_first=True)
if timegrain in ("Daily", "Weekly")
else get_period(r[0], timegrain)
for r in result
@ -294,13 +293,6 @@ def get_group_by_chart_config(chart, filters):
)
if data:
if chart.number_of_groups and chart.number_of_groups < len(data):
other_count = 0
for i in range(chart.number_of_groups - 1, len(data)):
other_count += data[i]["count"]
data = data[0 : chart.number_of_groups - 1]
data.append({"name": "Other", "count": other_count})
chart_config = {
"labels": [item["name"] if item["name"] else "Not Specified" for item in data],
"datasets": [{"name": chart.name, "values": [item["count"] for item in data]}],

View file

@ -173,7 +173,7 @@ class TestDashboardChart(FrappeTestCase):
self.assertEqual(result.get("datasets")[0].get("values"), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0])
self.assertEqual(
result.get("labels"),
["06-01-2019", "07-01-2019", "08-01-2019", "09-01-2019", "10-01-2019", "11-01-2019"],
["01-06-2019", "01-07-2019", "01-08-2019", "01-09-2019", "01-10-2019", "01-11-2019"],
)
def test_weekly_dashboard_chart(self):
@ -203,7 +203,7 @@ class TestDashboardChart(FrappeTestCase):
result = get(chart_name="Test Weekly Dashboard Chart", refresh=1)
self.assertEqual(result.get("datasets")[0].get("values"), [50.0, 300.0, 800.0, 0.0])
self.assertEqual(result.get("labels"), ["12-30-2018", "06-01-2019", "01-13-2019", "01-20-2019"])
self.assertEqual(result.get("labels"), ["12-30-2018", "01-06-2019", "01-13-2019", "01-20-2019"])
def test_avg_dashboard_chart(self):
insert_test_records()
@ -230,7 +230,7 @@ class TestDashboardChart(FrappeTestCase):
with patch.object(frappe.utils.data, "get_first_day_of_the_week", return_value="Monday"):
result = get(chart_name="Test Average Dashboard Chart", refresh=1)
self.assertEqual(result.get("labels"), ["12-30-2018", "06-01-2019", "01-13-2019", "01-20-2019"])
self.assertEqual(result.get("labels"), ["12-30-2018", "01-06-2019", "01-13-2019", "01-20-2019"])
self.assertEqual(result.get("datasets")[0].get("values"), [50.0, 150.0, 266.6666666666667, 0.0])
def test_user_date_label_dashboard_chart(self):
@ -255,13 +255,13 @@ class TestDashboardChart(FrappeTestCase):
with patch.object(frappe.utils.data, "get_user_date_format", return_value="dd.mm.yyyy"):
result = get(chart_name="Test Dashboard Chart Date Label")
self.assertEqual(
sorted(result.get("labels")), sorted(["01.05.2019", "01.12.2019", "19.01.2019"])
sorted(result.get("labels")), sorted(["05.01.2019", "12.01.2019", "19.01.2019"])
)
with patch.object(frappe.utils.data, "get_user_date_format", return_value="mm-dd-yyyy"):
result = get(chart_name="Test Dashboard Chart Date Label")
self.assertEqual(
sorted(result.get("labels")), sorted(["01-19-2019", "05-01-2019", "12-01-2019"])
sorted(result.get("labels")), sorted(["01-19-2019", "01-05-2019", "01-12-2019"])
)

View file

@ -41,6 +41,18 @@ frappe.ui.form.on("Event", {
},
__("Add Participants")
);
const [ends_on_date] = frm.doc.ends_on
? frm.doc.ends_on.split(" ")
: frm.doc.starts_on.split(" ");
if (frm.doc.google_meet_link && frappe.datetime.now_date() <= ends_on_date) {
frm.dashboard.set_headline(
__("Join video conference with {0}", [
`<a target='_blank' href='${frm.doc.google_meet_link}'>Google Meet</a>`,
])
);
}
},
repeat_on: function (frm) {
if (frm.doc.repeat_on === "Every Day") {

View file

@ -22,12 +22,14 @@
"sender",
"all_day",
"sync_with_google_calendar",
"add_video_conferencing",
"sb_00",
"google_calendar",
"pulled_from_google_calendar",
"cb_00",
"google_calendar_id",
"cb_00",
"google_calendar_event_id",
"google_meet_link",
"pulled_from_google_calendar",
"section_break_13",
"repeat_on",
"repeat_till",
@ -225,7 +227,7 @@
},
{
"collapsible": 1,
"depends_on": "eval:doc.sync_with_google_calendar",
"depends_on": "eval:doc.sync_with_google_calendar || doc.pulled_from_google_calendar",
"fieldname": "sb_00",
"fieldtype": "Section Break",
"label": "Google Calendar"
@ -245,6 +247,7 @@
"fieldname": "google_calendar_event_id",
"fieldtype": "Data",
"label": "Google Calendar Event ID",
"no_copy": 1,
"read_only": 1
},
{
@ -272,12 +275,27 @@
"label": "Sender",
"options": "Email",
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:doc.sync_with_google_calendar",
"description": "via Google Meet",
"fieldname": "add_video_conferencing",
"fieldtype": "Check",
"label": "Add Video Conferencing"
},
{
"fieldname": "google_meet_link",
"fieldtype": "Data",
"label": "Google Meet Link",
"no_copy": 1,
"read_only": 1
}
],
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
"modified": "2022-05-12 05:43:27.935510",
"modified": "2022-08-12 19:24:34.794098",
"modified_by": "Administrator",
"module": "Desk",
"name": "Event",

View file

@ -6,6 +6,7 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.doctype.notification_settings.notification_settings import (
is_email_notifications_enabled_for_type,
)
@ -55,6 +56,12 @@ class Event(Document):
if self.sync_with_google_calendar and not self.google_calendar:
frappe.throw(_("Select Google Calendar to which event should be synced."))
if not self.sync_with_google_calendar:
self.add_video_conferencing = 0
def before_save(self):
self.set_participants_email()
def on_update(self):
self.sync_communication()
@ -131,6 +138,22 @@ class Event(Document):
for participant in participants:
self.add_participant(participant["doctype"], participant["docname"])
def set_participants_email(self):
for participant in self.event_participants:
if participant.email:
continue
if participant.reference_doctype != "Contact":
participant_contact = get_default_contact(
participant.reference_doctype, participant.reference_docname
)
else:
participant_contact = participant.reference_docname
participant.email = (
frappe.get_value("Contact", participant_contact, "email_id") if participant_contact else None
)
@frappe.whitelist()
def delete_communication(event, reference_doctype, reference_docname):

View file

@ -6,7 +6,8 @@
"engine": "InnoDB",
"field_order": [
"reference_doctype",
"reference_docname"
"reference_docname",
"email"
],
"fields": [
{
@ -24,11 +25,17 @@
"label": "Reference Name",
"options": "reference_doctype",
"reqd": 1
},
{
"fieldname": "email",
"fieldtype": "Data",
"label": "Email",
"options": "Email"
}
],
"istable": 1,
"links": [],
"modified": "2022-08-03 12:20:50.466370",
"modified": "2022-10-18 17:49:33.549459",
"modified_by": "Administrator",
"module": "Desk",
"name": "Event Participants",

View file

@ -20,6 +20,14 @@ class NotificationLog(Document):
except frappe.OutgoingEmailError:
self.log_error(_("Failed to send notification email"))
@staticmethod
def clear_old_logs(days=180):
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
table = frappe.qb.DocType("Notification Log")
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
def get_permission_query_conditions(for_user):
if not for_user:

View file

@ -0,0 +1,7 @@
frappe.listview_settings["Notification Log"] = {
onload: function (listview) {
frappe.require("logtypes.bundle.js", () => {
frappe.utils.logtypes.show_log_retention_message(cur_list.doctype);
});
},
};

View file

@ -5,6 +5,7 @@ import json
import frappe
from frappe.desk.form.load import run_onload
from frappe.monitor import add_data_to_monitor
@frappe.whitelist()
@ -25,6 +26,8 @@ def savedocs(doc, action):
run_onload(doc)
send_updated_docs(doc)
add_data_to_monitor(doctype=doc.doctype, action=action)
frappe.msgprint(frappe._("Saved"), indicator="green", alert=True)

View file

@ -1,615 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe import _
from frappe.boot import get_allowed_pages, get_allowed_reports
from frappe.cache_manager import (
build_domain_restriced_doctype_cache,
build_domain_restriced_page_cache,
build_table_count_cache,
)
from frappe.desk.doctype.desktop_icon.desktop_icon import clear_desktop_icons_cache, set_hidden
@frappe.whitelist()
def get(module):
"""Returns data (sections, list of reports, counts) to render module view in desk:
`/desk/#Module/[name]`."""
data = get_data(module)
out = {"data": data}
return out
@frappe.whitelist()
def hide_module(module):
set_hidden(module, frappe.session.user, 1)
clear_desktop_icons_cache()
def get_table_with_counts():
counts = frappe.cache().get_value("information_schema:counts")
if counts:
return counts
else:
return build_table_count_cache()
def get_data(module, build=True):
"""Get module data for the module view `desk/#Module/[name]`"""
doctype_info = get_doctype_info(module)
data = build_config_from_file(module)
if not data:
data = build_standard_config(module, doctype_info)
else:
add_custom_doctypes(data, doctype_info)
add_section(data, _("Custom Reports"), "fa fa-list-alt", get_report_list(module))
data = combine_common_sections(data)
data = apply_permissions(data)
# set_last_modified(data)
if build:
exists_cache = get_table_with_counts()
def doctype_contains_a_record(name):
exists = exists_cache.get(name)
if not exists:
if not frappe.db.get_value("DocType", name, "issingle"):
exists = frappe.db.count(name)
else:
exists = True
exists_cache[name] = exists
return exists
for section in data:
for item in section["items"]:
# Onboarding
# First disable based on exists of depends_on list
doctype = item.get("doctype")
dependencies = item.get("dependencies") or None
if not dependencies and doctype:
item["dependencies"] = [doctype]
dependencies = item.get("dependencies")
if dependencies:
incomplete_dependencies = [d for d in dependencies if not doctype_contains_a_record(d)]
if len(incomplete_dependencies):
item["incomplete_dependencies"] = incomplete_dependencies
if item.get("onboard"):
# Mark Spotlights for initial
if item.get("type") == "doctype":
name = item.get("name")
count = doctype_contains_a_record(name)
item["count"] = count
return data
def build_config_from_file(module):
"""Build module info from `app/config/desktop.py` files."""
data = []
module = frappe.scrub(module)
for app in frappe.get_installed_apps():
try:
data += get_config(app, module)
except ImportError:
pass
return filter_by_restrict_to_domain(data)
def filter_by_restrict_to_domain(data):
"""filter Pages and DocType depending on the Active Module(s)"""
doctypes = (
frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
)
pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
for d in data:
_items = []
for item in d.get("items", []):
item_type = item.get("type")
item_name = item.get("name")
if (item_name in pages) or (item_name in doctypes) or item_type == "report":
_items.append(item)
d.update({"items": _items})
return data
def build_standard_config(module, doctype_info):
"""Build standard module data from DocTypes."""
if not frappe.db.get_value("Module Def", module):
frappe.throw(_("Module Not Found"))
data = []
add_section(
data,
_("Documents"),
"fa fa-star",
[d for d in doctype_info if d.document_type in ("Document", "Transaction")],
)
add_section(
data,
_("Setup"),
"fa fa-cog",
[d for d in doctype_info if d.document_type in ("Master", "Setup", "")],
)
add_section(data, _("Standard Reports"), "fa fa-list", get_report_list(module, is_standard="Yes"))
return data
def add_section(data, label, icon, items):
"""Adds a section to the module data."""
if not items:
return
data.append({"label": label, "icon": icon, "items": items})
def add_custom_doctypes(data, doctype_info):
"""Adds Custom DocTypes to modules setup via `config/desktop.py`."""
add_section(
data,
_("Documents"),
"fa fa-star",
[d for d in doctype_info if (d.custom and d.document_type in ("Document", "Transaction"))],
)
add_section(
data,
_("Setup"),
"fa fa-cog",
[d for d in doctype_info if (d.custom and d.document_type in ("Setup", "Master", ""))],
)
def get_doctype_info(module):
"""Returns list of non child DocTypes for given module."""
active_domains = frappe.get_active_domains()
doctype_info = frappe.get_all(
"DocType",
filters={"module": module, "istable": 0},
or_filters={"ifnull(restrict_to_domain, '')": "", "restrict_to_domain": ("in", active_domains)},
fields=["'doctype' as type", "name", "description", "document_type", "custom", "issingle"],
order_by="custom asc, document_type desc, name asc",
)
for d in doctype_info:
d.document_type = d.document_type or ""
d.description = _(d.description or "")
return doctype_info
def combine_common_sections(data):
"""Combine sections declared in separate apps."""
sections = []
sections_dict = {}
for each in data:
if each["label"] not in sections_dict:
sections_dict[each["label"]] = each
sections.append(each)
else:
sections_dict[each["label"]]["items"] += each["items"]
return sections
def apply_permissions(data):
default_country = frappe.db.get_default("country")
user = frappe.get_user()
user.build_permissions()
allowed_pages = get_allowed_pages()
allowed_reports = get_allowed_reports()
new_data = []
for section in data:
new_items = []
for item in section.get("items") or []:
item = frappe._dict(item)
if item.country and item.country != default_country:
continue
if (
(item.type == "doctype" and item.name in user.can_read)
or (item.type == "page" and item.name in allowed_pages)
or (item.type == "report" and item.name in allowed_reports)
or item.type == "help"
):
new_items.append(item)
if new_items:
new_section = section.copy()
new_section["items"] = new_items
new_data.append(new_section)
return new_data
def get_disabled_reports():
if not hasattr(frappe.local, "disabled_reports"):
frappe.local.disabled_reports = {r.name for r in frappe.get_all("Report", {"disabled": 1})}
return frappe.local.disabled_reports
def get_config(app, module):
"""Load module info from `[app].config.[module]`."""
config = frappe.get_module(f"{app}.config.{module}")
config = config.get_data()
sections = [s for s in config if s.get("condition", True)]
disabled_reports = get_disabled_reports()
for section in sections:
items = []
for item in section["items"]:
if item["type"] == "report" and item["name"] in disabled_reports:
continue
# some module links might not have name
if not item.get("name"):
item["name"] = item.get("label")
if not item.get("label"):
item["label"] = _(item.get("name"))
items.append(item)
section["items"] = items
return sections
def config_exists(app, module):
try:
frappe.get_module(f"{app}.config.{module}")
return True
except ImportError:
return False
def add_setup_section(config, app, module, label, icon):
"""Add common sections to `/desk#Module/Setup`"""
try:
setup_section = get_setup_section(app, module, label, icon)
if setup_section:
config.append(setup_section)
except ImportError:
pass
def get_setup_section(app, module, label, icon):
"""Get the setup section from each module (for global Setup page)."""
config = get_config(app, module)
for section in config:
if section.get("label") == _("Setup"):
return {"label": label, "icon": icon, "items": section["items"]}
def get_onboard_items(app, module):
try:
sections = get_config(app, module)
except ImportError:
return []
onboard_items = []
fallback_items = []
if not sections:
doctype_info = get_doctype_info(module)
sections = build_standard_config(module, doctype_info)
for section in sections:
for item in section["items"]:
if item.get("onboard", 0) == 1:
onboard_items.append(item)
# in case onboard is not set
fallback_items.append(item)
if len(onboard_items) > 5:
return onboard_items
return onboard_items or fallback_items
@frappe.whitelist()
def get_links_for_module(app, module):
return [{"value": l.get("name"), "label": l.get("label")} for l in get_links(app, module)]
def get_links(app, module):
try:
sections = get_config(app, frappe.scrub(module))
except ImportError:
return []
links = []
for section in sections:
for item in section["items"]:
links.append(item)
return links
@frappe.whitelist()
def get_desktop_settings():
from frappe.config import get_modules_from_all_apps_for_user
all_modules = get_modules_from_all_apps_for_user()
home_settings = get_home_settings()
modules_by_name = {}
for m in all_modules:
modules_by_name[m["module_name"]] = m
module_categories = ["Modules", "Domains", "Places", "Administration"]
user_modules_by_category = {}
user_saved_modules_by_category = home_settings.modules_by_category or {}
user_saved_links_by_module = home_settings.links_by_module or {}
def apply_user_saved_links(module):
module = frappe._dict(module)
all_links = get_links(module.app, module.module_name)
module_links_by_name = {}
for link in all_links:
module_links_by_name[link["name"]] = link
if module.module_name in user_saved_links_by_module:
user_links = frappe.parse_json(user_saved_links_by_module[module.module_name])
module.links = [module_links_by_name[l] for l in user_links if l in module_links_by_name]
return module
for category in module_categories:
if category in user_saved_modules_by_category:
user_modules = user_saved_modules_by_category[category]
user_modules_by_category[category] = [
apply_user_saved_links(modules_by_name[m]) for m in user_modules if modules_by_name.get(m)
]
else:
user_modules_by_category[category] = [
apply_user_saved_links(m) for m in all_modules if m.get("category") == category
]
# filter out hidden modules
if home_settings.hidden_modules:
for category in user_modules_by_category:
hidden_modules = home_settings.hidden_modules or []
modules = user_modules_by_category[category]
user_modules_by_category[category] = [
module for module in modules if module.module_name not in hidden_modules
]
return user_modules_by_category
@frappe.whitelist()
def update_hidden_modules(category_map):
category_map = frappe.parse_json(category_map)
home_settings = get_home_settings()
saved_hidden_modules = home_settings.hidden_modules or []
for category in category_map:
config = frappe._dict(category_map[category])
saved_hidden_modules += config.removed or []
saved_hidden_modules = [d for d in saved_hidden_modules if d not in (config.added or [])]
if home_settings.get("modules_by_category") and home_settings.modules_by_category.get(category):
module_placement = [
d for d in (config.added or []) if d not in home_settings.modules_by_category[category]
]
home_settings.modules_by_category[category] += module_placement
home_settings.hidden_modules = saved_hidden_modules
set_home_settings(home_settings)
return get_desktop_settings()
@frappe.whitelist()
def update_global_hidden_modules(modules):
modules = frappe.parse_json(modules)
frappe.only_for("System Manager")
doc = frappe.get_doc("User", "Administrator")
doc.set("block_modules", [])
for module in modules:
doc.append("block_modules", {"module": module})
doc.save(ignore_permissions=True)
return get_desktop_settings()
@frappe.whitelist()
def update_modules_order(module_category, modules):
modules = frappe.parse_json(modules)
home_settings = get_home_settings()
home_settings.modules_by_category = home_settings.modules_by_category or {}
home_settings.modules_by_category[module_category] = modules
set_home_settings(home_settings)
@frappe.whitelist()
def update_links_for_module(module_name, links):
links = frappe.parse_json(links)
home_settings = get_home_settings()
home_settings.setdefault("links_by_module", {})
home_settings["links_by_module"].setdefault(module_name, None)
home_settings["links_by_module"][module_name] = links
set_home_settings(home_settings)
return get_desktop_settings()
@frappe.whitelist()
def get_options_for_show_hide_cards():
global_options = []
if "System Manager" in frappe.get_roles():
global_options = get_options_for_global_modules()
return {"user_options": get_options_for_user_blocked_modules(), "global_options": global_options}
@frappe.whitelist()
def get_options_for_global_modules():
from frappe.config import get_modules_from_all_apps
all_modules = get_modules_from_all_apps()
blocked_modules = frappe.get_doc("User", "Administrator").get_blocked_modules()
options = []
for module in all_modules:
module = frappe._dict(module)
options.append(
{
"category": module.category,
"label": module.label,
"value": module.module_name,
"checked": module.module_name not in blocked_modules,
}
)
return options
@frappe.whitelist()
def get_options_for_user_blocked_modules():
from frappe.config import get_modules_from_all_apps_for_user
all_modules = get_modules_from_all_apps_for_user()
home_settings = get_home_settings()
hidden_modules = home_settings.hidden_modules or []
options = []
for module in all_modules:
module = frappe._dict(module)
options.append(
{
"category": module.category,
"label": module.label,
"value": module.module_name,
"checked": module.module_name not in hidden_modules,
}
)
return options
def set_home_settings(home_settings):
frappe.cache().hset("home_settings", frappe.session.user, home_settings)
frappe.db.set_value("User", frappe.session.user, "home_settings", json.dumps(home_settings))
@frappe.whitelist()
def get_home_settings():
def get_from_db():
settings = frappe.db.get_value("User", frappe.session.user, "home_settings")
return frappe.parse_json(settings or "{}")
home_settings = frappe.cache().hget("home_settings", frappe.session.user, get_from_db)
return home_settings
def get_module_link_items_from_list(app, module, list_of_link_names):
try:
sections = get_config(app, frappe.scrub(module))
except ImportError:
return []
links = []
for section in sections:
for item in section["items"]:
if item.get("label", "") in list_of_link_names:
links.append(item)
return links
def set_last_modified(data):
for section in data:
for item in section["items"]:
if item["type"] == "doctype":
item["last_modified"] = get_last_modified(item["name"])
def get_last_modified(doctype):
def _get():
try:
last_modified = frappe.get_all(
doctype, fields=["max(modified)"], as_list=True, limit_page_length=1
)[0][0]
except Exception as e:
if frappe.db.is_table_missing(e):
last_modified = None
else:
raise
# hack: save as -1 so that it is cached
if last_modified is None:
last_modified = -1
return last_modified
last_modified = frappe.cache().hget("last_modified", doctype, _get)
if last_modified == -1:
last_modified = None
return last_modified
def get_report_list(module, is_standard="No"):
"""Returns list on new style reports for modules."""
reports = frappe.get_list(
"Report",
fields=["name", "ref_doctype", "report_type"],
filters={"is_standard": is_standard, "disabled": 0, "module": module},
order_by="name",
)
out = []
for r in reports:
out.append(
{
"type": "report",
"doctype": r.ref_doctype,
"is_query_report": 1
if r.report_type in ("Query Report", "Script Report", "Custom Report")
else 0,
"label": _(r.name),
"name": r.name,
}
)
return out

View file

@ -2,11 +2,10 @@
# License: MIT. See LICENSE
import json
import os
import frappe
from frappe.geo.country_info import get_country_info
from frappe.translate import get_dict, send_translations, set_default_language
from frappe.translate import get_messages_for_boot, send_translations, set_default_language
from frappe.utils import cint, strip
from frappe.utils.password import update_password
@ -290,15 +289,7 @@ def load_messages(language):
frappe.clear_cache()
set_default_language(get_language_code(language))
frappe.db.commit()
m = get_dict("page", "setup-wizard")
for path in frappe.get_hooks("setup_wizard_requires"):
# common folder `assets` served from `sites/`
js_file_path = os.path.abspath(frappe.get_site_path("..", *path.strip("/").split("/")))
m.update(get_dict("jsfile", js_file_path))
m.update(get_dict("boot"))
send_translations(m)
send_translations(get_messages_for_boot())
return frappe.local.lang

View file

@ -12,6 +12,7 @@ from frappe import _
from frappe.core.utils import ljust_list
from frappe.model.utils import render_include
from frappe.modules import get_module_path, scrub
from frappe.monitor import add_data_to_monitor
from frappe.permissions import get_role_permissions
from frappe.translate import send_translations
from frappe.utils import (
@ -251,6 +252,7 @@ def run(
result = get_prepared_report_result(report, filters, dn, user)
else:
result = generate_report_result(report, filters, user, custom_columns, is_tree, parent_field)
add_data_to_monitor(report=report.reference_report or report.name)
result["add_total_row"] = report.add_total_row and not result.get("skip_total_row", False)

View file

@ -1,107 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import copy
import json
import frappe
@frappe.whitelist()
def get_data(doctypes, last_modified):
data_map = {}
for dump_report_map in frappe.get_hooks().dump_report_map:
data_map.update(frappe.get_attr(dump_report_map))
out = {}
doctypes = json.loads(doctypes)
last_modified = json.loads(last_modified)
for d in doctypes:
args = copy.deepcopy(data_map[d])
dt = d.find("[") != -1 and d[: d.find("[")] or d
out[dt] = {}
if args.get("from"):
modified_table = "item."
else:
modified_table = ""
conditions = order_by = ""
table = args.get("from") or ("`tab%s`" % dt)
if d in last_modified:
if not args.get("conditions"):
args["conditions"] = []
args["conditions"].append(modified_table + "modified > '" + last_modified[d] + "'")
out[dt]["modified_names"] = frappe.db.sql_list(
"""select %sname from %s
where %smodified > %s"""
% (modified_table, table, modified_table, "%s"),
last_modified[d],
)
if args.get("force_index"):
conditions = " force index (%s) " % args["force_index"]
if args.get("conditions"):
conditions += " where " + " and ".join(args["conditions"])
if args.get("order_by"):
order_by = " order by " + args["order_by"]
out[dt]["data"] = [
list(t)
for t in frappe.db.sql(
"""select {} from {} {} {}""".format(",".join(args["columns"]), table, conditions, order_by)
)
]
# last modified
modified_table = table
if "," in table:
modified_table = " ".join(table.split(",")[0].split(" ")[:-1])
tmp = frappe.db.sql(
"""select `modified`
from %s order by modified desc limit 1"""
% modified_table
)
out[dt]["last_modified"] = tmp and tmp[0][0] or ""
out[dt]["columns"] = list(map(lambda c: c.split(" as ")[-1], args["columns"]))
if args.get("links"):
out[dt]["links"] = args["links"]
for d in out:
unused_links = []
# only compress full dumps (not partial)
if out[d].get("links") and (d not in last_modified):
for link_key in out[d]["links"]:
link = out[d]["links"][link_key]
if link[0] in out and (link[0] not in last_modified):
# make a map of link ids
# to index
link_map = {}
doctype_data = out[link[0]]
col_idx = doctype_data["columns"].index(link[1])
for row_idx in range(len(doctype_data["data"])):
row = doctype_data["data"][row_idx]
link_map[row[col_idx]] = row_idx
for row in out[d]["data"]:
columns = list(out[d]["columns"])
if link_key in columns:
col_idx = columns.index(link_key)
# replace by id
if row[col_idx]:
row[col_idx] = link_map.get(row[col_idx])
else:
unused_links.append(link_key)
for link in unused_links:
del out[d]["links"][link]
return out

View file

@ -8,7 +8,6 @@ import re
import frappe
from frappe import _, is_whitelisted
from frappe.permissions import has_permission
from frappe.translate import get_translated_doctypes
from frappe.utils import cint, cstr, unique
@ -150,10 +149,6 @@ def search_widget(
filters = []
or_filters = []
translated_doctypes = frappe.cache().hget(
"translated_doctypes", "doctypes", get_translated_doctypes
)
# build from doctype
if txt:
field_types = [
@ -175,7 +170,7 @@ def search_widget(
for f in search_fields:
fmeta = meta.get_field(f.strip())
if (doctype not in translated_doctypes) and (
if not meta.translated_doctype and (
f == "name" or (fmeta and fmeta.fieldtype in field_types)
):
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])
@ -191,26 +186,25 @@ def search_widget(
fields = list(set(fields + json.loads(filter_fields)))
formatted_fields = [f"`tab{meta.name}`.`{f.strip()}`" for f in fields]
title_field_query = get_title_field_query(meta)
# Insert title field query after name
if title_field_query:
formatted_fields.insert(1, title_field_query)
# find relevance as location of search term from the beginning of string `name`. used for sorting results.
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)
if meta.show_title_field_in_link:
formatted_fields.insert(1, f"`tab{meta.name}`.{meta.title_field} as `label`")
# In order_by, `idx` gets second priority, because it stores link count
from frappe.model.db_query import get_order_by
order_by_based_on_meta = get_order_by(doctype, meta)
# 2 is the index of _relevance column
order_by = f"_relevance, {order_by_based_on_meta}, `tab{doctype}`.idx desc"
order_by = f"{order_by_based_on_meta}, `tab{doctype}`.idx desc"
if not meta.translated_doctype:
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)
order_by = f"_relevance, {order_by}"
ptype = "select" if frappe.only_has_select_perm(doctype) else "read"
ignore_permissions = (
@ -219,16 +213,13 @@ def search_widget(
else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))
)
if doctype in translated_doctypes:
page_length = None
values = frappe.get_list(
doctype,
filters=filters,
fields=formatted_fields,
or_filters=or_filters,
limit_start=start,
limit_page_length=page_length,
limit_page_length=None if meta.translated_doctype else page_length,
order_by=order_by,
ignore_permissions=ignore_permissions,
reference_doctype=reference_doctype,
@ -236,12 +227,15 @@ def search_widget(
strict=False,
)
if doctype in translated_doctypes:
if meta.translated_doctype:
# Filtering the values array so that query is included in very element
values = (
v
for v in values
if re.search(f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE)
result
for result in values
if any(
re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE)
for value in (result.values() if as_dict else result)
)
)
# Sorting the values array so that relevant results always come first
@ -250,12 +244,14 @@ def search_widget(
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
# remove _relevance from results
if as_dict:
for r in values:
r.pop("_relevance")
frappe.response["values"] = values
else:
frappe.response["values"] = [r[:-1] for r in values]
if not meta.translated_doctype:
if as_dict:
for r in values:
r.pop("_relevance")
else:
values = [r[:-1] for r in values]
frappe.response["values"] = values
def get_std_fields_list(meta, key):
@ -275,39 +271,23 @@ def get_std_fields_list(meta, key):
return sflist
def get_title_field_query(meta):
title_field = meta.title_field if meta.title_field else None
show_title_field_in_link = (
meta.show_title_field_in_link if meta.show_title_field_in_link else None
)
field = None
def build_for_autosuggest(res: list[tuple], doctype: str) -> list[dict]:
def to_string(parts):
return ", ".join(
unique(_(cstr(part)) if meta.translated_doctype else cstr(part) for part in parts if part)
)
if title_field and show_title_field_in_link:
field = f"`tab{meta.name}`.{title_field} as `label`"
return field
def build_for_autosuggest(res, doctype):
results = []
meta = frappe.get_meta(doctype)
if not (meta.title_field and meta.show_title_field_in_link):
for r in res:
r = list(r)
results.append({"value": r[0], "description": ", ".join(unique(cstr(d) for d in r[1:] if d))})
if meta.show_title_field_in_link:
for item in res:
item = list(item)
label = item[1] # use title as label
item[1] = item[0] # show name in description instead of title
del item[2] # remove redundant title ("label") value
results.append({"value": item[0], "label": label, "description": to_string(item[1:])})
else:
title_field_exists = meta.title_field and meta.show_title_field_in_link
_from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists
for r in res:
r = list(r)
results.append(
{
"value": r[0],
"label": r[1] if title_field_exists else None,
"description": ", ".join(unique(cstr(d) for d in r[_from:] if d)),
}
)
results.extend({"value": item[0], "description": to_string(item[1:])} for item in res)
return results
@ -383,7 +363,7 @@ def get_user_groups():
def get_link_title(doctype, docname):
meta = frappe.get_meta(doctype)
if meta.title_field and meta.show_title_field_in_link:
if meta.show_title_field_in_link:
return frappe.db.get_value(doctype, docname, meta.title_field)
return docname

View file

@ -40,8 +40,7 @@ class AutoEmailReport(Document):
@property
def sender_email(self):
email_id, login_id = frappe.db.get_value("Email Account", self.sender, ["email_id", "login_id"])
return login_id if login_id else email_id
return frappe.db.get_value("Email Account", self.sender, "email_id")
def validate_emails(self):
"""Cleanup list of emails"""

View file

@ -176,7 +176,7 @@ frappe.ui.form.on("Notification", {
},
callback: function (r) {
if (r.message && r.message.length > 0) {
frappe.msgprint(r.message);
frappe.msgprint(r.message.toString());
} else {
frappe.msgprint(__("No alerts for today"));
}

View file

@ -222,6 +222,7 @@ scheduler_events = {
"frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails",
"frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports",
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up",
"frappe.utils.subscription.enable_manage_subscription",
],
"daily_long": [
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily",

View file

@ -4,6 +4,7 @@
from datetime import datetime, timedelta
from urllib.parse import quote
from zoneinfo import ZoneInfo
import google.oauth2.credentials
import requests
@ -274,7 +275,7 @@ def sync_events_from_google_calendar(g_calendar, method=None):
if err.resp.status == 410:
set_encrypted_password("Google Calendar", account.name, "", "next_sync_token")
frappe.db.commit()
msg += " " + _("Sync token was invalid and has been resetted, Retry syncing.")
msg += " " + _("Sync token was invalid and has been reset, Retry syncing.")
frappe.msgprint(msg, title="Invalid Sync Token", indicator="blue")
else:
frappe.throw(msg)
@ -356,6 +357,7 @@ def insert_event_to_calendar(account, event, recurrence=None):
"google_calendar": account.name,
"google_calendar_id": account.google_calendar_id,
"google_calendar_event_id": event.get("id"),
"google_meet_link": event.get("hangoutLink"),
"pulled_from_google_calendar": 1,
}
calendar_event.update(
@ -373,6 +375,7 @@ def update_event_in_calendar(account, event, recurrence=None):
calendar_event = frappe.get_doc("Event", {"google_calendar_event_id": event.get("id")})
calendar_event.subject = event.get("summary")
calendar_event.description = event.get("description")
calendar_event.google_meet_link = event.get("hangoutLink")
calendar_event.update(
google_calendar_to_repeat_on(
recurrence=recurrence, start=event.get("start"), end=event.get("end")
@ -407,11 +410,30 @@ def insert_event_in_google_calendar(doc, method=None):
if doc.repeat_on:
event.update({"recurrence": repeat_on_to_google_calendar_recurrence_rule(doc)})
event.update({"attendees": get_attendees(doc)})
conference_data_version = 0
if doc.add_video_conferencing:
event.update({"conferenceData": get_conference_data(doc)})
conference_data_version = 1
try:
event = google_calendar.events().insert(calendarId=doc.google_calendar_id, body=event).execute()
frappe.db.set_value(
"Event", doc.name, "google_calendar_event_id", event.get("id"), update_modified=False
event = (
google_calendar.events()
.insert(
calendarId=doc.google_calendar_id, body=event, conferenceDataVersion=conference_data_version
)
.execute()
)
frappe.db.set_value(
"Event",
doc.name,
{"google_calendar_event_id": event.get("id"), "google_meet_link": event.get("hangoutLink")},
update_modified=False,
)
frappe.msgprint(_("Event Synced with Google Calendar."))
except HttpError as err:
frappe.throw(
@ -450,6 +472,7 @@ def update_event_in_google_calendar(doc, method=None):
.get(calendarId=doc.google_calendar_id, eventId=doc.google_calendar_event_id)
.execute()
)
event["summary"] = doc.subject
event["description"] = doc.description
event["recurrence"] = repeat_on_to_google_calendar_recurrence_rule(doc)
@ -462,9 +485,38 @@ def update_event_in_google_calendar(doc, method=None):
)
)
google_calendar.events().update(
calendarId=doc.google_calendar_id, eventId=doc.google_calendar_event_id, body=event
).execute()
conference_data_version = 0
if doc.add_video_conferencing:
event.update({"conferenceData": get_conference_data(doc)})
conference_data_version = 1
elif doc.get_doc_before_save().add_video_conferencing or event.get("hangoutLink"):
# remove google meet from google calendar event, if turning off add_video_conferencing
event.update({"conferenceData": None})
conference_data_version = 1
event.update({"attendees": get_attendees(doc)})
event = (
google_calendar.events()
.update(
calendarId=doc.google_calendar_id,
eventId=doc.google_calendar_event_id,
body=event,
conferenceDataVersion=conference_data_version,
)
.execute()
)
# if add_video_conferencing enabled or disabled during update, overwrite
frappe.db.set_value(
"Event",
doc.name,
{"google_meet_link": event.get("hangoutLink")},
update_modified=False,
)
doc.notify_update()
frappe.msgprint(_("Event Synced with Google Calendar."))
except HttpError as err:
frappe.throw(
@ -515,12 +567,20 @@ def google_calendar_to_repeat_on(start, end, recurrence=None):
Both have been mapped in a dict for easier mapping.
"""
repeat_on = {
"starts_on": get_datetime(start.get("date"))
if start.get("date")
else parser.parse(start.get("dateTime")).utcnow(),
"ends_on": get_datetime(end.get("date"))
if end.get("date")
else parser.parse(end.get("dateTime")).utcnow(),
"starts_on": (
get_datetime(start.get("date"))
if start.get("date")
else parser.parse(start.get("dateTime"))
.astimezone(ZoneInfo(get_time_zone()))
.replace(tzinfo=None)
),
"ends_on": (
get_datetime(end.get("date"))
if end.get("date")
else parser.parse(end.get("dateTime"))
.astimezone(ZoneInfo(get_time_zone()))
.replace(tzinfo=None)
),
"all_day": 1 if start.get("date") else 0,
"repeat_this_event": 1 if recurrence else 0,
"repeat_on": None,
@ -682,6 +742,39 @@ def get_recurrence_parameters(recurrence):
return frequency, until, byday
def get_conference_data(doc):
return {
"createRequest": {"requestId": doc.name, "conferenceSolutionKey": {"type": "hangoutsMeet"}},
"notes": doc.description,
}
def get_attendees(doc):
"""
Returns a list of dicts with attendee emails, if available in event_participants table
"""
attendees, email_not_found = [], []
for participant in doc.event_participants:
if participant.get("email"):
attendees.append({"email": participant.email})
else:
email_not_found.append(
{"dt": participant.reference_doctype, "dn": participant.reference_docname}
)
if email_not_found:
frappe.msgprint(
_("Google Calendar - Contact / email not found. Did not add attendee for -<br>{0}").format(
"<br>".join(f"{d.get('dt')} {d.get('dn')}" for d in email_not_found)
),
alert=True,
indicator="yellow",
)
return attendees
"""API Response
{
'kind': 'calendar#events',
@ -721,6 +814,32 @@ def get_recurrence_parameters(recurrence):
'recurrence': *recurrence,
'iCalUID': 'uid',
'sequence': 1,
'hangoutLink': 'https://meet.google.com/mee-ting-uri',
'conferenceData': {
'createRequest': {
'requestId': 'EV00001',
'conferenceSolutionKey': {
'type': 'hangoutsMeet'
},
'status': {
'statusCode': 'success'
}
},
'entryPoints': [
{
'entryPointType': 'video',
'uri': 'https://meet.google.com/mee-ting-uri',
'label': 'meet.google.com/mee-ting-uri'
}
],
'conferenceSolution': {
'key': {
'type': 'hangoutsMeet'
},
'name': 'Google Meet',
'iconUri': 'https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v6/web-512dp/logo_meet_2020q4_color_2x_web_512dp.png'
},
'conferenceId': 'mee-ting-uri'
'reminders': {
'useDefault': True
}

View file

@ -152,8 +152,9 @@ class BaseDocument:
if "name" in d:
self.name = d["name"]
ignore_children = hasattr(self, "flags") and self.flags.ignore_children
for key, value in d.items():
self.set(key, value)
self.set(key, value, as_value=ignore_children)
return self
@ -744,7 +745,7 @@ class BaseDocument:
# don't cache if fetching other values too
values = frappe.db.get_value(doctype, docname, values_to_fetch, as_dict=True)
if frappe.get_meta(doctype).issingle:
if getattr(frappe.get_meta(doctype), "issingle", 0):
values.name = doctype
if frappe.get_meta(doctype).get("is_virtual"):
@ -884,7 +885,7 @@ class BaseDocument:
if frappe.flags.in_install:
return
if self.meta.issingle:
if getattr(self.meta, "issingle", 0):
# single doctype value type is mediumtext
return
@ -1174,7 +1175,10 @@ class BaseDocument:
# get values from old doc
if self.get("parent_doc"):
parent_doc = self.parent_doc.get_latest()
ref_doc = [d for d in parent_doc.get(self.parentfield) if d.name == self.name][0]
child_docs = [d for d in parent_doc.get(self.parentfield) if d.name == self.name]
if not child_docs:
return
ref_doc = child_docs[0]
else:
ref_doc = self.get_latest()

View file

@ -635,7 +635,7 @@ class DatabaseQuery:
f.value = date_range
fallback = f"'{FallBackDateTimeStr}'"
if f.operator in (">", "<") and (f.fieldname in ("creation", "modified")):
if f.operator in (">", "<", ">=", "<=") and (f.fieldname in ("creation", "modified")):
value = cstr(f.value)
fallback = f"'{FallBackDateTimeStr}'"

View file

@ -95,6 +95,10 @@ def delete_doc(
update_flags(doc, flags, ignore_permissions)
check_permission_and_not_submitted(doc)
# delete custom table fields using this doctype.
frappe.db.delete(
"Custom Field", {"options": name, "fieldtype": ("in", frappe.model.table_fields)}
)
frappe.db.delete("__global_search", {"doctype": name})
delete_from_table(doctype, name, ignore_doctypes, None)

View file

@ -129,6 +129,7 @@ class Document(BaseDocument):
def load_from_db(self):
"""Load document and children from database and create properties
from fields"""
self.flags.ignore_children = True
if not getattr(self, "_metaclass", False) and self.meta.issingle:
single_doc = frappe.db.get_singles_dict(self.doctype, for_update=self.flags.for_update)
if not single_doc:
@ -150,6 +151,7 @@ class Document(BaseDocument):
)
super().__init__(d)
self.flags.pop("ignore_children", None)
for df in self._get_table_fields():
# Make sure not to query the DB for a child table, if it is a virtual one.
@ -948,15 +950,19 @@ class Document(BaseDocument):
from frappe.email.doctype.notification.notification import evaluate_alert
if self.flags.notifications is None:
alerts = frappe.cache().hget("notifications", self.doctype)
if alerts is None:
alerts = frappe.get_all(
def _get_notifications():
"""returns enabled notifications for the current doctype"""
return frappe.get_all(
"Notification",
fields=["name", "event", "method"],
filters={"enabled": 1, "document_type": self.doctype},
)
frappe.cache().hset("notifications", self.doctype, alerts)
self.flags.notifications = alerts
self.flags.notifications = frappe.cache().hget(
"notifications", self.doctype, _get_notifications
)
if not self.flags.notifications:
return
@ -1172,6 +1178,9 @@ class Document(BaseDocument):
# to trigger notification on value change
self.run_method("before_change")
if self.name is None:
return
frappe.db.set_value(
self.doctype,
self.name,

View file

@ -165,16 +165,7 @@ def set_new_name(doc):
if not doc.name and autoname:
set_name_from_naming_options(autoname, doc)
# if the autoname option is 'field:' and no name was derived, we need to
# notify
if not doc.name and autoname.startswith("field:"):
fieldname = autoname[6:]
frappe.throw(_("{0} is required").format(doc.meta.get_label(fieldname)))
# at this point, we fall back to name generation with the hash option
if not doc.name and autoname == "hash":
doc.name = make_autoname("hash", doc.doctype)
if not doc.name:
doc.name = make_autoname("hash", doc.doctype)
@ -220,6 +211,13 @@ def set_name_from_naming_options(autoname, doc):
if _autoname.startswith("field:"):
doc.name = _field_autoname(autoname, doc)
# if the autoname option is 'field:' and no name was derived, we need to
# notify
if not doc.name:
fieldname = autoname[6:]
frappe.throw(_("{0} is required").format(doc.meta.get_label(fieldname)))
elif _autoname.startswith("naming_series:"):
set_name_by_naming_series(doc)
elif _autoname.startswith("prompt"):
@ -275,8 +273,8 @@ def make_autoname(key="", doctype="", doc=""):
*Example:*
* DE/./.YY./.MM./.##### will create a series like
DE/09/01/0001 where 09 is the year, 01 is the month and 0001 is the series
* DE./.YY./.MM./.##### will create a series like
DE/09/01/00001 where 09 is the year, 01 is the month and 00001 is the series
"""
if key == "hash":
return frappe.generate_hash(doctype, 10)

View file

@ -175,6 +175,7 @@ def execute_patch(patchmodule, method=None, methodargs=None):
start_time = time.time()
frappe.db.begin()
frappe.db.auto_commit_on_many_writes = 0
try:
if patchmodule:
if patchmodule.startswith("finally:"):

View file

@ -305,13 +305,16 @@ def make_boilerplate(
def db_update(self):
pass
def get_list(self, args):
@staticmethod
def get_list(args):
pass
def get_count(self, args):
@staticmethod
def get_count(args):
pass
def get_stats(self, args):
@staticmethod
def get_stats(args):
pass
"""
),

View file

@ -25,6 +25,13 @@ def stop(response=None):
frappe.local.monitor.dump(response)
def add_data_to_monitor(**kwargs) -> None:
"""Add additional custom key-value pairs along with monitor log.
Note: Key-value pairs should be simple JSON exportable types."""
if hasattr(frappe.local, "monitor"):
frappe.local.monitor.add_custom_data(**kwargs)
def log_file():
return os.path.join(frappe.utils.get_bench_path(), "logs", "monitor.json.log")
@ -71,6 +78,10 @@ class Monitor:
waitdiff = self.data.timestamp - job.enqueued_at
self.data.job.wait = int(waitdiff.total_seconds() * 1000000)
def add_custom_data(self, **kwargs):
if self.data:
self.data.update(kwargs)
def dump(self, response=None):
try:
timediff = datetime.utcnow() - self.data.timestamp
@ -94,7 +105,7 @@ class Monitor:
def store(self):
if frappe.cache().llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES:
frappe.cache().ltrim(MONITOR_REDIS_KEY, 1, -1)
serialized = json.dumps(self.data, sort_keys=True, default=str)
serialized = json.dumps(self.data, sort_keys=True, default=str, separators=(",", ":"))
frappe.cache().rpush(MONITOR_REDIS_KEY, serialized)

View file

@ -18,11 +18,12 @@ if click_ctx:
class ParallelTestRunner:
def __init__(self, app, site, build_number=1, total_builds=1):
def __init__(self, app, site, build_number=1, total_builds=1, dry_run=False):
self.app = app
self.site = site
self.build_number = frappe.utils.cint(build_number) or 1
self.total_builds = frappe.utils.cint(total_builds)
self.dry_run = dry_run
self.setup_test_site()
self.run_tests()
@ -31,6 +32,9 @@ class ParallelTestRunner:
if not frappe.db:
frappe.connect()
if self.dry_run:
return
frappe.flags.in_test = True
frappe.clear_cache()
frappe.utils.scheduler.disable_scheduler()
@ -64,6 +68,10 @@ class ParallelTestRunner:
if not file_info:
return
if self.dry_run:
print("running tests from", "/".join(file_info))
return
frappe.set_user("Administrator")
path, filename = file_info
module = self.get_module(path, filename)
@ -108,12 +116,48 @@ class ParallelTestRunner:
sys.exit(1)
def get_test_file_list(self):
# Load balance based on total # of tests ~ each runner should get roughly same # of tests.
test_list = get_all_tests(self.app)
split_size = frappe.utils.ceil(len(test_list) / self.total_builds)
# [1,2,3,4,5,6] to [[1,2], [3,4], [4,6]] if split_size is 2
test_chunks = [test_list[x : x + split_size] for x in range(0, len(test_list), split_size)]
test_counts = [self.get_test_count(test) for test in test_list]
test_chunks = split_by_weight(test_list, test_counts, chunk_count=self.total_builds)
return test_chunks[self.build_number - 1]
@staticmethod
def get_test_count(test):
"""Get approximate count of tests inside a file"""
file_name = "/".join(test)
with open(file_name) as f:
test_count = f.read().count("def test_")
return test_count
def split_by_weight(work, weights, chunk_count):
"""Roughly split work by respective weight while keep ordering."""
expected_weight = sum(weights) // chunk_count
chunks = [[] for _ in range(chunk_count)]
chunk_no = 0
chunk_weight = 0
for task, weight in zip(work, weights):
if chunk_weight > expected_weight:
chunk_weight = 0
chunk_no += 1
assert chunk_no < chunk_count
chunks[chunk_no].append(task)
chunk_weight += weight
assert len(work) == sum(len(chunk) for chunk in chunks)
assert len(chunks) == chunk_count
return chunks
class ParallelTestResult(unittest.TextTestResult):
def startTest(self, test):

View file

@ -213,3 +213,5 @@ frappe.patches.v14_0.update_multistep_webforms
execute:frappe.delete_doc('Page', 'background_jobs', ignore_missing=True, force=True)
frappe.patches.v14_0.drop_unused_indexes
frappe.patches.v15_0.drop_modified_index
frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings
frappe.patches.v14_0.update_attachment_comment

View file

@ -0,0 +1,25 @@
import frappe
def execute():
navbar_settings = frappe.get_single("Navbar Settings")
if frappe.db.exists("Navbar Item", {"item_label": "Manage Subscriptions"}):
return
for idx, row in enumerate(navbar_settings.settings_dropdown[2:], start=4):
row.idx = idx
navbar_settings.append(
"settings_dropdown",
{
"item_label": "Manage Subscriptions",
"item_type": "Action",
"action": "frappe.ui.toolbar.redirectToUrl()",
"is_standard": 1,
"hidden": 1,
"idx": 3,
},
)
navbar_settings.save()

View file

@ -0,0 +1,33 @@
import frappe
def execute():
frappe.db.auto_commit_on_many_writes = 1
# Strip everything except link to attachment and icon from comments of type "Attached"
for name, content in frappe.get_all(
"Comment", filters={"comment_type": "Attachment"}, fields=["name", "content"], as_list=True
):
if not content:
continue
start = content.find("<a href")
if start != -1:
content = content[start:]
end = content.find("</i>")
end = content.find("</a>") if end == -1 else end
if end != -1:
content = content[: end + 4]
frappe.db.set_value("Comment", name, "content", content, update_modified=False)
# Strip "Removed " from comments of type "Attachment Removed"
for name, content in frappe.get_all(
"Comment",
filters={"comment_type": "Attachment Removed"},
fields=["name", "content"],
as_list=True,
):
if content and content.startswith("Removed "):
frappe.db.set_value("Comment", name, "content", content[8:], update_modified=False)

View file

@ -1,6 +1,7 @@
import frappe
import json
import frappe
def execute():
if frappe.db.exists("Social Login Key", "github"):

View file

@ -83,6 +83,7 @@ import "./frappe/ui/toolbar/search_utils.js";
import "./frappe/ui/toolbar/about.js";
import "./frappe/ui/toolbar/navbar.html";
import "./frappe/ui/toolbar/toolbar.js";
import "./frappe/ui/toolbar/subscription.js";
// import "./frappe/ui/toolbar/notifications.js";
import "./frappe/views/communication.js";
import "./frappe/views/translation_manager.js";

View file

@ -1,7 +1,6 @@
import "./jquery-bootstrap";
import "./libs.bundle.js";
import "./frappe/class.js";
import "./frappe/polyfill.js";
import "./lib/moment.js";
import "./frappe/provide.js";
import "./frappe/translate.js";
import "./frappe/form/formatters.js";

View file

@ -9,47 +9,46 @@
</div>
</div>
</template>
<script>
export default {
name: "BuildError",
data() {
return {
data: null
};
},
methods: {
show(data) {
this.data = data;
},
hide() {
this.data = null;
},
open_in_editor(location) {
frappe.socketio.socket.emit("open_in_editor", location);
},
error_component(error, i) {
let location = this.data.error.errors[i].location;
let location_string = `${location.file}:${location.line}:${
location.column
}`;
let template = error.replace(
" > " + location_string,
` &gt; <a class="file-link" @click="open">${location_string}</a>`
);
return {
template: `<div>${template}</div>`,
methods: {
open() {
frappe.socketio.socket.emit("open_in_editor", location);
}
}
};
<script setup>
import { ref } from "vue";
// variables
let data = ref(null);
// Methods
function show(data) {
data.value = data;
}
function hide() {
data.value = null;
}
function open_in_editor(location) {
frappe.socketio.socket.emit("open_in_editor", location);
}
function error_component(error, i) {
let location = data.value.error.errors[i].location;
let location_string = `${location.file}:${location.line}:${
location.column
}`;
let template = error.replace(
" > " + location_string,
` &gt; <a class="file-link" @click="open">${location_string}</a>`
);
return {
template: `<div>${template}</div>`,
methods: {
open() {
frappe.socketio.socket.emit("open_in_editor", location);
}
}
}
};
};
}
defineExpose({show, hide});
</script>
<style>
<style scoped>
.build-error-overlay {
position: fixed;
top: 0;

View file

@ -12,40 +12,41 @@
</a>
</div>
</template>
<script>
export default {
name: "BuildSuccess",
data() {
return {
is_shown: false,
live_reload: false,
};
},
methods: {
show(data) {
if (data.live_reload) {
this.live_reload = true;
this.reload();
}
this.is_shown = true;
if (this.timeout) {
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() => {
this.hide();
}, 10000);
},
hide() {
this.is_shown = false;
},
reload() {
window.location.reload();
}
<script setup>
import { ref } from "vue";
// variables
let is_shown = ref(false);
let live_reload = ref(false);
let timeout = ref(null);
// Methods
function show(data) {
if (data.live_reload) {
live_reload.value = true;
reload();
}
};
is_shown.value = true;
if (timeout.value) {
clearTimeout(timeout.value);
}
timeout.value = setTimeout(() => {
hide();
}, 10000);
}
function hide() {
is_shown.value = false;
}
function reload() {
window.location.reload();
}
defineExpose({show, hide});
</script>
<style>
<style scoped>
.build-success-message {
position: fixed;
z-index: 9999;

View file

@ -1,3 +1,4 @@
import { createApp } from "vue";
import BuildError from "./BuildError.vue";
import BuildSuccess from "./BuildSuccess.vue";
@ -48,11 +49,7 @@ function show_build_success(data) {
if (!success) {
let target = $('<div class="build-success-container">').appendTo($container).get(0);
let vm = new Vue({
el: target,
render: (h) => h(BuildSuccess),
});
success = vm.$children[0];
success = createApp(BuildSuccess).mount(target);
}
success.show(data);
}
@ -63,11 +60,7 @@ function show_build_error(data) {
}
if (!error) {
let target = $('<div class="build-error-container">').appendTo($container).get(0);
let vm = new Vue({
el: target,
render: (h) => h(BuildError),
});
error = vm.$children[0];
error = createApp(BuildError).mount(target);
}
error.show(data);
}

View file

@ -43,7 +43,6 @@ frappe.Application = class Application {
throw "boot failed";
}
this.setup_frappe_vue();
this.load_bootinfo();
this.load_user_permissions();
this.make_nav_bar();
@ -183,11 +182,6 @@ frappe.Application = class Application {
});
}
setup_frappe_vue() {
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
}
set_password(user) {
var me = this;
frappe.call({

View file

@ -4,7 +4,7 @@
<a
href=""
class="text-muted text-medium"
@click.prevent="$emit('hide-browser')"
@click.prevent="emit('hide-browser')"
>
{{ __("← Back to upload files") }}
</a>
@ -16,7 +16,7 @@
class="form-control input-xs"
:placeholder="__('Search by filename or extension')"
v-model="search_text"
@input="search_by_name"
@input="frappe.utils.debounce(search_by_name(), 300)"
/>
</div>
<TreeNode
@ -29,153 +29,154 @@
</div>
</div>
</template>
<script>
<script setup>
import { onMounted, ref } from "vue";
import TreeNode from "./TreeNode.vue";
export default {
name: "FileBrowser",
components: {
TreeNode
},
data() {
return {
node: {
label: __("Home"),
value: "Home",
children: [],
children_start: 0,
children_loading: false,
is_leaf: false,
fetching: false,
fetched: false,
open: false,
filtered: true
},
selected_node: {},
search_text: "",
page_length: 10
};
},
mounted() {
this.toggle_node(this.node);
},
methods: {
toggle_node(node) {
if (!node.fetched && !node.is_leaf) {
node.fetching = true;
node.children_start = 0;
// emits
let emit = defineEmits(["hide-browser"]);
// variables
let node = ref({
label: __("Home"),
value: "Home",
children: [],
children_start: 0,
children_loading: false,
is_leaf: false,
fetching: false,
fetched: false,
open: false,
filtered: true
});
let selected_node = ref({});
let search_text = ref("");
let page_length = ref(10);
let folder_node = ref(null);
// methods
function toggle_node(node) {
if (!node.fetched && !node.is_leaf) {
node.fetching = true;
node.children_start = 0;
node.children_loading = false;
get_files_in_folder(node.value, 0).then(
({ files, has_more }) => {
node.open = true;
node.children = files;
node.fetched = true;
node.fetching = false;
node.children_start += page_length.value;
node.has_more_children = has_more;
}
);
} else {
node.open = !node.open;
select_node(node);
}
}
function load_more(node) {
if (node.has_more_children) {
let start = node.children_start;
node.children_loading = true;
get_files_in_folder(node.value, start).then(
({ files, has_more }) => {
node.children = node.children.concat(files);
node.children_start += page_length.value;
node.has_more_children = has_more;
node.children_loading = false;
this.get_files_in_folder(node.value, 0).then(
({ files, has_more }) => {
node.open = true;
node.children = files;
node.fetched = true;
node.fetching = false;
node.children_start += this.page_length;
node.has_more_children = has_more;
}
);
} else {
node.open = !node.open;
this.select_node(node);
}
},
load_more(node) {
if (node.has_more_children) {
let start = node.children_start;
node.children_loading = true;
this.get_files_in_folder(node.value, start).then(
({ files, has_more }) => {
node.children = node.children.concat(files);
node.children_start += this.page_length;
node.has_more_children = has_more;
node.children_loading = false;
}
);
);
}
}
function select_node(node) {
if (node.is_leaf) {
selected_node.value = node;
}
}
function get_files_in_folder(folder, start) {
return frappe
.call("frappe.core.api.file.get_files_in_folder", {
folder,
start,
page_length: page_length.value
})
.then(r => {
let { files = [], has_more = false } = r.message || {};
files.sort((a, b) => {
if (a.is_folder && b.is_folder) {
return a.modified < b.modified ? -1 : 1;
}
if (a.is_folder) {
return -1;
}
if (b.is_folder) {
return 1;
}
return 0;
});
files = files.map(file => make_file_node(file));
return { files, has_more };
});
}
function search_by_name() {
if (search_text.value === "") {
node.value = folder_node.value;
return;
}
if (search_text.value.length < 3) return;
frappe
.call(
"frappe.core.api.file.get_files_by_search_text",
{
text: search_text.value
}
},
select_node(node) {
if (node.is_leaf) {
this.selected_node = node;
)
.then(r => {
let files = r.message || [];
files = files.map(file => make_file_node(file));
if (!folder_node.value) {
folder_node.value = node.value;
}
},
get_files_in_folder(folder, start) {
return frappe
.call("frappe.core.api.file.get_files_in_folder", {
folder,
start,
page_length: this.page_length
})
.then(r => {
let { files = [], has_more = false } = r.message || {};
files.sort((a, b) => {
if (a.is_folder && b.is_folder) {
return a.modified < b.modified ? -1 : 1;
}
if (a.is_folder) {
return -1;
}
if (b.is_folder) {
return 1;
}
return 0;
});
files = files.map(file => this.make_file_node(file));
return { files, has_more };
});
},
search_by_name: frappe.utils.debounce(function() {
if (this.search_text === "") {
this.node = this.folder_node;
return;
}
if (this.search_text.length < 3) return;
frappe
.call(
"frappe.core.api.file.get_files_by_search_text",
{
text: this.search_text
}
)
.then(r => {
let files = r.message || [];
files = files.map(file => this.make_file_node(file));
if (!this.folder_node) {
this.folder_node = this.node;
}
this.node = {
label: __("Search Results"),
value: "",
children: files,
by_search: true,
open: true,
filtered: true
};
});
}, 300),
make_file_node(file) {
let filename = file.file_name || file.name;
let label = frappe.utils.file_name_ellipsis(filename, 40);
return {
label: label,
filename: filename,
file_url: file.file_url,
value: file.name,
is_leaf: !file.is_folder,
fetched: !file.is_folder, // fetched if node is leaf
children: [],
children_loading: false,
children_start: 0,
open: false,
fetching: false,
node.value = {
label: __("Search Results"),
value: "",
children: files,
by_search: true,
open: true,
filtered: true
};
}
}
};
});
}
function make_file_node(file) {
let filename = file.file_name || file.name;
let label = frappe.utils.file_name_ellipsis(filename, 40);
return {
label: label,
filename: filename,
file_url: file.file_url,
value: file.name,
is_leaf: !file.is_folder,
fetched: !file.is_folder, // fetched if node is leaf
children: [],
children_loading: false,
children_start: 0,
open: false,
fetching: false,
filtered: true
};
}
// mounted
onMounted(() => {
toggle_node(node.value);
});
defineExpose({ selected_node });
</script>
<style>
<style scoped>
.file-browser-list {
height: 300px;
overflow: hidden;

View file

@ -12,20 +12,20 @@
<div>
<div>
<a class="flex" :href="file.doc.file_url" v-if="file.doc" target="_blank">
<span class="file-name">{{ file.name | file_name }}</span>
<span class="file-name">{{ file.name }}</span>
</a>
<span class="file-name" v-else>{{ file.name | file_name }}</span>
<span class="file-name" v-else>{{ file.name }}</span>
</div>
<div>
<span class="file-size">
{{ file.file_obj.size | file_size }}
{{ file_size }}
</span>
</div>
<div class="flex config-area">
<label v-if="is_optimizable" class="frappe-checkbox"><input type="checkbox" :checked="optimize" @change="$emit('toggle_optimize')">Optimize</label>
<label class="frappe-checkbox"><input type="checkbox" :checked="file.private" @change="$emit('toggle_private')">Private</label>
<label v-if="is_optimizable" class="frappe-checkbox"><input type="checkbox" :checked="optimize" @change="emit('toggle_optimize')">Optimize</label>
<label class="frappe-checkbox"><input type="checkbox" :checked="file.private" @change="emit('toggle_private')">Private</label>
</div>
<div>
<span v-if="file.error_message" class="file-error text-danger">
@ -45,75 +45,71 @@
<div v-if="uploaded" v-html="frappe.utils.icon('solid-success', 'lg')"></div>
<div v-if="file.failed" v-html="frappe.utils.icon('solid-error', 'lg')"></div>
<div class="file-action-buttons">
<button v-if="is_cropable" class="btn btn-crop muted" @click="$emit('toggle_image_cropper')" v-html="frappe.utils.icon('crop', 'md')"></button>
<button v-if="!uploaded && !file.uploading && !file.failed" class="btn muted" @click="$emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
<button v-if="is_cropable" class="btn btn-crop muted" @click="emit('toggle_image_cropper')" v-html="frappe.utils.icon('crop', 'md')"></button>
<button v-if="!uploaded && !file.uploading && !file.failed" class="btn muted" @click="emit('remove')" v-html="frappe.utils.icon('delete', 'md')"></button>
</div>
</div>
</div>
</template>
<script>
import ProgressRing from './ProgressRing.vue';
export default {
name: 'FilePreview',
props: ['file'],
components: {
ProgressRing
},
data() {
return {
src: null,
optimize: this.file.optimize
}
},
mounted() {
if (this.is_image) {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => this.src = fr.result;
fr.readAsDataURL(this.file.file_obj);
}
}
},
filters: {
file_size(value) {
return frappe.form.formatters.FileSize(value);
},
file_name(value) {
return value;
// return frappe.utils.file_name_ellipsis(value, 9);
}
},
computed: {
is_private() {
return this.file.doc ? this.file.doc.is_private : this.file.private;
},
uploaded() {
return this.file.request_succeeded;
},
is_image() {
return this.file.file_obj.type.startsWith('image');
},
is_optimizable() {
let is_svg = this.file.file_obj.type == 'image/svg+xml';
return this.is_image && !is_svg && !this.uploaded && !this.file.failed;
},
is_cropable() {
let croppable_types = ['image/jpeg', 'image/png'];
return !this.uploaded && !this.file.uploading && !this.file.failed && croppable_types.includes(this.file.file_obj.type);
},
progress() {
let value = Math.round((this.file.progress * 100) / this.file.total);
if (isNaN(value)) {
value = 0;
}
return value;
<script setup>
import { ref, onMounted, computed } from "vue";
import ProgressRing from "./ProgressRing.vue";
// emits
let emit = defineEmits(["toggle_optimize", "toggle_private", "toggle_image_cropper", "remove"]);
// props
let props = defineProps({
file: Object,
});
// variables
let src = ref(null);
let optimize = ref(props.file.optimize);
// computed
let file_size = computed(() => {
return frappe.form.formatters.FileSize(props.file.file_obj.size);
});
let is_private = computed(() => {
return props.file.doc ? props.file.doc.is_private : props.file.private;
});
let uploaded = computed(() => {
return props.file.request_succeeded;
});
let is_image = computed(() => {
return props.file.file_obj.type.startsWith('image');
});
let is_optimizable = computed(() => {
let is_svg = props.file.file_obj.type == 'image/svg+xml';
return is_image.value && !is_svg && !uploaded.value && !props.file.failed;
});
let is_cropable = computed(() => {
let croppable_types = ['image/jpeg', 'image/png'];
return !uploaded.value && !props.file.uploading && !props.file.failed && croppable_types.includes(props.file.file_obj.type);
});
let progress = computed(() => {
let value = Math.round((props.file.progress * 100) / props.file.total);
if (isNaN(value)) {
value = 0;
}
return value;
});
// mounted
onMounted(() => {
if (is_image.value) {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => src.value = fr.result;
fr.readAsDataURL(props.file.file_obj);
}
}
}
});
</script>
<style>
<style scoped>
.file-preview {
display: flex;
align-items: center;

View file

@ -127,476 +127,479 @@
</div>
</template>
<script>
<script setup>
import { computed, ref, watch } from 'vue';
import FilePreview from './FilePreview.vue';
import FileBrowser from './FileBrowser.vue';
import WebLink from './WebLink.vue';
import GoogleDrivePicker from '../../integrations/google_drive_picker';
import ImageCropper from './ImageCropper.vue';
export default {
name: 'FileUploader',
props: {
show_upload_button: {
default: true
},
disable_file_browser: {
default: false
},
allow_multiple: {
default: true
},
as_dataurl: {
default: false
},
doctype: {
default: null
},
docname: {
default: null
},
fieldname: {
default: null
},
folder: {
default: 'Home'
},
method: {
default: null
},
on_success: {
default: null
},
make_attachments_public: {
default: null,
},
restrictions: {
default: () => ({
max_file_size: null, // 2048 -> 2KB
max_number_of_files: null,
allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'],
crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free)
})
},
attach_doc_image: {
default: false
},
upload_notes: {
default: null // "Images or video, upto 2MB"
}
// props
const props = defineProps({
show_upload_button: {
default: true
},
components: {
FilePreview,
FileBrowser,
WebLink,
ImageCropper
disable_file_browser: {
default: false
},
data() {
return {
files: [],
is_dragging: false,
currently_uploading: -1,
show_file_browser: false,
show_web_link: false,
show_image_cropper: false,
crop_image_with_index: -1,
trigger_upload: false,
close_dialog: false,
hide_dialog_footer: false,
allow_take_photo: false,
allow_web_link: true,
google_drive_settings: {
enabled: false
},
wrapper_ready: false
}
allow_multiple: {
default: true
},
created() {
this.allow_take_photo = window.navigator.mediaDevices;
if (frappe.user_id !== "Guest") {
frappe.call({
// method only available after login
method: "frappe.integrations.doctype.google_settings.google_settings.get_file_picker_settings",
callback: (resp) => {
if (!resp.exc) {
this.google_drive_settings = resp.message;
}
}
});
}
if (this.restrictions.max_file_size == null) {
frappe.call('frappe.core.api.file.get_max_file_size')
.then(res => {
this.restrictions.max_file_size = Number(res.message);
});
}
if (this.restrictions.max_number_of_files == null && this.doctype) {
this.restrictions.max_number_of_files = frappe.get_meta(this.doctype)?.max_attachments;
}
as_dataurl: {
default: false
},
watch: {
files(newvalue, oldvalue) {
if (!this.allow_multiple && newvalue.length > 1) {
this.files = [newvalue[newvalue.length - 1]];
doctype: {
default: null
},
docname: {
default: null
},
fieldname: {
default: null
},
folder: {
default: 'Home'
},
method: {
default: null
},
on_success: {
default: null
},
make_attachments_public: {
default: null,
},
restrictions: {
default: () => ({
max_file_size: null, // 2048 -> 2KB
max_number_of_files: null,
allowed_file_types: [], // ['image/*', 'video/*', '.jpg', '.gif', '.pdf'],
crop_image_aspect_ratio: null // 1, 16 / 9, 4 / 3, NaN (free)
})
},
attach_doc_image: {
default: false
},
upload_notes: {
default: null // "Images or video, upto 2MB"
}
});
// variables
let files = ref([]);
let file_input = ref(null);
let file_browser = ref(null);
let web_link = ref(null);
let is_dragging = ref(false);
let currently_uploading = ref(-1);
let show_file_browser = ref(false);
let show_web_link = ref(false);
let show_image_cropper = ref(false);
let crop_image_with_index = ref(-1);
let trigger_upload = ref(false);
let close_dialog = ref(false);
let hide_dialog_footer = ref(false);
let allow_take_photo = ref(false);
let allow_web_link = ref(true);
let google_drive_settings = ref({
enabled: false
});
let wrapper_ready = ref(false);
// created
allow_take_photo.value = window.navigator.mediaDevices;
if (frappe.user_id !== "Guest") {
frappe.call({
// method only available after login
method: "frappe.integrations.doctype.google_settings.google_settings.get_file_picker_settings",
callback: (resp) => {
if (!resp.exc) {
google_drive_settings.value = resp.message;
}
}
},
computed: {
upload_complete() {
return this.files.length > 0
&& this.files.every(
file => file.total !== 0 && file.progress === file.total);
});
}
if (props.restrictions.max_file_size == null) {
frappe.call('frappe.core.api.file.get_max_file_size')
.then(res => {
props.restrictions.max_file_size = Number(res.message);
});
}
if (props.restrictions.max_number_of_files == null && props.doctype) {
props.restrictions.max_number_of_files = frappe.get_meta(props.doctype)?.max_attachments;
}
// methods
function dragover() {
is_dragging.value = true;
}
function dragleave() {
is_dragging.value = false;
}
function dropfiles(e) {
is_dragging.value = false;
add_files(e.dataTransfer.files);
}
function browse_files() {
file_input.value.click();
}
function on_file_input(e) {
add_files(file_input.value.files);
}
function remove_file(file) {
files.value = files.value.filter(f => f !== file);
}
function toggle_image_cropper(index) {
crop_image_with_index.value = show_image_cropper.value ? -1 : index;
hide_dialog_footer.value = !show_image_cropper.value;
show_image_cropper.value = !show_image_cropper.value;
}
function toggle_all_private() {
let flag;
let private_values = files.value.filter(file => file.private);
if (private_values.length < files.value.length) {
// there are some private and some public
// set all to private
flag = true;
} else {
// all are private, set all to public
flag = false;
}
files.value = files.value.map(file => {
file.private = flag;
return file;
});
}
function show_max_files_number_warning(file) {
console.warn(
`File skipped because it exceeds the allowed specified limit of ${max_number_of_files} uploads`,
file,
);
if (props.doctype) {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"', [file.name, max_number_of_files, props.doctype])
} else {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed', [file.name, max_number_of_files])
}
frappe.show_alert({
message: MSG,
indicator: "orange",
});
}
function add_files(file_array) {
let _files = Array.from(file_array)
.filter(check_restrictions)
.map(file => {
let is_image = file.type.startsWith('image');
let size_kb = file.size / 1024;
return {
file_obj: file,
cropper_file: file,
crop_box_data: null,
optimize: size_kb > 200 && is_image && !file.type.includes('svg'),
name: file.name,
doc: null,
progress: 0,
total: 0,
failed: false,
request_succeeded: false,
error_message: null,
uploading: false,
private: !props.make_attachments_public,
};
});
// pop extra files as per FileUploader.restrictions.max_number_of_files
max_number_of_files = props.restrictions.max_number_of_files;
if (max_number_of_files && _files.length > max_number_of_files) {
_files.slice(max_number_of_files).forEach(file => {
show_max_files_number_warning(file, props.doctype);
});
_files = _files.slice(0, max_number_of_files);
}
files.value = files.value.concat(_files);
// if only one file is allowed and crop_image_aspect_ratio is set, open cropper immediately
if (files.value.length === 1 && !props.allow_multiple && props.restrictions.crop_image_aspect_ratio != null) {
if (!files.value[0].file_obj.type.includes('svg')) {
toggle_image_cropper(0);
}
},
methods: {
dragover() {
this.is_dragging = true;
},
dragleave() {
this.is_dragging = false;
},
dropfiles(e) {
this.is_dragging = false;
this.add_files(e.dataTransfer.files);
},
browse_files() {
this.$refs.file_input.click();
},
on_file_input(e) {
this.add_files(this.$refs.file_input.files);
},
remove_file(file) {
this.files = this.files.filter(f => f !== file);
},
toggle_image_cropper(index) {
this.crop_image_with_index = this.show_image_cropper ? -1 : index;
this.hide_dialog_footer = !this.show_image_cropper;
this.show_image_cropper = !this.show_image_cropper;
},
toggle_all_private() {
let flag;
let private_values = this.files.filter(file => file.private);
if (private_values.length < this.files.length) {
// there are some private and some public
// set all to private
flag = true;
} else {
// all are private, set all to public
flag = false;
}
this.files = this.files.map(file => {
file.private = flag;
return file;
});
},
show_max_files_number_warning(file) {
console.warn(
`File skipped because it exceeds the allowed specified limit of ${max_number_of_files} uploads`,
file,
);
if (this.doctype) {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"', [file.name, max_number_of_files, this.doctype])
} else {
MSG = __('File "{0}" was skipped because only {1} uploads are allowed', [file.name, max_number_of_files])
}
frappe.show_alert({
message: MSG,
indicator: "orange",
});
},
add_files(file_array) {
let files = Array.from(file_array)
.filter(this.check_restrictions)
.map(file => {
let is_image = file.type.startsWith('image');
let size_kb = file.size / 1024;
return {
file_obj: file,
cropper_file: file,
crop_box_data: null,
optimize: size_kb > 200 && is_image && !file.type.includes('svg'),
name: file.name,
doc: null,
progress: 0,
total: 0,
failed: false,
request_succeeded: false,
error_message: null,
uploading: false,
private: !this.make_attachments_public,
};
});
// pop extra files as per FileUploader.restrictions.max_number_of_files
max_number_of_files = this.restrictions.max_number_of_files;
if (max_number_of_files && files.length > max_number_of_files) {
files.slice(max_number_of_files).forEach(file => {
this.show_max_files_number_warning(file, this.doctype);
});
files = files.slice(0, max_number_of_files);
}
this.files = this.files.concat(files);
// if only one file is allowed and crop_image_aspect_ratio is set, open cropper immediately
if (this.files.length === 1 && !this.allow_multiple && this.restrictions.crop_image_aspect_ratio != null) {
if (!this.files[0].file_obj.type.includes('svg')) {
this.toggle_image_cropper(0);
}
}
},
check_restrictions(file) {
let { max_file_size, allowed_file_types = [] } = this.restrictions;
let is_correct_type = true;
let valid_file_size = true;
if (allowed_file_types && allowed_file_types.length) {
is_correct_type = allowed_file_types.some((type) => {
// is this is a mime-type
if (type.includes('/')) {
if (!file.type) return false;
return file.type.match(type);
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.name.endsWith(type);
}
return false;
});
}
if (max_file_size && file.size != null) {
valid_file_size = file.size < max_file_size;
}
if (!is_correct_type) {
console.warn('File skipped because of invalid file type', file);
frappe.show_alert({
message: __('File "{0}" was skipped because of invalid file type', [file.name]),
indicator: 'orange'
});
}
if (!valid_file_size) {
console.warn('File skipped because of invalid file size', file.size, file);
frappe.show_alert({
message: __('File "{0}" was skipped because size exceeds {1} MB', [file.name, max_file_size / (1024 * 1024)]),
indicator: 'orange'
});
}
return is_correct_type && valid_file_size;
},
upload_files() {
if (this.show_file_browser) {
return this.upload_via_file_browser();
}
if (this.show_web_link) {
return this.upload_via_web_link();
}
if (this.as_dataurl) {
return this.return_as_dataurl();
}
return frappe.run_serially(
this.files.map(
(file, i) =>
() => this.upload_file(file, i)
)
);
},
upload_via_file_browser() {
let selected_file = this.$refs.file_browser.selected_node;
if (!selected_file.value) {
frappe.msgprint(__('Click on a file to select it.'));
this.close_dialog = true;
return Promise.reject();
}
this.close_dialog = true;
return this.upload_file({
file_url: selected_file.file_url
});
},
upload_via_web_link() {
let file_url = this.$refs.web_link.url;
if (!file_url) {
frappe.msgprint(__('Invalid URL'));
this.close_dialog = true;
return Promise.reject();
}
file_url = decodeURI(file_url)
this.close_dialog = true;
return this.upload_file({
file_url
});
},
return_as_dataurl() {
let promises = this.files.map(file =>
frappe.dom.file_to_base64(file.file_obj)
.then(dataurl => {
file.dataurl = dataurl;
this.on_success && this.on_success(file);
})
);
this.close_dialog = true;
return Promise.all(promises);
},
upload_file(file, i) {
this.currently_uploading = i;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', (e) => {
file.uploading = true;
})
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
file.progress = e.loaded;
file.total = e.total;
}
})
xhr.upload.addEventListener('load', (e) => {
file.uploading = false;
resolve();
})
xhr.addEventListener('error', (e) => {
file.failed = true;
reject();
})
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
if (xhr.status === 200) {
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message.doctype === 'File') {
file_doc = r.message;
}
} catch(e) {
r = xhr.responseText;
}
file.doc = file_doc;
if (this.on_success) {
this.on_success(file_doc, r);
}
if (i == this.files.length - 1 && this.files.every(file => file.request_succeeded)) {
this.close_dialog = true;
}
} else if (xhr.status === 403) {
file.failed = true;
let response = JSON.parse(xhr.responseText);
file.error_message = `Not permitted. ${response._error_message || ''}`;
} else if (xhr.status === 413) {
file.failed = true;
file.error_message = 'Size exceeds the maximum allowed file size.';
} else {
file.failed = true;
file.error_message = xhr.status === 0 ? 'XMLHttpRequest Error' : `${xhr.status} : ${xhr.statusText}`;
let error = null;
try {
error = JSON.parse(xhr.responseText);
} catch(e) {
// pass
}
frappe.request.cleanup({}, error);
}
}
}
xhr.open('POST', '/api/method/upload_file', true);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Frappe-CSRF-Token', frappe.csrf_token);
let form_data = new FormData();
if (file.file_obj) {
form_data.append('file', file.file_obj, file.name);
}
form_data.append('is_private', +file.private);
form_data.append('folder', this.folder);
if (file.file_url) {
form_data.append('file_url', file.file_url);
}
if (file.file_name) {
form_data.append('file_name', file.file_name);
}
if (this.doctype && this.docname) {
form_data.append('doctype', this.doctype);
form_data.append('docname', this.docname);
}
if (this.fieldname) {
form_data.append('fieldname', this.fieldname);
}
if (this.method) {
form_data.append('method', this.method);
}
if (file.optimize) {
form_data.append('optimize', true);
}
if (this.attach_doc_image) {
form_data.append('max_width', 200);
form_data.append('max_height', 200);
}
xhr.send(form_data);
});
},
capture_image() {
const capture = new frappe.ui.Capture({
animate: false,
error: true
});
capture.show();
capture.submit(data_urls => {
data_urls.forEach(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
this.url_to_file(data_url, filename, 'image/png').then((file) =>
this.add_files([file])
);
});
});
},
show_google_drive_picker() {
this.close_dialog = true;
let google_drive = new GoogleDrivePicker({
pickerCallback: data => this.google_drive_callback(data),
...this.google_drive_settings
});
google_drive.loadPicker();
},
google_drive_callback(data) {
if (data.action == google.picker.Action.PICKED) {
this.upload_file({
file_url: data.docs[0].url,
file_name: data.docs[0].name
});
} else if (data.action == google.picker.Action.CANCEL) {
cur_frm.attachments.new_attachment()
}
},
url_to_file(url, filename, mime_type) {
return fetch(url)
.then(res => res.arrayBuffer())
.then(buffer => new File([buffer], filename, { type: mime_type }));
},
}
}
function check_restrictions(file) {
let { max_file_size, allowed_file_types = [] } = props.restrictions;
let is_correct_type = true;
let valid_file_size = true;
if (allowed_file_types && allowed_file_types.length) {
is_correct_type = allowed_file_types.some((type) => {
// is this is a mime-type
if (type.includes('/')) {
if (!file.type) return false;
return file.type.match(type);
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.name.endsWith(type);
}
return false;
});
}
if (max_file_size && file.size != null) {
valid_file_size = file.size < max_file_size;
}
if (!is_correct_type) {
console.warn('File skipped because of invalid file type', file);
frappe.show_alert({
message: __('File "{0}" was skipped because of invalid file type', [file.name]),
indicator: 'orange'
});
}
if (!valid_file_size) {
console.warn('File skipped because of invalid file size', file.size, file);
frappe.show_alert({
message: __('File "{0}" was skipped because size exceeds {1} MB', [file.name, max_file_size / (1024 * 1024)]),
indicator: 'orange'
});
}
return is_correct_type && valid_file_size;
}
function upload_files() {
if (show_file_browser.value) {
return upload_via_file_browser();
}
if (show_web_link.value) {
return upload_via_web_link();
}
if (props.as_dataurl) {
return return_as_dataurl();
}
return frappe.run_serially(
files.value.map(
(file, i) =>
() => upload_file(file, i)
)
);
}
function upload_via_file_browser() {
let selected_file = file_browser.value.selected_node;
if (!selected_file.value) {
frappe.msgprint(__('Click on a file to select it.'));
close_dialog.value = true;
return Promise.reject();
}
close_dialog.value = true;
return upload_file({
file_url: selected_file.file_url
});
}
function upload_via_web_link() {
let file_url = web_link.value.url;
if (!file_url) {
frappe.msgprint(__('Invalid URL'));
close_dialog.value = true;
return Promise.reject();
}
file_url = decodeURI(file_url)
close_dialog.value = true;
return upload_file({
file_url
});
}
function return_as_dataurl() {
let promises = files.value.map(file =>
frappe.dom.file_to_base64(file.file_obj)
.then(dataurl => {
file.dataurl = dataurl;
props.on_success && props.on_success(file);
})
);
close_dialog.value = true;
return Promise.all(promises);
}
function upload_file(file, i) {
currently_uploading.value = i;
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.upload.addEventListener('loadstart', (e) => {
file.uploading = true;
})
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
file.progress = e.loaded;
file.total = e.total;
}
})
xhr.upload.addEventListener('load', (e) => {
file.uploading = false;
resolve();
})
xhr.addEventListener('error', (e) => {
file.failed = true;
reject();
})
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
if (xhr.status === 200) {
file.request_succeeded = true;
let r = null;
let file_doc = null;
try {
r = JSON.parse(xhr.responseText);
if (r.message.doctype === 'File') {
file_doc = r.message;
}
} catch(e) {
r = xhr.responseText;
}
file.doc = file_doc;
if (props.on_success) {
props.on_success(file_doc, r);
}
if (i == files.value.length - 1 && files.value.every(file => file.request_succeeded)) {
close_dialog.value = true;
}
} else if (xhr.status === 403) {
file.failed = true;
let response = JSON.parse(xhr.responseText);
file.error_message = `Not permitted. ${response._error_message || ''}`;
} else if (xhr.status === 413) {
file.failed = true;
file.error_message = 'Size exceeds the maximum allowed file size.';
} else {
file.failed = true;
file.error_message = xhr.status === 0 ? 'XMLHttpRequest Error' : `${xhr.status} : ${xhr.statusText}`;
let error = null;
try {
error = JSON.parse(xhr.responseText);
} catch(e) {
// pass
}
frappe.request.cleanup({}, error);
}
}
}
xhr.open('POST', '/api/method/upload_file', true);
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Frappe-CSRF-Token', frappe.csrf_token);
let form_data = new FormData();
if (file.file_obj) {
form_data.append('file', file.file_obj, file.name);
}
form_data.append('is_private', +file.private);
form_data.append('folder', props.folder);
if (file.file_url) {
form_data.append('file_url', file.file_url);
}
if (file.file_name) {
form_data.append('file_name', file.file_name);
}
if (props.doctype && props.docname) {
form_data.append('doctype', props.doctype);
form_data.append('docname', props.docname);
}
if (props.fieldname) {
form_data.append('fieldname', props.fieldname);
}
if (props.method) {
form_data.append('method', props.method);
}
if (file.optimize) {
form_data.append('optimize', true);
}
if (props.attach_doc_image) {
form_data.append('max_width', 200);
form_data.append('max_height', 200);
}
xhr.send(form_data);
});
}
function capture_image() {
const capture = new frappe.ui.Capture({
animate: false,
error: true
});
capture.show();
capture.submit(data_urls => {
data_urls.forEach(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
url_to_file(data_url, filename, 'image/png').then((file) =>
add_files([file])
);
});
});
}
function show_google_drive_picker() {
close_dialog.value = true;
let google_drive = new GoogleDrivePicker({
pickerCallback: data => google_drive_callback(data),
...google_drive_settings.value
});
google_drive.loadPicker();
}
function google_drive_callback(data) {
if (data.action == google.picker.Action.PICKED) {
upload_file({
file_url: data.docs[0].url,
file_name: data.docs[0].name
});
} else if (data.action == google.picker.Action.CANCEL) {
cur_frm.attachments.new_attachment()
}
}
function url_to_file(url, filename, mime_type) {
return fetch(url)
.then(res => res.arrayBuffer())
.then(buffer => new File([buffer], filename, { type: mime_type }));
}
// computed
let upload_complete = computed(() => {
return files.value.length > 0
&& files.value.every(
file => file.total !== 0 && file.progress === file.total);
});
// watcher
watch(files, (newvalue, oldvalue) => {
if (!props.allow_multiple && newvalue.length > 1) {
files.value = [newvalue[newvalue.length - 1]];
}
}, { deep: true });
defineExpose({
files,
add_files,
upload_files,
toggle_all_private,
wrapper_ready
});
</script>
<style>
<style scoped>
.file-upload-area {
min-height: 16rem;
display: flex;

View file

@ -1,7 +1,7 @@
<template>
<div>
<div>
<img ref="image" :src="src" :alt="file.name" />
<img ref="image_ref" :src="src" :alt="file.name" />
</div>
<div class="image-cropper-actions">
<div>
@ -25,7 +25,7 @@
<div>
<button
class="btn btn-sm margin-right"
@click="$emit('toggle_image_cropper')"
@click="emit('toggle_image_cropper')"
v-if="fixed_aspect_ratio == null"
>
{{ __("Back") }}
@ -38,86 +38,93 @@
</div>
</template>
<script>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import Cropper from "cropperjs";
export default {
name: "ImageCropper",
props: ["file", "fixed_aspect_ratio"],
data() {
let aspect_ratio =
this.fixed_aspect_ratio != null ? this.fixed_aspect_ratio : NaN;
return {
src: null,
cropper: null,
image: null,
aspect_ratio
};
},
watch: {
aspect_ratio(value) {
if (this.cropper) {
this.cropper.setAspectRatio(value);
}
}
},
mounted() {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => (this.src = fr.result);
fr.readAsDataURL(this.file.cropper_file);
}
let crop_box = this.file.crop_box_data;
this.image = this.$refs.image;
this.image.onload = () => {
this.cropper = new Cropper(this.image, {
zoomable: false,
scalable: false,
viewMode: 1,
data: crop_box,
aspectRatio: this.aspect_ratio
});
window.cropper = this.cropper;
};
},
computed: {
aspect_ratio_buttons() {
return [
{
label: __("1:1"),
value: 1
},
{
label: __("4:3"),
value: 4 / 3
},
{
label: __("16:9"),
value: 16 / 9
},
{
label: __("Free"),
value: NaN
}
];
}
},
methods: {
crop_image() {
this.file.crop_box_data = this.cropper.getData();
const canvas = this.cropper.getCroppedCanvas();
const file_type = this.file.file_obj.type;
canvas.toBlob(blob => {
var cropped_file_obj = new File([blob], this.file.name, {
type: blob.type
});
this.file.file_obj = cropped_file_obj;
this.$emit("toggle_image_cropper");
}, file_type);
}
// props
let props = defineProps({
file: Object,
fixed_aspect_ratio: Number,
});
// emits
let emit = defineEmits(["toggle_image_cropper"]);
// variables
let aspect_ratio = ref(props.fixed_aspect_ratio != null ? props.fixed_aspect_ratio : NaN);
let src = ref(null);
let cropper = ref(null);
let image = ref(null);
let image_ref = ref(null); // Template ref
// methods
function crop_image() {
props.file.crop_box_data = cropper.value.getData();
const canvas = cropper.value.getCroppedCanvas();
const file_type = props.file.file_obj.type;
canvas.toBlob(blob => {
var cropped_file_obj = new File([blob], props.file.name, {
type: blob.type
});
props.file.file_obj = cropped_file_obj;
emit("toggle_image_cropper");
}, file_type);
}
// mounted
onMounted(() => {
if (window.FileReader) {
let fr = new FileReader();
fr.onload = () => (src.value = fr.result);
fr.readAsDataURL(props.file.cropper_file);
}
};
let crop_box = props.file.crop_box_data;
image.value = image_ref.value;
image.value.onload = () => {
cropper.value = new Cropper(image.value, {
zoomable: false,
scalable: false,
viewMode: 1,
data: crop_box,
aspectRatio: aspect_ratio.value
});
window.cropper = cropper.value;
};
});
// computed
let aspect_ratio_buttons = computed(() => {
return [
{
label: __("1:1"),
value: 1
},
{
label: __("4:3"),
value: 4 / 3
},
{
label: __("16:9"),
value: 16 / 9
},
{
label: __("Free"),
value: NaN
}
];
});
// watcher
watch(aspect_ratio, (value) => {
if (cropper.value) {
cropper.value.setAspectRatio(value);
}
}, { deep: true });
</script>
<style>
<style scoped>
img {
display: block;
max-width: 100%;

View file

@ -39,34 +39,30 @@
</text>
</svg>
</template>
<script>
export default {
name: "ProgressRing",
props: {
primary: String,
secondary: String,
radius: Number,
progress: Number,
stroke: Number
},
data() {
const normalizedRadius = this.radius - this.stroke * 2;
const circumference = normalizedRadius * 2 * Math.PI;
return {
normalizedRadius,
circumference
};
},
computed: {
strokeDashoffset() {
return (
this.circumference - (this.progress / 100) * this.circumference
);
}
}
};
<script setup>
import { computed, ref } from "vue";
// props
let props = defineProps({
primary: String,
secondary: String,
radius: Number,
progress: Number,
stroke: Number
});
// variables
let normalizedRadius = ref(props.radius - props.stroke * 2);
let circumference = ref(normalizedRadius.value * 2 * Math.PI);
// computed
let strokeDashoffset = computed(() => {
return circumference.value - (props.progress / 100) * circumference.value;
});
</script>
<style scoped>
circle {
transition: stroke-dashoffset 0.35s;

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