Merge branch 'develop' of https://github.com/frappe/frappe into qb-fixes
This commit is contained in:
commit
4a573e252c
236 changed files with 5744 additions and 14141 deletions
1
.github/helper/db/mariadb.json
vendored
1
.github/helper/db/mariadb.json
vendored
|
|
@ -13,5 +13,6 @@
|
|||
"root_login": "root",
|
||||
"root_password": "travis",
|
||||
"host_name": "http://test_site:8000",
|
||||
"monitor": 1,
|
||||
"server_script_enabled": true
|
||||
}
|
||||
|
|
|
|||
2
.github/helper/documentation.py
vendored
2
.github/helper/documentation.py
vendored
|
|
@ -1,7 +1,7 @@
|
|||
import sys
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
docs_repos = [
|
||||
"frappe_docs",
|
||||
|
|
|
|||
2
.github/workflows/backport.yml
vendored
2
.github/workflows/backport.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/server-mariadb-tests.yml
vendored
3
.github/workflows/server-mariadb-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/server-postgres-tests.yml
vendored
3
.github/workflows/server-postgres-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/ui-tests.yml
vendored
11
.github/workflows/ui-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
codecov.yml
22
codecov.yml
|
|
@ -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/*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
231
cypress/integration/view_routing.js
Normal file
231
cypress/integration/view_routing.js
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
59
esbuild/frappe-vue-style.js
Normal file
59
esbuild/frappe-vue-style.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
229
frappe/api.py
229
frappe/api.py
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`"""
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]}],
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}'"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
33
frappe/patches/v14_0/update_attachment_comment.py
Normal file
33
frappe/patches/v14_0/update_attachment_comment.py
Normal 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)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import frappe
|
||||
import json
|
||||
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
if frappe.db.exists("Social Login Key", "github"):
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
` > <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,
|
||||
` > <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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue