Merge branch 'develop' into 32489-role-perm-based-masking

This commit is contained in:
Ejaaz Khan 2025-09-02 10:48:06 +05:30 committed by GitHub
commit 590fe7e520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
185 changed files with 94958 additions and 30590 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

@ -40,7 +40,7 @@ jobs:
env:
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: ./.github/actions/setup
name: Environment Setup
with:

View file

@ -78,7 +78,7 @@ jobs:
- 2525:25
- 3000:80
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: ./.github/actions/setup
name: Environment Setup
with:

View file

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

@ -62,7 +62,7 @@ jobs:
env:
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: ./.github/actions/setup
name: Environment Setup
with:

View file

@ -11,7 +11,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout Actions
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
repository: "frappe/backport"
path: ./actions

View file

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

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,7 +19,7 @@ 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
@ -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
@ -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,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
path: 'frappe'

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
path: 'frappe'
- uses: actions/setup-node@v4

View file

@ -14,7 +14,7 @@ jobs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- id: set-matrix
run: |
# Use grep and find to get the list of test files
@ -69,7 +69,7 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Python
uses: actions/setup-python@v5

View file

@ -30,7 +30,7 @@ jobs:
build: ${{ steps.check-build.outputs.build }}
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check if unit tests should be run
id: check-build
run: |
@ -68,9 +68,9 @@ jobs:
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download artifacts
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v5.0.0
- name: Upload coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -27,7 +27,7 @@ jobs:
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Check if build should be run
id: check-build
@ -55,9 +55,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download artifacts
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v5.0.0
- name: Upload python coverage data
uses: codecov/codecov-action@v5
with:

View file

@ -21,10 +21,15 @@
## Frappe Framework
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for ERPNext.
### Motivation
## Philosophy
> The best code is the one that is not written
Started in 2005, Frappe Framework was inspired by the Semantic Web. The "big idea" behind semantic web was of a framework that not only described how information is shown (like headings, body etc), but also what it means, like name, address etc.
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible. The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible.
The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
### Key Features

View file

@ -65,10 +65,9 @@ context("List View", () => {
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 + "']")
.should((el) => {
expect(el).to.exist;
})
cy.get(`.btn-default[data-name="${todo_name}"]`)
.scrollIntoView({ inline: "center", block: "nearest" })
.should("be.visible")
.click();
cy.window()

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

@ -1473,18 +1473,28 @@ def logger(
def get_desk_link(doctype, name, show_title_with_name=False, open_in_new_tab=False):
from urllib.parse import quote
meta = get_meta(doctype)
title = get_value(doctype, name, meta.get_title_field())
target_attr = ' target="_blank"' if open_in_new_tab else ""
# encode for href
encoded_name = quote(name)
if show_title_with_name and name != title:
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
else:
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
html = '<a href="/app/Form/{doctype}/{encoded_name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
return html.format(
doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title), target=target_attr
doctype=doctype,
name=name,
encoded_name=encoded_name,
doctype_local=_(doctype),
title_local=_(title),
target=target_attr,
)
@ -1511,7 +1521,13 @@ def is_setup_complete():
if not frappe.db.table_exists("Installed Application"):
return setup_complete
if all(frappe.get_all("Installed Application", {"has_setup_wizard": 1}, pluck="is_setup_complete")):
if all(
frappe.get_all(
"Installed Application",
{"app_name": ("in", ["frappe", "erpnext"])},
pluck="is_setup_complete",
)
):
setup_complete = True
return setup_complete

View file

@ -92,9 +92,8 @@ class AutoRepeat(Document):
def before_insert(self):
if not frappe.in_test:
start_date = getdate(self.start_date)
today_date = getdate(today())
if start_date <= today_date:
today_date = getdate()
if getdate(self.start_date) < today_date:
self.start_date = today_date
def on_update(self):
@ -139,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:

View file

@ -11,7 +11,6 @@ import frappe.defaults
import frappe.desk.desk_page
from frappe.core.doctype.installed_applications.installed_applications import (
get_setup_wizard_completed_apps,
get_setup_wizard_not_required_apps,
)
from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings
from frappe.desk.doctype.changelog_feed.changelog_feed import get_changelog_feed_items
@ -121,34 +120,9 @@ def get_bootinfo():
bootinfo.sentry_dsn = sentry_dsn
bootinfo.setup_wizard_completed_apps = get_setup_wizard_completed_apps() or []
bootinfo.setup_wizard_not_required_apps = get_setup_wizard_not_required_apps() or []
remove_apps_with_incomplete_dependencies(bootinfo)
return bootinfo
def remove_apps_with_incomplete_dependencies(bootinfo):
remove_apps = set()
for app in bootinfo.setup_wizard_not_required_apps:
if app in bootinfo.setup_wizard_completed_apps:
continue
for required_apps in frappe.get_hooks("required_apps"):
required_apps = required_apps.split("/")
for required_app in required_apps:
if app not in bootinfo.setup_wizard_not_required_apps:
continue
if required_app not in bootinfo.setup_wizard_completed_apps:
remove_apps.add(app)
for app in remove_apps:
if app in bootinfo.setup_wizard_not_required_apps:
bootinfo.setup_wizard_not_required_apps.remove(app)
def get_letter_heads():
letter_heads = {}

View file

@ -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")
@ -691,8 +691,9 @@ def disable_user(context: CliCtxObj, email):
@click.command("migrate")
@click.option("--skip-failing", is_flag=True, help="Skip patches that fail to run")
@click.option("--skip-search-index", is_flag=True, help="Skip search indexing for web documents")
@click.option("--skip-fixtures", is_flag=True, help="Skip loading fixtures")
@pass_context
def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False):
def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False, skip_fixtures=False):
"Run patches, sync schema and rebuild files/translations"
from frappe.migrate import SiteMigration
@ -701,8 +702,7 @@ def migrate(context: CliCtxObj, skip_failing=False, skip_search_index=False):
click.secho(f"Migrating {site}", fg="green")
try:
SiteMigration(
skip_failing=skip_failing,
skip_search_index=skip_search_index,
skip_failing=skip_failing, skip_search_index=skip_search_index, skip_fixtures=skip_fixtures
).run(site=site)
finally:
print()

View file

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

View file

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

View file

