Merge branch 'develop' into use-backticks

This commit is contained in:
Suraj Shetty 2022-04-10 07:50:53 +05:30 committed by GitHub
commit 2a994a3be4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2231 additions and 1536 deletions

View file

@ -5,6 +5,7 @@ pull_request_rules:
- and:
- author!=surajshetty3416
- author!=gavindsouza
- author!=deepeshgarg007
- or:
- base=version-13
- base=version-12

View file

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

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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');
};

View file

@ -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();

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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);
}

View file

@ -1,51 +1,58 @@
<div class="list-jobs">
{% if jobs.length %}
<table class="table table-borderless" style="table-layout: fixed;">
<thead>
<tr>
<th style="width: 20%">{{ __("Queue / Worker") }}</th>
<th>{{ __("Job") }}</th>
<th style="width: 15%">{{ __("Created") }}</th>
</tr>
</thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="worker-name">
<span class="indicator-pill no-margin {{ j.color }}"></span>
<span class="ml-2">{{ j.queue.split(".").slice(-1)[0] }}</span>
</td>
<td style="overflow: auto;">
<div>
<span class="job-name">
{{ frappe.utils.encode_tags(j.job_name) }}
</span>
</div>
{% if j.exc_info %}
{% if jobs.length %}
<table class="table table-background-jobs">
<thead>
<tr>
<th style="width: 10%">{{ __("Queue") }}</th>
<th>{{ __("Job") }}</th>
<th style="width: 10%">{{ __("Status") }}</th>
<th style="width: 15%">{{ __("Created") }}</th>
</tr>
</thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="worker-name">
{{ toTitle(j.queue.split(":").slice(-1)[0]) }}
</td>
<td>
<div>
<span class="job-name">
{{ frappe.utils.encode_tags(j.job_name) }}
</span>
</div>
{% if j.exc_info %}
<details>
<summary>{{ __("Exception") }}</summary>
<div class="exc_info">
<pre>{{ frappe.utils.encode_tags(j.exc_info) }}</pre>
</div>
{% endif %}
</td>
<td class="creation">{{ j.creation }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-background-jobs">
<img src="/assets/frappe/images/ui-states/list-empty-state.svg" alt="Empty State">
<p class="text-muted">{{ __("No pending or current jobs for this site") }}</p>
</details>
{% endif %}
</td>
<td>
<span class="indicator-pill {{ j.color }}">
{{ toTitle(j.status) }}
</span>
</td>
<td class="creation text-muted">
{{ frappe.datetime.prettyDate(j.creation) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-background-jobs">
<img
src="/assets/frappe/images/ui-states/list-empty-state.svg"
alt="Empty State"
/>
<p class="text-muted">{{ __("No jobs found on this site") }}</p>
</div>
{% endif %}
<div class="footer">
<div class="text-muted">
{{ __("Last refreshed") }}
{{ frappe.datetime.now_datetime(true).toLocaleString() }}
</div>
{% endif %}
<div class="footer row">
<div class="col-md-6 text-muted text-center text-md-left">{{ __("Last refreshed") }}
{{ frappe.datetime.now_datetime(true).toLocaleString() }}</div>
<div class="col-md-6 text-center text-md-right">
<span class="indicator-pill blue" class="mr-2">{{ __("Started") }}</span>
<span class="indicator-pill orange" class="mr-2">{{ __("Queued") }}</span>
<span class="indicator-pill red" class="mr-2">{{ __("Failed") }}</span>
<span class="indicator-pill green">{{ __("Finished") }}</span>
</div>
</div>
</div>
</div>

View file

@ -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('<div class="table-area"></div>');
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);
}
}

View file

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

View file

@ -1,5 +0,0 @@
<div class="frappe-card">
<div class="table-area">
</div>
</div>

View file

