diff --git a/.mergify.yml b/.mergify.yml
index 63fe1a0086..838ce75835 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -5,6 +5,7 @@ pull_request_rules:
- and:
- author!=surajshetty3416
- author!=gavindsouza
+ - author!=deepeshgarg007
- or:
- base=version-13
- base=version-12
diff --git a/CODEOWNERS b/CODEOWNERS
index f7d759c123..170334a4b4 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -6,9 +6,7 @@
* @frappe/frappe-review-team
templates/ @surajshetty3416
www/ @surajshetty3416
-integrations/ @leela
patches/ @surajshetty3416 @gavindsouza
-email/ @leela
event_streaming/ @ruchamahabal
data_import* @netchampfaris
core/ @surajshetty3416
diff --git a/cypress/integration/control_attach.js b/cypress/integration/control_attach.js
new file mode 100644
index 0000000000..0552780737
--- /dev/null
+++ b/cypress/integration/control_attach.js
@@ -0,0 +1,90 @@
+context('Attach Control', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/doctype');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
+ name: 'Test Attach Control',
+ fields: [
+ {
+ "label": "Attach File or Image",
+ "fieldname": "attach",
+ "fieldtype": "Attach",
+ "in_list_view": 1,
+ },
+ ]
+ });
+ });
+ });
+ it('Checking functionality for "Link" button in the "Attach" fieldtype', () => {
+ //Navigating to the new form for the newly created doctype
+ cy.new_form('Test Attach Control');
+
+ //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
+ cy.findByRole('button', {name: 'Attach'}).click();
+
+ //Clicking on "Link" button to attach a file using the "Link" button
+ cy.findByRole('button', {name: 'Link'}).click();
+ cy.findByPlaceholderText('Attach a web link').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
+
+ //Clicking on the Upload button to upload the file
+ cy.intercept("POST", "/api/method/upload_file").as("upload_image");
+ cy.get('.modal-footer').findByRole("button", {name: "Upload"}).click({delay: 500});
+ cy.wait("@upload_image");
+ cy.findByRole('button', {name: 'Save'}).click();
+
+ //Checking if the URL of the attached image is getting displayed in the field of the newly created doctype
+ cy.get('.attached-file > .ellipsis > .attached-file-link')
+ .should('have.attr', 'href')
+ .and('equal', 'https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
+
+ //Clicking on the "Clear" button
+ cy.get('[data-action="clear_attachment"]').click();
+
+ //Checking if clicking on the clear button clears the field of the doctype form and again displays the attach button
+ cy.get('.control-input > .btn-sm').should('contain', 'Attach');
+
+ //Deleting the doc
+ cy.go_to_list('Test Attach Control');
+ cy.get('.list-row-checkbox').eq(0).click();
+ cy.get('.actions-btn-group > .btn').contains('Actions').click();
+ cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
+ cy.click_modal_primary_button('Yes');
+ });
+
+ it('Checking functionality for "Library" button in the "Attach" fieldtype', () => {
+ //Navigating to the new form for the newly created doctype
+ cy.new_form('Test Attach Control');
+
+ //Clicking on the attach button which is displayed as part of creating a doctype with "Attach" fieldtype
+ cy.findByRole('button', {name: 'Attach'}).click();
+
+ //Clicking on "Library" button to attach a file using the "Library" button
+ cy.findByRole('button', {name: 'Library'}).click();
+ cy.contains('72402.jpg').click();
+
+ //Clicking on the Upload button to upload the file
+ cy.intercept("POST", "/api/method/upload_file").as("upload_image");
+ cy.get('.modal-footer').findByRole("button", {name: "Upload"}).click({delay: 500});
+ cy.wait("@upload_image");
+ cy.findByRole('button', {name: 'Save'}).click();
+
+ //Checking if the URL of the attached image is getting displayed in the field of the newly created doctype
+ cy.get('.attached-file > .ellipsis > .attached-file-link')
+ .should('have.attr', 'href')
+ .and('equal', 'https://wallpaperplay.com/walls/full/8/2/b/72402.jpg');
+
+ //Clicking on the "Clear" button
+ cy.get('[data-action="clear_attachment"]').click();
+
+ //Checking if clicking on the clear button clears the field of the doctype form and again displays the attach button
+ cy.get('.control-input > .btn-sm').should('contain', 'Attach');
+
+ //Deleting the doc
+ cy.go_to_list('Test Attach Control');
+ cy.get('.list-row-checkbox').eq(0).click();
+ cy.get('.actions-btn-group > .btn').contains('Actions').click();
+ cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
+ cy.click_modal_primary_button('Yes');
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/control_date.js b/cypress/integration/control_date.js
new file mode 100644
index 0000000000..35c585306c
--- /dev/null
+++ b/cypress/integration/control_date.js
@@ -0,0 +1,71 @@
+context('Date Control', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/doctype');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
+ name: 'Test Date Control',
+ fields: [
+ {
+ "label": "Date",
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "in_list_view": 1
+ },
+ ]
+ });
+ });
+ });
+ it('Selecting a date from the datepicker', () => {
+ cy.new_form('Test Date Control');
+ cy.get_field('date', 'Date').click();
+ cy.get('.datepicker--nav-title').click();
+ cy.get('.datepicker--nav-title').click({force: true});
+
+
+ //Inputing values in the date field
+ cy.get('.datepicker--years > .datepicker--cells > .datepicker--cell[data-year=2020]').click();
+ cy.get('.datepicker--months > .datepicker--cells > .datepicker--cell[data-month=0]').click();
+ cy.get('.datepicker--days > .datepicker--cells > .datepicker--cell[data-date=15]').click();
+
+ //Verifying if the selected date is displayed in the date field
+ cy.get_field('date', 'Date').should('have.value', '01-15-2020');
+ });
+
+ it('Checking next and previous button', () => {
+ cy.get_field('date', 'Date').click();
+
+ //Clicking on the next button in the datepicker
+ cy.get('.datepicker--nav-action[data-action=next]').click();
+
+ //Selecting a date from the datepicker
+ cy.get('.datepicker--cell[data-date=15]').click({force: true});
+
+ //Verifying if the selected date has been displayed in the date field
+ cy.get_field('date', 'Date').should('have.value', '02-15-2020');
+ cy.wait(500);
+ cy.get_field('date', 'Date').click();
+
+ //Clicking on the previous button in the datepicker
+ cy.get('.datepicker--nav-action[data-action=prev]').click();
+
+ //Selecting a date from the datepicker
+ cy.get('.datepicker--cell[data-date=15]').click({force: true});
+
+ //Verifying if the selected date has been displayed in the date field
+ cy.get_field('date', 'Date').should('have.value', '01-15-2020');
+ });
+
+ it('Clicking on "Today" button gives todays date', () => {
+ cy.get_field('date', 'Date').click();
+
+ //Clicking on "Today" button
+ cy.get('.datepicker--button').click();
+
+ //Picking up the todays date
+ const todays_date = Cypress.moment().format('MM-DD-YYYY');
+
+ //Verifying if clicking on "Today" button matches today's date
+ cy.get_field('date', 'Date').should('have.value', todays_date);
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/control_dynamic_link.js b/cypress/integration/control_dynamic_link.js
new file mode 100644
index 0000000000..cc1eb0b695
--- /dev/null
+++ b/cypress/integration/control_dynamic_link.js
@@ -0,0 +1,128 @@
+context('Dynamic Link', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/doctype');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
+ name: 'Test Dynamic Link',
+ fields: [
+ {
+ "label": "Document Type",
+ "fieldname": "doc_type",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ },
+ {
+ "label": "Document ID",
+ "fieldname": "doc_id",
+ "fieldtype": "Dynamic Link",
+ "options": "doc_type",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ },
+ ]
+ });
+ });
+ });
+
+
+ function get_dialog_with_dynamic_link() {
+ return cy.dialog({
+ title: 'Dynamic Link',
+ fields: [{
+ "label": "Document Type",
+ "fieldname": "doc_type",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "in_list_view": 1,
+ },
+ {
+ "label": "Document ID",
+ "fieldname": "doc_id",
+ "fieldtype": "Dynamic Link",
+ "options": "doc_type",
+ "in_list_view": 1,
+ }]
+ });
+ }
+
+ function get_dialog_with_dynamic_link_option() {
+ return cy.dialog({
+ title: 'Dynamic Link',
+ fields: [{
+ "label": "Document Type",
+ "fieldname": "doc_type",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "in_list_view": 1,
+ },
+ {
+ "label": "Document ID",
+ "fieldname": "doc_id",
+ "fieldtype": "Dynamic Link",
+ "get_options": () => {
+ return "User";
+ },
+ "in_list_view": 1,
+ }]
+ });
+ }
+
+ it('Creating a dynamic link by passing option as function and verifying it in a dialog', () => {
+ get_dialog_with_dynamic_link_option().as('dialog');
+ cy.get_field('doc_type').clear();
+ cy.fill_field('doc_type', 'User', 'Link');
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+ cy.get('.btn-modal-close').click({force: true});
+ });
+
+ it('Creating a dynamic link and verifying it in a dialog', () => {
+ get_dialog_with_dynamic_link().as('dialog');
+ cy.get_field('doc_type').clear();
+ cy.fill_field('doc_type', 'User', 'Link');
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+ cy.get('.btn-modal-close').click({force: true, multiple: true});
+ });
+
+ it('Creating a dynamic link and verifying it', () => {
+ cy.visit('/app/test-dynamic-link');
+
+ //Clicking on the Document ID field
+ cy.get_field('doc_type').clear();
+
+ //Entering User in the Doctype field
+ cy.fill_field('doc_type', 'User', 'Link', {delay: 500});
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+
+ //Opening a new form for dynamic link doctype
+ cy.new_form('Test Dynamic Link');
+ cy.get_field('doc_type').clear();
+
+ //Entering User in the Doctype field
+ cy.fill_field('doc_type', 'User', 'Link', {delay: 500});
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+ cy.get_field('doc_type').clear();
+
+ //Entering System Settings in the Doctype field
+ cy.fill_field('doc_type', 'System Settings', 'Link', {delay: 500});
+ cy.get_field('doc_id').click();
+
+ //Checking if the system throws error
+ cy.get('.modal-title').should('have.text', 'Error');
+ cy.get('.msgprint').should('have.text', 'System Settings is not a valid DocType for Dynamic Link');
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/discussions.js b/cypress/integration/discussions.js
index a6e0ff9b56..caf7d6c3f9 100644
--- a/cypress/integration/discussions.js
+++ b/cypress/integration/discussions.js
@@ -37,24 +37,24 @@ context('Discussions', () => {
};
const reply_through_comment_box = () => {
- cy.get('.discussion-on-page:visible .comment-field')
+ cy.get('.discussion-form:visible .comment-field')
.type('This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.')
.should('have.value', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.');
- cy.get('.discussion-on-page:visible .submit-discussion').click();
+ cy.get('.discussion-form:visible .submit-discussion').click();
cy.wait(3000);
cy.get('.discussion-on-page:visible').should('have.class', 'show');
- cy.get('.discussion-on-page:visible').children(".reply-card").eq(1).children(".reply-text")
+ cy.get('.discussion-on-page:visible').children(".reply-card").eq(1).find(".reply-text")
.should('have.text', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.\n');
};
const cancel_and_clear_comment_box = () => {
- cy.get('.discussion-on-page:visible .comment-field')
+ cy.get('.discussion-form:visible .comment-field')
.type('This is a discussion from the cypress ui tests.')
.should('have.value', 'This is a discussion from the cypress ui tests.');
- cy.get('.discussion-on-page:visible .cancel-comment').click();
- cy.get('.discussion-on-page:visible .comment-field').should('have.value', '');
+ cy.get('.discussion-form:visible .cancel-comment').click();
+ cy.get('.discussion-form:visible .comment-field').should('have.value', '');
};
const single_thread_discussion = () => {
@@ -62,13 +62,13 @@ context('Discussions', () => {
cy.get('.discussions-sidebar').should('have.length', 0);
cy.get('.reply').should('have.length', 0);
- cy.get('.discussion-on-page .comment-field')
+ cy.get('.discussion-form:visible .comment-field')
.type('This comment is being made on a single thread discussion.')
.should('have.value', 'This comment is being made on a single thread discussion.');
- cy.get('.discussion-on-page .submit-discussion').click();
+ cy.get('.discussion-form:visible .submit-discussion').click();
cy.wait(3000);
- cy.get('.discussion-on-page').children(".reply-card").eq(-1).children(".reply-text")
+ cy.get('.discussion-on-page').children(".reply-card").eq(-1).find(".reply-text")
.should('have.text', 'This comment is being made on a single thread discussion.\n');
};
diff --git a/cypress/integration/form.js b/cypress/integration/form.js
index 71cc6f4f0d..acaff9a191 100644
--- a/cypress/integration/form.js
+++ b/cypress/integration/form.js
@@ -29,10 +29,12 @@ context('Form', () => {
cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur();
cy.click_listview_row_item(0);
+ cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist');
cy.get('.prev-doc').should('be.visible').click();
cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
cy.hide_dialog();
+ cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist');
cy.get('.next-doc').should('be.visible').click();
cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
cy.hide_dialog();
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 4f273af21f..37134f0cbc 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -200,16 +200,15 @@ Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, va
Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fieldtype = 'Data') => {
let selector = `.frappe-control[data-fieldname="${tablefieldname}"]`;
selector += ` [data-idx="${row_idx}"]`;
- selector += ` .form-in-grid`;
if (fieldtype === 'Text Editor') {
selector += ` [data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`;
} else if (fieldtype === 'Code') {
selector += ` [data-fieldname="${fieldname}"] .ace_text-input`;
} else {
- selector += ` .form-control[data-fieldname="${fieldname}"]`;
+ selector += ` [data-fieldname="${fieldname}"]`;
+ return cy.get(selector).find('.form-control:visible, .static-area:visible').first();
}
-
return cy.get(selector);
});
@@ -290,6 +289,7 @@ Cypress.Commands.add('add_filter', () => {
});
Cypress.Commands.add('clear_filters', () => {
+ let has_filter = false;
cy.intercept({
method: 'POST',
url: 'api/method/frappe.model.utils.user_settings.save'
@@ -297,12 +297,17 @@ Cypress.Commands.add('clear_filters', () => {
cy.get('.filter-section .filter-button').click({force: true});
cy.wait(300);
cy.get('.filter-popover').should('exist');
+ cy.get('.filter-popover').then(popover => {
+ if (popover.find('input.input-with-feedback')[0].value != '') {
+ has_filter = true;
+ }
+ });
cy.get('.filter-popover').find('.clear-filters').click();
cy.get('.filter-section .filter-button').click();
cy.window().its('cur_list').then(cur_list => {
cur_list && cur_list.filter_area && cur_list.filter_area.clear();
+ has_filter && cy.wait('@filter-saved');
});
- cy.wait('@filter-saved');
});
Cypress.Commands.add('click_modal_primary_button', (btn_name) => {
diff --git a/cypress/support/index.js b/cypress/support/index.js
index 9cd770a31e..5980e96677 100644
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -17,6 +17,9 @@
import './commands';
import '@cypress/code-coverage/support';
+Cypress.on('uncaught:exception', (err, runnable) => {
+ return false;
+});
// Alternatively you can use CommonJS syntax:
// require('./commands')
diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js
index ff31aa4b74..4aa1ebc824 100644
--- a/esbuild/esbuild.js
+++ b/esbuild/esbuild.js
@@ -259,6 +259,7 @@ function get_build_options(files, outdir, plugins) {
return {
entryPoints: files,
entryNames: "[dir]/[name].[hash]",
+ target: ['es2017'],
outdir,
sourcemap: true,
bundle: true,
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 0abaf932a7..f92f76c98c 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
"""
Frappe - Low Code Open Source Framework in Python and JS
@@ -20,10 +20,10 @@ if _dev_server:
warnings.simplefilter('always', DeprecationWarning)
warnings.simplefilter('always', PendingDeprecationWarning)
-from werkzeug.local import Local, release_local
import sys, importlib, inspect, json
-import typing
import click
+from werkzeug.local import Local, release_local
+from typing import TYPE_CHECKING, Dict, List, Union
# Local application imports
from .exceptions import *
@@ -143,15 +143,14 @@ lang = local("lang")
# This if block is never executed when running the code. It is only used for
# telling static code analyzer where to find dynamically defined attributes.
-if typing.TYPE_CHECKING:
- from frappe.utils.redis_wrapper import RedisWrapper
-
+if TYPE_CHECKING:
from frappe.database.mariadb.database import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase
from frappe.query_builder.builder import MariaDB, Postgres
+ from frappe.utils.redis_wrapper import RedisWrapper
- db: typing.Union[MariaDBDatabase, PostgresDatabase]
- qb: typing.Union[MariaDB, Postgres]
+ db: Union[MariaDBDatabase, PostgresDatabase]
+ qb: Union[MariaDB, Postgres]
# end: static analysis hack
@@ -852,18 +851,25 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args, **kwargs):
+ allow_dict = kwargs.pop("_allow_dict", False)
+
+ def _respond(doc, from_redis=False):
+ if not allow_dict and isinstance(doc, dict):
+ local.document_cache[key] = doc = get_doc(doc)
+
+ elif from_redis:
+ local.document_cache[key] = doc
+
+ return doc
+
if key := can_cache_doc(args):
# local cache
- doc = local.document_cache.get(key)
- if doc:
- return doc
+ if doc := local.document_cache.get(key):
+ return _respond(doc)
# redis cache
- doc = cache().hget('document_cache', key)
- if doc:
- doc = get_doc(doc)
- local.document_cache[key] = doc
- return doc
+ if doc := cache().hget('document_cache', key):
+ return _respond(doc, True)
# database
doc = get_doc(*args, **kwargs)
@@ -896,8 +902,13 @@ def clear_document_cache(doctype, name):
del local.document_cache[key]
cache().hdel('document_cache', key)
-def get_cached_value(doctype, name, fieldname, as_dict=False):
- doc = get_cached_doc(doctype, name)
+def get_cached_value(doctype, name, fieldname="name", as_dict=False):
+ try:
+ doc = get_cached_doc(doctype, name, _allow_dict=True)
+ except DoesNotExistError:
+ clear_last_message()
+ return
+
if isinstance(fieldname, str):
if as_dict:
throw('Cannot make dict for single fieldname')
@@ -1523,12 +1534,16 @@ def get_value(*args, **kwargs):
"""
return db.get_value(*args, **kwargs)
-def as_json(obj, indent=1):
+def as_json(obj: Union[Dict, List], indent=1) -> str:
from frappe.utils.response import json_handler
+
try:
return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': '))
except TypeError:
- return json.dumps(obj, indent=indent, default=json_handler, separators=(',', ': '))
+ # this would break in case the keys are not all os "str" type - as defined in the JSON
+ # adding this to ensure keys are sorted (expected behaviour)
+ sorted_obj = dict(sorted(obj.items(), key=lambda kv: str(kv[0])))
+ return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=(',', ': '))
def are_emails_muted():
from frappe.utils import cint
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index e788c7ec4d..6bee442054 100644
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -780,9 +780,8 @@ def set_user_password(site, user, password, logout_all_sessions=False):
@pass_context
def set_last_active_for_user(context, user=None):
"Set users last active date to current datetime"
-
from frappe.core.doctype.user.user import get_system_users
- from frappe.utils.user import set_last_active_to_now
+ from frappe.utils import now_datetime
site = get_site(context)
@@ -795,9 +794,10 @@ def set_last_active_for_user(context, user=None):
else:
return
- set_last_active_to_now(user)
+ frappe.db.set_value("User", user, "last_active", now_datetime())
frappe.db.commit()
+
@click.command('publish-realtime')
@click.argument('event')
@click.option('--message')
diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json
index 175c64b9eb..f9d15af483 100644
--- a/frappe/core/doctype/communication/communication.json
+++ b/frappe/core/doctype/communication/communication.json
@@ -153,7 +153,7 @@
"fieldname": "communication_type",
"fieldtype": "Select",
"label": "Communication Type",
- "options": "Communication\nComment\nChat\nBot\nNotification\nFeedback\nAutomated Message",
+ "options": "Communication\nComment\nChat\nNotification\nFeedback\nAutomated Message",
"read_only": 1,
"reqd": 1
},
@@ -164,7 +164,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Comment Type",
- "options": "\nComment\nLike\nInfo\nLabel\nWorkflow\nCreated\nSubmitted\nCancelled\nUpdated\nDeleted\nAssigned\nAssignment Completed\nAttachment\nAttachment Removed\nShared\nUnshared\nBot\nRelinked",
+ "options": "\nComment\nLike\nInfo\nLabel\nWorkflow\nCreated\nSubmitted\nCancelled\nUpdated\nDeleted\nAssigned\nAssignment Completed\nAttachment\nAttachment Removed\nShared\nUnshared\nRelinked",
"read_only": 1
},
{
@@ -395,7 +395,7 @@
"icon": "fa fa-comment",
"idx": 1,
"links": [],
- "modified": "2021-11-30 09:03:25.728637",
+ "modified": "2022-03-30 11:24:25.728637",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py
index 475762f39d..38fb0fd757 100644
--- a/frappe/core/doctype/communication/communication.py
+++ b/frappe/core/doctype/communication/communication.py
@@ -10,7 +10,6 @@ from frappe.utils import validate_email_address, strip_html, cstr, time_diff_in_
from frappe.core.doctype.communication.email import validate_email
from frappe.core.doctype.communication.mixins import CommunicationEmailMixin
from frappe.core.utils import get_parent_doc
-from frappe.utils.bot import BotReply
from frappe.utils import parse_addr, split_emails
from frappe.core.doctype.comment.comment import update_comment_in_doc
from email.utils import getaddresses
@@ -105,7 +104,7 @@ class Communication(Document, CommunicationEmailMixin):
if self.communication_type == "Communication":
self.notify_change('add')
- elif self.communication_type in ("Chat", "Notification", "Bot"):
+ elif self.communication_type in ("Chat", "Notification"):
if self.reference_name == frappe.session.user:
message = self.as_dict()
message['broadcast'] = True
@@ -160,7 +159,6 @@ class Communication(Document, CommunicationEmailMixin):
if self.comment_type != 'Updated':
update_parent_document_on_communication(self)
- self.bot_reply()
def on_trash(self):
if self.communication_type == "Communication":
@@ -278,20 +276,6 @@ class Communication(Document, CommunicationEmailMixin):
if not self.sender_full_name:
self.sender_full_name = sender_email
- def bot_reply(self):
- if self.comment_type == 'Bot' and self.communication_type == 'Chat':
- reply = BotReply().get_reply(self.content)
- if reply:
- frappe.get_doc({
- "doctype": "Communication",
- "comment_type": "Bot",
- "communication_type": "Bot",
- "content": cstr(reply),
- "reference_doctype": self.reference_doctype,
- "reference_name": self.reference_name
- }).insert()
- frappe.local.flags.commit = True
-
def set_delivery_status(self, commit=False):
'''Look into the status of Email Queue linked to this Communication and set the Delivery Status of this Communication'''
delivery_status = None
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index f89eb31cc8..b10b84f048 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -244,6 +244,7 @@ class Importer:
existing_doc = frappe.get_doc(self.doctype, doc.get(id_field.fieldname))
updated_doc = frappe.get_doc(self.doctype, doc.get(id_field.fieldname))
+
updated_doc.update(doc)
if get_diff(existing_doc, updated_doc):
diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py
index 11077ca58b..489b8caa58 100644
--- a/frappe/core/doctype/data_import/test_importer.py
+++ b/frappe/core/doctype/data_import/test_importer.py
@@ -92,11 +92,18 @@ class TestImporter(unittest.TestCase):
# update child table id in template date
i.import_file.raw_data[1][4] = existing_doc.table_field_1[0].name
- i.import_file.raw_data[1][0] = existing_doc.name
+
+ # uppercase to check if autoname field isn't replaced in mariadb
+ if frappe.db.db_type == "mariadb":
+ i.import_file.raw_data[1][0] = existing_doc.name.upper()
+ else:
+ i.import_file.raw_data[1][0] = existing_doc.name
+
i.import_file.parse_data_from_template()
i.import_data()
updated_doc = frappe.get_doc(doctype_name, existing_doc.name)
+ self.assertEqual(existing_doc.title, updated_doc.title)
self.assertEqual(updated_doc.description, 'test description')
self.assertEqual(updated_doc.table_field_1[0].child_title, 'child title')
self.assertEqual(updated_doc.table_field_1[0].name, existing_doc.table_field_1[0].name)
diff --git a/frappe/core/page/background_jobs/background_jobs.css b/frappe/core/page/background_jobs/background_jobs.css
index 0c77522cb3..7716519113 100644
--- a/frappe/core/page/background_jobs/background_jobs.css
+++ b/frappe/core/page/background_jobs/background_jobs.css
@@ -1,43 +1,32 @@
-.list-jobs {
- font-size: var(--text-base);
-}
-.table {
+.table-background-jobs {
margin-bottom: 0px;
margin-top: 0px;
+ font-size: var(--text-md);
+ table-layout: fixed;
}
-thead {
- background-color: var(--control-bg);
- border-radius: var(--border-radius-sm);
+.table-background-jobs th {
+ font-weight: normal;
+ color: var(--text-muted);
}
-thead > tr {
- border-radius: var(--border-radius-sm);
+.table-background-jobs td {
+ color: var(--text-light);
}
-thead > tr > th:first-child {
- border-radius: var(--border-radius-sm) 0 0 var(--border-radius-sm);
-}
-thead > tr > th:last-child {
- border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0;
+.table-background-jobs th, .table-background-jobs td {
+ padding: var(--padding-sm) var(--padding-md);
}
-.worker-name {
- display: flex;
- align-items: center;
+.table-background-jobs tbody tr:hover {
+ background-color: var(--highlight-color);
}
.job-name {
font-size: var(--text-md);
- font-family: "Courier New", Courier, monospace;
- /* background-color: var(--control-bg); */
- /* padding: var(--padding-xs) var(--padding-sm); */
- /* border-radius: var(--border-radius-md); */
-}
-
-.background-job-row:hover {
- background-color: var(--bg-color);
+ font-family: var(--font-family-monospace);
+ word-break: break-word;
}
.no-background-jobs {
@@ -54,7 +43,5 @@ thead > tr > th:last-child {
}
.footer {
- align-items: flex-end;
- margin-top: var(--margin-md);
- font-size: var(--text-base);
+ padding: var(--padding-md);
}
diff --git a/frappe/core/page/background_jobs/background_jobs.html b/frappe/core/page/background_jobs/background_jobs.html
index 1b00ec3106..e0c1a8f633 100644
--- a/frappe/core/page/background_jobs/background_jobs.html
+++ b/frappe/core/page/background_jobs/background_jobs.html
@@ -1,51 +1,58 @@
-
- {% if jobs.length %}
-
-
-
- | {{ __("Queue / Worker") }} |
- {{ __("Job") }} |
- {{ __("Created") }} |
-
-
-
- {% for j in jobs %}
-
- |
-
- {{ j.queue.split(".").slice(-1)[0] }}
- |
-
-
-
- {{ frappe.utils.encode_tags(j.job_name) }}
-
-
- {% if j.exc_info %}
+{% if jobs.length %}
+
+
+
+ | {{ __("Queue") }} |
+ {{ __("Job") }} |
+ {{ __("Status") }} |
+ {{ __("Created") }} |
+
+
+
+ {% for j in jobs %}
+
+ |
+ {{ toTitle(j.queue.split(":").slice(-1)[0]) }}
+ |
+
+
+
+ {{ frappe.utils.encode_tags(j.job_name) }}
+
+
+ {% if j.exc_info %}
+
+ {{ __("Exception") }}
{{ frappe.utils.encode_tags(j.exc_info) }}
- {% endif %}
- |
- {{ j.creation }} |
-
- {% endfor %}
-
-
- {% else %}
-
- 
- {{ __("No pending or current jobs for this site") }}
+
+ {% endif %}
+ |
+
+
+ {{ toTitle(j.status) }}
+
+ |
+
+ {{ frappe.datetime.prettyDate(j.creation) }}
+ |
+
+ {% endfor %}
+
+
+{% else %}
+
+

+
{{ __("No jobs found on this site") }}
+
+{% endif %}
+
\ No newline at end of file
+
diff --git a/frappe/core/page/background_jobs/background_jobs.js b/frappe/core/page/background_jobs/background_jobs.js
index 0b4d6792dc..7334bfd5dd 100644
--- a/frappe/core/page/background_jobs/background_jobs.js
+++ b/frappe/core/page/background_jobs/background_jobs.js
@@ -1,7 +1,7 @@
-frappe.pages["background_jobs"].on_page_load = (wrapper) => {
+frappe.pages["background_jobs"].on_page_load = wrapper => {
const background_job = new BackgroundJobs(wrapper);
- $(wrapper).bind('show', () => {
+ $(wrapper).bind("show", () => {
background_job.show();
});
@@ -12,61 +12,135 @@ class BackgroundJobs {
constructor(wrapper) {
this.page = frappe.ui.make_app_page({
parent: wrapper,
- title: __('Background Jobs'),
+ title: __("Background Jobs"),
single_column: true
});
- this.called = false;
- this.show_failed = false;
+ this.page.add_inner_button(__("Remove Failed Jobs"), () => {
+ frappe.confirm(
+ __("Are you sure you want to remove all failed jobs?"),
+ () => {
+ frappe
+ .call(
+ "frappe.core.page.background_jobs.background_jobs.remove_failed_jobs"
+ )
+ .then(() => this.refresh_jobs());
+ }
+ );
+ });
- this.show_failed_button = this.page.add_inner_button(__("Show Failed Jobs"), () => {
- this.show_failed = !this.show_failed;
- if (this.show_failed_button) {
- this.show_failed_button.text(
- this.show_failed ? __("Hide Failed Jobs") : __("Show Failed Jobs")
- );
+ this.page.main.addClass("frappe-card");
+ this.page.body.append('');
+ this.$content = $(this.page.body).find(".table-area");
+
+ this.make_filters();
+ this.refresh_jobs = frappe.utils.throttle(
+ this.refresh_jobs.bind(this),
+ 1000
+ );
+ }
+
+ make_filters() {
+ this.view = this.page.add_field({
+ label: __("View"),
+ fieldname: "view",
+ fieldtype: "Select",
+ options: ["Jobs", "Workers"],
+ default: "Jobs",
+ change: () => {
+ this.queue_timeout.toggle(this.view.get_value() === "Jobs");
+ this.job_status.toggle(this.view.get_value() === "Jobs");
}
});
-
- // add a "Remove Failed Jobs button"
- this.remove_failed_button = this.page.add_inner_button(__("Remove Failed Jobs"), () => {
- frappe.call({
- method: 'frappe.core.page.background_jobs.background_jobs.remove_failed_jobs',
- callback: () => {
+ this.queue_timeout = this.page.add_field({
+ label: __("Queue"),
+ fieldname: "queue_timeout",
+ fieldtype: "Select",
+ options: [
+ { label: "All Queues", value: "all" },
+ { label: "Default", value: "default" },
+ { label: "Short", value: "short" },
+ { label: "Long", value: "long" }
+ ],
+ default: "all"
+ });
+ this.job_status = this.page.add_field({
+ label: __("Job Status"),
+ fieldname: "job_status",
+ fieldtype: "Select",
+ options: [
+ { label: "All Jobs", value: "all" },
+ { label: "Queued", value: "queued" },
+ { label: "Deferred", value: "deferred" },
+ { label: "Started", value: "started" },
+ { label: "Finished", value: "finished" },
+ { label: "Failed", value: "failed" }
+ ],
+ default: "all"
+ });
+ this.auto_refresh = this.page.add_field({
+ label: __("Auto Refresh"),
+ fieldname: "auto_refresh",
+ fieldtype: "Check",
+ default: 1,
+ change: () => {
+ if (this.auto_refresh.get_value()) {
this.refresh_jobs();
}
- });
+ }
});
-
- $(frappe.render_template('background_jobs_outer')).appendTo(this.page.body);
- this.content = $(this.page.body).find('.table-area');
}
show() {
this.refresh_jobs();
+ this.update_scheduler_status();
+ }
+
+ update_scheduler_status() {
frappe.call({
- method: 'frappe.core.page.background_jobs.background_jobs.get_scheduler_status',
- callback: res => {
- this.page.set_indicator(...res.message);
+ method:
+ "frappe.core.page.background_jobs.background_jobs.get_scheduler_status",
+ callback: r => {
+ let { status } = r.message;
+ if (status === "active") {
+ this.page.set_indicator(__("Scheduler: Active"), "green");
+ } else {
+ this.page.set_indicator(__("Scheduler: Inactive"), "red");
+ }
}
});
}
refresh_jobs() {
- if (this.called) return;
- this.called = true;
+ let view = this.view.get_value();
+ let args;
+ let { queue_timeout, job_status } = this.page.get_form_values();
+ if (view === "Jobs") {
+ args = { view, queue_timeout, job_status };
+ } else {
+ args = { view };
+ }
+ this.page.add_inner_message(__("Refreshing..."));
frappe.call({
- method: 'frappe.core.page.background_jobs.background_jobs.get_info',
- args: {
- show_failed: this.show_failed
- },
- callback: (res) => {
- this.called = false;
- this.page.body.find('.list-jobs').remove();
- $(frappe.render_template('background_jobs', { jobs: res.message || [] })).appendTo(this.content);
+ method: "frappe.core.page.background_jobs.background_jobs.get_info",
+ args,
+ callback: res => {
+ this.page.add_inner_message("");
- if (frappe.get_route()[0] === 'background_jobs') {
+ let template =
+ view === "Jobs" ? "background_jobs" : "background_workers";
+ this.$content.html(
+ frappe.render_template(template, {
+ jobs: res.message || []
+ })
+ );
+
+ let auto_refresh = this.auto_refresh.get_value();
+ if (
+ frappe.get_route()[0] === "background_jobs" &&
+ auto_refresh
+ ) {
setTimeout(() => this.refresh_jobs(), 2000);
}
}
diff --git a/frappe/core/page/background_jobs/background_jobs.py b/frappe/core/page/background_jobs/background_jobs.py
index 4d9deca526..960444c349 100644
--- a/frappe/core/page/background_jobs/background_jobs.py
+++ b/frappe/core/page/background_jobs/background_jobs.py
@@ -8,8 +8,8 @@ from rq import Worker
import frappe
from frappe import _
-from frappe.utils import convert_utc_to_user_timezone, format_datetime
-from frappe.utils.background_jobs import get_redis_conn, get_queues
+from frappe.utils import convert_utc_to_user_timezone
+from frappe.utils.background_jobs import get_queues, get_workers
from frappe.utils.scheduler import is_scheduler_inactive
if TYPE_CHECKING:
@@ -24,16 +24,15 @@ JOB_COLORS = {
@frappe.whitelist()
-def get_info(show_failed=False) -> List[Dict]:
- if isinstance(show_failed, str):
- show_failed = json.loads(show_failed)
-
- conn = get_redis_conn()
- queues = get_queues()
- workers = Worker.all(conn)
+def get_info(view=None, queue_timeout=None, job_status=None) -> List[Dict]:
jobs = []
def add_job(job: 'Job', name: str) -> None:
+ if job_status != "all" and job.get_status() != job_status:
+ return
+ if queue_timeout != "all" and not name.endswith(f':{queue_timeout}'):
+ return
+
if job.kwargs.get('site') == frappe.local.site:
job_info = {
'job_name': job.kwargs.get('kwargs', {}).get('playbook_method')
@@ -41,7 +40,7 @@ def get_info(show_failed=False) -> List[Dict]:
or str(job.kwargs.get('job_name')),
'status': job.get_status(),
'queue': name,
- 'creation': format_datetime(convert_utc_to_user_timezone(job.created_at)),
+ 'creation': convert_utc_to_user_timezone(job.created_at),
'color': JOB_COLORS[job.get_status()]
}
@@ -50,32 +49,31 @@ def get_info(show_failed=False) -> List[Dict]:
jobs.append(job_info)
- # show worker jobs
- for worker in workers:
- job = worker.get_current_job()
- if job:
- add_job(job, worker.name)
-
- for queue in queues:
- # show active queued jobs
- if queue.name != 'failed':
+ if view == 'Jobs':
+ queues = get_queues()
+ for queue in queues:
for job in queue.jobs:
add_job(job, queue.name)
- # show failed jobs, if requested
- if show_failed:
- fail_registry = queue.failed_job_registry
- for job_id in fail_registry.get_job_ids():
- job = queue.fetch_job(job_id)
- if job:
- add_job(job, queue.name)
+ elif view == 'Workers':
+ workers = get_workers()
+ for worker in workers:
+ current_job = worker.get_current_job()
+ if current_job and current_job.kwargs.get('site') == frappe.local.site:
+ add_job(current_job, job.origin)
+ else:
+ jobs.append({
+ 'queue': worker.name,
+ 'job_name': 'idle',
+ 'status': '',
+ 'creation': ''
+ })
return jobs
@frappe.whitelist()
def remove_failed_jobs():
- conn = get_redis_conn()
queues = get_queues()
for queue in queues:
fail_registry = queue.failed_job_registry
@@ -87,5 +85,5 @@ def remove_failed_jobs():
@frappe.whitelist()
def get_scheduler_status():
if is_scheduler_inactive():
- return [_("Inactive"), "red"]
- return [_("Active"), "green"]
+ return {'status': 'inactive'}
+ return {'status': 'active'}
diff --git a/frappe/core/page/background_jobs/background_jobs_outer.html b/frappe/core/page/background_jobs/background_jobs_outer.html
deleted file mode 100644
index 4ca3a32906..0000000000
--- a/frappe/core/page/background_jobs/background_jobs_outer.html
+++ /dev/null
@@ -1,5 +0,0 @@
-
\ No newline at end of file
diff --git a/frappe/core/page/background_jobs/background_workers.html b/frappe/core/page/background_jobs/background_workers.html
new file mode 100644
index 0000000000..1647cea4b4
--- /dev/null
+++ b/frappe/core/page/background_jobs/background_workers.html
@@ -0,0 +1,51 @@
+{% if jobs.length %}
+
+
+
+ | {{ __("Worker") }} |
+ {{ __("Current Job") }} |
+ {{ __("Status") }} |
+ {{ __("Created") }} |
+
+
+
+ {% for j in jobs %}
+
+ |
+ {{ j.queue }}
+ |
+
+
+
+ {{ frappe.utils.encode_tags(j.job_name) }}
+
+
+ {% if j.exc_info %}
+
+ {{ __("Exception") }}
+
+ {{ frappe.utils.encode_tags(j.exc_info) }}
+
+
+ {% endif %}
+ |
+
+ {{ toTitle(j.status) }}
+ |
+ {{ frappe.datetime.prettyDate(j.creation) }} |
+
+ {% endfor %}
+
+
+{% else %}
+
+

+
{{ __("No workers online on this site") }}
+
+{% endif %}
+
\ No newline at end of file
diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py
index af10c6d76a..fdd7bf3134 100644
--- a/frappe/custom/doctype/custom_field/custom_field.py
+++ b/frappe/custom/doctype/custom_field/custom_field.py
@@ -33,29 +33,40 @@ class CustomField(Document):
def before_insert(self):
self.set_fieldname()
- meta = frappe.get_meta(self.dt, cached=False)
- fieldnames = [df.fieldname for df in meta.get("fields")]
-
- if self.fieldname in fieldnames:
- frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt))
def validate(self):
+ # these imports have been added to avoid cyclical import, should fix in future
from frappe.custom.doctype.customize_form.customize_form import CustomizeForm
+ from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts
- meta = frappe.get_meta(self.dt, cached=False)
- fieldnames = [df.fieldname for df in meta.get("fields")]
+ # don't always get meta to improve performance
+ # setting idx is just an improvement, not a requirement
+ if self.is_new() or self.insert_after == "append":
+ meta = frappe.get_meta(self.dt, cached=False)
+ fieldnames = [df.fieldname for df in meta.get("fields")]
- if self.insert_after=='append':
- self.insert_after = fieldnames[-1]
+ if self.is_new() and self.fieldname in fieldnames:
+ frappe.throw(
+ _("A field with the name {0} already exists in {1}")
+ .format(frappe.bold(self.fieldname), self.dt)
+ )
- if self.insert_after and self.insert_after in fieldnames:
- self.idx = fieldnames.index(self.insert_after) + 1
+ if self.insert_after == "append":
+ self.insert_after = fieldnames[-1]
- old_fieldtype = self.db_get('fieldtype')
- is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype)
+ if self.insert_after and self.insert_after in fieldnames:
+ self.idx = fieldnames.index(self.insert_after) + 1
- if not self.is_virtual and is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype):
- frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype))
+ if (
+ not self.is_virtual
+ and (doc_before_save := self.get_doc_before_save())
+ and (old_fieldtype := doc_before_save.fieldtype) != self.fieldtype
+ and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype)
+ ):
+ frappe.throw(
+ _("Fieldtype cannot be changed from {0} to {1}")
+ .format(old_fieldtype, self.fieldtype)
+ )
if not self.fieldname:
frappe.throw(_("Fieldname not set for Custom Field"))
@@ -63,13 +74,12 @@ class CustomField(Document):
if self.get('translatable', 0) and not supports_translation(self.fieldtype):
self.translatable = 0
- if not self.flags.ignore_validate:
- from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts
- check_fieldname_conflicts(self)
+ check_fieldname_conflicts(self)
def on_update(self):
if not frappe.flags.in_setup_wizard:
frappe.clear_cache(doctype=self.dt)
+
if not self.flags.ignore_validate:
# validate field
from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype
diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js
index 7c2a7a2aff..4c2d207df9 100644
--- a/frappe/custom/doctype/customize_form/customize_form.js
+++ b/frappe/custom/doctype/customize_form/customize_form.js
@@ -49,6 +49,14 @@ frappe.ui.form.on("Customize Form", {
if (grid_row.doc && grid_row.doc.fieldtype == "Section Break") {
$(grid_row.row).css({ "font-weight": "bold" });
}
+
+ grid_row.row.removeClass("highlight");
+
+ if (grid_row.doc.is_custom_field &&
+ !grid_row.row.hasClass('highlight') &&
+ !grid_row.doc.is_system_generated) {
+ grid_row.row.addClass("highlight");
+ }
});
$(frm.wrapper).on("grid-make-sortable", function(e, frm) {
@@ -84,17 +92,11 @@ frappe.ui.form.on("Customize Form", {
},
setup_sortable: function(frm) {
- frm.page.body.find(".highlight").removeClass("highlight");
frm.doc.fields.forEach(function(f, i) {
- var data_row = frm.page.body.find(
- '[data-fieldname="fields"] [data-idx="' + f.idx + '"] .data-row'
- );
-
- if (f.is_custom_field) {
- data_row.addClass("highlight");
- } else {
+ if (!f.is_custom_field) {
f._sortable = false;
}
+
if (f.fieldtype == "Table") {
frm.add_custom_button(
f.options,
diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py
index b6bd7ba787..5ec5cae121 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -67,7 +67,12 @@ class CustomizeForm(Document):
self.set(prop, meta.get(prop))
for d in meta.get("fields"):
- new_d = {"fieldname": d.fieldname, "is_custom_field": d.get("is_custom_field"), "name": d.name}
+ new_d = {
+ "fieldname": d.fieldname,
+ "is_custom_field": d.get("is_custom_field"),
+ "is_system_generated": d.get("is_system_generated"),
+ "name": d.name
+ }
for prop in docfield_properties:
new_d[prop] = d.get(prop)
self.append("fields", new_d)
diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json
index 1cc4c9f623..cc446e321e 100644
--- a/frappe/custom/doctype/customize_form_field/customize_form_field.json
+++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json
@@ -7,6 +7,7 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "is_system_generated",
"label_and_type",
"label",
"fieldtype",
@@ -444,13 +445,21 @@
"fieldname": "no_copy",
"fieldtype": "Check",
"label": "No Copy"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_system_generated",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Is System Generated",
+ "read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-25 16:01:12.616736",
+ "modified": "2022-03-31 12:05:11.799654",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form Field",
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 24dfdd32df..7551c5f628 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -115,12 +115,13 @@ class Database(object):
{"name": "a%", "owner":"test@example.com"})
"""
+ debug = debug or getattr(self, "debug", False)
query = str(query)
if not run:
return query
- # remove \n \t from start and end of query
- query = re.sub(r'^\s*|\s*$', '', query)
+ # remove whitespace / indentation from start and end of query
+ query = query.strip()
if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
@@ -357,6 +358,7 @@ class Database(object):
order_by="KEEP_DEFAULT_ORDERING",
cache=False,
for_update=False,
+ *,
run=True,
pluck=False,
distinct=False,
@@ -386,17 +388,27 @@ class Database(object):
frappe.db.get_value("System Settings", None, "date_format")
"""
- ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
+ result = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct, limit=1)
if not run:
- return ret
+ return result
+
+ if not result:
+ return None
+
+ row = result[0]
+
+ if len(row) > 1 or as_dict:
+ return row
+ else:
+ # single field is requested, send it without wrapping in containers
+ return row[0]
- return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None
def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False,
- run=True, pluck=False, distinct=False, limit=None):
+ *, run=True, pluck=False, distinct=False, limit=None):
"""Returns multiple document properties.
:param doctype: DocType name.
@@ -435,6 +447,7 @@ class Database(object):
pluck=pluck,
distinct=distinct,
limit=limit,
+ as_dict=as_dict,
)
else:
@@ -487,6 +500,7 @@ class Database(object):
as_dict=False,
debug=False,
update=None,
+ *,
run=True,
pluck=False,
distinct=False,
@@ -537,7 +551,7 @@ class Database(object):
return r and [[i[1] for i in r]] or []
- def get_singles_dict(self, doctype, debug = False):
+ def get_singles_dict(self, doctype, debug=False, *, for_update=False):
"""Get Single DocType as dict.
:param doctype: DocType of the single object whose value is requested
@@ -548,10 +562,13 @@ class Database(object):
account_settings = frappe.db.get_singles_dict("Accounts Settings")
"""
result = self.query.get_sql(
- "Singles", filters={"doctype": doctype}, fields=["field", "value"]
+ "Singles",
+ filters={"doctype": doctype},
+ fields=["field", "value"],
+ for_update=for_update,
).run()
- dict_ = frappe._dict(result)
- return dict_
+
+ return frappe._dict(result)
@staticmethod
def get_all(*args, **kwargs):
@@ -621,7 +638,8 @@ class Database(object):
filters,
doctype,
as_dict,
- debug,
+ *,
+ debug=False,
order_by=None,
update=None,
for_update=False,
@@ -661,7 +679,20 @@ class Database(object):
)
return r
- def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False, limit=None):
+ def _get_value_for_many_names(
+ self,
+ doctype,
+ names,
+ field,
+ order_by,
+ *,
+ debug=False,
+ run=True,
+ pluck=False,
+ distinct=False,
+ limit=None,
+ as_dict=False
+ ):
names = list(filter(None, names))
if names:
return self.get_all(
@@ -671,7 +702,7 @@ class Database(object):
order_by=order_by,
pluck=pluck,
debug=debug,
- as_list=1,
+ as_list=not as_dict,
run=run,
distinct=distinct,
limit_page_length=limit
diff --git a/frappe/database/query.py b/frappe/database/query.py
index 15ab85ff56..641b584932 100644
--- a/frappe/database/query.py
+++ b/frappe/database/query.py
@@ -115,21 +115,23 @@ def change_orderby(order: str):
OPERATOR_MAP = {
- "+": operator.add,
- "=": operator.eq,
- "-": operator.sub,
- "!=": operator.ne,
- "<": operator.lt,
- ">": operator.gt,
- "<=": operator.le,
- ">=": operator.ge,
- "in": func_in,
- "not in": func_not_in,
- "like": like,
- "not like": not_like,
- "regex": func_regex,
- "between": func_between
- }
+ "+": operator.add,
+ "=": operator.eq,
+ "-": operator.sub,
+ "!=": operator.ne,
+ "<": operator.lt,
+ ">": operator.gt,
+ "<=": operator.le,
+ "=<": operator.le,
+ ">=": operator.ge,
+ "=>": operator.ge,
+ "in": func_in,
+ "not in": func_not_in,
+ "like": like,
+ "not like": not_like,
+ "regex": func_regex,
+ "between": func_between,
+}
class Query:
@@ -211,8 +213,7 @@ class Query:
_operator = OPERATOR_MAP[f[1]]
conditions = conditions.where(_operator(Field(f[0]), f[2]))
- conditions = self.add_conditions(conditions, **kwargs)
- return conditions
+ return self.add_conditions(conditions, **kwargs)
def dict_query(self, table: str, filters: Dict[str, Union[str, int]] = None, **kwargs) -> frappe.qb:
"""Build conditions using the given dictionary filters
@@ -251,8 +252,7 @@ class Query:
field = getattr(_table, key)
conditions = conditions.where(field.isnull())
- conditions = self.add_conditions(conditions, **kwargs)
- return conditions
+ return self.add_conditions(conditions, **kwargs)
def build_conditions(
self,
diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json
index 657e9df89d..c92b2005ed 100644
--- a/frappe/desk/doctype/system_console/system_console.json
+++ b/frappe/desk/doctype/system_console/system_console.json
@@ -1,7 +1,7 @@
{
"actions": [
{
- "action": "#List/Console Log/List",
+ "action": "app/console-log",
"action_type": "Route",
"label": "Logs"
},
@@ -86,7 +86,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-09-15 17:17:44.844767",
+ "modified": "2022-04-09 16:35:32.345542",
"modified_by": "Administrator",
"module": "Desk",
"name": "System Console",
@@ -104,5 +104,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py
index dd8623cae2..9003158a85 100644
--- a/frappe/email/doctype/email_queue/email_queue.py
+++ b/frappe/email/doctype/email_queue/email_queue.py
@@ -220,9 +220,9 @@ class SendMailContext:
def message_placeholder(self, placeholder_key):
map = {
- 'tracker': '',
- 'unsubscribe_url': '',
- 'cc': '',
+ 'tracker': '',
+ 'unsubscribe_url': '',
+ 'cc': '',
'recipient': '',
}
return map.get(placeholder_key)
diff --git a/frappe/email/queue.py b/frappe/email/queue.py
index f6f52e79e2..07a9c6552d 100755
--- a/frappe/email/queue.py
+++ b/frappe/email/queue.py
@@ -66,25 +66,25 @@ def get_emails_sent_today(email_account=None):
def get_unsubscribe_message(unsubscribe_message, expose_recipients):
if unsubscribe_message:
- unsubscribe_html = '''{0}'''.format(unsubscribe_message)
else:
- unsubscribe_link = '''{0}'''.format(_('Unsubscribe'))
unsubscribe_html = _("{0} to stop receiving emails of this type").format(unsubscribe_link)
html = """""".format(unsubscribe_html)
if expose_recipients == "footer":
- text = "\n"
+ text = "\n"
else:
text = ""
- text += "\n\n{unsubscribe_message}: \n".format(unsubscribe_message=unsubscribe_message)
+ text += "\n\n{unsubscribe_message}: \n".format(unsubscribe_message=unsubscribe_message)
return frappe._dict({
"html": html,
diff --git a/frappe/hooks.py b/frappe/hooks.py
index 78f4a2d801..b545c6a719 100644
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -282,14 +282,6 @@ sounds = [
# {"name": "chime", "src": "/assets/frappe/sounds/chime.mp3"},
]
-bot_parsers = [
- 'frappe.utils.bot.ShowNotificationBot',
- 'frappe.utils.bot.GetOpenListBot',
- 'frappe.utils.bot.ListBot',
- 'frappe.utils.bot.FindBot',
- 'frappe.utils.bot.CountBot'
-]
-
setup_wizard_exception = [
"frappe.desk.page.setup_wizard.setup_wizard.email_setup_wizard_exception",
"frappe.desk.page.setup_wizard.setup_wizard.log_setup_wizard_exception"
diff --git a/frappe/installer.py b/frappe/installer.py
index e28a942f01..c7dacc4ac1 100644
--- a/frappe/installer.py
+++ b/frappe/installer.py
@@ -142,8 +142,10 @@ def find_org(org_repo: str) -> Tuple[str, str]:
import requests
for org in ["frappe", "erpnext"]:
- res = requests.head(f"https://api.github.com/repos/{org}/{org_repo}")
- if res.ok:
+ response = requests.head(f"https://api.github.com/repos/{org}/{org_repo}")
+ if response.status_code == 400:
+ response = requests.head(f"https://github.com/{org}/{org_repo}")
+ if response.ok:
return org, org_repo
raise InvalidRemoteException
@@ -220,7 +222,7 @@ def install_app(name, verbose=False, set_as_patched=True):
# install pre-requisites
if app_hooks.required_apps:
for app in app_hooks.required_apps:
- name = parse_app_name(name)
+ name = parse_app_name(app)
install_app(name, verbose=verbose)
frappe.flags.in_install = name
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 8fd64689fc..57591d01d5 100644
--- a/frappe/model/base_document.py
+++ b/frappe/model/base_document.py
@@ -132,32 +132,30 @@ class BaseDocument(object):
def get_db_value(self, key):
return frappe.db.get_value(self.doctype, self.name, key)
- def get(self, key=None, filters=None, limit=None, default=None):
- if key:
- if isinstance(key, dict):
- return _filter(self.get_all_children(), key, limit=limit)
- if filters:
- if isinstance(filters, dict):
- value = _filter(self.__dict__.get(key, []), filters, limit=limit)
- else:
- default = filters
- filters = None
- value = self.__dict__.get(key, default)
+ def get(self, key, filters=None, limit=None, default=None):
+ if isinstance(key, dict):
+ return _filter(self.get_all_children(), key, limit=limit)
+
+ if filters:
+ if isinstance(filters, dict):
+ value = _filter(self.__dict__.get(key, []), filters, limit=limit)
else:
+ default = filters
+ filters = None
value = self.__dict__.get(key, default)
-
- if value is None and key in (
- d.fieldname for d in self.meta.get_table_fields()
- ):
- value = []
- self.set(key, value)
-
- if limit and isinstance(value, (list, tuple)) and len(value) > limit:
- value = value[:limit]
-
- return value
else:
- return self.__dict__
+ value = self.__dict__.get(key, default)
+
+ if value is None and key in (
+ d.fieldname for d in self.meta.get_table_fields()
+ ):
+ value = []
+ self.set(key, value)
+
+ if limit and isinstance(value, (list, tuple)) and len(value) > limit:
+ value = value[:limit]
+
+ return value
def getone(self, key, filters=None):
return self.get(key, filters=filters, limit=1)[0]
@@ -817,6 +815,13 @@ class BaseDocument(object):
elif language == "PythonExpression":
frappe.utils.validate_python_code(code_string, fieldname=field.label)
+ def _sync_autoname_field(self):
+ """Keep autoname field in sync with `name`"""
+ autoname = self.meta.autoname or ""
+ _empty, _field_specifier, fieldname = autoname.partition("field:")
+
+ if fieldname and self.name and self.name != self.get("fieldname"):
+ self.set(fieldname, self.name)
def throw_length_exceeded_error(self, df, max_length, value):
# check if parentfield exists (only applicable for child table doctype)
diff --git a/frappe/model/document.py b/frappe/model/document.py
index 3848fa8029..15e9c28d83 100644
--- a/frappe/model/document.py
+++ b/frappe/model/document.py
@@ -88,35 +88,27 @@ class Document(BaseDocument):
If DocType name and document name are passed, the object will load
all values (including child documents) from the database.
"""
- self.doctype = self.name = None
- self._default_new_docs = {}
+ self.doctype = None
+ self.name = None
self.flags = frappe._dict()
- if args and args[0] and isinstance(args[0], str):
- # first arugment is doctype
- if len(args)==1:
- # single
- self.doctype = self.name = args[0]
- else:
+ if args and args[0]:
+ if isinstance(args[0], str):
+ # first arugment is doctype
self.doctype = args[0]
- if isinstance(args[1], dict):
- # filter
- self.name = frappe.db.get_value(args[0], args[1], "name")
- if self.name is None:
- frappe.throw(_("{0} {1} not found").format(_(args[0]), args[1]),
- frappe.DoesNotExistError)
- else:
- self.name = args[1]
- if 'for_update' in kwargs:
- self.flags.for_update = kwargs.get('for_update')
+ # doctype for singles, string value or filters for other documents
+ self.name = self.doctype if len(args) == 1 else args[1]
- self.load_from_db()
- return
+ # for_update is set in flags to avoid changing load_from_db signature
+ # since it is used in virtual doctypes and inherited in child classes
+ self.flags.for_update = kwargs.get("for_update")
+ self.load_from_db()
+ return
- if args and args[0] and isinstance(args[0], dict):
- # first argument is a dict
- kwargs = args[0]
+ if isinstance(args[0], dict):
+ # first argument is a dict
+ kwargs = args[0]
if kwargs:
# init base document
@@ -133,17 +125,15 @@ class Document(BaseDocument):
frappe.whitelist()(fn)
return fn
- def reload(self):
- """Reload document from database"""
- self.load_from_db()
-
def load_from_db(self):
"""Load document and children from database and create properties
from fields"""
if not getattr(self, "_metaclass", False) and self.meta.issingle:
- single_doc = frappe.db.get_singles_dict(self.doctype)
+ single_doc = frappe.db.get_singles_dict(
+ self.doctype, for_update=self.flags.for_update
+ )
if not single_doc:
- single_doc = frappe.new_doc(self.doctype).as_dict()
+ single_doc = frappe.new_doc(self.doctype, as_dict=True)
single_doc["name"] = self.doctype
del single_doc["__islocal"]
@@ -177,6 +167,8 @@ class Document(BaseDocument):
if hasattr(self, "__setup__"):
self.__setup__()
+ reload = load_from_db
+
def get_latest(self):
if not getattr(self, "latest", None):
self.latest = frappe.get_doc(self.doctype, self.name)
@@ -500,6 +492,7 @@ class Document(BaseDocument):
self._validate_non_negative()
self._validate_length()
self._validate_code_fields()
+ self._sync_autoname_field()
self._extract_images_from_text_editor()
self._sanitize_content()
self._save_passwords()
@@ -848,16 +841,19 @@ class Document(BaseDocument):
frappe.CancelledLinkError)
def get_all_children(self, parenttype=None):
- """Returns all children documents from **Table** type field in a list."""
- ret = []
- for df in self.meta.get("fields", {"fieldtype": ['in', table_fields]}):
- if parenttype:
- if df.options==parenttype:
- return self.get(df.fieldname)
+ """Returns all children documents from **Table** type fields in a list."""
+
+ children = []
+
+ for df in self.meta.get_table_fields():
+ if parenttype and df.options != parenttype:
+ continue
+
value = self.get(df.fieldname)
if isinstance(value, list):
- ret.extend(value)
- return ret
+ children.extend(value)
+
+ return children
def run_method(self, method, *args, **kwargs):
"""run standard triggers, plus those in hooks"""
@@ -1375,11 +1371,9 @@ class Document(BaseDocument):
doctype = self.__class__.__name__
docstatus = f" docstatus={self.docstatus}" if self.docstatus else ""
- repr_str = f"<{doctype}: {name}{docstatus}"
+ parent = f" parent={self.parent}" if getattr(self, "parent", None) else ""
- if not hasattr(self, "parent"):
- return repr_str + ">"
- return f"{repr_str} parent={self.parent}>"
+ return f"<{doctype}: {name}{docstatus}{parent}>"
def __str__(self):
name = self.name or "unsaved"
diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py
index 4768faff48..0383327b68 100644
--- a/frappe/modules/utils.py
+++ b/frappe/modules/utils.py
@@ -45,7 +45,7 @@ def export_customizations(module, doctype, sync_on_migrate=0, with_permissions=0
if not frappe.get_conf().developer_mode:
raise Exception('Not developer mode')
- custom = {'custom_fields': [], 'property_setters': [], 'custom_perms': [],
+ custom = {'custom_fields': [], 'property_setters': [], 'custom_perms': [],'links':[],
'doctype': doctype, 'sync_on_migrate': sync_on_migrate}
def add(_doctype):
@@ -53,6 +53,8 @@ def export_customizations(module, doctype, sync_on_migrate=0, with_permissions=0
fields='*', filters={'dt': _doctype})
custom['property_setters'] += frappe.get_all('Property Setter',
fields='*', filters={'doc_type': _doctype})
+ custom['links'] += frappe.get_all('DocType Link',
+ fields='*', filters={'parent': _doctype})
add(doctype)
diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js
index 5d04fbe982..122aea9fa1 100644
--- a/frappe/printing/page/print/print.js
+++ b/frappe/printing/page/print/print.js
@@ -37,7 +37,7 @@ frappe.ui.form.PrintView = class {
this.print_wrapper = this.page.main.empty().html(
`
${frappe.render_template('print_skeleton_loading')}
-
@@ -364,7 +364,11 @@ frappe.ui.form.PrintView = class {
let doc_letterhead = this.frm.doc.letter_head;
return frappe.db
- .get_list('Letter Head', { fields: ['name', 'is_default'], limit: 0 })
+ .get_list('Letter Head', {
+ filters: {'disabled': 0},
+ fields: ['name', 'is_default'],
+ limit: 0
+ })
.then((letterheads) => {
letterheads.map((letterhead) => {
if (letterhead.is_default) default_letterhead = letterhead.name;
diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js
index 4ee52d16b8..e22235f60f 100644
--- a/frappe/public/js/frappe/form/controls/base_control.js
+++ b/frappe/public/js/frappe/form/controls/base_control.js
@@ -44,6 +44,8 @@ frappe.ui.form.Control = class BaseControl {
}
if ((!this.doctype && !this.docname) || this.df.parenttype === 'Web Form' || this.df.is_web_form) {
+ let status = "Write";
+
// like in case of a dialog box
if (cint(this.df.hidden)) {
// eslint-disable-next-line
@@ -55,10 +57,10 @@ frappe.ui.form.Control = class BaseControl {
if(explain) console.log("By Hidden Dependency: None"); // eslint-disable-line no-console
return "None";
- } else if (cint(this.df.read_only || this.df.is_virtual)) {
+ } else if (cint(this.df.read_only || this.df.is_virtual || this.df.fieldtype === "Read Only")) {
// eslint-disable-next-line
if (explain) console.log("By Read Only: Read"); // eslint-disable-line no-console
- return "Read";
+ status = "Read";
} else if ((this.grid &&
this.grid.display_status == 'Read') ||
@@ -67,10 +69,16 @@ frappe.ui.form.Control = class BaseControl {
this.layout.grid.display_status == 'Read')) {
// parent grid is read
if (explain) console.log("By Parent Grid Read-only: Read"); // eslint-disable-line no-console
- return "Read";
+ status = "Read";
}
- return "Write";
+ if (
+ status === "Read" &&
+ is_null(this.value) &&
+ !in_list(["HTML", "Image", "Button"], this.df.fieldtype)
+ ) status = "None";
+
+ return status;
}
var status = frappe.perm.get_field_display_status(this.df,
diff --git a/frappe/public/js/frappe/form/controls/control.js b/frappe/public/js/frappe/form/controls/control.js
index bd04938e35..90e8697b1c 100644
--- a/frappe/public/js/frappe/form/controls/control.js
+++ b/frappe/public/js/frappe/form/controls/control.js
@@ -23,7 +23,6 @@ import './table';
import './color';
import './signature';
import './password';
-import './read_only';
import './button';
import './html';
import './markdown_editor';
diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js
index f4c9849528..95abba616a 100644
--- a/frappe/public/js/frappe/form/controls/data.js
+++ b/frappe/public/js/frappe/form/controls/data.js
@@ -262,3 +262,5 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp
return this.grid || this.layout && this.layout.grid;
}
};
+
+frappe.ui.form.ControlReadOnly = frappe.ui.form.ControlData;
diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js
index 280eac3941..688e7da3e0 100644
--- a/frappe/public/js/frappe/form/controls/geolocation.js
+++ b/frappe/public/js/frappe/form/controls/geolocation.js
@@ -58,7 +58,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
}));
this.add_non_group_layers(data_layers, this.editableLayers);
try {
- this.map.flyToBounds(this.editableLayers.getBounds(), {
+ this.map.fitBounds(this.editableLayers.getBounds(), {
padding: [50,50]
});
}
@@ -66,10 +66,10 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
// suppress error if layer has a point.
}
this.editableLayers.addTo(this.map);
- this.map._onResize();
- } else if ((value===undefined) || (value == JSON.stringify(new L.FeatureGroup().toGeoJSON()))) {
- this.locate_control.start();
+ } else {
+ this.map.setView(frappe.utils.map_defaults.center, frappe.utils.map_defaults.zoom);
}
+ this.map.invalidateSize();
}
bind_leaflet_map() {
@@ -97,8 +97,7 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
});
L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/';
- this.map = L.map(this.map_id).setView(frappe.utils.map_defaults.center,
- frappe.utils.map_defaults.zoom);
+ this.map = L.map(this.map_id);
L.tileLayer(frappe.utils.map_defaults.tiles,
frappe.utils.map_defaults.options).addTo(this.map);
@@ -146,9 +145,8 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f
};
// create control and add to map
- var drawControl = new L.Control.Draw(options);
-
- this.map.addControl(drawControl);
+ this.drawControl = new L.Control.Draw(options);
+ this.map.addControl(this.drawControl);
this.map.on('draw:created', (e) => {
var type = e.layerType,
diff --git a/frappe/public/js/frappe/form/controls/read_only.js b/frappe/public/js/frappe/form/controls/read_only.js
deleted file mode 100644
index 2f1d1a2bca..0000000000
--- a/frappe/public/js/frappe/form/controls/read_only.js
+++ /dev/null
@@ -1,8 +0,0 @@
-frappe.ui.form.ControlReadOnly = class ControlReadOnly extends frappe.ui.form.ControlData {
- get_status(explain) {
- var status = super.get_status(explain);
- if(status==="Write")
- status = "Read";
- return;
- }
-};
diff --git a/frappe/public/js/frappe/model/perm.js b/frappe/public/js/frappe/model/perm.js
index 0eabfdd337..3ea9c6bc95 100644
--- a/frappe/public/js/frappe/model/perm.js
+++ b/frappe/public/js/frappe/model/perm.js
@@ -225,7 +225,10 @@ $.extend(frappe.perm, {
if (explain) console.log("By Workflow:" + status);
// read only field is checked
- if (status === "Write" && cint(df.read_only)) {
+ if (status === "Write" && (
+ cint(df.read_only) ||
+ df.fieldtype === "Read Only"
+ )) {
status = "Read";
}
if (explain) console.log("By Read Only:" + status);
@@ -276,4 +279,4 @@ $.extend(frappe.perm, {
return allowed_docs;
}
}
-});
\ No newline at end of file
+});
diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js
index 1936f5115e..479c020fbb 100644
--- a/frappe/public/js/frappe/ui/field_group.js
+++ b/frappe/public/js/frappe/ui/field_group.js
@@ -22,17 +22,15 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
super.make();
this.refresh();
// set default
- $.each(this.fields_list, function(i, field) {
- if (field.df["default"]) {
- let def_value = field.df["default"];
+ $.each(this.fields_list, (_, field) => {
+ if (!is_null(field.df.default)) {
+ let def_value = field.df.default;
- if (def_value == 'Today' && field.df["fieldtype"] == 'Date') {
+ if (def_value === "Today" && field.df.fieldtype === "Date") {
def_value = frappe.datetime.get_today();
}
- field.set_input(def_value);
- // if default and has depends_on, render its fields.
- me.refresh_dependency();
+ this.set_value(field.df.fieldname, def_value);
}
})
@@ -129,6 +127,7 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
if (f) {
f.set_value(val).then(() => {
f.set_input(val);
+ f.refresh();
this.refresh_dependency();
resolve();
});
diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js
index e3134b1f38..38c5f39589 100644
--- a/frappe/public/js/frappe/ui/page.js
+++ b/frappe/public/js/frappe/ui/page.js
@@ -850,9 +850,10 @@ frappe.ui.Page = class Page {
}
get_form_values() {
var values = {};
- this.page_form.fields_dict.forEach(function(field, key) {
- values[key] = field.get_value();
- });
+ for (let fieldname in this.fields_dict) {
+ let field = this.fields_dict[fieldname];
+ values[fieldname] = field.get_value();
+ }
return values;
}
add_view(name, html) {
diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js
index 2c1d93a2ec..16ba16e5d5 100644
--- a/frappe/public/js/frappe/ui/theme_switcher.js
+++ b/frappe/public/js/frappe/ui/theme_switcher.js
@@ -124,7 +124,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
toggle_theme(theme) {
this.current_theme = theme.toLowerCase();
document.documentElement.setAttribute("data-theme-mode", this.current_theme);
- frappe.show_alert("Theme Changed", 3);
+ frappe.show_alert(__("Theme Changed"), 3);
frappe.xcall("frappe.core.doctype.user.user.switch_theme", {
theme: toTitle(theme)
diff --git a/frappe/public/scss/desk/avatar.scss b/frappe/public/scss/desk/avatar.scss
index 638256c21d..073f90e20f 100644
--- a/frappe/public/scss/desk/avatar.scss
+++ b/frappe/public/scss/desk/avatar.scss
@@ -73,6 +73,7 @@
display: inline-block;
width: 100%;
height: 100%;
+ object-fit: cover;
background-color: var(--avatar-frame-bg);
background-size: cover;
background-repeat: no-repeat;
@@ -145,6 +146,7 @@
.standard-image {
width: 100%;
height: 100%;
+ object-fit: cover;
display: flex;
justify-content: center;
align-items: center;
diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss
index 2df349cb6c..b0a24eed38 100644
--- a/frappe/public/scss/desk/page.scss
+++ b/frappe/public/scss/desk/page.scss
@@ -51,10 +51,6 @@
}
}
-.custom-actions {
- display: flex;
-}
-
.page-actions {
align-items: center;
.btn {
@@ -71,6 +67,11 @@
.custom-btn-group {
display: inline-flex;
}
+
+ .custom-actions {
+ display: flex;
+ align-items: center;
+ }
}
.layout-main-section-wrapper {
diff --git a/frappe/public/scss/website/base.scss b/frappe/public/scss/website/base.scss
index 7e5d9b5b66..d666bcd410 100644
--- a/frappe/public/scss/website/base.scss
+++ b/frappe/public/scss/website/base.scss
@@ -1,13 +1,3 @@
-$font-size-xs: 0.7rem;
-$font-size-sm: 0.85rem;
-$font-size-lg: 1.12rem;
-$font-size-xl: 1.25rem;
-$font-size-2xl: 1.5rem;
-$font-size-3xl: 2rem;
-$font-size-4xl: 2.5rem;
-$font-size-5xl: 3rem;
-$font-size-6xl: 4rem;
-
html {
height: 100%;
}
@@ -29,68 +19,67 @@ h1, h2, h3, h4 {
}
h1 {
- font-size: $font-size-3xl;
+ font-size: 2rem;
line-height: 1.25;
letter-spacing: -0.025em;
margin-top: 3rem;
margin-bottom: 0.75rem;
@include media-breakpoint-up(sm) {
- font-size: $font-size-5xl;
- line-height: 2.5rem;
+ font-size: 2.5rem;
margin-top: 3.5rem;
margin-bottom: 1.25rem;
}
@include media-breakpoint-up(xl) {
- font-size: $font-size-6xl;
+ font-size: 3.5rem;
line-height: 1;
margin-top: 4rem;
}
}
h2 {
- font-size: $font-size-2xl;
+ font-size: 1.4rem;
margin-top: 2rem;
- margin-bottom: 0.75rem;
+ margin-bottom: 0.5rem;
@include media-breakpoint-up(sm) {
- font-size: $font-size-3xl;
+ font-size: 2rem;
margin-top: 4rem;
- margin-bottom: 1rem;
+ margin-bottom: 0.75rem;
}
@include media-breakpoint-up(xl) {
- font-size: $font-size-4xl;
+ font-size: 2.5rem;
margin-top: 4rem;
}
}
h3 {
- font-size: $font-size-xl;
- margin-top: 1.5rem;
+ font-size: 1.2rem;
+ margin-top: 2rem;
margin-bottom: 0.5rem;
@include media-breakpoint-up(sm) {
- font-size: $font-size-2xl;
+ font-size: 1.4rem;
margin-top: 2.5rem;
}
@include media-breakpoint-up(xl) {
- font-size: $font-size-3xl;
+ font-size: 1.9rem;
margin-top: 3.5rem;
}
}
h4 {
- font-size: $font-size-lg;
- margin-top: 1rem;
+ font-size: 1.1rem;
+ margin-top: 2rem;
margin-bottom: 0.5rem;
@include media-breakpoint-up(sm) {
- font-size: $font-size-xl;
- margin-top: 1.25rem;
+ font-size: 1.3rem;
+ margin-top: 2.5rem;
}
@include media-breakpoint-up(xl) {
- font-size: $font-size-2xl;
- margin-top: 1.75rem;
+ font-size: 1.5rem;
+ margin-top: 3rem;
}
a {
@@ -98,6 +87,10 @@ h4 {
}
}
-.btn.btn-lg {
- font-size: $font-size-lg;
+p {
+ line-height: 1.7;
+}
+
+.btn.btn-lg {
+ font-size: 1.1rem;
}
diff --git a/frappe/public/scss/website/blog.scss b/frappe/public/scss/website/blog.scss
index 4f289db125..ebc147b238 100644
--- a/frappe/public/scss/website/blog.scss
+++ b/frappe/public/scss/website/blog.scss
@@ -14,6 +14,10 @@
}
}
+.blog-list-content {
+ margin-bottom: 3rem;
+}
+
.blog-card {
margin-bottom: 2rem;
position: relative;
@@ -98,10 +102,15 @@
.blog-header {
margin-bottom: 3rem;
- margin-top: 3rem;
+ margin-top: 5rem;
}
}
+ .blog-comments {
+ margin-top: 1rem;
+ margin-bottom: 5rem;
+ }
+
.feedback-item svg {
vertical-align: sub;
diff --git a/frappe/public/scss/website/error-state.scss b/frappe/public/scss/website/error-state.scss
index c869e9e1df..7a83fc0084 100644
--- a/frappe/public/scss/website/error-state.scss
+++ b/frappe/public/scss/website/error-state.scss
@@ -1,4 +1,5 @@
.error-page {
+ margin: 3rem 0;
text-align: center;
.img-404 {
diff --git a/frappe/public/scss/website/footer.scss b/frappe/public/scss/website/footer.scss
index e5dae72808..9a36d7ab6d 100644
--- a/frappe/public/scss/website/footer.scss
+++ b/frappe/public/scss/website/footer.scss
@@ -1,5 +1,5 @@
.web-footer {
- margin: 5rem 0;
+ padding: 3rem 0;
min-height: 140px;
background-color: var(--fg-color);
border-top: 1px solid $border-color;
diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss
index 4352301b4c..933ac7ae22 100644
--- a/frappe/public/scss/website/index.scss
+++ b/frappe/public/scss/website/index.scss
@@ -88,6 +88,20 @@
border-radius: $dropdown-border-radius;
}
+.dropdown-item:active {
+ color: var(--fg-color);
+ text-decoration: none;
+ background-color: var(--gray-600);
+}
+
+.dropdown-item:active:hover {
+ color: var(--fg-color);
+}
+
+.dropdown-menu a:hover {
+ cursor: pointer;
+}
+
.input-dark {
background-color: $dark;
border-color: darken($primary, 40%);
@@ -100,8 +114,8 @@
@media (max-width: map-get($grid-breakpoints, "lg")) {
.page-content-wrapper .container {
- padding-left: 1rem;
- padding-right: 1rem;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
}
}
diff --git a/frappe/public/scss/website/markdown.scss b/frappe/public/scss/website/markdown.scss
index c2592b61e9..2b17c209cd 100644
--- a/frappe/public/scss/website/markdown.scss
+++ b/frappe/public/scss/website/markdown.scss
@@ -5,7 +5,6 @@
}
.from-markdown {
- color: $gray-700;
line-height: 1.7;
> :first-child {
@@ -30,7 +29,15 @@
}
p, li {
- font-size: $font-size-lg;
+ line-height: 1.7;
+
+ @include media-breakpoint-up(sm) {
+ font-size: 1.05rem;
+ }
+ }
+
+ p.lead {
+ @extend .lead;
}
li {
diff --git a/frappe/public/scss/website/page_builder.scss b/frappe/public/scss/website/page_builder.scss
index a2fea33765..2ca51067b7 100644
--- a/frappe/public/scss/website/page_builder.scss
+++ b/frappe/public/scss/website/page_builder.scss
@@ -16,16 +16,18 @@
}
}
-.hero-title, .hero-subtitle {
- max-width: 42rem;
- margin-top: 0rem;
- margin-bottom: 0.5rem;
-}
-
.lead {
+ color: var(--text-muted);
font-weight: normal;
font-size: 1.25rem;
+
+ margin-top: -0.5rem;
margin-bottom: 1.5rem;
+
+ @include media-breakpoint-up(sm) {
+ margin-top: -1rem;
+ margin-bottom: 2.5rem;
+ }
}
.hero-subtitle {
@@ -38,6 +40,12 @@
}
}
+.hero-title, .hero-subtitle {
+ max-width: 42rem;
+ margin-top: 0rem;
+ margin-bottom: 0.5rem;
+}
+
.hero.align-center {
h1, .hero-title, .hero-subtitle, .hero-buttons {
text-align: center;
@@ -51,6 +59,7 @@
.section-description {
max-width: 56rem;
+ color: var(--text-muted);
margin-top: 0.5rem;
font-size: $font-size-lg;
@@ -479,6 +488,12 @@
align-items: center;
}
+.collapsible-item-title {
+ font-weight: 600;
+ color: var(--text-color);
+ font-size: var(--text-2xl);
+}
+
.collapsible-item a {
text-decoration: none;
}
@@ -516,6 +531,7 @@
.section-description, .collapsible-items {
margin-left: auto;
margin-right: auto;
+ margin-top: 3rem;
}
}
@@ -542,7 +558,7 @@
font-weight: 600;
@include media-breakpoint-up(md) {
- font-size: $font-size-2xl;
+ font-size: $font-size-xl;
}
}
diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py
index cf01e885ba..b0292f2728 100644
--- a/frappe/query_builder/functions.py
+++ b/frappe/query_builder/functions.py
@@ -43,11 +43,15 @@ CombineDatetime = ImportMapper(
}
)
+DateFormat = ImportMapper({
+ db_type_is.MARIADB: CustomFunction("DATE_FORMAT", ["date", "format"]),
+ db_type_is.POSTGRES: ToChar,
+})
class Cast_(Function):
def __init__(self, value, as_type, alias=None):
if db_type_is.MARIADB and (
- (hasattr(as_type, "get_sql") and as_type.get_sql() == "varchar") or str(as_type).lower() == "varchar"
+ (hasattr(as_type, "get_sql") and as_type.get_sql().lower() == "varchar") or str(as_type).lower() == "varchar"
):
# mimics varchar cast in mariadb
# as mariadb doesn't have varchar data cast
@@ -62,7 +66,7 @@ class Cast_(Function):
def get_special_params_sql(self, **kwargs):
if self.name.lower() == "cast":
- type_sql = self.as_type.get_sql() if hasattr(self.as_type, "get_sql") else str(self.as_type).upper()
+ type_sql = self.as_type.get_sql(**kwargs) if hasattr(self.as_type, "get_sql") else str(self.as_type).upper()
return "AS {type}".format(type=type_sql)
diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py
index 205f1f9dcd..d3785e049a 100644
--- a/frappe/query_builder/terms.py
+++ b/frappe/query_builder/terms.py
@@ -1,10 +1,12 @@
from datetime import timedelta
from typing import Any, Dict, Optional
-from frappe.utils.data import format_timedelta
-from pypika.terms import Function, ValueWrapper
+from pypika.queries import QueryBuilder
+from pypika.terms import Criterion, Function, ValueWrapper
from pypika.utils import format_alias_sql
+from frappe.utils.data import format_timedelta
+
class NamedParameterWrapper:
"""Utility class to hold parameter values and keys"""
@@ -100,3 +102,12 @@ class ParameterizedFunction(Function):
)
return function_sql
+
+class subqry(Criterion):
+ def __init__(self, subq: QueryBuilder, alias: Optional[str] = None,) -> None:
+ super().__init__(alias)
+ self.subq = subq
+
+ def get_sql(self, **kwg: Any) -> str:
+ kwg["subquery"] = True
+ return self.subq.get_sql(**kwg)
diff --git a/frappe/templates/discussions/button.html b/frappe/templates/discussions/button.html
index 8e61d2412f..746227aa0b 100644
--- a/frappe/templates/discussions/button.html
+++ b/frappe/templates/discussions/button.html
@@ -1,9 +1,6 @@
{% if frappe.session.user != "Guest" and
(condition is not defined or (condition is defined and condition )) %}
-
+
{{ _(cta_title) }}
-
-
{% endif %}
diff --git a/frappe/templates/discussions/comment_box.html b/frappe/templates/discussions/comment_box.html
index ba8f440ad4..23c1bbecf1 100644
--- a/frappe/templates/discussions/comment_box.html
+++ b/frappe/templates/discussions/comment_box.html
@@ -28,7 +28,7 @@
-
diff --git a/frappe/templates/discussions/discussions.js b/frappe/templates/discussions/discussions.js
index 19c0f89a49..c786583fa6 100644
--- a/frappe/templates/discussions/discussions.js
+++ b/frappe/templates/discussions/discussions.js
@@ -4,8 +4,6 @@ frappe.ready(() => {
add_color_to_avatars();
- expand_first_discussion();
-
$(".search-field").keyup((e) => {
search_topic(e);
});
@@ -14,11 +12,11 @@ frappe.ready(() => {
show_new_topic_modal(e);
});
- $("#login-from-discussion").click((e) => {
+ $(".login-from-discussion").click((e) => {
login_from_discussion(e);
});
- $(".sidebar-topic").click((e) => {
+ $(".sidebar-parent").click((e) => {
if ($(e.currentTarget).attr("aria-expanded") == "true") {
e.stopPropagation();
}
@@ -31,17 +29,6 @@ frappe.ready(() => {
}
});
- $(document).on("input", ".discussion-on-page .comment-field", (e) => {
- if ($(e.currentTarget).val()) {
- $(e.currentTarget).css("height", "48px");
- $(".cancel-comment").removeClass("hide").addClass("show");
- $(e.currentTarget).css("height", $(e.currentTarget).prop("scrollHeight"));
- } else {
- $(".cancel-comment").removeClass("show").addClass("hide");
- $(e.currentTarget).css("height", "48px");
- }
- });
-
$(document).on("click", ".submit-discussion", (e) => {
submit_discussion(e);
});
@@ -50,16 +37,26 @@ frappe.ready(() => {
clear_comment_box();
});
- if ($(document).width() <= 550) {
- $(document).on("click", ".sidebar-parent", () => {
- hide_sidebar();
- });
- }
+ $(document).on("click", ".sidebar-parent", () => {
+ hide_sidebar();
+ });
- $(document).on("click", ".back", (e) => {
+ $(document).on("click", ".back-button", (e) => {
back_to_sidebar(e);
});
+ $(document).on("click", ".dismiss-reply", (e) => {
+ dismiss_reply(e);
+ });
+
+ $(document).on("click", ".reply-card .dropdown-menu", (e) => {
+ perform_action(e);
+ });
+
+ $(document).on("input", ".discussion-on-page .comment-field", (e) => {
+ adjust_comment_box(e);
+ });
+
});
const show_new_topic_modal = (e) => {
@@ -79,10 +76,17 @@ const setup_socket_io = () => {
if (window.dev_server) {
frappe.boot.socketio_port = "9000";
}
+
frappe.socketio.init(9000);
frappe.socketio.socket.on("publish_message", (data) => {
publish_message(data);
});
+ frappe.socketio.socket.on("update_message", (data) => {
+ update_message(data);
+ });
+ frappe.socketio.socket.on("delete_message", (data) => {
+ delete_message(data);
+ });
});
};
@@ -92,44 +96,47 @@ const publish_message = (data) => {
const topic = data.topic_info;
const single_thread = $(".is-single-thread").length;
const first_topic = !$(".reply-card").length;
- const document_match_found = doctype == topic.reference_doctype && docname == topic.reference_docname;
+ const document_match_found = (doctype == topic.reference_doctype) && (docname == topic.reference_docname);
+
+ post_message_cleanup();
+ data.template = hide_actions_on_conditions(data.template, data.reply_owner);
+ data.template = style_avatar_frame(data.template);
+ data.sidebar = style_avatar_frame(data.sidebar);
+ data.new_topic_template = style_avatar_frame(data.new_topic_template);
if ($(`.discussion-on-page[data-topic=${topic.name}]`).length) {
- post_message_cleanup();
- data.template = style_avatar_frame(data.template);
- $('' + data.template)
- .insertBefore(`.discussion-on-page[data-topic=${topic.name}] .discussion-form`);
+ $(data.template).insertBefore(`.discussion-on-page[data-topic=${topic.name}] .discussion-form`);
} else if (!first_topic && !single_thread && document_match_found) {
- post_message_cleanup();
- data.new_topic_template = style_avatar_frame(data.new_topic_template);
-
- $(data.sidebar).insertAfter(`.discussions-sidebar .form-group`);
+ $(data.sidebar).insertBefore($(`.discussions-sidebar .sidebar-parent`).first());
$(`#discussion-group`).prepend(data.new_topic_template);
-
if (topic.owner == frappe.session.user) {
$(".discussion-on-page") && $(".discussion-on-page").collapse();
- $(".sidebar-topic").first().click();
+ $(".sidebar-parent").first().click();
}
} else if (single_thread && document_match_found) {
- post_message_cleanup();
- data.template = style_avatar_frame(data.template);
$(data.template).insertBefore(`.discussion-form`);
$(".discussion-on-page").attr("data-topic", topic.name);
} else if (topic.owner == frappe.session.user && document_match_found) {
- post_message_cleanup();
window.location.reload();
}
update_reply_count(topic.name);
};
+const update_message = (data) => {
+ const reply_card = $(`[data-reply=${data.reply_name}]`);
+ reply_card.find(".reply-body").removeClass("hide");
+ reply_card.find(".reply-edit-card").addClass("hide");
+ reply_card.find(".reply-text").html(data.reply);
+ reply_card.find(".reply-actions").addClass("hide");
+};
+
const post_message_cleanup = () => {
$(".topic-title").val("");
- $(".comment-field").val("");
- $(".discussion-on-page .comment-field").css("height", "48px");
+ $(".discussion-form .comment-field").val("");
$("#discussion-modal").modal("hide");
$("#no-discussions").addClass("hide");
$(".cancel-comment").addClass("hide");
@@ -141,15 +148,6 @@ const update_reply_count = (topic) => {
$(`[data-target='#t${topic}']`).find(".reply-count").text(reply_count);
};
-const expand_first_discussion = () => {
- if ($(document).width() > 550) {
- $($(".discussions-parent .collapse")[0]).addClass("show");
- $($(".discussions-sidebar [data-toggle='collapse']")[0]).attr("aria-expanded", true);
- } else {
- $("#discussion-group").addClass("hide");
- }
-};
-
const search_topic = (e) => {
let input = $(e.currentTarget).val();
@@ -160,7 +158,7 @@ const search_topic = (e) => {
}
topics.each((i, elem) => {
- let topic_id = $(elem).parent().attr("data-target");
+ let topic_id = $(elem).closest(".sidebar-parent").attr("data-target");
/* Check match in replies */
let match_in_reply = false;
@@ -201,16 +199,20 @@ const submit_discussion = (e) => {
e.preventDefault();
e.stopImmediatePropagation();
+ const target = $(e.currentTarget);
+ const reply_name = target.closest(".reply-card").data("reply");
const title = $(".topic-title:visible").length ? $(".topic-title:visible").val().trim() : "";
- const reply = $(".comment-field:visible").val().trim();
+ let reply = reply_name ? target.closest(".reply-card") : target.closest(".discussion-form");
+ reply = reply.find(".comment-field").val().trim();
if (reply) {
- let doctype = $(e.currentTarget).closest(".discussions-parent").attr("data-doctype");
+ let doctype = target.closest(".discussions-parent").attr("data-doctype");
doctype = doctype ? decodeURIComponent(doctype) : doctype;
- let docname = $(e.currentTarget).closest(".discussions-parent").attr("data-docname");
+ let docname = target.closest(".discussions-parent").attr("data-docname");
docname = docname ? decodeURIComponent(docname) : docname;
+
frappe.call({
method: "frappe.website.doctype.discussion_topic.discussion_topic.submit_discussion",
args: {
@@ -218,7 +220,8 @@ const submit_discussion = (e) => {
"docname": docname ? docname : "",
"reply": reply,
"title": title,
- "topic_name": $(e.currentTarget).closest(".discussion-on-page").attr("data-topic")
+ "topic_name": target.closest(".discussion-on-page").attr("data-topic"),
+ "reply_name": reply_name
}
});
}
@@ -252,18 +255,64 @@ const style_avatar_frame = (template) => {
};
const clear_comment_box = () => {
- $(".discussion-on-page .comment-field").val("");
+ $(".discussion-form .comment-field").val("");
$(".cancel-comment").removeClass("show").addClass("hide");
- $(".discussion-on-page .comment-field").css("height", "48px");
};
const hide_sidebar = () => {
$(".discussions-sidebar").addClass("hide");
$("#discussion-group").removeClass("hide");
+ $(".search-field").addClass("hide");
+ $(".reply").addClass("hide");
};
const back_to_sidebar = () => {
$(".discussions-sidebar").removeClass("hide");
$("#discussion-group").addClass("hide");
$(".discussion-on-page").collapse("hide");
+ $(".search-field").removeClass("hide");
+ $(".reply").removeClass("hide");
+};
+
+const perform_action = (e) => {
+ const action = $(e.target).data().action;
+ const reply_card = $(e.target).closest(".reply-card");
+
+ if (action === "edit") {
+ reply_card.find(".reply-edit-card").removeClass("hide");
+ reply_card.find(".reply-body").addClass("hide");
+ reply_card.find(".reply-actions").removeClass("hide");
+ } else if (action === "delete") {
+ frappe.call({
+ method: "frappe.website.doctype.discussion_reply.discussion_reply.delete_message",
+ args: {
+ "reply_name": $(e.target).closest(".reply-card").data("reply")
+ }
+ });
+ }
+};
+
+const dismiss_reply = (e) => {
+ const reply_card = $(e.currentTarget).closest(".reply-card");
+ reply_card.find(".reply-edit-card").addClass("hide");
+ reply_card.find(".reply-body").removeClass("hide");
+ reply_card.find(".reply-actions").addClass("hide");
+};
+
+const adjust_comment_box = (e) => {
+ if ($(e.currentTarget).val()) {
+ $(".cancel-comment").removeClass("hide").addClass("show");
+ } else {
+ $(".cancel-comment").removeClass("show").addClass("hide");
+ }
+};
+
+const hide_actions_on_conditions = (template, owner) => {
+ let $template = $(template);
+ frappe.session.user != owner && $template.find(".dropdown").addClass("hide");
+ return $template.prop("outerHTML");
+};
+
+const delete_message = (data) => {
+ $(`[data-reply=${data.reply_name}]`).addClass("hide");
};
diff --git a/frappe/templates/discussions/discussions_section.html b/frappe/templates/discussions/discussions_section.html
index 07c229595b..5db7cf86b1 100644
--- a/frappe/templates/discussions/discussions_section.html
+++ b/frappe/templates/discussions/discussions_section.html
@@ -9,25 +9,31 @@
-
+
{% if topics and not single_thread %}
-
{1},Personnalisations pour {0} exportées vers:
{1}, Customize Form,Personnaliser le formulaire, Customize Form Field,Personnaliser un Champ de Formulaire, @@ -895,7 +896,7 @@ DocType {0} provided for the field {1} must have atleast one Link DocType can not be merged,DocType ne peut pas être fusionné, DocType can only be renamed by Administrator,DocType ne peut être renommé que par l'Administrateur, DocType is a Table / Form in the application.,DocType est un Tableau / Formulaire dans l'application., -DocType must be Submittable for the selected Doc Event,Le DocType doit être soumissible pour l'événement Doc sélectionné, +DocType must be Submittable for the selected Doc Event,Le DocType doit être validable pour l'événement Doc sélectionné, DocType on which this Workflow is applicable.,DocType pour lequel ce Flux de Travail est applicable., "DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores","Le nom du DocType doit commencer par une lettre et il peut uniquement se composer de lettres, des chiffres, d’espaces et du tiret bas (underscore)", Doctype required,Doctype requis, @@ -907,7 +908,7 @@ Document Restored,Document Restauré, Document Share Report,Rapport de Partage de Document, Document States,États du Document, Document Type is not importable,Le type de document n'est pas importable, -Document Type is not submittable,Le type de document n'est pas soumis, +Document Type is not submittable,Le type de document n'est pas valider, Document Type to Track,Type de document à suivre, Document Types,Types de documents, Document can't saved.,Le document ne peut pas être enregistré., @@ -1391,7 +1392,7 @@ Is Published Field must be a valid fieldname,Le Champ Publié doit-il être un n Is Single,Est Seul, Is Spam,Est Spam, Is Standard,Est Standard, -Is Submittable,Est Soumissible, +Is Submittable,Est Validable, Is Table,Est Table, Is Your Company Address,Est l'Adresse de votre Entreprise, It is risky to delete this file: {0}. Please contact your System Manager.,Il est risqué de supprimer ce fichier : {0}. Veuillez contactez votre Administrateur Système., @@ -1508,7 +1509,7 @@ Login not allowed at this time,Connexion non autorisée pour le moment, Login to comment,Connectez-vous pour commenter, Login token required,Identifiants de Connexion Requis, Login with LDAP,Se connecter avec LDAP, -Logout,Connectez - Out, +Logout,Déconnecté, Long Text,Texte Long, Looks like something is wrong with this site's Paypal configuration.,Il semble qu'il y ait une erreur avec la configuration Paypal de ce site., Looks like something is wrong with this site's payment gateway configuration. No payment has been made.,On dirait que quelque chose ne va pas dans la configuration de la passerelle de paiement de ce site. Aucun paiement n'a été effectué., @@ -1540,7 +1541,7 @@ Max Value,Valeur Max, Max width for type Currency is 100px in row {0},Largeur max pour le type Devise est 100px dans la ligne {0}, Maximum Attachment Limit for this record reached.,Taille maximale des Pièces Jointes pour cet enregistrement est atteint., Maximum {0} rows allowed,Maximum {0} lignes autorisés, -"Meaning of Submit, Cancel, Amend","Signification de Soumettre, Annuler, Modifier", +"Meaning of Submit, Cancel, Amend","Signification de Valider, Annuler, Modifier", Mention transaction completion page URL,Mentionnez la page URL de fin de transaction, Mentions,Mentions, Menu,Menu, @@ -1736,7 +1737,7 @@ Old Password,Ancien Mot De Passe, Old Password Required.,Ancien Mot de Passe Requis., Older backups will be automatically deleted,Les anciennes sauvegardes seront automatiquement supprimées, "On {0}, {1} wrote:","Sur {0}, {1} a écrit :", -"Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.","Une fois soumis, les documents à soumettre ne peuvent plus être modifiés. Ils ne peuvent être annulés et amendés.", +"Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.","Une fois validé, les documents à valider ne peuvent plus être modifiés. Ils ne peuvent être annulés et amendés.", "Once you have set this, the users will only be able access documents (eg. Blog Post) where the link exists (eg. Blogger).","Une fois que vous avez défini ceci, les utilisateurs ne pourront accèder qu'aux documents (e.g. Article de Blog) où le lien existe (e.g. Blogger) .", One Last Step,Une Dernière Étape, One Time Password (OTP) Registration Code from {},Code de Mot de Passe Unique (OTP) à partir de {}, @@ -1828,7 +1829,7 @@ Percent Complete,Pourcentage d'Avancement, Perm Level,Niveau d'Autorisation, Permanent,Permanent, Permanently Cancel {0}?,Annuler de Manière Permanente {0} ?, -Permanently Submit {0}?,Soumettre de Manière Permanente {0} ?, +Permanently Submit {0}?,Valider de Manière Permanente {0} ?, Permanently delete {0}?,Supprimer de Manière Permanente {0} ?, Permission Error,Erreur d'autorisation, Permission Level,Niveau d'Autorisation, @@ -1836,7 +1837,7 @@ Permission Levels,Niveaux d'Autorisation, Permission Rules,Règles d'Autorisation, Permissions,Autorisations, Permissions are automatically applied to Standard Reports and searches.,Les autorisations sont automatiquement appliquées aux rapports standard et aux recherches., -"Permissions are set on Roles and Document Types (called DocTypes) by setting rights like Read, Write, Create, Delete, Submit, Cancel, Amend, Report, Import, Export, Print, Email and Set User Permissions.","Les Autorisations sont définies sur les Rôles et les Types de Documents (appelés DocTypes) en définissant des droits , tels que Lire, Écrire, Créer, Supprimer, Soumettre, Annuler, Modifier, Rapporter, Importer, Exporter, Imprimer, Envoyer un Email et Définir les Autorisations de l'Utilisateur .", +"Permissions are set on Roles and Document Types (called DocTypes) by setting rights like Read, Write, Create, Delete, Submit, Cancel, Amend, Report, Import, Export, Print, Email and Set User Permissions.","Les Autorisations sont définies sur les Rôles et les Types de Documents (appelés DocTypes) en définissant des droits , tels que Lire, Écrire, Créer, Supprimer, Valider, Annuler, Modifier, Rapporter, Importer, Exporter, Imprimer, Envoyer un Email et Définir les Autorisations de l'Utilisateur .", Permissions at higher levels are Field Level permissions. All Fields have a Permission Level set against them and the rules defined at that permissions apply to the field. This is useful in case you want to hide or make certain field read-only for certain Roles.,Les Autorisations aux niveaux supérieurs sont des permissions de Niveau Champ. Un Niveau d'Autorisation est défini pour chaque Champ et les règles définies pour ces Autorisations s’appliquent au Champ. Ceci est utile si vous voulez cacher ou mettre certains champs en lecture seule pour certains Rôles., "Permissions at level 0 are Document Level permissions, i.e. they are primary for access to the document.","Les Autorisations au niveau 0 sont les autorisations de Niveau Document, c’est à dire qu'elles sont nécessaires pour accéder au document.", Permissions get applied on Users based on what Roles they are assigned.,Autorisations sont appliqués aux utilisateurs en fonction des Rôles qui leurs sont affectés., @@ -2122,7 +2123,7 @@ Row No,Rangée No, Row Status,État de la ligne, Row Values Changed,Valeurs de Lignes Modifiées, Row {0}: Not allowed to disable Mandatory for standard fields,Ligne {0}: impossible de désactiver Obligatoire pour les champs standard, -Row {0}: Not allowed to enable Allow on Submit for standard fields,Ligne {0} : Il n’est pas autorisé d’activer Autoriser à la Soumission pour les champs standards, +Row {0}: Not allowed to enable Allow on Submit for standard fields,Ligne {0} : Il n’est pas autorisé d’activer Autoriser à la Validation pour les champs standards, Rows Added,Lignes Ajoutées, Rows Removed,Lignes Supprimées, Rule,Règle, @@ -2164,7 +2165,7 @@ Search for '{0}',Rechercher '{0}', Search for anything,Rechercher tout, Search in a document type,Rechercher dans un type de document, Search or Create a New Chat,Rechercher ou créer un nouveau chat, -Search or type a command,Rechercher ou taper une commande, +Search or type a command (Ctrl + G),Rechercher ou taper une commande (Ctrl + G), Search...,Rechercher..., Searching,Recherche, Searching ...,Recherche ..., @@ -2394,13 +2395,13 @@ Stylesheets for Print Formats,Feuilles de style pour les Formats d'Impression, Sub-domain provided by erpnext.com,Sous-domaine fourni par erpnext.com, Subdomain,Sous-domaine, Subject Field,Champ de sujet, -Submit after importing,Soumettre après l'import, -Submit an Issue,Soumettre un ticket, -Submit this document to confirm,Soumettre ce document pour confirmer, -Submit {0} documents?,Soumettre {0} documents ?, -Submiting {0},Soumission de {0}, -Submitted Document cannot be converted back to draft. Transition row {0},Document Soumis ne peut pas être reconvertis en Brouillon. Ligne de transition {0}, -Submitting,Soumission, +Submit after importing,Valider après l'import, +Submit an Issue,Valider un ticket, +Submit this document to confirm,Valider ce document pour confirmer, +Submit {0} documents?,Valider {0} documents ?, +Submiting {0},Validation de {0}, +Submitted Document cannot be converted back to draft. Transition row {0},Document Valider ne peut pas être reconvertis en Brouillon. Ligne de transition {0}, +Submitting,Validation, Subscription Notification,Notification d'abonnement, Subsidiary,Filiale, Success Action,Action de succès, @@ -2783,7 +2784,7 @@ You are not permitted to view the newsletter.,Vous n'êtes pas autorisé à You are now following this document. You will receive daily updates via email. You can change this in User Settings.,Vous suivez maintenant ce document. Vous recevrez des mises à jour quotidiennes par courrier électronique. Vous pouvez modifier cela dans les paramètres de l'utilisateur., You can add dynamic properties from the document by using Jinja templating.,Vous pouvez ajouter des propriétés dynamiques au document à l'aide des modèles Jinja., You can also copy-paste this ,Vous pouvez également copier-coller cette, -"You can change Submitted documents by cancelling them and then, amending them.","Vous pouvez modifier les documents Soumis en les annulant et ensuite, en les modifiant.", +"You can change Submitted documents by cancelling them and then, amending them.","Vous pouvez modifier les documents Validés en les annulant et ensuite, en les modifiant.", You can find things by asking 'find orange in customers',Vous pouvez trouver des choses en demandant 'trouver orange dans clients', You can only upload upto 5000 records in one go. (may be less in some cases),Vous pouvez seulement charger jusqu'à 5000 enregistrement en une seule fois. (peut-être moins dans certains cas), You can use Customize Form to set levels on fields.,Vous pouvez utiliser Personaliser le Formulaire pour définir les niveaux de champs., @@ -2806,7 +2807,7 @@ You gained {0} points,Vous avez gagné {0} points, You have a new message from: ,Vous avez un nouveau message de:, You have been successfully logged out,Vous avez été déconnecté avec succès, You have unsaved changes in this form. Please save before you continue.,Vous avez des modifications non enregistrées dans ce formulaire. Veuillez enregistrer avant de continuer., -You must login to submit this form,Vous devez vous connecter pour soumettre ce formulaire, +You must login to submit this form,Vous devez vous connecter pour valider ce formulaire, You need to be in developer mode to edit a Standard Web Form,Vous devez être en Mode Développeur pour modifier un Formulaire Web Standard, You need to be logged in and have System Manager Role to be able to access backups.,Vous devez être connecté et avoir le Role Responsable Système pour pouvoir accéder aux sauvegardes., You need to be logged in to access this {0}.,Vous devez être connecté pour accéder à ce(tte) {0}., @@ -2819,7 +2820,7 @@ Your Language,Votre Langue, Your Name,Votre Nom, Your account has been locked and will resume after {0} seconds,Votre compte a été verrouillé et reprendra après {0} secondes, Your connection request to Google Calendar was successfully accepted,Votre demande de connexion à Google Agenda a été acceptée avec succès, -Your information has been submitted,Vos informations ont été soumises, +Your information has been submitted,Vos informations ont été validées, Your login id is,Votre id de connexion est, Your organization name and address for the email footer.,Le nom de votre société et l'adresse pour le pied de l'email., Your payment has been successfully registered.,Votre paiement a été enregistré avec succès., @@ -2981,7 +2982,7 @@ star,étoile, star-empty,étoile-vide, step-backward,vers-larrière, step-forward,vers-l'avant, -submitted this document,a soumis ce document, +submitted this document,a validé ce document, text in document type,Texte dans le type de document, text-height,Hauteur-texte, text-width,largeur-text, @@ -3093,11 +3094,11 @@ zoom-out,Réduire, "{0}, Row {1}","{0}, Ligne {1}", "{0}: '{1}' ({3}) will get truncated, as max characters allowed is {2}",{0} : {1} '({3}) sera tronqué car le nombre de caractères max est {2}, {0}: Cannot set Amend without Cancel,{0} : Impossible de choisir Modifier sans Annuler, -{0}: Cannot set Assign Amend if not Submittable,{0} : Impossible de définir ‘Assigner Modifier’ si non Soumissible, -{0}: Cannot set Assign Submit if not Submittable,{0} : Impossible de définir ‘Assigner Soumettre’ si non Soumissible, -{0}: Cannot set Cancel without Submit,{0} : Impossible de choisir Annuler sans Soumettre, +{0}: Cannot set Assign Amend if not Submittable,{0} : Impossible de définir ‘Assigner Modifier’ si non Validable, +{0}: Cannot set Assign Submit if not Submittable,{0} : Impossible de définir ‘Assigner Valider’ si non Validable, +{0}: Cannot set Cancel without Submit,{0} : Impossible de choisir Annuler sans Valider, {0}: Cannot set Import without Create,{0} : Impossible de choisir Import sans Créer, -"{0}: Cannot set Submit, Cancel, Amend without Write","{0} : Vous ne pouvez pas choisir Envoyer, Annuler, Modifier sans Écrire", +"{0}: Cannot set Submit, Cancel, Amend without Write","{0} : Vous ne pouvez pas choisir Valider, Annuler, Modifier sans Écrire", {0}: Cannot set import as {1} is not importable,{0} : Impossible de choisir import car {1} n'est pas importable, {0}: No basic permissions set,{0} : Aucune autorisation de base définie, "{0}: Only one rule allowed with the same Role, Level and {1}","{0} : Une seule règle est permise avec le même Rôle, Niveau et {1}", @@ -3152,8 +3153,8 @@ Administration,Administration, After Cancel,Après annuler, After Delete,Après la suppression, After Save,Après l'enregistrement, -After Save (Submitted Document),Après l'enregistrement (document soumis), -After Submit,Après soumettre, +After Save (Submitted Document),Après l'enregistrement (document valider), +After Submit,Après validation, Aggregate Function Based On,Fonction d'agrégation basée sur, Aggregate Function field is required to create a dashboard chart,Le champ Fonction d'agrégation est requis pour créer un graphique de tableau de bord, All Records,Tous les enregistrements, @@ -3198,8 +3199,8 @@ Before Cancel,Avant d'annuler, Before Delete,Avant de supprimer, Before Insert,Avant l'insertion, Before Save,Avant de sauvegarder, -Before Save (Submitted Document),Avant de sauvegarder (document soumis), -Before Submit,Avant de soumettre, +Before Save (Submitted Document),Avant de sauvegarder (document valider), +Before Submit,Avant de valider, Blank Template,Modèle vierge, Callback URL,URL de rappel, Cancel All Documents,Annuler tous les documents, @@ -3555,11 +3556,11 @@ Skipping column {0},Colonne ignorée {0}, Social Home,Maison sociale, Some columns might get cut off when printing to PDF. Try to keep number of columns under 10.,Certaines colonnes peuvent être coupées lors de l'impression au format PDF. Essayez de garder le nombre de colonnes sous 10., Something went wrong during the token generation. Click on {0} to generate a new one.,Quelque chose s'est mal passé pendant la génération de jetons. Cliquez sur {0} pour en générer un nouveau., -Submit After Import,Soumettre après importation, -Submitting...,Soumission..., +Submit After Import,Validation après importation, +Submitting...,Validation..., Success! You are good to go 👍,Succès! Vous êtes bon pour aller, Successful Transactions,Transactions réussies, -Successfully Submitted!,Soumis avec succès!, +Successfully Submitted!,Validation avec succès!, Successfully imported {0} record.,{0} enregistrement importé avec succès., Successfully imported {0} records.,{0} enregistrements importés avec succès., Successfully updated {0} record.,{0} enregistrement mis à jour avec succès., @@ -3658,7 +3659,7 @@ choose an,choisir un, empty,vide, of,de, or attach a,ou attacher un, -submitted this document {0},a soumis ce document {0}, +submitted this document {0},a validé ce document {0}, "tag name..., e.g. #tag","nom de tag ..., par exemple #tag", uploaded file,fichier téléchargé, via Data Import,via importation de données, @@ -3677,7 +3678,7 @@ via Data Import,via importation de données, {0} shared a document {1} {2} with you,{0} a partagé un document {1} {2} avec vous, {0} should not be same as {1},{0} ne doit pas être identique à {1}, {0} translations pending,{0} traductions en attente, -{0} {1} is linked with the following submitted documents: {2},{0} {1} est lié aux documents soumis suivants: {2}, +{0} {1} is linked with the following submitted documents: {2},{0} {1} est lié aux documents validés suivants: {2}, "{0}: Failed to attach new recurring document. To enable attaching document in the auto repeat notification email, enable {1} in Print Settings","{0}: Impossible de joindre un nouveau document récurrent. Pour activer la pièce jointe dans l'e-mail de notification de répétition automatique, activez {1} dans Paramètres d'impression", {0}: Fieldname cannot be one of {1},{0}: le nom de champ ne peut pas être l'un des {1}, {} Complete,{} Achevée, @@ -3792,7 +3793,7 @@ Sr,Sr, Start,Démarrer, Start Time,Heure de Début, Status,Statut, -Submitted,Soumis, +Submitted,Validé, Tag,Étiquette, Template,Modèle, Thursday,Jeudi, @@ -4139,13 +4140,13 @@ Document is only editable by users with role,Le document n'est modifiable qu {0}: Other permission rules may also apply,{0}: d'autres règles d'autorisation peuvent également s'appliquer, {0} Page Views,{0} pages vues, Expand,Développer, -Collapse,Effondrer, +Collapse,Réduire, "Invalid Bearer token, please provide a valid access token with prefix 'Bearer'.","Jeton de porteur non valide, veuillez fournir un jeton d'accès valide avec le préfixe «porteur».", "Failed to decode token, please provide a valid base64-encoded token.","Échec du décodage du jeton, veuillez fournir un jeton encodé en base64 valide.", "Invalid token, please provide a valid token with prefix 'Basic' or 'Token'.","Jeton non valide, veuillez fournir un jeton valide avec le préfixe «Basic» ou «Token».", {0} is not a valid Name,{0} n'est pas un nom valide, Your system is being updated. Please refresh again after a few moments.,Votre système est en cours de mise à jour. Veuillez actualiser à nouveau après quelques instants., -{0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.,{0} {1}: l'enregistrement soumis ne peut pas être supprimé. Vous devez d'abord {2} l'annuler {3}., +{0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.,{0} {1}: l'enregistrement validé ne peut pas être supprimé. Vous devez d'abord {2} l'annuler {3}., Invalid naming series (. missing) for {0},Série de noms non valide (. Manquante) pour {0}, Error has occurred in {0},Une erreur s'est produite dans {0}, Status Updated,Statut mis à jour, @@ -4215,7 +4216,7 @@ since yesterday,depuis hier, since last week,depuis la semaine dernière, since last month,depuis le mois dernier, since last year,depuis l'année dernière, -Show,Spectacle, +Show,Afficher, New Number Card,Nouvelle carte de numéro, Your Shortcuts,Vos raccourcis, You haven't added any Dashboard Charts or Number Cards yet.,Vous n'avez pas encore ajouté de tableaux de bord ou de cartes numériques., @@ -4509,7 +4510,7 @@ Oops,Oups, Skip Step,Passer l'étape, "You're doing great, let's take you back to the onboarding page.","Vous vous débrouillez très bien, revenons à la page d'intégration.", Good Work 🎉,Bon travail 🎉, -Submit this document to complete this step.,Soumettez ce document pour terminer cette étape., +Submit this document to complete this step.,Validez ce document pour terminer cette étape., Great,Génial, You may continue with onboarding,Vous pouvez continuer avec l'intégration, You seem good to go!,Vous semblez prêt à partir!, @@ -4700,3 +4701,17 @@ Value cannot be negative for {0}: {1},La valeur ne peut pas être négative pour Negative Value,Valeur négative, Authentication failed while receiving emails from Email Account: {0}.,L'authentification a échoué lors de la réception des e-mails du compte de messagerie: {0}., Message from server: {0},Message du serveur: {0}, +{0} edited this {1},{0} a édité {1}, +{0} created this {1}, {0} a créé {1} +Report an Issue, Signaler une anomalie +About, A Propos +My Profile, Mon profil +My Settings, Mes paramètres +Toggle Full Width, Changer l'affichage en pleine largeur +Toggle Theme, Basculer le thème +Theme Changed, Thème changé +Amend, Nouv. version +Document has been submitted, Document validé +Document has been cancelled, Document annulé +Document is in draft state, Document au statut brouillon +Copy to Clipboard,Copier vers le presse-papiers diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 4a6d578a9c..62b8df40b6 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -791,40 +791,27 @@ def get_build_version(): return frappe.utils.random_string(8) def get_assets_json(): + def _get_assets(): + # get merged assets.json and assets-rtl.json + assets = frappe.parse_json(frappe.read_file("assets/assets.json")) + + if assets_rtl := frappe.read_file("assets/assets-rtl.json"): + assets.update(frappe.parse_json(assets_rtl)) + + return assets + if not hasattr(frappe.local, "assets_json"): - cache = frappe.cache() - - # using .get instead of .get_value to avoid pickle.loads - try: - if not frappe.conf.developer_mode: - assets_json = cache.get("assets_json").decode('utf-8') - else: - assets_json = None - except (UnicodeDecodeError, AttributeError, ConnectionError): - assets_json = None - - if not assets_json: - # get merged assets.json and assets-rtl.json - assets_dict = frappe.parse_json( - frappe.read_file("assets/assets.json") + if not frappe.conf.developer_mode: + frappe.local.assets_json = frappe.cache().get_value( + "assets_json", + _get_assets, + shared=True, ) - assets_rtl = frappe.read_file("assets/assets-rtl.json") - if assets_rtl: - assets_dict.update( - frappe.parse_json(assets_rtl) - ) - frappe.local.assets_json = frappe.as_json(assets_dict) - # save in cache - cache.set_value("assets_json", frappe.local.assets_json, - shared=True) - - return assets_dict else: - # from cache, decode and send - frappe.local.assets_json = frappe.safe_decode(assets_json) + frappe.local.assets_json = _get_assets() - return frappe.parse_json(frappe.local.assets_json) + return frappe.local.assets_json def get_bench_relative_path(file_path): diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 0a68cf22c4..600a1fd4a9 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -40,8 +40,19 @@ def get_queues_timeout(): redis_connection = None -def enqueue(method, queue='default', timeout=None, event=None, - is_async=True, job_name=None, now=False, enqueue_after_commit=False, **kwargs): +def enqueue( + method, + queue='default', + timeout=None, + event=None, + is_async=True, + job_name=None, + now=False, + enqueue_after_commit=False, + *, + at_front=False, + **kwargs +): ''' Enqueue method to be executed using a background worker @@ -87,9 +98,8 @@ def enqueue(method, queue='default', timeout=None, event=None, "queue_args":queue_args }) return frappe.flags.enqueue_after_commit - else: - return q.enqueue_call(execute_job, timeout=timeout, - kwargs=queue_args) + + return q.enqueue_call(execute_job, timeout=timeout, kwargs=queue_args, at_front=at_front) def enqueue_doc(doctype, name=None, method=None, queue='default', timeout=300, now=False, **kwargs): @@ -224,9 +234,12 @@ def get_queue_list(queue_list=None, build_queue_name=False): queue_list = default_queue_list return [generate_qname(qtype) for qtype in queue_list] if build_queue_name else queue_list -def get_workers(queue): - '''Returns a list of Worker objects tied to a queue object''' - return Worker.all(queue=queue) +def get_workers(queue=None): + '''Returns a list of Worker objects tied to a queue object if queue is passed, else returns a list of all workers''' + if queue: + return Worker.all(queue=queue) + else: + return Worker.all(get_redis_conn()) def get_running_jobs_in_queue(queue): '''Returns a list of Jobs objects that are tied to a queue object and are currently running''' diff --git a/frappe/utils/bot.py b/frappe/utils/bot.py deleted file mode 100644 index d077847e25..0000000000 --- a/frappe/utils/bot.py +++ /dev/null @@ -1,224 +0,0 @@ -# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE - -import frappe, re, frappe.utils -from frappe.desk.notifications import get_notifications -from frappe import _ - -@frappe.whitelist() -def get_bot_reply(question): - return BotReply().get_reply(question) - -class BotParser(object): - '''Base class for bot parser''' - def __init__(self, reply, query): - self.query = query - self.reply = reply - self.tables = reply.tables - self.doctype_names = reply.doctype_names - - def has(self, *words): - '''return True if any of the words is present int the query''' - for word in words: - if re.search(r'\b{0}\b'.format(word), self.query): - return True - - def startswith(self, *words): - '''return True if the query starts with any of the given words''' - for w in words: - if self.query.startswith(w): - return True - - def strip_words(self, query, *words): - '''Remove the given words from the query''' - for word in words: - query = re.sub(r'\b{0}\b'.format(word), '', query) - - return query.strip() - - def format_list(self, data): - '''Format list as markdown''' - return _('I found these:') + ' ' + ', '.join(' [{title}](/app/Form/{doctype}/{name})'.format( - title = d.title or d.name, - doctype=self.get_doctype(), - name=d.name) for d in data) - - def get_doctype(self): - '''returns the doctype name from self.tables''' - return self.doctype_names[self.tables[0]] - -class ShowNotificationBot(BotParser): - '''Show open notifications''' - def get_reply(self): - if self.has("whatsup", "what's up", "wassup", "whats up", 'notifications', 'open tasks'): - n = get_notifications() - open_items = sorted(n.get('open_count_doctype').items()) - - if open_items: - return ("Following items need your attention:\n\n" - + "\n\n".join("{0} [{1}](/app/List/{1})".format(d[1], d[0]) - for d in open_items if d[1] > 0)) - else: - return 'Take it easy, nothing urgent needs your attention' - -class GetOpenListBot(BotParser): - '''Get list of open items''' - def get_reply(self): - if self.startswith('open', 'show open', 'list open', 'get open'): - if self.tables: - doctype = self.get_doctype() - from frappe.desk.notifications import get_notification_config - filters = get_notification_config().get('for_doctype').get(doctype, None) - if filters: - if isinstance(filters, dict): - data = frappe.get_list(doctype, filters=filters) - else: - data = [{'name':d[0], 'title':d[1]} for d in frappe.get_attr(filters)(as_list=True)] - - return ", ".join('[{title}](/app/Form/{doctype}/{name})'.format(doctype=doctype, - name=d.get('name'), title=d.get('title') or d.get('name')) for d in data) - else: - return _("Can't identify open {0}. Try something else.").format(doctype) - -class ListBot(BotParser): - def get_reply(self): - if self.query.endswith(' ' + _('list')) and self.startswith(_('list')): - self.query = _('list') + ' ' + self.query.replace(' ' + _('list'), '') - if self.startswith(_('list'), _('show')): - like = None - if ' ' + _('like') + ' ' in self.query: - self.query, like = self.query.split(' ' + _('like') + ' ') - - self.tables = self.reply.identify_tables(self.query.split(None, 1)[1]) - if self.tables: - doctype = self.get_doctype() - meta = frappe.get_meta(doctype) - fields = ['name'] - if meta.title_field: - fields.append('`{0}` as title'.format(meta.title_field)) - - filters = {} - if like: - filters={ - meta.title_field or 'name': ('like', '%' + like + '%') - } - return self.format_list(frappe.get_list(self.get_doctype(), fields=fields, filters=filters)) - -class CountBot(BotParser): - def get_reply(self): - if self.startswith('how many'): - self.tables = self.reply.identify_tables(self.query.split(None, 1)[1]) - if self.tables: - return str(frappe.db.sql('select count(*) from `tab{0}`'.format(self.get_doctype()))[0][0]) - -class FindBot(BotParser): - def get_reply(self): - if self.startswith('find', 'search'): - query = self.query.split(None, 1)[1] - - if self.has('from'): - text, table = query.split('from') - - if self.has('in'): - text, table = query.split('in') - - if table: - text = text.strip() - self.tables = self.reply.identify_tables(table.strip()) - - - if self.tables: - filters = {'name': ('like', '%{0}%'.format(text))} - or_filters = None - - title_field = frappe.get_meta(self.get_doctype()).title_field - if title_field and title_field!='name': - or_filters = {'title': ('like', '%{0}%'.format(text))} - - data = frappe.get_list(self.get_doctype(), - filters=filters, or_filters=or_filters) - if data: - return self.format_list(data) - else: - return _("Could not find {0} in {1}").format(text, self.get_doctype()) - - else: - self.out = _("Could not identify {0}").format(table) - else: - self.out = _("You can find things by asking 'find orange in customers'").format(table) - -class BotReply(object): - '''Build a reply for the bot by calling all parsers''' - def __init__(self): - self.tables = [] - - def get_reply(self, query): - self.query = query.lower() - self.setup() - self.pre_process() - - # basic replies - if self.query.split()[0] in ("hello", "hi"): - return _("Hello {0}").format(frappe.utils.get_fullname()) - - if self.query == "help": - return help_text.format(frappe.utils.get_fullname()) - - # build using parsers - replies = [] - for parser in frappe.get_hooks('bot_parsers'): - reply = None - try: - reply = frappe.get_attr(parser)(self, query).get_reply() - except frappe.PermissionError: - reply = _("Oops, you are not allowed to know that") - - if reply: - replies.append(reply) - - if replies: - return '\n\n'.join(replies) - - if not reply: - return _("Don't know, ask 'help'") - - def setup(self): - self.setup_tables() - self.identify_tables() - - def pre_process(self): - if self.query.endswith("?"): - self.query = self.query[:-1] - - if self.query in ("todo", "to do"): - self.query = "open todo" - - def setup_tables(self): - tables = frappe.get_all("DocType", {"istable": 0}) - self.all_tables = [d.name.lower() for d in tables] - self.doctype_names = {d.name.lower():d.name for d in tables} - - def identify_tables(self, query=None): - if not query: - query = self.query - self.tables = [] - for t in self.all_tables: - if t in query or t[:-1] in query: - self.tables.append(t) - - return self.tables - - - -help_text = """Hello {0}, I am a K.I.S.S Bot, not AI, so be kind. I can try answering a few questions like, - -- "todo": list my todos -- "show customers": list customers -- "show customers like giant": list customer containing giant -- "locate shirt": find where to find item "shirt" -- "open issues": find open issues, try "open sales orders" -- "how many users": count number of users -- "find asian in sales orders": find sales orders where name or title has "asian" - -have fun! -""" diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 212ae8eba6..e9f029d293 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1645,18 +1645,21 @@ def validate_json_string(string: str) -> None: raise frappe.ValidationError def get_user_info_for_avatar(user_id: str) -> Dict: - user_info = { - "email": user_id, - "image": "", - "name": user_id - } try: - user_info["email"] = frappe.get_cached_value("User", user_id, "email") - user_info["name"] = frappe.get_cached_value("User", user_id, "full_name") - user_info["image"] = frappe.get_cached_value("User", user_id, "user_image") - except Exception: - frappe.local.message_log = [] - return user_info + user = frappe.get_cached_doc("User", user_id) + return { + "email": user.email, + "image": user.user_image, + "name": user.full_name + } + + except frappe.DoesNotExistError: + frappe.clear_last_message() + return { + "email": user_id, + "image": "", + "name": user_id + } def validate_python_code(string: str, fieldname=None, is_expression: bool = True) -> None: diff --git a/frappe/utils/error.py b/frappe/utils/error.py index c38b320d98..ba0fbf1605 100644 --- a/frappe/utils/error.py +++ b/frappe/utils/error.py @@ -176,9 +176,13 @@ def collect_error_snapshots(): def clear_old_snapshots(): """Clear snapshots that are older than a month""" + from frappe.query_builder import DocType, Interval + from frappe.query_builder.functions import Now - frappe.db.sql("""delete from `tabError Snapshot` - where creation < (NOW() - INTERVAL '1' MONTH)""") + ErrorSnapshot = DocType("Error Snapshot") + frappe.db.delete(ErrorSnapshot, filters=( + ErrorSnapshot.creation < (Now() - Interval(months=1)) + )) path = get_error_snapshot_path() today = datetime.datetime.now() diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py index 1e654d7881..ce5985f619 100644 --- a/frappe/utils/file_manager.py +++ b/frappe/utils/file_manager.py @@ -6,6 +6,7 @@ import os, base64, re, json import hashlib import mimetypes import io +from frappe.query_builder.utils import DocType from frappe.utils import get_hook_method, get_files_path, random_string, encode, cstr, call_hook_method, cint from frappe import _ from frappe import conf @@ -176,7 +177,7 @@ def save_file(fname, content, dt, dn, folder=None, decode=False, is_private=0, d def get_file_data_from_hash(content_hash, is_private=0): - for name in frappe.db.sql_list("select name from `tabFile` where content_hash=%s and is_private=%s", (content_hash, is_private)): + for name in frappe.get_all("File", {"content_hash": content_hash, "is_private": is_private}, pluck="name"): b = frappe.get_doc('File', name) return {k: b.get(k) for k in frappe.get_hooks()['write_file_keys']} return False @@ -230,8 +231,7 @@ def write_file(content, fname, is_private=0): def remove_all(dt, dn, from_delete=False, delete_permanently=False): """remove all files in a transaction""" try: - for fid in frappe.db.sql_list("""select name from `tabFile` where - attached_to_doctype=%s and attached_to_name=%s""", (dt, dn)): + for fid in frappe.get_all("File", {"attached_to_doctype": dt, "attached_to_name": dn}, pluck="name"): if from_delete: # If deleting a doc, directly delete files frappe.delete_doc("File", fid, ignore_permissions=True, delete_permanently=delete_permanently) @@ -319,8 +319,10 @@ def get_file_path(file_name): if '../' in file_name: return - f = frappe.db.sql("""select file_url from `tabFile` - where name=%s or file_name=%s""", (file_name, file_name)) + File = DocType("File") + + f = frappe.qb.from_(File).where((File.name == file_name) | (File.file_name == file_name)).select(File.file_url).run() + if f: file_name = f[0][0] @@ -351,7 +353,7 @@ def get_file_name(fname, optional_suffix): # convert to unicode fname = cstr(fname) - n_records = frappe.db.sql("select name from `tabFile` where file_name=%s", fname) + n_records = frappe.get_all("File", {"file_name": fname}, pluck="name") if len(n_records) > 0 or os.path.exists(encode(get_files_path(fname))): f = fname.rsplit('.', 1) if len(f) == 1: diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 22938671a6..f2bc9946a1 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -355,7 +355,9 @@ def sync_global_search(): :return: """ while frappe.cache().llen('global_search_queue') > 0: - value = json.loads(frappe.cache().lpop('global_search_queue').decode('utf-8')) + # rpop to follow FIFO + # Last one should override all previous contents of same document + value = json.loads(frappe.cache().rpop('global_search_queue').decode('utf-8')) sync_value(value) def sync_value_in_queue(value): diff --git a/frappe/utils/goal.py b/frappe/utils/goal.py index 2d7e73eb1a..0a9116f0e5 100644 --- a/frappe/utils/goal.py +++ b/frappe/utils/goal.py @@ -1,157 +1,149 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from typing import Dict, Optional + import frappe from frappe import _ +from frappe.query_builder.functions import DateFormat, Function +from frappe.query_builder.utils import DocType +from frappe.utils.data import add_to_date, cstr, flt, now_datetime +from frappe.utils.formatters import format_value +from contextlib import suppress +def get_monthly_results( + goal_doctype: str, + goal_field: str, + date_col: str, + filters: Dict, + aggregation: str = "sum", +) -> Dict: + """Get monthly aggregation values for given field of doctype""" -def get_monthly_results(goal_doctype, goal_field, date_col, filter_str, aggregation = 'sum'): - '''Get monthly aggregation values for given field of doctype''' - # TODO: move to ORM? - if(frappe.db.db_type == 'postgres'): - month_year_format_query = '''to_char("{}", 'MM-YYYY')'''.format(date_col) - else: - month_year_format_query = 'date_format(`{}`, "%m-%Y")'.format(date_col) + Table = DocType(goal_doctype) + date_format = "%m-%Y" if frappe.db.db_type != "postgres" else "MM-YYYY" - conditions = ('where ' + filter_str) if filter_str else '' - results = frappe.db.sql('''SELECT {aggregation}(`{goal_field}`) AS {goal_field}, - {month_year_format_query} AS month_year - FROM `{table_name}` {conditions} - GROUP BY month_year''' - .format( - aggregation=aggregation, - goal_field=goal_field, - month_year_format_query=month_year_format_query, - table_name="tab" + goal_doctype, - conditions=conditions - ), as_dict=True) + return dict( + frappe.db.query.build_conditions(table=goal_doctype, filters=filters) + .select( + DateFormat(Table[date_col], date_format).as_("month_year"), + Function(aggregation, goal_field), + ) + .groupby("month_year") + .run() + ) - month_to_value_dict = {} - for d in results: - month_to_value_dict[d['month_year']] = d[goal_field] - - return month_to_value_dict @frappe.whitelist() -def get_monthly_goal_graph_data(title, doctype, docname, goal_value_field, goal_total_field, goal_history_field, - goal_doctype, goal_doctype_link, goal_field, date_field, filter_str, aggregation="sum"): - ''' - Get month-wise graph data for a doctype based on aggregation values of a field in the goal doctype +def get_monthly_goal_graph_data( + title: str, + doctype: str, + docname: str, + goal_value_field: str, + goal_total_field: str, + goal_history_field: str, + goal_doctype: str, + goal_doctype_link: str, + goal_field: str, + date_field: str, + filter_str: str = None, + aggregation: str = "sum", + filters: Optional[Dict] = None, +) -> Dict: + """ + Get month-wise graph data for a doctype based on aggregation values of a field in the goal doctype - :param title: Graph title - :param doctype: doctype of graph doc - :param docname: of the doc to set the graph in - :param goal_value_field: goal field of doctype - :param goal_total_field: current month value field of doctype - :param goal_history_field: cached history field - :param goal_doctype: doctype the goal is based on - :param goal_doctype_link: doctype link field in goal_doctype - :param goal_field: field from which the goal is calculated - :param filter_str: where clause condition - :param aggregation: a value like 'count', 'sum', 'avg' + :param title: Graph title + :param doctype: doctype of graph doc + :param docname: of the doc to set the graph in + :param goal_value_field: goal field of doctype + :param goal_total_field: current month value field of doctype + :param goal_history_field: cached history field + :param goal_doctype: doctype the goal is based on + :param goal_doctype_link: doctype link field in goal_doctype + :param goal_field: field from which the goal is calculated + :param filter_str: [DEPRECATED] where clause condition. Use filters. + :param aggregation: a value like 'count', 'sum', 'avg' + :param filters: optional filters - :return: dict of graph data - ''' + :return: dict of graph data + """ + if isinstance(filter_str, str): + frappe.throw("String filters have been deprecated. Pass Dict filters instead.", exc=DeprecationWarning) # nosemgrep - from frappe.utils.formatters import format_value - import json - - # should have atleast read perm - if not frappe.has_permission(goal_doctype): - return None - - meta = frappe.get_meta(doctype) doc = frappe.get_doc(doctype, docname) + doc.check_permission() + meta = doc.meta goal = doc.get(goal_value_field) - formatted_goal = format_value(goal, meta.get_field(goal_value_field), doc) + today_date = now_datetime().date() current_month_value = doc.get(goal_total_field) - formatted_value = format_value(current_month_value, meta.get_field(goal_total_field), doc) - - from frappe.utils import today, getdate, formatdate, add_months - current_month_year = formatdate(today(), "MM-yyyy") - + current_month_year = today_date.strftime("%m-%Y") # eg: "02-2022" + formatted_value = format_value( + current_month_value, meta.get_field(goal_total_field), doc + ) history = doc.get(goal_history_field) - try: - month_to_value_dict = json.loads(history) if history and '{' in history else None - except ValueError: - month_to_value_dict = None - if month_to_value_dict is None: - doc_filter = (goal_doctype_link + " = " + frappe.db.escape(docname)) if doctype != goal_doctype else '' - if filter_str: - doc_filter += ' and ' + filter_str if doc_filter else filter_str - month_to_value_dict = get_monthly_results(goal_doctype, goal_field, date_field, doc_filter, aggregation) + month_to_value_dict = None + if history and "{" in cstr(history): + with suppress(ValueError): + month_to_value_dict = frappe.parse_json(history) + + if month_to_value_dict is None: # nosemgrep + doc_filter = {} + with suppress(ValueError): + doc_filter = frappe.parse_json(filters or "{}") + if doctype != goal_doctype: + doc_filter[goal_doctype_link] = docname + + month_to_value_dict = get_monthly_results( + goal_doctype, goal_field, date_field, doc_filter, aggregation + ) month_to_value_dict[current_month_year] = current_month_value - months = [] - months_formatted = [] - values = [] + month_labels = [] + dataset_values = [] values_formatted = [] - for i in range(0, 12): - date_value = add_months(today(), -i) - month_value = formatdate(date_value, "MM-yyyy") - month_word = getdate(date_value).strftime('%b %y') - month_year = getdate(date_value).strftime('%B') + ', ' + getdate(date_value).strftime('%Y') - months.insert(0, month_word) - months_formatted.insert(0, month_year) - if month_value in month_to_value_dict: - val = month_to_value_dict[month_value] - else: - val = 0 - values.insert(0, val) - values_formatted.insert(0, format_value(val, meta.get_field(goal_total_field), doc)) + y_markers = {} - y_markers = [] summary_values = [ - { - 'title': _("This month"), - 'color': '#ffa00a', - 'value': formatted_value - } + {"title": _("This month"), "color": "#ffa00a", "value": formatted_value}, ] - if float(goal) > 0: - y_markers = [ - { - 'label': _("Goal"), - 'lineType': "dashed", - 'value': goal - }, - ] + if flt(goal) > 0: + formatted_goal = format_value(goal, meta.get_field(goal_value_field), doc) summary_values += [ + {"title": _("Goal"), "color": "#5e64ff", "value": formatted_goal}, { - 'title': _("Goal"), - 'color': '#5e64ff', - 'value': formatted_goal + "title": _("Completed"), + "color": "#28a745", + "value": f"{int(round(flt(current_month_value) / flt(goal) * 100))}%", }, - { - 'title': _("Completed"), - 'color': '#28a745', - 'value': str(int(round(float(current_month_value)/float(goal)*100))) + "%" - } ] + y_markers = { + "yMarkers": [{"label": _("Goal"), "lineType": "dashed", "value": flt(goal)}] + } - data = { - 'title': title, - # 'subtitle': + for i in range(12): + date_value = add_to_date(today_date, months=-i, as_datetime=True) + month_word = date_value.strftime("%b %y") # eg: "Feb 22" + month_labels.insert(0, month_word) - 'data': { - 'datasets': [ - { - 'values': values, - 'formatted': values_formatted - } - ], - 'labels': months, + month_value = date_value.strftime("%m-%Y") # eg: "02-2022" + val = month_to_value_dict.get(month_value, 0) + dataset_values.insert(0, val) + values_formatted.insert( + 0, format_value(val, meta.get_field(goal_total_field), doc) + ) + + return { + "title": title, + "data": { + "datasets": [{"values": dataset_values, "formatted": values_formatted}], + "labels": month_labels, + **y_markers, }, - - 'summary': summary_values, + "summary": summary_values, } - - if y_markers: - data["data"]["yMarkers"] = y_markers - - return data diff --git a/frappe/utils/install.py b/frappe/utils/install.py index ac26a98eb6..d197304c98 100644 --- a/frappe/utils/install.py +++ b/frappe/utils/install.py @@ -34,7 +34,7 @@ def after_install(): print_settings.save() # all roles to admin - frappe.get_doc("User", "Administrator").add_roles(*frappe.db.sql_list("""select name from tabRole""")) + frappe.get_doc("User", "Administrator").add_roles(*frappe.get_all("Role", pluck="name")) # update admin password update_password("Administrator", get_admin_password()) diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index 3702a009fb..4dd297b5b3 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -48,7 +48,8 @@ def validate_template(html): """Throws exception if there is a syntax error in the Jinja Template""" import frappe from jinja2 import TemplateSyntaxError - + if not html: + return jenv = get_jenv() try: jenv.from_string(html) diff --git a/frappe/utils/nestedset.py b/frappe/utils/nestedset.py index 2517761c45..a1fe04ccb6 100644 --- a/frappe/utils/nestedset.py +++ b/frappe/utils/nestedset.py @@ -16,6 +16,9 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.query_builder import DocType, Order +from frappe.query_builder.functions import Coalesce, Max +from frappe.query_builder.utils import DocType + class NestedSetRecursionError(frappe.ValidationError): pass class NestedSetMultipleRootsError(frappe.ValidationError): pass @@ -51,87 +54,91 @@ def update_add_node(doc, parent, parent_field): """ insert a new node """ - doctype = doc.doctype name = doc.name + Table = DocType(doctype) # get the last sibling of the parent if parent: - left, right = frappe.db.sql("select lft, rgt from `tab{0}` where name=%s for update" - .format(doctype), parent)[0] + left, right = frappe.db.get_value(doctype, {"name": parent}, ["lft", "rgt"], for_update=True) validate_loop(doc.doctype, doc.name, left, right) else: # root - right = frappe.db.sql(""" - SELECT COALESCE(MAX(rgt), 0) + 1 FROM `tab{0}` - WHERE COALESCE(`{1}`, '') = '' - """.format(doctype, parent_field))[0][0] + right = frappe.qb.from_(Table).select( + Coalesce(Max(Table.rgt), 0) + 1 + ).where(Coalesce(Table[parent_field], "") == "").run(pluck=True)[0] + right = right or 1 # update all on the right - frappe.db.sql("update `tab{0}` set rgt = rgt+2 where rgt >= %s" - .format(doctype), (right,)) - frappe.db.sql("update `tab{0}` set lft = lft+2 where lft >= %s" - .format(doctype), (right,)) + frappe.qb.update(Table).set(Table.rgt, Table.rgt + 2).where(Table.rgt >= right).run() + frappe.qb.update(Table).set(Table.lft, Table.lft + 2).where(Table.lft >= right).run() + + if frappe.qb.from_(Table).select("*").where((Table.lft == right) | (Table.rgt == right + 1)).run(): + frappe.throw(_("Nested set error. Please contact the Administrator.")) # update index of new node - if frappe.db.sql("select * from `tab{0}` where lft=%s or rgt=%s".format(doctype), (right, right+1)): - frappe.msgprint(_("Nested set error. Please contact the Administrator.")) - raise Exception - - frappe.db.sql("update `tab{0}` set lft=%s, rgt=%s where name=%s".format(doctype), - (right,right+1, name)) + frappe.qb.update(Table).set(Table.lft, right).set(Table.rgt, right + 1).where(Table.name == name).run() return right -def update_move_node(doc, parent_field): - parent = doc.get(parent_field) +def update_move_node(doc: Document, parent_field: str): + parent: str = doc.get(parent_field) + Table = DocType(doc.doctype) if parent: - new_parent = frappe.db.sql("""select lft, rgt from `tab{0}` - where name = %s for update""".format(doc.doctype), parent, as_dict=1)[0] + new_parent = frappe.qb.from_(Table).select( + Table.lft, Table.rgt + ).where(Table.name == parent).for_update().run(as_dict=True)[0] validate_loop(doc.doctype, doc.name, new_parent.lft, new_parent.rgt) # move to dark side - frappe.db.sql("""update `tab{0}` set lft = -lft, rgt = -rgt - where lft >= %s and rgt <= %s""".format(doc.doctype), (doc.lft, doc.rgt)) + frappe.qb.update(Table).set(Table.lft, - Table.lft).set(Table.rgt, - Table.rgt).where( + (Table.lft >= doc.lft) & (Table.rgt <= doc.rgt) + ).run() # shift left diff = doc.rgt - doc.lft + 1 - frappe.db.sql("""update `tab{0}` set lft = lft -%s, rgt = rgt - %s - where lft > %s""".format(doc.doctype), (diff, diff, doc.rgt)) + frappe.qb.update(Table).set(Table.lft, Table.lft - diff).set(Table.rgt, Table.rgt - diff).where( + Table.lft > doc.rgt + ).run() # shift left rgts of ancestors whose only rgts must shift - frappe.db.sql("""update `tab{0}` set rgt = rgt - %s - where lft < %s and rgt > %s""".format(doc.doctype), (diff, doc.lft, doc.rgt)) + frappe.qb.update(Table).set(Table.rgt, Table.rgt - diff).where( + (Table.lft < doc.lft) & (Table.rgt > doc.rgt) + ).run() if parent: - new_parent = frappe.db.sql("""select lft, rgt from `tab%s` - where name = %s for update""" % (doc.doctype, '%s'), parent, as_dict=1)[0] + # re-query value due to computation above + new_parent = frappe.qb.from_(Table).select( + Table.lft, Table.rgt + ).where(Table.name == parent).for_update().run(as_dict=True)[0] # set parent lft, rgt - frappe.db.sql("""update `tab{0}` set rgt = rgt + %s - where name = %s""".format(doc.doctype), (diff, parent)) + frappe.qb.update(Table).set(Table.rgt, Table.rgt + diff).where(Table.name == parent).run() # shift right at new parent - frappe.db.sql("""update `tab{0}` set lft = lft + %s, rgt = rgt + %s - where lft > %s""".format(doc.doctype), (diff, diff, new_parent.rgt)) + frappe.qb.update(Table).set(Table.lft, Table.lft + diff).set(Table.rgt, Table.rgt + diff).where( + Table.lft > new_parent.rgt + ).run() # shift right rgts of ancestors whose only rgts must shift - frappe.db.sql("""update `tab{0}` set rgt = rgt + %s - where lft < %s and rgt > %s""".format(doc.doctype), - (diff, new_parent.lft, new_parent.rgt)) - + frappe.qb.update(Table).set(Table.rgt, Table.rgt + diff).where( + (Table.lft < new_parent.lft) & (Table.rgt > new_parent.rgt) + ).run() new_diff = new_parent.rgt - doc.lft else: # new root - max_rgt = frappe.db.sql("""select max(rgt) from `tab{0}`""".format(doc.doctype))[0][0] + max_rgt = frappe.qb.from_(Table).select(Max(Table.rgt)).run(pluck=True)[0] new_diff = max_rgt + 1 - doc.lft # bring back from dark side - frappe.db.sql("""update `tab{0}` set lft = -lft + %s, rgt = -rgt + %s - where lft < 0""".format(doc.doctype), (new_diff, new_diff)) + frappe.qb.update(Table).set( + Table.lft, -Table.lft + new_diff + ).set( + Table.rgt, -Table.rgt + new_diff + ).where(Table.lft < 0).run() @frappe.whitelist() @@ -197,10 +204,10 @@ def rebuild_node(doctype, parent, left, parent_field): def validate_loop(doctype, name, lft, rgt): """check if item not an ancestor (loop)""" - if name in frappe.db.sql_list("""select name from `tab{0}` where lft <= %s and rgt >= %s""" - .format(doctype), (lft, rgt)): + if name in frappe.get_all(doctype, filters={"lft": ["<=", lft], "rgt": [">=", rgt]}, pluck="name"): frappe.throw(_("Item cannot be added to its own descendents"), NestedSetRecursionError) + class NestedSet(Document): def __setup__(self): if self.meta.get("nsm_parent_field"): @@ -232,9 +239,7 @@ class NestedSet(Document): raise def validate_if_child_exists(self): - has_children = frappe.db.sql("""select count(name) from `tab{doctype}` - where `{nsm_parent_field}`=%s""".format(doctype=self.doctype, nsm_parent_field=self.nsm_parent_field), - (self.name,))[0][0] + has_children = frappe.db.count(self.doctype, filters={self.nsm_parent_field: self.name}) if has_children: frappe.throw(_("Cannot delete {0} as it has child nodes").format(self.name), NestedSetChildExistsError) @@ -251,8 +256,7 @@ class NestedSet(Document): parent_field = self.nsm_parent_field # set old_parent for children - frappe.db.sql("update `tab{0}` set old_parent=%s where {1}=%s" - .format(self.doctype, parent_field), (newdn, newdn)) + frappe.db.set_value(self.doctype, {"old_parent": newdn}, {parent_field: newdn}, update_modified=False, for_update=False) if merge: rebuild_tree(self.doctype, parent_field) @@ -269,8 +273,7 @@ class NestedSet(Document): def validate_ledger(self, group_identifier="is_group"): if hasattr(self, group_identifier) and not bool(self.get(group_identifier)): - if frappe.db.sql("""select name from `tab{0}` where {1}=%s and docstatus!=2""" - .format(self.doctype, self.nsm_parent_field), (self.name)): + if frappe.get_all(self.doctype, {self.nsm_parent_field: self.name, "docstatus": ("!=", 2)}): frappe.throw(_("{0} {1} cannot be a leaf node as it has children").format(_(self.doctype), self.name)) def get_ancestors(self): @@ -291,10 +294,20 @@ class NestedSet(Document): def get_root_of(doctype): """Get root element of a DocType with a tree structure""" - result = frappe.db.sql("""select t1.name from `tab{0}` t1 where - (select count(*) from `tab{1}` t2 where - t2.lft < t1.lft and t2.rgt > t1.rgt) = 0 - and t1.rgt > t1.lft""".format(doctype, doctype)) + from frappe.query_builder.functions import Count + from frappe.query_builder.terms import subqry + + Table = DocType(doctype) + t1 = Table.as_("t1") + t2 = Table.as_("t2") + + subq = frappe.qb.from_(t2).select(Count("*")).where( + (t2.lft < t1.lft) & (t2.rgt > t1.rgt) + ) + result = frappe.qb.from_(t1).select(t1.name).where( + (subqry(subq) == 0) & (t1.rgt > t1.lft) + ).run() + return result[0][0] if result else None def get_ancestors_of(doctype, name, order_by="lft desc", limit=None): diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py index c40180b538..fbf272e906 100644 --- a/frappe/utils/redis_wrapper.py +++ b/frappe/utils/redis_wrapper.py @@ -138,6 +138,9 @@ class RedisWrapper(redis.Redis): def lpop(self, key): return super(RedisWrapper, self).lpop(self.make_key(key)) + def rpop(self, key): + return super(RedisWrapper, self).rpop(self.make_key(key)) + def llen(self, key): return super(RedisWrapper, self).llen(self.make_key(key)) diff --git a/frappe/utils/reset_doc.py b/frappe/utils/reset_doc.py deleted file mode 100755 index 15aff4dc6c..0000000000 --- a/frappe/utils/reset_doc.py +++ /dev/null @@ -1,151 +0,0 @@ - -import frappe -import json, os -from frappe.modules import scrub, get_module_path, utils -from frappe.custom.doctype.customize_form.customize_form import doctype_properties, docfield_properties -from frappe.custom.doctype.property_setter.property_setter import make_property_setter -from frappe.custom.doctype.custom_field.custom_field import create_custom_field -from frappe.core.page.permission_manager.permission_manager import get_standard_permissions -from frappe.permissions import setup_custom_perms -from urllib.request import urlopen - -branch = 'develop' - -def reset_all(): - for doctype in frappe.db.get_all('DocType', dict(custom=0)): - print(doctype.name) - reset_doc(doctype.name) - -def reset_doc(doctype): - ''' - doctype = name of the DocType that you want to reset - ''' - # fetch module name - module = frappe.db.get_value('DocType', doctype, 'module') - app = utils.get_module_app(module) - - # get path for doctype's json and its equivalent git url - doc_path = os.path.join(get_module_path(module), 'doctype', scrub(doctype), scrub(doctype)+'.json') - try: - git_link = '/'.join(['https://raw.githubusercontent.com/frappe',\ - app, branch, doc_path.split('apps/'+app)[1]]) - original_file = urlopen(git_link).read() - except: - print('Did not find {0} in {1}'.format(doctype, app)) - return - - # load local and original json objects - local_doc = json.loads(open(doc_path, 'r').read()) - original_doc = json.loads(original_file) - - remove_duplicate_fields(doctype) - set_property_setter(doctype, local_doc, original_doc) - make_custom_fields(doctype, local_doc, original_doc) - - with open(doc_path, 'w+') as f: - f.write(original_file) - f.close() - - setup_perms_for(doctype) - - frappe.db.commit() - -def remove_duplicate_fields(doctype): - for field in frappe.db.sql('''select fieldname, count(1) as cnt from tabDocField where parent=%s group by fieldname having cnt > 1''', doctype): - frappe.db.sql('delete from tabDocField where fieldname=%s and parent=%s limit 1', (field[0], doctype)) - print('removed duplicate {0} in {1}'.format(field[0], doctype)) - -def set_property_setter(doctype, local_doc, original_doc): - ''' compare doctype_properties and docfield_properties and create property_setter ''' - - # doctype_properties reset - for dp in doctype_properties: - # make property_setter to mimic changes made in local json - if dp in local_doc and dp not in original_doc: - make_property_setter(doctype, '', dp, local_doc[dp], doctype_properties[dp], for_doctype=True) - - local_fields = get_fields_dict(local_doc) - original_fields = get_fields_dict(original_doc) - - # iterate through field and properties of each of those field - for docfield in original_fields: - for prop in original_fields[docfield]: - # skip fields that are not in local_fields - if docfield not in local_fields: continue - - if prop in docfield_properties and prop in local_fields[docfield]\ - and original_fields[docfield][prop] != local_fields[docfield][prop]: - # make property_setter equivalent of local changes - make_property_setter(doctype, docfield, prop, local_fields[docfield][prop],\ - docfield_properties[prop]) - -def make_custom_fields(doctype, local_doc, original_doc): - ''' - check fields and create a custom field equivalent for non standard fields - ''' - local_fields, original_fields = get_fields_dict(local_doc), get_fields_dict(original_doc) - local_fields = sorted(local_fields.items(), key=lambda x: x[1]['idx']) - doctype_doc = frappe.get_doc('DocType', doctype) - - custom_docfield_properties, prev = get_custom_docfield_properties(), "" - for field, field_dict in local_fields: - df = {} - if field not in original_fields: - for prop in field_dict: - if prop in custom_docfield_properties: - df[prop] = field_dict[prop] - - df['insert_after'] = prev if prev else '' - - doctype_doc.fields = [d for d in doctype_doc.fields if d.fieldname != df['fieldname']] - doctype_doc.update_children() - - create_custom_field(doctype, df) - - # set current field as prev field for next field - prev = field - -def get_fields_dict(doc): - fields, idx = {}, 0 - for field in doc['fields']: - field['idx'] = idx - fields[field.get('fieldname')] = field - idx += 1 - - return fields - -def get_custom_docfield_properties(): - fields_meta = frappe.get_meta('Custom Field').fields - fields = {} - for d in fields_meta: - fields[d.fieldname] = d.fieldtype - - return fields - - -def setup_perms_for(doctype): - perms = frappe.get_all('DocPerm', fields='*', filters=dict(parent=doctype), order_by='idx asc') - # get default perms - try: - standard_perms = get_standard_permissions(doctype) - except (IOError, KeyError): - # no json file, doctype no longer exists! - return - - same = True - if len(standard_perms) != len(perms): - same = False - - else: - for i, p in enumerate(perms): - standard = standard_perms[i] - for fieldname in frappe.get_meta('DocPerm').get_fieldnames_with_value(): - if p.get(fieldname) != standard.get(fieldname): - same = False - break - - if not same: - break - - if not same: - setup_custom_perms(doctype) \ No newline at end of file diff --git a/frappe/utils/user.py b/frappe/utils/user.py index ca7a555c72..43d9d26ab8 100644 --- a/frappe/utils/user.py +++ b/frappe/utils/user.py @@ -1,15 +1,22 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import frappe, json -from frappe import _dict +from email.utils import formataddr +from typing import Dict, List, Optional, TYPE_CHECKING + +import frappe import frappe.share -from frappe.utils import cint +from frappe import _dict from frappe.boot import get_allowed_reports -from frappe.permissions import get_roles, get_valid_perms from frappe.core.doctype.domain_settings.domain_settings import get_active_modules +from frappe.permissions import get_roles, get_valid_perms from frappe.query_builder import DocType from frappe.query_builder.functions import Concat_ws +from frappe.query_builder import Order + +if TYPE_CHECKING: + from frappe.core.doctype.user.user import User + class UserPermissions: """ @@ -64,14 +71,14 @@ class UserPermissions: def build_doctype_map(self): """build map of special doctype properties""" + self.doctype_map = {} active_domains = frappe.get_active_domains() + all_doctypes = frappe.get_all("DocType", fields=["name", "in_create", "module", "istable", "issingle", "read_only", "restrict_to_domain"]) - self.doctype_map = {} - for r in frappe.db.sql("""select name, in_create, issingle, istable, - read_only, restrict_to_domain, module from tabDocType""", as_dict=1): - if (not r.restrict_to_domain) or (r.restrict_to_domain in active_domains): - self.doctype_map[r['name']] = r + for dt in all_doctypes: + if not dt.restrict_to_domain or (dt.restrict_to_domain in active_domains): + self.doctype_map[dt["name"]] = dt def build_perm_map(self): """build map of permissions at level 0""" @@ -150,10 +157,8 @@ class UserPermissions: self.can_write += self.in_create self.can_read += self.can_write - self.shared = frappe.db.sql_list("""select distinct share_doctype from `tabDocShare` - where `user`=%s and `read`=1""", self.name) + self.shared = frappe.get_all("DocShare", {"user": self.name, "read": 1}, distinct=True, pluck="share_doctype") self.can_read = list(set(self.can_read + self.shared)) - self.all_read += self.can_read for dt in no_list_view_link: @@ -161,11 +166,12 @@ class UserPermissions: self.can_read.remove(dt) if "System Manager" in self.get_roles(): - docs = frappe.get_all("DocType", {'allow_import': 1}) - self.can_import += [doc.name for doc in docs] - - customizations = frappe.get_all("Property Setter", fields=['doc_type'], filters={'property': 'allow_import', 'value': "1"}) - self.can_import += [custom.doc_type for custom in customizations] + self.can_import += frappe.get_all("DocType", {'allow_import': 1}, pluck="name") + self.can_import += frappe.get_all( + "Property Setter", + pluck="doc_type", + filters={"property": "allow_import", "value": "1"}, + ) frappe.cache().hset("can_import", frappe.session.user, self.can_import) @@ -186,10 +192,24 @@ class UserPermissions: return self.can_read def load_user(self): - d = frappe.db.sql("""select email, first_name, last_name, creation, - email_signature, user_type, desk_theme, language, - mute_sounds, send_me_a_copy, document_follow_notify - from tabUser where name = %s""", (self.name,), as_dict=1)[0] + d = frappe.db.get_value( + "User", + self.name, + [ + "creation", + "desk_theme", + "document_follow_notify", + "email", + "email_signature", + "first_name", + "language", + "last_name", + "mute_sounds", + "send_me_a_copy", + "user_type", + ], + as_dict=True, + ) if not self.can_read: self.build_permissions() @@ -209,142 +229,169 @@ class UserPermissions: def get_all_reports(self): return get_allowed_reports() -def get_user_fullname(user): + +def get_user_fullname(user: str) -> str: user_doctype = DocType("User") - fullname = frappe.get_value( - user_doctype, - filters={"name": user}, - fieldname=Concat_ws(" ", user_doctype.first_name, user_doctype.last_name), + return ( + frappe.get_value( + user_doctype, + filters={"name": user}, + fieldname=Concat_ws(" ", user_doctype.first_name, user_doctype.last_name), + ) + or "" ) - return fullname or '' -def get_fullname_and_avatar(user): - first_name, last_name, avatar, name = frappe.db.get_value("User", - user, ["first_name", "last_name", "user_image", "name"]) - return _dict({ - "fullname": " ".join(list(filter(None, [first_name, last_name]))), - "avatar": avatar, - "name": name - }) -def get_system_managers(only_name=False): +def get_fullname_and_avatar(user: str) -> _dict: + first_name, last_name, avatar, name = frappe.db.get_value( + "User", user, ["first_name", "last_name", "user_image", "name"] + ) + return _dict( + { + "fullname": " ".join(list(filter(None, [first_name, last_name]))), + "avatar": avatar, + "name": name, + } + ) + + +def get_system_managers(only_name: bool = False) -> List[str]: """returns all system manager's user details""" - import email.utils - system_managers = frappe.db.sql("""SELECT DISTINCT `name`, `creation`, - CONCAT_WS(' ', - CASE WHEN `first_name`= '' THEN NULL ELSE `first_name` END, - CASE WHEN `last_name`= '' THEN NULL ELSE `last_name` END - ) AS fullname - FROM `tabUser` AS p - WHERE `docstatus` < 2 - AND `enabled` = 1 - AND `name` NOT IN ({}) - AND exists - (SELECT * - FROM `tabHas Role` AS ur - WHERE ur.parent = p.name - AND ur.role='System Manager') - ORDER BY `creation` DESC""".format(", ".join(["%s"]*len(frappe.STANDARD_USERS))), - frappe.STANDARD_USERS, as_dict=True) + HasRole = DocType("Has Role") + User = DocType("User") + + if only_name: + fields = [User.name] + else: + fields = [User.full_name, User.name] + + system_managers = ( + frappe.qb.from_(User) + .join(HasRole) + .on((HasRole.parent == User.name)) + .where( + (HasRole.parenttype == "User") + & (User.enabled == 1) + & (HasRole.role == "System Manager") + & (User.docstatus < 2) + & (User.name.notin(frappe.STANDARD_USERS)) + ) + .select(*fields) + .orderby(User.creation, order=Order.desc) + .run(as_dict=True) + ) if only_name: return [p.name for p in system_managers] else: - return [email.utils.formataddr((p.fullname, p.name)) for p in system_managers] + return [formataddr((p.full_name, p.name)) for p in system_managers] -def add_role(user, role): + +def add_role(user: str, role: str) -> None: frappe.get_doc("User", user).add_roles(role) -def add_system_manager(email, first_name=None, last_name=None, send_welcome_email=False, password=None): + +def add_system_manager( + email: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + send_welcome_email: bool = False, + password: str = None, +) -> "User": # add user user = frappe.new_doc("User") - user.update({ - "name": email, - "email": email, - "enabled": 1, - "first_name": first_name or email, - "last_name": last_name, - "user_type": "System User", - "send_welcome_email": 1 if send_welcome_email else 0 - }) + user.update( + { + "name": email, + "email": email, + "enabled": 1, + "first_name": first_name or email, + "last_name": last_name, + "user_type": "System User", + "send_welcome_email": 1 if send_welcome_email else 0, + } + ) user.insert() # add roles - roles = frappe.get_all('Role', - fields=['name'], - filters={ - 'name': ['not in', ('Administrator', 'Guest', 'All')] - } + roles = frappe.get_all( + "Role", + fields=["name"], + filters={"name": ["not in", ("Administrator", "Guest", "All")]}, ) roles = [role.name for role in roles] user.add_roles(*roles) if password: from frappe.utils.password import update_password - update_password(user=user.name, pwd=password) -def get_enabled_system_users(): - # add more fields if required - return frappe.get_all('User', - fields=['email', 'language', 'name'], + update_password(user=user.name, pwd=password) + return user + + +def get_enabled_system_users() -> List[Dict]: + return frappe.get_all( + "User", + fields=["email", "language", "name"], filters={ - 'user_type': 'System User', - 'enabled': 1, - 'name': ['not in', ('Administrator', 'Guest')] - } + "user_type": "System User", + "enabled": 1, + "name": ["not in", ("Administrator", "Guest")], + }, ) -def is_website_user(): - return frappe.db.get_value('User', frappe.session.user, 'user_type') == "Website User" -def is_system_user(username): - return frappe.db.get_value("User", {"email": username, "enabled": 1, "user_type": "System User"}) +def is_website_user(username: Optional[str] = None) -> Optional[str]: + return ( + frappe.db.get_value("User", username or frappe.session.user, "user_type") + == "Website User" + ) -def get_users(): + +def is_system_user(username: Optional[str] = None) -> Optional[str]: + return frappe.db.get_value( + "User", + { + "email": username or frappe.session.user, + "enabled": 1, + "user_type": "System User", + }, + ) + + +def get_users() -> List[Dict]: from frappe.core.doctype.user.user import get_system_users + users = [] - system_managers = frappe.utils.user.get_system_managers(only_name=True) + system_managers = get_system_managers(only_name=True) + for user in get_system_users(): - users.append({ - "full_name": frappe.utils.user.get_user_fullname(user), - "email": user, - "is_system_manager": 1 if (user in system_managers) else 0 - }) + users.append( + { + "full_name": get_user_fullname(user), + "email": user, + "is_system_manager": user in system_managers, + } + ) return users -def set_last_active_to_now(user): - from frappe.utils import now_datetime - frappe.db.set_value("User", user, "last_active", now_datetime()) +def get_users_with_role(role: str) -> List[str]: + User = DocType("User") + HasRole = DocType("Has Role") -def reset_simultaneous_sessions(user_limit): - for user in frappe.db.sql("""select name, simultaneous_sessions from tabUser - where name not in ('Administrator', 'Guest') and user_type = 'System User' and enabled=1 - order by creation desc""", as_dict=1): - if user.simultaneous_sessions < user_limit: - user_limit = user_limit - user.simultaneous_sessions - else: - frappe.db.set_value("User", user.name, "simultaneous_sessions", 1) - user_limit = user_limit - 1 - -def get_link_to_reset_password(user): - link = '' - - if not cint(frappe.db.get_single_value('System Settings', 'setup_complete')): - user = frappe.get_doc("User", user) - link = user.reset_password(send_email=False) - frappe.db.commit() - - return { - 'link': link - } - -def get_users_with_role(role): - return [p[0] for p in frappe.db.sql("""SELECT DISTINCT `tabUser`.`name` - FROM `tabHas Role`, `tabUser` - WHERE `tabHas Role`.`role`=%s - AND `tabUser`.`name`!='Administrator' - AND `tabHas Role`.`parent`=`tabUser`.`name` - AND `tabUser`.`enabled`=1""", role)] + return ( + frappe.qb.from_(HasRole) + .from_(User) + .where( + (HasRole.role == role) + & (User.name != "Administrator") + & (User.enabled == 1) + & (HasRole.parent == User.name) + ) + .select(User.name) + .distinct() + .run(pluck=True) + ) diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html index d8ece09f46..4bab50c33e 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post.html +++ b/frappe/website/doctype/blog_post/templates/blog_post.html @@ -66,7 +66,7 @@ {% endif %} {% if not disable_comments %} -
{{ blog_title or _('Blog') }}
-{{ blog_introduction or '' }}
+{{ blog_title or _('Blog') }}
+{{ blog_introduction or '' }}
{{ item.title }}
+{{ subtitle }}
{%- endif -%} -{{ feature.title }}
{%- endif -%} {%- if feature.content -%} -{{ feature.content }}
+{{ frappe.utils.md_to_html(feature.content) }}
{%- endif -%}