@ -17,9 +17,19 @@ def invite_by_email(
frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite"))
# get relevant data from the database
disabled_user_emails = frappe.db.get_all(
"User",
filters={"email": ["in", email_list], "enabled": 0},
pluck="email",
)
accepted_invite_emails = frappe.db.get_all(
"User Invitation",
filters={"email": ["in", email_list], "status": "Accepted", "app_name": app_name},
filters={
"email": ["in", email_list],
"status": "Accepted",
"app_name": app_name,
"user": ["is", "set"],
},
pluck="email",
)
pending_invite_emails = frappe.db.get_all(
@ -29,7 +39,9 @@ def invite_by_email(
)
# create invitation documents
to_invite = list(set(email_list) - set(accepted_invite_emails) - set(pending_invite_emails))
to_invite = list(
set(email_list) - set(disabled_user_emails) - set(accepted_invite_emails) - set(pending_invite_emails)
)
for email in to_invite:
frappe.get_doc(
doctype="User Invitation",
@ -40,6 +52,7 @@ def invite_by_email(
).insert(ignore_permissions=True)
return {
"disabled_user_emails": disabled_user_emails,
"accepted_invite_emails": accepted_invite_emails,
"pending_invite_emails": pending_invite_emails,
"invited_emails": to_invite,

View file

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

@ -500,7 +500,7 @@
},
{
"default": "0",
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)",
"depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
"fieldname": "non_negative",
"fieldtype": "Check",
"label": "Non Negative"
@ -622,7 +622,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-17 00:48:20.359702",
"modified": "2025-08-26 22:08:20.940308",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -55,7 +55,7 @@
},
{
"collapsible": 1,
"description": "Change the starting / current sequence number of an existing series. <br>\n\nWarning: Incorrectly updating counters can prevent documents from getting created. ",
"description": "Change the starting / current sequence number of an existing series. <br>\n\nWarning: Incorrectly updating counters can prevent documents from getting created.",
"fieldname": "update_series",
"fieldtype": "Section Break",
"label": "Update Series Counter"

View file

@ -17,7 +17,13 @@ from frappe.database.schema import SPECIAL_CHAR_PATTERN
from frappe.exceptions import DoesNotExistError
from frappe.model.document import Document
from frappe.permissions import SYSTEM_USER_ROLE, get_doctypes_with_read
from frappe.utils import call_hook_method, cint, get_files_path, get_hook_method, get_url
from frappe.utils import (
call_hook_method,
cint,
get_files_path,
get_hook_method,
get_url,
)
from frappe.utils.file_manager import is_safe_path
from frappe.utils.image import optimize_image, strip_exif_data
@ -31,7 +37,7 @@ from .utils import *
exclude_from_linked_with = True
ImageFile.LOAD_TRUNCATED_IMAGES = True
URL_PREFIXES = ("http://", "https://")
URL_PREFIXES = ("http://", "https://", "/api/method/")
FILE_ENCODING_OPTIONS = ("utf-8-sig", "utf-8", "windows-1250", "windows-1252")
@ -139,7 +145,10 @@ class File(Document):
return
if not self.attached_to_name or not isinstance(self.attached_to_name, str | int):
frappe.throw(_("Attached To Name must be a string or an integer"), frappe.ValidationError)
frappe.throw(
_("Attached To Name must be a string or an integer"),
frappe.ValidationError,
)
if self.attached_to_field and SPECIAL_CHAR_PATTERN.search(self.attached_to_field):
frappe.throw(_("The fieldname you've specified in Attached To Field is invalid"))
@ -213,8 +222,8 @@ class File(Document):
if self.is_remote_file or not self.file_url:
return
if not self.file_url.startswith(("/files/", "/private/files/")):
# Probably an invalid URL since it doesn't start with http either
if not self.file_url.startswith(("/files/", "/private/files/", "/api/method/")):
# Probably an invalid URL since it doesn't start with http and isn't an internal URL either
frappe.throw(
_("URL must start with http:// or https://"),
title=_("Invalid URL"),
@ -318,7 +327,9 @@ class File(Document):
if current_attachment_count >= attachment_limit:
frappe.throw(
_("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format(
frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name
frappe.bold(attachment_limit),
self.attached_to_doctype,
self.attached_to_name,
),
exc=AttachmentLimitReached,
title=_("Attachment Limit Reached"),
@ -372,7 +383,10 @@ class File(Document):
return
if self.file_type not in allowed_extensions.splitlines():
frappe.throw(_("File type of {0} is not allowed").format(self.file_type), exc=FileTypeNotAllowed)
frappe.throw(
_("File type of {0} is not allowed").format(self.file_type),
exc=FileTypeNotAllowed,
)
def validate_duplicate_entry(self):
if not self.flags.ignore_duplicate_entry_error and not self.is_folder:
@ -407,7 +421,8 @@ class File(Document):
def set_file_name(self):
if not self.file_name and not self.file_url:
frappe.throw(
_("Fields `file_name` or `file_url` must be set for File"), exc=frappe.MandatoryError
_("Fields `file_name` or `file_url` must be set for File"),
exc=frappe.MandatoryError,
)
elif not self.file_name and self.file_url:
self.file_name = self.file_url.split("/")[-1]
@ -779,6 +794,9 @@ class File(Document):
frappe.clear_messages()
def set_is_private(self):
if self.is_private:
return
if self.file_url:
self.is_private = cint(self.file_url.startswith("/private"))

View file

@ -32,15 +32,18 @@ class InstalledApplications(Document):
self.delete_key("installed_applications")
for app in frappe.utils.get_installed_apps_info():
has_setup_wizard = 0
if app.get("app_name") == "frappe" or frappe.get_hooks(app_name=app.get("app_name")).get(
"setup_wizard_stages"
):
has_setup_wizard = 1
has_setup_wizard = 1
setup_complete = app_wise_setup_details.get(app.get("app_name")) or 0
if app.get("app_name") == "india_compliance":
setup_complete = app_wise_setup_details.get("erpnext") or 0
if app.get("app_name") in ["frappe", "erpnext"] and not setup_complete:
if app.get("app_name") == "frappe" and has_non_admin_user():
setup_complete = 1
if app.get("app_name") == "erpnext" and has_company():
setup_complete = 1
if app.get("app_name") not in ["frappe", "erpnext"]:
setup_complete = 0
has_setup_wizard = 0
self.append(
"installed_applications",
@ -77,6 +80,22 @@ class InstalledApplications(Document):
frappe.reload_doc("integrations", "doctype", "webhook")
def has_non_admin_user():
if frappe.db.has_table("User") and frappe.db.get_value(
"User", {"user_type": "System User", "name": ["not in", ["Administrator", "Guest"]]}
):
return True
return False
def has_company():
if frappe.db.has_table("Company") and frappe.get_all("Company", limit=1):
return True
return False
@frappe.whitelist()
def update_installed_apps_order(new_order: list[str] | str):
"""Change the ordering of `installed_apps` global

View file

@ -1,6 +1,9 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
from collections import defaultdict
import frappe
from frappe.model.document import Document
@ -25,3 +28,37 @@ class ModuleProfile(Document):
def get_permission_log_options(self, event=None):
return {"fields": ["block_modules"]}
def on_update(self):
self.clear_cache()
self.queue_action(
"update_all_users",
now=frappe.flags.in_test or frappe.flags.in_install,
enqueue_after_commit=True,
)
def update_all_users(self):
"""Changes in module_profile reflected across all its user"""
block_module = frappe.qb.DocType("Block Module")
user = frappe.qb.DocType("User")
all_current_modules = (
frappe.qb.from_(user)
.join(block_module)
.on(user.name == block_module.parent)
.where(user.module_profile == self.name)
.select(user.name, block_module.module)
).run()
user_modules = defaultdict(set)
for user, module in all_current_modules:
user_modules[user].add(module)
module_profile_modules = {module.module for module in self.block_modules}
for user_name, modules in user_modules.items():
if modules != module_profile_modules:
user = frappe.get_doc("User", user_name)
user.block_modules = []
for module in module_profile_modules:
user.append("block_modules", {"module": module})
user.save()

View file

@ -5,25 +5,152 @@ from frappe.tests import IntegrationTestCase
class TestModuleProfile(IntegrationTestCase):
def test_make_new_module_profile(self):
if not frappe.db.get_value("Module Profile", "_Test Module Profile"):
frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}],
}
).insert()
def setUp(self):
frappe.delete_doc_if_exists("Module Profile", "_Test Module Profile", force=1)
frappe.delete_doc_if_exists("Module Profile", "_Test Module Profile 2", force=1)
frappe.delete_doc_if_exists("User", "test-module-user1@example.com", force=1)
frappe.delete_doc_if_exists("User", "test-module-user2@example.com", force=1)
# add to user and check
if not frappe.db.get_value("User", "test-for-module_profile@example.com"):
new_user = frappe.get_doc(
{"doctype": "User", "email": "test-for-module_profile@example.com", "first_name": "Test User"}
).insert()
else:
new_user = frappe.get_doc("User", "test-for-module_profile@example.com")
def test_make_new_module_profile(self):
frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}],
}
).insert()
new_user = frappe.get_doc(
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
).insert()
new_user.module_profile = "_Test Module Profile"
new_user.save()
self.assertEqual(new_user.block_modules[0].module, "Accounts")
def test_multiple_block_modules(self):
"""Assign multiple blocked modules from profile to user"""
module_profile = frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}, {"module": "CRM"}, {"module": "HR"}],
}
).insert()
user = frappe.get_doc(
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
).insert()
user.module_profile = module_profile.name
user.save()
self.assertSetEqual({bm.module for bm in user.block_modules}, {"Accounts", "CRM", "HR"})
def test_update_module_profile_propagates_to_users(self):
"""Updating block_modules in profile should update linked users"""
module_profile = frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}],
}
).insert()
user = frappe.get_doc(
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
).insert()
user.module_profile = module_profile.name
user.save()
self.assertEqual({bm.module for bm in user.block_modules}, {"Accounts"})
module_profile.append("block_modules", {"module": "Projects"})
module_profile.save()
user.reload()
self.assertSetEqual({bm.module for bm in user.block_modules}, {"Accounts", "Projects"})
def test_clear_block_modules(self):
"""Clearing block_modules in profile should also clear them for users"""
module_profile = frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}],
}
).insert()
user = frappe.get_doc(
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
).insert()
user.module_profile = module_profile.name
user.save()
self.assertTrue(user.block_modules)
module_profile.block_modules = []
module_profile.save()
user.reload()
self.assertEqual(user.block_modules, [])
def test_multiple_users_same_profile(self):
"""Updates should propagate to all users linked to the same profile"""
module_profile = frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}],
}
).insert()
user1 = frappe.get_doc(
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "User One"}
).insert()
user2 = frappe.get_doc(
{"doctype": "User", "email": "test-module-user2@example.com", "first_name": "User Two"}
).insert()
for u in (user1, user2):
u.module_profile = module_profile.name
u.save()
module_profile.append("block_modules", {"module": "Projects"})
module_profile.save()
user1.reload()
user2.reload()
self.assertEqual([bm.module for bm in user1.block_modules], ["Accounts", "Projects"])
self.assertEqual([bm.module for bm in user2.block_modules], ["Accounts", "Projects"])
def test_switch_user_module_profile(self):
"""Switching user to a different profile updates their block_modules"""
profile1 = frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile",
"block_modules": [{"module": "Accounts"}],
}
).insert()
profile2 = frappe.get_doc(
{
"doctype": "Module Profile",
"module_profile_name": "_Test Module Profile 2",
"block_modules": [{"module": "HR"}],
}
).insert()
user = frappe.get_doc(
{"doctype": "User", "email": "test-module-user1@example.com", "first_name": "Test User"}
).insert()
user.module_profile = profile1.name
user.save()
self.assertEqual([bm.module for bm in user.block_modules], ["Accounts"])
user.module_profile = profile2.name
user.save()
self.assertEqual([bm.module for bm in user.block_modules], ["HR"])

View file

@ -162,7 +162,7 @@
"label": "Filters"
},
{
"depends_on": "eval:doc.report_type != \"Custom Report\"",
"depends_on": "eval:![\"Custom Report\", \"Report Builder\"].includes(doc.report_type)",
"fieldname": "filters",
"fieldtype": "Table",
"label": "Filters",
@ -177,7 +177,7 @@
"label": "Columns"
},
{
"depends_on": "eval:doc.report_type != \"Custom Report\"",
"depends_on": "eval:![\"Custom Report\", \"Report Builder\"].includes(doc.report_type)",
"fieldname": "columns",
"fieldtype": "Table",
"label": "Columns",
@ -207,7 +207,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-03-12 17:08:09.629411",
"modified": "2025-08-28 18:28:32.510719",
"modified_by": "Administrator",
"module": "Core",
"name": "Report",

View file

@ -13,7 +13,7 @@ frappe.ui.form.on("RQ Job", {
frm.add_custom_button(__("Force Stop job"), () => {
frappe.confirm(
__(
"This will terminate the job immediately and might be dangerous, are you sure? "
"This will terminate the job immediately and might be dangerous, are you sure?"
),
() => {
frappe

View file

@ -63,6 +63,10 @@ def get_contact_number(contact_name, ref_doctype, ref_name):
@frappe.whitelist()
def send_sms(receiver_list, msg, sender_name="", success_msg=True):
send_sms_hook_methods = frappe.get_hooks("send_sms")
if send_sms_hook_methods:
return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg)
import json
if isinstance(receiver_list, str):
@ -78,10 +82,6 @@ def send_sms(receiver_list, msg, sender_name="", success_msg=True):
"success_msg": success_msg,
}
send_sms_hook_methods = frappe.get_hooks("send_sms")
if send_sms_hook_methods:
return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg)
if frappe.db.get_single_value("SMS Settings", "sms_gateway_url"):
send_via_gateway(arg)
else:

View file

@ -304,7 +304,7 @@
"default": "10",
"fieldname": "allow_consecutive_login_attempts",
"fieldtype": "Int",
"label": "Allow Consecutive Login Attempts "
"label": "Allow Consecutive Login Attempts"
},
{
"fieldname": "column_break_34",
@ -706,7 +706,7 @@
},
{
"default": "100000",
"description": "This value specifies the max number of rows that can be rendered in report view. ",
"description": "This value specifies the max number of rows that can be rendered in report view.",
"fieldname": "max_report_rows",
"fieldtype": "Int",
"label": "Max Report Rows"

View file

@ -156,9 +156,8 @@ class SystemSettings(Document):
social_login_enabled = frappe.db.exists("Social Login Key", {"enable_social_login": 1})
ldap_enabled = frappe.db.get_single_value("LDAP Settings", "enabled")
login_with_email_link_enabled = frappe.db.get_single_value("System Settings", "login_with_email_link")
if not (social_login_enabled or ldap_enabled or login_with_email_link_enabled):
if not (social_login_enabled or ldap_enabled or self.login_with_email_link):
frappe.throw(
_(
"Please enable atleast one Social Login Key or LDAP or Login With Email Link before disabling username/password based login."

View file

@ -1,16 +0,0 @@
# Transaction Log Changelog
## v1.0.0
Initial version
The line hash summarizes:
- The index of the row
- The timestamp
- The document raw data
The chain hash summarizes:
- The previous line hash
- The current line hash
## v1.0.1
Modification of the timestamp fieldtype from "Time" to "Datetime"

View file

@ -1,44 +0,0 @@
# Copyright (c) 2018, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import hashlib
import frappe
from frappe.tests import IntegrationTestCase
class TestTransactionLog(IntegrationTestCase):
def test_validate_chaining(self):
frappe.get_doc(
{
"doctype": "Transaction Log",
"reference_doctype": "Test Doctype",
"document_name": "Test Document 1",
"data": "first_data",
}
).insert(ignore_permissions=True)
second_log = frappe.get_doc(
{
"doctype": "Transaction Log",
"reference_doctype": "Test Doctype",
"document_name": "Test Document 2",
"data": "second_data",
}
).insert(ignore_permissions=True)
third_log = frappe.get_doc(
{
"doctype": "Transaction Log",
"reference_doctype": "Test Doctype",
"document_name": "Test Document 3",
"data": "third_data",
}
).insert(ignore_permissions=True)
sha = hashlib.sha256()
sha.update(
frappe.safe_encode(str(third_log.transaction_hash))
+ frappe.safe_encode(str(second_log.chaining_hash))
)
self.assertEqual(sha.hexdigest(), third_log.chaining_hash)

View file

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

View file

@ -1,124 +0,0 @@
{
"actions": [],
"creation": "2018-02-06 11:48:51.270524",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"row_index",
"section_break_2",
"reference_doctype",
"document_name",
"column_break_5",
"timestamp",
"checksum_version",
"section_break_8",
"previous_hash",
"transaction_hash",
"chaining_hash",
"data",
"amended_from"
],
"fields": [
{
"fieldname": "row_index",
"fieldtype": "Data",
"label": "Row Index",
"read_only": 1
},
{
"fieldname": "section_break_2",
"fieldtype": "Section Break"
},
{
"fieldname": "reference_doctype",
"fieldtype": "Data",
"label": "Reference Document Type",
"read_only": 1
},
{
"fieldname": "document_name",
"fieldtype": "Data",
"label": "Document Name",
"read_only": 1
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"fieldname": "timestamp",
"fieldtype": "Datetime",
"label": "Timestamp",
"read_only": 1
},
{
"fieldname": "checksum_version",
"fieldtype": "Data",
"label": "Checksum Version",
"read_only": 1
},
{
"fieldname": "section_break_8",
"fieldtype": "Section Break"
},
{
"fieldname": "previous_hash",
"fieldtype": "Small Text",
"hidden": 1,
"label": "Previous Hash",
"read_only": 1
},
{
"fieldname": "transaction_hash",
"fieldtype": "Small Text",
"label": "Transaction Hash",
"read_only": 1
},
{
"fieldname": "chaining_hash",
"fieldtype": "Small Text",
"hidden": 1,
"label": "Chaining Hash",
"read_only": 1
},
{
"fieldname": "data",
"fieldtype": "Long Text",
"hidden": 1,
"label": "Data",
"read_only": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Transaction Log",
"print_hide": 1,
"read_only": 1
}
],
"in_create": 1,
"links": [],
"modified": "2024-03-23 16:03:59.373102",
"modified_by": "Administrator",
"module": "Core",
"name": "Transaction Log",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -1,86 +0,0 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# License: MIT. See LICENSE
import hashlib
import frappe
from frappe.model.document import Document
from frappe.query_builder import DocType
from frappe.utils import cint, now_datetime
class TransactionLog(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
amended_from: DF.Link | None
chaining_hash: DF.SmallText | None
checksum_version: DF.Data | None
data: DF.LongText | None
document_name: DF.Data | None
previous_hash: DF.SmallText | None
reference_doctype: DF.Data | None
row_index: DF.Data | None
timestamp: DF.Datetime | None
transaction_hash: DF.SmallText | None
# end: auto-generated types
def before_insert(self):
index = get_current_index()
self.row_index = index
self.timestamp = now_datetime()
if index != 1:
prev_hash = frappe.get_all(
"Transaction Log", filters={"row_index": str(index - 1)}, pluck="chaining_hash", limit=1
)
if prev_hash:
self.previous_hash = prev_hash[0]
else:
self.previous_hash = "Indexing broken"
else:
self.previous_hash = self.hash_line()
self.transaction_hash = self.hash_line()
self.chaining_hash = self.hash_chain()
self.checksum_version = "v1.0.1"
def hash_line(self):
sha = hashlib.sha256()
sha.update(
frappe.safe_encode(str(self.row_index))
+ frappe.safe_encode(str(self.timestamp))
+ frappe.safe_encode(str(self.data))
)
return sha.hexdigest()
def hash_chain(self):
sha = hashlib.sha256()
sha.update(
frappe.safe_encode(str(self.transaction_hash)) + frappe.safe_encode(str(self.previous_hash))
)
return sha.hexdigest()
def get_current_index():
series = DocType("Series")
current = (
frappe.qb.from_(series).where(series.name == "TRANSACTLOG").for_update().select("current")
).run()
if current and current[0][0] is not None:
current = current[0][0]
frappe.db.sql(
"""UPDATE `tabSeries`
SET `current` = `current` + 1
where `name` = 'TRANSACTLOG'"""
)
current = cint(current) + 1
else:
frappe.db.sql("INSERT INTO `tabSeries` (name, current) VALUES ('TRANSACTLOG', 1)")
current = 1
return current

View file

@ -62,6 +62,7 @@ frappe.ui.form.on("User", {
let d = frm.add_child("block_modules");
d.module = v.module;
});
frm.module_editor.disable = 1;
frm.module_editor && frm.module_editor.show();
},
});
@ -93,7 +94,11 @@ frappe.ui.form.on("User", {
if (frm.doc.user_type == "System User") {
var module_area = $("<div>").appendTo(frm.fields_dict.modules_html.wrapper);
frm.module_editor = new frappe.ModuleEditor(frm, module_area);
frm.module_editor = new frappe.ModuleEditor(
frm,
module_area,
frm.doc.module_profile ? 1 : 0
);
}
} else {
frm.roles_editor.show();
@ -248,6 +253,7 @@ frappe.ui.form.on("User", {
frm.roles_editor.show();
}
frm.module_editor.disable = frm.doc.module_profile ? 1 : 0;
frm.module_editor && frm.module_editor.show();
if (frappe.session.user == doc.name) {
@ -336,7 +342,7 @@ frappe.ui.form.on("User", {
},
callback: function (r) {
if (r.message) {
frappe.msgprint(__("Save API Secret: {0}", [r.message.api_secret]));
show_api_key_dialog(r.message.api_key, r.message.api_secret);
frm.reload_doc();
}
},
@ -437,3 +443,52 @@ function get_roles_for_editing_user() {
.map((perm) => perm.role) || ["System Manager"]
);
}
function show_api_key_dialog(api_key, api_secret) {
const dialog = new frappe.ui.Dialog({
title: __("API Keys"),
fields: [
{
label: __("API Key"),
fieldname: "api_key",
fieldtype: "Code",
read_only: 1,
default: api_key,
},
{
label: __("API Secret"),
fieldname: "api_secret",
fieldtype: "Code",
read_only: 1,
default: api_secret,
},
],
size: "small",
primary_action_label: __("Download"),
primary_action: () => {
frappe.tools.downloadify(
[
["api_key", "api_secret"],
[api_key, api_secret],
],
"System Manager",
"frappe_api_keys"
);
dialog.hide();
},
secondary_action_label: __("Copy token to clipboard"),
secondary_action: () => {
const token = `${api_key}:${api_secret}`;
frappe.utils.copy_to_clipboard(token);
dialog.hide();
},
});
dialog.show();
dialog.show_message(
__("Store the API secret securely. It won't be displayed again."),
"yellow",
1
);
}

View file

@ -601,6 +601,7 @@
"unique": 1
},
{
"description": "<a href=\"https://docs.frappe.io/framework/user/en/api/rest#1-token-based-authentication\" target=\"_blank\">\n Click here to learn about token-based authentication\n</a>",
"fieldname": "generate_keys",
"fieldtype": "Button",
"label": "Generate Keys",

View file

@ -590,6 +590,13 @@ class User(Document):
note.remove(row)
note.save(ignore_permissions=True)
# Unlink user from all of its invitation docs
invites = frappe.db.get_all("User Invitation", filters={"email": self.name}, pluck="name")
for invite in invites:
invite_doc = frappe.get_doc("User Invitation", invite)
invite_doc.user = None
invite_doc.save(ignore_permissions=True)
def before_rename(self, old_name, new_name, merge=False):
# if merging, delete the old user notification settings
if merge:
@ -1343,7 +1350,7 @@ def generate_keys(user: str):
user_details.api_secret = api_secret
user_details.save()
return {"api_secret": api_secret}
return {"api_key": user_details.api_key, "api_secret": api_secret}
@frappe.whitelist()

View file

@ -24,13 +24,9 @@ Define user invitation hooks in your app's `hooks.py` file. An example is shown
![user invitation hooks example](./user_invitation_hooks_example.png)
- `only_for`
Roles that are allowed to invite users to your app.
- `allowed_roles`
Roles that are allowed to be invited to your app.
A map of `only_for` roles to a list of roles that are allowed to be invited to your app.
- `after_accept`
@ -104,3 +100,4 @@ Cancels a specific pending invitation associated with an installed Framework app
- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`.
- To manually mark an invitation as expired, you can use the `expire` method on the invitation document.
- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document.
- Disabled users cannot be invited. Trying to invite a disabled user from the Desk will generate an error and the whitelisted functions will ignore emails associated with disabled users.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View file

@ -20,6 +20,7 @@ emails = [
"test_user_invite3@example.com",
"test_user_invite4@example.com",
"test_user_invite5@example.com",
"test_user_invite6@example.com",
]
@ -138,8 +139,7 @@ class IntegrationTestUserInvitation(IntegrationTestCase):
redirect_to_path="/abc",
app_name="frappe",
).insert()
invitation.status = "Accepted"
invitation.save()
invitation.accept()
self.assertEqual(len(self.get_email_names(False)), 1)
pending_invite_email = emails[2]
frappe.get_doc(
@ -156,10 +156,35 @@ class IntegrationTestUserInvitation(IntegrationTestCase):
roles=["System Manager"],
redirect_to_path="/xyz",
)
self.assertSequenceEqual(res["disabled_user_emails"], [])
self.assertSequenceEqual(res["accepted_invite_emails"], [accepted_invite_email])
self.assertSequenceEqual(res["pending_invite_emails"], [pending_invite_email])
self.assertSequenceEqual(res["invited_emails"], [email_to_invite])
self.assertEqual(len(self.get_email_names(False)), 3)
user = frappe.get_doc("User", invitation.email)
IntegrationTestUserInvitation.delete_invitation(invitation.name)
frappe.delete_doc("User", user.name)
def test_invite_by_email_api_disabled_user(self):
user = frappe.new_doc("User")
user.first_name = "Random"
user.last_name = "User"
user.email = emails[5]
user.append_roles("System Manager")
user.insert()
user.reload()
user.enabled = 0
user.save()
res = invite_by_email(
emails=user.email,
roles=["System Manager"],
redirect_to_path="/xyz",
)
self.assertSequenceEqual(res["disabled_user_emails"], [user.email])
self.assertSequenceEqual(res["accepted_invite_emails"], [])
self.assertSequenceEqual(res["pending_invite_emails"], [])
self.assertSequenceEqual(res["invited_emails"], [])
frappe.delete_doc("User", user.email)
def test_accept_invitation_api_pass_redirect(self):
invitation = frappe.get_doc(

View file

@ -84,13 +84,22 @@ class UserInvitation(Document):
self._validate_roles()
self._validate_email()
if frappe.db.get_value(
"User Invitation", filters={"email": self.email, "status": "Accepted", "app_name": self.app_name}
"User Invitation",
filters={
"email": self.email,
"status": "Accepted",
"app_name": self.app_name,
"user": ["is", "set"],
},
):
frappe.throw(title=_("Error"), msg=_("invitation already accepted"))
frappe.throw(title=_("Error"), msg=_("Invitation already accepted"))
if frappe.db.get_value(
"User Invitation", filters={"email": self.email, "status": "Pending", "app_name": self.app_name}
):
frappe.throw(title=_("Error"), msg=_("invitation already exists"))
frappe.throw(title=_("Error"), msg=_("Invitation already exists"))
user_enabled = frappe.db.get_value("User", self.email, "enabled")
if user_enabled is not None and user_enabled == 0:
frappe.throw(title=_("Error"), msg=_("User is disabled"))
def _after_insert(self):
key = frappe.generate_hash()
@ -150,13 +159,21 @@ class UserInvitation(Document):
def _validate_app_name(self):
UserInvitation.validate_app_name(self.app_name)
def _get_allowed_roles(self):
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
if not isinstance(user_invitation_hook, dict):
return []
res = set[str]()
allowed_roles_mp = user_invitation_hook.get("allowed_roles") or dict()
only_for = set(allowed_roles_mp.keys())
for role in only_for & set(frappe.get_roles()):
res.update(allowed_roles_mp[role])
return list(res)
def _validate_roles(self):
if self.app_name == "frappe":
return
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
allowed_roles: list[str] = []
if isinstance(user_invitation_hook, dict):
allowed_roles = user_invitation_hook.get("allowed_roles") or []
allowed_roles = self._get_allowed_roles()
for r in self.roles:
if r.role in allowed_roles:
continue
@ -175,7 +192,7 @@ class UserInvitation(Document):
@staticmethod
def validate_app_name(app_name: str):
if app_name not in frappe.get_installed_apps():
frappe.throw(title=_("Invalid app"), msg=_("application is not installed"))
frappe.throw(title=_("Invalid app"), msg=_("Application is not installed"))
@staticmethod
def validate_role(app_name: str) -> None:
@ -183,9 +200,7 @@ class UserInvitation(Document):
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name)
only_for: list[str] = []
if isinstance(user_invitation_hook, dict):
only_for = user_invitation_hook.get("only_for") or []
if "System Manager" not in only_for:
only_for.append("System Manager")
only_for = list((user_invitation_hook.get("allowed_roles") or dict()).keys())
frappe.only_for(only_for)
@ -209,7 +224,7 @@ def get_allowed_apps(user: Document | None) -> list[str]:
user_invitation_hooks = frappe.get_hooks("user_invitation", app_name=app)
if not isinstance(user_invitation_hooks, dict):
continue
only_for = user_invitation_hooks.get("only_for") or []
only_for = list((user_invitation_hooks.get("allowed_roles") or dict()).keys())
if set(only_for) & user_roles:
allowed_apps.append(app)
return allowed_apps

View file

@ -4,7 +4,7 @@
import json
import frappe
from frappe.model import no_value_fields, table_fields
from frappe.model import datetime_fields, no_value_fields, table_fields
from frappe.model.document import Document
from frappe.utils import cstr
@ -120,6 +120,10 @@ def get_diff(old, new, for_child=False, compare_cancelled=False):
if df.fieldtype in ("Link", "Dynamic Link"):
old_value, new_value = cstr(old_value), cstr(new_value)
if df.fieldtype in datetime_fields:
if old_value is None and new_value == "":
new_value = None
if not for_child and df.fieldtype in table_fields:
old_rows_by_name = {}
for d in old_value:

View file

@ -4,6 +4,7 @@
<p>{{ data.comment }}</p>
{% endif %}
{% const getEscapedValue = (v) => v === null ? "null" : frappe.utils.escape_html(v) %}
{% if data.changed && data.changed.length %}
<h4>{{ __("Values Changed") }}</h4>
<table class="table table-bordered">
@ -18,8 +19,8 @@
{% for item in data.changed %}
<tr>
<td>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</td>
<td class="diff-remove">{{ frappe.utils.escape_html(item[1]) }}</td>
<td class="diff-add">{{ frappe.utils.escape_html(item[2]) }}</td>
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
</tr>
{% endfor %}
</tbody>
@ -50,7 +51,7 @@
{% for row_key in item_keys %}
<tr>
<td class="small">{{ row_key }}</td>
<td class="small">{{ frappe.utils.escape_html(item[1][row_key]) }}</td>
<td class="small">{{ getEscapedValue(item[1][row_key]) }}</td>
</tr>
{% endfor %}
</tbody>
@ -85,8 +86,8 @@
<td>{{ frappe.meta.get_label(doc.ref_doctype, table_info[0]) }}</td>
<td>{{ table_info[1] }}</td>
<td>{{ item[0] }}</td>
<td class="diff-remove">{{ frappe.utils.escape_html(item[1]) }}</td>
<td class="diff-add">{{ frappe.utils.escape_html(item[2]) }}</td>
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
</tr>
{% endfor %}
{% endfor %}

View file

@ -22,7 +22,7 @@ from frappe.permissions import (
)
from frappe.utils.user import get_users_with_role as _get_user_with_role
not_allowed_in_permission_manager = ["DocType", "Patch Log", "Module Def", "Transaction Log"]
not_allowed_in_permission_manager = ["DocType", "Patch Log", "Module Def"]
@frappe.whitelist()

View file

@ -1,10 +0,0 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.query_reports["Transaction Log Report"] = {
onload: function (query_report) {
query_report.add_make_chart_button = function () {
//
};
},
};

View file

@ -1,26 +0,0 @@
{
"add_total_row": 0,
"creation": "2018-03-15 18:37:48.783779",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"modified": "2018-12-27 18:10:29.785415",
"modified_by": "Administrator",
"module": "Core",
"name": "Transaction Log Report",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Transaction Log",
"report_name": "Transaction Log Report",
"report_type": "Script Report",
"roles": [
{
"role": "Administrator"
},
{
"role": "System Manager"
}
]
}

View file

@ -1,117 +0,0 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# License: MIT. See LICENSE
import hashlib
import frappe
from frappe import _
from frappe.utils import format_datetime
def execute(filters=None):
columns, data = get_columns(filters), get_data(filters)
return columns, data
def get_data(filters=None):
result = []
logs = frappe.get_all("Transaction Log", fields=["*"], order_by="creation desc")
for l in logs:
row_index = int(l.row_index)
if row_index > 1:
previous_hash = frappe.get_all(
"Transaction Log",
fields=["chaining_hash"],
filters={"row_index": row_index - 1},
)
if not previous_hash:
integrity = False
else:
integrity = check_data_integrity(
l.chaining_hash, l.transaction_hash, l.previous_hash, previous_hash[0]["chaining_hash"]
)
result.append(
[
_(str(integrity)),
_(l.reference_doctype),
l.document_name,
l.owner,
l.modified_by,
format_datetime(l.timestamp, "YYYYMMDDHHmmss"),
]
)
else:
result.append(
[
_("First Transaction"),
_(l.reference_doctype),
l.document_name,
l.owner,
l.modified_by,
format_datetime(l.timestamp, "YYYYMMDDHHmmss"),
]
)
return result
def check_data_integrity(chaining_hash, transaction_hash, registered_previous_hash, previous_hash):
if registered_previous_hash != previous_hash:
return False
calculated_chaining_hash = calculate_chain(transaction_hash, previous_hash)
if calculated_chaining_hash != chaining_hash:
return False
else:
return True
def calculate_chain(transaction_hash, previous_hash):
sha = hashlib.sha256()
sha.update(transaction_hash.encode("utf-8") + previous_hash.encode("utf-8"))
return sha.hexdigest()
def get_columns(filters=None):
return [
{
"label": _("Chain Integrity"),
"fieldname": "chain_integrity",
"fieldtype": "Data",
"width": 150,
},
{
"label": _("Reference Doctype"),
"fieldname": "reference_doctype",
"fieldtype": "Data",
"width": 150,
},
{
"label": _("Reference Name"),
"fieldname": "reference_name",
"fieldtype": "Data",
"width": 150,
},
{
"label": _("Owner"),
"fieldname": "owner",
"fieldtype": "Data",
"width": 100,
},
{
"label": _("Modified By"),
"fieldname": "modified_by",
"fieldtype": "Data",
"width": 100,
},
{
"label": _("Timestamp"),
"fieldname": "timestamp",
"fieldtype": "Data",
"width": 100,
},
]

View file

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

View file

@ -7,12 +7,14 @@
"engine": "InnoDB",
"field_order": [
"disable_count",
"disable_comment_count",
"disable_sidebar_stats",
"disable_auto_refresh",
"allow_edit",
"disable_sidebar_stats",
"disable_automatic_recency_filters",
"total_fields",
"column_break_oany",
"disable_comment_count",
"disable_scrolling",
"allow_edit",
"section_break_evqq",
"fields_html",
"fields"
],
@ -35,12 +37,6 @@
"fieldtype": "Check",
"label": "Disable Auto Refresh"
},
{
"fieldname": "total_fields",
"fieldtype": "Select",
"label": "Maximum Number of Fields",
"options": "\n4\n5\n6\n7\n8\n9\n10"
},
{
"fieldname": "fields_html",
"fieldtype": "HTML",
@ -71,11 +67,25 @@
"fieldname": "disable_automatic_recency_filters",
"fieldtype": "Check",
"label": "Disable Automatic Recency Filters"
},
{
"default": "0",
"fieldname": "disable_scrolling",
"fieldtype": "Check",
"label": "Disable Scrolling"
},
{
"fieldname": "column_break_oany",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_evqq",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"links": [],
"modified": "2025-03-12 16:28:46.073808",
"modified": "2025-08-25 15:54:18.886680",
"modified_by": "Administrator",
"module": "Desk",
"name": "List View Settings",

View file

@ -19,9 +19,9 @@ class ListViewSettings(Document):
disable_automatic_recency_filters: DF.Check
disable_comment_count: DF.Check
disable_count: DF.Check
disable_scrolling: DF.Check
disable_sidebar_stats: DF.Check
fields: DF.Code | None
total_fields: DF.Literal["", "4", "5", "6", "7", "8", "9", "10"]
# end: auto-generated types
pass

View file

@ -302,6 +302,9 @@ def save_page(name, public, new_widgets, blocks):
public = frappe.parse_json(public)
doc = frappe.get_doc("Workspace", name)
if not doc.type:
doc.type = "Workspace"
doc.content = blocks
save_new_widget(doc, name, blocks, new_widgets)

View file

@ -72,7 +72,7 @@ def add(args=None, *, ignore_permissions=False):
else:
from frappe.utils import nowdate
description = str(args.get("description", ""))
description = args.get("description") or ""
has_content = strip_html(description) or "<img" in description
if not has_content:
args["description"] = _("Assignment for {0} {1}").format(args["doctype"], args["name"])

View file

@ -1,6 +1,7 @@
import frappe
from frappe.model import no_value_fields, table_fields
from frappe.utils.caching import http_cache
from frappe.www.printview import set_title_values_for_link_and_dynamic_link_fields
@frappe.whitelist()
@ -42,12 +43,16 @@ def get_preview_data(doctype, docname):
"preview_title": preview_data.get(title_field),
"name": preview_data.get("name"),
}
if meta.show_title_field_in_link and meta.title_field:
doc = frappe.get_doc(doctype, docname)
set_title_values_for_link_and_dynamic_link_fields(meta, doc)
for key, val in preview_data.items():
if val and meta.has_field(key) and key not in [image_field, title_field, "name"]:
formatted_preview_data[meta.get_field(key).label] = frappe.format(
val,
meta.get_field(key).fieldtype,
value=val,
doc=doc,
df=meta.get_field(key),
translated=True,
)

View file

@ -231,7 +231,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
? frappe.last_response.setup_wizard_failure_message
: __("Failed to complete setup");
this.update_setup_message(__("Could not start up: ") + fail_msg);
this.update_setup_message(__("Could not start up:") + " " + fail_msg);
this.$working_state.find(".title").html(__("Setup failed"));

View file

@ -67,8 +67,9 @@ def get_count() -> int | None:
# args.limit is specified to avoid getting accurate count.
if not args.limit:
args.fields = [f"count({fieldname}) as total_count"]
return execute(**args)[0].get("total_count")
args.fields = [fieldname]
partial_query = execute(**args, run=0)
return frappe.db.sql(f"select count(*) from ( {partial_query} ) p")[0][0]
args.fields = [fieldname]
partial_query = execute(**args, run=0)

View file

@ -79,6 +79,7 @@ def get_communication_doctype(doctype, txt, searchfield, start, page_len, filter
user_perms = frappe.utils.user.UserPermissions(frappe.session.user)
user_perms.build_permissions()
can_read = user_perms.can_read
from frappe import _
from frappe.modules import load_doctype_module
com_doctypes = []
@ -96,7 +97,15 @@ def get_communication_doctype(doctype, txt, searchfield, start, page_len, filter
d[0] for d in frappe.db.get_values("DocType", {"issingle": 0, "istable": 0, "hide_toolbar": 0})
]
return [[dt] for dt in com_doctypes if txt.lower().replace("%", "") in dt.lower() and dt in can_read]
results = []
txt_lower = txt.lower().replace("%", "")
for dt in com_doctypes:
if dt in can_read:
if txt_lower in dt.lower() or txt_lower in _(dt).lower():
results.append([dt])
return results
def sendmail(

View file

@ -36,7 +36,7 @@
"default_incoming",
"column_break_uynb",
"attachment_limit",
"last_synced_at",
"last_received_at",
"mailbox_settings",
"use_imap",
"use_ssl",
@ -257,7 +257,7 @@
{
"default": "250",
"depends_on": "use_imap",
"description": "Total number of emails to sync in initial sync process ",
"description": "Total number of emails to sync in initial sync process",
"fieldname": "initial_sync_count",
"fieldtype": "Select",
"hide_days": 1,
@ -497,7 +497,7 @@
},
{
"default": "0",
"description": "For more information, <a class=\"text-muted\" href=\"https://erpnext.com/docs/user/manual/en/setting-up/email/linking-emails-to-document\">click here</a>.",
"documentation_url": "https://docs.frappe.io/erpnext/user/manual/en/linking-emails-to-document",
"fieldname": "enable_automatic_linking",
"fieldtype": "Check",
"hide_days": 1,
@ -661,12 +661,6 @@
"fieldname": "column_break_uynb",
"fieldtype": "Column Break"
},
{
"depends_on": "eval: doc.service == \"Frappe Mail\"",
"fieldname": "last_synced_at",
"fieldtype": "Datetime",
"label": "Last Synced At"
},
{
"depends_on": "eval: (doc.service == \"Frappe Mail\" && doc.auth_method != \"Basic\" && !doc.__islocal && !doc.__unsaved)",
"fieldname": "validate_frappe_mail_settings",
@ -707,13 +701,19 @@
"fieldtype": "Data",
"label": "Always BCC Address",
"options": "Email"
},
{
"depends_on": "eval: doc.service == \"Frappe Mail\"",
"fieldname": "last_received_at",
"fieldtype": "Datetime",
"label": "Last Received At"
}
],
"icon": "fa fa-inbox",
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
"modified": "2025-03-10 14:22:11.021118",
"modified": "2025-08-20 11:35:14.540578",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Account",

View file

@ -94,7 +94,7 @@ class EmailAccount(Document):
imap_folder: DF.Table[IMAPFolder]
incoming_port: DF.Data | None
initial_sync_count: DF.Literal["100", "250", "500"]
last_synced_at: DF.Datetime | None
last_received_at: DF.Datetime | None
login_id: DF.Data | None
login_id_is_different: DF.Check
no_failed: DF.Int
@ -646,9 +646,9 @@ class EmailAccount(Document):
try:
if self.service == "Frappe Mail":
frappe_mail_client = self.get_frappe_mail_client()
messages = frappe_mail_client.pull_raw(last_synced_at=self.last_synced_at)
messages = frappe_mail_client.pull_raw(last_received_at=self.last_received_at)
process_mail(messages)
self.db_set("last_synced_at", messages["last_synced_at"], update_modified=False)
self.db_set("last_received_at", messages["last_received_at"], update_modified=False)
else:
email_sync_rule = self.build_email_sync_rule()
email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule)

View file

@ -51,7 +51,7 @@
"depends_on": "eval:doc.use_html",
"fieldname": "response_html",
"fieldtype": "Code",
"label": "Response ",
"label": "Response",
"options": "Jinja"
}
],

View file

@ -1,3 +1,5 @@
import math
import uuid
from datetime import datetime
from typing import Any
from urllib.parse import urljoin
@ -8,6 +10,8 @@ from frappe import _
from frappe.frappeclient import FrappeClient, FrappeOAuth2Client
from frappe.utils import convert_utc_to_system_timezone, get_datetime, get_system_timezone
CHUNK_SIZE = 5 * 1024 * 1024 # 5MB
class FrappeMail:
"""Class to interact with the Frappe Mail API."""
@ -83,6 +87,7 @@ class FrappeMail:
headers=headers,
timeout=timeout,
)
response.raise_for_status()
return self.client.post_process(response)
@ -98,23 +103,47 @@ class FrappeMail:
) -> None:
"""Sends an email using the Frappe Mail API."""
session_id = str(uuid.uuid4())
endpoint = "/api/method/mail.api.outbound.send_raw"
data = {"from_": sender, "to": recipients, "is_newsletter": is_newsletter}
self.request("POST", endpoint=endpoint, data=data, files={"raw_message": message})
def pull_raw(self, limit: int = 50, last_synced_at: str | None = None) -> dict[str, str | list[str]]:
"""Pulls emails for the email using the Frappe Mail API."""
if isinstance(message, str):
message = message.encode("utf-8")
total_size = len(message)
total_chunks = math.ceil(total_size / CHUNK_SIZE)
for i in range(total_chunks):
start = i * CHUNK_SIZE
end = start + CHUNK_SIZE
chunk = message[start:end]
files = {"raw_message": ("raw_message.eml", chunk)}
data = {
"from_": sender,
"to": recipients,
"is_newsletter": is_newsletter,
"uuid": session_id,
"chunk_index": i,
"total_chunk_count": total_chunks,
"chunk_byte_offset": start,
}
self.request("POST", endpoint=endpoint, data=data, files=files)
def pull_raw(
self, mailbox: str = "inbox", limit: int = 50, last_received_at: str | None = None
) -> dict[str, str | list[str]]:
"""Pull emails for the account using the Frappe Mail API."""
endpoint = "/api/method/mail.api.inbound.pull_raw"
if last_synced_at:
last_synced_at = add_or_update_tzinfo(last_synced_at)
if last_received_at:
last_received_at = add_or_update_tzinfo(last_received_at)
data = {"email": self.email, "limit": limit, "last_synced_at": last_synced_at}
data = {"mailbox": mailbox, "limit": limit, "last_received_at": last_received_at}
headers = {"X-Site": frappe.utils.get_url()}
response = self.request("GET", endpoint=endpoint, data=data, headers=headers)
last_synced_at = convert_utc_to_system_timezone(get_datetime(response["last_synced_at"]))
last_received_at = convert_utc_to_system_timezone(get_datetime(response["last_received_at"]))
return {"latest_messages": response["mails"], "last_synced_at": last_synced_at}
return {"latest_messages": response["mails"], "last_received_at": last_received_at}
def add_or_update_tzinfo(date_time: datetime | str, timezone: str | None = None) -> str:

View file

@ -11,6 +11,7 @@ import frappe
import frappe.sessions
import frappe.utils
from frappe import _, is_whitelisted, ping
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.monitor import add_data_to_monitor
from frappe.permissions import check_doctype_permission
@ -230,8 +231,8 @@ def download_file(file_url: str):
Endpoints : download_file, frappe.core.doctype.file.file.download_file
URL Params : file_name = /path/to/file relative to site path
"""
file: File = frappe.get_doc("File", {"file_url": file_url})
if not file.is_downloadable():
file = find_file_by_url(file_url)
if not file:
raise frappe.PermissionError
frappe.local.response.filename = os.path.basename(file_url)

View file

@ -575,5 +575,7 @@ persistent_cache_keys = [
]
user_invitation = {
"only_for": ["System Manager"],
"allowed_roles": {
"System Manager": [],
},
}

View file

@ -333,6 +333,7 @@ def install_app(name, verbose=False, set_as_patched=True, force=False):
for after_sync in app_hooks.after_sync or []:
frappe.get_attr(after_sync)() #
frappe.clear_cache()
frappe.client_cache.erase_persistent_caches()
frappe.flags.in_install = False
@ -347,6 +348,7 @@ def add_to_installed_apps(app_name, rebuild_website=True):
post_install(rebuild_website)
frappe.get_single("Installed Applications").update_versions()
frappe.db.commit()
def remove_from_installed_apps(app_name):
@ -416,6 +418,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False)
remove_from_installed_apps(app_name)
frappe.get_single("Installed Applications").update_versions()
frappe.db.commit()
frappe.clear_cache()
for after_uninstall in app_hooks.after_uninstall or []:
frappe.get_attr(after_uninstall)()
@ -428,6 +431,9 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False)
click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green")
frappe.flags.in_uninstall = False
if not dry_run:
frappe.clear_cache()
def _delete_modules(modules: list[str], dry_run: bool) -> list[str]:
"""Delete modules belonging to the app and all related doctypes.

View file

@ -4,21 +4,16 @@
frappe.ui.form.on("Connected App", {
refresh: (frm) => {
frm.add_custom_button(__("Get OpenID Configuration"), async () => {
if (!frm.doc.openid_configuration) {
frappe.msgprint(__("Please enter OpenID Configuration URL"));
} else {
try {
const response = await fetch(frm.doc.openid_configuration);
const oidc = await response.json();
frm.set_value("authorization_uri", oidc.authorization_endpoint);
frm.set_value("token_uri", oidc.token_endpoint);
frm.set_value("userinfo_uri", oidc.userinfo_endpoint);
frm.set_value("introspection_uri", oidc.introspection_endpoint);
frm.set_value("revocation_uri", oidc.revocation_endpoint);
} catch (error) {
frappe.msgprint(__("Please check OpenID Configuration URL"));
}
}
frm.call("get_openid_configuration").then(({ message: oidc }) => {
frm.set_value("authorization_uri", oidc.authorization_endpoint);
frm.set_value("token_uri", oidc.token_endpoint);
frm.set_value("userinfo_uri", oidc.userinfo_endpoint);
frm.set_value("introspection_uri", oidc.introspection_endpoint);
frm.set_value("revocation_uri", oidc.revocation_endpoint);
frm.fields_dict.authorization_uri.section.collapse(false); // Un-collapse
frappe.show_alert(__("OpenID Configuration fetched successfully!"));
});
});
if (!frm.is_new()) {
@ -26,7 +21,7 @@ frappe.ui.form.on("Connected App", {
frappe.call({
method: "initiate_web_application_flow",
doc: frm.doc,
callback: function (r) {
callback: (r) => {
window.open(r.message, "_blank");
},
});

View file

@ -9,6 +9,7 @@ from requests_oauthlib import OAuth2Session
import frappe
from frappe import _
from frappe.integrations.utils import make_get_request
from frappe.model.document import Document
if any((os.getenv("CI"), frappe.conf.developer_mode, frappe.conf.allow_tests)):
@ -47,6 +48,12 @@ class ConnectedApp(Document):
in a Token Cache.
"""
@frappe.whitelist()
def get_openid_configuration(self):
if not self.openid_configuration:
frappe.throw(_("Please enter OpenID Configuration URL"))
return make_get_request(self.openid_configuration)
def validate(self):
base_url = frappe.utils.get_url()
callback_path = (

View file

@ -5,7 +5,7 @@ frappe.ui.form.on("Google Settings", {
refresh: function (frm) {
frm.dashboard.set_headline(
__("For more information, {0}.", [
`<a href='https://erpnext.com/docs/user/manual/en/erpnext_integration/google_settings'>${__(
`<a href='https://erpnext.com/docs/user/manual/en/google_settings'>${__(
"Click here"
)}</a>`,
])

View file

@ -295,7 +295,7 @@
"description": "Do not create new user if user with email does not exist in the system",
"fieldname": "do_not_create_new_user",
"fieldtype": "Check",
"label": "Do Not Create New User "
"label": "Do Not Create New User"
}
],
"in_create": 1,

View file

@ -37,7 +37,7 @@
"label": "API Secret"
},
{
"description": "Enabling this will register your site on a central relay server to send push notifications for all installed apps through Firebase Cloud Messaging. This server only stores user tokens and error logs, and no messages are saved. ",
"description": "Enabling this will register your site on a central relay server to send push notifications for all installed apps through Firebase Cloud Messaging. This server only stores user tokens and error logs, and no messages are saved.",
"fieldname": "section_break_qgjr",
"fieldtype": "Section Break",
"label": "Relay Settings"

View file

@ -89,6 +89,17 @@ class SocialLoginKey(Document):
frappe.throw(
_("Please enter Client Secret before social login is enabled"), exc=ClientSecretNotSetError
)
if self.auth_url_data:
try:
json.loads(self.auth_url_data)
except json.JSONDecodeError:
frappe.throw(_("Auth URL data should be valid JSON"))
if self.api_endpoint_args:
try:
json.loads(self.api_endpoint_args)
except json.JSONDecodeError:
frappe.throw(_("API Endpoint Args should be valid JSON"))
def set_icon(self):
icon_map = {

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

31637
frappe/locale/da.po Normal file

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

31863
frappe/locale/nb.po Normal file

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

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