@ -0,0 +1,51 @@
{% if jobs.length %}
<table class="table table-background-jobs">
<thead>
<tr>
<th style="width: 40%">{{ __("Worker") }}</th>
<th>{{ __("Current Job") }}</th>
<th style="width: 10%">{{ __("Status") }}</th>
<th style="width: 15%">{{ __("Created") }}</th>
</tr>
</thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="worker-name">
{{ j.queue }}
</td>
<td>
<div>
<span class="job-name">
{{ frappe.utils.encode_tags(j.job_name) }}
</span>
</div>
{% if j.exc_info %}
<details>
<summary>{{ __("Exception") }}</summary>
<div class="exc_info">
<pre>{{ frappe.utils.encode_tags(j.exc_info) }}</pre>
</div>
</details>
{% endif %}
</td>
<td>
<span class="indicator-pill {{ j.color }}">{{ toTitle(j.status) }}</span>
</td>
<td class="creation text-muted">{{ frappe.datetime.prettyDate(j.creation) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="no-background-jobs">
<img src="/assets/frappe/images/ui-states/list-empty-state.svg" alt="Empty State">
<p class="text-muted">{{ __("No workers online on this site") }}</p>
</div>
{% endif %}
<div class="footer">
<div class="text-muted">
{{ __("Last refreshed") }}
{{ frappe.datetime.now_datetime(true).toLocaleString() }}
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -220,9 +220,9 @@ class SendMailContext:
def message_placeholder(self, placeholder_key):
map = {
'tracker': '<!--email open check-->',
'unsubscribe_url': '<!--unsubscribe url-->',
'cc': '<!--cc message-->',
'tracker': '<!--email_open_check-->',
'unsubscribe_url': '<!--unsubscribe_url-->',
'cc': '<!--cc_message-->',
'recipient': '<!--recipient-->',
}
return map.get(placeholder_key)

View file

@ -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 = '''<a href="<!--unsubscribe url-->"
unsubscribe_html = '''<a href="<!--unsubscribe_url-->"
target="_blank">{0}</a>'''.format(unsubscribe_message)
else:
unsubscribe_link = '''<a href="<!--unsubscribe url-->"
unsubscribe_link = '''<a href="<!--unsubscribe_url-->"
target="_blank">{0}</a>'''.format(_('Unsubscribe'))
unsubscribe_html = _("{0} to stop receiving emails of this type").format(unsubscribe_link)
html = """<div class="email-unsubscribe">
<!--cc message-->
<!--cc_message-->
<div>
{0}
</div>
</div>""".format(unsubscribe_html)
if expose_recipients == "footer":
text = "\n<!--cc message-->"
text = "\n<!--cc_message-->"
else:
text = ""
text += "\n\n{unsubscribe_message}: <!--unsubscribe url-->\n".format(unsubscribe_message=unsubscribe_message)
text += "\n\n{unsubscribe_message}: <!--unsubscribe_url-->\n".format(unsubscribe_message=unsubscribe_message)
return frappe._dict({
"html": html,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,7 +37,7 @@ frappe.ui.form.PrintView = class {
this.print_wrapper = this.page.main.empty().html(
`<div class="print-preview-wrapper"><div class="print-preview">
${frappe.render_template('print_skeleton_loading')}
<iframe class="print-format-container" width="100%" height="0" frameBorder="0" scrolling="no"">
<iframe class="print-format-container" width="100%" height="0" frameBorder="0" scrolling="no">
</iframe>
</div>
<div class="page-break-message text-muted text-center text-medium margin-top"></div>
@ -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;

View file

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

View file

@ -23,7 +23,6 @@ import './table';
import './color';
import './signature';
import './password';
import './read_only';
import './button';
import './html';
import './markdown_editor';

View file

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

View file

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

View file

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

View file

@ -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;
}
}
});
});

View file

@ -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();
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
.error-page {
margin: 3rem 0;
text-align: center;
.img-404 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,6 @@
{% if frappe.session.user != "Guest" and
(condition is not defined or (condition is defined and condition )) %}
<span class="btn btn-md btn-default reply">
<span class="btn btn-md btn-secondary-dark reply">
{{ _(cta_title) }}
<!-- Below svg is not a part of the current design. Hence it is commented.
The comment will be removed after all design changes are implemented. -->
<!-- <svg class="icon icon-sm ml-1"><use href="#icon-add" style="stroke: var(--gray-700)"></use></svg> -->
</span>
{% endif %}

View file

@ -28,7 +28,7 @@
</div>
<a class="dark-links cancel-comment hide"> {{ _("Cancel") }} </a>
<div class="btn btn-md btn-default submit-discussion pull-right mb-1">
<div class="btn btn-sm btn-default submit-discussion pull-right mb-1">
{{ _("Post") }}
</div>
</div>

View file

@ -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);
$('<div class="card-divider-dark mb-8"></div>' + 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");
};

View file

@ -9,25 +9,31 @@
<div class="discussions-header">
<span class="discussion-heading">{{ _(title) }}</span>
{% if topics | length and not single_thread %}
{% include "frappe/templates/discussions/search.html" %}
{% endif %}
{% if topics and not single_thread %}
{% include "frappe/templates/discussions/button.html" %}
{% endif %}
</div>
<div class="card-style thread-card {% if topics | length and not single_thread %} discussions-card {% endif %}
{% if not topics | length %} empty-state {% endif %}">
<div class="">
{% if topics and not single_thread %}
<div class="discussions-sidebar">
{% include "frappe/templates/discussions/search.html" %}
<div class="discussions-sidebar card-style">
{% for topic in topics %}
{% set replies = frappe.get_all("Discussion Reply", {"topic": topic.name})%}
{% include "frappe/templates/discussions/sidebar.html" %}
{% if loop.index != topics | length %}
<div class="card-divider"></div>
{% endif %}
{% endfor %}
</div>
<div class="mr-2" id="discussion-group">
<div class="hide" id="discussion-group">
{% for topic in topics %}
{% include "frappe/templates/discussions/reply_section.html" %}
{% endfor %}
@ -38,19 +44,25 @@
{% include "frappe/templates/discussions/reply_section.html" %}
{% else %}
<div class="no-discussions">
<img class="icon icon-xl" src="/assets/frappe/icons/timeless/message.svg">
<div class="discussion-heading mt-4 mb-0" style="color: inherit;"> {{ empty_state_title }} </div>
<div class="small mb-6"> {{ empty_state_subtitle }} </div>
{% if frappe.session.user == "Guest" %}
<div class="btn btn-default btn-md mt-3" id="login-from-discussion"> {{ _("Login") }} </div>
{% elif condition is defined and not condition %}
<div class="btn btn-default btn-md mt-3" id="login-from-discussion" data-redirect="{{ redirect_to }}">
{{ button_name }}
<div class="empty-state">
<div>
<img class="icon icon-xl" src="/assets/frappe/icons/timeless/message.svg">
</div>
<div class="empty-state-text">
<div class="empty-state-heading">{{ empty_state_title }}</div>
<div class="course-meta">{{ empty_state_subtitle }}</div>
</div>
<div>
{% if frappe.session.user == "Guest" %}
<div class="btn btn-default btn-md login-from-discussion"> {{ _("Login") }} </div>
{% elif condition is defined and not condition %}
<div class="btn btn-default btn-md login-from-discussion" data-redirect="{{ redirect_to }}">
{{ button_name }}
</div>
{% else %}
{% include "frappe/templates/discussions/button.html" %}
{% endif %}
</div>
{% else %}
{% include "frappe/templates/discussions/button.html" %}
{% endif %}
</div>
{% endif %}
</div>

View file

@ -1,14 +1,50 @@
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
<div class="reply-card">
<div class="reply-card" data-reply="{{ reply.name }}">
{% set member = frappe.db.get_value("User", reply.owner, ["name", "full_name", "username"], as_dict=True) %}
<div class="d-flex align-items-center small mb-2">
{% if loop.index == 1 or single_thread %}
<div class="reply-header">
{{ avatar(reply.owner) }}
{% endif %}
<a class="button-links {% if loop.index == 1 or single_thread %} ml-2 {% endif %}" {% if get_profile_url %} href="{{ get_profile_url(member.username) }}" {% endif %}>
<a class="button-links topic-author ml-4"
{% if get_profile_url %} href="{{ get_profile_url(member.username) }}" {% endif %}>
{{ member.full_name }}
</a>
<div class="ml-3 frappe-timestamp" data-timestamp="{{ reply.creation }}"> {{ frappe.utils.pretty_date(reply.creation) }} </div>
<div class="ml-2 frappe-timestamp small" data-timestamp="{{ reply.creation }}"> {{ frappe.utils.pretty_date(reply.creation) }} </div>
<div class="reply-actions hide">
<div class="submit-discussion mr-2"> {{ _("Post") }} </div>
<div class="dismiss-reply"> {{ _("Dismiss") }} </div>
</div>
</div>
<div class="reply-body">
{% if frappe.session.user == reply.owner %}
<div class="dropdown">
<svg class="icon icon-sm dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<use xlink:href="#icon-dot-horizontal"></use>
</svg>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton">
<li>
<a class="dropdown-item small" data-action="edit"> {{ _("Edit") }} </a>
</li>
{% if index != 1 %}
<li>
<a class="dropdown-item small" data-action="delete"> {{ _("Delete") }} </a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
<div class="reply-text">{{ frappe.utils.md_to_html(reply.reply) }}</div>
</div>
<div class="reply-edit-card hide">
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<textarea type="text" autocomplete="off" class="input-with-feedback form-control comment-field"
data-fieldtype="Text" data-fieldname="feedback_comments" spellcheck="false">{{ reply.reply }}</textarea>
</div>
</div>
</div>
</div>
<div class="reply-text">{{ frappe.utils.md_to_html(reply.reply) }}</div>
</div>

View file

@ -1,43 +1,52 @@
{% if topic %}
{% set replies = frappe.get_all("Discussion Reply", {"topic": topic.name},
["reply", "owner", "creation"], order_by="creation")%}
["reply", "owner", "creation", "name"], order_by="creation")%}
{% endif %}
<div class="collapse discussion-on-page" data-parent="#discussion-group"
<div class=" {% if not single_thread %} collapse {% endif %} discussion-on-page card-style" data-parent="#discussion-group"
{% if topic %} id="t{{ topic.name }}" data-topic="{{ topic.name }}" {% endif %}>
{% if not single_thread %}
<div class="btn btn-md btn-default ellipsis back">
{{ _("Back") }}
</div>
{% endif %}
<div class="reply-section-header">
{% if not single_thread %}
<div class="back-button">
<svg class="icon icon-md mr-0">
<use class="" href="#icon-left"></use>
</svg>
</div>
{% endif %}
{% if topic and topic.title %}
<div class="discussion-heading p-0">{{ topic.title }}</div>
{% endif %}
{% if topic and topic.title %}
<div class="discussion-heading p-0">{{ topic.title }}</div>
{% endif %}
</div>
{% for reply in replies %}
{% set index = loop.index %}
{% include "frappe/templates/discussions/reply_card.html" %}
{% if loop.index != replies | length %}
<div class="card-divider-dark mb-8"></div>
{% endif %}
{% endfor %}
{% if frappe.session.user == "Guest" or (condition is defined and not condition) %}
<div class="d-flex flex-column align-items-center small">
{{ _("Want to join the discussion?") }}
{% if frappe.session.user == "Guest" %}
<div class="btn btn-default btn-md mt-3 mb-3" id="login-from-discussion">{{ _("Login") }}</div>
{% elif not condition %}
<div class="btn btn-default btn-md mt-3 mb-3" id="login-from-discussion" data-redirect="{{ redirect_to }}">{{ button_name }}
<div class="empty-state">
<div>
<img class="icon icon-xl" src="/assets/frappe/icons/timeless/message.svg">
</div>
<div class="empty-state-text">
<div class="empty-state-heading">{{ _("Want to discuss?") }}</div>
<div class="course-meta">{{ _("Post it here, our mentors will help you out.") }}</div>
</div>
<div>
{% if frappe.session.user == "Guest" %}
<div class="btn btn-default btn-md login-from-discussion"> {{ _("Login") }} </div>
{% elif condition is defined and not condition %}
<div class="btn btn-default btn-md login-from-discussion" data-redirect="{{ redirect_to }}">
{{ button_name }}
</div>
{% endif %}
</div>
{% endif %}
</div>
{% else %}
{% include "frappe/templates/discussions/comment_box.html" %}
{% endif %}
</div>

View file

@ -1,9 +1,2 @@
<div class="form-group">
<div class="control-input-wrapper">
<div class="control-input">
<input type="text" autocomplete="off" class="input-with-feedback form-control search-field"
data-fieldtype="Text" data-fieldname="feedback_comments" placeholder="Search {{ title }}"
spellcheck="false"></input>
</div>
</div>
</div>
<input type="text" autocomplete="off" class="search-field" data-fieldtype="Text"
data-fieldname="feedback_comments" placeholder="Search {{ title }}" spellcheck="false"></input>

View file

@ -1,19 +1,24 @@
<div class="sidebar-parent">
<div class="sidebar-topic" data-target="#t{{ topic.name }}" data-toggle="collapse" aria-expanded="false">
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
{% set creator = frappe.db.get_value("User", topic.owner, ["name", "username", "full_name", "user_image"], as_dict=True) %}
<div class="sidebar-parent" data-target="#t{{ topic.name }}" data-toggle="collapse" aria-expanded="false">
<div class="mr-4">
{{ avatar(creator.name, size="avatar-medium") }}
</div>
<div class="flex-grow-1">
<div class="discussion-topic-title">{{ topic.title }}</div>
<div class="sidebar-info">
{% set creator = frappe.get_doc("User", topic.owner) %}
<span class="reply-author ml-0">
{{ creator.full_name }}
</span>
<span class="small d-flex">
<span class="mr-2 d-flex align-items-center">
<div class="sidebar-topic">
<svg class="icon icon-md m-0 mr-2">
<use class="" href="#icon-reply"></use>
</svg>
<div class="topic-author">{{ creator.full_name }}</div>
<div class="ml-2 frappe-timestamp small" data-timestamp="{{ topic.creation }}"> {{ frappe.utils.pretty_date(topic.creation) }} </div>
<div class="ml-auto">
<span class="d-flex align-items-center">
<img class="mr-1" src="/assets/frappe/icons/timeless/message.svg">
<span class="reply-count">{{ replies | length }}</span>
</span>
<span> {{ frappe.utils.format_date(topic.creation, "dd MMM YYYY") }} </span>
</span>
</div>
</div>
</div>
<div class="card-divider"></div>
</div>

View file

@ -18,7 +18,7 @@
<!--unsubscribe link here-->
<div class="email-pixel">
<!--email open check-->
<!--email_open_check-->
</div>
<!-- default_mail_footer -->

View file

@ -1,14 +1,18 @@
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
<div class="comment-row media my-5">
<div class="my-5 comment-row media">
<div class="comment-avatar">
{{ avatar(user_id=(comment.comment_email or comment.sender), size='avatar-medium') }}
{{ avatar(user_id=(frappe.utils.strip_html(comment.comment_email or comment.sender)), size='avatar-medium') }}
</div>
<div class="comment-content">
<div class="head mb-2">
<span class="title font-weight-bold mr-2">{{ comment.sender_full_name or comment.comment_by }}</span>
<span class="time small text-muted">{{ frappe.utils.pretty_date(comment.creation) }}</span>
<div class="mb-2 head">
<span class="mr-2 title font-weight-bold">
{{ frappe.utils.strip_html(comment.sender_full_name or comment.comment_by) | e }}
</span>
<span class="time small text-muted">
{{ frappe.utils.pretty_date(comment.creation) }}
</span>
</div>
<div class="content">{{ comment.content | markdown }}</div>
<div class="content">{{ frappe.utils.strip_html(comment.content) | markdown }}</div>
</div>
</div>

View file

@ -1,25 +1,10 @@
.thread-card {
flex-direction: column;
padding: 1rem;
}
.thread-card .form-control {
background-color: #FFFFFF;
font-size: inherit;
color: inherit;
padding: 0.75rem 1rem;
border-radius: 4px;
resize: none;
}
.modal .comment-field {
height: 300px;
resize: none;
}
.discussion-on-page .comment-field {
height: 48px;
box-shadow: inset 0px 0px 4px rgba(0, 0, 0, 0.2);
padding: 1rem;
}
.modal .cancel-comment {
@ -31,61 +16,49 @@
}
.cancel-comment {
font-size: 0.75rem;
font-size: var(--text-sm);
margin-right: 0.5rem;
cursor: pointer;
}
.no-discussions {
width: 500px;
margin: 0 auto;
text-align: center;
}
.no-discussions .button {
margin: auto;
}
.discussions-header {
margin: 2.5rem 0 1.25rem;
display: flex;
align-items: center;
}
@media (max-width: 500px) {
.discussions-header {
flex-direction: column;
align-items: inherit;
}
}
.discussions-header .button {
float: right;
}
.discussions-parent .search-field {
background-color: #E2E6E9;
.search-field {
background-image: url(/assets/frappe/icons/timeless/search.svg);
background-repeat: no-repeat;
text-indent: 1.5rem;
background-position: 1rem 0.7rem;
height: 36px;
font-size: 12px;
padding: 0.65rem 0.9rem;
background-position: 1rem 0.65rem;
font-size: var(--text-md);
padding: 0.5rem 1rem;
border: 1px solid var(--dark-border-color);
border-radius: var(--border-radius-md);
margin-right: 0.5rem;
}
.discussions-sidebar {
background-color: #F4F5F6;
padding: 0.75rem;
border-radius: 4px;
}
@media (max-width: 550px) {
.discussions-sidebar {
padding: 1rem;
@media (max-width: 500px) {
.search-field {
margin: 0.75rem 0;
}
}
.sidebar-topic {
padding: 0.75rem;
margin: 0.75rem 0;
cursor: pointer;
}
.sidebar-topic[aria-expanded="true"] {
background: #FFFFFF;
border-radius: 4px;
display: flex;
align-items: center;
}
.comment-footer {
@ -95,23 +68,46 @@
}
.reply-card {
margin-bottom: 2rem;
margin-bottom: 1.5rem;
}
.discussions-parent .collapsing {
transition: height 0s;
.reply-card .dropdown {
float: right;
}
.discussion-topic-title {
color: var(--gray-900);
color: var(--text-color);
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: 0.5rem;
}
.discussion-on-page .topic-title {
display: none;
}
.discussions-sidebar .sidebar-parent:last-child .card-divider {
display: none;
.discussion-on-page {
flex-direction: column;
padding: 1.5rem;
}
.submit-discussion {
cursor: pointer;
}
.reply-body {
background: var(--bg-color);
padding: 1rem;
border-radius: var(--border-radius);
font-size: var(--text-md);
color: var(--text-color);
}
.reply-actions {
display: flex;
align-items: center;
font-size: var(--text-sm);
margin-left: auto;
}
.reply-text h1 {
@ -130,6 +126,10 @@
font-size: 1rem;
}
.reply-text p {
margin-bottom: 0;
}
.sidebar-info {
margin-top: 0.5rem;
display: flex;
@ -139,12 +139,11 @@
.discussion-heading {
font-weight: 600;
font-size: 22px;
font-size: var(--text-3xl);
line-height: 146%;
letter-spacing: -0.0175em;
color: var(--gray-900);
margin-bottom: 1rem;
padding: 0 1rem;
color: var(--text-color);
flex-grow: 1;
}
.card-style {
@ -152,7 +151,7 @@
background: white;
border-radius: 8px;
position: relative;
border: 1px solid var(--gray-200);
box-shadow: var(--shadow-sm);
}
.discussions-card {
@ -179,48 +178,93 @@
}
}
@media (max-width: 550px) {
.back {
margin-top: 0.5rem;
margin-bottom: 1rem;
}
}
@media (min-width: 550px) {
.back {
display: none;
}
.back-button {
margin-right: 1rem;
cursor: pointer;
}
.reply-author {
display: flex;
align-items: center;
margin: 0px 8px;
font-size: 12px;
font-size: var(--text-sm);
line-height: 135%;
color: var(--gray-900);
}
.card-divider {
border-top: 1px solid var(--gray-200);
margin-bottom: 1rem;
}
.card-divider-dark {
border-top: 1px solid var(--gray-300);
margin-bottom: 1rem;
}
.empty-state {
background: var(--gray-200);
border: 1px dashed var(--gray-400);
box-sizing: border-box;
border-radius: 8px;
padding: 2.5rem;
}
.discussions-parent .btn-default {
color: var(--gray-700);
color: var(--text-color);
}
.discussions-header .btn {
float: right;
}
.empty-state {
background: var(--control-bg);
border-radius: var(--border-radius-lg);
padding: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.empty-state-text {
flex: 1;
margin-left: 1.25rem;
}
.empty-state-heading {
font-size: var(--text-xl);
color: var(--text-color);
font-weight: 600;
}
.sidebar-parent {
display: flex;
align-items: center;
padding: 1.25rem;
cursor: pointer;
}
@media (max-width: 500px) {
.sidebar-parent {
padding: 0.5rem;
}
}
@media (max-width: 400px) {
.sidebar-parent {
font-size: var(--text-sm);
}
}
.topic-author {
color: var(--text-light);
font-weight: 500;
}
.reply-section-header {
display: flex;
align-items: center;
margin-bottom: 2.5rem;
}
.reply-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.dismiss-reply {
cursor: pointer;
}
.discussions-sidebar {
flex-direction: column;
}
.card-divider {
border-top: 1px solid var(--dark-border-color);
margin-bottom: 0;
}
.reply-body .dropdown-menu {
min-width: 7rem;
}

View file

@ -83,17 +83,17 @@ class FrappeAPITestCase(unittest.TestCase):
return self._sid
def get(self, path: str, params: Optional[Dict] = None) -> TestResponse:
return make_request(target=self.TEST_CLIENT.get, args=(path, ), kwargs={"data": params})
def get(self, path: str, params: Optional[Dict] = None, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.get, args=(path, ), kwargs={"data": params, **kwargs})
def post(self, path, data) -> TestResponse:
return make_request(target=self.TEST_CLIENT.post, args=(path, ), kwargs={"data": data})
def post(self, path, data, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.post, args=(path, ), kwargs={"data": data, **kwargs})
def put(self, path, data) -> TestResponse:
return make_request(target=self.TEST_CLIENT.put, args=(path, ), kwargs={"data": data})
def put(self, path, data, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.put, args=(path, ), kwargs={"data": data, **kwargs})
def delete(self, path) -> TestResponse:
return make_request(target=self.TEST_CLIENT.delete, args=(path, ))
def delete(self, path, **kwargs) -> TestResponse:
return make_request(target=self.TEST_CLIENT.delete, args=(path, ), kwargs=kwargs)
class TestResourceAPI(FrappeAPITestCase):

View file

@ -28,6 +28,22 @@ class TestBackgroundJobs(unittest.TestCase):
fail_registry = queue.failed_job_registry
self.assertEqual(fail_registry.count, 0)
def test_enqueue_at_front(self):
kwargs = {
"method": "frappe.handler.ping",
"queue": "short",
}
# give worker something to work on first so that get_position doesn't return None
frappe.enqueue(**kwargs)
# test enqueue with at_front=True
low_priority_job = frappe.enqueue(**kwargs)
high_priority_job = frappe.enqueue(**kwargs, at_front=True)
# lesser is earlier
self.assertTrue(high_priority_job.get_position() < low_priority_job.get_position())
def fail_function():
return 1 / 0

View file

@ -312,6 +312,21 @@ class TestDB(unittest.TestCase):
frappe.db.MAX_WRITES_PER_TRANSACTION = Database.MAX_WRITES_PER_TRANSACTION
def test_transaction_write_counting(self):
note = frappe.get_doc(doctype="Note", title="transaction counting").insert()
writes = frappe.db.transaction_writes
frappe.db.set_value("Note", note.name, "content", "abc")
self.assertEqual(1, frappe.db.transaction_writes - writes)
writes = frappe.db.transaction_writes
frappe.db.sql("""
update `tabNote`
set content = 'abc'
where name = %s
""", note.name)
self.assertEqual(1, frappe.db.transaction_writes - writes)
def test_pk_collision_ignoring(self):
# note has `name` generated from title
for _ in range(3):

View file

@ -27,7 +27,7 @@ class TestEmail(unittest.TestCase):
self.assertTrue('test@example.com' in queue_recipients)
self.assertTrue('test1@example.com' in queue_recipients)
self.assertEqual(len(queue_recipients), 2)
self.assertTrue('<!--unsubscribe url-->' in email_queue[0]['message'])
self.assertTrue('<!--unsubscribe_url-->' in email_queue[0]['message'])
def test_send_after(self):
self.test_email_queue(send_after=1)

View file

@ -1,14 +1,15 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import unittest
import frappe
from frappe.utils import global_search
from frappe.test_runner import make_test_objects
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.page.setup_wizard.install_fixtures import update_global_search_doctypes
from frappe.utils import global_search, now_datetime
from frappe.test_runner import make_test_objects
import frappe.utils
class TestGlobalSearch(unittest.TestCase):
def setUp(self):
@ -17,7 +18,6 @@ class TestGlobalSearch(unittest.TestCase):
self.assertTrue('__global_search' in frappe.db.get_tables())
doctype = "Event"
global_search.reset()
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
make_property_setter(doctype, "subject", "in_global_search", 1, "Int")
make_property_setter(doctype, "event_type", "in_global_search", 1, "Int")
make_property_setter(doctype, "roles", "in_global_search", 1, "Int")
@ -42,12 +42,11 @@ class TestGlobalSearch(unittest.TestCase):
doctype='Event',
subject=text,
repeat_on='Monthly',
starts_on=frappe.utils.now_datetime())).insert()
starts_on=now_datetime())).insert()
global_search.sync_global_search()
frappe.db.commit()
def test_search(self):
self.insert_test_events()
results = global_search.search('awakens')
@ -75,7 +74,6 @@ class TestGlobalSearch(unittest.TestCase):
results = global_search.search('Monthly')
self.assertEqual(len(results), 0)
doctype = "Event"
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
make_property_setter(doctype, "repeat_on", "in_global_search", 1, "Int")
global_search.rebuild_for_doctype(doctype)
results = global_search.search('Monthly')
@ -83,7 +81,6 @@ class TestGlobalSearch(unittest.TestCase):
def test_delete_doc(self):
self.insert_test_events()
global_search.sync_global_search()
event_name = frappe.get_all('Event')[0].name
event = frappe.get_doc('Event', event_name)
test_subject = event.subject
@ -92,6 +89,7 @@ class TestGlobalSearch(unittest.TestCase):
frappe.delete_doc('Event', event_name)
global_search.sync_global_search()
frappe.db.commit()
results = global_search.search(test_subject)
self.assertTrue(all(r["name"] != event_name for r in results), msg="Deleted documents appearing in global search.")
@ -112,7 +110,7 @@ class TestGlobalSearch(unittest.TestCase):
doc = frappe.get_doc({
'doctype':'Event',
'subject': text,
'starts_on': frappe.utils.now_datetime()
'starts_on': now_datetime()
})
doc.insert()
@ -173,7 +171,7 @@ class TestGlobalSearch(unittest.TestCase):
doc = frappe.get_doc({
'doctype':'Event',
'subject': 'Lorem Ipsum',
'starts_on': frappe.utils.now_datetime(),
'starts_on': now_datetime(),
'description': case["data"]
})

View file

@ -1,33 +1,50 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import unittest
import frappe
from frappe.utils.goal import get_monthly_results, get_monthly_goal_graph_data
from frappe.test_runner import make_test_objects
import frappe.utils
from frappe.utils import format_date, today
from frappe.utils.goal import get_monthly_goal_graph_data, get_monthly_results
from frappe.tests.utils import FrappeTestCase
class TestGoal(unittest.TestCase):
class TestGoal(FrappeTestCase):
def setUp(self):
make_test_objects('Event', reset=True)
make_test_objects("Event", reset=True)
def tearDown(self):
frappe.db.delete("Event")
# make_test_objects('Event', reset=True)
frappe.db.commit()
def test_get_monthly_results(self):
'''Test monthly aggregation values of a field'''
result_dict = get_monthly_results('Event', 'subject', 'creation', "event_type='Private'", 'count')
"""Test monthly aggregation values of a field"""
result_dict = get_monthly_results(
"Event",
"subject",
"creation",
filters={"event_type": "Private"},
aggregation="count",
)
from frappe.utils import today, formatdate
self.assertEqual(result_dict.get(formatdate(today(), "MM-yyyy")), 2)
self.assertEqual(result_dict.get(format_date(today(), "MM-yyyy")), 2)
def test_get_monthly_goal_graph_data(self):
'''Test for accurate values in graph data (based on test_get_monthly_results)'''
docname = frappe.get_list('Event', filters = {"subject": ["=", "_Test Event 1"]})[0]["name"]
frappe.db.set_value('Event', docname, 'description', 1)
data = get_monthly_goal_graph_data('Test', 'Event', docname, 'description', 'description', 'description',
'Event', '', 'description', 'creation', "starts_on = '2014-01-01'", 'count')
self.assertEqual(float(data['data']['datasets'][0]['values'][-1]), 1)
"""Test for accurate values in graph data (based on test_get_monthly_results)"""
docname = frappe.get_list("Event", filters={"subject": ["=", "_Test Event 1"]})[
0
]["name"]
frappe.db.set_value("Event", docname, "description", 1)
data = get_monthly_goal_graph_data(
"Test",
"Event",
docname,
"description",
"description",
"description",
"Event",
"",
"description",
"creation",
filters={"starts_on": "2014-01-01"},
aggregation="count",
)
self.assertEqual(float(data["data"]["datasets"][0]["values"][-1]), 1)

View file

@ -35,6 +35,17 @@ class TestNaming(unittest.TestCase):
title2 = append_number_if_name_exists('Note', 'Test', 'title', '_')
self.assertEqual(title2, 'Test_1')
def test_field_autoname_name_sync(self):
country = frappe.get_last_doc("Country")
original_name = country.name
country.country_name = "Not a country"
country.save()
country.reload()
self.assertEqual(country.name, original_name)
self.assertEqual(country.name, country.country_name)
def test_format_autoname(self):
'''
Test if braced params are replaced in format autoname

View file

@ -3,7 +3,7 @@ from typing import Callable
import frappe
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Coalesce, GroupConcat, Match, CombineDatetime
from frappe.query_builder.functions import Coalesce, GroupConcat, Match, CombineDatetime, Cast_
from frappe.query_builder.utils import db_type_is
from frappe.query_builder import Case
@ -53,6 +53,11 @@ class TestCustomFunctionsMariaDB(unittest.TestCase):
select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp"))
self.assertIn("timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`) `timestamp`", str(select_query).lower())
def test_cast(self):
note = frappe.qb.DocType("Note")
self.assertEqual("CONCAT(`tabnote`.`name`, '')", Cast_(note.name, "varchar"))
self.assertEqual("CAST(`tabnote`.`name` AS INTEGER)", Cast_(note.name, "integer"))
@run_only_if(db_type_is.POSTGRES)
class TestCustomFunctionsPostgres(unittest.TestCase):
@ -97,6 +102,11 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp"))
self.assertIn('"tabnote"."posting_date"+"tabnote"."posting_time" "timestamp"', str(select_query).lower())
def test_cast(self):
note = frappe.qb.DocType("Note")
self.assertEqual("CAST(`tabnote`.`name` AS VARCHAR)", Cast_(note.name, "varchar"))
self.assertEqual("CAST(`tabnote`.`name` AS INTEGER)", Cast_(note.name, "integer"))
class TestBuilderBase(object):
def test_adding_tabs(self):

View file

@ -36,7 +36,18 @@ class TestTranslate(unittest.TestCase):
def test_extract_message_from_file(self):
data = frappe.translate.get_messages_from_file(translation_string_file)
self.assertListEqual(data, expected_output)
exp_filename = "apps/frappe/frappe/tests/translation_test_file.txt"
self.assertEqual(len(data), len(expected_output),
msg=f"Mismatched output:\nExpected: {expected_output}\nFound: {data}")
for extracted, expected in zip(data, expected_output):
ext_filename, ext_message, ext_context, ext_line = extracted
exp_message, exp_context, exp_line = expected
self.assertEqual(ext_filename, exp_filename)
self.assertEqual(ext_message, exp_message)
self.assertEqual(ext_context, exp_context)
self.assertEqual(ext_line, exp_line)
def test_translation_with_context(self):
try:
@ -107,13 +118,16 @@ class TestTranslate(unittest.TestCase):
expected_output = [
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', None, 4),
('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 6),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Submit', 'Some DocType', 8),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 15),
('apps/frappe/frappe/tests/translation_test_file.txt', 'Submit', 'Some DocType', 17),
('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 19),
('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 21)
('Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),
('Warning: Unable to find {0} in any table related to {1}', None, 4),
("You don't have any messages yet.", None, 6),
('Submit', 'Some DocType', 8),
('Warning: Unable to find {0} in any table related to {1}', 'This is some context', 15),
('Submit', 'Some DocType', 17),
("You don't have any messages yet.", None, 19),
("You don't have any messages yet.", None, 21),
("Long string that needs its own line because of black formatting.", None, 24),
("Long string with", "context", 28),
("Long string with", "context on newline", 32),
]

View file

@ -18,4 +18,18 @@ _('Submit', context="Some DocType")
_("""You don't have any messages yet.""")
_('''You don't have any messages yet.''')
_('''You don't have any messages yet.''')
// allow newline in beginning
_(
"""Long string that needs its own line because of black formatting."""
).format("blah")
_(
"Long string with", context="context"
).format("blah")
_(
"Long string with",
context="context on newline"
).format("blah")

View file

@ -203,39 +203,40 @@ def create_data_for_discussions():
def create_web_page(title, route, single_thread):
web_page = frappe.db.exists("Web Page", {"route": route})
if not web_page:
web_page = frappe.get_doc({
if web_page:
return web_page
web_page = frappe.get_doc({
"doctype": "Web Page",
"title": title,
"route": route,
"published": True
})
web_page.save()
web_page.save()
web_page.append("page_blocks", {
"web_template": "Discussions",
"web_template_values": frappe.as_json({
"title": "Discussions",
"cta_title": "New Discussion",
"docname": web_page.name,
"single_thread": single_thread
})
web_page.append("page_blocks", {
"web_template": "Discussions",
"web_template_values": frappe.as_json({
"title": "Discussions",
"cta_title": "New Discussion",
"docname": web_page.name,
"single_thread": single_thread
})
web_page.save()
})
web_page.save()
return web_page
return web_page.name
def create_topic_and_reply(web_page):
topic = frappe.db.exists("Discussion Topic",{
"reference_doctype": "Web Page",
"reference_docname": web_page.name
"reference_docname": web_page
})
if not topic:
topic = frappe.get_doc({
"doctype": "Discussion Topic",
"reference_doctype": "Web Page",
"reference_docname": web_page.name,
"reference_docname": web_page,
"title": "Test Topic"
})
topic.save()
@ -274,7 +275,6 @@ def update_child_table(name):
doc.save()
@frappe.whitelist()
def insert_doctype_with_child_table_record(name):
if frappe.db.get_all(name, {'title': 'Test Grid Search'}):

View file

@ -23,6 +23,35 @@ from frappe.utils import get_bench_path, is_html, strip, strip_html_tags
from frappe.query_builder import Field, DocType
from pypika.terms import PseudoColumn
TRANSLATE_PATTERN = re.compile(
r"_\([\s\n]*" # starts with literal `_(`, ignore following whitespace/newlines
# BEGIN: message search
r"([\"']{,3})" # start of message string identifier - allows: ', ", """, '''; 1st capture group
r"(?P<message>((?!\1).)*)" # Keep matching until string closing identifier is met which is same as 1st capture group
r"\1" # match exact string closing identifier
# END: message search
# BEGIN: python context search
r"([\s\n]*,[\s\n]*context\s*=\s*" # capture `context=` with ignoring whitespace
r"([\"'])" # start of context string identifier; 5th capture group
r"(?P<py_context>((?!\5).)*)" # capture context string till closing id is found
r"\5" # match context string closure
r")?" # match 0 or 1 context strings
# END: python context search
# BEGIN: JS context search
r"(\s*,\s*(.)*?\s*(,\s*" # skip message format replacements: ["format", ...] | null | []
r"([\"'])" # start of context string; 11th capture group
r"(?P<js_context>((?!\11).)*)" # capture context string till closing id is found
r"\11" # match context string closure
r")*"
r")*" # match one or more context string
# END: JS context search
r"[\s\n]*\)" # Closing function call ignore leading whitespace/newlines
)
def get_language(lang_list: List = None) -> str:
"""Set `frappe.local.lang` from HTTP headers at beginning of request
@ -651,9 +680,8 @@ def extract_messages_from_code(code):
frappe.clear_last_message()
messages = []
pattern = r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,\s*(.)*?\s*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)"
for m in re.compile(pattern).finditer(code):
for m in TRANSLATE_PATTERN.finditer(code):
message = m.group('message')
context = m.group('py_context') or m.group('js_context')
pos = m.start()

View file

@ -218,7 +218,7 @@ Route,Route,
Sales Manager,Responsable des Ventes,
Sales Master Manager,Directeur des Ventes,
Sales User,Chargé de Ventes,
Salutation,Salutations,
Salutation,Civilité,
Sample,Échantillon,
Saturday,Samedi,
Saved,Enregistré,
@ -246,7 +246,7 @@ Start Import,Démarrer l'import,
State,Etat,
Stopped,Arrêté,
Subject,Sujet,
Submit,Soumettre,
Submit,Valider,
Successful,Réussi,
Summary,Résumé,
Sunday,Dimanche,
@ -293,7 +293,7 @@ old_parent,grand_parent,
(Ctrl + G),(Ctrl + G),
** Failed: {0} to {1}: {2},** Échec: {0} à {1}: {2},
**Currency** Master,Données de Base **Devise**,
0 - Draft; 1 - Submitted; 2 - Cancelled,0 - Brouillon; 1 - Soumis; 2 - Annulé,
0 - Draft; 1 - Submitted; 2 - Cancelled,0 - Brouillon; 1 - Validé; 2 - Annulé,
0 is highest,0 est le plus élevé,
1 Currency = [?] Fraction\nFor e.g. 1 USD = 100 Cent,1 Devise = [?] Fraction \nE.g. 1 USD = 100 centimes,
1 comment,1 commentaire,
@ -377,7 +377,7 @@ Align Labels to the Right,Alignez les Étiquettes à Droite,
Align Value,Aligner la Valeur,
All Images attached to Website Slideshow should be public,Toutes les images jointes au diaporama du site Web doivent être publiques,
All customizations will be removed. Please confirm.,Toutes les personnalisations seront supprimées. Veuillez confirmer.,
"All possible Workflow States and roles of the workflow. Docstatus Options: 0 is""Saved"", 1 is ""Submitted"" and 2 is ""Cancelled""","Tous les États et Rôles possibles du Flux de Travail. Options de Statut du Document : 0 est ""Enregistré"", 1 est ""Soumis"" et 2 est ""Annulé""",
"All possible Workflow States and roles of the workflow. Docstatus Options: 0 is""Saved"", 1 is ""Submitted"" and 2 is ""Cancelled""","Tous les États et Rôles possibles du Flux de Travail. Options de Statut du Document : 0 est ""Enregistré"", 1 est ""Validé"" et 2 est ""Annulé""",
All-uppercase is almost as easy to guess as all-lowercase.,Tout en majuscules est presque aussi facile à deviner que tout en minuscules.,
Allocated To,Attribué à,
Allow,Autoriser,
@ -404,7 +404,7 @@ Allow Self Approval,Autoriser l&#39;auto-approbation,
Allow approval for creator of the document,Autoriser l'approbation par le créateur du document,
Allow events in timeline,Autoriser les événements dans la chronologie,
Allow in Quick Entry,Autoriser dans les entrées rapides,
Allow on Submit,Autoriser à la Soumission,
Allow on Submit,Autoriser à la Validation,
Allow only one session per user,Autoriser une seule session par utilisateur,
Allow page break inside tables,Autoriser les sauts de page dans les tables,
Allow saving if mandatory fields are not filled,Autoriser l&#39;enregistrement si les champs obligatoires ne sont pas remplis,
@ -594,7 +594,7 @@ Cancelled Document restored as Draft,Le document annulé a été restauré en ta
Cancelling,Annulation,
Cancelling {0},Annulation de {0},
Cannot Remove,Ne peut être retiré,
Cannot cancel before submitting. See Transition {0},Impossible d'annuler avant de soumettre. Voir Transition {0},
Cannot cancel before submitting. See Transition {0},Impossible d'annuler avant de valider. Voir Transition {0},
Cannot change docstatus from 0 to 2,Impossible de changer le statut du document de 0 à 2,
Cannot change docstatus from 1 to 0,Impossible de changer le statut du document de 1 à 0,
Cannot change header content,Impossible de changer le contenu de l&#39;en-tête,
@ -627,7 +627,7 @@ Card Details,Détails de la carte,
Categorize blog posts.,Catégoriser les posts de blog.,
Category Description,Description de la Catégorie,
Cent,Centime,
"Certain documents, like an Invoice, should not be changed once final. The final state for such documents is called Submitted. You can restrict which roles can Submit.","Certains documents, comme une Facture, ne devraient pas être modifiés une fois finalisés. L'état final de ces documents est appelée Soumis. Vous pouvez limiter les rôles pouvant Soumettre.",
"Certain documents, like an Invoice, should not be changed once final. The final state for such documents is called Submitted. You can restrict which roles can Submit.","Certains documents, comme une Facture, ne devraient pas être modifiés une fois finalisés. L'état final de ces documents est appelée Validé. Vous pouvez limiter les rôles pouvant Valider.",
Chain Integrity,Intégrité de la chaîne,
Chaining Hash,Hachage de chaînage,
Change Label (via Custom Translation),Modifier le libellé (via Traduction Personnalisée ),
@ -786,6 +786,7 @@ Custom Sidebar Menu,Barre Latérale Personnalisée,
Custom Translations,Traductions Personnalisées,
Customization,Personnalisation,
Customizations Reset,Réinitialiser les Personnalisations,
Reset Customizations,Réinitialiser les Personnalisations,
Customizations for <b>{0}</b> exported to:<br>{1},Personnalisations pour <b>{0}</b> exportées vers: <br> {1},
Customize Form,Personnaliser le formulaire,
Customize Form Field,Personnaliser un Champ de Formulaire,
@ -895,7 +896,7 @@ DocType <b>{0}</b> provided for the field <b>{1}</b> 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, despaces 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&#39;est pas importable,
Document Type is not submittable,Le type de document n&#39;est pas soumis,
Document Type is not submittable,Le type de document n&#39;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&#39;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 sappliquent 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, cest à 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 nest pas autorisé dactiver Autoriser à la Soumission pour les champs standards,
Row {0}: Not allowed to enable Allow on Submit for standard fields,Ligne {0} : Il nest pas autorisé dactiver 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 &#39;{0}&#39;,
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&#39;abonnement,
Subsidiary,Filiale,
Success Action,Action de succès,
@ -2783,7 +2784,7 @@ You are not permitted to view the newsletter.,Vous n&#39;ê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&#39;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&#39;enregistrement,
After Save (Submitted Document),Après l&#39;enregistrement (document soumis),
After Submit,Après soumettre,
After Save (Submitted Document),Après l&#39;enregistrement (document valider),
After Submit,Après validation,
Aggregate Function Based On,Fonction d&#39;agrégation basée sur,
Aggregate Function field is required to create a dashboard chart,Le champ Fonction d&#39;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&#39;annuler,
Before Delete,Avant de supprimer,
Before Insert,Avant l&#39;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&#39;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&#39;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&#39;e-mail de notification de répétition automatique, activez {1} dans Paramètres d&#39;impression",
{0}: Fieldname cannot be one of {1},{0}: le nom de champ ne peut pas être l&#39;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&#39;est modifiable qu
{0}: Other permission rules may also apply,{0}: d&#39;autres règles d&#39;autorisation peuvent également s&#39;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&#39;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&#39;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&#39;enregistrement soumis ne peut pas être supprimé. Vous devez d&#39;abord {2} l&#39;annuler {3}.,
{0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.,{0} {1}: l&#39;enregistrement validé ne peut pas être supprimé. Vous devez d&#39;abord {2} l&#39;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&#39;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&#39;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&#39;avez pas encore ajouté de tableaux de bord ou de cartes numériques.,
@ -4509,7 +4510,7 @@ Oops,Oups,
Skip Step,Passer l&#39;é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&#39;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&#39;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&#39;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&#39;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

Can't render this file because it has a wrong number of fields in line 4705.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -66,7 +66,7 @@
{% endif %}
{% if not disable_comments %}
<div class="my-5 blog-comments">
<div class="blog-comments">
{% include 'templates/includes/comments/comments.html' %}
</div>
{% endif %}

View file

@ -8,8 +8,8 @@
<div class="col-md-8">
<div class="hero">
<div class="hero-content">
<h1 class="hero-title">{{ blog_title or _('Blog') }}</h1>
<p class="hero-subtitle mb-0">{{ blog_introduction or '' }}</p>
<h1>{{ blog_title or _('Blog') }}</h1>
<p>{{ blog_introduction or '' }}</p>
</div>
</div>
</div>

View file

@ -117,6 +117,34 @@ class TestBlogPost(unittest.TestCase):
frappe.flags.force_website_cache = True
def test_spam_comments(self):
# Make a temporary Blog Post (and a Blog Category)
blog = make_test_blog('Test Spam Comment')
# Create a spam comment
frappe.get_doc(
doctype="Comment",
comment_type="Comment",
reference_doctype="Blog Post",
reference_name=blog.name,
comment_email="<a href=\"https://example.com/spam/\">spam</a>",
comment_by="<a href=\"https://example.com/spam/\">spam</a>",
published=1,
content="More spam content. <a href=\"https://example.com/spam/\">spam</a> with link.",
).insert()
# Visit the blog post page
set_request(path=blog.route)
blog_page_response = get_response()
blog_page_html = frappe.safe_decode(blog_page_response.get_data())
self.assertNotIn('<a href="https://example.com/spam/">spam</a>', blog_page_html)
self.assertIn("More spam content. spam with link.", blog_page_html)
# Cleanup
frappe.delete_doc("Blog Post", blog.name)
frappe.delete_doc("Blog Category", blog.blog_category)
def scrub(text):
return WebsiteGenerator.scrub(None, text)

View file

@ -1,12 +1,21 @@
# Copyright (c) 2021, FOSS United and contributors
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class DiscussionReply(Document):
def after_insert(self):
def on_update(self):
frappe.publish_realtime(
event="update_message",
message = {
"reply": frappe.utils.md_to_html(self.reply),
"reply_name": self.name
},
after_commit=True)
def after_insert(self):
replies = frappe.db.count("Discussion Reply", {"topic": self.topic})
topic_info = frappe.get_all("Discussion Topic",
{"name": self.topic},
@ -37,6 +46,19 @@ class DiscussionReply(Document):
"template": template,
"topic_info": topic_info[0],
"sidebar": sidebar,
"new_topic_template": new_topic_template
"new_topic_template": new_topic_template,
"reply_owner": self.owner
},
after_commit=True)
def after_delete(self):
frappe.publish_realtime(
event="delete_message",
message = {
"reply_name": self.name
},
after_commit=True)
@frappe.whitelist()
def delete_message(reply_name):
frappe.delete_doc("Discussion Reply", reply_name, ignore_permissions=True)

View file

@ -8,7 +8,14 @@ class DiscussionTopic(Document):
pass
@frappe.whitelist()
def submit_discussion(doctype, docname, reply, title, topic_name=None):
def submit_discussion(doctype, docname, reply, title, topic_name=None, reply_name=None):
if reply_name:
doc = frappe.get_doc("Discussion Reply", reply_name)
doc.reply = reply
doc.save(ignore_permissions=True)
return
if topic_name:
save_message(reply, topic_name)
return topic_name

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