fix: update code from develop branch

This commit is contained in:
theerayut 2025-10-12 12:55:57 +07:00
commit c1e42a6a50
351 changed files with 180431 additions and 44286 deletions

View file

@ -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

View file

@ -61,3 +61,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# replace `frappe.flags.in_test` with `frappe.in_test`
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8
# another ruff update
6ca4d4d167a1a009d99062747711de7a994aa633

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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: |

View file

@ -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 }}"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }}

View file

@ -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:

View file

@ -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: []

View file

@ -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

View file

@ -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

1 **/hooks.py frappe.gettext.extractors.navbar.extract
2 **/doctype/*/*.json frappe.gettext.extractors.doctype.extract
3 **/workspace/*/*.json frappe.gettext.extractors.workspace.extract
4 **/web_form/*/*.json frappe.gettext.extractors.web_form.extract
5 **/onboarding_step/*/*.json frappe.gettext.extractors.onboarding_step.extract
6 **/module_onboarding/*/*.json frappe.gettext.extractors.module_onboarding.extract
7 **/report/*/*.json frappe.gettext.extractors.report.extract

View file

@ -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 });

View file

@ -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);
});
});
});
});

View file

@ -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 });

View 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,
});
});
});
});

View file

@ -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

View file

@ -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

View file

@ -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:
try:
args = orjson.loads(request_data)
except orjson.JSONDecodeError:
frappe.throw(_("Invalid request body"), frappe.DataError)
else:
args = {}
args.update(request.args or {})

View file

@ -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:

View file

@ -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,6 +175,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View file

@ -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,6 +264,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "reference_document",
"sort_field": "creation",
"sort_order": "DESC",

View file

@ -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,9 +138,13 @@ 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:
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"))
@ -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:
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

View file

@ -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)

View file

@ -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": []
}

View file

@ -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

View file

@ -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 = {}

View file

@ -147,17 +147,26 @@ def _clear_doctype_cache_from_redis(doctype: str | None = None):
clear_single(doctype)
# clear all parent doctypes
try:
for dt in frappe.get_all(
"DocField", "parent", 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.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)
"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)

View file

@ -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",
)

View file

@ -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()

View file

@ -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"]})

View file

@ -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."""

View file

@ -109,7 +109,8 @@
{
"fieldname": "phone",
"fieldtype": "Data",
"label": "Phone"
"label": "Phone",
"options": "Phone"
},
{
"fieldname": "fax",

View file

@ -31,23 +31,22 @@ def execute(filters=None):
def get_columns(filters):
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",
return [
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",
]

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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,6 +248,7 @@
}
],
"read_only": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],

View file

@ -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

View file

@ -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()

View file

@ -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,19 +608,28 @@
"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": []

View file

@ -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

View file

@ -22,6 +22,7 @@
"submit",
"cancel",
"amend",
"mask",
"additional_permissions",
"report",
"export",
@ -205,17 +206,26 @@
"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": []

View file

@ -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

View file

@ -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",

View file

@ -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)

View file

@ -24,6 +24,7 @@ frappe.listview_settings["DocType"] = {
fieldtype: "Data",
reqd: 1,
default: doctype_name,
length: 61,
},
{ fieldtype: "Column Break" },
{

View file

@ -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,

View file

@ -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"))

View file

@ -0,0 +1,7 @@
frappe.listview_settings["File"] = {
formatters: {
file_name: function (value) {
return frappe.utils.escape_html(value || "");
},
},
};

View file

@ -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
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

View file

@ -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()

View file

@ -5,8 +5,13 @@ from frappe.tests import IntegrationTestCase
class TestModuleProfile(IntegrationTestCase):
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)
def test_make_new_module_profile(self):
if not frappe.db.get_value("Module Profile", "_Test Module Profile"):
frappe.get_doc(
{
"doctype": "Module Profile",
@ -15,15 +20,137 @@ class TestModuleProfile(IntegrationTestCase):
}
).insert()
# 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"}
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
).insert()
else:
new_user = frappe.get_doc("User", "test-for-module_profile@example.com")
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"])

View file

@ -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",

View file

@ -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/&lt;method-name&gt;</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);
},
});

View file

@ -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",

View file

@ -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):

View file

@ -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

View file

@ -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",
@ -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",

View file

@ -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."

View file

@ -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"

View file

@ -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)

View file

@ -1,4 +0,0 @@
// Copyright (c) 2018, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Transaction Log", {});

View file

@ -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
}

View file

@ -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

View file

@ -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
);
}

View file

@ -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",

View file

@ -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()

View file

@ -24,13 +24,9 @@ Define user invitation hooks in your app's `hooks.py` file. An example is shown
![user invitation hooks example](./user_invitation_hooks_example.png)
- `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

View file

@ -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(

View file

@ -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

View file

@ -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:

View file

@ -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 %}

View file

@ -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",
];
}

View file

@ -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()

View file

@ -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 () {
//
};
},
};

View file

@ -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"
}
]
}

View file

@ -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,
},
]

View file

@ -18,7 +18,7 @@ STANDARD_EXCLUSIONS = [
"*.scss",
"*.vue",
"*.html",
"*/test_*",
"*/test_*/*",
"*/node_modules/*",
"*/doctype/*/*_dashboard.py",
"*/patches/*",

View file

@ -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("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 &lt; 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' &amp;&amp; frm.doc.purpose!='Material Receipt') {
msgprint('You are only allowed Material Receipt');
if(user === "user1@example.com" &amp;&amp; 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 &gt; 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>`;

View file

@ -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",

View file

@ -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 = {

View file

@ -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")

View file

@ -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)

View file

@ -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}')

View file

@ -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)

View file

@ -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

View file

@ -107,6 +107,7 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
conn = self.create_connection(read_only)
conn.isolation_level = None
conn.create_function("regexp", 2, regexp)
conn.create_function("regexp_replace", 3, regexp_replace)
pragmas = {
"journal_mode": "WAL",
"synchronous": "NORMAL",
@ -583,3 +584,10 @@ def regexp(expr: str, item: str) -> bool:
Although it works in the CLI - doesn't work through python
"""
return re.search(expr, item) is not None
def regexp_replace(item: str, pattern: str, repl: str) -> str:
"""
Define regexp_replace implementation for SQLite
"""
return re.sub(pattern, repl, item)

View file

@ -125,7 +125,7 @@ class SQLiteTable(DBTable):
if self.meta.sort_field == "modified" and not frappe.db.get_column_index(
self.table_name, "modified", unique=False
):
index_queries.append(f"CREATE INDEX `modified` ON `{self.table_name}` (`modified`)")
index_queries.append(f"CREATE INDEX IF NOT EXISTS `modified` ON `{self.table_name}` (`modified`)")
for query in index_queries:
frappe.db.sql_ddl(query)

View file

@ -899,7 +899,7 @@ def tests_utils_get_dependencies(doctype):
import frappe
from frappe.tests.utils.generators import get_modules
module, test_module = get_modules(doctype)
_module, test_module = get_modules(doctype)
meta = frappe.get_meta(doctype)
link_fields = meta.get_link_fields()

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