Merge branch 'develop' into event

This commit is contained in:
Shariq Ansari 2025-09-04 17:22:42 +05:30 committed by GitHub
commit 5d925384dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
853 changed files with 402032 additions and 217871 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

@ -58,3 +58,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# ruff update
84ef6ec677c8657c3243ac456a1ef794bfb34a50
# replace `frappe.flags.in_test` with `frappe.in_test`
653c80b8483cc41aef25cd7d66b9b6bb188bf5f8

View file

@ -8,7 +8,7 @@ inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '20'
default: '22'
build-assets:
required: false
description: 'Wether to build assets'
@ -100,7 +100,7 @@ runs:
run: |
# Install System Dependencies
start_time=$(date +%s)
curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | sudo bash
sudo apt -qq update
sudo apt -qq remove mysql-server mysql-client
sudo apt -qq install libcups2-dev redis-server mariadb-client libmariadb-dev
@ -121,6 +121,7 @@ runs:
python -m venv ${GITHUB_WORKSPACE}/env
source ${GITHUB_WORKSPACE}/env/bin/activate
pip install --quiet --upgrade pip
pip cache remove mysqlclient
pip install --quiet frappe-bench
@ -166,9 +167,6 @@ runs:
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'";
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "FLUSH PRIVILEGES";
elif [ "$DB" == "postgres" ]; then
echo "${{ inputs.db-root-password }}" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "${{ inputs.db-root-password }}" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
fi
- shell: bash -e {0}

View file

@ -1,9 +1,6 @@
{
"db_host": "127.0.0.1",
"db_port": 5432,
"db_name": "test_frappe",
"db_password": "test_frappe",
"db_type": "postgres",
"db_type": "sqlite",
"allow_tests": true,
"auto_email_id": "test@example.com",
"mail_server": "localhost",
@ -11,8 +8,6 @@
"mail_login": "test@example.com",
"mail_password": "test",
"admin_password": "admin",
"root_login": "postgres",
"root_password": "db_root",
"host_name": "http://test_site:8000",
"server_script_enabled": true
}
}

View file

@ -11,6 +11,7 @@ WEBSITE_REPOS = [
DOCUMENTATION_DOMAINS = [
"docs.erpnext.com",
"frappeframework.com",
"docs.frappe.io",
]

View file

@ -16,7 +16,7 @@ on:
node-version:
required: false
type: number
default: 20
default: 22
db-artifact-url:
required: false
type: string
@ -33,14 +33,14 @@ jobs:
DB_ROOT_PASSWORD: db_root
services:
mariadb:
image: mariadb:11.3
image: mariadb:11.8
ports:
- 3306:3306
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
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

@ -13,12 +13,12 @@ on:
node-version:
required: false
type: number
default: 20
default: 22
parallel-runs:
required: false
type: number
default: 2
enable-postgres:
enable-sqlite:
required: false
type: boolean
default: false
@ -55,7 +55,6 @@ jobs:
timeout-minutes: 30
env:
NODE_ENV: "production"
PYTHONOPTIMIZE: 2
# noisy 3rd party library warnings
PYTHONWARNINGS: "module,ignore:::babel.messages.extract"
DB_ROOT_PASSWORD: db_root
@ -63,34 +62,23 @@ jobs:
strategy:
fail-fast: false
matrix:
db: ${{ fromJson(inputs.enable-postgres && '["mariadb", "postgres"]' || '["mariadb"]') }}
db: ${{ fromJson(inputs.enable-sqlite && '["mariadb", "sqlite"]' || '["mariadb"]') }}
index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }}
services:
mariadb:
image: mariadb:11.3
image: mariadb:11.8
ports:
- 3306:3306
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
env:
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
postgres:
image: postgres:12.4
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
env:
POSTGRES_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
smtp_server:
image: rnwood/smtp4dev
image: rnwood/smtp4dev:3.7.1
ports:
- 2525:25
- 3000:80
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: ./.github/actions/setup
name: Environment Setup
with:

View file

@ -63,7 +63,7 @@ jobs:
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

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 20
default: 22
parallel-runs:
required: false
type: number
@ -44,7 +44,6 @@ jobs:
timeout-minutes: 30
env:
NODE_ENV: "production"
PYTHONOPTIMIZE: 2
# noisy 3rd party library warnings
PYTHONWARNINGS: "ignore"
DB_ROOT_PASSWORD: db_root
@ -56,14 +55,14 @@ jobs:
index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }}
services:
mariadb:
image: mariadb:11.3
image: mariadb:11.8
ports:
- 3306:3306
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
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:
@ -99,6 +98,10 @@ jobs:
bench --site test_site execute frappe.utils.install.complete_setup_wizard
bench --site test_site execute frappe.tests.ui_test_helpers.create_test_user
- uses: browser-actions/setup-chrome@latest
- run: |
echo "BROWSER_PATH=$(which chrome)" >> $GITHUB_ENV
- name: Run Tests
run: |
source ${GITHUB_WORKSPACE}/env/bin/activate
@ -107,6 +110,7 @@ jobs:
--with-coverage \
--headless \
--parallel \
--browser ${{ env.BROWSER_PATH }} \
--ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
env:
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb

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,14 +12,14 @@ 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
with:
node-version: 20
node-version: 22
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save

View file

@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- uses: actions/labeler@v5
with:

View file

@ -19,12 +19,12 @@ 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
with:
node-version: 20
node-version: 22
check-latest: true
- name: Check commit titles
@ -42,7 +42,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version: '3.13'
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Validate Docs
env:
@ -57,7 +57,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'
@ -80,7 +80,7 @@ jobs:
with:
python-version: '3.13'
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Cache pip
uses: actions/cache@v4
@ -95,7 +95,7 @@ jobs:
run: |
pip install pip-audit
cd ${GITHUB_WORKSPACE}
pip-audit --desc on .
pip-audit --desc on --ignore-vuln PYSEC-2023-312 .
precommit:
name: 'Pre-Commit'
@ -103,7 +103,7 @@ jobs:
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-python@v5
with:
python-version: '3.13'

View file

@ -16,13 +16,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
path: 'frappe'
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- uses: actions/setup-python@v5
with:

View file

