Merge branch 'develop' into qb-fixes

This commit is contained in:
Sagar Vora 2025-12-29 15:43:32 +05:30 committed by GitHub
commit 0a76f1fc36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
177 changed files with 22155 additions and 20989 deletions

View file

@ -64,3 +64,6 @@ e9bbe03354079cfcef65a77b0c33f57b047a7c93
# another ruff update
6ca4d4d167a1a009d99062747711de7a994aa633
# some more ruff
8723a2b6ee9dbec800077f18202ba53b0ef553e7

View file

@ -4,11 +4,11 @@ inputs:
python-version:
description: 'Python version to use'
required: false
default: '3.12.6'
default: '3.14'
node-version:
description: 'Node.js version to use'
required: false
default: '22'
default: '24'
build-assets:
required: false
description: 'Wether to build assets'
@ -45,12 +45,12 @@ runs:
git config --global advice.detachedHead false
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
path: apps/${{ github.event.repository.name }}
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ inputs.python-version }}
@ -64,14 +64,14 @@ runs:
fi
- name: Checkout Frappe
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: ${{ env.FRAPPE_GH_ORG || github.repository_owner }}/frappe
ref: ${{ github.event.client_payload.frappe_sha || github.base_ref || github.ref_name }}
path: apps/frappe
if: github.event.repository.name != 'frappe'
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
check-latest: true

4
.github/stale.yml vendored
View file

@ -1,11 +1,11 @@
# Configuration for probot-stale - https://github.com/probot/stale
# Number of days of inactivity before an Issue or Pull Request becomes stale
daysUntilStale: 28
daysUntilStale: 60
# Number of days of inactivity before a stale Issue or Pull Request is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
daysUntilClose: 3
daysUntilClose: 5
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
exemptLabels:

View file

@ -12,11 +12,11 @@ on:
python-version:
required: false
type: string
default: '3.10'
default: '3.14'
node-version:
required: false
type: number
default: 22
default: 24
db-artifact-url:
required: false
type: string
@ -49,6 +49,15 @@ jobs:
disable-socketio: true
disable-web: true
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: |
3.11
3.13
${{ inputs.python-version }}
- name: Execute pre-migration tasks
if: inputs.pre
@ -108,7 +117,7 @@ jobs:
fi
echo "Setting up environment..."
if rm -rf ${GITHUB_WORKSPACE}/env && python -m venv ${GITHUB_WORKSPACE}/env; then
if rm -rf ${GITHUB_WORKSPACE}/env && python"$2" -m venv ${GITHUB_WORKSPACE}/env; then
source ${GITHUB_WORKSPACE}/env/bin/activate
pip install --quiet --upgrade pip
pip install --quiet frappe-bench
@ -148,13 +157,13 @@ jobs:
- name: Update to v14
run: |
source $RUNNER_TEMP/migrate
update_to_version 14
update_to_version 14 3.11
exit $?
- name: Update to v15
run: |
source $RUNNER_TEMP/migrate
update_to_version 15
update_to_version 15 3.13
exit $?
- name: Update to last commit

View file

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 22
default: 24
parallel-runs:
required: false
type: number

View file

@ -13,7 +13,7 @@ on:
node-version:
required: false
type: number
default: 22
default: 24
parallel-runs:
required: false
type: number

View file

@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: Setup dependencies
run: |
npm install @semantic-release/git @semantic-release/exec --no-save

View file

@ -24,7 +24,7 @@ jobs:
fetch-depth: 200
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
check-latest: true
- name: Check commit titles

View file

@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: actions/setup-python@v6
with:

View file

@ -16,7 +16,7 @@ jobs:
path: 'frappe'
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- uses: actions/setup-python@v6
with:
python-version: '3.14'

View file

@ -79,7 +79,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
check-latest: true
- name: Add to Hosts

View file

@ -58,8 +58,8 @@ jobs:
uses: ./.github/workflows/_base-migration.yml
with:
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
python-version: '3.10'
node-version: 22
python-version: '3.14'
node-version: 24
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
coverage:

View file

@ -6,7 +6,7 @@ fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
files: "frappe.*"
@ -22,7 +22,7 @@ repos:
exclude: ^frappe/tests/classes/context_managers\.py$
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
rev: v0.14.10
hooks:
- id: ruff
name: "Run ruff import sorter"
@ -71,7 +71,7 @@ repos:
)$
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.22.0
rev: v9.23.0
hooks:
- id: commitlint
stages: [commit-msg]

View file

