Merge branch 'develop' into fix-ambigus-table-join

This commit is contained in:
Devin Slauenwhite 2023-03-03 09:44:29 -05:00 committed by GitHub
commit 160f0b65fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
303 changed files with 6305 additions and 4513 deletions

View file

@ -69,6 +69,7 @@ ignore =
F841,
E713,
E712,
B028,
max-line-length = 200
exclude=,test_*.py

View file

@ -80,7 +80,20 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: '3.10'
- uses: actions/checkout@v3
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-
- run: |
pip install pip-audit
pip-audit ${GITHUB_WORKSPACE}
cd ${GITHUB_WORKSPACE}
sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456
pip-audit --desc on .

View file

@ -71,23 +71,3 @@ pull_request_rules:
assignees:
- "{{ author }}"
- name: backport to version-13-pre-release
conditions:
- label="backport version-13-pre-release"
actions:
backport:
branches:
- version-13-pre-release
assignees:
- "{{ author }}"
- name: backport to version-12-hotfix
conditions:
- label="backport version-12-hotfix"
actions:
backport:
branches:
- version-12-hotfix
assignees:
- "{{ author }}"

View file

@ -48,8 +48,8 @@ repos:
)$
- repo: https://github.com/timothycrosley/isort
rev: 5.9.1
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort

View file