@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
path: 'frappe'
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
- uses: actions/setup-python@v5
with:
python-version: '3.13'

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
@ -60,7 +60,7 @@ jobs:
services:
mysql:
image: mariadb:11.3
image: mariadb:11.8
env:
MARIADB_ROOT_PASSWORD: db_root
ports:
@ -69,7 +69,7 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Python
uses: actions/setup-python@v5
@ -79,7 +79,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
check-latest: true
- name: Add to Hosts

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: |
@ -44,7 +44,7 @@ jobs:
name: Tests
uses: ./.github/workflows/_base-server-tests.yml
with:
enable-postgres: true # This will test against both MariaDB and PostgreSQL
enable-sqlite: false # This will test against both MariaDB and SQLite if enabled
parallel-runs: 2
enable-coverage: ${{ github.event_name != 'pull_request' }}
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
@ -58,7 +58,7 @@ jobs:
with:
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
python-version: '3.10'
node-version: 20
node-version: 22
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
coverage:
@ -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.2.1
uses: actions/download-artifact@v5.0.0
- name: Upload coverage data
uses: codecov/codecov-action@v5
with:

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.2.1
uses: actions/download-artifact@v5.0.0
- name: Upload python coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -6,7 +6,7 @@
</div>
<div align="center">
<a target="_blank" href="#LICENSE" title="License: MIT"><img src="https://img.shields.io/badge/License-MIT-success.svg"></a>
<a target="_blank" href="LICENSE" title="License: MIT"><img src="https://img.shields.io/badge/License-MIT-success.svg"></a>
<a href="https://codecov.io/gh/frappe/frappe"><img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/></a>
</div>
<div align="center">
@ -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
@ -103,26 +108,24 @@ To setup the repository locally follow the steps mentioned below:
2. In a separate terminal window, run the following commands:
```
# Create a new site
bench new-site frappe.dev
# Map your site to localhost
bench --site frappe.dev add-to-hosts
bench new-site frappe.localhost
```
3. Open the URL `http://frappe.dev:8000/app` in your browser, you should see the app running
3. Open the URL `http://frappe.localhost:8000/app` in your browser, you should see the app running
## Learning and community
1. [Frappe School](https://frappe.school) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
2. [Official documentation](https://docs.frappe.io/framework) - Extensive documentation for Frappe Framework.
3. [Discussion Forum](https://discuss.frappe.io/) - Engage with community of Frappe Framework users and service providers.
4. [buildwithhussain.dev](https://buildwithhussain.dev) - Watch Frappe Framework being used in the wild to build world-class web apps.
4. [buildwithhussain.com](https://buildwithhussain.com) - Watch Frappe Framework being used in the wild to build world-class web apps.
## Contributing
1. [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines)
1. [Report Security Vulnerabilities](https://frappe.io/security)
1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
2. [Translations](https://crowdin.com/project/frappe)
<br>
<br>
@ -133,4 +136,4 @@ To setup the repository locally follow the steps mentioned below:
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
</picture>
</a>
</div>
</div>

View file

@ -4,9 +4,9 @@ context("Awesome Bar", () => {
cy.login();
cy.visit("/app/todo"); // Make sure ToDo filters are cleared.
cy.clear_filters();
cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared.
cy.visit("/app/web-page"); // Make sure Blog Post filters are cleared.
cy.clear_filters();
cy.visit("/app/website"); // Go to some other page.
cy.visit("/app/build"); // Go to some other page.
});
beforeEach(() => {
@ -53,19 +53,19 @@ context("Awesome Bar", () => {
});
it("navigates to another doctype, filter not bleeding", () => {
cy.get("@awesome_bar").type("blog post");
cy.get("@awesome_bar").type("web page");
cy.wait(150); // Wait a bit before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "Blog Post");
cy.get(".title-text").should("contain", "Web Page");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.location("search").should("be.empty");
});
it("navigates to new form", () => {
cy.get("@awesome_bar").type("new blog post");
cy.get("@awesome_bar").type("new web page");
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text:visible").should("have.text", "New Blog Post");
cy.get(".title-text:visible").should("have.text", "New Web Page");
});
it("calculates math expressions", () => {

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

@ -43,7 +43,7 @@ context("Sidebar", () => {
.window()
.its("frappe")
.then((frappe) => {
return frappe.call("frappe.tests.ui_test_helpers.create_blog_post");
return frappe.call("frappe.tests.ui_test_helpers.create_doctype_for_attachment");
});
});
@ -53,7 +53,7 @@ context("Sidebar", () => {
}).then((todo) => {
verify_attachment_visibility(`todo/${todo.message.name}`, true);
});
verify_attachment_visibility("blog-post/test-blog-attachment-post", false);
verify_attachment_visibility("test-blog-category/_Test Blog Category 2", false);
});
it("Verify attachment accessibility UX", () => {

View file

@ -8,7 +8,7 @@ context("Table MultiSelect", () => {
it("select value from multiselect dropdown", () => {
cy.new_form("Assignment Rule");
cy.fill_field("__newname", name);
cy.fill_field("document_type", "Blog Post");
cy.fill_field("document_type", "Web Page");
cy.get(".section-head").contains("Assignment Rules").scrollIntoView();
cy.fill_field("assign_condition", 'status=="Open"', "Code");
cy.get('input[data-fieldname="users"]').focus().as("input");

View file

@ -0,0 +1,99 @@
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,
});
});
});
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

@ -472,6 +472,11 @@ async function write_assets_json(metafile) {
}
async function update_assets_json_in_cache() {
// Redis won't be present during docker image build
if (process.env.FRAPPE_DOCKER_BUILD) {
return;
}
// update assets_json cache in redis, so that it can be read directly by python
let client = get_redis_subscriber("redis_cache");
// handle error event to avoid printing stack traces
@ -523,7 +528,7 @@ function run_build_command_for_apps(apps) {
log(
`\nInstalling dependencies for ${chalk.bold(app)} (because node_modules not found)`
);
execSync("yarn install", { encoding: "utf8", stdio: "inherit" });
execSync("yarn install --frozen-lockfile", { encoding: "utf8", stdio: "inherit" });
}
log("\nRunning build command for", chalk.bold(app));

View file

@ -24,16 +24,12 @@ from collections.abc import Callable, Iterable
from typing import (
TYPE_CHECKING,
Any,
Generic,
Literal,
Optional,
TypeAlias,
TypeVar,
Union,
overload,
)
import click
import orjson
from werkzeug.datastructures import Headers
import frappe
@ -45,10 +41,11 @@ from frappe.utils.caching import deprecated_local_cache as local_cache
from frappe.utils.caching import request_cache, site_cache
from frappe.utils.data import as_unicode, bold, cint, cstr, safe_decode, safe_encode, sbool
from frappe.utils.local import Local, LocalProxy, release_local
from frappe.utils.translations import _, _lt, set_user_lang
# Local application imports
from .exceptions import *
from .types import Filters, FilterSignature, FilterTuple, _dict
from .types import _dict
from .utils.jinja import (
get_email_from_template,
get_jenv,
@ -62,25 +59,27 @@ __title__ = "Frappe Framework"
if TYPE_CHECKING: # pragma: no cover
from logging import Logger
from types import ModuleType
from werkzeug.wrappers import Request
from frappe.database.mariadb.database import MariaDBDatabase as PyMariaDBDatabase
from frappe.database.mariadb.mysqlclient import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.database.sqlite.database import SQLiteDatabase
from frappe.model.document import Document
from frappe.query_builder.builder import MariaDB, Postgres
from frappe.types.lazytranslatedstring import _LazyTranslate
from frappe.query_builder.builder import MariaDB, Postgres, SQLite
from frappe.utils.redis_wrapper import ClientCache, RedisWrapper
controllers: dict[str, "Document"] = {}
controllers: dict[str, type] = {}
lazy_controllers: dict[str, type] = {}
local = Local()
cache: Optional["RedisWrapper"] = None
client_cache: Optional["ClientCache"] = None
STANDARD_USERS = ("Guest", "Administrator")
# this global may be subsequently changed by frappe.tests.utils.toggle_test_mode()
in_test = False
_dev_server = int(sbool(os.environ.get("DEV_SERVER", False)))
if _dev_server:
@ -88,66 +87,6 @@ if _dev_server:
warnings.simplefilter("always", PendingDeprecationWarning)
def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
"""Return translated string in current lang, if exists.
Usage:
_('Change')
_('Change', context='Coins')
"""
from frappe.translate import get_all_translations
from frappe.utils import is_html, strip_html_tags
if not hasattr(local, "lang"):
local.lang = lang or "en"
if not lang:
lang = local.lang
non_translated_string = msg
if is_html(msg):
msg = strip_html_tags(msg)
# msg should always be unicode
msg = as_unicode(msg).strip()
translated_string = ""
all_translations = get_all_translations(lang)
if context:
string_key = f"{msg}:{context}"
translated_string = all_translations.get(string_key)
if not translated_string:
translated_string = all_translations.get(msg)
return translated_string or non_translated_string
def _lt(msg: str, lang: str | None = None, context: str | None = None) -> "_LazyTranslate":
"""Lazily translate a string.
This function returns a "lazy string" which when casted to string via some operation applies
translation first before casting.
This is only useful for translating strings in global scope or anything that potentially runs
before `frappe.init()`
Note: Result is not guaranteed to equivalent to pure strings for all operations.
"""
from .types.lazytranslatedstring import _LazyTranslate
return _LazyTranslate(msg, lang, context)
def set_user_lang(user: str, user_language: str | None = None) -> None:
"""Guess and set user language for the session. `frappe.local.lang`"""
from frappe.translate import get_user_lang
local.lang = get_user_lang(user) or user_language
# local-globals
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: make session a dataclass instead of undtyped _dict
@ -161,8 +100,10 @@ ResponseDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FlagsDict: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
FormDict: TypeAlias = _dict[str, str]
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase"]] = local("db")
qb: LocalProxy[Union["MariaDB", "Postgres"]] = local("qb")
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase", "SQLiteDatabase"]] = local(
"db"
)
qb: LocalProxy[Union["MariaDB", "Postgres", "SQLite"]] = local("qb")
conf: LocalProxy[ConfType] = local("conf")
form_dict: LocalProxy[FormDict] = local("form_dict")
form = form_dict
@ -182,7 +123,7 @@ lang: LocalProxy[str] = local("lang")
if TYPE_CHECKING: # pragma: no cover
# trick because some type checkers fail to follow "RedisWrapper", etc (written as string literal)
# trough a generic wrapper; seems to be a bug
db: PyMariaDBDatabase | MariaDBDatabase | PostgresDatabase
db: PyMariaDBDatabase | MariaDBDatabase | PostgresDatabase | SQLiteDatabase
qb: MariaDB | Postgres
conf: ConfType
form_dict: FormDict
@ -215,7 +156,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
"in_install_db": False,
"in_install_app": False,
"in_import": False,
"in_test": False,
"in_test": in_test,
"mute_messages": False,
"ignore_links": False,
"mute_emails": False,
@ -259,7 +200,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": [], "icons": []}
local.session = _dict()
local.dev_server = _dev_server
local.dev_server = _dev_server # only for backwards compatibility
local.qb = get_query_builder(local.conf.db_type)
if not cache or not client_cache:
setup_redis_cache_connection()
@ -304,9 +245,11 @@ def connect(site: str | None = None, db_name: str | None = None, set_admin_as_us
db_name_ = conf.db_name or db_name
db_password = conf.db_password
assert db_user, "site must be fully initialized, db_user missing"
assert db_name_, "site must be fully initialized, db_name missing"
assert db_password, "site must be fully initialized, db_password missing"
if frappe.conf.db_type in ("mariadb", "postgres"):
assert db_user, "site must be fully initialized, db_user missing"
assert db_password, "site must be fully initialized, db_password missing"
local.db = get_db(
socket=conf.db_socket,
@ -348,6 +291,9 @@ def connect_replica() -> bool:
local.primary_db = local.db
local.db = local.replica_db
if hasattr(frappe.local, "_recorder"):
frappe.local._recorder._patch_sql(local.db)
return True
@ -389,13 +335,6 @@ def setup_redis_cache_connection():
client_cache = ClientCache()
def get_traceback(with_context: bool = False) -> str:
"""Return error traceback."""
from frappe.utils import get_traceback
return get_traceback(with_context=with_context)
def errprint(msg: str) -> None:
"""Log error. This is sent back as `exc` in response.
@ -422,20 +361,6 @@ def log(msg: str) -> None:
debug_log.append(as_unicode(msg))
def create_folder(path, with_init=False):
"""Create a folder in the given path and add an `__init__.py` file (optional).
:param path: Folder path.
:param with_init: Create `__init__.py` in the new folder."""
from frappe.utils import touch_file
if not os.path.exists(path):
os.makedirs(path)
if with_init:
touch_file(os.path.join(path, "__init__.py"))
def set_user(username: str):
"""Set current user.
@ -476,138 +401,22 @@ def get_request_header(key, default=None):
return request.headers.get(key, default)
def sendmail(
recipients=None,
sender="",
subject="No Subject",
message="No Message",
as_markdown=False,
delayed=True,
reference_doctype=None,
reference_name=None,
unsubscribe_method=None,
unsubscribe_params=None,
unsubscribe_message=None,
add_unsubscribe_link=1,
attachments=None,
content=None,
doctype=None,
name=None,
reply_to=None,
queue_separately=False,
cc=None,
bcc=None,
message_id=None,
in_reply_to=None,
send_after=None,
expose_recipients=None,
send_priority=1,
communication=None,
retry=1,
now=None,
read_receipt=None,
is_notification=False,
inline_images=None,
template=None,
args=None,
header=None,
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
) -> Optional["EmailQueue"]:
"""Send email using user's default **Email Account** or global default **Email Account**.
:param recipients: List of recipients.
:param sender: Email sender. Default is current user or default outgoing account.
:param subject: Email Subject.
:param message: (or `content`) Email Content.
:param as_markdown: Convert content markdown to HTML.
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
:param send_priority: Priority for Email Queue, default 1.
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
:param reference_name: (or `name`) Append as communication to this document name.
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
:param attachments: List of attachments.
:param reply_to: Reply-To Email Address.
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
:param send_after: Send after the given datetime.
:param expose_recipients: Display all recipients in the footer message - "This email was sent to"
:param communication: Communication link to be set in Email Queue record
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
:param template: Name of html template from templates/emails folder
:param args: Arguments for rendering the template
:param header: Append header in email
:param with_container: Wraps email inside a styled container
"""
if recipients is None:
recipients = []
if cc is None:
cc = []
if bcc is None:
bcc = []
text_content = None
if template:
message, text_content = get_email_from_template(template, args)
message = content or message
if as_markdown:
from frappe.utils import md_to_html
message = md_to_html(message)
if not delayed:
now = True
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
builder = QueueBuilder(
recipients=recipients,
sender=sender,
subject=subject,
message=message,
text_content=text_content,
reference_doctype=doctype or reference_doctype,
reference_name=name or reference_name,
add_unsubscribe_link=add_unsubscribe_link,
unsubscribe_method=unsubscribe_method,
unsubscribe_params=unsubscribe_params,
unsubscribe_message=unsubscribe_message,
attachments=attachments,
reply_to=reply_to,
cc=cc,
bcc=bcc,
message_id=message_id,
in_reply_to=in_reply_to,
send_after=send_after,
expose_recipients=expose_recipients,
send_priority=send_priority,
queue_separately=queue_separately,
communication=communication,
read_receipt=read_receipt,
is_notification=is_notification,
inline_images=inline_images,
header=header,
print_letterhead=print_letterhead,
with_container=with_container,
email_read_tracker_url=email_read_tracker_url,
)
# build email queue and send the email if send_now is True.
return builder.process(send_now=now)
whitelisted: set[Callable] = set()
guest_methods: set[Callable] = set()
xss_safe_methods: set[Callable] = set()
allowed_http_methods_for_whitelisted_func: dict[Callable, list[str]] = {}
def _in_request_or_test():
"""
Internal
Used by whitelist to determine whether type hints should be validated or not
"""
return getattr(local, "request", None) or in_test
def whitelist(allow_guest=False, xss_safe=False, methods=None):
"""
Decorator for whitelisting a function and making it accessible via HTTP.
@ -631,17 +440,8 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
# validate argument types only if request is present
in_request_or_test = lambda: getattr(local, "request", None) or local.flags.in_test # noqa: E731
# get function from the unbound / bound method
# this is needed because functions can be compared, but not methods
method = None
if hasattr(fn, "__func__"):
method = validate_argument_types(fn, apply_condition=in_request_or_test)
fn = method.__func__
else:
fn = validate_argument_types(fn, apply_condition=in_request_or_test)
# validate argument types if request is present or in test context
fn = validate_argument_types(fn, apply_condition=_in_request_or_test)
whitelisted.add(fn)
allowed_http_methods_for_whitelisted_func[fn] = methods
@ -652,7 +452,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
if xss_safe:
xss_safe_methods.add(fn)
return method or fn
return fn
return innerfn
@ -662,7 +462,7 @@ def is_whitelisted(method):
is_guest = session["user"] == "Guest"
if method not in whitelisted or (is_guest and method not in guest_methods):
summary = _("You are not permitted to access this resource.")
summary = _("You are not permitted to access this resource. Login to access")
detail = _("Function {0} is not whitelisted.").format(bold(f"{method.__module__}.{method.__name__}"))
msg = f"<details><summary>{summary}</summary>{detail}</details>"
throw(msg, PermissionError, title=_("Method Not Allowed"))
@ -732,7 +532,7 @@ def only_for(roles: list[str] | tuple[str] | str, message=False):
:param roles: Permitted role(s)
"""
if local.flags.in_test or local.session.user == "Administrator":
if local.session.user == "Administrator":
return
if isinstance(roles, str):
@ -759,7 +559,7 @@ def get_domain_data(module):
else:
return _dict()
except ImportError:
if local.flags.in_test:
if in_test:
return _dict()
else:
raise
@ -839,7 +639,7 @@ def has_website_permission(doc=None, ptype="read", user=None, verbose=False, doc
if doc:
if isinstance(doc, str):
doc = get_doc(doctype, doc)
doc = get_lazy_doc(doctype, doc)
doctype = doc.doctype
@ -899,30 +699,6 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str:
return secrets.token_hex(math.ceil(length / 2))[:length]
def new_doc(
doctype: str,
*,
parent_doc: Optional["Document"] = None,
parentfield: str | None = None,
as_dict: bool = False,
**kwargs,
) -> "Document":
"""Return a new document of the given DocType with defaults set.
:param doctype: DocType of the new document.
:param parent_doc: [optional] add to parent document.
:param parentfield: [optional] add against this `parentfield`.
:param as_dict: [optional] return as dictionary instead of Document.
:param kwargs: [optional] You can specify fields as field=value pairs in function call.
"""
from frappe.model.create_new import get_new_doc
new_doc = get_new_doc(doctype, parent_doc, parentfield, as_dict=as_dict)
return new_doc.update(kwargs)
def set_value(doctype, docname, fieldname, value=None):
"""Set document value. Calls `frappe.client.set_value`"""
import frappe.client
@ -930,170 +706,6 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args: Any, **kwargs: Any) -> "Document":
"""Identical to `frappe.get_doc`, but return from cache if available."""
if (key := can_cache_doc(args)) and (doc := cache.get_value(key)):
return doc
# Not found in cache, fetch from DB
doc = get_doc(*args, **kwargs)
# Store in cache
if not key:
key = get_document_cache_key(doc.doctype, doc.name)
_set_document_in_cache(key, doc)
return doc
def _set_document_in_cache(key: str, doc: "Document") -> None:
cache.set_value(key, doc, expires_in_sec=3600)
def can_cache_doc(args) -> str | None:
"""
Determine if document should be cached based on get_doc params.
Return cache key if doc can be cached, None otherwise.
"""
if not args:
return
doctype = args[0]
name = doctype if len(args) == 1 or args[1] is None else args[1]
# Only cache if both doctype and name are strings
if isinstance(doctype, str) and isinstance(name, str):
return get_document_cache_key(doctype, name)
def get_document_cache_key(doctype: str, name: str):
return f"document_cache::{doctype}::{name}"
def clear_document_cache(doctype: str, name: str | None = None) -> None:
def clear_in_redis():
if name is not None:
cache.delete_value(get_document_cache_key(doctype, name))
else:
cache.delete_keys(get_document_cache_key(doctype, ""))
clear_in_redis()
if hasattr(db, "after_commit"):
db.after_commit.add(clear_in_redis)
db.after_rollback.add(clear_in_redis)
if doctype == "System Settings" and hasattr(local, "system_settings"):
delattr(local, "system_settings")
if doctype == "Website Settings" and hasattr(local, "website_settings"):
delattr(local, "website_settings")
def get_cached_value(
doctype: str, name: str | dict, fieldname: str | Iterable[str] = "name", as_dict: bool = False
) -> Any:
try:
doc = get_cached_doc(doctype, name)
except DoesNotExistError:
clear_last_message()
return
if isinstance(fieldname, str):
if as_dict:
throw("Cannot make dict for single fieldname")
return doc.get(fieldname)
values = [doc.get(f) for f in fieldname]
if as_dict:
return _dict(zip(fieldname, values, strict=False))
return values
_SingleDocument: TypeAlias = "Document"
_NewDocument: TypeAlias = "Document"
@overload
def get_doc(document: "Document", /) -> "Document":
pass
@overload
def get_doc(doctype: str, /) -> _SingleDocument:
"""Retrieve Single DocType from DB, doctype must be positional argument."""
pass
@overload
def get_doc(doctype: str, name: str, /, *, for_update: bool | None = None) -> "Document":
"""Retrieve DocType from DB, doctype and name must be positional argument."""
pass
@overload
def get_doc(**kwargs: dict) -> "_NewDocument":
"""Initialize document from kwargs.
Not recommended. Use `frappe.new_doc` instead."""
pass
@overload
def get_doc(documentdict: dict) -> "_NewDocument":
"""Create document from dict.
Not recommended. Use `frappe.new_doc` instead."""
pass
def get_doc(*args: Any, **kwargs: Any) -> "Document":
"""Return a `frappe.model.document.Document` object of the given type and name.
:param arg1: DocType name as string **or** document JSON.
:param arg2: [optional] Document name as string.
Examples:
# insert a new document
todo = frappe.get_doc({"doctype":"ToDo", "description": "test"})
todo.insert()
# open an existing document
todo = frappe.get_doc("ToDo", "TD0001")
"""
import frappe.model.document
return frappe.model.document.get_doc(*args, **kwargs)
def get_last_doc(
doctype,
filters: FilterSignature | None = None,
order_by="creation desc",
*,
for_update=False,
):
"""Get last created document of this type."""
d = get_all(doctype, filters=filters, limit_page_length=1, order_by=order_by, pluck="name")
if d:
return get_doc(doctype, d[0], for_update=for_update)
else:
raise DoesNotExistError(doctype=doctype)
def get_single(doctype):
"""Return a `frappe.model.document.Document` object of the given Single doctype."""
return get_doc(doctype, doctype)
def get_meta(doctype, cached=True):
"""Get `frappe.model.meta.Meta` instance of given doctype name."""
import frappe.model.meta
return frappe.model.meta.get_meta(doctype, cached=cached)
def get_meta_module(doctype):
import frappe.modules
@ -1137,11 +749,6 @@ def delete_doc(
)
def delete_doc_if_exists(doctype, name, force=0):
"""Delete document if exists."""
delete_doc(doctype, name, force=force, ignore_missing=True)
def reload_doctype(doctype, force=False, reset_permissions=False):
"""Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files."""
reload_doc(
@ -1205,7 +812,7 @@ def rename_doc(
)
def get_module(modulename: str) -> "ModuleType":
def get_module(modulename: str):
"""Return a module object for given Python module name using `importlib.import_module`."""
return importlib.import_module(modulename)
@ -1306,7 +913,7 @@ def get_installed_apps(*, _ensure_on_bench: bool = False) -> list[str]:
if not db:
connect()
installed = json.loads(db.get_global("installed_apps") or "[]")
installed = orjson.loads(db.get_global("installed_apps") or "[]")
if _ensure_on_bench:
all_apps = cache.get_value("all_apps", get_all_apps)
@ -1564,7 +1171,7 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
def make_property_setter(
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True, *, module=None
):
"""Create a new **Property Setter** (for overriding DocType and DocField properties).
@ -1603,6 +1210,7 @@ def make_property_setter(
"doctype": "Property Setter",
"doctype_or_field": args.doctype_or_field,
"doc_type": doctype,
"module": module,
"field_name": args.fieldname,
"row_name": args.row_name,
"property": args.property,
@ -1625,50 +1233,6 @@ def import_doc(path):
import_doc(path)
def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
"""No_copy fields also get copied."""
import copy
from types import MappingProxyType
from frappe.model.base_document import BaseDocument
def remove_no_copy_fields(d):
for df in d.meta.get("fields", {"no_copy": 1}):
if hasattr(d, df.fieldname):
d.set(df.fieldname, None)
fields_to_clear = ["name", "owner", "creation", "modified", "modified_by"]
if not local.flags.in_test:
fields_to_clear.append("docstatus")
if isinstance(doc, BaseDocument) or hasattr(doc, "as_dict"):
d = doc.as_dict()
elif isinstance(doc, MappingProxyType): # global test record
d = dict(doc)
else:
d = doc
newdoc = get_doc(copy.deepcopy(d))
newdoc.set("__islocal", 1)
for fieldname in [*fields_to_clear, "amended_from", "amendment_date"]:
newdoc.set(fieldname, None)
if not ignore_no_copy:
remove_no_copy_fields(newdoc)
for d in newdoc.get_all_children():
d.set("__islocal", 1)
for fieldname in fields_to_clear:
d.set(fieldname, None)
if not ignore_no_copy:
remove_no_copy_fields(d)
return newdoc
def respond_as_web_page(
title,
html,
@ -1833,7 +1397,7 @@ def get_value(*args, **kwargs):
:param as_dict: Return values as dict.
:param debug: Print query in error log.
"""
return db.get_value(*args, **kwargs)
return local.db.get_value(*args, **kwargs)
def as_json(obj: dict | list, indent=1, separators=None, ensure_ascii=True) -> str:
@ -1872,96 +1436,6 @@ def are_emails_muted():
from frappe.deprecation_dumpster import frappe_get_test_records as get_test_records
def format_value(*args, **kwargs):
"""Format value with given field properties.
:param value: Value to be formatted.
:param df: (Optional) DocField object with properties `fieldtype`, `options` etc."""
import frappe.utils.formatters
return frappe.utils.formatters.format_value(*args, **kwargs)
def format(*args, **kwargs):
"""Format value with given field properties.
:param value: Value to be formatted.
:param df: (Optional) DocField object with properties `fieldtype`, `options` etc."""
import frappe.utils.formatters
return frappe.utils.formatters.format_value(*args, **kwargs)
def attach_print(
doctype,
name,
file_name=None,
print_format=None,
style=None,
html=None,
doc=None,
lang=None,
print_letterhead=True,
password=None,
letterhead=None,
):
from frappe.translate import print_language
from frappe.utils import scrub_urls
from frappe.utils.pdf import get_pdf
print_settings = db.get_singles_dict("Print Settings")
kwargs = dict(
print_format=print_format,
style=style,
doc=doc,
no_letterhead=not print_letterhead,
letterhead=letterhead,
password=password,
)
local.flags.ignore_print_permissions = True
with print_language(lang or local.lang):
content = ""
if cint(print_settings.send_print_as_pdf):
ext = ".pdf"
kwargs["as_pdf"] = True
content = (
get_pdf(html, options={"password": password} if password else None)
if html
else get_print(doctype, name, **kwargs)
)
else:
ext = ".html"
content = html or scrub_urls(get_print(doctype, name, **kwargs)).encode("utf-8")
local.flags.ignore_print_permissions = False
if not file_name:
file_name = name
file_name = cstr(file_name).replace(" ", "").replace("/", "-") + ext
return {"fname": file_name, "fcontent": content}
def enqueue(*args, **kwargs):
"""
Enqueue method to be executed using a background worker
:param method: method string or method object
:param queue: (optional) should be either long, default or short
:param timeout: (optional) should be set according to the functions
:param event: this is passed to enable clearing of jobs from queues
:param is_async: (optional) if is_async=False, the method is executed immediately, else via a worker
:param job_name: (optional) can be used to name an enqueue call, which can be used to prevent duplicate calls
:param kwargs: keyword arguments to be passed to the method
"""
import frappe.utils.background_jobs
return frappe.utils.background_jobs.enqueue(*args, **kwargs)
def task(**task_kwargs):
def decorator_task(f):
f.enqueue = lambda **fun_kwargs: enqueue(f, **task_kwargs, **fun_kwargs)
@ -1970,22 +1444,6 @@ def task(**task_kwargs):
return decorator_task
def enqueue_doc(*args, **kwargs):
"""
Enqueue method to be executed using a background worker
:param doctype: DocType of the document on which you want to run the event
:param name: Name of the document on which you want to run the event
:param method: method string or method object
:param queue: (optional) should be either long, default or short
:param timeout: (optional) should be set according to the functions
:param kwargs: keyword arguments to be passed to the method
"""
import frappe.utils.background_jobs
return frappe.utils.background_jobs.enqueue_doc(*args, **kwargs)
def get_doctype_app(doctype):
def _get_doctype_app():
doctype_module = local.db.get_value("DocType", doctype, "module")
@ -2014,12 +1472,30 @@ def logger(
)
def get_desk_link(doctype, name):
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())
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {title_local}</a>'
return html.format(doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title))
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}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
else:
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,
encoded_name=encoded_name,
doctype_local=_(doctype),
title_local=_(title),
target=target_attr,
)
def get_website_settings(key):
@ -2039,6 +1515,24 @@ def get_active_domains():
return get_active_domains()
@request_cache
def is_setup_complete():
setup_complete = False
if not frappe.db.table_exists("Installed Application"):
return setup_complete
if all(
frappe.get_all(
"Installed Application",
{"app_name": ("in", ["frappe", "erpnext"])},
pluck="is_setup_complete",
)
):
setup_complete = True
return setup_complete
@whitelist(allow_guest=True)
def ping():
return "pong"
@ -2075,10 +1569,33 @@ import frappe._optimizations
from frappe.cache_manager import clear_cache, reset_metadata_version
from frappe.config import get_common_site_config, get_conf, get_site_config
from frappe.core.doctype.system_settings.system_settings import get_system_settings
from frappe.model.document import (
get_doc,
get_lazy_doc,
copy_doc,
new_doc,
get_cached_doc,
can_cache_doc,
get_document_cache_key,
clear_document_cache,
get_cached_value,
get_single_value,
get_last_doc,
get_single,
_set_document_in_cache,
)
from frappe.model.meta import get_meta
from frappe.realtime import publish_progress, publish_realtime
from frappe.utils import mock, parse_json, safe_eval
from frappe.utils import get_traceback, mock, parse_json, safe_eval, create_folder
from frappe.utils.background_jobs import enqueue, enqueue_doc
from frappe.utils.error import log_error
from frappe.utils.print_utils import get_print
from frappe.utils.formatters import format_value
from frappe.utils.print_utils import get_print, attach_print
from frappe.email import sendmail
# for backwards compatibility
format = format_value
delete_doc_if_exists = delete_doc
frappe._optimizations.optimize_all()
frappe._optimizations.register_fault_handler()

View file

@ -93,9 +93,7 @@ def freeze_gc():
def optimize_for_gil_contention():
from frappe.utils import sbool
if not bool(sbool(os.environ.get("FRAPPE_PERF_PIN_WORKERS", True))):
if not os.environ.get("FRAPPE_PERF_PIN_WORKERS"):
return
if "gunicorn" not in str(sys.argv[0]):

View file

@ -41,6 +41,17 @@ def handle(request: Request):
`DELETE` will delete
"""
if frappe.get_system_settings("log_api_requests"):
doc = frappe.get_doc(
{
"doctype": "API Request Log",
"path": request.path,
"user": frappe.session.user,
"method": request.method,
}
)
doc.deferred_insert()
try:
endpoint, arguments = API_URL_MAP.bind_to_environ(request.environ).match()
except NotFound: # Wrap 404 - backward compatiblity

View file

@ -15,7 +15,7 @@ from werkzeug.routing import Rule
import frappe
import frappe.client
from frappe import _, get_newargs, is_whitelisted
from frappe import _, cint, cstr, get_newargs, is_whitelisted
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.handler import is_valid_http_method, run_server_script, upload_file
@ -65,17 +65,99 @@ def read_doc(doctype: str, name: str):
doc = frappe.get_doc(doctype, name)
doc.check_permission("read")
doc.apply_fieldlevel_read_permissions()
return doc
_doc = doc.as_dict()
for key in _doc:
df = doc.meta.get_field(key)
if df and df.fieldtype == "Link" and isinstance(_doc.get(key), int):
_doc[key] = cstr(_doc.get(key))
return _doc
def document_list(doctype: str):
if frappe.form_dict.get("fields"):
frappe.form_dict["fields"] = json.loads(frappe.form_dict["fields"])
def document_list(doctype: str) -> list[dict[str, Any]]:
"""
GET /api/v2/document/<doctype>?fields=[...],filters={...},...
# set limit of records for frappe.get_list
frappe.form_dict.limit_page_length = frappe.form_dict.limit or 20
# evaluate frappe.get_list
return frappe.call(frappe.client.get_list, doctype, **frappe.form_dict)
REST API endpoint for fetching doctype records
Args:
doctype: DocType name
Query Parameters (accessible via frappe.form_dict):
fields: JSON string of field names to fetch
filters: JSON string of filters to apply
order_by: Order by field
start: Starting offset for pagination (default: 0)
limit: Maximum number of records to fetch (default: 20)
group_by: Group by field
as_dict: Return results as dictionary (default: True)
Response:
frappe.response["data"]: List of document records as dicts
frappe.response["has_next_page"]: Indicates if more pages are available
Controller Customization:
Doctype controllers can customize queries by implementing a static get_list(query) method
that receives a QueryBuilder object and returns a modified QueryBuilder.
Example:
class Project(Document):
@staticmethod
def get_list(query):
Project = frappe.qb.DocType("Project")
if user_has_role("Project Owner"):
query = query.where(Project.owner == frappe.session.user)
else:
query = query.where(Project.is_private == 0)
return query
"""
from frappe.model.base_document import get_controller
args = frappe.form_dict
fields: list | None = frappe.parse_json(args.get("fields", None))
filters: dict | None = frappe.parse_json(args.get("filters", None))
order_by: str | None = args.get("order_by", None)
start: int = cint(args.get("start", 0))
limit: int = cint(args.get("limit", 20))
group_by: str | None = args.get("group_by", None)
debug: bool = args.get("debug", False)
as_dict: bool = args.get("as_dict", True)
query = frappe.qb.get_query(
table=doctype,
fields=fields,
filters=filters,
order_by=order_by,
offset=start,
limit=limit + 1, # Fetch one extra to check if there's a next page
group_by=group_by,
ignore_permissions=False,
)
# Check if the doctype controller has a static get_list method
controller = get_controller(doctype)
if hasattr(controller, "get_list"):
try:
return_value = controller.get_list(query)
if return_value is not None:
# Validate that the returned value has a run method (is a QueryBuilder-like object)
if not hasattr(return_value, "run"):
frappe.throw(
_(
"Custom get_list method for {0} must return a QueryBuilder object or None, got {1}"
).format(doctype, type(return_value).__name__)
)
query = return_value
except Exception as e:
frappe.throw(_("Error in {0}.get_list: {1}").format(doctype, str(e)))
data = query.run(as_dict=as_dict, debug=debug)
frappe.response["has_next_page"] = len(data) > limit
return data[:limit]
def count(doctype: str) -> int:
@ -89,7 +171,13 @@ def count(doctype: str) -> int:
def create_doc(doctype: str):
data = frappe.form_dict
data.pop("doctype", None)
return frappe.new_doc(doctype, **data).insert()
doc = frappe.new_doc(doctype, **data)
if (name := data.get("name")) and isinstance(name, str | int):
doc.flags.name_set = True
return doc.insert().as_dict()
def copy_doc(doctype: str, name: str, ignore_no_copy: bool = True):
@ -110,12 +198,13 @@ def update_doc(doctype: str, name: str):
data.pop("flags", None)
doc.update(data)
doc.save()
doc.apply_fieldlevel_read_permissions()
# check for child table doctype
if doc.get("parenttype"):
frappe.get_doc(doc.parenttype, doc.parent).save()
return doc
return doc.as_dict()
def delete_doc(doctype: str, name: str):
@ -141,7 +230,9 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None):
doc.is_whitelisted(method)
doc.check_permission(PERMISSION_MAP[frappe.request.method])
return doc.run_method(method, **frappe.form_dict)
result = doc.run_method(method, **frappe.form_dict)
frappe.response.docs.append(doc.as_dict())
return result
def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):

View file

@ -5,6 +5,7 @@ import functools
import logging
import os
import orjson
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.middleware.profiler import ProfilerMiddleware
from werkzeug.middleware.proxy_fix import ProxyFix
@ -21,6 +22,7 @@ import frappe.recorder
import frappe.utils.response
from frappe import _
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, check_request_ip, validate_auth
from frappe.integrations.oauth2 import get_resource_url, handle_wellknown, is_oauth_metadata_enabled
from frappe.middlewares import StaticDataMiddleware
from frappe.permissions import handle_does_not_exist_error
from frappe.utils import CallbackManager, cint, get_site_name
@ -65,6 +67,11 @@ import frappe.website.website_generator # web page doctypes
# end: module pre-loading
# better werkzeug default
# this is necessary because frappe desk sends most requests as form data
# and some of them can exceed werkzeug's default limit of 500kb
Request.max_form_memory_size = None
def after_response_wrapper(app):
"""Wrap a WSGI application to call after_response hooks after we have responded.
@ -92,8 +99,6 @@ def application(request: Request):
response = None
try:
rollback = True
init_request(request)
validate_auth()
@ -121,29 +126,28 @@ def application(request: Request):
elif request.path.startswith("/private/files/"):
response = frappe.utils.response.download_private_file(request.path)
elif request.path.startswith("/.well-known/") and request.method == "GET":
response = handle_wellknown(request.path)
elif request.method in ("GET", "HEAD", "POST"):
response = get_response()
else:
raise NotFound
except HTTPException as e:
return e
except Exception as e:
response = handle_exception(e)
response = e.get_response(request.environ) if isinstance(e, HTTPException) else handle_exception(e)
if db := getattr(frappe.local, "db", None):
db.rollback(chain=True)
else:
rollback = sync_database(rollback)
sync_database()
finally:
# Important note:
# this function *must* always return a response, hence any exception thrown outside of
# try..catch block like this finally block needs to be handled appropriately.
if rollback and request.method in UNSAFE_HTTP_METHODS and frappe.db:
frappe.db.rollback()
try:
run_after_request_hooks(request, response)
except Exception:
@ -177,14 +181,13 @@ def init_request(request):
# site does not exist
raise NotFound
frappe.connect(set_admin_as_user=False)
if frappe.local.conf.maintenance_mode:
frappe.connect()
if frappe.local.conf.allow_reads_during_maintenance:
setup_read_only_mode()
else:
raise frappe.SessionStopped("Session Stopped")
else:
frappe.connect(set_admin_as_user=False)
if request.path.startswith("/api/method/upload_file"):
from frappe.core.api.file import get_max_file_size
@ -256,6 +259,9 @@ def process_response(response: Response):
if hasattr(frappe.local, "conf"):
set_cors_headers(response)
if response.status_code in (401, 403) and is_oauth_metadata_enabled("resource"):
set_authenticate_headers(response)
# Update custom headers added during request processing
response.headers.update(frappe.local.response_headers)
@ -269,10 +275,12 @@ def process_response(response: Response):
def set_cors_headers(response):
allowed_origins = frappe.conf.allow_cors
if hasattr(frappe.local, "allow_cors"):
allowed_origins = frappe.local.allow_cors
if not (
(allowed_origins := frappe.conf.allow_cors)
and (request := frappe.local.request)
and (origin := request.headers.get("Origin"))
allowed_origins and (request := frappe.local.request) and (origin := request.headers.get("Origin"))
):
return
@ -303,12 +311,17 @@ def set_cors_headers(response):
response.headers.update(cors_headers)
def make_form_dict(request: Request):
import json
def set_authenticate_headers(response: Response):
headers = {
"WWW-Authenticate": f'Bearer resource_metadata="{get_resource_url()}/.well-known/oauth-protected-resource"'
}
response.headers.update(headers)
def make_form_dict(request: Request):
request_data = request.get_data(as_text=True)
if request_data and request.is_json:
args = json.loads(request_data)
args = orjson.loads(request_data)
else:
args = {}
args.update(request.args or {})
@ -397,21 +410,21 @@ def handle_exception(e):
return response
def sync_database(rollback: bool) -> bool:
def sync_database():
db = getattr(frappe.local, "db", None)
if not db:
# db isn't initialized, can't commit or rollback
return
# if HTTP method would change server state, commit if necessary
if frappe.db and (frappe.local.flags.commit or frappe.local.request.method in UNSAFE_HTTP_METHODS):
frappe.db.commit()
rollback = False
elif frappe.db:
frappe.db.rollback()
rollback = False
if frappe.local.request.method in UNSAFE_HTTP_METHODS or frappe.local.flags.commit:
db.commit(chain=True)
else:
db.rollback(chain=True)
# update session
if session := getattr(frappe.local, "session_obj", None):
if session.update():
rollback = False
return rollback
frappe.request.after_response.add(session.update)
# Always initialize sentry SDK if the DSN is sent

View file

@ -5,6 +5,14 @@ import re
import frappe
from frappe import _
from frappe.core.doctype.installed_applications.installed_applications import (
get_apps_with_incomplete_dependencies,
get_setup_wizard_completed_apps,
get_setup_wizard_not_required_apps,
)
# check if route is /app or /app/* and not /app1 or /app1/*
DESK_APP_PATTERN = re.compile(r"^/app(/.*)?$")
@frappe.whitelist()
@ -12,23 +20,33 @@ def get_apps():
apps = frappe.get_installed_apps()
app_list = []
for app in apps:
if (
app not in get_setup_wizard_completed_apps()
and app not in get_setup_wizard_not_required_apps()
and "System Manager" not in frappe.get_roles()
):
continue
if app == "frappe":
continue
app_details = frappe.get_hooks("add_to_apps_screen", app_name=app)
if not len(app_details):
continue
for app_detail in app_details:
has_permission_path = app_detail.get("has_permission")
if has_permission_path and not frappe.get_attr(has_permission_path)():
continue
app_list.append(
{
"name": app,
"logo": app_detail.get("logo"),
"title": _(app_detail.get("title")),
"route": app_detail.get("route"),
}
)
try:
has_permission_path = app_detail.get("has_permission")
if has_permission_path and not frappe.get_attr(has_permission_path)():
continue
app_list.append(
{
"name": app,
"logo": app_detail.get("logo"),
"title": _(app_detail.get("title")),
"route": app_detail.get("route"),
}
)
except Exception:
frappe.log_error(f"Failed to call has_permission hook ({has_permission_path}) for {app}")
return app_list
@ -40,10 +58,8 @@ def get_route(app_name):
def is_desk_apps(apps):
for app in apps:
# check if route is /app or /app/* and not /app1 or /app1/*
pattern = r"^/app(/.*)?$"
route = app.get("route")
if route and not re.match(pattern, route):
if route and not re.match(DESK_APP_PATTERN, route):
return False
return True
@ -56,7 +72,7 @@ def get_default_path():
return None
system_default_app = frappe.get_system_settings("default_app")
user_default_app = frappe.db.get_value("User", frappe.session.user, "default_app")
user_default_app = frappe.get_cached_value("User", frappe.session.user, "default_app")
if system_default_app and not user_default_app:
return get_route(system_default_app)
elif user_default_app:
@ -75,3 +91,24 @@ def set_app_as_default(app_name):
frappe.db.set_value("User", frappe.session.user, "default_app", "")
else:
frappe.db.set_value("User", frappe.session.user, "default_app", app_name)
@frappe.whitelist()
def get_incomplete_setup_route(current_app, app_route):
pending_apps = get_apps_with_incomplete_dependencies(current_app)
if not pending_apps:
return app_route
for app in pending_apps:
if app == "frappe":
return "app"
app_details = frappe.get_hooks("add_to_apps_screen", app_name=app)
if not app_details:
continue
if route := app_details[0].get("route"):
return route
return app_route

View file

@ -131,7 +131,7 @@ class LoginManager:
self.make_session(resume=True)
self.get_user_info()
self.set_user_info(resume=True)
except AttributeError:
except (AttributeError, frappe.DoesNotExistError):
self.user = "Guest"
self.get_user_info()
self.make_session()
@ -487,7 +487,10 @@ def validate_ip_address(user):
if bypass_restrict_ip_check:
return
frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError)
frappe.throw(
_("Access not allowed from this IP Address") + f": {frappe.local.request_ip}",
frappe.AuthenticationError,
)
def get_login_attempt_tracker(key: str, raise_locked_exception: bool = True):
@ -701,17 +704,22 @@ def validate_auth_via_api_keys(authorization_header):
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
if not api_key or not api_secret:
raise frappe.AuthenticationError
doctype = frappe_authorization_source or "User"
doc = frappe.db.get_value(doctype=doctype, filters={"api_key": api_key}, fieldname=["name"])
if not doc:
docname = frappe.db.get_value(
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
)
if not docname:
raise frappe.AuthenticationError
form_dict = frappe.local.form_dict
doc_secret = get_decrypted_password(doctype, doc, fieldname="api_secret")
if api_secret == doc_secret:
doc_secret = get_decrypted_password(doctype, docname, fieldname="api_secret", raise_exception=False)
if doc_secret and api_secret == doc_secret:
if doctype == "User":
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
else:
user = frappe.db.get_value(doctype, doc, "user")
user = frappe.db.get_value(doctype, docname, "user")
if frappe.local.login_manager.user in ("", "Guest"):
frappe.set_user(user)
frappe.local.form_dict = form_dict

View file

@ -192,7 +192,7 @@ def get_assignments(doc) -> list[dict]:
"ToDo",
fields=["name", "assignment_rule"],
filters=dict(
reference_type=doc.get("doctype"), reference_name=doc.get("name"), status=("!=", "Cancelled")
reference_type=doc.get("doctype"), reference_name=str(doc.get("name")), status=("!=", "Cancelled")
),
limit=5,
)
@ -220,7 +220,7 @@ def reopen_closed_assignment(doc):
"ToDo",
filters={
"reference_type": doc.doctype,
"reference_name": doc.name,
"reference_name": str(doc.name),
"status": "Closed",
},
pluck="name",
@ -312,7 +312,7 @@ def apply(doc=None, method=None, doctype=None, name=None):
"ToDo",
filters={
"reference_type": doc.doctype,
"reference_name": doc.name,
"reference_name": str(doc.name),
},
pluck="name",
)
@ -367,7 +367,7 @@ def update_due_date(doc, state=None):
filters={
"assignment_rule": rule.get("name"),
"reference_type": doc.doctype,
"reference_name": doc.name,
"reference_name": str(doc.name),
"status": "Open",
},
pluck="name",

View file

@ -2,21 +2,12 @@
# License: MIT. See LICENSE
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.tests.utils import make_test_records
TEST_DOCTYPE = "Assignment Test"
class UnitTestAssignmentRule(UnitTestCase):
"""
Unit tests for AssignmentRule.
Use this class for testing individual functions and methods.
"""
pass
class TestAutoAssign(IntegrationTestCase):
@classmethod
def setUpClass(cls):

View file

@ -71,7 +71,9 @@ frappe.ui.form.on("Auto Repeat", {
frappe.model.with_doc("Email Template", frm.doc.template, () => {
let email_template = frappe.get_doc("Email Template", frm.doc.template);
frm.set_value("subject", email_template.subject);
frm.set_value("message", email_template.response);
let message_value = email_template.response;
if (email_template.use_html) message_value = email_template.response_html;
frm.set_value("message", message_value);
frm.refresh_field("subject");
frm.refresh_field("message");
});

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,10 +264,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "reference_document",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "reference_document",
"track_changes": 1
}
}

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
@ -86,10 +91,9 @@ class AutoRepeat(Document):
validate_template(self.message or "")
def before_insert(self):
if not frappe.flags.in_test:
start_date = getdate(self.start_date)
today_date = getdate(today())
if start_date <= today_date:
if not frappe.in_test:
today_date = getdate()
if getdate(self.start_date) < today_date:
self.start_date = today_date
def on_update(self):
@ -112,7 +116,7 @@ class AutoRepeat(Document):
frappe.db.set_value(self.reference_doctype, self.reference_document, "auto_repeat", "")
def validate_reference_doctype(self):
if frappe.flags.in_test or frappe.flags.in_patch:
if frappe.in_test or frappe.flags.in_patch:
return
if not frappe.get_meta(self.reference_doctype).allow_auto_repeat:
frappe.throw(
@ -134,14 +138,18 @@ class AutoRepeat(Document):
return
if self.end_date:
end_date = getdate(self.end_date)
self.validate_from_to_dates("start_date", "end_date")
if self.end_date == self.start_date:
frappe.throw(
_("{0} should not be same as {1}").format(
frappe.bold(_("End Date")), frappe.bold(_("Start Date"))
if end_date == getdate():
frappe.throw(_("End Date cannot be today."))
if end_date == getdate(self.start_date):
frappe.throw(
_("{0} should not be same as {1}").format(
frappe.bold(_("End Date")), frappe.bold(_("Start Date"))
)
)
)
def validate_email_id(self):
if self.notify_by_email:
@ -219,9 +227,16 @@ class AutoRepeat(Document):
def create_documents(self):
try:
new_doc = self.make_new_document()
if self.generate_separate_documents_for_each_assignee and self.assignee:
new_docs = self.make_new_documents()
else:
new_docs = self.make_new_document([assignee.user for assignee in self.assignee])
if self.notify_by_email and self.recipients:
self.send_notification(new_doc)
if isinstance(new_docs, list):
for new_doc in new_docs:
self.send_notification(new_doc)
else:
self.send_notification(new_docs)
except Exception:
error_log = self.log_error(
_("Auto repeat failed. Please enable auto repeat after fixing the issues.")
@ -229,15 +244,34 @@ class AutoRepeat(Document):
self.disable_auto_repeat()
if self.reference_document and not frappe.flags.in_test:
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)
new_doc.flags.updater_reference = {
"doctype": self.doctype,
"docname": self.name,
"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()
@ -343,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
@ -483,9 +519,7 @@ def make_auto_repeat_entry():
if not jobs or enqueued_method not in jobs[frappe.local.site]:
date = getdate(today())
data = get_auto_repeat_entries(date)
frappe.enqueue(enqueued_method, data=data)
# Set auto-repeat to complete when all auto-repeats are added to the queue
set_auto_repeat_as_completed(data)
frappe.enqueue(enqueued_method, data=data, queue="long")
def create_repeated_entries(data):
@ -501,6 +535,10 @@ def create_repeated_entries(data):
if schedule_date and not doc.disabled:
frappe.db.set_value("Auto Repeat", doc.name, "next_schedule_date", schedule_date)
if doc.is_completed():
doc.status = "Completed"
doc.save()
def get_auto_repeat_entries(date=None):
if not date:
@ -517,14 +555,6 @@ def get_auto_repeat_entries(date=None):
return query.run(as_dict=1)
def set_auto_repeat_as_completed(auto_repeat):
for entry in auto_repeat:
doc = frappe.get_doc("Auto Repeat", entry.name)
if doc.is_completed():
doc.status = "Completed"
doc.save()
@frappe.whitelist()
def make_auto_repeat(doctype, docname, frequency="Daily", start_date=None, end_date=None):
if not start_date:

View file

@ -9,7 +9,7 @@ from frappe.automation.doctype.auto_repeat.auto_repeat import (
week_map,
)
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, add_months, getdate, today
if TYPE_CHECKING:
@ -32,15 +32,6 @@ def add_custom_fields() -> "CustomField":
)
class UnitTestAutoRepeat(UnitTestCase):
"""
Unit tests for AutoRepeat.
Use this class for testing individual functions and methods.
"""
pass
class TestAutoRepeat(IntegrationTestCase):
@classmethod
def setUpClass(cls):
@ -94,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"
@ -230,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

@ -1,11 +1,11 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class NewsletterAttachment(Document):
class AutoRepeatUser(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@ -14,10 +14,10 @@ class NewsletterAttachment(Document):
if TYPE_CHECKING:
from frappe.types import DF
attachment: DF.Attach
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
user: DF.Link
# end: auto-generated types
pass

View file

@ -1,16 +1,7 @@
# Copyright (c) 2019, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestMilestone(UnitTestCase):
"""
Unit tests for Milestone.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestMilestone(IntegrationTestCase):

View file

@ -2,16 +2,7 @@
# License: MIT. See LICENSE
import frappe
import frappe.cache_manager
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestMilestoneTracker(UnitTestCase):
"""
Unit tests for MilestoneTracker.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestMilestoneTracker(IntegrationTestCase):

View file

@ -4,19 +4,10 @@
import frappe
from frappe.automation.doctype.reminder.reminder import create_new_reminder, send_reminders
from frappe.desk.doctype.notification_log.notification_log import get_notification_logs
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.utils import add_to_date, now_datetime
class UnitTestReminder(UnitTestCase):
"""
Unit tests for Reminder.
Use this class for testing individual functions and methods.
"""
pass
class TestReminder(IntegrationTestCase):
def test_reminder(self):
description = "TEST_REMINDER"

View file

@ -1,6 +1,6 @@
{
"charts": [],
"content": "[{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"EgtURZsoiF\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"wE9n7TIrAc\",\"type\":\"card\",\"data\":{\"card_name\":\"Alerts and Notifications\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"3imoh2oqsJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}},{\"id\":\"O7jrc2YQTN\",\"type\":\"card\",\"data\":{\"card_name\":\"Newsletter\",\"col\":4}}]",
"content": "[{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"EgtURZsoiF\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"wE9n7TIrAc\",\"type\":\"card\",\"data\":{\"card_name\":\"Alerts and Notifications\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"3imoh2oqsJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}}]",
"creation": "2020-03-02 14:53:24.980279",
"custom_blocks": [],
"docstatus": 0,
@ -105,74 +105,6 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email",
"link_count": 3,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
"link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
"link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
"link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
"link_count": 2,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
"link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
"link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@ -320,9 +252,58 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email",
"link_count": 4,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
"link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
"link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
"link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
"link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
"modified": "2024-09-03 21:54:05.403066",
"modified": "2025-06-27 11:39:44.392114",
"modified_by": "Administrator",
"module": "Automation",
"name": "Tools",

View file

@ -9,6 +9,9 @@ import os
import frappe
import frappe.defaults
import frappe.desk.desk_page
from frappe.core.doctype.installed_applications.installed_applications import (
get_setup_wizard_completed_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
from frappe.desk.doctype.form_tour.form_tour import get_onboarding_ui_tours
@ -21,10 +24,6 @@ from frappe.permissions import has_permission
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery
from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points
from frappe.social.doctype.energy_point_settings.energy_point_settings import (
is_energy_point_enabled,
)
from frappe.utils import add_user_info, cstr, get_system_timezone
from frappe.utils.change_log import get_versions
from frappe.utils.frappecloud import on_frappecloud
@ -46,6 +45,8 @@ def get_bootinfo():
# system info
bootinfo.sitename = frappe.local.site
bootinfo.sysdefaults = frappe.defaults.get_defaults()
bootinfo.sysdefaults["setup_complete"] = frappe.is_setup_complete()
bootinfo.server_date = frappe.utils.nowdate()
if frappe.session["user"] != "Guest":
@ -99,10 +100,7 @@ def get_bootinfo():
bootinfo.lang_dict = get_lang_dict()
bootinfo.success_action = get_success_action()
bootinfo.update(get_email_accounts(user=frappe.session.user))
bootinfo.energy_points_enabled = is_energy_point_enabled()
bootinfo.website_tracking_enabled = is_tracking_enabled()
bootinfo.sms_gateway_enabled = bool(frappe.db.get_single_value("SMS Settings", "sms_gateway_url"))
bootinfo.points = get_energy_points(frappe.session.user)
bootinfo.frequently_visited_links = frequently_visited_links()
bootinfo.link_preview_doctypes = get_link_preview_doctypes()
bootinfo.additional_filters_config = get_additional_filters_from_hooks()
@ -121,6 +119,7 @@ def get_bootinfo():
if sentry_dsn := get_sentry_dsn():
bootinfo.sentry_dsn = sentry_dsn
bootinfo.setup_wizard_completed_apps = get_setup_wizard_completed_apps() or []
return bootinfo
@ -352,7 +351,7 @@ def add_home_page(bootinfo, docs):
return
home_page = frappe.db.get_default("desktop:home_page")
if home_page == "setup-wizard":
if not frappe.is_setup_complete():
bootinfo.setup_wizard_requires = frappe.get_hooks("setup_wizard_requires")
try:

View file

@ -52,7 +52,7 @@ def build_missing_files():
folder = os.path.join(sites_path, "assets", "frappe", "dist", type)
current_asset_files.extend(os.listdir(folder))
development = frappe.local.conf.developer_mode or frappe.local.dev_server
development = frappe.local.conf.developer_mode or frappe._dev_server
build_mode = "development" if development else "production"
assets_json = frappe.read_file("assets/assets.json")

View file

@ -6,7 +6,6 @@ import frappe
common_default_keys = ["__default", "__global"]
doctypes_for_mapping = {
"Energy Point Rule",
"Assignment Rule",
"Milestone Tracker",
"Document Naming Rule",
@ -120,6 +119,7 @@ def clear_defaults_cache(user=None):
def clear_doctype_cache(doctype=None):
clear_controller_cache(doctype)
frappe.client_cache.erase_persistent_caches(doctype=doctype)
_clear_doctype_cache_from_redis(doctype)
if hasattr(frappe.db, "after_commit"):
@ -173,14 +173,18 @@ def _clear_doctype_cache_from_redis(doctype: str | None = None):
frappe.cache.delete_value(to_del)
def clear_controller_cache(doctype=None):
def clear_controller_cache(doctype=None, *, site=None):
if not doctype:
frappe.controllers.pop(frappe.local.site, None)
frappe.controllers.pop(site or frappe.local.site, None)
frappe.lazy_controllers.pop(site or frappe.local.site, None)
return
if site_controllers := frappe.controllers.get(frappe.local.site):
if site_controllers := frappe.controllers.get(site or frappe.local.site):
site_controllers.pop(doctype, None)
if lazy_site_controllers := frappe.lazy_controllers.get(site or frappe.local.site):
lazy_site_controllers.pop(doctype, None)
def get_doctype_map(doctype, name, filters=None, order_by=None):
return frappe.client_cache.get_value(
@ -203,13 +207,24 @@ def build_table_count_cache():
):
return
table_name = frappe.qb.Field("table_name").as_("name")
table_rows = frappe.qb.Field("table_rows").as_("count")
information_schema = frappe.qb.Schema("information_schema")
if frappe.db.db_type != "sqlite":
table_name = frappe.qb.Field("table_name").as_("name")
table_rows = frappe.qb.Field("table_rows").as_("count")
information_schema = frappe.qb.Schema("information_schema")
data = (frappe.qb.from_(information_schema.tables).select(table_name, table_rows)).run(as_dict=True)
counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data}
frappe.cache.set_value("information_schema:counts", counts)
data = (frappe.qb.from_(information_schema.tables).select(table_name, table_rows)).run(as_dict=True)
counts = {d.get("name").replace("tab", "", 1): d.get("count", None) for d in data}
frappe.cache.set_value("information_schema:counts", counts)
else:
counts = {}
name = frappe.qb.Field("name")
type = frappe.qb.Field("type")
sqlite_master = frappe.qb.Schema("sqlite_master")
data = frappe.qb.from_(sqlite_master).select(name).where(type == "table").run(as_dict=True)
for table in data:
count = frappe.db.sql(f"SELECT COUNT(*) FROM `{table.name}`")[0][0]
counts[table.name.replace("tab", "", 1)] = count
frappe.cache.set_value("information_schema:counts", counts)
return counts

View file

@ -295,7 +295,7 @@ def bulk_update(docs):
@frappe.whitelist()
def has_permission(doctype, docname, perm_type="read"):
def has_permission(doctype: str, docname: str, perm_type: str = "read"):
"""Return a JSON with data whether the document has the requested permission.
:param doctype: DocType of the document to be checked
@ -306,18 +306,18 @@ def has_permission(doctype, docname, perm_type="read"):
@frappe.whitelist()
def get_doc_permissions(doctype, docname):
def get_doc_permissions(doctype: str, docname: str):
"""Return an evaluated document permissions dict like `{"read":1, "write":1}`.
:param doctype: DocType of the document to be evaluated
:param docname: `name` of the document to be evaluated
"""
doc = frappe.get_doc(doctype, docname)
doc = frappe.get_lazy_doc(doctype, docname)
return {"permissions": frappe.permissions.get_doc_permissions(doc)}
@frappe.whitelist()
def get_password(doctype, name, fieldname):
def get_password(doctype: str, name: str, fieldname: str):
"""Return a password type property. Only applicable for System Managers
:param doctype: DocType of the document that holds the password
@ -325,7 +325,7 @@ def get_password(doctype, name, fieldname):
:param fieldname: `fieldname` of the password property
"""
frappe.only_for("System Manager")
return frappe.get_doc(doctype, name).get_password(fieldname)
return frappe.get_lazy_doc(doctype, name).get_password(fieldname)
from frappe.deprecation_dumpster import get_js as _get_js
@ -361,7 +361,7 @@ def attach_file(
:param is_private: Attach file as private file (1 or 0)
:param docfield: file to attach to (optional)"""
doc = frappe.get_doc(doctype, docname)
doc = frappe.get_lazy_doc(doctype, docname)
doc.check_permission()
file = frappe.get_doc(
@ -387,7 +387,7 @@ def attach_file(
@frappe.whitelist()
@http_cache(max_age=10 * 60)
def is_document_amended(doctype, docname):
def is_document_amended(doctype: str, docname: str):
if frappe.permissions.has_permission(doctype):
try:
return frappe.db.exists(doctype, {"amended_from": docname})

View file

@ -22,8 +22,8 @@ from frappe.utils.bench_helper import CliCtxObj
@click.option(
"--db-type",
default="mariadb",
type=click.Choice(["mariadb", "postgres"]),
help='Optional "postgres" or "mariadb". Default is "mariadb"',
type=click.Choice(["mariadb", "postgres", "sqlite"]),
help='Optional "sqlite", "postgres" or "mariadb". Default is "mariadb"',
)
@click.option("--db-host", help="Database Host")
@click.option("--db-port", type=int, help="Database Port")
@ -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")
@ -379,6 +379,11 @@ def partial_restore(context: CliCtxObj, sql_file_path, verbose, encryption_key=N
verbose = context.verbose or verbose
frappe.init(site)
frappe.connect()
if frappe.conf.db_type == "sqlite":
click.secho("Partial restore is not supported for SQLite databases", fg="red")
sys.exit(1)
err, out = frappe.utils.execute_in_shell(f"file {sql_file_path}", check_exit_code=True)
if err:
click.secho("Failed to detect type of backup file", fg="red")
@ -686,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
@ -696,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

@ -304,6 +304,10 @@ class TestCommands(BaseTestCommands):
self.execute("bench --site {test_site} restore {database}", site_data)
self.assertEqual(self.returncode, 1)
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_partial_restore(self):
_now = now()
for num in range(10):
@ -330,6 +334,10 @@ class TestCommands(BaseTestCommands):
self.assertEqual(self.returncode, 0)
self.assertEqual(frappe.db.count("ToDo"), todo_count)
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_recorder(self):
frappe.recorder.stop()
@ -528,6 +536,10 @@ class TestCommands(BaseTestCommands):
self.assertEqual(conf[key], value)
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_different_db_username(self):
site = frappe.generate_hash()
user = "".join(secrets.choice(string.ascii_letters) for _ in range(8))
@ -565,6 +577,10 @@ class TestCommands(BaseTestCommands):
)
self.assertEqual(self.returncode, 0)
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_existing_db_username(self):
site = frappe.generate_hash()
user = "".join(secrets.choice(string.ascii_letters) for _ in range(8))
@ -687,6 +703,10 @@ class TestBackups(BaseTestCommands):
)
self.assertEqual(self.returncode, 0)
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_backup_fails_with_exit_code(self):
"""Provide incorrect options to check if exit code is 1"""
odb = BackupGenerator(
@ -778,6 +798,10 @@ class TestBackups(BaseTestCommands):
self.execute("bench --site {site} backup --verbose")
self.assertEqual(self.returncode, 0)
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_backup_only_specific_doctypes(self):
"""Take a backup with (include) backup options set in the site config `frappe.conf.backup.includes`"""
self.execute(
@ -789,6 +813,10 @@ class TestBackups(BaseTestCommands):
database = fetch_latest_backups(partial=True)["database"]
self.assertEqual([], missing_in_backup(self.backup_map["includes"]["includes"], database))
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_backup_excluding_specific_doctypes(self):
"""Take a backup with (exclude) backup options set (`frappe.conf.backup.excludes`, `--exclude`)"""
# test 1: take a backup with frappe.conf.backup.excludes
@ -811,6 +839,10 @@ class TestBackups(BaseTestCommands):
database = fetch_latest_backups(partial=True)["database"]
self.assertFalse(exists_in_backup(self.backup_map["excludes"]["excludes"], database))
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_selective_backup_priority_resolution(self):
"""Take a backup with conflicting backup options set (`frappe.conf.excludes`, `--include`)"""
self.execute(
@ -821,6 +853,10 @@ class TestBackups(BaseTestCommands):
database = fetch_latest_backups(partial=True)["database"]
self.assertEqual([], missing_in_backup(self.backup_map["includes"]["includes"], database))
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_dont_backup_conf(self):
"""Take a backup ignoring frappe.conf.backup settings (with --ignore-backup-conf option)"""
self.execute("bench --site {site} backup --ignore-backup-conf")
@ -901,7 +937,7 @@ class TestAddNewUser(BaseTestCommands):
class TestBenchBuild(IntegrationTestCase):
def test_build_assets_size_check(self):
CURRENT_SIZE = 3.3 # MB
CURRENT_SIZE = 3.4 # MB
JS_ASSET_THRESHOLD = 0.01
hooks = frappe.get_hooks()
@ -960,7 +996,11 @@ class TestCommandUtils(IntegrationTestCase):
class TestDBCli(BaseTestCommands):
@timeout(10)
def test_db_cli(self):
self.execute("bench --site {site} db-console", kwargs={"cmd_input": rb"\q"})
if frappe.conf.db_type == "sqlite":
cmd_input = b".quit"
else:
cmd_input = rb"\q"
self.execute("bench --site {site} db-console", kwargs={"cmd_input": cmd_input})
self.assertEqual(self.returncode, 0)
@run_only_if(db_type_is.MARIADB)

View file

@ -2,6 +2,7 @@ import os
import subprocess
import sys
import time
import unittest
from typing import TYPE_CHECKING
import click
@ -11,8 +12,6 @@ from frappe.commands import get_site, pass_context
from frappe.utils.bench_helper import CliCtxObj
if TYPE_CHECKING:
import unittest
from frappe.testing import TestRunner
@ -34,8 +33,30 @@ def main(
debug: bool = False,
debug_exceptions: tuple[Exception] | None = None,
selected_categories: list[str] | None = None,
lightmode: bool = False,
) -> None:
"""Main function to run tests"""
if lightmode:
from frappe.testing.config import TestParameters
test_params = TestParameters(
site=site,
app=app,
module=module,
doctype=doctype,
module_def=module_def,
verbose=verbose,
tests=tests,
force=force,
profile=profile,
junit_xml_output=junit_xml_output,
doctype_list_path=doctype_list_path,
failfast=failfast,
case=case,
)
run_tests_in_light_mode(test_params)
return
import logging
from frappe.testing import (
@ -46,6 +67,9 @@ def main(
discover_module_tests,
)
from frappe.testing.environment import _cleanup_after_tests, _initialize_test_environment
from frappe.tests.utils.generators import _clear_test_log
_clear_test_log()
if debug and not debug_exceptions:
debug_exceptions = (Exception,)
@ -156,6 +180,30 @@ def main(
testing_module_logger.debug(f"Total test run time: {end_time - start_time:.3f} seconds")
def run_tests_in_light_mode(test_params):
from frappe.testing.loader import FrappeTestLoader
from frappe.testing.result import FrappeTestResult
from frappe.tests.utils import toggle_test_mode
# init environment
frappe.init(test_params.site)
if not frappe.db:
frappe.connect()
# disable scheduler
global scheduler_disabled_by_user
scheduler_disabled_by_user = frappe.utils.scheduler.is_scheduler_disabled(verbose=False)
if not scheduler_disabled_by_user:
frappe.utils.scheduler.disable_scheduler()
frappe.clear_cache()
toggle_test_mode(True)
suite = FrappeTestLoader().discover_tests(test_params)
result = unittest.TextTestRunner(failfast=test_params.failfast, resultclass=FrappeTestResult).run(suite)
if not result.wasSuccessful():
sys.exit(1)
def _setup_xml_output(junit_xml_output):
"""Setup XML output for test results if specified"""
global unittest_runner
@ -245,6 +293,7 @@ def _get_doctypes_for_module_def(app, module_def):
default="all",
help="Select test category to run",
)
@click.option("--lightmode", is_flag=True, default=False)
@pass_context
def run_tests(
context: CliCtxObj,
@ -262,6 +311,7 @@ def run_tests(
failfast=False,
case=None,
test_category="all",
lightmode=False,
debug=False,
):
"""Run python unit-tests"""
@ -275,7 +325,7 @@ def run_tests(
site = get_site(context)
frappe.init(site)
allow_tests = frappe.get_conf().allow_tests
allow_tests = frappe.conf.allow_tests
if not (allow_tests or os.environ.get("CI")):
click.secho("Testing is disabled for the site!", bold=True)
@ -306,6 +356,7 @@ def run_tests(
skip_before_tests=skip_before_tests,
debug=debug,
selected_categories=[] if test_category == "all" else test_category,
lightmode=lightmode,
)
@ -321,6 +372,7 @@ def run_tests(
)
@click.option("--use-orchestrator", is_flag=True, help="Use orchestrator to run parallel tests")
@click.option("--dry-run", is_flag=True, default=False, help="Dont actually run tests")
@click.option("--lightmode", is_flag=True, default=False, help="Skips all before test setup")
@pass_context
def run_parallel_tests(
context: CliCtxObj,
@ -330,6 +382,7 @@ def run_parallel_tests(
with_coverage=False,
use_orchestrator=False,
dry_run=False,
lightmode=False,
):
from traceback_with_variables import activate_by_import
@ -350,6 +403,7 @@ def run_parallel_tests(
build_number=build_number,
total_builds=total_builds,
dry_run=dry_run,
lightmode=lightmode,
)
mode = "Orchestrator" if use_orchestrator else "Parallel"
banner = f"""

View file

@ -77,7 +77,7 @@ def build(
skip_frappe = False
# don't minify in developer_mode for faster builds
development = frappe.local.conf.developer_mode or frappe.local.dev_server
development = frappe.local.conf.developer_mode or frappe._dev_server
mode = "development" if development else "production"
if production:
mode = "production"
@ -175,7 +175,7 @@ def destroy_all_sessions(context: CliCtxObj, reason=None):
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
@pass_context
def show_config(context: CliCtxObj, format):
"Print configuration file to STDOUT in speified format"
"Print configuration file to STDOUT in specified format"
if not context.sites:
raise SiteNotSpecifiedError
@ -524,12 +524,27 @@ def postgres(context: CliCtxObj, extra_args):
_enter_console(extra_args=extra_args)
@click.command("sqlite", context_settings=EXTRA_ARGS_CTX)
@click.argument("extra_args", nargs=-1)
@pass_context
def sqlite(context: CliCtxObj, extra_args):
"""
Enter into sqlite console for a given site.
"""
site = get_site(context)
frappe.init(site)
frappe.conf.db_type = "sqlite"
_enter_console(extra_args=extra_args)
def _enter_console(extra_args=None):
from frappe.database import get_command
from frappe.utils import get_site_path
if frappe.conf.db_type == "mariadb":
os.environ["MYSQL_HISTFILE"] = os.path.abspath(get_site_path("logs", "mariadb_console.log"))
elif frappe.conf.db_type == "sqlite":
os.environ["SQLITE_HISTORY"] = os.path.abspath(get_site_path("logs", "sqlite_console.log"))
else:
os.environ["PSQL_HISTORY"] = os.path.abspath(get_site_path("logs", "postgresql_console.log"))
@ -899,7 +914,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."""
@ -1033,6 +1048,7 @@ commands = [
make_app,
create_patch,
mariadb,
sqlite,
postgres,
request,
reset_perms,

View file

@ -78,21 +78,23 @@ def _get_site_config(sites_path: str, site_path: str) -> _dict[str, Any]:
os.environ.get("FRAPPE_REDIS_CACHE") or config.get("redis_cache") or "redis://127.0.0.1:13311"
)
config["db_type"] = os.environ.get("FRAPPE_DB_TYPE") or config.get("db_type") or "mariadb"
config["db_socket"] = os.environ.get("FRAPPE_DB_SOCKET") or config.get("db_socket")
config["db_host"] = os.environ.get("FRAPPE_DB_HOST") or config.get("db_host") or "127.0.0.1"
config["db_port"] = int(
os.environ.get("FRAPPE_DB_PORT") or config.get("db_port") or db_default_ports(config["db_type"])
)
# Set the user as database name if not set in config
config["db_user"] = os.environ.get("FRAPPE_DB_USER") or config.get("db_user") or config.get("db_name")
if config["db_type"] in ("mariadb", "postgres"):
config["db_socket"] = os.environ.get("FRAPPE_DB_SOCKET") or config.get("db_socket")
config["db_host"] = os.environ.get("FRAPPE_DB_HOST") or config.get("db_host") or "127.0.0.1"
config["db_port"] = int(
os.environ.get("FRAPPE_DB_PORT") or config.get("db_port") or db_default_ports(config["db_type"])
)
# Set the user as database name if not set in config
config["db_user"] = os.environ.get("FRAPPE_DB_USER") or config.get("db_user") or config.get("db_name")
# read password
config["db_password"] = os.environ.get("FRAPPE_DB_PASSWORD") or config.get("db_password")
# vice versa for dbname if not defined
config["db_name"] = os.environ.get("FRAPPE_DB_NAME") or config.get("db_name") or config["db_user"]
# read password
config["db_password"] = os.environ.get("FRAPPE_DB_PASSWORD") or config.get("db_password")
# Allow externally extending the config with hooks
if extra_config := config.get("extra_config"):
if isinstance(extra_config, str):

View file

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

View file

@ -4,16 +4,7 @@ from functools import partial
import frappe
from frappe.contacts.doctype.address.address import address_query, get_address_display
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestAddress(UnitTestCase):
"""
Unit tests for Address.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestAddress(IntegrationTestCase):

View file

@ -29,7 +29,7 @@ class AddressTemplate(Document):
if not self.is_default and not self._get_previous_default():
self.is_default = 1
if frappe.get_system_settings("setup_complete"):
if frappe.is_setup_complete():
frappe.msgprint(_("Setting this Address Template as default as there is no other default"))
def on_update(self):

View file

@ -2,19 +2,10 @@
# License: MIT. See LICENSE
import frappe
from frappe.contacts.doctype.address_template.address_template import get_default_address_template
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.utils.jinja import validate_template
class UnitTestAddressTemplate(UnitTestCase):
"""
Unit tests for AddressTemplate.
Use this class for testing individual functions and methods.
"""
pass
class TestAddressTemplate(IntegrationTestCase):
def setUp(self) -> None:
frappe.db.delete("Address Template", {"country": "India"})

View file

@ -3,20 +3,11 @@
import frappe
from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.email import get_contact_list
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
EXTRA_TEST_RECORD_DEPENDENCIES = ["Contact", "Salutation"]
class UnitTestContact(UnitTestCase):
"""
Unit tests for Contact.
Use this class for testing individual functions and methods.
"""
pass
class TestContact(IntegrationTestCase):
def test_check_default_email(self):
emails = [

View file

@ -1,15 +1,6 @@
# Copyright (c) 2017, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestGender(UnitTestCase):
"""
Unit tests for Gender.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestGender(IntegrationTestCase):

View file

@ -1,15 +1,6 @@
# Copyright (c) 2017, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestSalutation(UnitTestCase):
"""
Unit tests for Salutation.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestSalutation(IntegrationTestCase):

View file

@ -0,0 +1,139 @@
import frappe
import frappe.utils
from frappe import _
from frappe.core.doctype.user_invitation.user_invitation import UserInvitation
@frappe.whitelist(methods=["POST"])
def invite_by_email(
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe"
) -> dict[str, list[str]]:
UserInvitation.validate_role(app_name)
# validate emails
frappe.utils.validate_email_address(emails, throw=True)
email_list = frappe.utils.split_emails(emails)
if not email_list:
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,
"user": ["is", "set"],
},
pluck="email",
)
pending_invite_emails = frappe.db.get_all(
"User Invitation",
filters={"email": ["in", email_list], "status": "Pending", "app_name": app_name},
pluck="email",
)
# create invitation documents
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",
email=email,
roles=[dict(role=role) for role in roles],
app_name=app_name,
redirect_to_path=redirect_to_path,
).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,
}
@frappe.whitelist(allow_guest=True, methods=["GET"])
def accept_invitation(key: str) -> None:
_accept_invitation(key, False)
# `app_name` is required for security
@frappe.whitelist(methods=["PATCH", "POST"])
def cancel_invitation(name: str, app_name: str):
UserInvitation.validate_role(app_name)
if not frappe.db.exists("User Invitation", name):
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
invitation = frappe.get_doc("User Invitation", name)
if invitation.app_name != app_name:
# message is not specific enough for security
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
if invitation.status == "Cancelled":
return {"cancelled_now": False}
if invitation.status != "Pending":
frappe.throw(title=_("Error"), msg=_("Invitation cannot be cancelled"))
invitation.flags.ignore_permissions = True
return {"cancelled_now": invitation.cancel_invite()}
@frappe.whitelist(methods=["GET"])
def get_pending_invitations(app_name: str):
UserInvitation.validate_role(app_name)
pending_invitations = frappe.db.get_all(
"User Invitation", fields=["name", "email"], filters={"status": "Pending", "app_name": app_name}
)
res = []
for pending_invitation in pending_invitations:
roles = frappe.db.get_all("User Role", fields=["role"], filters={"parent": pending_invitation.name})
res.append(
{
"name": pending_invitation.name,
"email": pending_invitation.email,
"roles": [r.role for r in roles],
}
)
return res
def _accept_invitation(key: str, in_test: bool) -> None:
# get invitation
hashed_key = frappe.utils.sha256_hash(key)
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
if not invitation_name:
frappe.throw(title=_("Error"), msg=_("Invalid key"))
invitation = frappe.get_doc("User Invitation", invitation_name)
# accept invitation
invitation.accept(ignore_permissions=True)
user = frappe.get_doc("User", invitation.email)
should_update_password = not user.last_password_reset_date and not bool(
frappe.get_system_settings("disable_user_pass_login")
)
# set redirect_to
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
if should_update_password:
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
# GET requests do not cause an implicit commit
frappe.db.commit() # nosemgrep
if not in_test and not should_update_password:
frappe.local.login_manager.login_as(invitation.email)
# set response
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = redirect_to

View file

@ -37,7 +37,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "User ",
"label": "User",
"options": "User",
"read_only": 1
},

View file

@ -38,6 +38,12 @@ class AccessLog(Document):
@frappe.whitelist()
@frappe.write_only()
@retry(
stop=stop_after_attempt(3),
retry=retry_if_exception_type(frappe.DuplicateEntryError),
reraise=True,
)
def make_access_log(
doctype=None,
document=None,
@ -48,41 +54,10 @@ def make_access_log(
page=None,
columns=None,
):
_make_access_log(
doctype,
document,
method,
file_type,
report_name,
filters,
page,
columns,
)
@frappe.write_only()
@retry(
stop=stop_after_attempt(3),
retry=retry_if_exception_type(frappe.DuplicateEntryError),
reraise=True,
)
def _make_access_log(
doctype=None,
document=None,
method=None,
file_type=None,
report_name=None,
filters=None,
page=None,
columns=None,
):
user = frappe.session.user
in_request = frappe.request and frappe.request.method == "GET"
access_log = frappe.get_doc(
{
"doctype": "Access Log",
"user": user,
"user": frappe.session.user,
"export_from": doctype,
"reference_document": document,
"file_type": file_type,
@ -94,14 +69,11 @@ def _make_access_log(
}
)
if frappe.flags.read_only:
if not frappe.in_test:
access_log.deferred_insert()
return
else:
access_log.db_insert()
# `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview`
# dont commit in test mode. It must be tempting to put this block along with the in_request in the
# whitelisted method...yeah, don't do it. That part would be executed possibly on a read only DB conn
if not frappe.flags.in_test or in_request:
frappe.db.commit()
# only for backward compatibility
_make_access_log = make_access_log

View file

@ -14,19 +14,10 @@ from frappe.core.doctype.data_import.data_import import export_csv
from frappe.core.doctype.user.user import generate_keys
# imports - standard imports
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.utils import cstr, get_site_url
class UnitTestAccessLog(UnitTestCase):
"""
Unit tests for AccessLog.
Use this class for testing individual functions and methods.
"""
pass
class TestAccessLog(IntegrationTestCase):
def setUp(self):
# generate keys for current user to send requests for the following tests

View file

@ -4,16 +4,7 @@ import time
import frappe
from frappe.auth import CookieManager, LoginManager
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestActivityLog(UnitTestCase):
"""
Unit tests for ActivityLog.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestActivityLog(IntegrationTestCase):

View file

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("API Request Log", {
// refresh(frm) {
// },
// });

View file

@ -0,0 +1,62 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-05-21 16:51:56.070193",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"path",
"method",
"user"
],
"fields": [
{
"fieldname": "path",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Path"
},
{
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "User",
"options": "User"
},
{
"fieldname": "method",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Method"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-05-21 17:09:55.054044",
"modified_by": "Administrator",
"module": "Core",
"name": "API Request Log",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,28 @@
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class APIRequestLog(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
method: DF.Data | None
path: DF.Data | None
user: DF.Link | None
# end: auto-generated types
@staticmethod
def clear_old_logs(days: int = 90):
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
table = frappe.qb.DocType("API Request Log")
frappe.db.delete(table, filters=(table.creation < (Now() - Interval(days=days))))

View file

@ -0,0 +1,20 @@
# Copyright (c) 2025, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestAPIRequestLog(IntegrationTestCase):
"""
Integration tests for APIRequestLog.
Use this class for testing interactions between multiple components.
"""
pass

View file

@ -2,19 +2,10 @@
# See license.txt
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.utils import today
class UnitTestAuditTrail(UnitTestCase):
"""
Unit tests for AuditTrail.
Use this class for testing individual functions and methods.
"""
pass
class TestAuditTrail(IntegrationTestCase):
def setUp(self):
self.child_doctype = create_custom_child_doctype()

View file

@ -66,7 +66,7 @@ class Comment(Document):
def on_update(self):
update_comment_in_doc(self)
if self.is_new():
if not self.is_new():
self.notify_change("update")
def on_trash(self):

View file

@ -4,21 +4,17 @@ import json
import frappe
from frappe.templates.includes.comments.comments import add_comment
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.tests.test_helpers import setup_for_tests
from frappe.tests.test_model_utils import set_user
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
class UnitTestComment(UnitTestCase):
"""
Unit tests for Comment.
Use this class for testing individual functions and methods.
"""
pass
EXTRA_TEST_RECORD_DEPENDENCIES = ["Web Page"]
class TestComment(IntegrationTestCase):
def setUp(self):
setup_for_tests()
def test_comment_creation(self):
test_doc = frappe.get_doc(doctype="ToDo", description="test")
test_doc.insert()
@ -51,16 +47,16 @@ class TestComment(IntegrationTestCase):
# test via blog
def test_public_comment(self):
test_blog = make_test_blog()
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
add_comment_args = {
"comment": "Good comment with 10 chars",
"comment_email": "test@test.com",
"comment_by": "Good Tester",
"reference_doctype": test_blog.doctype,
"reference_name": test_blog.name,
"route": test_blog.route,
"route": f"blog/{test_blog.doctype}/{test_blog.name}",
}
add_comment(**add_comment_args)
@ -73,7 +69,7 @@ class TestComment(IntegrationTestCase):
1,
)
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
add_comment_args.update(comment="pleez vizits my site http://mysite.com", comment_by="bad commentor")
add_comment(**add_comment_args)
@ -90,7 +86,7 @@ class TestComment(IntegrationTestCase):
)
# test for filtering html and css injection elements
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
add_comment_args.update(comment="<script>alert(1)</script>Comment", comment_by="hacker")
add_comment(**add_comment_args)
@ -105,26 +101,10 @@ class TestComment(IntegrationTestCase):
test_blog.delete()
@IntegrationTestCase.change_settings("Blog Settings", {"allow_guest_to_comment": 0})
def test_guest_cannot_comment(self):
test_blog = make_test_blog()
with set_user("Guest"):
self.assertEqual(
add_comment(
comment="Good comment with 10 chars",
comment_email="mail@example.org",
comment_by="Good Tester",
reference_doctype="Blog Post",
reference_name=test_blog.name,
route=test_blog.route,
),
None,
)
def test_user_not_logged_in(self):
some_system_user = frappe.db.get_value("User", {"name": ("not in", frappe.STANDARD_USERS)})
test_blog = make_test_blog()
test_blog = frappe.get_doc("Web Page", "test-web-page-1")
with set_user("Guest"):
self.assertRaises(
frappe.ValidationError,
@ -132,7 +112,7 @@ class TestComment(IntegrationTestCase):
comment="Good comment with 10 chars",
comment_email=some_system_user,
comment_by="Good Tester",
reference_doctype="Blog Post",
reference_doctype="Web Page",
reference_name=test_blog.name,
route=test_blog.route,
)

View file

@ -401,7 +401,11 @@ class Communication(Document, CommunicationEmailMixin):
return
for doctype, docname in parse_email([self.recipients, self.cc, self.bcc]):
if not frappe.db.get_value(doctype, docname, ignore=True):
# Both document and doctype names should be case insensitive in email addresses.
doctype = frappe.db.get_value("DocType", doctype)
if doctype:
docname = frappe.db.get_value(doctype, docname, ignore=True)
if not (doctype and docname):
continue
self.add_link(doctype, docname)
@ -579,7 +583,7 @@ def parse_email(email_strings):
if not document_parts or len(document_parts) != 2:
continue
doctype = unquote_plus(document_parts[0])
doctype = frappe.unscrub(unquote_plus(document_parts[0]))
docname = unquote_plus(document_parts[1])
yield doctype, docname
@ -644,7 +648,10 @@ def update_first_response_time(parent, communication):
is_system_user(communication.sender)
or frappe.get_cached_value("User", frappe.session.user, "user_type") == "System User"
):
if communication.sent_or_received == "Sent":
if (
communication.sent_or_received == "Sent"
and communication.communication_type == "Communication"
):
first_responded_on = communication.creation
if parent.meta.has_field("first_responded_on"):
parent.db_set("first_responded_on", first_responded_on)

View file

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
import frappe
import frappe.email.smtp
from frappe import _
from frappe.database.utils import commit_after_response
from frappe.email.email_body import get_message_id
from frappe.utils import (
cint,
@ -272,7 +273,7 @@ def add_attachments(name: str, attachments: Iterable[str | dict]) -> None:
@frappe.whitelist(allow_guest=True, methods=("GET",))
def mark_email_as_seen(name: str | None = None):
frappe.request.after_response.add(lambda: _mark_email_as_seen(name))
commit_after_response(lambda: _mark_email_as_seen(name))
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
@ -282,8 +283,6 @@ def _mark_email_as_seen(name):
except Exception:
frappe.log_error("Unable to mark as seen", None, "Communication", name)
frappe.db.commit() # nosemgrep: after_response requires explicit commit
def update_communication_as_read(name):
if not name or not isinstance(name, str):

View file

@ -81,7 +81,7 @@ class CommunicationEmailMixin:
if doc_owner := self.get_owner():
cc.append(doc_owner)
cc = set(cc) - {self.sender_mailid}
assignees = set(self.get_assignees())
assignees = set(self.get_assignees()) - {self.sender_mailid}
# Check and remove If user disabled notifications for incoming emails on assigned document.
for assignee in assignees.copy():
if not is_email_notifications_enabled_for_type(assignee, "threads_on_assigned_document"):

View file

@ -6,22 +6,13 @@ import frappe
from frappe.core.doctype.communication.communication import Communication, get_emails, parse_email
from frappe.core.doctype.communication.email import add_attachments, make
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
if TYPE_CHECKING:
from frappe.contacts.doctype.contact.contact import Contact
from frappe.email.doctype.email_account.email_account import EmailAccount
class UnitTestCommunication(UnitTestCase):
"""
Unit tests for Communication.
Use this class for testing individual functions and methods.
"""
pass
class TestCommunication(IntegrationTestCase):
def test_email(self):
valid_email_list = [

View file

@ -1,15 +1,6 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestCustomDocperm(UnitTestCase):
"""
Unit tests for CustomDocperm.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestCustomDocPerm(IntegrationTestCase):

View file

@ -1,15 +1,6 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestCustomRole(UnitTestCase):
"""
Unit tests for CustomRole.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestCustomRole(IntegrationTestCase):

View file

@ -446,7 +446,8 @@ class DataExporter:
value = format_datetime(value)
elif fieldtype == "Duration":
value = format_duration(value, df.hide_days)
elif fieldtype == "Text Editor" and value:
value = frappe.core.utils.html2text(value)
row[_column_start_end.start + i + 1] = value
def build_response_as_excel(self):

View file

@ -2,16 +2,7 @@
# License: MIT. See LICENSE
import frappe
from frappe.core.doctype.data_export.exporter import DataExporter
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestDataExport(UnitTestCase):
"""
Unit tests for DataExport.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestDataExporter(IntegrationTestCase):

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()
@ -101,7 +102,7 @@ class DataImport(Document):
def start_import(self):
from frappe.utils.scheduler import is_scheduler_inactive
run_now = frappe.flags.in_test or frappe.conf.developer_mode
run_now = frappe.in_test or frappe.conf.developer_mode
if is_scheduler_inactive() and not run_now:
frappe.throw(_("Scheduler is inactive. Cannot import data."), title=_("Scheduler Inactive"))
@ -135,15 +136,19 @@ class DataImport(Document):
@frappe.whitelist()
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
return frappe.get_doc("Data Import", data_import).get_preview_from_template(
import_file, google_sheets_url
)
def get_preview_from_template(
data_import: str, import_file: str | None = None, google_sheets_url: str | None = None
):
di: DataImport = frappe.get_doc("Data Import", data_import)
di.check_permission("read")
return di.get_preview_from_template(import_file, google_sheets_url)
@frappe.whitelist()
def form_start_import(data_import: str):
return frappe.get_doc("Data Import", data_import).start_import()
di: DataImport = frappe.get_doc("Data Import", data_import)
di.check_permission("write")
return di.start_import()
def start_import(data_import):
@ -175,6 +180,7 @@ def download_template(doctype, export_fields=None, export_records=None, export_f
:param export_filters: Filter dict
:param file_type: File type to export into
"""
frappe.has_permission(doctype, "read", throw=True)
export_fields = frappe.parse_json(export_fields)
export_filters = frappe.parse_json(export_filters)
@ -192,24 +198,25 @@ def download_template(doctype, export_fields=None, export_records=None, export_f
@frappe.whitelist()
def download_errored_template(data_import_name):
data_import = frappe.get_doc("Data Import", data_import_name)
def download_errored_template(data_import_name: str):
data_import: DataImport = frappe.get_doc("Data Import", data_import_name)
data_import.check_permission("read")
data_import.export_errored_rows()
@frappe.whitelist()
def download_import_log(data_import_name):
data_import = frappe.get_doc("Data Import", data_import_name)
def download_import_log(data_import_name: str):
data_import: DataImport = frappe.get_doc("Data Import", data_import_name)
data_import.check_permission("read")
data_import.download_import_log()
@frappe.whitelist()
def get_import_status(data_import_name):
import_status = {}
data_import = frappe.get_doc("Data Import", data_import_name)
import_status["status"] = data_import.status
def get_import_status(data_import_name: str):
data_import: DataImport = frappe.get_doc("Data Import", data_import_name)
data_import.check_permission("read")
import_status = {"status": data_import.status}
logs = frappe.get_all(
"Data Import Log",
fields=["count(*) as count", "success"],
@ -217,7 +224,7 @@ def get_import_status(data_import_name):
group_by="success",
)
total_payload_count = frappe.db.get_value("Data Import", data_import_name, "payload_count")
total_payload_count = data_import.payload_count
for log in logs:
if log.get("success"):
@ -256,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

@ -733,7 +733,7 @@ class Row:
}
)
return
elif df.fieldtype in ["Date", "Datetime"]:
elif df.fieldtype == "Date":
value = self.get_date(value, col)
if isinstance(value, str):
# value was not parsed as datetime object
@ -748,6 +748,21 @@ class Row:
}
)
return
elif df.fieldtype == "Datetime":
value = self.get_datetime(value, col)
if isinstance(value, str):
# value was not parsed as datetime object
self.warnings.append(
{
"row": self.row_number,
"col": col.column_number,
"field": df_as_json(df),
"message": _("Value {0} must in {1} format").format(
frappe.bold(value), frappe.bold(get_user_format(col.date_format))
),
}
)
return
elif df.fieldtype == "Duration":
if not DURATION_PATTERN.match(value):
self.warnings.append(
@ -783,15 +798,31 @@ class Row:
value = cint(value)
elif df.fieldtype in ["Float", "Percent", "Currency"]:
value = flt(value)
elif df.fieldtype in ["Date", "Datetime"]:
elif df.fieldtype == "Date":
value = self.get_date(value, col)
elif df.fieldtype == "Datetime":
value = self.get_datetime(value, col)
elif df.fieldtype == "Duration":
value = duration_to_seconds(value)
return value
def get_date(self, value, column):
if isinstance(value, datetime | date):
def get_date(self, value, column) -> date:
if isinstance(value, date):
return value
date_format = column.date_format
if date_format:
try:
return datetime.strptime(value, date_format).date()
except ValueError:
# ignore date values that dont match the format
# import will break for these values later
pass
return value
def get_datetime(self, value, column) -> datetime:
if isinstance(value, datetime):
return value
date_format = column.date_format
@ -1004,8 +1035,11 @@ class Column:
if self.df.fieldtype == "Link":
# find all values that dont exist
values = list({cstr(v) for v in self.column_values if v})
exists = [cstr(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})]
transform = (lambda v: cstr(v).lower()) if frappe.db.db_type == "mariadb" else cstr
values = list({transform(v) for v in self.column_values if v})
exists = [
transform(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})
]
not_exists = list(set(values) - set(exists))
if not_exists:
missing_values = ", ".join(not_exists)

View file

@ -1,16 +1,7 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestDataImport(UnitTestCase):
"""
Unit tests for DataImport.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestDataImport(IntegrationTestCase):

View file

@ -3,20 +3,11 @@
import frappe
from frappe.core.doctype.data_import.exporter import Exporter
from frappe.core.doctype.data_import.test_importer import create_doctype_if_not_exists
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
doctype_name = "DocType for Export"
class UnitTestDataImport(UnitTestCase):
"""
Unit tests for DataImport.
Use this class for testing individual functions and methods.
"""
pass
class TestExporter(IntegrationTestCase):
def setUp(self):
create_doctype_if_not_exists(doctype_name)

View file

@ -2,22 +2,13 @@
# License: MIT. See LICENSE
import frappe
from frappe.core.doctype.data_import.importer import Importer
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.tests.test_query_builder import db_type_is, run_only_if
from frappe.utils import format_duration, getdate
doctype_name = "DocType for Import"
class UnitTestDataImport(UnitTestCase):
"""
Unit tests for DataImport.
Use this class for testing individual functions and methods.
"""
pass
class TestImporter(IntegrationTestCase):
@classmethod
def setUpClass(cls):

View file

@ -2,16 +2,7 @@
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestDataImportLog(UnitTestCase):
"""
Unit tests for DataImportLog.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestDataImportLog(IntegrationTestCase):

View file

@ -1,15 +1,6 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestDeletedDocument(UnitTestCase):
"""
Unit tests for DeletedDocument.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestDeletedDocument(IntegrationTestCase):

View file

@ -499,7 +499,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"
@ -609,18 +609,20 @@
"label": "Sticky"
}
],
"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-08-26 22:08:20.940308",
"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

@ -46,7 +46,7 @@ class DocShare(Document):
def get_doc(self):
if not getattr(self, "_doc", None):
self._doc = frappe.get_doc(self.share_doctype, self.share_name)
self._doc = frappe.get_lazy_doc(self.share_doctype, self.share_name)
return self._doc
def validate_user(self):

View file

@ -4,20 +4,11 @@
import frappe
import frappe.share
from frappe.automation.doctype.auto_repeat.test_auto_repeat import create_submittable_doctype
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
class UnitTestDocshare(UnitTestCase):
"""
Unit tests for Docshare.
Use this class for testing individual functions and methods.
"""
pass
class TestDocShare(IntegrationTestCase):
def setUp(self):
self.user = "test@example.com"

View file

@ -2,7 +2,7 @@
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
@ -12,14 +12,6 @@ EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTest{classname}(UnitTestCase):
"""
Unit tests for {classname}.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTest{classname}(IntegrationTestCase):
"""

View file

@ -15,6 +15,7 @@
"restrict_to_domain",
"read_only",
"in_create",
"protect_attached_files",
"sb1",
"naming_rule",
"autoname",
@ -32,6 +33,7 @@
"editable_grid",
"quick_entry",
"grid_page_length",
"rows_threshold_for_grid_search",
"cb01",
"track_changes",
"track_seen",
@ -75,6 +77,7 @@
"email_append_to",
"sender_field",
"sender_name_field",
"recipient_account_field",
"subject_field",
"fields_tab",
"fields_section",
@ -286,6 +289,7 @@
},
{
"default": "0",
"depends_on": "eval:!doc.issingle",
"fieldname": "allow_import",
"fieldtype": "Check",
"label": "Allow Import (via Data Import Tool)"
@ -683,14 +687,36 @@
"options": "Dynamic\nCompressed"
},
{
"default": "50",
"depends_on": "istable",
"fieldname": "grid_page_length",
"fieldtype": "Int",
"label": "Grid Page Length",
"non_negative": 1
"default": "50",
"depends_on": "istable",
"fieldname": "grid_page_length",
"fieldtype": "Int",
"label": "Grid Page Length",
"non_negative": 1
},
{
"default": "0",
"description": "Users are only able to delete attached files if the document is either in draft or if the document is canceled and they are also able to delete the document.",
"fieldname": "protect_attached_files",
"fieldtype": "Check",
"label": "Protect Attached Files"
},
{
"default": "0",
"depends_on": "istable",
"fieldname": "rows_threshold_for_grid_search",
"fieldtype": "Int",
"label": "Rows Threshold for Grid Search",
"non_negative": 1
},
{
"depends_on": "email_append_to",
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
}
],
"grid_page_length": 50,
"icon": "fa fa-bolt",
"idx": 6,
"index_web_pages_for_search": 1,
@ -764,14 +790,9 @@
"group": "Rules",
"link_doctype": "Assignment Rule",
"link_fieldname": "document_type"
},
{
"group": "Rules",
"link_doctype": "Energy Point Rule",
"link_fieldname": "reference_doctype"
}
],
"modified": "2025-02-20 19:05:52.119679",
"modified": "2025-07-19 12:23:16.296416",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -801,6 +822,7 @@
}
],
"route": "doctype",
"row_format": "Dynamic",
"search_fields": "module",
"show_name_in_global_search": 1,
"sort_field": "creation",

View file

@ -154,12 +154,15 @@ class DocType(Document):
]
nsm_parent_field: DF.Data | None
permissions: DF.Table[DocPerm]
protect_attached_files: DF.Check
queue_in_background: DF.Check
quick_entry: DF.Check
read_only: DF.Check
recipient_account_field: DF.Data | None
restrict_to_domain: DF.Link | None
route: DF.Data | None
row_format: DF.Literal["Dynamic", "Compressed"]
rows_threshold_for_grid_search: DF.Int
search_fields: DF.Data | None
sender_field: DF.Data | None
sender_name_field: DF.Data | None
@ -320,7 +323,7 @@ class DocType(Document):
def check_developer_mode(self):
"""Throw exception if not developer mode or via patch"""
if frappe.flags.in_patch or frappe.flags.in_test:
if frappe.flags.in_patch or frappe.in_test:
return
if not frappe.conf.get("developer_mode") and not self.custom:
@ -332,7 +335,7 @@ class DocType(Document):
if self.is_virtual and self.custom:
frappe.throw(_("Not allowed to create custom Virtual DocType."), CannotCreateStandardDoctypeError)
if frappe.conf.get("developer_mode"):
if frappe.conf.developer_mode and not self.owner:
self.owner = "Administrator"
self.modified_by = "Administrator"
@ -517,7 +520,7 @@ class DocType(Document):
self.setup_autoincrement_and_sequence()
try:
frappe.db.updatedb(self.name, Meta(None, bootstrap=self))
frappe.db.updatedb(self.name, Meta(self))
except Exception as e:
print(f"\n\nThere was an issue while migrating the DocType: {self.name}\n")
raise e
@ -592,7 +595,7 @@ class DocType(Document):
global_search_fields_after_update.append("name")
if set(global_search_fields_before_update) != set(global_search_fields_after_update):
now = (not frappe.request) or frappe.flags.in_test or frappe.flags.in_install
now = (not frappe.request) or frappe.in_test or frappe.flags.in_install
frappe.enqueue("frappe.utils.global_search.rebuild_for_doctype", now=now, doctype=self.name)
def set_base_class_for_controller(self):

View file

@ -4,6 +4,7 @@ import os
import random
import string
import unittest
from unittest.case import skipIf
from unittest.mock import patch
import frappe
@ -24,19 +25,10 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.desk.form.load import getdoc
from frappe.model.delete_doc import delete_controllers
from frappe.model.sync import remove_orphan_doctypes
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests import IntegrationTestCase
from frappe.utils import get_table_name
class UnitTestDoctype(UnitTestCase):
"""
Unit tests for Doctype.
Use this class for testing individual functions and methods.
"""
pass
class TestDocType(IntegrationTestCase):
def tearDown(self):
frappe.db.rollback()
@ -56,6 +48,10 @@ class TestDocType(IntegrationTestCase):
doc = new_doctype(name).insert()
doc.delete()
@skipIf(
frappe.conf.db_type == "sqlite",
"Not for SQLite for now",
)
def test_making_sequence_on_change(self):
frappe.delete_doc_if_exists("DocType", self._testMethodName)
dt = new_doctype(self._testMethodName).insert(ignore_permissions=True)

View file

@ -1,16 +1,7 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestDocumentNamingRule(UnitTestCase):
"""
Unit tests for DocumentNamingRule.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestDocumentNamingRule(IntegrationTestCase):

View file

@ -1,16 +1,7 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
class UnitTestDocumentNamingRuleCondition(UnitTestCase):
"""
Unit tests for DocumentNamingRuleCondition.
Use this class for testing individual functions and methods.
"""
pass
from frappe.tests import IntegrationTestCase
class TestDocumentNamingRuleCondition(IntegrationTestCase):

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