@ -72,8 +72,9 @@ context("Sidebar", () => {
// attach 1 more image to reach attachment limit
attach_file("cypress/fixtures/sample_attachments/attachment-11.txt");
cy.get(".layout-side-section").scrollTo("top", { ensureScrollable: false });
cy.get(".add-attachment-btn").should("be.hidden");
cy.get(".explore-link").should("be.visible");
// cy.get(".explore-link").should("be.visible");
// test "Show All" button
cy.get(".attachment-row").should("have.length", 10);

View file

@ -226,6 +226,6 @@ context("View", () => {
it("Route to Website Workspace", () => {
cy.visit("/desk/website");
cy.get(".workspace-title").should("contain", "Website");
cy.get(".navbar-breadcrumbs:visible").get("li > a").should("contain", "Website");
});
});

View file

@ -73,8 +73,8 @@ if TYPE_CHECKING: # pragma: no cover
controllers: dict[str, type] = {}
lazy_controllers: dict[str, type] = {}
local = Local()
cache: Optional["RedisWrapper"] = None
client_cache: Optional["ClientCache"] = None
cache: "RedisWrapper" | None = None
client_cache: "ClientCache" | None = None
STANDARD_USERS = ("Guest", "Administrator")
# this global may be subsequently changed by frappe.tests.utils.toggle_test_mode()
@ -88,22 +88,20 @@ if _dev_server:
# local-globals
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type ConfType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: make session a dataclass instead of undtyped _dict
SessionType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type SessionType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: implement dataclass
LogMessageType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
type LogMessageType = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: implement dataclass
# holds job metadata if the code is run in a background job context
JobMetaType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
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]
type JobMetaType = _dict[str, Any] # type: ignore[no-any-explicit]
type ResponseDict = _dict[str, Any] # type: ignore[no-any-explicit]
type FlagsDict = _dict[str, Any] # type: ignore[no-any-explicit]
type FormDict = _dict[str, str]
db: LocalProxy[Union["PyMariaDBDatabase", "MariaDBDatabase", "PostgresDatabase", "SQLiteDatabase"]] = local(
"db"
)
qb: LocalProxy[Union["MariaDB", "Postgres", "SQLite"]] = local("qb")
db: LocalProxy["PyMariaDBDatabase" | "MariaDBDatabase" | "PostgresDatabase" | "SQLiteDatabase"] = local("db")
qb: LocalProxy["MariaDB" | "Postgres" | "SQLite"] = local("qb")
conf: LocalProxy[ConfType] = local("conf")
form_dict: LocalProxy[FormDict] = local("form_dict")
form = form_dict
@ -675,7 +673,7 @@ def is_table(doctype: str) -> bool:
def get_precision(
doctype: str, fieldname: str, currency: str | None = None, doc: Optional["Document"] = None
doctype: str, fieldname: str, currency: str | None = None, doc: "Document" | None = None
) -> int:
"""Get precision for a given field"""
from frappe.model.meta import get_field_precision

View file

@ -10,12 +10,14 @@ from frappe.core.doctype.installed_applications.installed_applications import (
get_setup_wizard_completed_apps,
get_setup_wizard_not_required_apps,
)
from frappe.utils.caching import request_cache
# check if route is /desk or /desk/* and not /app1 or /app1/*
DESK_APP_PATTERN = re.compile(r"^/desk(/.*)?$")
@frappe.whitelist()
@request_cache
def get_apps():
apps = frappe.get_installed_apps()
app_list = []

View file

@ -67,8 +67,11 @@ frappe.ui.form.on("Assignment Rule", {
[{ label: "Owner", value: "owner" }]
);
if (doctype) {
frm.set_fields_as_options("due_date_based_on", doctype, (df) =>
["Date", "Datetime"].includes(df.fieldtype)
frm.set_fields_as_options(
"due_date_based_on",
doctype,
(df) => ["Date", "Datetime"].includes(df.fieldtype),
[{ value: " ", label: " " }]
).then((options) =>
frm.set_df_property("due_date_based_on", "hidden", !options.length)
);

View file

@ -537,6 +537,7 @@ def get_sentry_dsn():
def get_sidebar_items():
from frappe import _
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import auto_generate_sidebar_from_module
sidebars = frappe.get_all("Workspace Sidebar", fields=["name", "header_icon"])
@ -560,7 +561,7 @@ def get_sidebar_items():
}
for si in w.items:
workspace_sidebar = {
"label": si.label,
"label": _(si.label),
"link_to": si.link_to,
"link_type": si.link_type,
"type": si.type,

View file

@ -372,7 +372,7 @@
"idx": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-02-20 19:19:29.427081",
"modified": "2025-12-25 19:19:29.427081",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
@ -437,4 +437,4 @@
"title_field": "subject",
"track_changes": 1,
"track_seen": 1
}
}

View file

@ -230,7 +230,7 @@ class Communication(Document, CommunicationEmailMixin):
html_signature = soup.find("div", {"class": "ql-editor read-mode"})
_signature = None
if html_signature:
_signature = html_signature.renderContents()
_signature = html_signature.encode_contents()
if (cstr(_signature) or signature) not in self.content:
self.content = f'{self.content}</p><br><p class="signature">{signature}'

View file

@ -45,7 +45,7 @@ def restore(name, alert=True):
frappe.throw(_("Document {0} Already Restored").format(name), exc=frappe.DocumentAlreadyRestored)
doc = frappe.get_doc(json.loads(deleted.data))
doc.flags.from_restore = True
try:
doc.insert()
except frappe.DocstatusTransitionError:

View file

@ -629,7 +629,7 @@
"options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger"
},
{
"depends_on": "eval: doc.type == \"Tab Break\"",
"depends_on": "eval: doc.fieldtype == \"Tab Break\"",
"fieldname": "icon",
"fieldtype": "Icon",
"label": "Icon"

View file

@ -1140,10 +1140,10 @@ def validate_empty_name(dt, autoname):
frappe.toast(_("Warning: Naming is not set"), indicator="yellow")
def validate_autoincrement_autoname(dt: Union[DocType, "CustomizeForm"]) -> bool:
def validate_autoincrement_autoname(dt: DocType | "CustomizeForm") -> bool:
"""Checks if can doctype can change to/from autoincrement autoname"""
def get_autoname_before_save(dt: Union[DocType, "CustomizeForm"]) -> str:
def get_autoname_before_save(dt: DocType | "CustomizeForm") -> str:
if dt.doctype == "Customize Form":
property_value = frappe.db.get_value(
"Property Setter", {"doc_type": dt.doc_type, "property": "autoname"}, "value"

View file

@ -54,7 +54,7 @@ def get_extension(
filename,
extn: str | None = None,
content: bytes | None = None,
response: Optional["Response"] = None,
response: "Response" | None = None,
) -> str:
mimetype = None
@ -426,7 +426,7 @@ def decode_file_content(content: bytes) -> bytes:
return safe_b64decode(content)
def find_file_by_url(path: str, name: str | None = None) -> Optional["File"]:
def find_file_by_url(path: str, name: str | None = None) -> "File" | None:
filters = {"file_url": str(path)}
if name:
filters["name"] = str(name)

View file

@ -43,9 +43,9 @@ def make_perm_log(doc, method=None):
def insert_perm_log(
doc: Document,
doc_before_save: Document = None,
for_doctype: Optional["str"] = None,
for_document: Optional["str"] = None,
fields: Optional["list | tuple"] = None,
for_doctype: "str" | None = None,
for_document: "str" | None = None,
fields: "list | tuple" | None = None,
):
if frappe.flags.in_install or frappe.flags.in_migrate:
# no need to log changes when migrating or installing app/site

View file

@ -65,7 +65,7 @@ class Report(Document):
if self.is_standard == "No":
# allow only script manager to edit scripts
if self.report_type != "Report Builder":
if self.report_type not in ("Report Builder", "Custom Report"):
frappe.only_for("Script Manager", True)
if frappe.db.get_value("Report", self.name, "is_standard") == "Yes":

View file

@ -178,10 +178,7 @@ class TestRQJob(IntegrationTestCase):
LAST_MEASURED_USAGE += 2
# Observed higher usage on 3.14. Temporarily raising the limit
from sys import version_info
if version_info >= (3, 14):
LAST_MEASURED_USAGE += 5
LAST_MEASURED_USAGE += 5
self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg)

View file

@ -109,7 +109,6 @@ def serialize_worker(worker: Worker) -> frappe._dict:
def compute_utilization(worker: Worker) -> float:
with suppress(Exception):
total_time = (
datetime.datetime.now(datetime.timezone.utc)
- worker.birth_date.replace(tzinfo=datetime.timezone.utc)
datetime.datetime.now(datetime.UTC) - worker.birth_date.replace(tzinfo=datetime.UTC)
).total_seconds()
return worker.total_working_time / total_time * 100

View file

@ -27,6 +27,7 @@ from frappe.tests.classes.context_managers import change_settings
from frappe.tests.test_api import FrappeAPITestCase
from frappe.tests.utils import toggle_test_mode
from frappe.utils import get_url
from frappe.www.login import sanitize_redirect
user_module = frappe.core.doctype.user.user
@ -333,7 +334,9 @@ class TestUser(IntegrationTestCase):
sign_up(random_user, random_user_name, "/welcome"),
(1, "Please check your email for verification"),
)
self.assertEqual(frappe.cache.hget("redirect_after_login", random_user), "/welcome")
self.assertEqual(
frappe.cache.hget("redirect_after_login", random_user), sanitize_redirect("/welcome")
)
# re-register
self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Already Registered"))

View file

@ -39,6 +39,7 @@ from frappe.utils.password import check_password, get_password_reset_limit
from frappe.utils.password import update_password as _update_password
from frappe.utils.user import get_system_managers
from frappe.website.utils import get_home_page, is_signup_disabled
from frappe.www.login import sanitize_redirect
desk_properties = (
"search_bar",
@ -1115,7 +1116,7 @@ def sign_up(email: str, full_name: str, redirect_to: str) -> tuple[int, str]:
user.add_roles(default_role)
if redirect_to:
frappe.cache.hset("redirect_after_login", user.name, redirect_to)
frappe.cache.hset("redirect_after_login", user.name, sanitize_redirect(redirect_to))
if user.flags.email_sent:
return 1, _("Please check your email for verification")

View file

@ -265,6 +265,15 @@ frappe.ui.form.on("Customize Form", {
),
default: 0,
},
{
fieldtype: "Check",
fieldname: "apply_module_export_filter",
label: __("Apply Module Export Filter"),
description: __(
"Export only customizations assigned to the selected module.<br><span class='text-muted'><strong>Note:</strong> You must set the <em>Module (for export)</em> field on Custom Field and Property Setter records before applying this filter.</span><p class='alert alert-warning'> <strong>Warning:</strong> Customizations from other modules will be excluded.</p>"
),
default: 0,
},
],
function (data) {
frappe.call({
@ -274,6 +283,7 @@ frappe.ui.form.on("Customize Form", {
module: data.module,
sync_on_migrate: data.sync_on_migrate,
with_permissions: data.with_permissions,
apply_module_export_filter: data.apply_module_export_filter,
},
});
},

View file

@ -807,6 +807,7 @@ docfield_properties = {
"link_filters": "JSON",
"placeholder": "Data",
"button_color": "Select",
"mask": "Check",
}
doctype_link_properties = {

View file

@ -55,7 +55,7 @@ class TestCustomizeForm(IntegrationTestCase):
d = self.get_customize_form("Event")
self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")), 44)
self.assertEqual(len(d.get("fields")), 49)
d = self.get_customize_form("Event")
self.assertEqual(d.doc_type, "Event")

View file

@ -24,6 +24,7 @@
"no_copy",
"allow_in_quick_entry",
"translatable",
"mask",
"link_filters",
"column_break_7",
"default",
@ -494,6 +495,13 @@
"fieldtype": "Select",
"label": "Button Color",
"options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger"
},
{
"default": "0",
"depends_on": "eval:[\"Select\", \"Read Only\", \"Phone\", \"Percent\", \"Password\", \"Link\", \"Int\", \"Float\", \"Dynamic Link\", \"Duration\", \"Datetime\", \"Currency\", \"Data\", \"Date\"].includes(doc.fieldtype)",
"fieldname": "mask",
"fieldtype": "Check",
"label": "Mask"
}
],
"grid_page_length": 50,
@ -501,7 +509,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-12 01:13:53.053888",
"modified": "2025-12-23 23:04:19.800253",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",

View file

@ -90,6 +90,7 @@ class CustomizeFormField(Document):
length: DF.Int
link_filters: DF.JSON | None
mandatory_depends_on: DF.Code | None
mask: DF.Check
no_copy: DF.Check
non_negative: DF.Check
options: DF.SmallText | None

View file

@ -315,6 +315,12 @@ class Database:
if auto_commit:
self.commit()
if self.db_type == "postgres" and getattr(self._cursor, "name", None):
"""named cursors in Postgres are lazy and don't retrieve column names immediately,
so explicitly performed here to avoid early exit during `unbuffered_cursor` usage
"""
self._cursor.fetchmany(0)
if not self._cursor.description:
return ()

View file

@ -570,5 +570,9 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
table = get_table_name(doctype)
count = self.sql("select table_rows from information_schema.tables where table_name = %s", table)
# Scope to current database to avoid cross-site estimates
count = self.sql(
"select table_rows from information_schema.tables where table_name = %s and table_schema = %s",
(table, frappe.db.cur_db_name),
)
return cint(count[0][0]) if count else 0

View file

@ -596,5 +596,9 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
table = get_table_name(doctype)
count = self.sql("select table_rows from information_schema.tables where table_name = %s", table)
# Scope to current database to avoid cross-site estimates
count = self.sql(
"select table_rows from information_schema.tables where table_name = %s and table_schema = %s",
(table, frappe.db.cur_db_name),
)
return cint(count[0][0]) if count else 0

View file

@ -1,4 +1,5 @@
import re
from contextlib import contextmanager
import psycopg2
import psycopg2.extensions
@ -488,9 +489,31 @@ class PostgresDatabase(PostgresExceptionUtil, Database):
from frappe.utils.data import cint
table = get_table_name(doctype)
count = self.sql("select reltuples from pg_class where relname = %s", table)
# Scope to current database to avoid cross-site estimates
count = self.sql(
"select c.reltuples from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.relname = %s and n.nspname = %s and c.relkind = 'r'",
(table, self.db_schema),
)
return cint(count[0][0]) if count else 0
@contextmanager
def unbuffered_cursor(self):
"""Unbuffered cursor in Postgres can only call .execute() once,
usage:
with frappe.db.unbuffered_cursor():
frappe.db.sql()
"""
try:
if not self._conn:
self.connect()
original_cursor = self._cursor
new_cursor = self._cursor = self._conn.cursor(name="ss_cursor")
yield
finally:
self._cursor = original_cursor
new_cursor.close()
def modify_query(query):
""" "Modifies query according to the requirements of postgres"""

View file

@ -1440,15 +1440,22 @@ class Engine:
For child tables (when parent_doctype is specified):
- permissions are checked against the parent doctype
- a join to the parent table is added
- conditions reference the parent table's fields
- for non-single parent doctypes: a join to the parent table is added,
conditions reference parent fields
- for single parent doctypes: all permissions are already checked by has_permission,
we exit early without adding any conditions
"""
if not self.apply_permissions:
return
# For child tables, join to parent table so permission conditions can reference it
if self.permission_doctype != self.doctype:
parent_meta = frappe.get_meta(self.permission_doctype)
if parent_meta.issingle:
# Child table of single doctype
# permissions are already checked by has_permission
return
self.query = self.query.inner_join(self.permission_table).on(
self.table.parent == self.permission_table.name
)

View file

@ -12,7 +12,7 @@ if TYPE_CHECKING:
queue_prefix = "insert_queue_for_"
def deferred_insert(doctype: str, records: list[Union[dict, "Document"]] | str):
def deferred_insert(doctype: str, records: list[dict | "Document"] | str):
if isinstance(records, dict | list):
_records = json.dumps(records)
else:
@ -48,7 +48,7 @@ def save_to_db():
frappe.db.commit()
def insert_record(record: Union[dict, "Document"], doctype: str):
def insert_record(record: dict | "Document", doctype: str):
try:
record.update({"doctype": doctype})
frappe.get_doc(record).insert()

View file

@ -72,6 +72,7 @@ def _bulk_action(doctype, docnames, action, data, task_id=None):
if data:
data = frappe.parse_json(data)
child_table_updates = data.get("child_table_updates") if data else None
failed = []
num_documents = len(docnames)
@ -90,7 +91,28 @@ def _bulk_action(doctype, docnames, action, data, task_id=None):
doc.cancel()
message = _("Cancelling {0}").format(doctype)
elif action == "update" and not doc.docstatus.is_cancelled():
doc.update(data)
# Handle child table updates
if child_table_updates:
table_fields = doc.meta.get_table_fields()
for child_doctype, field_updates in child_table_updates.items():
# Find the table field that contains this child doctype
table_fieldname = next(
(field.fieldname for field in table_fields if field.options == child_doctype),
None,
)
if table_fieldname and hasattr(doc, table_fieldname):
child_meta = frappe.get_meta(child_doctype)
child_docs = getattr(doc, table_fieldname)
for child_doc in child_docs:
for fieldname, value in field_updates.items():
if child_meta.has_field(fieldname):
setattr(child_doc, fieldname, value)
# Handle regular field updates
if data:
doc.update(data)
doc.save()
message = _("Updating {0}").format(doctype)
else:

View file

@ -14,6 +14,7 @@ class TestBulkUpdate(IntegrationTestCase):
def setUpClass(cls) -> None:
super().setUpClass()
cls.doctype = new_doctype(is_submittable=1, custom=1).insert().name
cls.child_doctype = new_doctype(istable=1, custom=1).insert().name
frappe.db.commit()
for _ in range(50):
frappe.new_doc(cls.doctype, some_fieldname=frappe.mock("name")).insert()
@ -46,3 +47,59 @@ class TestBulkUpdate(IntegrationTestCase):
submitted = frappe.get_all(self.doctype, {"docstatus": 1}, limit=20, pluck="name")
submit_cancel_or_update_docs(self.doctype, submitted, action="cancel")
self.wait_for_assertion(lambda: check_docstatus(submitted, 2))
def test_bulk_update_parent_fields(self):
docnames = frappe.get_all(self.doctype, {"docstatus": 0}, limit=5, pluck="name")
failed = submit_cancel_or_update_docs(
self.doctype, docnames, action="update", data={"some_fieldname": "_Test Sync"}
)
self.assertEqual(failed, [])
def check_field_values(docs, expected):
frappe.db.rollback()
values = frappe.get_all(self.doctype, {"name": ["in", docs]}, ["name", "some_fieldname"])
return all(v.some_fieldname == expected for v in values)
docnames_bg = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
submit_cancel_or_update_docs(
self.doctype, docnames_bg, action="update", data={"some_fieldname": "_Test Background"}
)
self.wait_for_assertion(lambda: check_field_values(docnames_bg, "_Test Background"))
def test_bulk_update_child_fields(self):
doctype_doc = frappe.get_doc("DocType", self.doctype)
doctype_doc.append(
"fields", {"fieldname": "child_table", "fieldtype": "Table", "options": self.child_doctype}
)
doctype_doc.save()
frappe.db.commit()
existing_docs = frappe.get_all(self.doctype, {"docstatus": 0}, pluck="name")
for docname in existing_docs:
doc = frappe.get_doc(self.doctype, docname)
doc.append("child_table", {"some_fieldname": "_Test Child Value"})
doc.save()
frappe.db.commit()
update_data = {
"child_table_updates": {
self.child_doctype: {"some_fieldname": "_Test Child Updated"},
}
}
def check_child_field(docs, expected):
frappe.db.rollback()
for docname in docs:
doc = frappe.get_doc(self.doctype, docname)
if not doc.child_table or doc.child_table[0].some_fieldname != expected:
return False
return True
docnames = frappe.get_all(self.doctype, {"docstatus": 0}, limit=5, pluck="name")
failed = submit_cancel_or_update_docs(self.doctype, docnames, action="update", data=update_data)
self.assertEqual(failed, [])
docnames_bg = frappe.get_all(self.doctype, {"docstatus": 0}, limit=20, pluck="name")
submit_cancel_or_update_docs(self.doctype, docnames_bg, action="update", data=update_data)
self.wait_for_assertion(lambda: check_child_field(docnames_bg, "_Test Child Updated"))

View file

@ -13,13 +13,14 @@
"event_category",
"event_type",
"color",
"send_reminder",
"repeat_this_event",
"location",
"column_break_4",
"starts_on",
"ends_on",
"status",
"sender",
"attending",
"all_day",
"sync_with_google_calendar",
"add_video_conferencing",
@ -43,14 +44,18 @@
"sunday",
"section_break_8",
"description",
"participants",
"participants_tab",
"event_participants",
"links_tab",
"section_break_jlqd",
"reference_doctype",
"column_break_pjkw",
"reference_docname",
"section_break_qemp",
"links"
"links",
"notifications_tab",
"send_reminder",
"notifications"
],
"fields": [
{
@ -210,12 +215,6 @@
"print_width": "300px",
"width": "300px"
},
{
"fieldname": "participants",
"fieldtype": "Section Break",
"label": "Participants",
"oldfieldtype": "Section Break"
},
{
"fieldname": "event_participants",
"fieldtype": "Table",
@ -327,14 +326,46 @@
{
"fieldname": "section_break_qemp",
"fieldtype": "Section Break"
},
{
"fieldname": "notifications_tab",
"fieldtype": "Tab Break",
"label": "Notifications"
},
{
"fieldname": "notifications",
"fieldtype": "Table",
"label": "Notifications",
"options": "Event Notifications"
},
{
"fieldname": "location",
"fieldtype": "Data",
"label": "Location"
},
{
"fieldname": "participants_tab",
"fieldtype": "Tab Break",
"label": "Participants"
},
{
"fieldname": "links_tab",
"fieldtype": "Tab Break",
"label": "Links"
},
{
"fieldname": "attending",
"fieldtype": "Select",
"label": "Attending",
"options": "\nYes\nNo\nMaybe"
}
],
"grid_page_length": 50,
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
"modified": "2025-08-12 13:37:36.319985",
"modified_by": "Administrator",
"modified": "2025-10-07 08:53:24.732646",
"modified_by": "shariq@frappe.io",
"module": "Desk",
"name": "Event",
"naming_rule": "Expression (old style)",

View file

@ -14,6 +14,7 @@ from frappe.desk.doctype.notification_settings.notification_settings import (
)
from frappe.desk.reportview import get_filters_cond
from frappe.model.document import Document
from frappe.model.utils.user_settings import get_user_settings, sync_user_settings, update_user_settings
from frappe.utils import (
add_days,
add_months,
@ -53,11 +54,13 @@ class Event(Document):
if TYPE_CHECKING:
from frappe.core.doctype.dynamic_link.dynamic_link import DynamicLink
from frappe.desk.doctype.event_notifications.event_notifications import EventNotifications
from frappe.desk.doctype.event_participants.event_participants import EventParticipants
from frappe.types import DF
add_video_conferencing: DF.Check
all_day: DF.Check
attending: DF.Literal["", "Yes", "No", "Maybe"]
color: DF.Color | None
description: DF.TextEditor | None
ends_on: DF.Datetime | None
@ -68,9 +71,11 @@ class Event(Document):
google_calendar: DF.Link | None
google_calendar_event_id: DF.Data | None
google_calendar_id: DF.Data | None
google_meet_link: DF.Data | None
google_meet_link: DF.SmallText | None
links: DF.Table[DynamicLink]
location: DF.Data | None
monday: DF.Check
notifications: DF.Table[EventNotifications]
pulled_from_google_calendar: DF.Check
reference_docname: DF.DynamicLink | None
reference_doctype: DF.Link | None
@ -124,6 +129,8 @@ class Event(Document):
for communication in communications:
frappe.delete_doc("Communication", communication, force=True)
self.remove_event_from_user_settings()
def sync_communication(self):
if not self.event_participants:
return
@ -209,6 +216,40 @@ class Event(Document):
frappe.get_value("Contact", participant_contact, "email_id") if participant_contact else None
)
def remove_event_from_user_settings(self):
user_settings = get_user_settings("Event", for_update=True)
if user_settings:
user_settings = json.loads(user_settings)
if "notifications" in user_settings:
notifications = user_settings.get("notifications")
completedEvents = notifications.get("completedEvents", [])
if self.name in completedEvents:
completedEvents.remove(self.name)
notifications["completedEvents"] = completedEvents
updated_notifications = notifications
user_settings["notifications"] = updated_notifications
update_user_settings("Event", json.dumps(user_settings), for_update=True)
sync_user_settings()
@frappe.whitelist()
def update_attending_status(event_name, attendee, status):
event_doc = frappe.get_doc("Event", event_name)
if event_doc.owner == attendee == frappe.session.user:
frappe.db.set_value("Event", event_name, "attending", status)
return
for participant in event_doc.event_participants:
if participant.email == attendee:
frappe.db.set_value("Event Participants", participant.name, "attending", status)
return
if not has_permission(event_doc, user=attendee):
frappe.throw(_("You are not allowed to update the status of this event."))
@frappe.whitelist()
def delete_communication(event, reference_doctype, reference_docname):
@ -238,13 +279,22 @@ def get_permission_query_conditions(user):
query = f"""(`tabEvent`.`event_type`='Public' or `tabEvent`.`owner`={frappe.db.escape(user)})"""
if shared_events := frappe.share.get_shared("Event", user=user):
query += f" or `tabEvent`.`name` in ({', '.join([frappe.db.escape(e) for e in shared_events])})"
query += f" or exists (select 'x' from `tabEvent Participants` ep where ep.parent=`tabEvent`.name and ep.email={frappe.db.escape(user)})"
return query
def has_permission(doc, user):
def has_permission(doc, ptype=None, user=None):
if doc.event_type == "Public" or doc.owner == user:
return True
for participant in doc.event_participants:
if participant.email == user:
if ptype in ["write", "create", "delete"]:
return False
return True
return False
@ -285,7 +335,7 @@ def get_events(
start: date, end: date, user: str | None = None, for_reminder: bool = False, filters=None
) -> list[frappe._dict]:
user = user or frappe.session.user
EventLikeDict: TypeAlias = Event | frappe._dict
type EventLikeDict = Event | frappe._dict
resolved_events: list[EventLikeDict] = []
if isinstance(filters, str):

View file

@ -0,0 +1,57 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-10-01 14:19:43.271468",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"type",
"before",
"interval",
"time"
],
"fields": [
{
"default": "Notification",
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Notification\nEmail"
},
{
"fieldname": "before",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Before"
},
{
"fieldname": "interval",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Interval"
},
{
"fieldname": "time",
"fieldtype": "Time",
"in_list_view": 1,
"label": "Time"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-01 14:26:21.526868",
"modified_by": "Administrator",
"module": "Desk",
"name": "Event Notifications",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,26 @@
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class EventNotifications(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
before: DF.Int
interval: DF.Literal[None]
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
time: DF.Time | None
type: DF.Literal["Notification", "Email"]
# end: auto-generated types
pass

View file

@ -7,7 +7,8 @@
"field_order": [
"reference_doctype",
"reference_docname",
"email"
"email",
"attending"
],
"fields": [
{
@ -31,19 +32,27 @@
"fieldtype": "Data",
"label": "Email",
"options": "Email"
},
{
"fieldname": "attending",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Attending",
"options": "\nYes\nNo\nMaybe"
}
],
"istable": 1,
"links": [],
"modified": "2024-03-23 16:03:25.717745",
"modified_by": "Administrator",
"modified": "2025-10-06 15:03:36.186683",
"modified_by": "shariq@frappe.io",
"module": "Desk",
"name": "Event Participants",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -12,6 +12,7 @@ class EventParticipants(Document):
if TYPE_CHECKING:
from frappe.types import DF
attending: DF.Literal["", "Yes", "No", "Maybe"]
email: DF.Data | None
parent: DF.Data
parentfield: DF.Data

View file

@ -49,9 +49,9 @@ frappe.ui.form.on("System Console", {
frm.sql_output.destroy();
frm.get_field("sql_output").html("");
}
frm.trigger("load_completions");
}
frm.trigger("load_completions");
const field = frm.get_field("console");
field.df.options = frm.doc.type;
field.set_language();
@ -130,6 +130,7 @@ frappe.ui.form.on("System Console", {
load_completions(frm) {
if (frm.doc.type != "Python") {
frm.set_df_property("console", "autocompletions", []);
return;
}
setTimeout(() => {
frappe

View file

@ -24,7 +24,7 @@ if typing.TYPE_CHECKING:
def getdoc(doctype, name):
"""
Loads a doclist for a given document. This method is called directly from the client.
Requries "doctype", "name" as form variables.
Requires "doctype", "name" as form variables.
Will also call the "onload" method on the document.
"""
@ -182,7 +182,7 @@ def get_milestones(doctype, name):
def get_attachments(dt, dn):
return frappe.get_all(
"File",
fields=["name", "file_name", "file_url", "is_private", "file_type", "file_size"],
fields=["name", "file_name", "file_url", "is_private"],
filters={"attached_to_name": str(dn), "attached_to_doctype": dt},
)

View file

@ -10,7 +10,7 @@ from frappe.utils.data import convert_utc_to_system_timezone
def get_time(path: Path):
return convert_utc_to_system_timezone(
datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.timezone.utc)
datetime.datetime.fromtimestamp(path.stat().st_mtime, tz=datetime.UTC)
).strftime("%a %b %d %H:%M %Y")

View file

@ -22,6 +22,7 @@
.navbar-container{
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
width: 100%;
padding: 10px 20px 10px 20px;
@ -41,11 +42,15 @@
color: var(--text-light);
}
.desktop-navbar-modal-search{
.desktop-navbar-modal-search {
background-color: var(--control-bg);
border-radius: var(--border-radius-sm);
padding: 6px 10px;
height: 32px;
padding: 0px 10px;
display: flex;
align-items: center;
width: 100%;
margin-right: 0px;
}
#brand-logo{
width: auto;
@ -60,6 +65,11 @@
align-items: center;
justify-content: center;
flex-direction: row;
.icons-container {
display: flex;
flex-direction: column;
gap: 10px;
}
}
.icon-stroke{
@ -91,6 +101,10 @@
align-items: center;
gap: 12px;
padding: 13px 16px 12px 16px;
position: relative;
}
.desktop-icon.edit-mode .hide-button {
display: flex;
}
.icon-container:has(.app-logo) {
padding: 0;
@ -99,10 +113,16 @@
.icon-container img{
width: var(--desktop-icon-dimension);
height: var(--desktop-icon-dimension);
user-drag: none;
-webkit-user-drag: none;
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
}
.icon-container{
padding: 10px;
border-radius: 10px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
@ -112,10 +132,6 @@
.icon-container:has(.icon){
background-color: var(--surface-gray-3);
}
.icon-container .icon{
width: 27px;
height: 27px;
}
.icon-container:hover{
transform: scale(1.05);
transition: transform 0.1s;
@ -162,6 +178,7 @@
.desktop-modal{
backdrop-filter: var(--desktop-blur);
display: flex !important;
justify-content: center;
& .modal-dialog{
& .modal-content {
top: 120px;
@ -234,6 +251,7 @@
}
.folder-icon{
border-radius: 10px;
background-color: var(--folder-icon-background-color) !important;
box-shadow: 0px 0px 1px 0px rgba(0, 0, 0, 0.14);
padding: 7px;
@ -286,4 +304,132 @@
}
.right-page-arrow, .left-page-arrow{
margin: 0px;
}
}
.bounce {
animation: bounceIn 2s infinite 2s;
}
@keyframes bounceIn {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
opacity: 1;
}
20% {
transform: translateY(-30px);
}
60% {
transform: translateY(-15px);
}
}
.hide-button{
display: none;
background-color: var(--surface-gray-2);
position: absolute;
border-radius: 67px;
width: 24px;
height: 24px;
padding: 4px;
text-align: center;
vertical-align: center;
align-items: center;
top: -5px;
left: 108px;
}
.edit-mode{
border: 1px dashed var(--outline-gray-2);
border-radius: 20px;
}
.edit-mode-buttons{
display: none;
gap: 4px;
}
.desktop-wrapper[data-mode="Edit"]{
.edit-mode-buttons{
display: flex;
justify-content: flex-end;
position: absolute;
bottom: 5%;
right: 5%;
}
}
/* @media screen and (max-width: 320px) {
.icons{
grid-template-columns: repeat(2, 1fr) !important;
}
} */
@media screen and (max-width: 570px) {
:root {
--desktop-icon-dimension: 50px;
--desktop-icon-container: 117px;
--folder-thumbnail-icon-height:17px;
}
.desktop-container {
padding: 20px 13px;
> .icons-container {
width: 100%;
margin-top: 0;
padding: 0;
> .icons {
gap: 8px;
row-gap: 12px;
@media screen and (max-width: 380px) {
--desktop-icon-container: 100px;
.folder-icon > .icons{
grid-template-columns: repeat(2, 1fr) I !important;
}
}
> .desktop-icon {
width: var(--desktop-icon-container);
height: var(--desktop-icon-container);
}
}
}
}
.desktop-modal-body {
width: 90vw;
> .icons-container {
width: 100%;
overflow: hidden !important;
margin: 0px;
margin-top: 0;
padding: 0;
> .icons {
position: relative;
right: 6%;
column-gap: 4px;
row-gap: 8px;
@media screen and (max-width: 380px) {
--desktop-icon-container: 100px;
.folder-icon > .icons{
grid-template-columns: repeat(2, 1fr) I !important;
}
}
> .desktop-icon {
width: var(--desktop-icon-container);
height: var(--desktop-icon-container);
}
}
}
}
.folder-icon > .icons-container {
overflow: hidden;
> .icons {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: repeat(2, 1fr) !important;
}
}
}

View file

@ -19,14 +19,22 @@
{{ _("Search") }}
</span>
<span>
{{ "⌘ K" if is_mac else "Ctrl K" }}
{{ "⌘K" if is_mac else "Ctrl+K" }}
</span>
</button>
</div>
<div class="desktop-avatar" style="margin-left: -10px;">
<div class="desktop-avatar">
</div>
</header>
<div class="desktop-container">
</div>
<div class="edit-mode-buttons">
<button class="discard btn btn-default ellipsis">
{{ _("Discard") }}
</button>
<button class="save btn btn-primary ellipsis">
{{ _("Save") }}
</button>
</div>`
</div>

View file

@ -1,4 +1,6 @@
frappe.desktop_utils = {};
frappe.desktop_grids = [];
frappe.desktop_icons_objects = [];
$.extend(frappe.desktop_utils, {
modal: null,
modal_stack: [],
@ -59,10 +61,14 @@ function get_route(desktop_icon) {
}
function get_desktop_icon_by_label(title, filters) {
let icons = frappe.desktop_icons;
if (frappe.pages["desktop"].desktop_page.edit_mode) {
icons = frappe.new_desktop_icons;
}
if (!filters) {
return frappe.boot.desktop_icons.find((f) => f.label === title && f.hidden != 1);
return icons.find((f) => f.label === title && f.hidden != 1);
} else {
return frappe.boot.desktop_icons.find((f) => {
return icons.find((f) => {
return (
f.label === title &&
Object.keys(filters).every((key) => f[key] === filters[key]) &&
@ -76,12 +82,9 @@ function get_desktop_icon_by_idx(idx, parent_icon) {
return frappe.boot.desktop_icons.find((f) => f.idx == idx && f.parent_icon == parent_icon);
}
function save_desktop() {
function save_desktop(icons) {
// saving in localStorage;
localStorage.setItem(
`${frappe.session.user}:desktop`,
JSON.stringify(frappe.boot.desktop_icons)
);
localStorage.setItem(`${frappe.session.user}:desktop`, JSON.stringify(icons));
frappe.toast("Desktop Saved");
frappe.pages["desktop"].desktop_page.update();
}
@ -103,8 +106,10 @@ function toggle_icons(icons) {
class DesktopPage {
constructor(page) {
this.page = page;
this.edit_mode = false;
this.prepare();
this.make(page);
this.setup_events();
}
update() {
this.prepare();
@ -116,10 +121,11 @@ class DesktopPage {
this.apps_icons = [];
const icon_map = {};
const all_icons = (
frappe.desktop_icons =
JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`)) ||
frappe.boot.desktop_icons
).filter((icon) => {
frappe.boot.desktop_icons;
let icons = this.edit_mode ? frappe.new_desktop_icons : frappe.desktop_icons;
const all_icons = icons.filter((icon) => {
if (icon.hidden != 1) {
icon.child_icons = [];
icon_map[icon.label] = icon;
@ -127,7 +133,6 @@ class DesktopPage {
}
return false;
});
all_icons.forEach((icon) => {
if (icon.parent_icon && icon_map[icon.parent_icon]) {
icon_map[icon.parent_icon].child_icons.push(icon);
@ -138,7 +143,16 @@ class DesktopPage {
}
});
}
setup_events() {
this.wrapper.find(".hide-button").on("click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
let desktop_label = event.currentTarget.parentElement.dataset.id;
let desktop_icon = get_desktop_icon_by_label(desktop_label);
desktop_icon.hidden = 1;
frappe.pages["desktop"].desktop_page.update();
});
}
make() {
this.page.page_head.hide();
$(this.page.body).empty();
@ -152,22 +166,103 @@ class DesktopPage {
col: 3,
},
});
this.setup_editing_mode();
if (this.edit_mode) {
this.start_editing_layout();
}
}
setup() {
this.setup_avatar();
this.setup_navbar();
this.setup_awesomebar();
this.setup_editing_mode();
this.handle_route_change();
this.setup_events();
}
setup_editing_mode() {
const me = this;
let menu_items = [
{
label: "Edit Layout",
icon: "edit",
onClick: function () {
frappe.new_desktop_icons = JSON.parse(JSON.stringify(frappe.desktop_icons));
me.start_editing_layout();
},
},
{
label: "Reset Layout",
icon: "rotate-ccw",
onClick: function () {
reset_to_default();
me.update();
},
},
];
frappe.ui.create_menu({
parent: this.wrapper,
menu_items: menu_items,
right_click: true,
});
}
stop_editing_layout(action) {
this.edit_mode = false;
$(".desktop-icon").removeClass("edit-mode");
$(".desktop-wrapper").removeAttr("data-mode");
if (action === "cancel") {
frappe.new_desktop_icons = null;
this.update();
return;
}
// submit
save_desktop(frappe.new_desktop_icons);
}
start_editing_layout() {
this.edit_mode = true;
$(".desktop-icon").addClass("edit-mode");
$(".desktop-wrapper").attr("data-mode", "Edit");
frappe.desktop_grids.forEach((desktop_grid) => {
if (!desktop_grid.no_dragging) {
desktop_grid.grids.forEach((grid) => {
desktop_grid.setup_reordering(grid);
});
}
});
frappe.desktop_icons_objects.forEach((icon_object) => {
icon_object.setup_dragging();
});
if (this.edit_mode) this.setup_edit_buttons();
}
setup_edit_buttons() {
const me = this;
this.$edit_button = $(".edit-mode-buttons");
this.$edit_button.find(".discard").on("click", function () {
me.stop_editing_layout("cancel");
});
this.$edit_button.find(".save").on("click", function () {
me.stop_editing_layout("submit");
});
}
setup_avatar() {
$(".desktop-avatar").html(frappe.avatar(frappe.session.user, "avatar-medium"));
let is_dark = document.documentElement.getAttribute("data-theme") === "dark";
let menu_items = [
{
icon: "edit",
label: "Edit Profile",
url: `/update-profile/${frappe.session.user}`,
},
{
icon: is_dark ? "sun" : "moon",
label: "Toggle Theme",
onClick: function () {
new frappe.ui.ThemeSwitcher().show();
},
},
{
icon: "lock",
label: "Reset Password",
@ -281,6 +376,7 @@ class DesktopIconGrid {
this.grids = [];
this.prepare();
this.make();
frappe.desktop_grids.push(this);
}
prepare() {
@ -308,9 +404,9 @@ class DesktopIconGrid {
}
this.grids.push($(template).appendTo(this.icons_container));
this.make_icons(this.icons_data_by_page, this.grids[i]);
if (!this.no_dragging) {
this.setup_reordering(this.grids[i]);
}
// if (!this.no_dragging) {
// this.setup_reordering(this.grids[i]);
// }
}
if (!this.in_folder && this.total_pages > 1) {
this.add_page_indicators();
@ -450,6 +546,7 @@ class DesktopIconGrid {
if (!frappe.is_mobile()) {
this.sortable = new Sortable($(grid).get(0), {
swapThreshold: 0.09,
desktop: true,
animation: 150,
sort: true, // keep sorting normally
dragoverBubble: true,
@ -458,6 +555,9 @@ class DesktopIconGrid {
put: true,
pull: true,
},
onStart(evt) {
frappe.desktop_utils.dragged_item = evt.item;
},
setData: function (/** DataTransfer */ dataTransfer, /** HTMLElement*/ dragEl) {
let title = $(dragEl).find(".icon-title").text();
let icon = me.icons.find((d) => {
@ -465,7 +565,11 @@ class DesktopIconGrid {
});
dataTransfer.setData("text/plain", JSON.stringify(icon.icon_data)); // `dataTransfer` object of HTML5 DragEvent
},
onMove() {
return frappe.desktop_utils.allow_move || false;
},
onEnd: function (evt) {
if (frappe.desktop_utils.in_folder_creation) return;
if (evt.oldIndex !== evt.newIndex) {
if (evt.to.parentElement == evt.from.parentElement) {
let reordered_icons = me.sortable.toArray();
@ -490,10 +594,8 @@ class DesktopIconGrid {
selected_icon.parent_icon = null;
}
}
} else {
frappe.toast("Nothing changed");
}
save_desktop();
// save_desktop();
},
});
}
@ -505,7 +607,7 @@ class DesktopIconGrid {
icon.idx = idx;
}
});
frappe.boot.desktop_icons.sort((a, b) => a.idx - b.idx);
frappe.desktop_icons.sort((a, b) => a.idx - b.idx);
}
add_to_main_screen(title) {
let icon = get_desktop_icon_by_label(title);
@ -520,11 +622,14 @@ class DesktopIcon {
this.icon_subtitle = "";
this.icon_type = this.icon_data.icon_type;
this.in_folder = in_folder;
this.icon_data.in_folder = in_folder;
this.link_type = this.icon_data.link_type;
if (this.icon_type != "Folder" && !this.icon_data.sidebar) {
this.icon_route = get_route(this.icon_data);
}
this.child_icons = this.get_child_icons_data();
if (this.icon_data.child_icons) {
this.child_icons = this.get_child_icons_data();
}
let render = this.validate_icon();
if (render) {
this.icon = $(
@ -537,7 +642,7 @@ class DesktopIcon {
this.parent_icon = this.icon_data.icon;
this.setup_click();
this.render_folder_thumbnail();
this.setup_dragging();
frappe.desktop_icons_objects.push(this);
}
// this.child_icons = this.get_desktop_icon(this.icon_title).child_icons;
@ -563,7 +668,7 @@ class DesktopIcon {
}
setup_click() {
const me = this;
if (this.child_icons.length && (this.icon_type == "App" || this.icon_type == "Folder")) {
if (this.child_icons?.length && (this.icon_type == "App" || this.icon_type == "Folder")) {
$(this.icon).on("click", () => {
let modal = frappe.desktop_utils.create_desktop_modal(me);
modal.setup(me.icon_title, me.child_icons, 4);
@ -621,6 +726,7 @@ class DesktopIcon {
}
setup_dragging() {
if (!frappe.pages["desktop"].desktop_page.edit_mode) return;
this.icon.on("drag", (event) => {
const mouse_x = event.clientX;
const mouse_y = event.clientY;
@ -639,6 +745,51 @@ class DesktopIcon {
}
}
});
this.icon.on("dragstart", function (event) {
frappe.desktop_utils.dragged_item = event.target;
});
this.icon.on("dragover", function (event) {
console.log(event.target);
if (frappe.desktop_utils.dragged_item == event.target.parentElement) return;
if (
event.target == frappe.desktop_utils.dragged_item ||
frappe.desktop_utils.dragged_item.contains(event.target)
) {
return;
}
if (event.target.parentElement.classList.contains("icon-container")) {
frappe.desktop_utils.allow_move = false;
frappe.desktop_utils.in_folder_creation = true;
let icon_list = [];
icon_list.push(
get_desktop_icon_by_label(event.target.parentElement.parentElement.dataset.id)
);
icon_list.push(
get_desktop_icon_by_label(frappe.desktop_utils.dragged_item.dataset.id)
);
let icon = {
label: "Untitled Folder",
icon_type: "Folder",
child_icons: icon_list,
};
let modal = frappe.desktop_utils.create_desktop_modal(icon);
modal.setup(icon.label, icon_list, 4);
$(event.target.parentElement).addClass("folder-icon");
$(event.target.parentElement).empty();
modal.show();
frappe.boot.desktop_icons.push(icon);
icon_list.forEach((icon) => {
let desktop_icon = frappe.utils.get_desktop_icon_by_label(icon.label);
desktop_icon.parent_icon = "Untitled Folder";
frappe.new_desktop_icons.splice(frappe.boot.desktop_icons.indexOf(icon), 1);
frappe.new_desktop_icons.push(desktop_icon);
});
} else {
frappe.desktop_utils.allow_move = true;
}
});
}
}

View file

@ -497,10 +497,9 @@ def build_xlsx_data(
include_hidden_columns = cint(include_hidden_columns)
include_indentation = cint(include_indentation)
if cint(include_filters):
if cint(include_filters) and data.filters:
filter_data = []
filters = data.filters
for filter_name, filter_value in filters.items():
for filter_name, filter_value in data.filters.items():
if not filter_value:
continue
filter_value = (

View file

@ -3,9 +3,10 @@
import json
import re
from typing import TypedDict
from typing_extensions import NotRequired # not required in 3.11+
from typing import (
NotRequired, # not required in 3.11+
TypedDict,
)
import frappe

View file

@ -1,6 +1,8 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import frappe
@ -148,7 +150,7 @@ def sendmail(
email_read_tracker_url=None,
x_priority: Literal[1, 3, 5] = 3,
email_headers=None,
) -> Optional["EmailQueue"]:
) -> EmailQueue | None:
"""Send email using user's default **Email Account** or global default **Email Account**.

View file

@ -403,7 +403,7 @@ class Email:
if self.mail["Date"]:
try:
utc = email.utils.mktime_tz(email.utils.parsedate_tz(self.mail["Date"]))
utc_dt = datetime.datetime.fromtimestamp(utc, tz=datetime.timezone.utc)
utc_dt = datetime.datetime.fromtimestamp(utc, tz=datetime.UTC)
self.date = convert_utc_to_system_timezone(utc_dt).strftime("%Y-%m-%d %H:%M:%S")
except Exception:
self.date = now()

View file

@ -38,9 +38,10 @@ app_include_css = [
"report.bundle.css",
]
app_include_icons = [
"/assets/frappe/icons/icons.svg",
"/assets/frappe/icons/lucide/icons.svg",
"/assets/frappe/icons/timeless/icons.svg",
"/assets/frappe/icons/espresso/icons.svg",
"/assets/frappe/icons/desktop_icons/alphabets.svg",
]
doctype_js = {
@ -51,6 +52,7 @@ doctype_js = {
web_include_js = ["website_script.js"]
web_include_css = []
web_include_icons = [
"/assets/frappe/icons/lucide/icons.svg",
"/assets/frappe/icons/timeless/icons.svg",
"/assets/frappe/icons/espresso/icons.svg",
]

View file

@ -74,8 +74,8 @@ class TokenCache(Document):
def get_expires_in(self):
system_timezone = ZoneInfo(get_system_timezone())
modified: datetime.datetime = get_datetime(self.modified).replace(tzinfo=system_timezone)
expiry_utc = modified.astimezone(datetime.timezone.utc) + datetime.timedelta(seconds=self.expires_in)
now_utc = datetime.datetime.now(datetime.timezone.utc)
expiry_utc = modified.astimezone(datetime.UTC) + datetime.timedelta(seconds=self.expires_in)
now_utc = datetime.datetime.now(datetime.UTC)
return cint((expiry_utc - now_utc).total_seconds())
def is_expired(self):

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,8 @@ from collections.abc import Generator, Iterable
from contextlib import contextmanager
from functools import wraps
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union, overload
from typing import TYPE_CHECKING, Any, Literal, Optional, Self, TypeAlias, Union, overload, override
from typing_extensions import Self, override
from werkzeug.exceptions import NotFound
import frappe
@ -34,7 +33,7 @@ from frappe.utils.data import get_absolute_url, get_datetime, get_timedelta, get
from frappe.utils.global_search import update_global_search
if TYPE_CHECKING:
from typing_extensions import Self
from typing import Self
from frappe.core.doctype.docfield.docfield import DocField
@ -43,8 +42,8 @@ DOCUMENT_LOCK_EXPIRY = 3 * 60 * 60 # All locks expire in 3 hours automatically
DOCUMENT_LOCK_SOFT_EXPIRY = 30 * 60 # Let users force-unlock after 30 minutes
_SingleDocument: TypeAlias = "Document"
_NewDocument: TypeAlias = "Document"
type _SingleDocument = "Document"
type _NewDocument = "Document"
@overload
@ -614,7 +613,7 @@ class Document(BaseDocument):
for df in self.meta.get_table_fields():
self.update_child_table(df.fieldname, df)
def update_child_table(self, fieldname: str, df: Optional["DocField"] = None):
def update_child_table(self, fieldname: str, df: "DocField" | None = None):
"""sync child table for given fieldname"""
df: DocField = df or self.meta.get_field(fieldname)
if df.is_virtual:
@ -1994,7 +1993,7 @@ def bulk_insert(
def _document_values_generator(
documents: Iterable["Document"],
columns: list[str],
) -> Generator[tuple[Any], None, None]:
) -> Generator[tuple[Any]]:
for doc in documents:
doc.creation = doc.modified = now()
doc.owner = doc.modified_by = frappe.session.user
@ -2140,7 +2139,7 @@ def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
def new_doc(
doctype: str,
*,
parent_doc: Optional["Document"] = None,
parent_doc: "Document" | None = None,
parentfield: str | None = None,
as_dict: bool = False,
**kwargs,

View file

@ -502,7 +502,9 @@ class Meta(Document):
recent_change = frappe.db.sql(
f"SELECT `creation` FROM `tab{self.name}` ORDER BY `creation` DESC LIMIT 1"
) # nosemgrep
if get_datetime(recent_change[0][0]) > add_to_date(None, days=-1 * LARGE_TABLE_RECENCY_THRESHOLD):
if recent_change and get_datetime(recent_change[0][0]) > add_to_date(
None, days=-1 * LARGE_TABLE_RECENCY_THRESHOLD
):
self.is_large_table = True
@cached_property

View file

@ -200,7 +200,7 @@ def set_new_name(doc):
doc.name = validate_name(doc.doctype, doc.name)
def is_autoincremented(doctype: str, meta: Optional["Meta"] = None) -> bool:
def is_autoincremented(doctype: str, meta: "Meta" | None = None) -> bool:
"""Checks if the doctype has autoincrement autoname set"""
if not meta:
@ -328,7 +328,7 @@ def _generate_random_string(length=10):
def parse_naming_series(
parts: list[str] | str,
doctype=None,
doc: Optional["Document"] = None,
doc: "Document" | None = None,
number_generator: Callable[[str, int], str] | None = None,
) -> str:
"""Parse the naming series and get next name.

View file

@ -166,9 +166,9 @@ def update_user_settings(doctype, old_fieldname, new_fieldname):
sync_user_settings()
user_settings = frappe.db.sql(
''' select user, doctype, data from `__UserSettings`
where doctype=%s and data like "%%%s%%"''',
(doctype, old_fieldname),
""" select user, doctype, data from `__UserSettings`
where doctype=%s and data like %s""",
(doctype, f"%{old_fieldname}%"),
as_dict=1,
)

View file

@ -1,8 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from __future__ import annotations
import json
from collections import defaultdict
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
import frappe
from frappe import _
@ -40,7 +43,7 @@ def get_workflow_name(doctype):
@frappe.whitelist()
def get_transitions(
doc: Union["Document", str, dict], workflow: "Workflow" = None, raise_exception: bool = False
doc: Document | str | dict, workflow: Workflow = None, raise_exception: bool = False
) -> list[dict]:
"""Return list of possible transitions for the given doc"""
from frappe.model.document import Document

View file

@ -56,21 +56,41 @@ def get_doc_module(module: str, doctype: str, name: str) -> "ModuleType":
@frappe.whitelist()
def export_customizations(
module: str, doctype: str, sync_on_migrate: bool = False, with_permissions: bool = False
module: str,
doctype: str,
sync_on_migrate: bool = False,
with_permissions: bool = False,
apply_module_export_filter: bool = False,
):
"""Export Custom Field and Property Setter for the current document to the app folder.
This will be synced with bench migrate"""
sync_on_migrate = cint(sync_on_migrate)
with_permissions = cint(with_permissions)
apply_module_export_filter = cint(apply_module_export_filter)
cf_filters = {"dt": doctype}
ps_filters = {"doc_type": doctype}
if apply_module_export_filter:
cf_filters["module"] = module
ps_filters["module"] = module
if not frappe.conf.developer_mode:
frappe.throw(_("Only allowed to export customizations in developer mode"))
custom = {
"custom_fields": frappe.get_all("Custom Field", fields="*", filters={"dt": doctype}, order_by="name"),
"custom_fields": frappe.get_all(
"Custom Field",
fields="*",
filters=cf_filters,
order_by="name",
),
"property_setters": frappe.get_all(
"Property Setter", fields="*", filters={"doc_type": doctype}, order_by="name"
"Property Setter",
fields="*",
filters=ps_filters,
order_by="name",
),
"custom_perms": [],
"links": frappe.get_all("DocType Link", fields="*", filters={"parent": doctype}, order_by="name"),
@ -85,7 +105,9 @@ def export_customizations(
# also update the custom fields and property setters for all child tables
for d in frappe.get_meta(doctype).get_table_fields():
export_customizations(module, d.options, sync_on_migrate, with_permissions)
export_customizations(
module, d.options, sync_on_migrate, with_permissions, apply_module_export_filter
)
if custom["custom_fields"] or custom["property_setters"] or custom["custom_perms"]:
folder_path = os.path.join(get_module_path(module), "custom")
@ -301,9 +323,7 @@ def get_app_publisher(module: str) -> str:
return frappe.get_hooks(hook="app_publisher", app_name=app)[0]
def make_boilerplate(
template: str, doc: Union["Document", "frappe._dict"], opts: Union[dict, "frappe._dict"] = None
):
def make_boilerplate(template: str, doc: "Document" | "frappe._dict", opts: dict | "frappe._dict" = None):
target_path = get_doc_path(doc.module, doc.doctype, doc.name)
template_name = template.replace("controller", scrub(doc.name))
if template_name.endswith("._py"):

View file

@ -52,7 +52,7 @@ class Monitor:
self.data = frappe._dict(
{
"site": frappe.local.site,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
"timestamp": datetime.datetime.now(datetime.UTC),
"transaction_type": transaction_type,
"uuid": str(uuid.uuid4()),
}
@ -85,7 +85,7 @@ class Monitor:
if job := rq.get_current_job():
self.data.job_id = job.id
waitdiff = self.data.timestamp - job.enqueued_at.replace(tzinfo=datetime.timezone.utc)
waitdiff = self.data.timestamp - job.enqueued_at.replace(tzinfo=datetime.UTC)
self.data.job.wait = int(waitdiff.total_seconds() * 1000000)
def add_custom_data(self, **kwargs):
@ -94,7 +94,7 @@ class Monitor:
def dump(self, response=None):
try:
timediff = datetime.datetime.now(datetime.timezone.utc) - self.data.timestamp
timediff = datetime.datetime.now(datetime.UTC) - self.data.timestamp
# Obtain duration in microseconds
self.data.duration = int(timediff.total_seconds() * 1000000)

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