@ -26,7 +26,7 @@ context("Control Color", () => {
//Checking if the css attribute is correct
cy.get(".color-map").should("have.css", "color", "rgb(79, 157, 217)");
cy.get(".hue-map").should("have.css", "color", "rgb(0, 145, 255)");
cy.get(".hue-map").should("have.css", "color", "rgb(0, 144, 255)");
//Checking if the correct color is being selected
cy.get("@dialog").then((dialog) => {

View file

@ -229,19 +229,15 @@ context("Control Link", () => {
);
cy.reload();
cy.new_form("ToDo");
cy.fill_field("description", "new", "Text Editor");
cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form");
cy.findByRole("button", { name: "Save" }).click();
cy.wait("@save_form");
cy.fill_field("description", "new", "Text Editor").wait(200);
cy.save();
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",
"Administrator"
);
// if user clears default value explicitly, system should not reset default again
cy.get_field("assigned_by").clear().blur();
cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form");
cy.findByRole("button", { name: "Save" }).click();
cy.wait("@save_form");
cy.save();
cy.get_field("assigned_by").should("have.value", "");
cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should(
"contain",

View file

@ -10,7 +10,7 @@ context("Folder Navigation", () => {
cy.get(".filter-selector > .btn").findByText("1 filter").click();
cy.findByRole("button", { name: "Clear Filters" }).click();
cy.get(".filter-action-buttons > .text-muted").findByText("+ Add a Filter").click();
cy.get(".fieldname-select-area > .awesomplete > .form-control").type("Fol{enter}");
cy.get(".fieldname-select-area > .awesomplete > .form-control:last").type("Fol{enter}");
cy.get(
".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback"
).type("Home{enter}");

View file

@ -26,6 +26,11 @@ context("Form", () => {
});
});
beforeEach(() => {
cy.login();
cy.visit("/app/website");
});
it("create a new form", () => {
cy.visit("/app/todo/new");
cy.get_field("description", "Text Editor")
@ -172,4 +177,57 @@ context("Form", () => {
send_welcome_email: 0,
});
});
it("update docfield property using set_df_property in child table", () => {
cy.visit("/app/contact/Test Form Contact 1");
cy.window()
.its("cur_frm")
.then((frm) => {
cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table");
// set property before form_render event of child table
cy.get("@table")
.find('[data-idx="1"]')
.invoke("attr", "data-name")
.then((cdn) => {
frm.set_df_property(
"phone_nos",
"hidden",
1,
"Contact Phone",
"is_primary_phone",
cdn
);
});
cy.get("@table").find('[data-idx="1"] .edit-grid-row').click();
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_phone"]')
.should("be.hidden");
cy.get("@table-form").find(".grid-footer-toolbar").click();
// set property on form_render event of child table
cy.get("@table").find('[data-idx="1"] .edit-grid-row').click();
cy.get("@table")
.find('[data-idx="1"]')
.invoke("attr", "data-name")
.then((cdn) => {
frm.set_df_property(
"phone_nos",
"hidden",
0,
"Contact Phone",
"is_primary_phone",
cdn
);
});
cy.get(".grid-row-open").as("table-form");
cy.get("@table-form")
.find('.frappe-control[data-fieldname="is_primary_phone"]')
.should("be.visible");
cy.get("@table-form").find(".grid-footer-toolbar").click();
});
});
});

View file

@ -87,7 +87,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "Options is required");
cy.hide_dialog();
cy.get(first_field).click();
cy.get(first_field).click({ force: true });
cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input")
.click()
@ -114,7 +114,7 @@ context("Form Builder", () => {
cy.get_open_dialog().find(".msgprint").should("contain", "In List View");
cy.hide_dialog();
cy.get(first_field).click();
cy.get(first_field).click({ force: true });
cy.get(".sidebar-container .field label .label-area").contains("In List View").click();
// validate In Global Search
@ -273,4 +273,29 @@ context("Form Builder", () => {
.find(".msgprint")
.should("contain", "cannot be hidden and mandatory without any default value");
});
it("Undo/Redo", () => {
cy.visit(`/app/form-builder/${doctype_name}`);
// click on second tab
cy.get(".tabs .tab:last").click();
let first_column = ".tab-content.active .section-columns-container:first .column:first";
let first_field = first_column + " .field:first";
let label = "div[title='Double click to edit label'] span:first";
// drag the first field to second position
cy.get(first_field).drag(first_column + " .field:nth-child(2)", {
target: { x: 100, y: 10 },
});
cy.get(first_field).find(label).should("have.text", "Check");
// undo
cy.get("body").type("{ctrl}z");
cy.get(first_field).find(label).should("have.text", "Data");
// redo
cy.get("body").type("{ctrl}{shift}z");
cy.get(first_field).find(label).should("have.text", "Check");
});
});

View file

@ -15,11 +15,13 @@ context("List View Settings", () => {
cy.clear_filters();
cy.wait(300);
cy.get(".list-count").should("contain", "20 of");
cy.get("[href='#icon-small-message']").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.findByLabelText("Disable Count").check({ force: true });
cy.findByLabelText("Disable Comment Count").check({ force: true });
cy.findByLabelText("Disable Sidebar Stats").check({ force: true });
cy.findByRole("button", { name: "Save" }).click();
@ -27,11 +29,13 @@ context("List View Settings", () => {
cy.get(".list-count").should("be.empty");
cy.get(".list-sidebar .list-tags").should("not.exist");
cy.get("[href='#icon-small-message']").should("not.be.visible");
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.findByLabelText("Disable Count").uncheck({ force: true });
cy.findByLabelText("Disable Comment Count").uncheck({ force: true });
cy.findByLabelText("Disable Sidebar Stats").uncheck({ force: true });
cy.findByRole("button", { name: "Save" }).click();
});

View file

@ -18,6 +18,7 @@ context("Navigation", () => {
it.only("Navigate to previous page after login", () => {
cy.visit("/app/todo");
cy.get(".page-head").findByTitle("To Do").should("be.visible");
cy.clear_filters();
cy.request("/api/method/logout");
cy.reload().as("reload");
cy.get("@reload").get(".page-card .btn-primary").contains("Login").click();

View file

@ -103,8 +103,9 @@ context("View", () => {
});
it("Route to File View", () => {
cy.intercept("POST", "/api/method/frappe.desk.reportview.get").as("list_loaded");
cy.visit("app/file");
cy.wait(500);
cy.wait("@list_loaded");
cy.window()
.its("cur_list")
.then((list) => {
@ -113,7 +114,7 @@ context("View", () => {
});
cy.visit("app/file/view/home/Attachments");
cy.wait(500);
cy.wait("@list_loaded");
cy.window()
.its("cur_list")
.then((list) => {

View file

@ -148,4 +148,39 @@ context("Workspace Blocks", () => {
.should("eq", "Pending");
cy.go("back");
});
it("Number Card Block", () => {
cy.create_records([
{
doctype: "Number Card",
label: "Test Number Card",
document_type: "ToDo",
color: "#f74343",
},
]);
cy.get(".codex-editor__redactor .ce-block");
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
cy.get(".ce-block").first().click({ force: true }).type("{enter}");
cy.get(".block-list-container .block-list-item").contains("Number Card").click();
// add number card
cy.fill_field("number_card_name", "Test Number Card", "Link");
cy.get('[data-fieldname="number_card_name"] ul li').contains("Test Number Card").click();
cy.click_modal_primary_button("Add");
cy.get(".ce-block .number-widget-box").first().as("number_card");
cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card");
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
cy.get("@number_card").find(".widget-title").should("contain", "Test Number Card");
// edit number card
cy.get(".standard-actions .btn-secondary[data-label=Edit]").click();
cy.get("@number_card").realHover().find(".widget-control .edit-button").click();
cy.get_field("label", "Data").invoke("val", "ToDo Count");
cy.click_modal_primary_button("Save");
cy.get("@number_card").find(".widget-title").should("contain", "ToDo Count");
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
cy.get("@number_card").find(".widget-title").should("contain", "ToDo Count");
});
});

View file

@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => {
Cypress.Commands.add("save", () => {
cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call");
cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true });
cy.get(`.page-container:visible button[data-label="Save"]`).click({ force: true });
cy.wait("@save_call");
});
Cypress.Commands.add("hide_dialog", () => {

View file

@ -23,7 +23,7 @@ import click
from werkzeug.local import Local, release_local
from frappe.query_builder import (
get_qb_engine,
get_query,
get_query_builder,
patch_query_aggregation,
patch_query_execute,
@ -238,13 +238,12 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None:
local.jenv = None
local.jloader = None
local.cache = {}
local.document_cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": []}
local.session = _dict()
local.dev_server = _dev_server
local.qb = get_query_builder(local.conf.db_type or "mariadb")
local.qb.engine = get_qb_engine()
local.qb.get_query = get_query
setup_module_map()
if not _qb_patched.get(local.conf.db_type):
@ -571,7 +570,7 @@ def get_user():
def get_roles(username=None) -> list[str]:
"""Returns roles of current user."""
if not local.session:
if not local.session or not local.session.user:
return ["Guest"]
import frappe.permissions
@ -623,6 +622,7 @@ def sendmail(
header=None,
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
):
"""Send email using user's default **Email Account** or global default **Email Account**.
@ -704,6 +704,7 @@ def sendmail(
header=header,
print_letterhead=print_letterhead,
with_container=with_container,
email_read_tracker_url=email_read_tracker_url,
)
# build email queue and send the email if send_now is True.
@ -770,7 +771,12 @@ def is_whitelisted(method):
is_guest = session["user"] == "Guest"
if method not in whitelisted or is_guest and method not in guest_methods:
throw(_("Not permitted"), PermissionError)
summary = _("You are not permitted to access this resource.")
detail = _("Function {0} is not whitelisted.").format(
bold(f"{method.__module__}.{method.__name__}")
)
msg = f"<details><summary>{summary}</summary>{detail}</details>"
throw(msg, PermissionError, title="Method Not Allowed")
if is_guest and method not in xss_safe_methods:
# strictly sanitize form_dict
@ -1070,25 +1076,10 @@ def set_value(doctype, docname, fieldname, value=None):
def get_cached_doc(*args, **kwargs) -> "Document":
def _respond(doc, from_redis=False):
if isinstance(doc, dict):
local.document_cache[key] = doc = get_doc(doc)
elif from_redis:
local.document_cache[key] = doc
if (key := can_cache_doc(args)) and (doc := cache().hget("document_cache", key)):
return doc
if key := can_cache_doc(args):
# local cache - has "ready" `Document` objects
if doc := local.document_cache.get(key):
return _respond(doc)
# redis cache
if doc := cache().hget("document_cache", key):
return _respond(doc, True)
# Not found in local/redis, fetch from DB
# Not found in cache, fetch from DB
doc = get_doc(*args, **kwargs)
# Store in cache
@ -1101,14 +1092,7 @@ def get_cached_doc(*args, **kwargs) -> "Document":
def _set_document_in_cache(key: str, doc: "Document") -> None:
local.document_cache[key] = doc
# Avoid setting in local.cache since we're already using local.document_cache above
# Try pickling the doc object as-is first, else fallback to doc.as_dict()
try:
cache().hset("document_cache", key, doc, cache_locally=False)
except Exception:
cache().hset("document_cache", key, doc.as_dict(), cache_locally=False)
cache().hset("document_cache", key, doc)
def can_cache_doc(args) -> str | None:
@ -1134,12 +1118,11 @@ def get_document_cache_key(doctype: str, name: str):
def clear_document_cache(doctype, name):
cache().hdel("last_modified", doctype)
key = get_document_cache_key(doctype, name)
if key in local.document_cache:
del local.document_cache[key]
cache().hdel("document_cache", key)
cache().hdel("document_cache", get_document_cache_key(doctype, name))
if doctype == "System Settings" and hasattr(local, "system_settings"):
delattr(local, "system_settings")
if doctype == "Website Settings" and hasattr(local, "website_settings"):
delattr(local, "website_settings")
@ -1399,23 +1382,37 @@ def get_all_apps(with_internal_apps=True, sites_path=None):
@request_cache
def get_installed_apps(sort=False, frappe_last=False):
"""Get list of installed apps in current site."""
def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False):
"""
Get list of installed apps in current site.
:param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt
:param frappe_last: [DEPRECATED] Keep frappe last. Do not use this, reverse the app list instead.
:param ensure_on_bench: Only return apps that are present on bench.
"""
from frappe.utils.deprecations import deprecation_warning
if getattr(flags, "in_install_db", True):
return []
if not db:
connect()
if not local.all_apps:
local.all_apps = cache().get_value("all_apps", get_all_apps)
installed = json.loads(db.get_global("installed_apps") or "[]")
if sort:
if not local.all_apps:
local.all_apps = cache().get_value("all_apps", get_all_apps)
deprecation_warning("`sort` argument is deprecated and will be removed in v15.")
installed = [app for app in local.all_apps if app in installed]
if _ensure_on_bench:
all_apps = cache().get_value("all_apps", get_all_apps)
installed = [app for app in installed if app in all_apps]
if frappe_last:
deprecation_warning("`frappe_last` argument is deprecated and will be removed in v15.")
if "frappe" in installed:
installed.remove("frappe")
installed.append("frappe")
@ -1445,19 +1442,17 @@ def _load_app_hooks(app_name: str | None = None):
import types
hooks = {}
apps = [app_name] if app_name else get_installed_apps(sort=True)
apps = [app_name] if app_name else get_installed_apps(_ensure_on_bench=True)
for app in apps:
try:
app_hooks = get_module(f"{app}.hooks")
except ImportError:
except ImportError as e:
if local.flags.in_install_app:
# if app is not installed while restoring
# ignore it
pass
print(f'Could not find app "{app}"')
if not request:
raise SystemExit
print(f'Could not find app "{app}": \n{e}')
raise
def _is_valid_hook(obj):
@ -1573,7 +1568,7 @@ def read_file(path, raise_not_found=False):
def get_attr(method_string: str) -> Any:
"""Get python method object from its name."""
app_name = method_string.split(".")[0]
app_name = method_string.split(".", 1)[0]
if (
not local.flags.in_uninstall
and not local.flags.in_install

View file

@ -131,6 +131,7 @@ class _RESTAPIHandler:
doc = frappe.get_doc(self.doctype, self.name)
if not doc.has_permission("read"):
raise frappe.PermissionError
doc.apply_fieldlevel_read_permissions()
frappe.local.response.update({"data": doc})
def update_doc(self):

View file

@ -41,9 +41,6 @@ def application(request: Request):
init_request(request)
frappe.recorder.record()
frappe.monitor.start()
frappe.rate_limiter.apply()
frappe.api.validate_auth()
if request.method == "OPTIONS":
@ -74,15 +71,15 @@ def application(request: Request):
response = handle_exception(e)
else:
rollback = after_request(rollback)
rollback = sync_database(rollback)
finally:
if request.method in UNSAFE_HTTP_METHODS and frappe.db and rollback:
frappe.db.rollback()
frappe.rate_limiter.update()
frappe.monitor.stop(response)
frappe.recorder.dump()
if getattr(frappe.local, "initialised", False):
for after_request_task in frappe.get_hooks("after_request"):
frappe.call(after_request_task, response=response, request=request)
log_request(request, response)
process_response(response)
@ -119,6 +116,9 @@ def init_request(request):
if request.method != "OPTIONS":
frappe.local.http_request = HTTPRequest()
for before_request_task in frappe.get_hooks("before_request"):
frappe.call(before_request_task)
def setup_read_only_mode():
"""During maintenance_mode reads to DB can still be performed to reduce downtime. This
@ -318,7 +318,7 @@ def handle_exception(e):
return response
def after_request(rollback):
def sync_database(rollback: bool) -> bool:
# if HTTP method would change server state, commit if necessary
if (
frappe.db
@ -332,9 +332,8 @@ def after_request(rollback):
rollback = False
# update session
if getattr(frappe.local, "session_obj", None):
updated_in_db = frappe.local.session_obj.update()
if updated_in_db:
if session := getattr(frappe.local, "session_obj", None):
if session.update():
frappe.db.commit()
rollback = False
@ -376,6 +375,7 @@ def serve(
"0.0.0.0",
int(port),
application,
exclude_patterns=["test_*"],
use_reloader=False if in_test_env else not no_reload,
use_debugger=not in_test_env,
use_evalex=not in_test_env,

View file

@ -55,7 +55,9 @@ class HTTPRequest:
def set_request_ip(self):
if frappe.get_request_header("X-Forwarded-For"):
frappe.local.request_ip = (frappe.get_request_header("X-Forwarded-For").split(",")[0]).strip()
frappe.local.request_ip = (
frappe.get_request_header("X-Forwarded-For").split(",", 1)[0]
).strip()
elif frappe.get_request_header("REMOTE_ADDR"):
frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR")
@ -305,7 +307,7 @@ class LoginManager:
current_hour = int(now_datetime().strftime("%H"))
if login_before and current_hour > login_before:
if login_before and current_hour >= login_before:
frappe.throw(_("Login not allowed at this time"), frappe.AuthenticationError)
if login_after and current_hour < login_after:

View file

@ -1,5 +1,7 @@
# Copyright (c) 2018, Frappe Technologies and Contributors
# License: MIT. See LICENSE
from typing import TYPE_CHECKING
import frappe
from frappe.automation.doctype.auto_repeat.auto_repeat import (
create_repeated_entries,
@ -10,8 +12,11 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, add_months, getdate, today
if TYPE_CHECKING:
from frappe.custom.doctype.custom_field.custom_field import CustomField
def add_custom_fields():
def add_custom_fields() -> "CustomField":
df = dict(
fieldname="auto_repeat",
label="Auto Repeat",
@ -22,15 +27,17 @@ def add_custom_fields():
print_hide=1,
read_only=1,
)
create_custom_field("ToDo", df)
return create_custom_field("ToDo", df) or frappe.get_doc(
"Custom Field", dict(fieldname=df["fieldname"], dt="ToDo")
)
class TestAutoRepeat(FrappeTestCase):
def setUp(self):
if not frappe.db.sql(
"SELECT `fieldname` FROM `tabCustom Field` WHERE `fieldname`='auto_repeat' and `dt`=%s", "Todo"
):
add_custom_fields()
@classmethod
def setUpClass(cls):
cls.custom_field = add_custom_fields()
cls.addClassCleanup(cls.custom_field.delete)
return super().setUpClass()
def test_daily_auto_repeat(self):
todo = frappe.get_doc(

View file

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

View file

@ -0,0 +1,90 @@
{
"actions": [],
"autoname": "hash",
"creation": "2023-02-22 11:23:58.183276",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user",
"remind_at",
"description",
"reminder_doctype",
"reminder_docname",
"notified"
],
"fields": [
{
"default": "__user",
"fieldname": "user",
"fieldtype": "Link",
"hidden": 1,
"label": "User",
"options": "User",
"reqd": 1,
"search_index": 1
},
{
"fieldname": "reminder_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Document Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "reminder_docname",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Document Name",
"options": "reminder_doctype",
"read_only": 1
},
{
"default": "now",
"fieldname": "remind_at",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Remind At",
"reqd": 1,
"search_index": 1
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description",
"reqd": 1
},
{
"default": "0",
"fieldname": "notified",
"fieldtype": "Check",
"hidden": 1,
"label": "notified"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-02-24 13:47:50.419648",
"modified_by": "Administrator",
"module": "Automation",
"name": "Reminder",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"if_owner": 1,
"read": 1,
"role": "All",
"write": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "description"
}

View file

@ -0,0 +1,78 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint
from frappe.utils.data import add_to_date, get_datetime, now_datetime
class Reminder(Document):
@staticmethod
def clear_old_logs(days=30):
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
table = frappe.qb.DocType("Reminder")
frappe.db.delete(table, filters=(table.remind_at < (Now() - Interval(days=days))))
def validate(self):
self.user = frappe.session.user
if get_datetime(self.remind_at) < now_datetime():
frappe.throw(_("Reminder cannot be created in past."))
def send_reminder(self):
if self.notified:
return
self.db_set("notified", 1, update_modified=False)
try:
notification = frappe.new_doc("Notification Log")
notification.for_user = self.user
notification.set("type", "Alert")
notification.document_type = self.reminder_doctype
notification.document_name = self.reminder_docname
notification.subject = self.description
notification.insert()
except Exception:
self.log_error("Failed to send reminder")
@frappe.whitelist()
def create_new_reminder(
remind_at: str,
description: str,
reminder_doctype: str | None = None,
reminder_docname: str | None = None,
):
reminder = frappe.new_doc("Reminder")
reminder.description = description
reminder.remind_at = remind_at
reminder.reminder_doctype = reminder_doctype
reminder.reminder_docname = reminder_docname
return reminder.insert()
def send_reminders():
# Ensure that we send all reminders that might be before next job execution.
job_freq = cint(frappe.get_conf().scheduler_interval) or 240
upper_threshold = add_to_date(now_datetime(), seconds=job_freq, as_string=True, as_datetime=True)
lower_threshold = add_to_date(now_datetime(), hours=-8, as_string=True, as_datetime=True)
pending_reminders = frappe.get_all(
"Reminder",
filters=[
("remind_at", "<=", upper_threshold),
("remind_at", ">=", lower_threshold), # dont send too old reminders if failed to send
("notified", "=", 0),
],
pluck="name",
)
for reminder in pending_reminders:
frappe.get_doc("Reminder", reminder).send_reminder()

View file

@ -0,0 +1,28 @@
# Copyright (c) 2023, Frappe Technologies and Contributors
# See license.txt
import frappe
from frappe.automation.doctype.reminder.reminder import create_new_reminder, send_reminders
from frappe.desk.doctype.notification_log.notification_log import get_notification_logs
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_to_date, now_datetime
class TestReminder(FrappeTestCase):
def test_reminder(self):
description = "TEST_REMINDER"
create_new_reminder(
remind_at=add_to_date(now_datetime(), minutes=1, as_datetime=True, as_string=True),
description=description,
)
send_reminders()
notifications = get_notification_logs()["notification_logs"]
self.assertIn(
description,
[n.subject for n in notifications],
msg=f"Failed to find reminder notification \n{notifications}",
)

View file

@ -12,6 +12,7 @@ from frappe.desk.doctype.route_history.route_history import frequently_visited_l
from frappe.desk.form.load import get_meta_bundle
from frappe.email.inbox import get_email_accounts
from frappe.model.base_document import get_controller
from frappe.permissions import has_permission
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery
@ -101,7 +102,7 @@ def get_bootinfo():
bootinfo.app_logo_url = get_app_logo()
bootinfo.link_title_doctypes = get_link_title_doctypes()
bootinfo.translated_doctypes = get_translated_doctypes()
bootinfo.subscription_expiry = add_subscription_expiry()
bootinfo.subscription_conf = add_subscription_conf()
return bootinfo
@ -234,7 +235,10 @@ def get_user_pages_or_reports(parent, cache=False):
has_role[p.name] = {"modified": p.modified, "title": p.title}
elif parent == "Report":
reports = frappe.get_all(
if not has_permission("Report", raise_exception=False):
return {}
reports = frappe.get_list(
"Report",
fields=["name", "report_type"],
filters={"name": ("in", has_role.keys())},
@ -243,6 +247,10 @@ def get_user_pages_or_reports(parent, cache=False):
for report in reports:
has_role[report.name]["report_type"] = report.report_type
non_permitted_reports = set(has_role.keys()) - {r.name for r in reports}
for r in non_permitted_reports:
has_role.pop(r, None)
# Expire every six hours
_cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600)
return has_role
@ -431,8 +439,8 @@ def load_currency_docs(bootinfo):
bootinfo.docs += currency_docs
def add_subscription_expiry():
def add_subscription_conf():
try:
return frappe.conf.subscription["expiry"]
return frappe.conf.subscription
except Exception:
return ""

View file

@ -127,8 +127,6 @@ def clear_doctype_cache(doctype=None):
for key in ("is_table", "doctype_modules", "document_cache"):
cache.delete_value(key)
frappe.local.document_cache = {}
def clear_single(dt):
for name in doctype_cache_keys:
cache.hdel(name, dt)
@ -160,11 +158,10 @@ def clear_doctype_cache(doctype=None):
def clear_controller_cache(doctype=None):
if not doctype:
del frappe.controllers
frappe.controllers = {}
frappe.controllers.pop(frappe.local.site, None)
return
for site_controllers in frappe.controllers.values():
if site_controllers := frappe.controllers.get(frappe.local.site):
site_controllers.pop(doctype, None)

View file

@ -86,6 +86,8 @@ def get(doctype, name=None, filters=None, parent=None):
doc = frappe.get_doc(doctype) # single
doc.check_permission()
doc.apply_fieldlevel_read_permissions()
return doc.as_dict()

View file

@ -5,7 +5,6 @@ import click
import frappe
from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import cint
@click.command("trigger-scheduler-event", help="Trigger a scheduler event")
@ -74,36 +73,40 @@ def disable_scheduler(context):
@click.command("scheduler")
@click.option("--site", help="site name")
@click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable"]))
@click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable", "status"]))
@click.option(
"--format", "-f", default="text", type=click.Choice(["json", "text"]), help="Output format"
)
@click.option("--verbose", "-v", is_flag=True, help="Verbose output")
@pass_context
def scheduler(context, state, site=None):
def scheduler(context, state: str, format: str, verbose: bool = False, site: str | None = None):
"""Control scheduler state."""
import frappe.utils.scheduler
from frappe.installer import update_site_config
import frappe
from frappe.utils.scheduler import is_scheduler_inactive, toggle_scheduler
if not site:
site = get_site(context)
site = site or get_site(context)
try:
frappe.init(site=site)
output = {
"text": "Scheduler is {status} for site {site}",
"json": '{{"status": "{status}", "site": "{site}"}}',
}
if state == "pause":
update_site_config("pause_scheduler", 1)
elif state == "resume":
update_site_config("pause_scheduler", 0)
elif state == "disable":
frappe.connect()
frappe.utils.scheduler.disable_scheduler()
frappe.db.commit()
elif state == "enable":
frappe.connect()
frappe.utils.scheduler.enable_scheduler()
frappe.db.commit()
with frappe.init_site(site=site):
match state:
case "status":
frappe.connect()
status = "disabled" if is_scheduler_inactive(verbose=verbose) else "enabled"
return print(output[format].format(status=status, site=site))
case "pause" | "resume":
from frappe.installer import update_site_config
print(f"Scheduler {state}d for site {site}")
update_site_config("pause_scheduler", state == "pause")
case "enable" | "disable":
frappe.connect()
toggle_scheduler(state == "enable")
frappe.db.commit()
finally:
frappe.destroy()
print(output[format].format(status=f"{state}d", site=site))
@click.command("set-maintenance-mode")

View file

@ -44,7 +44,7 @@ from frappe.exceptions import SiteNotSpecifiedError
@click.option(
"--force", help="Force restore if site/database already exists", is_flag=True, default=False
)
@click.option("--source_sql", help="Initiate database with a SQL file")
@click.option("--source-sql", "--source_sql", help="Initiate database with a SQL file")
@click.option("--install-app", multiple=True, help="Install app after installation")
@click.option(
"--set-default", is_flag=True, default=False, help="Set the new site as default site"
@ -67,10 +67,13 @@ def new_site(
set_default=False,
):
"Create a new site"
from frappe.installer import _new_site
from frappe.installer import _new_site, extract_sql_from_archive
frappe.init(site=site, new_site=True)
if source_sql:
source_sql = extract_sql_from_archive(source_sql)
_new_site(
db_name,
site,
@ -592,6 +595,8 @@ def disable_user(context, email):
@pass_context
def migrate(context, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations"
from traceback_with_variables import activate_by_import
from frappe.migrate import SiteMigration
for site in context.sites:

View file

@ -17,6 +17,7 @@ DATA_IMPORT_DEPRECATION = (
"[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n"
"Use `data-import` command instead to import data via 'Data Import'."
)
EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True}
@click.command("build")
@ -485,9 +486,10 @@ def bulk_rename(context, doctype, path):
frappe.destroy()
@click.command("db-console")
@click.command("db-console", context_settings=EXTRA_ARGS_CTX)
@click.argument("extra_args", nargs=-1)
@pass_context
def database(context):
def database(context, extra_args):
"""
Enter into the Database console for given site.
"""
@ -496,14 +498,18 @@ def database(context):
raise SiteNotSpecifiedError
frappe.init(site=site)
if not frappe.conf.db_type or frappe.conf.db_type == "mariadb":
_mariadb()
_mariadb(extra_args=extra_args)
elif frappe.conf.db_type == "postgres":
_psql()
_psql(extra_args=extra_args)
@click.command("mariadb")
@click.command(
"mariadb",
context_settings=EXTRA_ARGS_CTX,
)
@click.argument("extra_args", nargs=-1)
@pass_context
def mariadb(context):
def mariadb(context, extra_args):
"""
Enter into mariadb console for a given site.
"""
@ -511,21 +517,22 @@ def mariadb(context):
if not site:
raise SiteNotSpecifiedError
frappe.init(site=site)
_mariadb()
_mariadb(extra_args=extra_args)
@click.command("postgres")
@click.command("postgres", context_settings=EXTRA_ARGS_CTX)
@click.argument("extra_args", nargs=-1)
@pass_context
def postgres(context):
def postgres(context, extra_args):
"""
Enter into postgres console for a given site.
"""
site = get_site(context)
frappe.init(site=site)
_psql()
_psql(extra_args=extra_args)
def _mariadb():
def _mariadb(extra_args=None):
from frappe.database.mariadb.database import MariaDBDatabase
mysql = which("mysql")
@ -543,10 +550,12 @@ def _mariadb():
"--safe-updates",
"-A",
]
if extra_args:
command += list(extra_args)
os.execv(mysql, command)
def _psql():
def _psql(extra_args=None):
psql = which("psql")
host = frappe.conf.db_host or "127.0.0.1"
@ -554,7 +563,10 @@ def _psql():
env = os.environ.copy()
env["PGPASSWORD"] = frappe.conf.db_password
conn_string = f"postgresql://{frappe.conf.db_name}@{host}:{port}/{frappe.conf.db_name}"
subprocess.run([psql, conn_string], check=True, env=env)
psql_cmd = [psql, conn_string]
if extra_args:
psql_cmd = psql_cmd + list(extra_args)
subprocess.run(psql_cmd, check=True, env=env)
@click.command("jupyter")
@ -562,7 +574,7 @@ def _psql():
def jupyter(context):
"""Start an interactive jupyter notebook"""
installed_packages = (
r.split("==")[0]
r.split("==", 1)[0]
for r in subprocess.check_output([sys.executable, "-m", "pip", "freeze"], encoding="utf8")
)
@ -896,7 +908,7 @@ def run_ui_tests(
os.chdir(app_base_path)
node_bin = subprocess.getoutput("npm bin")
node_bin = subprocess.getoutput("yarn bin")
cypress_path = f"{node_bin}/cypress"
drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop"
real_events_plugin_path = f"{node_bin}/../cypress-real-events"
@ -1001,7 +1013,7 @@ def request(context, args=None, path=None):
frappe.local.form_dict = frappe._dict()
if args.startswith("/api/method"):
frappe.local.form_dict.cmd = args.split("?")[0].split("/")[-1]
frappe.local.form_dict.cmd = args.split("?", 1)[0].split("/")[-1]
elif path:
with open(os.path.join("..", path)) as f:
args = json.loads(f.read())
@ -1030,6 +1042,16 @@ def make_app(destination, app_name, no_git=False):
make_boilerplate(destination, app_name, no_git=no_git)
@click.command("create-patch")
def create_patch():
"Creates a new patch interactively"
from frappe.utils.boilerplate import PatchCreator
pc = PatchCreator()
pc.fetch_user_inputs()
pc.create_patch_file()
@click.command("set-config")
@click.argument("key")
@click.argument("value")
@ -1061,6 +1083,8 @@ def set_config(context, key, value, global_=False, parse=False, as_dict=False):
common_site_config_path = os.path.join(sites_path, "common_site_config.json")
update_site_config(key, value, validate=False, site_config_path=common_site_config_path)
else:
if not context.sites:
raise SiteNotSpecifiedError
for site in context.sites:
frappe.init(site=site)
update_site_config(key, value, validate=False)
@ -1176,6 +1200,7 @@ commands = [
data_import,
import_doc,
make_app,
create_patch,
mariadb,
postgres,
request,

View file

@ -204,7 +204,6 @@
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 1,
"share": 1,
"write": 1
}

View file

@ -0,0 +1,10 @@
{{ address_line1 }}<br>
{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}
{{ city }}<br>
{% if state %}{{ state }}<br>{% endif -%}
{% if pincode %}{{ pincode }}<br>{% endif -%}
{{ country }}<br>
<br>
{% if phone %}{{ _("Phone") }}: {{ phone }}<br>{% endif -%}
{% if fax %}{{ _("Fax") }}: {{ fax }}<br>{% endif -%}
{% if email_id %}{{ _("Email") }}: {{ email_id }}<br>{% endif -%}

View file

@ -53,7 +53,6 @@
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 1,
"share": 1,
"write": 1
}
@ -62,4 +61,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View file

@ -4,52 +4,36 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import cint
from frappe.utils.jinja import validate_template
class AddressTemplate(Document):
def validate(self):
validate_template(self.template)
if not self.template:
self.template = get_default_address_template()
self.defaults = frappe.db.get_values(
"Address Template", {"is_default": 1, "name": ("!=", self.name)}
)
if not self.is_default:
if not self.defaults:
self.is_default = 1
if cint(frappe.db.get_single_value("System Settings", "setup_complete")):
frappe.msgprint(_("Setting this Address Template as default as there is no other default"))
validate_template(self.template)
if not self.is_default and not self._get_previous_default():
self.is_default = 1
if frappe.db.get_single_value("System Settings", "setup_complete"):
frappe.msgprint(_("Setting this Address Template as default as there is no other default"))
def on_update(self):
if self.is_default and self.defaults:
for d in self.defaults:
frappe.db.set_value("Address Template", d[0], "is_default", 0)
if self.is_default and (previous_default := self._get_previous_default()):
frappe.db.set_value("Address Template", previous_default, "is_default", 0)
def on_trash(self):
if self.is_default:
frappe.throw(_("Default Address Template cannot be deleted"))
def _get_previous_default(self) -> str | None:
return frappe.db.get_value("Address Template", {"is_default": 1, "name": ("!=", self.name)})
@frappe.whitelist()
def get_default_address_template():
"""Get default address template (translated)"""
return (
"""{{ address_line1 }}<br>{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}\
{{ city }}<br>
{% if state %}{{ state }}<br>{% endif -%}
{% if pincode %}{{ pincode }}<br>{% endif -%}
{{ country }}<br>
{% if phone %}"""
+ _("Phone")
+ """: {{ phone }}<br>{% endif -%}
{% if fax %}"""
+ _("Fax")
+ """: {{ fax }}<br>{% endif -%}
{% if email_id %}"""
+ _("Email")
+ """: {{ email_id }}<br>{% endif -%}"""
)
def get_default_address_template() -> str:
"""Return the default address template."""
from pathlib import Path
return (Path(__file__).parent / "address_template.jinja").read_text()

View file

@ -1,39 +1,39 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.contacts.doctype.address_template.address_template import get_default_address_template
from frappe.tests.utils import FrappeTestCase
from frappe.utils.jinja import validate_template
class TestAddressTemplate(FrappeTestCase):
def setUp(self):
self.make_default_address_template()
def setUp(self) -> None:
frappe.db.delete("Address Template", {"country": "India"})
frappe.db.delete("Address Template", {"country": "Brazil"})
def test_default_address_template(self):
validate_template(get_default_address_template())
def test_default_is_unset(self):
a = frappe.get_doc("Address Template", "India")
a.is_default = 1
a.save()
frappe.get_doc({"doctype": "Address Template", "country": "India", "is_default": 1}).insert()
b = frappe.get_doc("Address Template", "Brazil")
b.is_default = 1
b.save()
self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 1)
frappe.get_doc({"doctype": "Address Template", "country": "Brazil", "is_default": 1}).insert()
self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 0)
self.assertEqual(frappe.db.get_value("Address Template", "Brazil", "is_default"), 1)
def tearDown(self):
a = frappe.get_doc("Address Template", "India")
a.is_default = 1
a.save()
def test_delete_address_template(self):
india = frappe.get_doc(
{"doctype": "Address Template", "country": "India", "is_default": 0}
).insert()
@classmethod
def make_default_address_template(self):
template = """{{ address_line1 }}<br>{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}{{ city }}<br>{% if state %}{{ state }}<br>{% endif -%}{% if pincode %}{{ pincode }}<br>{% endif -%}{{ country }}<br>{% if phone %}Phone: {{ phone }}<br>{% endif -%}{% if fax %}Fax: {{ fax }}<br>{% endif -%}{% if email_id %}Email: {{ email_id }}<br>{% endif -%}"""
brazil = frappe.get_doc(
{"doctype": "Address Template", "country": "Brazil", "is_default": 1}
).insert()
if not frappe.db.exists("Address Template", "India"):
frappe.get_doc(
{"doctype": "Address Template", "country": "India", "is_default": 1, "template": template}
).insert()
india.reload() # might have been modified by the second template
india.delete() # should not raise an error
if not frappe.db.exists("Address Template", "Brazil"):
frappe.get_doc(
{"doctype": "Address Template", "country": "Brazil", "template": template}
).insert()
self.assertRaises(frappe.ValidationError, brazil.delete)

View file

@ -275,7 +275,6 @@
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 1,
"share": 1,
"write": 1
},

View file

@ -112,6 +112,3 @@ class TestAddressesAndContacts(FrappeTestCase):
1,
]
self.assertListEqual(test_item, report_data[idx])
def tearDown(self):
frappe.db.rollback()

View file

@ -8,7 +8,13 @@ from frappe.utils import cstr
class AccessLog(Document):
pass
@staticmethod
def clear_old_logs(days=30):
from frappe.query_builder import Interval
from frappe.query_builder.functions import Now
table = frappe.qb.DocType("Access Log")
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
@frappe.whitelist()

View file

@ -487,28 +487,32 @@ def parse_email(communication, email_strings):
"""
Parse email to add timeline links.
When automatic email linking is enabled, an email from email_strings can contain
a doctype and docname ie in the format `admin+doctype+docname@example.com`,
a doctype and docname ie in the format `admin+doctype+docname@example.com` or `admin+doctype=docname@example.com`,
the email is parsed and doctype and docname is extracted and timeline link is added.
"""
if not frappe.get_all("Email Account", filters={"enable_automatic_linking": 1}):
if not frappe.db.get_value("Email Account", filters={"enable_automatic_linking": 1}):
return
delimiter = "+"
for email_string in email_strings:
if email_string:
for email in email_string.split(","):
if delimiter in email:
email = email.split("@")[0]
email_local_parts = email.split(delimiter)
if not len(email_local_parts) == 3:
continue
email_username = email.split("@", 1)[0]
email_local_parts = email_username.split("+")
docname = doctype = None
if len(email_local_parts) == 3:
doctype = unquote(email_local_parts[1])
docname = unquote(email_local_parts[2])
if doctype and docname and frappe.db.exists(doctype, docname):
communication.add_link(doctype, docname)
elif len(email_local_parts) == 2:
document_parts = email_local_parts[1].split("=", 1)
if len(document_parts) != 2:
continue
doctype = unquote(document_parts[0])
docname = unquote(document_parts[1])
if doctype and docname and frappe.db.get_value(doctype, docname, ignore=True):
communication.add_link(doctype, docname)
def get_email_without_link(email):
@ -521,7 +525,7 @@ def get_email_without_link(email):
try:
_email = email.split("@")
email_id = _email[0].split("+")[0]
email_id = _email[0].split("+", 1)[0]
email_host = _email[1]
except IndexError:
return email

View file

@ -12,6 +12,7 @@ from frappe.utils import (
cint,
get_datetime,
get_formatted_email,
get_imaginary_pixel_response,
get_string_between,
list_to_str,
split_emails,
@ -249,18 +250,7 @@ def mark_email_as_seen(name: str = None):
frappe.log_error("Unable to mark as seen", None, "Communication", name)
finally:
frappe.response.update(
{
"type": "binary",
"filename": "imaginary_pixel.png",
"filecontent": (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r"
b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0"
b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82"
),
}
)
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
def update_communication_as_read(name):

View file

@ -219,17 +219,17 @@ class TestCommunication(FrappeTestCase):
self.assertIn(comm_note_2.name, data)
def test_link_in_email(self):
frappe.delete_doc_if_exists("Note", "test document link in email")
create_email_account()
note = frappe.get_doc(
{
"doctype": "Note",
"title": "test document link in email",
"content": "test document link in email",
}
).insert(ignore_permissions=True)
notes = {}
for i in range(2):
frappe.delete_doc_if_exists("Note", f"test document link in email {i}")
notes[i] = frappe.get_doc(
{
"doctype": "Note",
"title": f"test document link in email {i}",
}
).insert(ignore_permissions=True)
comm = frappe.get_doc(
{
@ -237,14 +237,15 @@ class TestCommunication(FrappeTestCase):
"communication_medium": "Email",
"subject": "Document Link in Email",
"sender": "comm_sender@example.com",
"recipients": f'comm_recipient+{quote("Note")}+{quote(note.name)}@example.com',
"recipients": f'comm_recipient+{quote("Note")}+{quote(notes[0].name)}@example.com,comm_recipient+{quote("Note")}={quote(notes[1].name)}@example.com',
}
).insert(ignore_permissions=True)
doc_links = [
(timeline_link.link_doctype, timeline_link.link_name) for timeline_link in comm.timeline_links
]
self.assertIn(("Note", note.name), doc_links)
self.assertIn(("Note", notes[0].name), doc_links)
self.assertIn(("Note", notes[1].name), doc_links)
def test_parse_emails(self):
emails = get_emails(

View file

@ -27,7 +27,6 @@
"report",
"export",
"import",
"set_user_permissions",
"column_break_19",
"share",
"print",
@ -179,13 +178,6 @@
"fieldtype": "Check",
"label": "Import"
},
{
"default": "0",
"description": "This role update User Permissions for a user",
"fieldname": "set_user_permissions",
"fieldtype": "Check",
"label": "Set User Permissions"
},
{
"fieldname": "column_break_19",
"fieldtype": "Column Break"
@ -223,7 +215,7 @@
}
],
"links": [],
"modified": "2020-12-03 15:20:48.296730",
"modified": "2023-02-20 13:19:04.889081",
"modified_by": "Administrator",
"module": "Core",
"name": "Custom DocPerm",

View file

@ -94,6 +94,7 @@
"fieldtype": "Select",
"hidden": 1,
"label": "Status",
"no_copy": 1,
"options": "Pending\nSuccess\nPartial Success\nError",
"read_only": 1
},
@ -170,7 +171,7 @@
],
"hide_toolbar": 1,
"links": [],
"modified": "2022-02-01 20:08:37.624914",
"modified": "2022-02-14 10:08:37.624914",
"modified_by": "Administrator",
"module": "Core",
"name": "Data Import",
@ -194,4 +195,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -304,6 +304,7 @@
},
{
"default": "0",
"depends_on": "eval:!in_list(['Section Break', 'Column Break', 'Tab Break'], doc.fieldtype)",
"fieldname": "permlevel",
"fieldtype": "Int",
"label": "Perm Level",
@ -555,7 +556,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-01-11 20:46:43.164926",
"modified": "2023-02-20 12:07:29.552523",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -26,7 +26,6 @@
"report",
"export",
"import",
"set_user_permissions",
"column_break_19",
"share",
"print",
@ -178,13 +177,6 @@
"fieldtype": "Check",
"label": "Import"
},
{
"default": "0",
"description": "This role update User Permissions for a user",
"fieldname": "set_user_permissions",
"fieldtype": "Check",
"label": "Set User Permissions"
},
{
"fieldname": "column_break_19",
"fieldtype": "Column Break"
@ -218,7 +210,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2020-12-03 15:15:30.488212",
"modified": "2023-02-20 13:21:45.071310",
"modified_by": "Administrator",
"module": "Core",
"name": "DocPerm",

View file

@ -125,3 +125,17 @@ class TestDocShare(FrappeTestCase):
)
frappe.share.remove(doctype, submittable_doc.name, self.user)
def test_share_int_pk(self):
test_doc = frappe.new_doc("Console Log")
test_doc.insert()
frappe.share.add("Console Log", test_doc.name, self.user)
frappe.set_user(self.user)
self.assertIn(
str(test_doc.name), [str(name) for name in frappe.get_list("Console Log", pluck="name")]
)
test_doc.reload()
self.assertTrue(test_doc.has_permission("read"))

View file

@ -4,5 +4,6 @@
# import frappe
{base_class_import}
class {classname}({base_class}):
{custom_controller}

View file

@ -55,7 +55,7 @@ frappe.ui.form.on("DocType", {
msg += __("If you just want to customize for your site, use {0} instead.", [
customize_form_link,
]);
frm.dashboard.add_comment(msg, "yellow");
frm.dashboard.add_comment(msg, "yellow", true);
}
if (frm.is_new()) {
@ -104,88 +104,7 @@ frappe.ui.form.on("DocType", {
frappe.ui.form.on("DocField", {
form_render(frm, doctype, docname) {
// Render two select fields for Fetch From instead of Small Text for better UX
let field = frm.cur_grid.grid_form.fields_dict.fetch_from;
$(field.input_area).hide();
let $doctype_select = $(`<select class="form-control">`);
let $field_select = $(`<select class="form-control">`);
let $wrapper = $('<div class="fetch-from-select row"><div>');
$wrapper.append($doctype_select, $field_select);
field.$input_wrapper.append($wrapper);
$doctype_select.wrap('<div class="col"></div>');
$field_select.wrap('<div class="col"></div>');
let row = frappe.get_doc(doctype, docname);
let curr_value = { doctype: null, fieldname: null };
if (row.fetch_from) {
let [doctype, fieldname] = row.fetch_from.split(".");
curr_value.doctype = doctype;
curr_value.fieldname = fieldname;
}
let doctypes = frm.doc.fields
.filter((df) => df.fieldtype == "Link")
.filter((df) => df.options && df.fieldname != row.fieldname)
.sort((a, b) => a.options.localeCompare(b.options))
.map((df) => ({
label: `${df.options} (${df.fieldname})`,
value: df.fieldname,
}));
$doctype_select.add_options([
{ label: __("Select DocType"), value: "", selected: true },
...doctypes,
]);
$doctype_select.on("change", () => {
row.fetch_from = "";
frm.dirty();
update_fieldname_options();
});
function update_fieldname_options() {
$field_select.find("option").remove();
let link_fieldname = $doctype_select.val();
if (!link_fieldname) return;
let link_field = frm.doc.fields.find((df) => df.fieldname === link_fieldname);
let link_doctype = link_field.options;
frappe.model.with_doctype(link_doctype, () => {
let fields = frappe.meta
.get_docfields(link_doctype, null, {
fieldtype: ["not in", frappe.model.no_value_type],
})
.sort((a, b) => a.label.localeCompare(b.label))
.map((df) => ({
label: `${df.label} (${df.fieldtype})`,
value: df.fieldname,
}));
$field_select.add_options([
{
label: __("Select Field"),
value: "",
selected: true,
disabled: true,
},
...fields,
]);
if (curr_value.fieldname) {
$field_select.val(curr_value.fieldname);
}
});
}
$field_select.on("change", () => {
let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`;
row.fetch_from = fetch_from;
frm.dirty();
});
if (curr_value.doctype) {
$doctype_select.val(curr_value.doctype);
update_fieldname_options();
}
frm.trigger("setup_fetch_from_fields", doctype, docname);
},
fieldtype: function (frm) {

View file

@ -604,6 +604,7 @@
{
"default": "0",
"depends_on": "eval: doc.is_submittable",
"description": "Enabling this will submit documents in background",
"fieldname": "queue_in_background",
"fieldtype": "Check",
"label": "Queue in Background"
@ -707,7 +708,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-12-14 09:47:27.315351",
"modified": "2023-01-04 17:23:09.206018",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -744,4 +745,4 @@
"states": [],
"track_changes": 1,
"translated_doctype": 1
}
}

View file

@ -366,8 +366,10 @@ class DocType(Document):
d.fieldname = d.fieldname + "_column"
elif d.fieldtype == "Tab Break":
d.fieldname = d.fieldname + "_tab"
else:
elif d.fieldtype in ("Section Break", "Column Break", "Tab Break"):
d.fieldname = d.fieldtype.lower().replace(" ", "_") + "_" + str(random_string(4))
else:
frappe.throw(_("Row #{}: Fieldname is required").format(d.idx), title="Missing Fieldname")
else:
if d.fieldname in restricted:
frappe.throw(_("Fieldname {0} is restricted").format(d.fieldname), InvalidFieldNameError)
@ -883,7 +885,7 @@ def validate_series(dt, autoname=None, name=None):
if not autoname and dt.get("fields", {"fieldname": "naming_series"}):
dt.autoname = "naming_series:"
elif dt.autoname and dt.autoname.startswith("naming_series:"):
fieldname = dt.autoname.split("naming_series:")[0] or "naming_series"
fieldname = dt.autoname.split("naming_series:", 1)[0] or "naming_series"
if not dt.get("fields", {"fieldname": fieldname}):
frappe.throw(
_("Fieldname called {0} must exist to enable autonaming").format(frappe.bold(fieldname)),
@ -911,7 +913,7 @@ def validate_series(dt, autoname=None, name=None):
and (not autoname.startswith("format:"))
):
prefix = autoname.split(".")[0]
prefix = autoname.split(".", 1)[0]
doctype = frappe.qb.DocType("DocType")
used_in = (
frappe.qb.from_(doctype)
@ -981,7 +983,7 @@ def change_name_column_type(doctype_name: str, type: str) -> None:
def validate_links_table_fieldnames(meta):
"""Validate fieldnames in Links table"""
if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures:
if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures or frappe.flags.in_migrate:
return
fieldnames = tuple(field.fieldname for field in meta.fields)
@ -1096,10 +1098,7 @@ def validate_fields(meta):
)
def check_link_table_options(docname, d):
if frappe.flags.in_patch:
return
if frappe.flags.in_fixtures:
if frappe.flags.in_patch or frappe.flags.in_fixtures:
return
if d.fieldtype in ("Link",) + table_fields:
@ -1133,7 +1132,7 @@ def validate_fields(meta):
d.options = options
def check_hidden_and_mandatory(docname, d):
if d.hidden and d.reqd and not d.default:
if d.hidden and d.reqd and not d.default and not frappe.flags.in_migrate:
frappe.throw(
_("{0}: Field {1} in row {2} cannot be hidden and mandatory without default").format(
docname, d.label, d.idx
@ -1346,7 +1345,7 @@ def validate_fields(meta):
if meta.sort_field:
sort_fields = [meta.sort_field]
if "," in meta.sort_field:
sort_fields = [d.split()[0] for d in meta.sort_field.split(",")]
sort_fields = [d.split(maxsplit=1)[0] for d in meta.sort_field.split(",")]
for fieldname in sort_fields:
if fieldname not in (fieldname_list + list(default_fields) + list(child_table_fields)):
@ -1416,10 +1415,9 @@ def validate_fields(meta):
)
df_options_str = "<ul><li>" + "</li><li>".join(_(x) for x in data_field_options) + "</ul>"
frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True)
frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", alert=True)
def check_child_table_option(docfield):
if frappe.flags.in_fixtures:
return
if docfield.fieldtype not in ["Table MultiSelect", "Table"]:
@ -1462,31 +1460,34 @@ def validate_fields(meta):
check_invalid_fieldnames(meta.get("name"), d.fieldname)
check_unique_fieldname(meta.get("name"), d.fieldname)
check_fieldname_length(d.fieldname)
check_illegal_mandatory(meta.get("name"), d)
check_link_table_options(meta.get("name"), d)
check_dynamic_link_options(d)
check_hidden_and_mandatory(meta.get("name"), d)
check_in_list_view(meta.get("istable"), d)
check_in_global_search(d)
check_illegal_default(d)
check_unique_and_text(meta.get("name"), d)
check_illegal_depends_on_conditions(d)
check_child_table_option(d)
check_table_multiselect_option(d)
scrub_options_in_select(d)
scrub_fetch_from(d)
validate_data_field_type(d)
check_max_height(d)
check_no_of_ratings(d)
check_fold(fields)
check_search_fields(meta, fields)
check_title_field(meta)
check_timeline_field(meta)
check_is_published_field(meta)
check_website_search_field(meta)
check_sort_field(meta)
check_image_field(meta)
if not frappe.flags.in_migrate:
check_link_table_options(meta.get("name"), d)
check_illegal_mandatory(meta.get("name"), d)
check_dynamic_link_options(d)
check_in_list_view(meta.get("istable"), d)
check_in_global_search(d)
check_illegal_depends_on_conditions(d)
check_illegal_default(d)
check_child_table_option(d)
check_max_height(d)
check_no_of_ratings(d)
if not frappe.flags.in_migrate:
check_fold(fields)
check_search_fields(meta, fields)
check_title_field(meta)
check_timeline_field(meta)
check_is_published_field(meta)
check_website_search_field(meta)
check_sort_field(meta)
check_image_field(meta)
def get_fields_not_allowed_in_list_view(meta) -> list[str]:
@ -1603,11 +1604,6 @@ def validate_permissions(doctype, for_remove=False, alert=False):
d.set("import", 0)
d.set("export", 0)
for ptype, label in [["set_user_permissions", _("Set User Permissions")]]:
if d.get(ptype):
d.set(ptype, 0)
frappe.msgprint(_("{0} cannot be set for Single types").format(label))
def check_if_submittable(d):
if d.submit and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Submit if not Submittable").format(get_txt(d)))

View file

@ -172,32 +172,6 @@ class TestDocType(FrappeTestCase):
if condition:
self.assertFalse(re.match(pattern, condition))
def test_data_field_options(self):
doctype_name = "Test Data Fields"
valid_data_field_options = frappe.model.data_field_options + ("",)
invalid_data_field_options = ("Invalid Option 1", frappe.utils.random_string(5))
for field_option in valid_data_field_options + invalid_data_field_options:
test_doctype = frappe.get_doc(
{
"doctype": "DocType",
"name": doctype_name,
"module": "Core",
"custom": 1,
"fields": [
{"fieldname": f"{field_option}_field", "fieldtype": "Data", "options": field_option}
],
}
)
if field_option in invalid_data_field_options:
# assert that only data options in frappe.model.data_field_options are valid
self.assertRaises(frappe.ValidationError, test_doctype.insert)
else:
test_doctype.insert()
self.assertEqual(test_doctype.name, doctype_name)
test_doctype.delete()
def test_sync_field_order(self):
import os
@ -552,13 +526,14 @@ class TestDocType(FrappeTestCase):
self.assertRaises(InvalidFieldNameError, validate_links_table_fieldnames, doc)
def test_create_virtual_doctype(self):
"""Test virtual DOcTYpe."""
"""Test virtual DocType."""
virtual_doc = new_doctype("Test Virtual Doctype")
virtual_doc.is_virtual = 1
virtual_doc.insert()
virtual_doc.save()
virtual_doc.insert(ignore_if_duplicate=True)
virtual_doc.reload()
doc = frappe.get_doc("DocType", "Test Virtual Doctype")
self.assertDictEqual(doc.as_dict(), virtual_doc.as_dict())
self.assertEqual(doc.is_virtual, 1)
self.assertFalse(frappe.db.table_exists("Test Virtual Doctype"))

View file

@ -81,10 +81,10 @@
},
{
"depends_on": "transaction_type",
"description": "Generate 3 preview of names generate by any valid series.",
"description": "Get a preview of generated names with a series.",
"fieldname": "try_naming_series",
"fieldtype": "Data",
"label": "Try a naming Series"
"label": "Try a Naming Series"
},
{
"fieldname": "transaction_type",
@ -111,7 +111,7 @@
"icon": "fa fa-sort-by-order",
"issingle": 1,
"links": [],
"modified": "2022-05-30 23:51:36.136535",
"modified": "2023-02-20 13:11:56.662100",
"modified_by": "Administrator",
"module": "Core",
"name": "Document Naming Settings",
@ -130,4 +130,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}
}

View file

@ -1,9 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from frappe.tests.utils import FrappeTestCase
from frappe.utils.logger import sanitized_dict
# test_records = frappe.get_test_records('Error Snapshot')
class TestErrorSnapshot(FrappeTestCase):
pass
def test_form_dict_sanitization(self):
self.assertNotEqual(sanitized_dict({"pwd": "SECRET", "usr": "WHAT"}).get("pwd"), "SECRET")

View file

@ -24,6 +24,8 @@ frappe.ui.form.on("File", {
preview_file: function (frm) {
let $preview = "";
let file_name = frm.doc.file_name.split("?")[0];
let file_extension = file_name.split(".").pop()?.toLowerCase();
if (frappe.utils.is_image_file(frm.doc.file_url)) {
$preview = $(`<div class="img_preview">
@ -40,7 +42,7 @@ frappe.ui.form.on("File", {
${__("Your browser does not support the video element.")}
</video>
</div>`);
} else if (frm.doc.file_name.split("?")[0].endsWith(".pdf")) {
} else if (file_extension === "pdf") {
$preview = $(`<div class="img_preview">
<object style="background:#323639;" width="100%">
<embed
@ -51,7 +53,7 @@ frappe.ui.form.on("File", {
>
</object>
</div>`);
} else if (frm.doc.file_name.split("?")[0].endsWith(".mp3")) {
} else if (file_extension === "mp3") {
$preview = $(`<div class="img_preview">
<audio width="480" height="60" controls>
<source src="${frm.doc.file_url}" type="audio/mpeg">

View file

@ -329,7 +329,11 @@ class File(Document):
self.file_url = duplicate_file.file_url
def set_file_name(self):
if not self.file_name and self.file_url:
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
)
elif not self.file_name and self.file_url:
self.file_name = self.file_url.split("/")[-1]
else:
self.file_name = re.sub(r"/", "", self.file_name)

View file

@ -225,7 +225,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F
def _save_file(match):
data = match.group(1).split("data:")[1]
headers, content = data.split(",")
mtype = headers.split(";")[0]
mtype = headers.split(";", 1)[0]
if isinstance(content, str):
content = content.encode("utf-8")
@ -237,7 +237,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F
if "filename=" in headers:
filename = headers.split("filename=")[-1]
filename = safe_decode(filename).split(";")[0]
filename = safe_decode(filename).split(";", 1)[0]
else:
filename = get_random_filename(content_type=mtype)

View file

@ -2,6 +2,66 @@
// For license information, please see license.txt
frappe.ui.form.on("Installed Applications", {
// refresh: function(frm) {
// }
refresh: function (frm) {
frm.add_custom_button(__("Update Hooks Resolution Order"), () => {
frm.trigger("show_update_order_dialog");
});
},
show_update_order_dialog() {
const dialog = new frappe.ui.Dialog({
title: __("Update Hooks Resolution Order"),
fields: [
{
fieldname: "apps",
fieldtype: "Table",
label: __("Installed Apps"),
cannot_add_rows: true,
cannot_delete_rows: true,
in_place_edit: true,
data: [],
fields: [
{
fieldtype: "Data",
fieldname: "app_name",
label: __("App Name"),
in_list_view: 1,
read_only: 1,
},
],
},
],
primary_action: function () {
const new_order = this.get_values()["apps"].map((row) => row.app_name);
frappe.call({
method: "frappe.core.doctype.installed_applications.installed_applications.update_installed_apps_order",
freeze: true,
args: {
new_order: new_order,
},
});
this.hide();
},
primary_action_label: __("Update Order"),
});
frappe
.xcall(
"frappe.core.doctype.installed_applications.installed_applications.get_installed_app_order"
)
.then((data) => {
data.forEach((app) => {
dialog.fields_dict.apps.df.data.push({
app_name: app,
});
});
dialog.fields_dict.apps.grid.refresh();
// hack: change checkboxes to drag handles.
let grid = $(dialog.fields_dict.apps.grid.parent);
grid.find(".grid-row-check:first").remove() &&
grid.find(".grid-row-check").replaceWith(frappe.utils.icon("menu"));
dialog.show();
});
},
});

View file

@ -1,10 +1,17 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# License: MIT. See LICENSE
import json
import frappe
from frappe import _
from frappe.model.document import Document
class InvalidAppOrder(frappe.ValidationError):
pass
class InstalledApplications(Document):
def update_versions(self):
self.delete_key("installed_applications")
@ -18,3 +25,51 @@ class InstalledApplications(Document):
},
)
self.save()
@frappe.whitelist()
def update_installed_apps_order(new_order: list[str] | str):
"""Change the ordering of `installed_apps` global
This list is used to resolve hooks and by default it's order of installation on site.
Sometimes it might not be the ordering you want, so thie function is provided to override it.
"""
frappe.only_for("System Manager")
if isinstance(new_order, str):
new_order = json.loads(new_order)
frappe.local.request_cache and frappe.local.request_cache.clear()
existing_order = frappe.get_installed_apps(_ensure_on_bench=True)
if set(existing_order) != set(new_order) or not isinstance(new_order, list):
frappe.throw(
_("You are only allowed to update order, do not remove or add apps."), exc=InvalidAppOrder
)
# Ensure frappe is always first regardless of user's preference.
if "frappe" in new_order:
new_order.remove("frappe")
new_order.insert(0, "frappe")
frappe.db.set_global("installed_apps", json.dumps(new_order))
_create_version_log_for_change(existing_order, new_order)
def _create_version_log_for_change(old, new):
version = frappe.new_doc("Version")
version.ref_doctype = "DefaultValue"
version.docname = "installed_apps"
version.data = frappe.as_json({"changed": [["current", json.dumps(old), json.dumps(new)]]})
version.flags.ignore_links = True # This is a fake doctype
version.flags.ignore_permissions = True
version.insert()
@frappe.whitelist()
def get_installed_app_order() -> list[str]:
frappe.only_for("System Manager")
return frappe.get_installed_apps(_ensure_on_bench=True)

View file

@ -1,8 +1,16 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
import frappe
from frappe.core.doctype.installed_applications.installed_applications import (
InvalidAppOrder,
update_installed_apps_order,
)
from frappe.tests.utils import FrappeTestCase
class TestInstalledApplications(FrappeTestCase):
pass
def test_order_change(self):
update_installed_apps_order(["frappe"])
self.assertRaises(InvalidAppOrder, update_installed_apps_order, [])
self.assertRaises(InvalidAppOrder, update_installed_apps_order, ["frappe", "deepmind"])

View file

@ -19,6 +19,8 @@ DEFAULT_LOGTYPES_RETENTION = {
"Route History": 90,
"Submission Queue": 30,
"Prepared Report": 30,
"Webhook Request Log": 30,
"Reminder": 30,
}

View file

@ -26,7 +26,7 @@ class PackageImport(Document):
attachment = attachment[0]
# get package_name from file (package_name-0.0.0.tar.gz)
package_name = attachment.file_name.split(".")[0].rsplit("-", 1)[0]
package_name = attachment.file_name.split(".", 1)[0].rsplit("-", 1)[0]
if not os.path.exists(frappe.get_site_path("packages")):
os.makedirs(frappe.get_site_path("packages"))

View file

@ -1,6 +1,6 @@
{
"actions": [],
"autoname": "PATCHLOG.#####",
"autoname": "hash",
"creation": "2013-01-17 11:36:45",
"description": "List of patches executed",
"doctype": "DocType",
@ -20,11 +20,11 @@
"icon": "fa fa-cog",
"idx": 1,
"links": [],
"modified": "2022-06-13 05:34:37.845368",
"modified": "2023-01-17 15:35:11.688615",
"modified_by": "Administrator",
"module": "Core",
"name": "Patch Log",
"naming_rule": "Expression (old style)",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [
{

View file

@ -141,6 +141,8 @@ def serialize_job(job: Job) -> frappe._dict:
creation=convert_utc_to_user_timezone(job.created_at),
modified=convert_utc_to_user_timezone(modified),
_comment_count=0,
owner=job.kwargs.get("user"),
modified_by=job.kwargs.get("user"),
)

View file

@ -3,11 +3,17 @@
frappe.ui.form.on("Submission Queue", {
refresh: function (frm) {
if (frm.doc.status === "Queued" && frm.doc.job_id) {
if (frm.doc.status === "Queued" && frappe.boot.user.roles.includes("System Manager")) {
frm.add_custom_button(__("Unlock Reference Document"), () => {
frappe.confirm(__("Are you sure you want to go ahead with this action?"), () => {
frm.call("unlock_doc");
});
frappe.confirm(
`
Are you sure you want to go ahead with this action?
Doing this could unlock other submissions of this document which are in queue (if present)
and could lead to non-ideal conditions.`,
() => {
frm.call("unlock_doc");
}
);
});
}
},

View file

@ -20,8 +20,9 @@
"fields": [
{
"fieldname": "job_id",
"fieldtype": "Data",
"fieldtype": "Link",
"label": "Job Id",
"options": "RQ Job",
"read_only": 1
},
{
@ -80,14 +81,14 @@
},
{
"fieldname": "exception",
"fieldtype": "Text",
"fieldtype": "Long Text",
"label": "Exception",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-11-12 16:48:37.797232",
"modified": "2023-01-23 12:45:53.997708",
"modified_by": "Administrator",
"module": "Core",
"name": "Submission Queue",
@ -102,6 +103,11 @@
"report": 1,
"role": "System Manager",
"share": 1
},
{
"if_owner": 1,
"read": 1,
"role": "All"
}
],
"sort_field": "modified",

View file

@ -4,8 +4,6 @@
from urllib.parse import quote
from rq import get_current_job
from rq.exceptions import NoSuchJobError
from rq.job import Job
import frappe
from frappe import _
@ -13,7 +11,6 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create
from frappe.model.document import Document
from frappe.monitor import add_data_to_monitor
from frappe.utils import now, time_diff_in_seconds
from frappe.utils.background_jobs import get_redis_conn
from frappe.utils.data import cint
@ -39,6 +36,7 @@ class SubmissionQueue(Document):
frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days))))
def insert(self, to_be_queued_doc: Document, action: str):
self.status = "Queued"
self.to_be_queued_doc = to_be_queued_doc
self.action_for_queuing = action
super().insert(ignore_permissions=True)
@ -70,6 +68,7 @@ class SubmissionQueue(Document):
def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str):
# Set the job id for that submission doctype
self.update_job_id(get_current_job().id)
_action = action_for_queuing.lower()
if _action == "update":
_action = "submit"
@ -85,7 +84,7 @@ class SubmissionQueue(Document):
)
values = {"status": "Finished"}
except Exception:
values = {"status": "Failed", "exception": frappe.get_traceback()}
values = {"status": "Failed", "exception": frappe.get_traceback(with_context=True)}
frappe.db.rollback()
values["ended_at"] = now()
@ -96,22 +95,27 @@ class SubmissionQueue(Document):
if submission_status == "Failed":
doctype = self.doctype
docname = self.name
message = _("Submission of {0} {1} with action {2} failed")
message = _("Action {0} failed on {1} {2}. View it {3}")
else:
doctype = self.ref_doctype
docname = self.ref_docname
message = _("Submission of {0} {1} with action {2} completed successfully")
message = _("Action {0} completed successfully on {1} {2}. View it {3}")
message = message.format(
frappe.bold(str(self.ref_doctype)), frappe.bold(self.ref_docname), frappe.bold(action)
message_replacements = (
frappe.bold(action),
frappe.bold(str(self.ref_doctype)),
frappe.bold(str(self.ref_docname)),
)
time_diff = time_diff_in_seconds(now(), self.created_at)
if cint(time_diff) <= 60:
frappe.publish_realtime(
"msgprint",
{
"message": message
+ f". View it <a href='/app/{quote(doctype.lower().replace(' ', '-'))}/{quote(docname)}'><b>here</b></a>",
"message": message.format(
*message_replacements,
f"<a href='/app/{quote(doctype.lower().replace(' ', '-'))}/{quote(docname)}'><b>here</b></a>",
),
"alert": True,
"indicator": "red" if submission_status == "Failed" else "green",
},
@ -122,50 +126,27 @@ class SubmissionQueue(Document):
"type": "Alert",
"document_type": doctype,
"document_name": docname,
"subject": message,
"subject": message.format(*message_replacements, "here"),
}
notify_to = frappe.db.get_value("User", self.enqueued_by, fieldname="email")
enqueue_create_notification([notify_to], notification_doc)
def _unlock_reference_doc(self):
"""
Only execute if self.job_id is defined.
"""
try:
job = Job.fetch(self.job_id, connection=get_redis_conn())
status = job.get_status(refresh=True)
exc = job.exc_info
except NoSuchJobError:
exc = None
status = "failed"
if status in ("queued", "started"):
frappe.msgprint(_("Document in queue for execution!"))
return
self.queued_doc.unlock()
values = (
{"status": "Finished"} if status == "finished" else {"status": "Failed", "exception": exc}
)
frappe.db.set_value(self.doctype, self.name, values, update_modified=False)
frappe.msgprint(_("Document Unlocked"))
@frappe.whitelist()
def unlock_doc(self):
# NOTE: this can lead to some weird unlocking/locking behaviours.
# for example: hitting unlock on a submission could lead to unlocking of another submission
# of the same reference document.
if self.status != "Queued" and not self.job_id:
if self.status != "Queued":
return
self._unlock_reference_doc()
self.queued_doc.unlock()
frappe.msgprint(_("Document Unlocked"))
def queue_submission(doc: Document, action: str, alert: bool = True):
queue = frappe.new_doc("Submission Queue")
queue.state = "Queued"
queue.ref_doctype = doc.doctype
queue.ref_docname = doc.name
queue.insert(doc, action)
@ -185,9 +166,25 @@ def get_latest_submissions(doctype, docname):
# NOTE: not used creation as orderby intentianlly as we have used update_modified=False everywhere
# hence assuming modified will be equal to creation for submission queue documents
dt = "Submission Queue"
filters = {"ref_doctype": doctype, "ref_docname": docname}
return {
"latest_submission": frappe.db.get_value(dt, filters),
"latest_failed_submission": frappe.db.get_value(dt, filters | {"status": "Failed"}),
}
latest_submission = frappe.db.get_value(
"Submission Queue",
filters={"ref_doctype": doctype, "ref_docname": docname},
fieldname=["name", "exception", "status"],
)
out = None
if latest_submission:
out = {
"latest_submission": latest_submission[0],
"exc": format_tb(latest_submission[1]),
"status": latest_submission[2],
}
return out
def format_tb(traceback: str | None = None):
if not traceback:
return
return traceback.strip().split("\n")[-1]

View file

@ -30,13 +30,6 @@ frappe.ui.form.on("System Settings", {
frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
}
},
enable_prepared_report_auto_deletion: function (frm) {
if (frm.doc.enable_prepared_report_auto_deletion) {
if (!frm.doc.prepared_report_expiry_period) {
frm.set_value("prepared_report_expiry_period", 7);
}
}
},
on_update: function (frm) {
if (frappe.boot.time_zone && frappe.boot.time_zone.system !== frm.doc.time_zone) {
// Clear cache after saving to refresh the values of boot.

View file

@ -769,7 +769,6 @@
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 1,
"share": 1,
"write": 1
},
@ -793,4 +792,4 @@
"states": [],
"title_field": "full_name",
"track_changes": 1
}
}

View file

@ -122,11 +122,20 @@ class User(Document):
now = frappe.flags.in_test or frappe.flags.in_install
self.send_password_notification(self.__new_password)
frappe.enqueue(
"frappe.core.doctype.user.user.create_contact", user=self, ignore_mandatory=True, now=now
"frappe.core.doctype.user.user.create_contact",
user=self,
ignore_mandatory=True,
now=now,
enqueue_after_commit=True,
)
if self.name not in STANDARD_USERS and not self.user_image:
frappe.enqueue("frappe.core.doctype.user.user.update_gravatar", name=self.name, now=now)
frappe.enqueue(
"frappe.core.doctype.user.user.update_gravatar",
name=self.name,
now=now,
enqueue_after_commit=True,
)
# Set user selected timezone
if self.time_zone:
@ -305,12 +314,10 @@ class User(Document):
.from_(user_role_doctype)
.select(user_doctype.name)
.where(user_role_doctype.role == "System Manager")
.where(user_doctype.docstatus < 2)
.where(user_doctype.enabled == 1)
.where(user_role_doctype.parent == user_doctype.name)
.where(user_role_doctype.parent.notin(["Administrator", self.name]))
.limit(1)
.distinct()
).run()
def get_fullname(self):

View file

@ -277,7 +277,7 @@ def create_user(email, *roles):
user = frappe.new_doc("User")
user.email = email
user.first_name = email.split("@")[0]
user.first_name = email.split("@", 1)[0]
if not roles:
roles = ("System Manager",)

View file

@ -287,7 +287,7 @@ def user_linked_with_permission_on_doctype(doc, user):
def apply_permissions_for_non_standard_user_type(doc, method=None):
"""Create user permission for the non standard user type"""
if not frappe.db.table_exists("User Type"):
if not frappe.db.table_exists("User Type") or frappe.flags.in_migrate:
return
user_types = frappe.cache().get_value(

View file

@ -64,9 +64,9 @@ class Dashboard {
let title = this.dashboard_name;
if (!this.dashboard_name.toLowerCase().includes(__("dashboard"))) {
// ensure dashboard title has "dashboard"
title = __("{0} Dashboard", [title]);
title = __("{0} Dashboard", [__(title)]);
}
this.page.set_title(title);
this.page.set_title(__(title));
this.set_dropdown();
this.container.empty();
this.refresh();

View file

@ -320,7 +320,6 @@ frappe.PermissionEngine = class PermissionEngine {
"report",
"import",
"export",
"set_user_permissions",
"share",
];
}

View file

@ -62,8 +62,8 @@ def get_roles_and_doctypes():
roles_list = [{"label": _(d.get("name")), "value": d.get("name")} for d in roles]
return {
"doctypes": sorted(doctypes_list, key=lambda d: d["label"]),
"roles": sorted(roles_list, key=lambda d: d["label"]),
"doctypes": sorted(doctypes_list, key=lambda d: d["label"].casefold()),
"roles": sorted(roles_list, key=lambda d: d["label"].casefold()),
}

View file

@ -7,25 +7,24 @@
"allow_print": 0,
"apply_document_permissions": 0,
"breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]",
"client_script": "frappe.web_form.after_load = () => {\n if (window.location.pathname.endsWith(\"/new\") && frappe.session.user) {\n let current_path = window.location.href;\n window.location.href = current_path.replace(\"/new\", \"/\" + frappe.session.user);\n }\n}",
"creation": "2016-09-19 05:16:59.242754",
"doc_type": "User",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"introduction_text": "",
"is_multi_step_form": 0,
"is_standard": 1,
"list_columns": [],
"login_required": 1,
"max_attachment_size": 0,
"modified": "2022-07-18 16:51:19.796411",
"modified": "2023-01-18 10:26:26.766414",
"modified_by": "Administrator",
"module": "Core",
"name": "edit-profile",
"owner": "Administrator",
"published": 1,
"route": "update-profile",
"route_to_success_link": 0,
"show_attachments": 0,
"show_list": 0,
"show_sidebar": 0,

View file

@ -18,6 +18,18 @@ class CustomField(Document):
self.name = self.dt + "-" + self.fieldname
def set_fieldname(self):
restricted = (
"name",
"parent",
"creation",
"modified",
"modified_by",
"parentfield",
"parenttype",
"file_list",
"flags",
"docstatus",
)
if not self.fieldname:
label = self.label
if not label:
@ -34,6 +46,9 @@ class CustomField(Document):
# fieldnames should be lowercase
self.fieldname = self.fieldname.lower()
if self.fieldname in restricted:
self.fieldname = self.fieldname + "1"
def before_insert(self):
self.set_fieldname()
@ -142,7 +157,7 @@ def get_fields_label(doctype=None):
return frappe.msgprint(_("Custom Fields can only be added to a standard DocType."))
return [
{"value": df.fieldname or "", "label": _(df.label or "")}
{"value": df.fieldname or "", "label": _(df.label) if df.label else ""}
for df in frappe.get_meta(doctype).get("fields")
]

View file

@ -263,6 +263,10 @@ frappe.ui.form.on("Customize Form Field", {
f.is_custom_field = true;
frm.trigger("setup_default_views");
},
form_render(frm, doctype, docname) {
frm.trigger("setup_fetch_from_fields", doctype, docname);
},
});
// can't delete standard links
@ -310,22 +314,59 @@ frappe.ui.form.on("DocType State", {
},
});
frappe.customize_form.set_primary_action = function (frm) {
frm.page.set_primary_action(__("Update"), function () {
if (frm.doc.doc_type) {
return frm.call({
doc: frm.doc,
freeze: true,
btn: frm.page.btn_primary,
method: "save_customization",
callback: function (r) {
if (!r.exc) {
frappe.customize_form.clear_locals_and_refresh(frm);
frm.script_manager.trigger("doc_type");
}
},
});
frappe.customize_form.validate_fieldnames = async function (frm) {
for (let i = 0; i < frm.doc.fields.length; i++) {
let field = frm.doc.fields[i];
let fieldname = field.label && frappe.model.scrub(field.label).toLowerCase();
if (
field.label &&
!field.fieldname &&
in_list(frappe.model.restricted_fields, fieldname)
) {
let message = __(
"For field <b>{0}</b> in row <b>{1}</b>, fieldname <b>{2}</b> is restricted it will be renamed as <b>{2}1</b>. Do you want to continue?",
[field.label, field.idx, fieldname]
);
await pause_to_confirm(message);
}
}
function pause_to_confirm(message) {
return new Promise((resolve) => {
frappe.confirm(
message,
() => resolve(),
() => {
frm.page.btn_primary.prop("disabled", false);
}
);
});
}
};
frappe.customize_form.save_customization = function (frm) {
if (frm.doc.doc_type) {
return frm.call({
doc: frm.doc,
freeze: true,
freeze_message: __("Saving Customization..."),
btn: frm.page.btn_primary,
method: "save_customization",
callback: function (r) {
if (!r.exc) {
frappe.customize_form.clear_locals_and_refresh(frm);
frm.script_manager.trigger("doc_type");
}
},
});
}
};
frappe.customize_form.set_primary_action = function (frm) {
frm.page.set_primary_action(__("Update"), async () => {
await this.validate_fieldnames(frm);
this.save_customization(frm);
});
};

View file

@ -212,6 +212,7 @@
},
{
"default": "0",
"depends_on": "eval:!in_list(['Section Break', 'Column Break', 'Tab Break'], doc.fieldtype)",
"fieldname": "permlevel",
"fieldtype": "Int",
"in_list_view": 1,
@ -467,7 +468,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2022-11-30 14:25:50.649449",
"modified": "2023-02-20 12:07:40.242470",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",

View file

@ -43,7 +43,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-09-01 03:22:33.973058",
"modified": "2023-02-14 17:53:24.486171",
"modified_by": "Administrator",
"module": "Custom",
"name": "DocType Layout",
@ -64,7 +64,7 @@
},
{
"read": 1,
"role": "Guest"
"role": "All"
}
],
"route": "doctype-layout",

View file

@ -57,7 +57,6 @@
"report": 1,
"export": 1,
"import": 0,
"set_user_permissions": 0,
"share": 1,
"print": 1,
"email": 1,

View file

@ -57,7 +57,6 @@
"report": 1,
"export": 1,
"import": 0,
"set_user_permissions": 0,
"share": 1,
"print": 1,
"email": 1,

View file

View file

@ -0,0 +1,7 @@
// Copyright (c) 2023, Frappe Technologies and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Audit System Hooks"] = {
filters: [],
};

View file

@ -0,0 +1,27 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2023-01-25 15:02:21.896117",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": "",
"modified": "2023-01-31 14:53:37.778576",
"modified_by": "Administrator",
"module": "Custom",
"name": "Audit System Hooks",
"owner": "Administrator",
"prepared_report": 0,
"query": "",
"ref_doctype": "Property Setter",
"report_name": "Audit System Hooks",
"report_type": "Script Report",
"roles": [
{
"role": "System Manager"
}
]
}

View file

@ -0,0 +1,70 @@
# Copyright (c) 2023, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
def execute(filters=None):
return get_columns(), get_data()
def get_columns():
values_field_type = "Data" # TODO: better text wrapping in reportview
columns = [
{"label": "Hook name", "fieldname": "hook_name", "fieldtype": "Data", "width": 200},
{"label": "Hook key (optional)", "fieldname": "hook_key", "fieldtype": "Data", "width": 200},
{"label": "Hook Values (resolved)", "fieldname": "hook_values", "fieldtype": values_field_type},
]
# Each app is shown in order as a column
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
columns += [
{"label": app, "fieldname": app, "fieldtype": values_field_type} for app in installed_apps
]
return columns
def get_data():
hooks = frappe.get_hooks()
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
def fmt_hook_values(v):
"""Improve readability by discarding falsy values and removing containers when only 1
value is in container"""
if not v:
return ""
v = delist(v)
if isinstance(v, (dict, list)):
try:
return frappe.as_json(v)
except Exception:
pass
return str(v)
data = []
for hook, values in hooks.items():
if isinstance(values, dict):
for k, v in values.items():
row = {"hook_name": hook, "hook_key": fmt_hook_values(k), "hook_values": fmt_hook_values(v)}
for app in installed_apps:
if app_hooks := delist(frappe.get_hooks(hook, app_name=app)):
row[app] = fmt_hook_values(app_hooks.get(k))
data.append(row)
else:
row = {"hook_name": hook, "hook_values": fmt_hook_values(values)}
for app in installed_apps:
row[app] = fmt_hook_values(frappe.get_hooks(hook, app_name=app))
data.append(row)
return data
def delist(val):
if isinstance(val, list) and len(val) == 1:
return val[0]
return val

View file

@ -0,0 +1,17 @@
# Copyright (c) 2022, Frappe Technologies and contributors
# For license information, please see license.txt
from frappe.custom.report.audit_system_hooks.audit_system_hooks import execute
from frappe.tests.utils import FrappeTestCase
class TestAuditSystemHooksReport(FrappeTestCase):
def test_basic_query(self):
_, data = execute()
for row in data:
if row.get("hook_name") == "app_name":
self.assertEqual(row.get("hook_values"), "frappe")
break
else:
self.fail("Failed to generate hooks report")

View file

@ -20,6 +20,7 @@ import frappe.defaults
import frappe.model.meta
from frappe import _
from frappe.database.utils import (
DefaultOrderBy,
EmptyQueryValues,
FallBackDateTimeStr,
LazyMogrify,
@ -221,7 +222,7 @@ class Database:
self._cursor.execute(query, values)
except Exception as e:
if self.is_syntax_error(e):
frappe.errprint(f"Syntax error in query:\n{query} {values}")
frappe.errprint(f"Syntax error in query:\n{query} {values or ''}")
elif self.is_deadlocked(e):
raise frappe.QueryDeadlockError(e) from e
@ -232,7 +233,7 @@ class Database:
elif self.is_read_only_mode_error(e):
frappe.throw(
_(
"Site is running in read only mode, this action can not be performed right now. Please try again later."
"Site is running in read only mode for maintenance or site update, this action can not be performed right now. Please try again later."
),
title=_("In Read Only Mode"),
exc=frappe.InReadOnlyMode,
@ -422,7 +423,7 @@ class Database:
ignore=None,
as_dict=False,
debug=False,
order_by="KEEP_DEFAULT_ORDERING",
order_by=DefaultOrderBy,
cache=False,
for_update=False,
*,
@ -492,7 +493,7 @@ class Database:
ignore=None,
as_dict=False,
debug=False,
order_by="KEEP_DEFAULT_ORDERING",
order_by=DefaultOrderBy,
update=None,
cache=False,
for_update=False,
@ -551,7 +552,7 @@ class Database:
if (filters is not None) and (filters != doctype or doctype == "DocType"):
try:
if order_by:
order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by
order_by = "modified" if order_by == DefaultOrderBy else order_by
out = self._get_values_from_table(
fields=fields,
filters=filters,
@ -622,7 +623,7 @@ class Database:
return [map(values.get, fields)]
else:
r = frappe.qb.engine.get_query(
r = frappe.qb.get_query(
"Singles",
filters={"field": ("in", tuple(fields)), "doctype": doctype},
fields=["field", "value"],
@ -655,7 +656,7 @@ class Database:
# Get coulmn and value of the single doctype Accounts Settings
account_settings = frappe.db.get_singles_dict("Accounts Settings")
"""
queried_result = frappe.qb.engine.get_query(
queried_result = frappe.qb.get_query(
"Singles",
filters={"doctype": doctype},
fields=["field", "value"],
@ -761,7 +762,7 @@ class Database:
if cache and fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname]
val = frappe.qb.engine.get_query(
val = frappe.qb.get_query(
table="Singles",
filters={"doctype": doctype, "field": fieldname},
fields="value",
@ -772,7 +773,9 @@ class Database:
if not df:
frappe.throw(
_("Invalid field name: {0}").format(frappe.bold(fieldname)), self.InvalidColumnName
_("Field {0} does not exist on {1}").format(
frappe.bold(fieldname), frappe.bold(doctype), self.InvalidColumnName
)
)
val = cast_fieldtype(df.fieldtype, val)
@ -801,16 +804,16 @@ class Database:
distinct=False,
limit=None,
):
query = frappe.qb.engine.get_query(
query = frappe.qb.get_query(
table=doctype,
filters=filters,
orderby=order_by,
order_by=order_by,
for_update=for_update,
fields=fields,
distinct=distinct,
limit=limit,
)
if fields == "*" and not isinstance(fields, (list, tuple)) and not isinstance(fields, Criterion):
if isinstance(fields, str) and fields == "*":
as_dict = True
return query.run(as_dict=as_dict, debug=debug, update=update, run=run, pluck=pluck)
@ -830,15 +833,14 @@ class Database:
as_dict=False,
):
if names := list(filter(None, names)):
return frappe.qb.engine.get_query(
return frappe.qb.get_query(
doctype,
fields=field,
filters=names,
order_by=order_by,
pluck=pluck,
distinct=distinct,
limit=limit,
).run(debug=debug, run=run, as_dict=as_dict)
).run(debug=debug, run=run, as_dict=as_dict, pluck=pluck)
return {}
def set_value(
@ -887,7 +889,7 @@ class Database:
field, val, modified=modified, modified_by=modified_by, update_modified=update_modified
)
query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True)
query = frappe.qb.get_query(table=dt, filters=dn, update=True)
if isinstance(dn, str):
frappe.clear_document_cache(dt, dn)
@ -1044,7 +1046,7 @@ class Database:
dt = dt.copy() # don't modify the original dict
dt, dn = dt.pop("doctype"), dt
return self.get_value(dt, dn, ignore=True, cache=cache)
return self.get_value(dt, dn, ignore=True, cache=cache, order_by=None)
def count(self, dt, filters=None, debug=False, cache=False, distinct: bool = True):
"""Returns `COUNT(*)` for given DocType and filters."""
@ -1052,9 +1054,9 @@ class Database:
cache_count = frappe.cache().get_value(f"doctype:count:{dt}")
if cache_count is not None:
return cache_count
count = frappe.qb.engine.get_query(
table=dt, filters=filters, fields=Count("*"), distinct=distinct
).run(debug=debug)[0][0]
count = frappe.qb.get_query(table=dt, filters=filters, fields=Count("*"), distinct=distinct).run(
debug=debug
)[0][0]
if not filters and cache:
frappe.cache().set_value(f"doctype:count:{dt}", count, expires_in_sec=86400)
return count
@ -1068,13 +1070,7 @@ class Database:
if not datetime:
return FallBackDateTimeStr
if isinstance(datetime, str):
if ":" not in datetime:
datetime = datetime + " 00:00:00.000000"
else:
datetime = datetime.strftime("%Y-%m-%d %H:%M:%S.%f")
return datetime
return get_datetime(datetime).strftime("%Y-%m-%d %H:%M:%S.%f")
def get_creation_count(self, doctype, minutes):
"""Get count of records created in the last x minutes"""
@ -1195,7 +1191,7 @@ class Database:
Doctype name can be passed directly, it will be pre-pended with `tab`.
"""
filters = filters or kwargs.get("conditions")
query = frappe.qb.engine.build_conditions(table=doctype, filters=filters).delete()
query = frappe.qb.get_query(table=doctype, filters=filters, delete=True)
if "debug" not in kwargs:
kwargs["debug"] = debug
return query.run(**kwargs)

View file

@ -116,6 +116,7 @@ CREATE TABLE `tabDocPerm` (
-- Table structure for table `tabDocType Action`
--
DROP TABLE IF EXISTS `tabDocType Action`;
CREATE TABLE `tabDocType Action` (
`name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
`creation` datetime(6) DEFAULT NULL,
@ -137,9 +138,10 @@ CREATE TABLE `tabDocType Action` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC;
--
-- Table structure for table `tabDocType Action`
-- Table structure for table `tabDocType Link`
--
DROP TABLE IF EXISTS `tabDocType Link`;
CREATE TABLE `tabDocType Link` (
`name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL,
`creation` datetime(6) DEFAULT NULL,

View file

@ -94,7 +94,7 @@ class MariaDBTable(DBTable):
if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False):
add_index_query.append(f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)")
for col in self.drop_index + self.drop_unique:
for col in {*self.drop_index, *self.drop_unique}:
if col.fieldname == "name":
continue

View file

@ -19,7 +19,7 @@ def get_mariadb_version(version_string: str = ""):
# MariaDB classifies their versions as Major (1st and 2nd number), and Minor (3rd number)
# Example: Version 10.3.13 is Major Version = 10.3, Minor Version = 13
version_string = version_string or get_mariadb_variables().get("version")
version = version_string.split("-")[0]
version = version_string.split("-", 1)[0]
return version.rsplit(".", 1)

View file

@ -0,0 +1,138 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import operator
from typing import Callable
import frappe
from frappe.database.utils import NestedSetHierarchy
from frappe.model.db_query import get_timespan_date_range
from frappe.query_builder import Field
def like(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `LIKE`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `LIKE`
"""
return key.like(value)
def func_in(key: Field, value: list | tuple) -> frappe.qb:
"""Wrapper method for `IN`
Args:
key (str): field
value (Union[int, str]): criterion
Returns:
frappe.qb: `frappe.qb object with `IN`
"""
if isinstance(value, str):
value = value.split(",")
return key.isin(value)
def not_like(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `NOT LIKE`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `NOT LIKE`
"""
return key.not_like(value)
def func_not_in(key: Field, value: list | tuple | str):
"""Wrapper method for `NOT IN`
Args:
key (str): field
value (Union[int, str]): criterion
Returns:
frappe.qb: `frappe.qb object with `NOT IN`
"""
if isinstance(value, str):
value = value.split(",")
return key.notin(value)
def func_regex(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `REGEX`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `REGEX`
"""
return key.regex(value)
def func_between(key: Field, value: list | tuple) -> frappe.qb:
"""Wrapper method for `BETWEEN`
Args:
key (str): field
value (Union[int, str]): criterion
Returns:
frappe.qb: `frappe.qb object with `BETWEEN`
"""
return key[slice(*value)]
def func_is(key, value):
"Wrapper for IS"
return key.isnotnull() if value.lower() == "set" else key.isnull()
def func_timespan(key: Field, value: str) -> frappe.qb:
"""Wrapper method for `TIMESPAN`
Args:
key (str): field
value (str): criterion
Returns:
frappe.qb: `frappe.qb object with `TIMESPAN`
"""
return func_between(key, get_timespan_date_range(value))
# default operators
OPERATOR_MAP: dict[str, Callable] = {
"+": operator.add,
"=": operator.eq,
"-": operator.sub,
"!=": operator.ne,
"<": operator.lt,
">": operator.gt,
"<=": operator.le,
"=<": operator.le,
">=": operator.ge,
"=>": operator.ge,
"/": operator.truediv,
"*": operator.mul,
"in": func_in,
"not in": func_not_in,
"like": like,
"not like": not_like,
"regex": func_regex,
"between": func_between,
"is": func_is,
"timespan": func_timespan,
"nested_set": NestedSetHierarchy,
# TODO: Add support for custom operators (WIP) - via filters_config hooks
}

File diff suppressed because it is too large Load diff

View file

@ -17,7 +17,7 @@ QueryValues = tuple | list | dict | NoneType
EmptyQueryValues = object()
FallBackDateTimeStr = "0001-01-01 00:00:00.000000"
DefaultOrderBy = "KEEP_DEFAULT_ORDERING"
NestedSetHierarchy = (
"ancestors of",
"descendants of",
@ -34,6 +34,14 @@ def is_pypika_function_object(field: str) -> bool:
return getattr(field, "__module__", None) == "pypika.functions" or isinstance(field, Function)
def get_doctype_name(table_name: str) -> str:
if table_name.startswith(("tab", "`tab", '"tab')):
table_name = table_name.replace("tab", "", 1)
table_name = table_name.replace("`", "")
table_name = table_name.replace('"', "")
return table_name
class LazyString:
def _setup(self) -> None:
raise NotImplementedError

View file

@ -158,14 +158,11 @@ class Workspace:
def build_workspace(self):
self.cards = {"items": self.get_links()}
self.charts = {"items": self.get_charts()}
self.shortcuts = {"items": self.get_shortcuts()}
self.onboardings = {"items": self.get_onboardings()}
self.quick_lists = {"items": self.get_quick_lists()}
self.number_cards = {"items": self.get_number_cards()}
def _doctype_contains_a_record(self, name):
exists = self.table_counts.get(name, False)
@ -332,6 +329,21 @@ class Workspace:
return steps
@handle_not_exist
def get_number_cards(self):
all_number_cards = []
if frappe.has_permission("Number Card", throw=False):
number_cards = self.doc.number_cards
for number_card in number_cards:
if frappe.has_permission("Number Card", doc=number_card.number_card_name):
# Translate label
number_card.label = (
_(number_card.label) if number_card.label else _(number_card.number_card_name)
)
all_number_cards.append(number_card)
return all_number_cards
@frappe.whitelist()
@frappe.read_only()
@ -354,6 +366,7 @@ def get_desktop_page(page):
"cards": workspace.cards,
"onboardings": workspace.onboardings,
"quick_lists": workspace.quick_lists,
"number_cards": workspace.number_cards,
}
except DoesNotExistError:
frappe.log_error("Workspace Missing")
@ -482,6 +495,10 @@ def save_new_widget(doc, page, blocks, new_widgets):
doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts"))
if widgets.quick_list:
doc.quick_lists.extend(new_widget(widgets.quick_list, "Workspace Quick List", "quick_lists"))
if widgets.number_card:
doc.number_cards.extend(
new_widget(widgets.number_card, "Workspace Number Card", "number_cards")
)
if widgets.card:
doc.build_links_table_from_card(widgets.card)
@ -511,12 +528,12 @@ def save_new_widget(doc, page, blocks, new_widgets):
def clean_up(original_page, blocks):
page_widgets = {}
for wid in ["shortcut", "card", "chart", "quick_list"]:
for wid in ["shortcut", "card", "chart", "quick_list", "number_card"]:
# get list of widget's name from blocks
page_widgets[wid] = [x["data"][wid + "_name"] for x in loads(blocks) if x["type"] == wid]
# shortcut, chart & quick_list cleanup
for wid in ["shortcut", "chart", "quick_list"]:
# shortcut, chart, quick_list & number_card cleanup
for wid in ["shortcut", "chart", "quick_list", "number_card"]:
updated_widgets = []
original_page.get(wid + "s").reverse()

View file

@ -278,15 +278,14 @@ def get_group_by_chart_config(chart, filters):
group_by_field = chart.group_by_based_on
doctype = chart.document_type
data = frappe.db.get_list(
data = frappe.get_list(
doctype,
fields=[
f"{group_by_field} as name",
"{aggregate_function}({value_field}) as count".format(
aggregate_function=aggregate_function, value_field=value_field
),
f"{aggregate_function}({value_field}) as count",
],
filters=filters,
parent_doctype=chart.parent_document_type,
group_by=group_by_field,
order_by="count desc",
ignore_ifnull=True,

View file

@ -306,8 +306,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
)
# process recurring events
start = start.split(" ")[0]
end = end.split(" ")[0]
start = start.split(" ", 1)[0]
end = end.split(" ", 1)[0]
add_events = []
remove_events = []
@ -315,7 +315,7 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
new_event = e.copy()
enddate = (
add_days(date, int(date_diff(e.ends_on.split(" ")[0], e.starts_on.split(" ")[0])))
add_days(date, int(date_diff(e.ends_on.split(" ", 1)[0], e.starts_on.split(" ", 1)[0])))
if (e.starts_on and e.ends_on)
else date
)
@ -337,8 +337,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
repeat = "3000-01-01" if cstr(e.repeat_till) == "" else e.repeat_till
if e.repeat_on == "Yearly":
start_year = cint(start.split("-")[0])
end_year = cint(end.split("-")[0])
start_year = cint(start.split("-", 1)[0])
end_year = cint(end.split("-", 1)[0])
# creates a string with date (27) and month (07) eg: 07-27
event_start = "-".join(event_start.split("-")[1:])
@ -357,12 +357,13 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[
if e.repeat_on == "Monthly":
# creates a string with date (27) and month (07) and year (2019) eg: 2019-07-27
date = start.split("-")[0] + "-" + start.split("-")[1] + "-" + event_start.split("-")[2]
year, month = start.split("-", maxsplit=2)[:2]
date = f"{year}-{month}-" + event_start.split("-", maxsplit=3)[2]
# last day of month issue, start from prev month!
try:
getdate(date)
except ValueError:
except Exception:
date = date.split("-")
date = date[0] + "-" + str(cint(date[1]) - 1) + "-" + date[2]

View file

@ -7,6 +7,7 @@
"engine": "InnoDB",
"field_order": [
"disable_count",
"disable_comment_count",
"disable_sidebar_stats",
"disable_auto_refresh",
"total_fields",
@ -49,13 +50,20 @@
"hidden": 1,
"label": "Fields",
"read_only": 1
},
{
"default": "0",
"fieldname": "disable_comment_count",
"fieldtype": "Check",
"label": "Disable Comment Count"
}
],
"links": [],
"modified": "2020-05-12 18:27:15.568199",
"modified": "2023-02-14 14:46:43.764229",
"modified_by": "Administrator",
"module": "Desk",
"name": "List View Settings",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -72,5 +80,6 @@
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -30,7 +30,7 @@ def mark_as_seen(note):
note = frappe.get_doc("Note", note)
if frappe.session.user not in [d.user for d in note.seen_by]:
note.append("seen_by", {"user": frappe.session.user})
note.save(ignore_version=True)
note.save(ignore_version=True, ignore_permissions=True)
def get_permission_query_conditions(user):

View file

@ -92,4 +92,7 @@ def get_permission_query_conditions(user):
@frappe.whitelist()
def set_seen_value(value, user):
if frappe.flags.read_only:
return
frappe.db.set_value("Notification Settings", user, "seen", value, update_modified=False)

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