fix: update code from develop branch
This commit is contained in:
commit
c1e42a6a50
351 changed files with 180431 additions and 44286 deletions
23
.coveragerc
23
.coveragerc
|
|
@ -1,23 +0,0 @@
|
|||
[run]
|
||||
omit =
|
||||
tests/*
|
||||
.github/*
|
||||
commands/*
|
||||
**/test_*.py
|
||||
|
||||
[report]
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
|
||||
exclude_also =
|
||||
def __repr__
|
||||
if self.debug:
|
||||
if settings.DEBUG
|
||||
raise AssertionError
|
||||
raise NotImplementedError
|
||||
if 0:
|
||||
if __name__ == .__main__.:
|
||||
if TYPE_CHECKING:
|
||||
class .*\bProtocol\):
|
||||
@(abc\.)?abstractmethod
|
||||
|
|
@ -61,3 +61,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
|
|||
|
||||
# replace `frappe.flags.in_test` with `frappe.in_test`
|
||||
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8
|
||||
|
||||
# another ruff update
|
||||
6ca4d4d167a1a009d99062747711de7a994aa633
|
||||
|
|
|
|||
2
.github/workflows/_base-migration.yml
vendored
2
.github/workflows/_base-migration.yml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/_base-server-tests.yml
vendored
2
.github/workflows/_base-server-tests.yml
vendored
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
- 2525:25
|
||||
- 3000:80
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
|
|
|
|||
8
.github/workflows/_base-type-check.yml
vendored
8
.github/workflows/_base-type-check.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
steps:
|
||||
- run: npm install toml
|
||||
- name: Get pyproject.toml
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
id: get-pyproject
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
|
@ -33,7 +33,7 @@ jobs:
|
|||
return { mypyFiles, content };
|
||||
|
||||
- name: Check for changes in mypy files
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
id: check-changes
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
|
|
@ -59,11 +59,11 @@ jobs:
|
|||
|
||||
- name: Set up Python
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
|
||||
- name: Cache pip
|
||||
|
|
|
|||
2
.github/workflows/_base-ui-tests.yml
vendored
2
.github/workflows/_base-ui-tests.yml
vendored
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
|
|
|
|||
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@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "frappe/backport"
|
||||
path: ./actions
|
||||
|
|
|
|||
4
.github/workflows/create-release.yml
vendored
4
.github/workflows/create-release.yml
vendored
|
|
@ -12,12 +12,12 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Setup dependencies
|
||||
|
|
|
|||
4
.github/workflows/generate-pot-file.yml
vendored
4
.github/workflows/generate-pot-file.yml
vendored
|
|
@ -18,12 +18,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
|
|
|||
2
.github/workflows/label-base-on-title.yml
vendored
2
.github/workflows/label-base-on-title.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR title and add label if it matches prefixes
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
|
|
|
|||
4
.github/workflows/labeller.yml
vendored
4
.github/workflows/labeller.yml
vendored
|
|
@ -11,8 +11,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/labeler@v5
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
|
|
|||
20
.github/workflows/linters.yml
vendored
20
.github/workflows/linters.yml
vendored
|
|
@ -19,10 +19,10 @@ jobs:
|
|||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 200
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
|
@ -39,10 +39,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: 'Setup Environment'
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Validate Docs
|
||||
env:
|
||||
|
|
@ -57,8 +57,8 @@ jobs:
|
|||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
|
|
@ -76,11 +76,11 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
|
|
@ -103,8 +103,8 @@ jobs:
|
|||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
|
|
|
|||
6
.github/workflows/on_release.yml
vendored
6
.github/workflows/on_release.yml
vendored
|
|
@ -16,15 +16,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: 'frappe'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Set up bench and build assets
|
||||
|
|
|
|||
6
.github/workflows/publish-assets-develop.yml
vendored
6
.github/workflows/publish-assets-develop.yml
vendored
|
|
@ -11,13 +11,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: 'frappe'
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Set up bench and build assets
|
||||
|
|
|
|||
10
.github/workflows/run-indinvidual-tests.yml
vendored
10
.github/workflows/run-indinvidual-tests.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- id: set-matrix
|
||||
run: |
|
||||
# Use grep and find to get the list of test files
|
||||
|
|
@ -69,15 +69,15 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
check-latest: true
|
||||
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
|
|
|
|||
8
.github/workflows/server-tests.yml
vendored
8
.github/workflows/server-tests.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
build: ${{ steps.check-build.outputs.build }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Check if unit tests should be run
|
||||
id: check-build
|
||||
run: |
|
||||
|
|
@ -68,9 +68,9 @@ jobs:
|
|||
if: ${{ github.event_name != 'pull_request' }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4.3.0
|
||||
uses: actions/download-artifact@v5.0.0
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
- frappe/hrms
|
||||
steps:
|
||||
- name: Dispatch Downstream CI (if supported)
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
uses: peter-evans/repository-dispatch@v4
|
||||
with:
|
||||
token: ${{ secrets.CI_PAT }}
|
||||
repository: ${{ matrix.repo }}
|
||||
|
|
|
|||
6
.github/workflows/ui-tests.yml
vendored
6
.github/workflows/ui-tests.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check if build should be run
|
||||
id: check-build
|
||||
|
|
@ -55,9 +55,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4.3.0
|
||||
uses: actions/download-artifact@v5.0.0
|
||||
- name: Upload python coverage data
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
exclude: 'node_modules|.git'
|
||||
default_stages: [pre-commit]
|
||||
default_install_hook_types: [pre-commit, commit-msg]
|
||||
fail_fast: false
|
||||
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ repos:
|
|||
exclude: ^frappe/tests/classes/context_managers\.py$
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.1
|
||||
rev: v0.13.2
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Run ruff import sorter"
|
||||
|
|
@ -69,6 +70,13 @@ repos:
|
|||
frappe/public/js/lib/.*
|
||||
)$
|
||||
|
||||
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
|
||||
rev: v9.22.0
|
||||
hooks:
|
||||
- id: commitlint
|
||||
stages: [commit-msg]
|
||||
additional_dependencies: ['conventional-changelog-conventionalcommits']
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
skip: []
|
||||
|
|
|
|||
|
|
@ -21,10 +21,15 @@
|
|||
## Frappe Framework
|
||||
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for ERPNext.
|
||||
|
||||
### Motivation
|
||||
## Philosophy
|
||||
|
||||
> The best code is the one that is not written
|
||||
|
||||
Started in 2005, Frappe Framework was inspired by the Semantic Web. The "big idea" behind semantic web was of a framework that not only described how information is shown (like headings, body etc), but also what it means, like name, address etc.
|
||||
|
||||
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible. The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
|
||||
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
|
||||
|
||||
The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
|
||||
|
||||
### Key Features
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
**/hooks.py,frappe.gettext.extractors.navbar.extract
|
||||
**/doctype/*/*.json,frappe.gettext.extractors.doctype.extract
|
||||
**/workspace/*/*.json,frappe.gettext.extractors.workspace.extract
|
||||
**/web_form/*/*.json,frappe.gettext.extractors.web_form.extract
|
||||
**/onboarding_step/*/*.json,frappe.gettext.extractors.onboarding_step.extract
|
||||
**/module_onboarding/*/*.json,frappe.gettext.extractors.module_onboarding.extract
|
||||
**/report/*/*.json,frappe.gettext.extractors.report.extract
|
||||
|
|
|
|||
|
|
|
@ -75,14 +75,15 @@ context("Form", () => {
|
|||
|
||||
cy.get('.frappe-control[data-fieldname="email_ids"]').as("table");
|
||||
cy.get("@table").find("button.grid-add-row").click();
|
||||
cy.get("@table").find("button.grid-add-row").click();
|
||||
cy.get("@table").find('[data-idx="1"]').as("row1");
|
||||
cy.get("@table").find('[data-idx="2"]').as("row2");
|
||||
|
||||
cy.get("@row1").click();
|
||||
cy.get("@row1").find("input.input-with-feedback.form-control").as("email_input1");
|
||||
|
||||
cy.get("@email_input1").type(website_input, { waitForAnimations: false });
|
||||
|
||||
cy.get("@table").find("button.grid-add-row").click();
|
||||
cy.get("@table").find('[data-idx="2"]').as("row2");
|
||||
cy.get("@row2").click();
|
||||
cy.get("@row2").find("input.input-with-feedback.form-control").as("email_input2");
|
||||
cy.get("@email_input2").type(valid_email, { waitForAnimations: false });
|
||||
|
|
|
|||
|
|
@ -51,4 +51,32 @@ context("List View", () => {
|
|||
cy.get(".list-row-container:visible").should("contain", "Approved");
|
||||
});
|
||||
});
|
||||
|
||||
it("Adds a button to each list view row", () => {
|
||||
// Get a ToDo with a reference name
|
||||
cy.call("frappe.client.get_value", {
|
||||
doctype: "ToDo",
|
||||
filters: {
|
||||
reference_name: ["is", "set"],
|
||||
},
|
||||
fieldname: "name",
|
||||
}).then((r) => {
|
||||
const todo_name = r.message.name;
|
||||
cy.go_to_list("ToDo");
|
||||
|
||||
// Check if the 'Open' button is present in the ToDo list view
|
||||
cy.get(`.btn-default[data-name="${todo_name}"]`)
|
||||
.scrollIntoView({ inline: "center", block: "nearest" })
|
||||
.should("be.visible")
|
||||
.click();
|
||||
|
||||
cy.window()
|
||||
.its("cur_frm")
|
||||
.then((frm) => {
|
||||
// Routes to the reference document
|
||||
expect(frm.doc.doctype).to.equal("ToDo");
|
||||
expect(frm.doc.name).to.not.equal(todo_name);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ context("List View Settings", () => {
|
|||
cy.clear_filters();
|
||||
cy.wait(300);
|
||||
cy.get(".list-count").should("contain", "20 of");
|
||||
cy.get("[href='#es-line-chat-alt']").should("be.visible");
|
||||
cy.get(".frappe-list svg.es-icon.es-line").should("be.visible");
|
||||
cy.get(".menu-btn-group button").click();
|
||||
cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click();
|
||||
cy.get(".modal-dialog").should("contain", "DocType Settings");
|
||||
cy.get(".modal-dialog").should("contain", "DocType List View Settings");
|
||||
|
||||
cy.findByLabelText("Disable Count").check({ force: true });
|
||||
cy.findByLabelText("Disable Comment Count").check({ force: true });
|
||||
|
|
@ -33,7 +33,7 @@ context("List View Settings", () => {
|
|||
|
||||
cy.get(".menu-btn-group button").click({ force: true });
|
||||
cy.get(".dropdown-menu li").filter(":visible").contains("List Settings").click();
|
||||
cy.get(".modal-dialog").should("contain", "DocType Settings");
|
||||
cy.get(".modal-dialog").should("contain", "DocType List View Settings");
|
||||
cy.findByLabelText("Disable Count").uncheck({ force: true });
|
||||
cy.findByLabelText("Disable Comment Count").uncheck({ force: true });
|
||||
cy.findByLabelText("Disable Sidebar Stats").uncheck({ force: true });
|
||||
|
|
|
|||
117
cypress/integration/utils.js
Normal file
117
cypress/integration/utils.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
context("Utils", () => {
|
||||
before(() => {
|
||||
cy.login();
|
||||
cy.visit("/app");
|
||||
});
|
||||
|
||||
function run_util(name, ...args) {
|
||||
return cy
|
||||
.window()
|
||||
.its("frappe")
|
||||
.then((frappe) => {
|
||||
return frappe.utils[name](...args);
|
||||
});
|
||||
}
|
||||
|
||||
it("should round hidden seconds to minutes", () => {
|
||||
run_util("seconds_to_duration", 89, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", -89, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: -0,
|
||||
hours: -0,
|
||||
minutes: -1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", 91, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 2,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", -91, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: -0,
|
||||
hours: -0,
|
||||
minutes: -2,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", 60 * 60, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 1,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", 15 * 60, { hide_seconds: 1 }).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 15,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse days, hours, minutes and seconds", () => {
|
||||
run_util("seconds_to_duration", 60 * 60 * 24 + 60 * 60 + 60 + 1).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 1,
|
||||
hours: 1,
|
||||
minutes: 1,
|
||||
seconds: 1,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", (60 * 60 * 24 + 60 * 60 + 60 + 1) * -1).then(
|
||||
(duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: -1,
|
||||
hours: -1,
|
||||
minutes: -1,
|
||||
seconds: -1,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
run_util("seconds_to_duration", 60 * 60 * 24 + 60 * 60 + 60 + 1, {
|
||||
hide_days: 1,
|
||||
hide_seconds: 1,
|
||||
}).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: 25,
|
||||
minutes: 1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
|
||||
run_util("seconds_to_duration", (60 * 60 * 24 + 60 * 60 + 60 + 1) * -1, {
|
||||
hide_days: 1,
|
||||
hide_seconds: 1,
|
||||
}).then((duration) => {
|
||||
expect(duration).to.deep.equal({
|
||||
days: 0,
|
||||
hours: -25,
|
||||
minutes: -1,
|
||||
seconds: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1473,18 +1473,28 @@ def logger(
|
|||
|
||||
|
||||
def get_desk_link(doctype, name, show_title_with_name=False, open_in_new_tab=False):
|
||||
from urllib.parse import quote
|
||||
|
||||
meta = get_meta(doctype)
|
||||
title = get_value(doctype, name, meta.get_title_field())
|
||||
|
||||
target_attr = ' target="_blank"' if open_in_new_tab else ""
|
||||
|
||||
# encode for href
|
||||
encoded_name = quote(name)
|
||||
|
||||
if show_title_with_name and name != title:
|
||||
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
|
||||
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
|
||||
else:
|
||||
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
|
||||
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
|
||||
|
||||
return html.format(
|
||||
doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title), target=target_attr
|
||||
doctype=doctype,
|
||||
name=name,
|
||||
encoded_name=encoded_name,
|
||||
doctype_local=_(doctype),
|
||||
title_local=_(title),
|
||||
target=target_attr,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1511,7 +1521,13 @@ def is_setup_complete():
|
|||
if not frappe.db.table_exists("Installed Application"):
|
||||
return setup_complete
|
||||
|
||||
if all(frappe.get_all("Installed Application", {"has_setup_wizard": 1}, pluck="is_setup_complete")):
|
||||
if all(
|
||||
frappe.get_all(
|
||||
"Installed Application",
|
||||
{"app_name": ("in", ["frappe", "erpnext"])},
|
||||
pluck="is_setup_complete",
|
||||
)
|
||||
):
|
||||
setup_complete = True
|
||||
|
||||
return setup_complete
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from contextlib import suppress
|
||||
from enum import Enum
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
|
@ -7,8 +8,8 @@ from werkzeug.routing import Map, Submount
|
|||
from werkzeug.wrappers import Request, Response
|
||||
|
||||
import frappe
|
||||
import frappe.client
|
||||
from frappe import _
|
||||
from frappe.pulse.app_heartbeat_event import capture_app_heartbeat
|
||||
from frappe.utils.response import build_response
|
||||
|
||||
|
||||
|
|
@ -63,7 +64,12 @@ def handle(request: Request):
|
|||
|
||||
if data is not None:
|
||||
frappe.response["data"] = data
|
||||
return build_response("json")
|
||||
data = build_response("json")
|
||||
|
||||
with suppress(Exception):
|
||||
capture_app_heartbeat(arguments)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# Merge all API version routing rules
|
||||
|
|
|
|||
|
|
@ -321,7 +321,10 @@ def set_authenticate_headers(response: Response):
|
|||
def make_form_dict(request: Request):
|
||||
request_data = request.get_data(as_text=True)
|
||||
if request_data and request.is_json:
|
||||
args = orjson.loads(request_data)
|
||||
try:
|
||||
args = orjson.loads(request_data)
|
||||
except orjson.JSONDecodeError:
|
||||
frappe.throw(_("Invalid request body"), frappe.DataError)
|
||||
else:
|
||||
args = {}
|
||||
args.update(request.args or {})
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ class HTTPRequest:
|
|||
elif frappe.get_request_header("REMOTE_ADDR"):
|
||||
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")
|
||||
|
||||
elif frappe.request and getattr(frappe.request, "remote_addr", None):
|
||||
frappe.local.request_ip = frappe.request.remote_addr
|
||||
|
||||
else:
|
||||
frappe.local.request_ip = "127.0.0.1"
|
||||
|
||||
|
|
@ -666,7 +669,7 @@ def validate_oauth(authorization_header):
|
|||
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
|
||||
get_url_delimiter()
|
||||
)
|
||||
valid, oauthlib_request = get_oauth_server().verify_request(
|
||||
valid, _oauthlib_request = get_oauth_server().verify_request(
|
||||
uri, http_method, body, headers, required_scopes
|
||||
)
|
||||
if valid:
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
"label": "Assignment Rules"
|
||||
},
|
||||
{
|
||||
"description": "Simple Python Expression, Example: status == 'Open' and type == 'Bug'",
|
||||
"description": "Simple Python Expression, Example: <code class=\"language-python\">status == 'Open' and issue_type == 'Bug'</code>",
|
||||
"fieldname": "assign_condition",
|
||||
"fieldtype": "Code",
|
||||
"in_list_view": 1,
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")",
|
||||
"description": "Simple Python Expression, Example: <code class=\"language-python\">status in (\"Closed\", \"Cancelled\")</code>",
|
||||
"fieldname": "unassign_condition",
|
||||
"fieldtype": "Code",
|
||||
"label": "Unassign Condition",
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"description": "Simple Python Expression, Example: Status in (\"Invalid\")",
|
||||
"description": "Simple Python Expression, Example: <code class=\"language-python\">status == \"Invalid\"</code>",
|
||||
"fieldname": "close_condition",
|
||||
"fieldtype": "Code",
|
||||
"label": "Close Condition",
|
||||
|
|
@ -152,9 +152,10 @@
|
|||
"mandatory_depends_on": "eval: doc.rule == 'Based on Field'"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:01:27.590910",
|
||||
"modified": "2025-08-25 17:09:11.644603",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Assignment Rule",
|
||||
|
|
@ -174,8 +175,9 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"repeat_on_last_day",
|
||||
"column_break_12",
|
||||
"next_schedule_date",
|
||||
"section_break_looa",
|
||||
"generate_separate_documents_for_each_assignee",
|
||||
"assignee",
|
||||
"section_break_16",
|
||||
"repeat_on_days",
|
||||
"notification",
|
||||
|
|
@ -86,7 +89,7 @@
|
|||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Frequency",
|
||||
"options": "\nDaily\nWeekly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
|
||||
"options": "\nDaily\nWeekly\nFortnightly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
@ -198,10 +201,26 @@
|
|||
"depends_on": "eval:doc.frequency==='Weekly';",
|
||||
"fieldname": "section_break_16",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "generate_separate_documents_for_each_assignee",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate Separate Documents For Each Assignee"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_looa",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "assignee",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Assignee",
|
||||
"options": "Auto Repeat User"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-01-20 14:15:55.287788",
|
||||
"modified": "2025-06-09 18:20:23.775881",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Auto Repeat",
|
||||
|
|
@ -245,10 +264,11 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "reference_document",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "reference_document",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from frappe.contacts.doctype.contact.contact import (
|
|||
get_contacts_linking_to,
|
||||
)
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form import assign_to
|
||||
from frappe.desk.form.assign_to import add as assign_to
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
|
|
@ -49,11 +49,16 @@ class AutoRepeat(Document):
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.automation.doctype.auto_repeat_day.auto_repeat_day import AutoRepeatDay
|
||||
from frappe.automation.doctype.auto_repeat_user.auto_repeat_user import AutoRepeatUser
|
||||
from frappe.types import DF
|
||||
|
||||
assignee: DF.TableMultiSelect[AutoRepeatUser]
|
||||
disabled: DF.Check
|
||||
end_date: DF.Date | None
|
||||
frequency: DF.Literal["", "Daily", "Weekly", "Monthly", "Quarterly", "Half-yearly", "Yearly"]
|
||||
frequency: DF.Literal[
|
||||
"", "Daily", "Weekly", "Fortnightly", "Monthly", "Quarterly", "Half-yearly", "Yearly"
|
||||
]
|
||||
generate_separate_documents_for_each_assignee: DF.Check
|
||||
message: DF.Text | None
|
||||
next_schedule_date: DF.Date | None
|
||||
notify_by_email: DF.Check
|
||||
|
|
@ -87,9 +92,8 @@ class AutoRepeat(Document):
|
|||
|
||||
def before_insert(self):
|
||||
if not frappe.in_test:
|
||||
start_date = getdate(self.start_date)
|
||||
today_date = getdate(today())
|
||||
if start_date <= today_date:
|
||||
today_date = getdate()
|
||||
if getdate(self.start_date) < today_date:
|
||||
self.start_date = today_date
|
||||
|
||||
def on_update(self):
|
||||
|
|
@ -134,14 +138,18 @@ class AutoRepeat(Document):
|
|||
return
|
||||
|
||||
if self.end_date:
|
||||
end_date = getdate(self.end_date)
|
||||
|
||||
self.validate_from_to_dates("start_date", "end_date")
|
||||
|
||||
if self.end_date == self.start_date:
|
||||
frappe.throw(
|
||||
_("{0} should not be same as {1}").format(
|
||||
frappe.bold(_("End Date")), frappe.bold(_("Start Date"))
|
||||
if end_date == getdate():
|
||||
frappe.throw(_("End Date cannot be today."))
|
||||
if end_date == getdate(self.start_date):
|
||||
frappe.throw(
|
||||
_("{0} should not be same as {1}").format(
|
||||
frappe.bold(_("End Date")), frappe.bold(_("Start Date"))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_email_id(self):
|
||||
if self.notify_by_email:
|
||||
|
|
@ -219,9 +227,16 @@ class AutoRepeat(Document):
|
|||
|
||||
def create_documents(self):
|
||||
try:
|
||||
new_doc = self.make_new_document()
|
||||
if self.generate_separate_documents_for_each_assignee and self.assignee:
|
||||
new_docs = self.make_new_documents()
|
||||
else:
|
||||
new_docs = self.make_new_document([assignee.user for assignee in self.assignee])
|
||||
if self.notify_by_email and self.recipients:
|
||||
self.send_notification(new_doc)
|
||||
if isinstance(new_docs, list):
|
||||
for new_doc in new_docs:
|
||||
self.send_notification(new_doc)
|
||||
else:
|
||||
self.send_notification(new_docs)
|
||||
except Exception:
|
||||
error_log = self.log_error(
|
||||
_("Auto repeat failed. Please enable auto repeat after fixing the issues.")
|
||||
|
|
@ -232,7 +247,14 @@ class AutoRepeat(Document):
|
|||
if self.reference_document and not frappe.in_test:
|
||||
self.notify_error_to_user(error_log)
|
||||
|
||||
def make_new_document(self):
|
||||
def make_new_documents(self):
|
||||
docs = []
|
||||
for assignee in self.assignee:
|
||||
new_doc = self.make_new_document(assignee=[assignee.user])
|
||||
docs.append(new_doc)
|
||||
return docs
|
||||
|
||||
def make_new_document(self, assignee=None):
|
||||
reference_doc = frappe.get_doc(self.reference_doctype, self.reference_document)
|
||||
new_doc = frappe.copy_doc(reference_doc, ignore_no_copy=False)
|
||||
self.update_doc(new_doc, reference_doc)
|
||||
|
|
@ -242,7 +264,14 @@ class AutoRepeat(Document):
|
|||
"label": _("via Auto Repeat"),
|
||||
}
|
||||
new_doc.insert(ignore_permissions=True)
|
||||
|
||||
if assignee:
|
||||
args = {
|
||||
"assign_to": assignee,
|
||||
"doctype": self.reference_doctype,
|
||||
"name": new_doc.name,
|
||||
"description": new_doc.get_title(),
|
||||
}
|
||||
assign_to(args=args)
|
||||
if self.submit_on_creation:
|
||||
new_doc.submit()
|
||||
|
||||
|
|
@ -348,6 +377,8 @@ class AutoRepeat(Document):
|
|||
def get_days(self, schedule_date):
|
||||
if self.frequency == "Weekly":
|
||||
days = self.get_offset_for_weekly_frequency(schedule_date)
|
||||
elif self.frequency == "Fortnightly":
|
||||
days = 14
|
||||
else:
|
||||
# daily frequency
|
||||
days = 1
|
||||
|
|
|
|||
|
|
@ -85,6 +85,32 @@ class TestAutoRepeat(IntegrationTestCase):
|
|||
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
|
||||
def test_fortnightly_auto_repeat(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test fortnightly todo", assigned_by="Administrator"
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(
|
||||
reference_doctype="ToDo",
|
||||
frequency="Fortnightly",
|
||||
reference_document=todo.name,
|
||||
start_date=add_days(today(), -14),
|
||||
)
|
||||
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
new_todo = frappe.get_doc("ToDo", new_todo)
|
||||
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
|
||||
def test_weekly_auto_repeat_with_weekdays(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test auto repeat with weekdays", assigned_by="Administrator"
|
||||
|
|
@ -221,6 +247,68 @@ class TestAutoRepeat(IntegrationTestCase):
|
|||
)
|
||||
self.assertEqual(docnames[0].docstatus, 1)
|
||||
|
||||
def test_auto_repeat_assignee(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test assignee todo", assigned_by="Administrator"
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(reference_document=todo.name)
|
||||
doc.update(
|
||||
{
|
||||
"assignee": [
|
||||
{"user": "Administrator"},
|
||||
{"user": "Guest"},
|
||||
]
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
new_todo = frappe.get_doc("ToDo", new_todo)
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
self.assertListEqual(
|
||||
sorted(list(new_todo.get_assigned_users())),
|
||||
sorted(["Administrator", "Guest"]),
|
||||
)
|
||||
|
||||
def test_auto_repeat_assignee_with_separate_documents(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo",
|
||||
description="test assignee todo with multiple doc",
|
||||
assigned_by="Administrator",
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(reference_document=todo.name)
|
||||
doc.update(
|
||||
{
|
||||
"assignee": [
|
||||
{"user": "Administrator"},
|
||||
{"user": "Guest"},
|
||||
],
|
||||
"generate_separate_documents_for_each_assignee": 1,
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo_count = frappe.db.count("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
self.assertEqual(new_todo_count, 2)
|
||||
|
||||
|
||||
def make_auto_repeat(**args):
|
||||
args = frappe._dict(args)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-06-09 18:19:22.034128",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"user"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-09 18:19:41.543336",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Auto Repeat User",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class AutoRepeatUser(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -11,7 +11,6 @@ import frappe.defaults
|
|||
import frappe.desk.desk_page
|
||||
from frappe.core.doctype.installed_applications.installed_applications import (
|
||||
get_setup_wizard_completed_apps,
|
||||
get_setup_wizard_not_required_apps,
|
||||
)
|
||||
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings
|
||||
from frappe.desk.doctype.changelog_feed.changelog_feed import get_changelog_feed_items
|
||||
|
|
@ -121,34 +120,9 @@ def get_bootinfo():
|
|||
bootinfo.sentry_dsn = sentry_dsn
|
||||
|
||||
bootinfo.setup_wizard_completed_apps = get_setup_wizard_completed_apps() or []
|
||||
bootinfo.setup_wizard_not_required_apps = get_setup_wizard_not_required_apps() or []
|
||||
remove_apps_with_incomplete_dependencies(bootinfo)
|
||||
|
||||
return bootinfo
|
||||
|
||||
|
||||
def remove_apps_with_incomplete_dependencies(bootinfo):
|
||||
remove_apps = set()
|
||||
|
||||
for app in bootinfo.setup_wizard_not_required_apps:
|
||||
if app in bootinfo.setup_wizard_completed_apps:
|
||||
continue
|
||||
|
||||
for required_apps in frappe.get_hooks("required_apps"):
|
||||
required_apps = required_apps.split("/")
|
||||
|
||||
for required_app in required_apps:
|
||||
if app not in bootinfo.setup_wizard_not_required_apps:
|
||||
continue
|
||||
|
||||
if required_app not in bootinfo.setup_wizard_completed_apps:
|
||||
remove_apps.add(app)
|
||||
|
||||
for app in remove_apps:
|
||||
if app in bootinfo.setup_wizard_not_required_apps:
|
||||
bootinfo.setup_wizard_not_required_apps.remove(app)
|
||||
|
||||
|
||||
def get_letter_heads():
|
||||
letter_heads = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -147,17 +147,26 @@ def _clear_doctype_cache_from_redis(doctype: str | None = 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:
|
||||
try:
|
||||
for dt in frappe.get_all(
|
||||
"Custom Field", "dt", dict(fieldtype=["in", frappe.model.table_fields], options=doctype)
|
||||
"DocField",
|
||||
"parent",
|
||||
dict(fieldtype=["in", frappe.model.table_fields], options=doctype),
|
||||
ignore_ddl=True,
|
||||
):
|
||||
clear_single(dt.dt)
|
||||
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),
|
||||
ignore_ddl=True,
|
||||
):
|
||||
clear_single(dt.dt)
|
||||
except frappe.DoesNotExistError:
|
||||
pass # core doctypes getting migrated.
|
||||
|
||||
# clear all notifications
|
||||
delete_notification_count_for(doctype)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def create_rq_users(set_admin_password=False, use_rq_auth=False):
|
|||
)
|
||||
click.secho(f"`export {env_key}={user_credentials['default'][1]}`")
|
||||
click.secho(
|
||||
"NOTE: Please save the admin password as you " "can not access redis server without the password",
|
||||
"NOTE: Please save the admin password as you can not access redis server without the password",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ def _restore(
|
|||
click.secho("Failed to detect type of backup file", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
if "cipher" in out.decode().split(":")[-1].strip():
|
||||
if "AES" in out.decode().split(":")[-1].strip():
|
||||
if encryption_key:
|
||||
click.secho("Encrypted backup file detected. Decrypting using provided key.", fg="yellow")
|
||||
|
||||
|
|
@ -336,7 +336,7 @@ def restore_backup(
|
|||
# Check if the backup is of an older version of frappe and the user hasn't specified force
|
||||
if is_downgrade(sql_file_path, verbose=True) and not force:
|
||||
warn_message = (
|
||||
"This is not recommended and may lead to unexpected behaviour. " "Do you want to continue anyway?"
|
||||
"This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
|
||||
)
|
||||
click.confirm(warn_message, abort=True)
|
||||
|
||||
|
|
@ -691,8 +691,9 @@ def disable_user(context: CliCtxObj, email):
|
|||
@click.command("migrate")
|
||||
@click.option("--skip-failing", is_flag=True, help="Skip patches that fail to run")
|
||||
@click.option("--skip-search-index", is_flag=True, help="Skip search indexing for web documents")
|
||||
@click.option("--skip-fixtures", is_flag=True, help="Skip loading fixtures")
|
||||
@pass_context
|
||||
def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False):
|
||||
def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False, skip_fixtures=False):
|
||||
"Run patches, sync schema and rebuild files/translations"
|
||||
|
||||
from frappe.migrate import SiteMigration
|
||||
|
|
@ -701,8 +702,7 @@ def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False):
|
|||
click.secho(f"Migrating {site}", fg="green")
|
||||
try:
|
||||
SiteMigration(
|
||||
skip_failing=skip_failing,
|
||||
skip_search_index=skip_search_index,
|
||||
skip_failing=skip_failing, skip_search_index=skip_search_index, skip_fixtures=skip_fixtures
|
||||
).run(site=site)
|
||||
finally:
|
||||
print()
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ class TestCommands(BaseTestCommands):
|
|||
self.execute("bench --site {test_site} backup --exclude 'ToDo'", site_data)
|
||||
site_data.update({"kw": "\"{'partial':True}\""})
|
||||
self.execute(
|
||||
"bench --site {test_site} execute" " frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
|
||||
"bench --site {test_site} execute frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
|
||||
site_data,
|
||||
)
|
||||
site_data.update({"database": json.loads(self.stdout)["database"]})
|
||||
|
|
|
|||
|
|
@ -435,8 +435,7 @@ def import_doc(context: CliCtxObj, path, force=False):
|
|||
type=click.Path(exists=True, dir_okay=False, resolve_path=True),
|
||||
required=True,
|
||||
help=(
|
||||
"Path to import file (.csv, .xlsx)."
|
||||
"Consider that relative paths will resolve from 'sites' directory"
|
||||
"Path to import file (.csv, .xlsx). Consider that relative paths will resolve from 'sites' directory"
|
||||
),
|
||||
)
|
||||
@click.option("--doctype", type=str, required=True)
|
||||
|
|
@ -914,7 +913,7 @@ def set_config(context: CliCtxObj, key, value, global_=False, parse=False):
|
|||
"output",
|
||||
type=click.Choice(["plain", "table", "json", "legacy"]),
|
||||
help="Output format",
|
||||
default="legacy",
|
||||
default="plain",
|
||||
)
|
||||
def get_version(output):
|
||||
"""Show the versions of all the installed apps."""
|
||||
|
|
|
|||
|
|
@ -109,7 +109,8 @@
|
|||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone"
|
||||
"label": "Phone",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "fax",
|
||||
|
|
|
|||
|
|
@ -31,23 +31,22 @@ def execute(filters=None):
|
|||
|
||||
|
||||
def get_columns(filters):
|
||||
reference_doctype = filters.get("reference_doctype")
|
||||
return [
|
||||
"{reference_doctype}:Link/{reference_doctype}".format(
|
||||
reference_doctype=filters.get("reference_doctype")
|
||||
),
|
||||
"Address Line 1",
|
||||
"Address Line 2",
|
||||
"City",
|
||||
"State",
|
||||
"Postal Code",
|
||||
"Country",
|
||||
"Is Primary Address:Check",
|
||||
"First Name",
|
||||
"Last Name",
|
||||
"Address",
|
||||
"Phone",
|
||||
"Email Id",
|
||||
"Is Primary Contact:Check",
|
||||
f"{_(reference_doctype)}:Link/{reference_doctype}",
|
||||
_("Address Line 1"),
|
||||
_("Address Line 2"),
|
||||
_("City"),
|
||||
_("State"),
|
||||
_("Postal Code"),
|
||||
_("Country"),
|
||||
f"{_('Is Primary Address')}:Check",
|
||||
_("First Name"),
|
||||
_("Last Name"),
|
||||
_("Address"),
|
||||
_("Phone"),
|
||||
_("Email Id"),
|
||||
f"{_('Is Primary Contact')}:Check",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,19 @@ def invite_by_email(
|
|||
frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite"))
|
||||
|
||||
# get relevant data from the database
|
||||
disabled_user_emails = frappe.db.get_all(
|
||||
"User",
|
||||
filters={"email": ["in", email_list], "enabled": 0},
|
||||
pluck="email",
|
||||
)
|
||||
accepted_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"email": ["in", email_list], "status": "Accepted", "app_name": app_name},
|
||||
filters={
|
||||
"email": ["in", email_list],
|
||||
"status": "Accepted",
|
||||
"app_name": app_name,
|
||||
"user": ["is", "set"],
|
||||
},
|
||||
pluck="email",
|
||||
)
|
||||
pending_invite_emails = frappe.db.get_all(
|
||||
|
|
@ -29,7 +39,9 @@ def invite_by_email(
|
|||
)
|
||||
|
||||
# create invitation documents
|
||||
to_invite = list(set(email_list) - set(accepted_invite_emails) - set(pending_invite_emails))
|
||||
to_invite = list(
|
||||
set(email_list) - set(disabled_user_emails) - set(accepted_invite_emails) - set(pending_invite_emails)
|
||||
)
|
||||
for email in to_invite:
|
||||
frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
|
|
@ -40,6 +52,7 @@ def invite_by_email(
|
|||
).insert(ignore_permissions=True)
|
||||
|
||||
return {
|
||||
"disabled_user_emails": disabled_user_emails,
|
||||
"accepted_invite_emails": accepted_invite_emails,
|
||||
"pending_invite_emails": pending_invite_emails,
|
||||
"invited_emails": to_invite,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "User ",
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class TestAuditTrail(IntegrationTestCase):
|
|||
re_amended_doc = amend_document(amended_doc, changed_fields, {}, 1)
|
||||
|
||||
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", re_amended_doc.name)
|
||||
documents, results = comparator.compare_document()
|
||||
_documents, results = comparator.compare_document()
|
||||
|
||||
test_field_values = results["changed"]["Field"]
|
||||
self.check_expected_values(test_field_values, ["first value", "second value", "third value"])
|
||||
|
|
@ -41,7 +41,7 @@ class TestAuditTrail(IntegrationTestCase):
|
|||
amended_doc = amend_document(doc, {}, rows_updated, 1)
|
||||
|
||||
comparator = create_comparator_doc("Test Custom Doctype for Doc Comparator", amended_doc.name)
|
||||
documents, results = comparator.compare_document()
|
||||
_documents, results = comparator.compare_document()
|
||||
|
||||
results = frappe._dict(results)
|
||||
self.check_rows_updated(results.row_changed)
|
||||
|
|
|
|||
|
|
@ -565,11 +565,11 @@ def parse_email(email_strings):
|
|||
|
||||
for email in email_string.split(","):
|
||||
local_part = email.split("@", 1)[0].strip('"')
|
||||
user, detail = None, None
|
||||
_user, detail = None, None
|
||||
if "+" in local_part:
|
||||
user, detail = local_part.split("+", 1)
|
||||
_user, detail = local_part.split("+", 1)
|
||||
elif "--" in local_part:
|
||||
detail, user = local_part.rsplit("--", 1)
|
||||
detail, _user = local_part.rsplit("--", 1)
|
||||
|
||||
if not detail:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"submit",
|
||||
"cancel",
|
||||
"amend",
|
||||
"mask",
|
||||
"additional_permissions",
|
||||
"report",
|
||||
"export",
|
||||
|
|
@ -153,6 +154,16 @@
|
|||
"print_width": "32px",
|
||||
"width": "32px"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "mask",
|
||||
"fieldtype": "Check",
|
||||
"label": "Mask",
|
||||
"oldfieldname": "mask",
|
||||
"oldfieldtype": "Check",
|
||||
"print_width": "32px",
|
||||
"width": "32px"
|
||||
},
|
||||
{
|
||||
"fieldname": "additional_permissions",
|
||||
"fieldtype": "Section Break",
|
||||
|
|
@ -214,11 +225,13 @@
|
|||
"label": "Select"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:02:14.726078",
|
||||
"modified": "2025-05-22 16:59:35.484376",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Custom DocPerm",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
|
@ -235,8 +248,9 @@
|
|||
}
|
||||
],
|
||||
"read_only": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "parent"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class CustomDocPerm(Document):
|
|||
email: DF.Check
|
||||
export: DF.Check
|
||||
if_owner: DF.Check
|
||||
mask: DF.Check
|
||||
parent: DF.Data | None
|
||||
permlevel: DF.Int
|
||||
print: DF.Check
|
||||
|
|
|
|||
|
|
@ -77,10 +77,11 @@ class DataImport(Document):
|
|||
return
|
||||
validate_google_sheets_url(self.google_sheets_url)
|
||||
|
||||
def set_payload_count(self):
|
||||
def set_payload_count(self, importer: Importer | None = None):
|
||||
if self.import_file:
|
||||
i = self.get_importer()
|
||||
payloads = i.import_file.get_payloads_for_import()
|
||||
if importer is None:
|
||||
importer = self.get_importer()
|
||||
payloads = importer.import_file.get_payloads_for_import()
|
||||
self.payload_count = len(payloads)
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
@ -262,12 +263,15 @@ def import_file(doctype, file_path, import_type, submit_after_import=False, cons
|
|||
"""
|
||||
|
||||
data_import = frappe.new_doc("Data Import")
|
||||
data_import.reference_doctype = doctype
|
||||
data_import.import_file = file_path
|
||||
data_import.submit_after_import = submit_after_import
|
||||
data_import.import_type = (
|
||||
"Insert New Records" if import_type.lower() == "insert" else "Update Existing Records"
|
||||
)
|
||||
|
||||
i = Importer(doctype=doctype, file_path=file_path, data_import=data_import, console=console)
|
||||
data_import.set_payload_count(i)
|
||||
i.import_data()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
"label",
|
||||
"fieldtype",
|
||||
"fieldname",
|
||||
"precision",
|
||||
"length",
|
||||
"precision",
|
||||
"non_negative",
|
||||
"hide_days",
|
||||
"hide_seconds",
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
"is_virtual",
|
||||
"search_index",
|
||||
"not_nullable",
|
||||
"mask",
|
||||
"column_break_18",
|
||||
"options",
|
||||
"sort_options",
|
||||
|
|
@ -135,7 +136,7 @@
|
|||
},
|
||||
{
|
||||
"depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
|
||||
"description": "Set non-standard precision for a Float or Currency field",
|
||||
"description": "Set non-standard precision for a Float, Currency or Percent field",
|
||||
"fieldname": "precision",
|
||||
"fieldtype": "Select",
|
||||
"label": "Precision",
|
||||
|
|
@ -143,7 +144,7 @@
|
|||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)",
|
||||
"depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int', 'Float', 'Currency', 'Percent'], doc.fieldtype)",
|
||||
"fieldname": "length",
|
||||
"fieldtype": "Int",
|
||||
"label": "Length"
|
||||
|
|
@ -499,7 +500,7 @@
|
|||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)",
|
||||
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
|
||||
"fieldname": "non_negative",
|
||||
"fieldtype": "Check",
|
||||
"label": "Non Negative"
|
||||
|
|
@ -607,20 +608,29 @@
|
|||
"fieldname": "sticky",
|
||||
"fieldtype": "Check",
|
||||
"label": "Sticky"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:[\"Select\", \"Read Only\", \"Phone\", \"Percent\", \"Password\", \"Link\", \"Int\", \"Float\", \"Dynamic Link\", \"Duration\", \"Datetime\", \"Currency\", \"Data\", \"Date\"].includes(doc.fieldtype)",
|
||||
"fieldname": "mask",
|
||||
"fieldtype": "Check",
|
||||
"label": "Mask"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-30 14:58:19.746600",
|
||||
"modified": "2025-09-17 13:20:57.852396",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocField",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ class DocField(Document):
|
|||
link_filters: DF.JSON | None
|
||||
make_attachment_public: DF.Check
|
||||
mandatory_depends_on: DF.Code | None
|
||||
mask: DF.Check
|
||||
max_height: DF.Data | None
|
||||
no_copy: DF.Check
|
||||
non_negative: DF.Check
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"submit",
|
||||
"cancel",
|
||||
"amend",
|
||||
"mask",
|
||||
"additional_permissions",
|
||||
"report",
|
||||
"export",
|
||||
|
|
@ -205,18 +206,27 @@
|
|||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Select"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "mask",
|
||||
"fieldtype": "Check",
|
||||
"label": "Mask"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:02:18.443496",
|
||||
"modified": "2025-05-20 16:50:32.679113",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocPerm",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class DocPerm(Document):
|
|||
email: DF.Check
|
||||
export: DF.Check
|
||||
if_owner: DF.Check
|
||||
mask: DF.Check
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
|
|
|||
|
|
@ -702,7 +702,7 @@
|
|||
"label": "Protect Attached Files"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"default": "20",
|
||||
"depends_on": "istable",
|
||||
"fieldname": "rows_threshold_for_grid_search",
|
||||
"fieldtype": "Int",
|
||||
|
|
@ -792,7 +792,7 @@
|
|||
"link_fieldname": "document_type"
|
||||
}
|
||||
],
|
||||
"modified": "2025-07-19 12:23:16.296416",
|
||||
"modified": "2025-09-23 06:48:13.555017",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,18 @@ def validate_fields(meta: Meta):
|
|||
if docfield.options and (int(docfield.options) > 10 or int(docfield.options) < 3):
|
||||
frappe.throw(_("Options for Rating field can range from 3 to 10"))
|
||||
|
||||
def check_decimal_config(docfield):
|
||||
if docfield.fieldtype not in ("Currency", "Float", "Percent"):
|
||||
return
|
||||
|
||||
if docfield.length and docfield.precision:
|
||||
if cint(docfield.precision) > cint(docfield.length):
|
||||
frappe.throw(
|
||||
_("Precision ({0}) for {1} cannot be greater than its length ({2}).").format(
|
||||
docfield.precision, frappe.bold(docfield.label), docfield.length
|
||||
)
|
||||
)
|
||||
|
||||
fields = meta.get("fields")
|
||||
fieldname_list = [d.fieldname for d in fields]
|
||||
|
||||
|
|
@ -1682,6 +1694,7 @@ def validate_fields(meta: Meta):
|
|||
scrub_options_in_select(d)
|
||||
validate_fetch_from(d)
|
||||
validate_data_field_type(d)
|
||||
check_decimal_config(d)
|
||||
|
||||
if not frappe.flags.in_migrate or in_ci:
|
||||
check_unique_fieldname(meta.get("name"), d.fieldname)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ frappe.listview_settings["DocType"] = {
|
|||
fieldtype: "Data",
|
||||
reqd: 1,
|
||||
default: doctype_name,
|
||||
length: 61,
|
||||
},
|
||||
{ fieldtype: "Column Break" },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -827,6 +827,45 @@ class TestDocType(IntegrationTestCase):
|
|||
self.assertEqual(get_format(compressed_dt), "COMPRESSED")
|
||||
self.assertEqual(get_format(dynamic_dt), "DYNAMIC")
|
||||
|
||||
def test_decimal_field_configuration(self):
|
||||
doctype = new_doctype(
|
||||
"Test Decimal Config",
|
||||
fields=[
|
||||
{
|
||||
"fieldname": "decimal_field",
|
||||
"fieldtype": "Currency",
|
||||
"length": 30,
|
||||
"precision": 3,
|
||||
}
|
||||
],
|
||||
).insert(ignore_if_duplicate=True)
|
||||
decimal_field_type = frappe.db.get_column_type(doctype.name, "decimal_field")
|
||||
self.assertIn("(30,3)", decimal_field_type.lower())
|
||||
|
||||
def test_decimal_field_precision_exceeds_length(self):
|
||||
doctype = new_doctype(
|
||||
"Test Decimal Config 2",
|
||||
fields=[
|
||||
{
|
||||
"fieldname": "decimal_field",
|
||||
"fieldtype": "Currency",
|
||||
"length": 10,
|
||||
"precision": 11,
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doctype.insert)
|
||||
|
||||
def test_delete_doc_clears_cache(self):
|
||||
dt = new_doctype(
|
||||
fields=[{"fieldname": "test_fdname", "fieldtype": "Data", "label": "Test Field"}],
|
||||
).insert()
|
||||
frappe.get_meta(dt.name)
|
||||
frappe.delete_doc("DocType", dt.name, force=1, delete_permanently=False)
|
||||
frappe.db.commit()
|
||||
with self.assertRaises(frappe.DoesNotExistError):
|
||||
frappe.get_meta(dt.name)
|
||||
|
||||
|
||||
def new_doctype(
|
||||
name: str | None = None,
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"description": "Change the starting / current sequence number of an existing series. <br>\n\nWarning: Incorrectly updating counters can prevent documents from getting created. ",
|
||||
"description": "Change the starting / current sequence number of an existing series. <br>\n\nWarning: Incorrectly updating counters can prevent documents from getting created.",
|
||||
"fieldname": "update_series",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Update Series Counter"
|
||||
|
|
|
|||
|
|
@ -17,9 +17,17 @@ from frappe.database.schema import SPECIAL_CHAR_PATTERN
|
|||
from frappe.exceptions import DoesNotExistError
|
||||
from frappe.model.document import Document
|
||||
from frappe.permissions import SYSTEM_USER_ROLE, get_doctypes_with_read
|
||||
from frappe.utils import call_hook_method, cint, get_files_path, get_hook_method, get_url
|
||||
from frappe.utils import (
|
||||
call_hook_method,
|
||||
cint,
|
||||
get_files_path,
|
||||
get_hook_method,
|
||||
get_url,
|
||||
)
|
||||
from frappe.utils.file_manager import is_safe_path
|
||||
from frappe.utils.html_utils import escape_html
|
||||
from frappe.utils.image import optimize_image, strip_exif_data
|
||||
from frappe.utils.pdf import pdf_contains_js
|
||||
|
||||
from .exceptions import (
|
||||
AttachmentLimitReached,
|
||||
|
|
@ -30,8 +38,10 @@ from .exceptions import (
|
|||
from .utils import *
|
||||
|
||||
exclude_from_linked_with = True
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
URL_PREFIXES = ("http://", "https://")
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True # nosemgrep
|
||||
|
||||
URL_PREFIXES = ("http://", "https://", "/api/method/")
|
||||
FILE_ENCODING_OPTIONS = ("utf-8-sig", "utf-8", "windows-1250", "windows-1252")
|
||||
|
||||
|
||||
|
|
@ -131,7 +141,6 @@ class File(Document):
|
|||
self.validate_file_path()
|
||||
self.validate_file_url()
|
||||
self.validate_file_on_disk()
|
||||
|
||||
self.file_size = frappe.form_dict.file_size or self.file_size
|
||||
|
||||
def validate_attachment_references(self):
|
||||
|
|
@ -139,7 +148,10 @@ class File(Document):
|
|||
return
|
||||
|
||||
if not self.attached_to_name or not isinstance(self.attached_to_name, str | int):
|
||||
frappe.throw(_("Attached To Name must be a string or an integer"), frappe.ValidationError)
|
||||
frappe.throw(
|
||||
_("Attached To Name must be a string or an integer"),
|
||||
frappe.ValidationError,
|
||||
)
|
||||
|
||||
if self.attached_to_field and SPECIAL_CHAR_PATTERN.search(self.attached_to_field):
|
||||
frappe.throw(_("The fieldname you've specified in Attached To Field is invalid"))
|
||||
|
|
@ -213,8 +225,8 @@ class File(Document):
|
|||
if self.is_remote_file or not self.file_url:
|
||||
return
|
||||
|
||||
if not self.file_url.startswith(("/files/", "/private/files/")):
|
||||
# Probably an invalid URL since it doesn't start with http either
|
||||
if not self.file_url.startswith(("/files/", "/private/files/", "/api/method/")):
|
||||
# Probably an invalid URL since it doesn't start with http and isn't an internal URL either
|
||||
frappe.throw(
|
||||
_("URL must start with http:// or https://"),
|
||||
title=_("Invalid URL"),
|
||||
|
|
@ -318,7 +330,9 @@ class File(Document):
|
|||
if current_attachment_count >= attachment_limit:
|
||||
frappe.throw(
|
||||
_("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format(
|
||||
frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name
|
||||
frappe.bold(attachment_limit),
|
||||
self.attached_to_doctype,
|
||||
self.attached_to_name,
|
||||
),
|
||||
exc=AttachmentLimitReached,
|
||||
title=_("Attachment Limit Reached"),
|
||||
|
|
@ -372,7 +386,14 @@ class File(Document):
|
|||
return
|
||||
|
||||
if self.file_type not in allowed_extensions.splitlines():
|
||||
frappe.throw(_("File type of {0} is not allowed").format(self.file_type), exc=FileTypeNotAllowed)
|
||||
frappe.throw(
|
||||
_("File type of {0} is not allowed").format(self.file_type),
|
||||
exc=FileTypeNotAllowed,
|
||||
)
|
||||
|
||||
def check_content(self):
|
||||
if self.file_type == "PDF" and self._content and pdf_contains_js(self._content):
|
||||
frappe.throw(_("This PDF cannot be uploaded as it contains unsafe content."))
|
||||
|
||||
def validate_duplicate_entry(self):
|
||||
if not self.flags.ignore_duplicate_entry_error and not self.is_folder:
|
||||
|
|
@ -407,7 +428,8 @@ class File(Document):
|
|||
def set_file_name(self):
|
||||
if not self.file_name and not self.file_url:
|
||||
frappe.throw(
|
||||
_("Fields `file_name` or `file_url` must be set for File"), exc=frappe.MandatoryError
|
||||
_("Fields `file_name` or `file_url` must be set for File"),
|
||||
exc=frappe.MandatoryError,
|
||||
)
|
||||
elif not self.file_name and self.file_url:
|
||||
self.file_name = self.file_url.split("/")[-1]
|
||||
|
|
@ -634,7 +656,7 @@ class File(Document):
|
|||
|
||||
if isinstance(self._content, str):
|
||||
self._content = self._content.encode()
|
||||
|
||||
self.check_content()
|
||||
with open(file_path, "wb+") as f:
|
||||
f.write(self._content)
|
||||
os.fsync(f.fileno())
|
||||
|
|
@ -763,7 +785,7 @@ class File(Document):
|
|||
def create_attachment_record(self):
|
||||
icon = ' <i class="fa fa-lock text-warning"></i>' if self.is_private else ""
|
||||
file_url = quote(frappe.safe_encode(self.file_url), safe="/:") if self.file_url else self.file_name
|
||||
file_name = self.file_name or self.file_url
|
||||
file_name = escape_html(self.file_name or self.file_url)
|
||||
|
||||
self.add_comment_in_reference_doc(
|
||||
"Attachment",
|
||||
|
|
@ -779,6 +801,9 @@ class File(Document):
|
|||
frappe.clear_messages()
|
||||
|
||||
def set_is_private(self):
|
||||
if self.is_private:
|
||||
return
|
||||
|
||||
if self.file_url:
|
||||
self.is_private = cint(self.file_url.startswith("/private"))
|
||||
|
||||
|
|
|
|||
7
frappe/core/doctype/file/file_list.js
Normal file
7
frappe/core/doctype/file/file_list.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
frappe.listview_settings["File"] = {
|
||||
formatters: {
|
||||
file_name: function (value) {
|
||||
return frappe.utils.escape_html(value || "");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -32,15 +32,18 @@ class InstalledApplications(Document):
|
|||
|
||||
self.delete_key("installed_applications")
|
||||
for app in frappe.utils.get_installed_apps_info():
|
||||
has_setup_wizard = 0
|
||||
if app.get("app_name") == "frappe" or frappe.get_hooks(app_name=app.get("app_name")).get(
|
||||
"setup_wizard_stages"
|
||||
):
|
||||
has_setup_wizard = 1
|
||||
|
||||
has_setup_wizard = 1
|
||||
setup_complete = app_wise_setup_details.get(app.get("app_name")) or 0
|
||||
if app.get("app_name") == "india_compliance":
|
||||
setup_complete = app_wise_setup_details.get("erpnext") or 0
|
||||
if app.get("app_name") in ["frappe", "erpnext"] and not setup_complete:
|
||||
if app.get("app_name") == "frappe" and has_non_admin_user():
|
||||
setup_complete = 1
|
||||
|
||||
if app.get("app_name") == "erpnext" and has_company():
|
||||
setup_complete = 1
|
||||
|
||||
if app.get("app_name") not in ["frappe", "erpnext"]:
|
||||
setup_complete = 0
|
||||
has_setup_wizard = 0
|
||||
|
||||
self.append(
|
||||
"installed_applications",
|
||||
|
|
@ -77,6 +80,22 @@ class InstalledApplications(Document):
|
|||
frappe.reload_doc("integrations", "doctype", "webhook")
|
||||
|
||||
|
||||
def has_non_admin_user():
|
||||
if frappe.db.has_table("User") and frappe.db.get_value(
|
||||
"User", {"user_type": "System User", "name": ["not in", ["Administrator", "Guest"]]}
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def has_company():
|
||||
if frappe.db.has_table("Company") and frappe.get_all("Company", limit=1):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_installed_apps_order(new_order: list[str] | str):
|
||||
"""Change the ordering of `installed_apps` global
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
# Copyright (c) 2020, Frappe Technologies and contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
|
|
@ -25,3 +28,37 @@ class ModuleProfile(Document):
|
|||
|
||||
def get_permission_log_options(self, event=None):
|
||||
return {"fields": ["block_modules"]}
|
||||
|
||||
def on_update(self):
|
||||
self.clear_cache()
|
||||
self.queue_action(
|
||||
"update_all_users",
|
||||
now=frappe.flags.in_test or frappe.flags.in_install,
|
||||
enqueue_after_commit=True,
|
||||
)
|
||||
|
||||
def update_all_users(self):
|
||||
"""Changes in module_profile reflected across all its user"""
|
||||
block_module = frappe.qb.DocType("Block Module")
|
||||
user = frappe.qb.DocType("User")
|
||||
|
||||
all_current_modules = (
|
||||
frappe.qb.from_(user)
|
||||
.join(block_module)
|
||||
.on(user.name == block_module.parent)
|
||||
.where(user.module_profile == self.name)
|
||||
.select(user.name, block_module.module)
|
||||
).run()
|
||||
user_modules = defaultdict(set)
|
||||
for user, module in all_current_modules:
|
||||
user_modules[user].add(module)
|
||||
|
||||
module_profile_modules = {module.module for module in self.block_modules}
|
||||
|
||||
for user_name, modules in user_modules.items():
|
||||
if modules != module_profile_modules:
|
||||
user = frappe.get_doc("User", user_name)
|
||||
user.block_modules = []
|
||||
for module in module_profile_modules:
|
||||
user.append("block_modules", {"module": module})
|
||||
user.save()
|
||||
|
|
|
|||
|
|
@ -5,25 +5,152 @@ from frappe.tests import IntegrationTestCase
|
|||
|
||||
|
||||
class TestModuleProfile(IntegrationTestCase):
|
||||
def test_make_new_module_profile(self):
|
||||
if not frappe.db.get_value("Module Profile", "_Test Module Profile"):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}],
|
||||
}
|
||||
).insert()
|
||||
def setUp(self):
|
||||
frappe.delete_doc_if_exists("Module Profile", "_Test Module Profile", force=1)
|
||||
frappe.delete_doc_if_exists("Module Profile", "_Test Module Profile 2", force=1)
|
||||
frappe.delete_doc_if_exists("User", "test-module-user1@example.com", force=1)
|
||||
frappe.delete_doc_if_exists("User", "test-module-user2@example.com", force=1)
|
||||
|
||||
# add to user and check
|
||||
if not frappe.db.get_value("User", "test-for-module_profile@example.com"):
|
||||
new_user = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-for-module_profile@example.com", "first_name": "Test User"}
|
||||
).insert()
|
||||
else:
|
||||
new_user = frappe.get_doc("User", "test-for-module_profile@example.com")
|
||||
def test_make_new_module_profile(self):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
new_user = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
|
||||
).insert()
|
||||
|
||||
new_user.module_profile = "_Test Module Profile"
|
||||
new_user.save()
|
||||
|
||||
self.assertEqual(new_user.block_modules[0].module, "Accounts")
|
||||
|
||||
def test_multiple_block_modules(self):
|
||||
"""Assign multiple blocked modules from profile to user"""
|
||||
module_profile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}, {"module": "CRM"}, {"module": "HR"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
user = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
|
||||
).insert()
|
||||
|
||||
user.module_profile = module_profile.name
|
||||
user.save()
|
||||
|
||||
self.assertSetEqual({bm.module for bm in user.block_modules}, {"Accounts", "CRM", "HR"})
|
||||
|
||||
def test_update_module_profile_propagates_to_users(self):
|
||||
"""Updating block_modules in profile should update linked users"""
|
||||
module_profile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
user = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
|
||||
).insert()
|
||||
|
||||
user.module_profile = module_profile.name
|
||||
user.save()
|
||||
|
||||
self.assertEqual({bm.module for bm in user.block_modules}, {"Accounts"})
|
||||
|
||||
module_profile.append("block_modules", {"module": "Projects"})
|
||||
module_profile.save()
|
||||
|
||||
user.reload()
|
||||
self.assertSetEqual({bm.module for bm in user.block_modules}, {"Accounts", "Projects"})
|
||||
|
||||
def test_clear_block_modules(self):
|
||||
"""Clearing block_modules in profile should also clear them for users"""
|
||||
module_profile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
user = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
|
||||
).insert()
|
||||
|
||||
user.module_profile = module_profile.name
|
||||
user.save()
|
||||
self.assertTrue(user.block_modules)
|
||||
|
||||
module_profile.block_modules = []
|
||||
module_profile.save()
|
||||
|
||||
user.reload()
|
||||
self.assertEqual(user.block_modules, [])
|
||||
|
||||
def test_multiple_users_same_profile(self):
|
||||
"""Updates should propagate to all users linked to the same profile"""
|
||||
module_profile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
user1 = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "User One"}
|
||||
).insert()
|
||||
user2 = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user2@example.com", "first_name": "User Two"}
|
||||
).insert()
|
||||
|
||||
for u in (user1, user2):
|
||||
u.module_profile = module_profile.name
|
||||
u.save()
|
||||
|
||||
module_profile.append("block_modules", {"module": "Projects"})
|
||||
module_profile.save()
|
||||
|
||||
user1.reload()
|
||||
user2.reload()
|
||||
self.assertEqual([bm.module for bm in user1.block_modules], ["Accounts", "Projects"])
|
||||
self.assertEqual([bm.module for bm in user2.block_modules], ["Accounts", "Projects"])
|
||||
|
||||
def test_switch_user_module_profile(self):
|
||||
"""Switching user to a different profile updates their block_modules"""
|
||||
profile1 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile",
|
||||
"block_modules": [{"module": "Accounts"}],
|
||||
}
|
||||
).insert()
|
||||
profile2 = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Module Profile",
|
||||
"module_profile_name": "_Test Module Profile 2",
|
||||
"block_modules": [{"module": "HR"}],
|
||||
}
|
||||
).insert()
|
||||
|
||||
user = frappe.get_doc(
|
||||
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
|
||||
).insert()
|
||||
|
||||
user.module_profile = profile1.name
|
||||
user.save()
|
||||
self.assertEqual([bm.module for bm in user.block_modules], ["Accounts"])
|
||||
|
||||
user.module_profile = profile2.name
|
||||
user.save()
|
||||
self.assertEqual([bm.module for bm in user.block_modules], ["HR"])
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@
|
|||
"label": "Filters"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.report_type != \"Custom Report\"",
|
||||
"depends_on": "eval:![\"Custom Report\", \"Report Builder\"].includes(doc.report_type)",
|
||||
"fieldname": "filters",
|
||||
"fieldtype": "Table",
|
||||
"label": "Filters",
|
||||
|
|
@ -177,7 +177,7 @@
|
|||
"label": "Columns"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.report_type != \"Custom Report\"",
|
||||
"depends_on": "eval:![\"Custom Report\", \"Report Builder\"].includes(doc.report_type)",
|
||||
"fieldname": "columns",
|
||||
"fieldtype": "Table",
|
||||
"label": "Columns",
|
||||
|
|
@ -207,7 +207,7 @@
|
|||
"idx": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-12 17:08:09.629411",
|
||||
"modified": "2025-08-28 18:28:32.510719",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Report",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ frappe.ui.form.on("RQ Job", {
|
|||
frm.add_custom_button(__("Force Stop job"), () => {
|
||||
frappe.confirm(
|
||||
__(
|
||||
"This will terminate the job immediately and might be dangerous, are you sure? "
|
||||
"This will terminate the job immediately and might be dangerous, are you sure?"
|
||||
),
|
||||
() => {
|
||||
frappe
|
||||
|
|
|
|||
|
|
@ -44,16 +44,15 @@ frappe.ui.form.on("Server Script", {
|
|||
},
|
||||
|
||||
setup_help(frm) {
|
||||
frm.get_field("help_html").html(`
|
||||
const help_field = frm.get_field("help_html");
|
||||
help_field.html(`
|
||||
<h4>DocType Event</h4>
|
||||
<p>Add logic for standard doctype events like Before Insert, After Submit, etc.</p>
|
||||
<pre>
|
||||
<code>
|
||||
<pre><code class="language-python">
|
||||
# set property
|
||||
if "test" in doc.description:
|
||||
doc.status = 'Closed'
|
||||
|
||||
|
||||
# validate
|
||||
if "validate" in doc.description:
|
||||
raise frappe.ValidationError
|
||||
|
|
@ -65,13 +64,11 @@ if doc.allocated_to:
|
|||
owner = doc.allocated_to,
|
||||
description = doc.subject
|
||||
)).insert()
|
||||
</code>
|
||||
</pre>
|
||||
</code></pre>
|
||||
|
||||
<h5>Payment processing</h5>
|
||||
<p>Payment processing events have a special state. See the <a href="https://github.com/frappe/payments/blob/develop/payments/controllers/payment_controller.py">PaymentController in Frappe Payments</a> for details.</p>
|
||||
<pre>
|
||||
<code>
|
||||
<pre><code class="language-python">
|
||||
# retreive payment session state
|
||||
ps = doc.flags.payment_session
|
||||
|
||||
|
|
@ -81,7 +78,10 @@ if ps.is_success:
|
|||
# custom process return values
|
||||
doc.flags.payment_session.result = {
|
||||
"message": "Thank you for your payment",
|
||||
"action": {"href": "https://shop.example.com", "label": "Return to shop"},
|
||||
"action": {
|
||||
"href": "https://shop.example.com",
|
||||
"label": "Return to shop"
|
||||
}
|
||||
}
|
||||
if ps.is_pre_authorized:
|
||||
if ps.changed: # could be an idempotent run
|
||||
|
|
@ -92,21 +92,20 @@ if ps.is_processing:
|
|||
if ps.is_declined:
|
||||
if ps.changed: # could be an idempotent run
|
||||
...
|
||||
</code>
|
||||
</pre>
|
||||
</code></pre>
|
||||
|
||||
<p>The <i>On Payment Failed</i> (<code>on_payment_failed</code>) event only transports the error message which the controller implementation had extracted from the transaction.</p>
|
||||
<pre>
|
||||
<code>
|
||||
|
||||
<pre><code class="language-python">
|
||||
msg = doc.flags.payment_failure_message
|
||||
doc.my_failure_message_field = msg
|
||||
</code>
|
||||
</pre>
|
||||
</code></pre>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>API Call</h4>
|
||||
<p>Respond to <code>/api/method/<method-name></code> calls, just like whitelisted methods</p>
|
||||
<pre><code>
|
||||
<pre><code class="language-python">
|
||||
# respond to API
|
||||
|
||||
if frappe.form_dict.message == "ping":
|
||||
|
|
@ -119,12 +118,16 @@ else:
|
|||
|
||||
<h4>Permission Query</h4>
|
||||
<p>Add conditions to the where clause of list queries.</p>
|
||||
<pre><code>
|
||||
# generate dynamic conditions and set it in the conditions variable
|
||||
tenant_id = frappe.db.get_value(...)
|
||||
conditions = f'tenant_id = {tenant_id}'
|
||||
<p>Generate dynamic conditions and set it in the conditions variable:</p>
|
||||
|
||||
# resulting select query
|
||||
<pre><code class="language-python">
|
||||
tenant_id = frappe.db.get_value(...) # -> 2
|
||||
conditions = f'tenant_id = {tenant_id}'
|
||||
</code></pre>
|
||||
|
||||
<p>The resulting select query is:</p>
|
||||
|
||||
<pre><code class="language-sql">
|
||||
select name from \`tabPerson\`
|
||||
where tenant_id = 2
|
||||
order by creation desc
|
||||
|
|
@ -135,15 +138,13 @@ order by creation desc
|
|||
<h4>Workflow Task</h4>
|
||||
<p>Execute when a particular <a href="/app/workflow-action-master">Workflow Action Master</a> is executed.</p>
|
||||
<p>Gets the document which the action is being applied on in the <code>doc</code> variable.</p>
|
||||
<code><pre>
|
||||
<pre><code class="language-python">
|
||||
# create a customer with the same name as the given document
|
||||
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = doc.first_name + " " + doc.last_name # we get this from the workflow action
|
||||
customer.customer_name = doc.first_name + " " + doc.last_name # we get this doc from the workflow action
|
||||
customer.customer_type = "Company"
|
||||
|
||||
c.save()
|
||||
</code></pre>
|
||||
`);
|
||||
customer.save()
|
||||
</code></pre>`);
|
||||
frappe.utils.highlight_pre(help_field.$wrapper);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@
|
|||
"label": "Time Window (Seconds)"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.event_frequency==='Cron'",
|
||||
"depends_on": "eval:doc.script_type === \"Scheduler Event\" && doc.event_frequency === 'Cron'",
|
||||
"description": "<pre>* * * * *\n\u252c \u252c \u252c \u252c \u252c\n\u2502 \u2502 \u2502 \u2502 \u2502\n\u2502 \u2502 \u2502 \u2502 \u2514 day of week (0 - 6) (0 is Sunday)\n\u2502 \u2502 \u2502 \u2514\u2500\u2500\u2500\u2500\u2500 month (1 - 12)\n\u2502 \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 day of month (1 - 31)\n\u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 hour (0 - 23)\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 minute (0 - 59)\n\n---\n\n* - Any value\n/ - Step values\n</pre>\n",
|
||||
"fieldname": "cron_format",
|
||||
"fieldtype": "Data",
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
"link_fieldname": "server_script"
|
||||
}
|
||||
],
|
||||
"modified": "2025-07-03 16:12:29.676150",
|
||||
"modified": "2025-09-02 11:11:07.528661",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Server Script",
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ def get_contact_number(contact_name, ref_doctype, ref_name):
|
|||
|
||||
@frappe.whitelist()
|
||||
def send_sms(receiver_list, msg, sender_name="", success_msg=True):
|
||||
send_sms_hook_methods = frappe.get_hooks("send_sms")
|
||||
if send_sms_hook_methods:
|
||||
return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg)
|
||||
|
||||
import json
|
||||
|
||||
if isinstance(receiver_list, str):
|
||||
|
|
|
|||
|
|
@ -164,6 +164,18 @@ class SubmissionQueue(Document):
|
|||
|
||||
|
||||
def queue_submission(doc: Document, action: str, alert: bool = True):
|
||||
if existing_queue := frappe.db.get_value(
|
||||
"Submission Queue", {"ref_doctype": doc.doctype, "ref_docname": doc.name, "status": "Queued"}
|
||||
):
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"This document has already been queued for submission. You can track the progress over {0}."
|
||||
).format(f"<a href='/app/submission-queue/{existing_queue}'><b>here</b></a>"),
|
||||
indicator="orange",
|
||||
alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
queue = frappe.new_doc("Submission Queue")
|
||||
queue.ref_doctype = doc.doctype
|
||||
queue.ref_docname = doc.name
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"apply_strict_user_permissions",
|
||||
"column_break_21",
|
||||
"allow_older_web_view_links",
|
||||
"show_external_link_warning",
|
||||
"security_tab",
|
||||
"security",
|
||||
"session_expiry",
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
"column_break_txqh",
|
||||
"deny_multiple_sessions",
|
||||
"disable_user_pass_login",
|
||||
"max_signups_allowed_per_hour",
|
||||
"login_methods_section",
|
||||
"allow_login_using_mobile_number",
|
||||
"allow_login_using_user_name",
|
||||
|
|
@ -81,6 +83,7 @@
|
|||
"allow_guests_to_upload_files",
|
||||
"force_web_capture_mode_for_uploads",
|
||||
"strip_exif_metadata_from_uploaded_images",
|
||||
"delete_background_exported_reports_after",
|
||||
"column_break_uqma",
|
||||
"allowed_file_extensions",
|
||||
"app_tab",
|
||||
|
|
@ -304,7 +307,7 @@
|
|||
"default": "10",
|
||||
"fieldname": "allow_consecutive_login_attempts",
|
||||
"fieldtype": "Int",
|
||||
"label": "Allow Consecutive Login Attempts "
|
||||
"label": "Allow Consecutive Login Attempts"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_34",
|
||||
|
|
@ -706,7 +709,7 @@
|
|||
},
|
||||
{
|
||||
"default": "100000",
|
||||
"description": "This value specifies the max number of rows that can be rendered in report view. ",
|
||||
"description": "This value specifies the max number of rows that can be rendered in report view.",
|
||||
"fieldname": "max_report_rows",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max Report Rows"
|
||||
|
|
@ -727,12 +730,34 @@
|
|||
"fieldname": "log_api_requests",
|
||||
"fieldtype": "Check",
|
||||
"label": "Log API Requests"
|
||||
},
|
||||
{
|
||||
"default": "48",
|
||||
"description": "Defines how long exported reports sent via email are kept in the system. Older files will be automatically deleted.",
|
||||
"fieldname": "delete_background_exported_reports_after",
|
||||
"fieldtype": "Int",
|
||||
"label": "Delete Background Exported Reports After (Hours)",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "300",
|
||||
"fieldname": "max_signups_allowed_per_hour",
|
||||
"fieldtype": "Int",
|
||||
"label": "Max signups allowed per hour",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "Never",
|
||||
"fieldname": "show_external_link_warning",
|
||||
"fieldtype": "Select",
|
||||
"label": "Show External Link Warning",
|
||||
"options": "Never\nAsk\nAlways"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-05 20:54:53.538436",
|
||||
"modified": "2025-09-24 16:04:02.016562",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "System Settings",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class SystemSettings(Document):
|
|||
"yyyy-mm-dd", "dd-mm-yyyy", "dd/mm/yyyy", "dd.mm.yyyy", "mm/dd/yyyy", "mm-dd-yyyy"
|
||||
]
|
||||
default_app: DF.Literal[None]
|
||||
delete_background_exported_reports_after: DF.Int
|
||||
deny_multiple_sessions: DF.Check
|
||||
disable_change_log_notification: DF.Check
|
||||
disable_document_sharing: DF.Check
|
||||
|
|
@ -73,6 +74,7 @@ class SystemSettings(Document):
|
|||
max_auto_email_report_per_user: DF.Int
|
||||
max_file_size: DF.Int
|
||||
max_report_rows: DF.Int
|
||||
max_signups_allowed_per_hour: DF.Int
|
||||
minimum_password_score: DF.Literal["1", "2", "3", "4"]
|
||||
number_format: DF.Literal[
|
||||
"#,###.##",
|
||||
|
|
@ -95,6 +97,7 @@ class SystemSettings(Document):
|
|||
session_expiry: DF.Data | None
|
||||
setup_complete: DF.Check
|
||||
show_absolute_datetime_in_timeline: DF.Check
|
||||
show_external_link_warning: DF.Literal["Never", "Ask", "Always"]
|
||||
store_attached_pdf_document: DF.Check
|
||||
strip_exif_metadata_from_uploaded_images: DF.Check
|
||||
time_format: DF.Literal["HH:mm:ss", "HH:mm"]
|
||||
|
|
@ -156,9 +159,8 @@ class SystemSettings(Document):
|
|||
|
||||
social_login_enabled = frappe.db.exists("Social Login Key", {"enable_social_login": 1})
|
||||
ldap_enabled = frappe.db.get_single_value("LDAP Settings", "enabled")
|
||||
login_with_email_link_enabled = frappe.db.get_single_value("System Settings", "login_with_email_link")
|
||||
|
||||
if not (social_login_enabled or ldap_enabled or login_with_email_link_enabled):
|
||||
if not (social_login_enabled or ldap_enabled or self.login_with_email_link):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please enable atleast one Social Login Key or LDAP or Login With Email Link before disabling username/password based login."
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
# Transaction Log Changelog
|
||||
|
||||
## v1.0.0
|
||||
Initial version
|
||||
|
||||
The line hash summarizes:
|
||||
- The index of the row
|
||||
- The timestamp
|
||||
- The document raw data
|
||||
|
||||
The chain hash summarizes:
|
||||
- The previous line hash
|
||||
- The current line hash
|
||||
|
||||
## v1.0.1
|
||||
Modification of the timestamp fieldtype from "Time" to "Datetime"
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Copyright (c) 2018, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
import hashlib
|
||||
|
||||
import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
|
||||
class TestTransactionLog(IntegrationTestCase):
|
||||
def test_validate_chaining(self):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Transaction Log",
|
||||
"reference_doctype": "Test Doctype",
|
||||
"document_name": "Test Document 1",
|
||||
"data": "first_data",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
second_log = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Transaction Log",
|
||||
"reference_doctype": "Test Doctype",
|
||||
"document_name": "Test Document 2",
|
||||
"data": "second_data",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
third_log = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Transaction Log",
|
||||
"reference_doctype": "Test Doctype",
|
||||
"document_name": "Test Document 3",
|
||||
"data": "third_data",
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
sha = hashlib.sha256()
|
||||
sha.update(
|
||||
frappe.safe_encode(str(third_log.transaction_hash))
|
||||
+ frappe.safe_encode(str(second_log.chaining_hash))
|
||||
)
|
||||
|
||||
self.assertEqual(sha.hexdigest(), third_log.chaining_hash)
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
// Copyright (c) 2018, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Transaction Log", {});
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
{
|
||||
"actions": [],
|
||||
"creation": "2018-02-06 11:48:51.270524",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"row_index",
|
||||
"section_break_2",
|
||||
"reference_doctype",
|
||||
"document_name",
|
||||
"column_break_5",
|
||||
"timestamp",
|
||||
"checksum_version",
|
||||
"section_break_8",
|
||||
"previous_hash",
|
||||
"transaction_hash",
|
||||
"chaining_hash",
|
||||
"data",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "row_index",
|
||||
"fieldtype": "Data",
|
||||
"label": "Row Index",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_2",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Data",
|
||||
"label": "Reference Document Type",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "document_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Document Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_5",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "timestamp",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Timestamp",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "checksum_version",
|
||||
"fieldtype": "Data",
|
||||
"label": "Checksum Version",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_8",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "previous_hash",
|
||||
"fieldtype": "Small Text",
|
||||
"hidden": 1,
|
||||
"label": "Previous Hash",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "transaction_hash",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Transaction Hash",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "chaining_hash",
|
||||
"fieldtype": "Small Text",
|
||||
"hidden": 1,
|
||||
"label": "Chaining Hash",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "data",
|
||||
"fieldtype": "Long Text",
|
||||
"hidden": 1,
|
||||
"label": "Data",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Transaction Log",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:03:59.373102",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Transaction Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Administrator",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import hashlib
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.utils import cint, now_datetime
|
||||
|
||||
|
||||
class TransactionLog(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
amended_from: DF.Link | None
|
||||
chaining_hash: DF.SmallText | None
|
||||
checksum_version: DF.Data | None
|
||||
data: DF.LongText | None
|
||||
document_name: DF.Data | None
|
||||
previous_hash: DF.SmallText | None
|
||||
reference_doctype: DF.Data | None
|
||||
row_index: DF.Data | None
|
||||
timestamp: DF.Datetime | None
|
||||
transaction_hash: DF.SmallText | None
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
index = get_current_index()
|
||||
self.row_index = index
|
||||
self.timestamp = now_datetime()
|
||||
if index != 1:
|
||||
prev_hash = frappe.get_all(
|
||||
"Transaction Log", filters={"row_index": str(index - 1)}, pluck="chaining_hash", limit=1
|
||||
)
|
||||
if prev_hash:
|
||||
self.previous_hash = prev_hash[0]
|
||||
else:
|
||||
self.previous_hash = "Indexing broken"
|
||||
else:
|
||||
self.previous_hash = self.hash_line()
|
||||
self.transaction_hash = self.hash_line()
|
||||
self.chaining_hash = self.hash_chain()
|
||||
self.checksum_version = "v1.0.1"
|
||||
|
||||
def hash_line(self):
|
||||
sha = hashlib.sha256()
|
||||
sha.update(
|
||||
frappe.safe_encode(str(self.row_index))
|
||||
+ frappe.safe_encode(str(self.timestamp))
|
||||
+ frappe.safe_encode(str(self.data))
|
||||
)
|
||||
return sha.hexdigest()
|
||||
|
||||
def hash_chain(self):
|
||||
sha = hashlib.sha256()
|
||||
sha.update(
|
||||
frappe.safe_encode(str(self.transaction_hash)) + frappe.safe_encode(str(self.previous_hash))
|
||||
)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def get_current_index():
|
||||
series = DocType("Series")
|
||||
current = (
|
||||
frappe.qb.from_(series).where(series.name == "TRANSACTLOG").for_update().select("current")
|
||||
).run()
|
||||
|
||||
if current and current[0][0] is not None:
|
||||
current = current[0][0]
|
||||
|
||||
frappe.db.sql(
|
||||
"""UPDATE `tabSeries`
|
||||
SET `current` = `current` + 1
|
||||
where `name` = 'TRANSACTLOG'"""
|
||||
)
|
||||
current = cint(current) + 1
|
||||
else:
|
||||
frappe.db.sql("INSERT INTO `tabSeries` (name, current) VALUES ('TRANSACTLOG', 1)")
|
||||
current = 1
|
||||
return current
|
||||
|
|
@ -37,18 +37,6 @@ frappe.ui.form.on("User", {
|
|||
}
|
||||
},
|
||||
|
||||
role_profiles: function (frm) {
|
||||
if (frm.doc.role_profiles && frm.doc.role_profiles.length) {
|
||||
frm.roles_editor.disable = 1;
|
||||
frm.call("populate_role_profile_roles").then(() => {
|
||||
frm.roles_editor.show();
|
||||
});
|
||||
} else {
|
||||
frm.roles_editor.disable = 0;
|
||||
frm.roles_editor.show();
|
||||
}
|
||||
},
|
||||
|
||||
module_profile: function (frm) {
|
||||
if (frm.doc.module_profile) {
|
||||
frappe.call({
|
||||
|
|
@ -62,6 +50,7 @@ frappe.ui.form.on("User", {
|
|||
let d = frm.add_child("block_modules");
|
||||
d.module = v.module;
|
||||
});
|
||||
frm.module_editor.disable = 1;
|
||||
frm.module_editor && frm.module_editor.show();
|
||||
},
|
||||
});
|
||||
|
|
@ -93,7 +82,11 @@ frappe.ui.form.on("User", {
|
|||
|
||||
if (frm.doc.user_type == "System User") {
|
||||
var module_area = $("<div>").appendTo(frm.fields_dict.modules_html.wrapper);
|
||||
frm.module_editor = new frappe.ModuleEditor(frm, module_area);
|
||||
frm.module_editor = new frappe.ModuleEditor(
|
||||
frm,
|
||||
module_area,
|
||||
frm.doc.module_profile ? 1 : 0
|
||||
);
|
||||
}
|
||||
} else {
|
||||
frm.roles_editor.show();
|
||||
|
|
@ -248,6 +241,7 @@ frappe.ui.form.on("User", {
|
|||
frm.roles_editor.show();
|
||||
}
|
||||
|
||||
frm.module_editor.disable = frm.doc.module_profile ? 1 : 0;
|
||||
frm.module_editor && frm.module_editor.show();
|
||||
|
||||
if (frappe.session.user == doc.name) {
|
||||
|
|
@ -336,7 +330,7 @@ frappe.ui.form.on("User", {
|
|||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.msgprint(__("Save API Secret: {0}", [r.message.api_secret]));
|
||||
show_api_key_dialog(r.message.api_key, r.message.api_secret);
|
||||
frm.reload_doc();
|
||||
}
|
||||
},
|
||||
|
|
@ -425,6 +419,25 @@ frappe.ui.form.on("User Email", {
|
|||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("User Role Profile", {
|
||||
role_profiles_add: function (frm) {
|
||||
if (frm.doc.role_profiles.length > 0) {
|
||||
frm.roles_editor.disable = 1;
|
||||
frm.call("populate_role_profile_roles").then(() => {
|
||||
frm.roles_editor.show();
|
||||
});
|
||||
$(".deselect-all, .select-all").prop("disabled", true);
|
||||
}
|
||||
},
|
||||
role_profiles_remove: function (frm) {
|
||||
if (frm.doc.role_profiles.length == 0) {
|
||||
frm.roles_editor.disable = 0;
|
||||
frm.roles_editor.show();
|
||||
$(".deselect-all, .select-all").prop("disabled", false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function has_access_to_edit_user() {
|
||||
return has_common(frappe.user_roles, get_roles_for_editing_user());
|
||||
}
|
||||
|
|
@ -437,3 +450,52 @@ function get_roles_for_editing_user() {
|
|||
.map((perm) => perm.role) || ["System Manager"]
|
||||
);
|
||||
}
|
||||
|
||||
function show_api_key_dialog(api_key, api_secret) {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("API Keys"),
|
||||
fields: [
|
||||
{
|
||||
label: __("API Key"),
|
||||
fieldname: "api_key",
|
||||
fieldtype: "Code",
|
||||
read_only: 1,
|
||||
default: api_key,
|
||||
},
|
||||
{
|
||||
label: __("API Secret"),
|
||||
fieldname: "api_secret",
|
||||
fieldtype: "Code",
|
||||
read_only: 1,
|
||||
default: api_secret,
|
||||
},
|
||||
],
|
||||
size: "small",
|
||||
primary_action_label: __("Download"),
|
||||
primary_action: () => {
|
||||
frappe.tools.downloadify(
|
||||
[
|
||||
["api_key", "api_secret"],
|
||||
[api_key, api_secret],
|
||||
],
|
||||
"System Manager",
|
||||
"frappe_api_keys"
|
||||
);
|
||||
|
||||
dialog.hide();
|
||||
},
|
||||
secondary_action_label: __("Copy token to clipboard"),
|
||||
secondary_action: () => {
|
||||
const token = `${api_key}:${api_secret}`;
|
||||
frappe.utils.copy_to_clipboard(token);
|
||||
dialog.hide();
|
||||
},
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
dialog.show_message(
|
||||
__("Store the API secret securely. It won't be displayed again."),
|
||||
"yellow",
|
||||
1
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@
|
|||
"unique": 1
|
||||
},
|
||||
{
|
||||
"description": "<a href=\"https://docs.frappe.io/framework/user/en/api/rest#1-token-based-authentication\" target=\"_blank\">\n Click here to learn about token-based authentication\n</a>",
|
||||
"fieldname": "generate_keys",
|
||||
"fieldtype": "Button",
|
||||
"label": "Generate Keys",
|
||||
|
|
|
|||
|
|
@ -590,6 +590,13 @@ class User(Document):
|
|||
note.remove(row)
|
||||
note.save(ignore_permissions=True)
|
||||
|
||||
# Unlink user from all of its invitation docs
|
||||
invites = frappe.db.get_all("User Invitation", filters={"email": self.name}, pluck="name")
|
||||
for invite in invites:
|
||||
invite_doc = frappe.get_doc("User Invitation", invite)
|
||||
invite_doc.user = None
|
||||
invite_doc.save(ignore_permissions=True)
|
||||
|
||||
def before_rename(self, old_name, new_name, merge=False):
|
||||
# if merging, delete the old user notification settings
|
||||
if merge:
|
||||
|
|
@ -1028,7 +1035,9 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
|
|||
else:
|
||||
return 0, _("Registered but disabled")
|
||||
else:
|
||||
if frappe.db.get_creation_count("User", 60) > 300:
|
||||
max_signups_allowed_per_hour = cint(frappe.get_system_settings("max_signups_allowed_per_hour") or 300)
|
||||
users_created_past_hour = frappe.db.get_creation_count("User", 60)
|
||||
if users_created_past_hour >= max_signups_allowed_per_hour:
|
||||
frappe.respond_as_web_page(
|
||||
_("Temporarily Disabled"),
|
||||
_(
|
||||
|
|
@ -1343,7 +1352,7 @@ def generate_keys(user: str):
|
|||
user_details.api_secret = api_secret
|
||||
user_details.save()
|
||||
|
||||
return {"api_secret": api_secret}
|
||||
return {"api_key": user_details.api_key, "api_secret": api_secret}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
|
|||
|
|
@ -24,13 +24,9 @@ Define user invitation hooks in your app's `hooks.py` file. An example is shown
|
|||
|
||||

|
||||
|
||||
- `only_for`
|
||||
|
||||
Roles that are allowed to invite users to your app.
|
||||
|
||||
- `allowed_roles`
|
||||
|
||||
Roles that are allowed to be invited to your app.
|
||||
A map of `only_for` roles to a list of roles that are allowed to be invited to your app.
|
||||
|
||||
- `after_accept`
|
||||
|
||||
|
|
@ -104,3 +100,4 @@ Cancels a specific pending invitation associated with an installed Framework app
|
|||
- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`.
|
||||
- To manually mark an invitation as expired, you can use the `expire` method on the invitation document.
|
||||
- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document.
|
||||
- Disabled users cannot be invited. Trying to invite a disabled user from the Desk will generate an error and the whitelisted functions will ignore emails associated with disabled users.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 73 KiB |
|
|
@ -20,6 +20,7 @@ emails = [
|
|||
"test_user_invite3@example.com",
|
||||
"test_user_invite4@example.com",
|
||||
"test_user_invite5@example.com",
|
||||
"test_user_invite6@example.com",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -138,8 +139,7 @@ class IntegrationTestUserInvitation(IntegrationTestCase):
|
|||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
invitation.status = "Accepted"
|
||||
invitation.save()
|
||||
invitation.accept()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
pending_invite_email = emails[2]
|
||||
frappe.get_doc(
|
||||
|
|
@ -156,10 +156,35 @@ class IntegrationTestUserInvitation(IntegrationTestCase):
|
|||
roles=["System Manager"],
|
||||
redirect_to_path="/xyz",
|
||||
)
|
||||
self.assertSequenceEqual(res["disabled_user_emails"], [])
|
||||
self.assertSequenceEqual(res["accepted_invite_emails"], [accepted_invite_email])
|
||||
self.assertSequenceEqual(res["pending_invite_emails"], [pending_invite_email])
|
||||
self.assertSequenceEqual(res["invited_emails"], [email_to_invite])
|
||||
self.assertEqual(len(self.get_email_names(False)), 3)
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
IntegrationTestUserInvitation.delete_invitation(invitation.name)
|
||||
frappe.delete_doc("User", user.name)
|
||||
|
||||
def test_invite_by_email_api_disabled_user(self):
|
||||
user = frappe.new_doc("User")
|
||||
user.first_name = "Random"
|
||||
user.last_name = "User"
|
||||
user.email = emails[5]
|
||||
user.append_roles("System Manager")
|
||||
user.insert()
|
||||
user.reload()
|
||||
user.enabled = 0
|
||||
user.save()
|
||||
res = invite_by_email(
|
||||
emails=user.email,
|
||||
roles=["System Manager"],
|
||||
redirect_to_path="/xyz",
|
||||
)
|
||||
self.assertSequenceEqual(res["disabled_user_emails"], [user.email])
|
||||
self.assertSequenceEqual(res["accepted_invite_emails"], [])
|
||||
self.assertSequenceEqual(res["pending_invite_emails"], [])
|
||||
self.assertSequenceEqual(res["invited_emails"], [])
|
||||
frappe.delete_doc("User", user.email)
|
||||
|
||||
def test_accept_invitation_api_pass_redirect(self):
|
||||
invitation = frappe.get_doc(
|
||||
|
|
|
|||
|
|
@ -84,13 +84,22 @@ class UserInvitation(Document):
|
|||
self._validate_roles()
|
||||
self._validate_email()
|
||||
if frappe.db.get_value(
|
||||
"User Invitation", filters={"email": self.email, "status": "Accepted", "app_name": self.app_name}
|
||||
"User Invitation",
|
||||
filters={
|
||||
"email": self.email,
|
||||
"status": "Accepted",
|
||||
"app_name": self.app_name,
|
||||
"user": ["is", "set"],
|
||||
},
|
||||
):
|
||||
frappe.throw(title=_("Error"), msg=_("invitation already accepted"))
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation already accepted"))
|
||||
if frappe.db.get_value(
|
||||
"User Invitation", filters={"email": self.email, "status": "Pending", "app_name": self.app_name}
|
||||
):
|
||||
frappe.throw(title=_("Error"), msg=_("invitation already exists"))
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation already exists"))
|
||||
user_enabled = frappe.db.get_value("User", self.email, "enabled")
|
||||
if user_enabled is not None and user_enabled == 0:
|
||||
frappe.throw(title=_("Error"), msg=_("User is disabled"))
|
||||
|
||||
def _after_insert(self):
|
||||
key = frappe.generate_hash()
|
||||
|
|
@ -150,13 +159,21 @@ class UserInvitation(Document):
|
|||
def _validate_app_name(self):
|
||||
UserInvitation.validate_app_name(self.app_name)
|
||||
|
||||
def _get_allowed_roles(self):
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
|
||||
if not isinstance(user_invitation_hook, dict):
|
||||
return []
|
||||
res = set[str]()
|
||||
allowed_roles_mp = user_invitation_hook.get("allowed_roles") or dict()
|
||||
only_for = set(allowed_roles_mp.keys())
|
||||
for role in only_for & set(frappe.get_roles()):
|
||||
res.update(allowed_roles_mp[role])
|
||||
return list(res)
|
||||
|
||||
def _validate_roles(self):
|
||||
if self.app_name == "frappe":
|
||||
return
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
|
||||
allowed_roles: list[str] = []
|
||||
if isinstance(user_invitation_hook, dict):
|
||||
allowed_roles = user_invitation_hook.get("allowed_roles") or []
|
||||
allowed_roles = self._get_allowed_roles()
|
||||
for r in self.roles:
|
||||
if r.role in allowed_roles:
|
||||
continue
|
||||
|
|
@ -175,7 +192,7 @@ class UserInvitation(Document):
|
|||
@staticmethod
|
||||
def validate_app_name(app_name: str):
|
||||
if app_name not in frappe.get_installed_apps():
|
||||
frappe.throw(title=_("Invalid app"), msg=_("application is not installed"))
|
||||
frappe.throw(title=_("Invalid app"), msg=_("Application is not installed"))
|
||||
|
||||
@staticmethod
|
||||
def validate_role(app_name: str) -> None:
|
||||
|
|
@ -183,9 +200,7 @@ class UserInvitation(Document):
|
|||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name)
|
||||
only_for: list[str] = []
|
||||
if isinstance(user_invitation_hook, dict):
|
||||
only_for = user_invitation_hook.get("only_for") or []
|
||||
if "System Manager" not in only_for:
|
||||
only_for.append("System Manager")
|
||||
only_for = list((user_invitation_hook.get("allowed_roles") or dict()).keys())
|
||||
frappe.only_for(only_for)
|
||||
|
||||
|
||||
|
|
@ -209,7 +224,7 @@ def get_allowed_apps(user: Document | None) -> list[str]:
|
|||
user_invitation_hooks = frappe.get_hooks("user_invitation", app_name=app)
|
||||
if not isinstance(user_invitation_hooks, dict):
|
||||
continue
|
||||
only_for = user_invitation_hooks.get("only_for") or []
|
||||
only_for = list((user_invitation_hooks.get("allowed_roles") or dict()).keys())
|
||||
if set(only_for) & user_roles:
|
||||
allowed_apps.append(app)
|
||||
return allowed_apps
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.model import no_value_fields, table_fields
|
||||
from frappe.model import datetime_fields, no_value_fields, table_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
|
@ -120,6 +120,10 @@ def get_diff(old, new, for_child=False, compare_cancelled=False):
|
|||
if df.fieldtype in ("Link", "Dynamic Link"):
|
||||
old_value, new_value = cstr(old_value), cstr(new_value)
|
||||
|
||||
if df.fieldtype in datetime_fields:
|
||||
if old_value is None and new_value == "":
|
||||
new_value = None
|
||||
|
||||
if not for_child and df.fieldtype in table_fields:
|
||||
old_rows_by_name = {}
|
||||
for d in old_value:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
<p>{{ data.comment }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% const getEscapedValue = (v) => v === null ? "null" : frappe.utils.escape_html(v) %}
|
||||
{% if data.changed && data.changed.length %}
|
||||
<h4>{{ __("Values Changed") }}</h4>
|
||||
<table class="table table-bordered">
|
||||
|
|
@ -18,8 +19,8 @@
|
|||
{% for item in data.changed %}
|
||||
<tr>
|
||||
<td>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</td>
|
||||
<td class="diff-remove">{{ frappe.utils.escape_html(item[1]) }}</td>
|
||||
<td class="diff-add">{{ frappe.utils.escape_html(item[2]) }}</td>
|
||||
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
|
||||
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -50,7 +51,7 @@
|
|||
{% for row_key in item_keys %}
|
||||
<tr>
|
||||
<td class="small">{{ row_key }}</td>
|
||||
<td class="small">{{ frappe.utils.escape_html(item[1][row_key]) }}</td>
|
||||
<td class="small">{{ getEscapedValue(item[1][row_key]) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
|
@ -85,8 +86,8 @@
|
|||
<td>{{ frappe.meta.get_label(doc.ref_doctype, table_info[0]) }}</td>
|
||||
<td>{{ table_info[1] }}</td>
|
||||
<td>{{ item[0] }}</td>
|
||||
<td class="diff-remove">{{ frappe.utils.escape_html(item[1]) }}</td>
|
||||
<td class="diff-add">{{ frappe.utils.escape_html(item[2]) }}</td>
|
||||
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
|
||||
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ frappe.PermissionEngine = class PermissionEngine {
|
|||
|
||||
add_check(cell, d, fieldname, label, description = "") {
|
||||
if (!label) label = toTitle(fieldname.replace(/_/g, " "));
|
||||
if (d.permlevel > 0 && ["read", "write"].indexOf(fieldname) == -1) {
|
||||
if (d.permlevel > 0 && ["read", "write", "mask"].indexOf(fieldname) == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -331,6 +331,7 @@ frappe.PermissionEngine = class PermissionEngine {
|
|||
"import",
|
||||
"export",
|
||||
"share",
|
||||
"mask",
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from frappe.permissions import (
|
|||
)
|
||||
from frappe.utils.user import get_users_with_role as _get_user_with_role
|
||||
|
||||
not_allowed_in_permission_manager = ["DocType", "Patch Log", "Module Def", "Transaction Log"]
|
||||
not_allowed_in_permission_manager = ["DocType", "Patch Log", "Module Def"]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
// Copyright (c) 2019, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Transaction Log Report"] = {
|
||||
onload: function (query_report) {
|
||||
query_report.add_make_chart_button = function () {
|
||||
//
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"add_total_row": 0,
|
||||
"creation": "2018-03-15 18:37:48.783779",
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2018-12-27 18:10:29.785415",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Transaction Log Report",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "Transaction Log",
|
||||
"report_name": "Transaction Log Report",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Administrator"
|
||||
},
|
||||
{
|
||||
"role": "System Manager"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import hashlib
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import format_datetime
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
columns, data = get_columns(filters), get_data(filters)
|
||||
|
||||
return columns, data
|
||||
|
||||
|
||||
def get_data(filters=None):
|
||||
result = []
|
||||
logs = frappe.get_all("Transaction Log", fields=["*"], order_by="creation desc")
|
||||
|
||||
for l in logs:
|
||||
row_index = int(l.row_index)
|
||||
if row_index > 1:
|
||||
previous_hash = frappe.get_all(
|
||||
"Transaction Log",
|
||||
fields=["chaining_hash"],
|
||||
filters={"row_index": row_index - 1},
|
||||
)
|
||||
if not previous_hash:
|
||||
integrity = False
|
||||
else:
|
||||
integrity = check_data_integrity(
|
||||
l.chaining_hash, l.transaction_hash, l.previous_hash, previous_hash[0]["chaining_hash"]
|
||||
)
|
||||
|
||||
result.append(
|
||||
[
|
||||
_(str(integrity)),
|
||||
_(l.reference_doctype),
|
||||
l.document_name,
|
||||
l.owner,
|
||||
l.modified_by,
|
||||
format_datetime(l.timestamp, "YYYYMMDDHHmmss"),
|
||||
]
|
||||
)
|
||||
else:
|
||||
result.append(
|
||||
[
|
||||
_("First Transaction"),
|
||||
_(l.reference_doctype),
|
||||
l.document_name,
|
||||
l.owner,
|
||||
l.modified_by,
|
||||
format_datetime(l.timestamp, "YYYYMMDDHHmmss"),
|
||||
]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_data_integrity(chaining_hash, transaction_hash, registered_previous_hash, previous_hash):
|
||||
if registered_previous_hash != previous_hash:
|
||||
return False
|
||||
|
||||
calculated_chaining_hash = calculate_chain(transaction_hash, previous_hash)
|
||||
|
||||
if calculated_chaining_hash != chaining_hash:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def calculate_chain(transaction_hash, previous_hash):
|
||||
sha = hashlib.sha256()
|
||||
sha.update(transaction_hash.encode("utf-8") + previous_hash.encode("utf-8"))
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def get_columns(filters=None):
|
||||
return [
|
||||
{
|
||||
"label": _("Chain Integrity"),
|
||||
"fieldname": "chain_integrity",
|
||||
"fieldtype": "Data",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Reference Doctype"),
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Data",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Reference Name"),
|
||||
"fieldname": "reference_name",
|
||||
"fieldtype": "Data",
|
||||
"width": 150,
|
||||
},
|
||||
{
|
||||
"label": _("Owner"),
|
||||
"fieldname": "owner",
|
||||
"fieldtype": "Data",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"label": _("Modified By"),
|
||||
"fieldname": "modified_by",
|
||||
"fieldtype": "Data",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"label": _("Timestamp"),
|
||||
"fieldname": "timestamp",
|
||||
"fieldtype": "Data",
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
|
|
@ -18,7 +18,7 @@ STANDARD_EXCLUSIONS = [
|
|||
"*.scss",
|
||||
"*.vue",
|
||||
"*.html",
|
||||
"*/test_*",
|
||||
"*/test_*/*",
|
||||
"*/node_modules/*",
|
||||
"*/doctype/*/*_dashboard.py",
|
||||
"*/patches/*",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
|
||||
frappe.ui.form.on("Client Script", {
|
||||
setup(frm) {
|
||||
frm.get_field("sample").html(SAMPLE_HTML);
|
||||
const sample_field = frm.get_field("sample");
|
||||
sample_field.html(SAMPLE_HTML);
|
||||
frappe.utils.highlight_pre(sample_field.$wrapper);
|
||||
},
|
||||
refresh(frm) {
|
||||
if (frm.doc.dt && frm.doc.script) {
|
||||
|
|
@ -106,53 +108,50 @@ frappe.ui.form.on('${doctype}', {
|
|||
|
||||
const SAMPLE_HTML = `<h3>Client Script Help</h3>
|
||||
<p>Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started</p>
|
||||
<pre><code>
|
||||
|
||||
<pre><code class="language-javascript">
|
||||
// fetch local_tax_no on selection of customer
|
||||
// cur_frm.add_fetch(link_field, source_fieldname, target_fieldname);
|
||||
cur_frm.add_fetch("customer", "local_tax_no', 'local_tax_no');
|
||||
// cur_frm.add_fetch(link_field, source_fieldname, target_fieldname);
|
||||
cur_frm.add_fetch("customer", "local_tax_no", "local_tax_no");
|
||||
|
||||
// additional validation on dates
|
||||
frappe.ui.form.on('Task', 'validate', function(frm) {
|
||||
frappe.ui.form.on("Task", "validate", function(frm) {
|
||||
if (frm.doc.from_date < get_today()) {
|
||||
msgprint('You can not select past date in From Date');
|
||||
msgprint("You can not select past date in From Date");
|
||||
validated = false;
|
||||
}
|
||||
});
|
||||
|
||||
// make a field read-only after saving
|
||||
frappe.ui.form.on('Task', {
|
||||
frappe.ui.form.on("Task", {
|
||||
refresh: function(frm) {
|
||||
// use the __islocal value of doc, to check if the doc is saved or not
|
||||
frm.set_df_property('myfield', 'read_only', frm.doc.__islocal ? 0 : 1);
|
||||
frm.set_df_property("myfield", "read_only", frm.is_new() ? 0 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
// additional permission check
|
||||
frappe.ui.form.on('Task', {
|
||||
frappe.ui.form.on("Task", {
|
||||
validate: function(frm) {
|
||||
if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {
|
||||
msgprint('You are only allowed Material Receipt');
|
||||
if(user === "user1@example.com" && frm.doc.purpose !== "Material Receipt") {
|
||||
msgprint("You are only allowed Material Receipt");
|
||||
validated = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// calculate sales incentive
|
||||
frappe.ui.form.on('Sales Invoice', {
|
||||
frappe.ui.form.on("Sales Invoice", {
|
||||
validate: function(frm) {
|
||||
// calculate incentives for each person on the deal
|
||||
total_incentive = 0
|
||||
$.each(frm.doc.sales_team, function(i, d) {
|
||||
for (const row of frm.doc.sales_team) {
|
||||
// calculate incentive
|
||||
var incentive_percent = 2;
|
||||
if(frm.doc.base_grand_total > 400) incentive_percent = 4;
|
||||
// actual incentive
|
||||
d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;
|
||||
total_incentive += flt(d.incentives)
|
||||
row.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;
|
||||
total_incentive += flt(row.incentives)
|
||||
});
|
||||
frm.doc.total_incentive = total_incentive;
|
||||
}
|
||||
})
|
||||
|
||||
</code></pre>`;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"label",
|
||||
"search_fields",
|
||||
"grid_page_length",
|
||||
"rows_threshold_for_grid_search",
|
||||
"link_filters",
|
||||
"column_break_5",
|
||||
"istable",
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
"force_re_route_to_default_view",
|
||||
"column_break_29",
|
||||
"show_preview_popup",
|
||||
"show_name_in_global_search",
|
||||
"email_settings_section",
|
||||
"default_email_template",
|
||||
"column_break_26",
|
||||
|
|
@ -422,6 +424,19 @@
|
|||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
},
|
||||
{
|
||||
"depends_on": "istable",
|
||||
"fieldname": "rows_threshold_for_grid_search",
|
||||
"fieldtype": "Int",
|
||||
"label": "Rows Threshold for Grid Search",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_name_in_global_search",
|
||||
"fieldtype": "Check",
|
||||
"label": "Make \"name\" searchable in Global Search"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
|
|
@ -430,7 +445,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-19 12:23:41.564203",
|
||||
"modified": "2025-09-23 07:13:52.631903",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom",
|
||||
"name": "Customize Form",
|
||||
|
|
|
|||
|
|
@ -75,9 +75,11 @@ class CustomizeForm(Document):
|
|||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
rows_threshold_for_grid_search: DF.Int
|
||||
search_fields: DF.Data | None
|
||||
sender_field: DF.Data | None
|
||||
sender_name_field: DF.Data | None
|
||||
show_name_in_global_search: DF.Check
|
||||
show_preview_popup: DF.Check
|
||||
show_title_field_in_link: DF.Check
|
||||
sort_field: DF.Literal[None]
|
||||
|
|
@ -306,6 +308,8 @@ class CustomizeForm(Document):
|
|||
)
|
||||
|
||||
def set_property_setters_for_doctype(self, meta):
|
||||
if self.get("show_name_in_global_search") != meta.get("show_name_in_global_search"):
|
||||
self.flags.rebuild_doctype_for_global_search = True
|
||||
for prop, prop_type in doctype_properties.items():
|
||||
if self.get(prop) != meta.get(prop):
|
||||
self.make_property_setter(prop, self.get(prop), prop_type)
|
||||
|
|
@ -735,6 +739,7 @@ doctype_properties = {
|
|||
"track_views": "Check",
|
||||
"allow_auto_repeat": "Check",
|
||||
"allow_import": "Check",
|
||||
"show_name_in_global_search": "Check",
|
||||
"show_preview_popup": "Check",
|
||||
"default_email_template": "Data",
|
||||
"email_append_to": "Check",
|
||||
|
|
@ -748,6 +753,7 @@ doctype_properties = {
|
|||
"force_re_route_to_default_view": "Check",
|
||||
"translated_doctype": "Check",
|
||||
"grid_page_length": "Int",
|
||||
"rows_threshold_for_grid_search": "Int",
|
||||
}
|
||||
|
||||
docfield_properties = {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class TestCustomizeForm(IntegrationTestCase):
|
|||
|
||||
d = self.get_customize_form("Event")
|
||||
self.assertEqual(d.doc_type, "Event")
|
||||
self.assertEqual(len(d.get("fields")), 38)
|
||||
self.assertEqual(len(d.get("fields")), 44)
|
||||
|
||||
d = self.get_customize_form("Event")
|
||||
self.assertEqual(d.doc_type, "Event")
|
||||
|
|
|
|||
|
|
@ -1192,6 +1192,7 @@ class Database:
|
|||
self.sql("commit")
|
||||
self.begin()
|
||||
|
||||
self.value_cache.clear()
|
||||
self.after_commit.run()
|
||||
|
||||
def rollback(self, *, save_point=None, chain=False):
|
||||
|
|
@ -1206,10 +1207,12 @@ class Database:
|
|||
|
||||
if chain:
|
||||
self.sql("rollback and chain")
|
||||
self.value_cache.clear()
|
||||
else:
|
||||
self.sql("rollback")
|
||||
self.begin()
|
||||
|
||||
self.value_cache.clear()
|
||||
self.after_rollback.run()
|
||||
else:
|
||||
warnings.warn(message=TRANSACTION_DISABLED_MSG, stacklevel=2)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ class PostgresTable(DBTable):
|
|||
if isinstance(default, str):
|
||||
default = frappe.db.escape(default)
|
||||
change_nullability.append(
|
||||
f"ALTER COLUMN \"{col.fieldname}\" {'SET' if col.not_nullable else 'DROP'} NOT NULL"
|
||||
f'ALTER COLUMN "{col.fieldname}" {"SET" if col.not_nullable else "DROP"} NOT NULL'
|
||||
)
|
||||
change_nullability.append(f'ALTER COLUMN "{col.fieldname}" SET DEFAULT {default}')
|
||||
|
||||
|
|
|
|||
|
|
@ -288,6 +288,8 @@ class Engine:
|
|||
doctype: str | None = None,
|
||||
) -> "Criterion | None":
|
||||
"""Builds a pypika Criterion object for a simple filter condition."""
|
||||
import operator as builtin_operator
|
||||
|
||||
_field = self._validate_and_prepare_filter_field(field, doctype)
|
||||
_value = convert_to_value(value)
|
||||
_operator = operator
|
||||
|
|
@ -323,7 +325,7 @@ class Engine:
|
|||
|
||||
operator_fn = OPERATOR_MAP[_operator.casefold()]
|
||||
if _value is None and isinstance(_field, Field):
|
||||
return _field.isnull()
|
||||
return _field.isnotnull() if operator_fn == builtin_operator.ne else _field.isnull()
|
||||
else:
|
||||
return operator_fn(_field, _value)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ SPECIAL_CHAR_PATTERN = re.compile(r"[\W]", flags=re.UNICODE)
|
|||
|
||||
VARCHAR_CAST_PATTERN = re.compile(r"varchar\(([\d]+)\)")
|
||||
|
||||
CONFIGURABLE_DECIMAL_TYPES = ("Currency", "Float", "Percent")
|
||||
DEFAULT_DECIMAL_LENGTH = 21
|
||||
DEFAULT_DECIMAL_PRECISION = 9
|
||||
|
||||
|
||||
class InvalidColumnName(frappe.ValidationError):
|
||||
pass
|
||||
|
|
@ -429,13 +433,19 @@ def get_definition(fieldtype, precision=None, length=None, *, options=None):
|
|||
size = d[1] if d[1] else None
|
||||
|
||||
if size:
|
||||
# This check needs to exist for backward compatibility.
|
||||
# Till V13, default size used for float, currency and percent are (18, 6).
|
||||
if fieldtype in ["Float", "Currency", "Percent"] and cint(precision) > 6:
|
||||
size = "21,9"
|
||||
if fieldtype in CONFIGURABLE_DECIMAL_TYPES:
|
||||
width = length if length else DEFAULT_DECIMAL_LENGTH
|
||||
precision_is_set = precision not in (None, "")
|
||||
precision = precision if precision_is_set else DEFAULT_DECIMAL_PRECISION
|
||||
if cint(precision) > cint(width):
|
||||
precision = width
|
||||
size = f"{cint(width)},{cint(precision)}"
|
||||
|
||||
if length:
|
||||
if coltype == "varchar":
|
||||
# Reference: https://mariadb.com/docs/server/server-usage/storage-engines/innodb/innodb-row-formats/troubleshooting-row-size-too-large-errors-with-innodb
|
||||
if cint(length) < 64:
|
||||
length = 64
|
||||
size = length
|
||||
elif coltype == "int" and length < 11:
|
||||
# allow setting custom length for int if length provided is less than 11
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue