Merge branch 'develop' of github.com:frappe/frappe into refactor-file

This commit is contained in:
Gavin D'souza 2022-03-24 21:38:21 +05:30
commit 47cf46cd49
153 changed files with 3124 additions and 1121 deletions

View file

@ -0,0 +1,59 @@
export default {
name: "Child Table Doctype 1",
actions: [],
custom: 1,
autoname: "format: Test-{####}",
creation: "2022-02-09 20:15:21.242213",
doctype: "DocType",
editable_grid: 1,
engine: "InnoDB",
fields: [
{
fieldname: "data",
fieldtype: "Data",
in_list_view: 1,
label: "Data"
},
{
fieldname: "barcode",
fieldtype: "Barcode",
in_list_view: 1,
label: "Barcode"
},
{
fieldname: "check",
fieldtype: "Check",
in_list_view: 1,
label: "Check"
},
{
fieldname: "rating",
fieldtype: "Rating",
in_list_view: 1,
label: "Rating"
},
{
fieldname: "duration",
fieldtype: "Duration",
in_list_view: 1,
label: "Duration"
},
{
fieldname: "date",
fieldtype: "Date",
in_list_view: 1,
label: "Date"
}
],
links: [],
istable: 1,
modified: "2022-02-10 12:03:12.603763",
modified_by: "Administrator",
module: "Custom",
naming_rule: "By fieldname",
owner: "Administrator",
permissions: [],
sort_field: 'modified',
sort_order: 'ASC',
track_changes: 1
};

View file

@ -20,6 +20,12 @@ export default {
label: "Child Table",
options: "Child Table Doctype",
reqd: 1
},
{
fieldname: "child_table_1",
fieldtype: "Table",
label: "Child Table 1",
options: "Child Table Doctype 1"
}
],
links: [],

View file

@ -1,5 +1,6 @@
import doctype_with_child_table from '../fixtures/doctype_with_child_table';
import child_table_doctype from '../fixtures/child_table_doctype';
import child_table_doctype_1 from '../fixtures/child_table_doctype_1';
import doctype_to_link from '../fixtures/doctype_to_link';
const doctype_to_link_name = doctype_to_link.name;
const child_table_doctype_name = child_table_doctype.name;
@ -9,6 +10,7 @@ context('Dashboard links', () => {
cy.visit('/login');
cy.login();
cy.insert_doc('DocType', child_table_doctype, true);
cy.insert_doc('DocType', child_table_doctype_1, true);
cy.insert_doc('DocType', doctype_with_child_table, true);
cy.insert_doc('DocType', doctype_to_link, true);
return cy.window().its('frappe').then(frappe => {

View file

@ -0,0 +1,107 @@
import doctype_with_child_table from '../fixtures/doctype_with_child_table';
import child_table_doctype from '../fixtures/child_table_doctype';
import child_table_doctype_1 from '../fixtures/child_table_doctype_1';
const doctype_with_child_table_name = doctype_with_child_table.name;
context('Grid Search', () => {
before(() => {
cy.visit('/login');
cy.login();
cy.visit('/app/website');
cy.insert_doc('DocType', child_table_doctype, true);
cy.insert_doc('DocType', child_table_doctype_1, true);
cy.insert_doc('DocType', doctype_with_child_table, true);
return cy.window().its('frappe').then(frappe => {
return frappe.xcall("frappe.tests.ui_test_helpers.insert_doctype_with_child_table_record", {
name: doctype_with_child_table_name
});
});
});
it('Test search row visibility', () => {
cy.window().its('frappe').then(frappe => {
frappe.model.user_settings.save('Doctype With Child Table', 'GridView', {
'Child Table Doctype 1': [
{'fieldname': 'data', 'columns': 2},
{'fieldname': 'barcode', 'columns': 1},
{'fieldname': 'check', 'columns': 1},
{'fieldname': 'rating', 'columns': 2},
{'fieldname': 'duration', 'columns': 2},
{'fieldname': 'date', 'columns': 2}
]
});
});
cy.visit(`/app/doctype-with-child-table/Test Grid Search`);
cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table');
cy.get('@table').find('.grid-row-check:last').click();
cy.get('@table').find('.grid-footer').contains('Delete').click();
cy.get('.grid-heading-row .grid-row .search').should('not.exist');
});
it('test search field for different fieldtypes', () => {
cy.visit(`/app/doctype-with-child-table/Test Grid Search`);
cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table');
// Index Column
cy.get('@table').find('.grid-heading-row .row-index.search input').type('3');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2);
cy.get('@table').find('.grid-heading-row .row-index.search input').clear();
// Data Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('Data');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 1);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').clear();
// Barcode Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('092');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').clear();
// Check Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('1');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 9);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear();
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('0');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 11);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear();
// Rating Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').type('3');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').clear();
// Duration Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('3d');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').clear();
// Date Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('2022');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4);
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').clear();
});
it('test with multiple filter', () => {
cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table');
// Data Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('a');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 10);
// Barcode Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('0');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 8);
// Duration Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('d');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 5);
// Date Column
cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('02-');
cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2);
});
});

View file

@ -31,5 +31,8 @@ context('List Paging', () => {
cy.get('.list-paging-area .btn-group .btn-paging[data-value="500"]').click();
cy.get('.list-paging-area .list-count').should('contain.text', '500 of');
cy.get('.list-paging-area .btn-more').click();
cy.get('.list-paging-area .list-count').should('contain.text', '1000 of');
});
});

View file

@ -7,8 +7,8 @@ context('Web Form', () => {
cy.visit('/update-profile');
cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
cy.get('.web-form-actions .btn-primary').click();
cy.wait(500);
cy.get('.modal.show > .modal-dialog').should('be.visible');
cy.wait(5000);
cy.url().should('include', '/me');
});
it('Navigate and Submit a MultiStep WebForm', () => {
@ -16,14 +16,12 @@ context('Web Form', () => {
cy.visit('/update-profile-duplicate');
cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
cy.get('.btn-next').should('be.visible');
cy.get('.web-form-footer .btn-primary').should('not.be.visible');
cy.get('.btn-next').click();
cy.get('.btn-previous').should('be.visible');
cy.get('.btn-next').should('not.be.visible');
cy.get('.web-form-footer .btn-primary').should('be.visible');
cy.get('.web-form-actions .btn-primary').click();
cy.wait(500);
cy.get('.modal.show > .modal-dialog').should('be.visible');
cy.wait(5000);
cy.url().should('include', '/me');
});
});
});

View file

@ -286,7 +286,7 @@ function get_watch_config() {
notify_redis({ error });
} else {
let {
assets_json,
new_assets_json,
prev_assets_json
} = await write_assets_json(result.metafile);
@ -294,7 +294,7 @@ function get_watch_config() {
if (prev_assets_json) {
changed_files = get_rebuilt_assets(
prev_assets_json,
assets_json
new_assets_json
);
let timestamp = new Date().toLocaleTimeString();
@ -384,6 +384,7 @@ let prev_assets_json;
let curr_assets_json;
async function write_assets_json(metafile) {
let rtl = false;
prev_assets_json = curr_assets_json;
let out = {};
for (let output in metafile.outputs) {
@ -392,13 +393,14 @@ async function write_assets_json(metafile) {
if (info.entryPoint) {
let key = path.basename(info.entryPoint);
if (key.endsWith('.css') && asset_path.includes('/css-rtl/')) {
rtl = true;
key = `rtl_${key}`;
}
out[key] = asset_path;
}
}
let assets_json_path = path.resolve(assets_path, "assets.json");
let assets_json_path = path.resolve(assets_path, `assets${rtl?'-rtl':''}.json`);
let assets_json;
try {
assets_json = await fs.promises.readFile(assets_json_path, "utf-8");
@ -407,21 +409,21 @@ async function write_assets_json(metafile) {
}
assets_json = JSON.parse(assets_json);
// update with new values
assets_json = Object.assign({}, assets_json, out);
curr_assets_json = assets_json;
let new_assets_json = Object.assign({}, assets_json, out);
curr_assets_json = new_assets_json;
await fs.promises.writeFile(
assets_json_path,
JSON.stringify(assets_json, null, 4)
JSON.stringify(new_assets_json, null, 4)
);
await update_assets_json_in_cache(assets_json);
await update_assets_json_in_cache();
return {
assets_json,
new_assets_json,
prev_assets_json
};
}
function update_assets_json_in_cache(assets_json) {
function update_assets_json_in_cache() {
// update assets_json cache in redis, so that it can be read directly by python
return new Promise(resolve => {
let client = get_redis_subscriber("redis_cache");
@ -429,7 +431,7 @@ function update_assets_json_in_cache(assets_json) {
client.on("error", _ => {
log_warn("Cannot connect to redis_cache to update assets_json");
});
client.set("assets_json", JSON.stringify(assets_json), err => {
client.del("assets_json", err => {
client.unref();
resolve();
});

View file

@ -36,6 +36,7 @@ from frappe.query_builder import (
patch_query_execute,
patch_query_aggregation,
)
from frappe.utils.data import cstr
__version__ = '14.0.0-dev'
@ -215,6 +216,7 @@ def init(site, sites_path=None, new_site=False):
local.cache = {}
local.document_cache = {}
local.meta_cache = {}
local.autoincremented_status_map = {site: -1}
local.form_dict = _dict()
local.session = _dict()
local.dev_server = _dev_server
@ -858,8 +860,7 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args, **kwargs):
if args and len(args) > 1 and isinstance(args[1], str):
key = get_document_cache_key(args[0], args[1])
if key := can_cache_doc(args):
# local cache
doc = local.document_cache.get(key)
if doc:
@ -877,8 +878,24 @@ def get_cached_doc(*args, **kwargs):
return doc
def can_cache_doc(args):
"""
Determine if document should be cached based on get_doc params.
Returns cache key if doc can be cached, None otherwise.
"""
if not args:
return
doctype = args[0]
name = doctype if len(args) == 1 else args[1]
# Only cache if both doctype and name are strings
if isinstance(doctype, str) and isinstance(name, str):
return get_document_cache_key(doctype, name)
def get_document_cache_key(doctype, name):
return '{0}::{1}'.format(doctype, name)
return f'{doctype}::{name}'
def clear_document_cache(doctype, name):
cache().hdel("last_modified", doctype)
@ -919,8 +936,7 @@ def get_doc(*args, **kwargs):
doc = frappe.model.document.get_doc(*args, **kwargs)
# set in cache
if args and len(args) > 1:
key = get_document_cache_key(args[0], args[1])
if key := can_cache_doc(args):
local.document_cache[key] = doc
cache().hset('document_cache', key, doc.as_dict())
@ -970,8 +986,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa
def delete_doc_if_exists(doctype, name, force=0):
"""Delete document if exists."""
if db.exists(doctype, name):
delete_doc(doctype, name, force=force)
delete_doc(doctype, name, force=force, ignore_missing=True)
def reload_doctype(doctype, force=False, reset_permissions=False):
"""Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files."""
@ -1009,7 +1024,7 @@ def get_module(modulename):
def scrub(txt):
"""Returns sluggified string. e.g. `Sales Order` becomes `sales_order`."""
return txt.replace(' ', '_').replace('-', '_').lower()
return cstr(txt).replace(' ', '_').replace('-', '_').lower()
def unscrub(txt):
"""Returns titlified string. e.g. `sales_order` becomes `Sales Order`."""
@ -1244,9 +1259,10 @@ def get_newargs(fn, kwargs):
if hasattr(fn, 'fnargs'):
fnargs = fn.fnargs
else:
fnargs = inspect.getfullargspec(fn).args
fnargs.extend(inspect.getfullargspec(fn).kwonlyargs)
varkw = inspect.getfullargspec(fn).varkw
fullargspec = inspect.getfullargspec(fn)
fnargs = fullargspec.args
fnargs.extend(fullargspec.kwonlyargs)
varkw = fullargspec.varkw
newargs = {}
for a in kwargs:
@ -1258,7 +1274,7 @@ def get_newargs(fn, kwargs):
return newargs
def make_property_setter(args, ignore_validate=False, validate_fields_for_doctype=True):
def make_property_setter(args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True):
"""Create a new **Property Setter** (for overriding DocType and DocField properties).
If doctype is not specified, it will create a property setter for all fields with the
@ -1289,6 +1305,7 @@ def make_property_setter(args, ignore_validate=False, validate_fields_for_doctyp
'property': args.property,
'value': args.value,
'property_type': args.property_type or "Data",
'is_system_generated': is_system_generated,
'__islocal': 1
})
ps.flags.ignore_validate = ignore_validate
@ -1456,7 +1473,7 @@ def get_list(doctype, *args, **kwargs):
:param fields: List of fields or `*`.
:param filters: List of filters (see example).
:param order_by: Order By e.g. `modified desc`.
:param limit_page_start: Start results at record #. Default 0.
:param limit_start: Start results at record #. Default 0.
:param limit_page_length: No of records in the page. Default 20.
Example usage:

View file

@ -677,7 +677,9 @@ def _drop_site(site, db_root_username=None, db_root_password=None, archived_site
try:
if not no_backup:
scheduled_backup(ignore_files=False, force=True)
click.secho(f"Taking backup of {site}", fg="green")
odb = scheduled_backup(ignore_files=False, force=True, verbose=True)
odb.print_summary()
except Exception as err:
if force:
pass
@ -692,6 +694,7 @@ def _drop_site(site, db_root_username=None, db_root_password=None, archived_site
click.echo("\n".join(messages))
sys.exit(1)
click.secho("Dropping site database and user", fg="green")
drop_user_and_database(frappe.conf.db_name, db_root_username, db_root_password)
archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites')
@ -725,7 +728,7 @@ def move(dest_dir, site):
@click.command('set-password')
@click.argument('user')
@click.argument('password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False)
@pass_context
def set_password(context, user, password=None, logout_all_sessions=False):
"Set password for a user on a site"
@ -738,7 +741,7 @@ def set_password(context, user, password=None, logout_all_sessions=False):
@click.command('set-admin-password')
@click.argument('admin-password', required=False)
@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False)
@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False)
@pass_context
def set_admin_password(context, admin_password=None, logout_all_sessions=False):
"Set Administrator password for a site"

View file

@ -1,15 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
# Copyright (c) 2022, Frappe Technologies and contributors
# License: MIT. See LICENSE
from frappe import _
from frappe.utils import get_fullname, now
from frappe.model.document import Document
from frappe.core.utils import set_timeline_doc
import frappe
from frappe import _
from frappe.core.utils import set_timeline_doc
from frappe.model.document import Document
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from pypika.terms import PseudoColumn
from frappe.utils import get_fullname, now
class ActivityLog(Document):
def before_insert(self):
@ -49,5 +48,5 @@ def clear_activity_logs(days=None):
days = 90
doctype = DocType("Activity Log")
frappe.db.delete(doctype, filters=(
doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})")
))
doctype.creation < (Now() - Interval(days=days))
))

View file

@ -4,8 +4,8 @@ import unittest
from urllib.parse import quote
import frappe
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.core.doctype.communication.communication import get_emails
from frappe.email.doctype.email_queue.email_queue import EmailQueue
test_records = frappe.get_test_records('Communication')
@ -202,7 +202,7 @@ class TestCommunication(unittest.TestCase):
self.assertIn(("Note", note.name), doc_links)
def parse_emails(self):
def test_parse_emails(self):
emails = get_emails(
[
'comm_recipient+DocType+DocName@example.com',

View file

@ -324,7 +324,7 @@ class DataExporter:
d = doc.copy()
meta = frappe.get_meta(dt)
if self.all_doctypes:
d.name = '"'+ d.name+'"'
d.name = f'"{d.name}"'
if len(rows) < rowidx + 1:
rows.append([""] * (len(self.columns) + 1))

View file

@ -61,6 +61,13 @@ frappe.ui.form.on('DocType', {
frm.events.set_naming_rule_description(frm);
},
istable: (frm) => {
if (frm.doc.istable && frm.is_new()) {
frm.set_value('autoname', 'autoincrement');
frm.set_value('allow_rename', 0);
}
},
naming_rule: function(frm) {
// set the "autoname" property based on naming_rule
if (frm.doc.naming_rule && !frm.__from_autoname) {
@ -70,6 +77,10 @@ frappe.ui.form.on('DocType', {
if (frm.doc.naming_rule=='Set by user') {
frm.set_value('autoname', 'Prompt');
} else if (frm.doc.naming_rule === 'Autoincrement') {
frm.set_value('autoname', 'autoincrement');
// set allow rename to be false when using autoincrement
frm.set_value('allow_rename', 0);
} else if (frm.doc.naming_rule=='By fieldname') {
frm.set_value('autoname', 'field:');
} else if (frm.doc.naming_rule=='By "Naming Series" field') {
@ -91,6 +102,7 @@ frappe.ui.form.on('DocType', {
set_naming_rule_description(frm) {
let naming_rule_description = {
'Set by user': '',
'Autoincrement': 'Uses Auto Increment feature of database.<br><b>WARNING: After using this option, any other naming option will not be accessible.</b>',
'By fieldname': 'Format: <code>field:[fieldname]</code>. Valid fieldname must exist',
'By "Naming Series" field': 'Format: <code>naming_series:[fieldname]</code>. Fieldname called <code>naming_series</code> must exist',
'Expression': 'Format: <code>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</code> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.',
@ -111,6 +123,8 @@ frappe.ui.form.on('DocType', {
frm.__from_autoname = true;
if (frm.doc.autoname.toLowerCase() === 'prompt') {
frm.set_value('naming_rule', 'Set by user');
} else if (frm.doc.autoname.toLowerCase() === 'autoincrement') {
frm.set_value('naming_rule', 'Autoincrement');
} else if (frm.doc.autoname.startsWith('field:')) {
frm.set_value('naming_rule', 'By fieldname');
} else if (frm.doc.autoname.startsWith('naming_series:')) {

View file

@ -208,7 +208,7 @@
"label": "Naming"
},
{
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>autoincrement</b> - Uses Databases' Auto Increment feature</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"fieldname": "autoname",
"fieldtype": "Data",
"label": "Auto Name",
@ -216,6 +216,7 @@
"oldfieldtype": "Data"
},
{
"depends_on": "eval:doc.naming_rule !== \"Autoincrement\"",
"fieldname": "name_case",
"fieldtype": "Select",
"label": "Name Case",
@ -282,6 +283,7 @@
},
{
"default": "1",
"depends_on": "eval:doc.naming_rule !== \"Autoincrement\"",
"fieldname": "allow_rename",
"fieldtype": "Check",
"label": "Allow Rename",
@ -565,7 +567,7 @@
"fieldtype": "Select",
"label": "Naming Rule",
"length": 40,
"options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script"
"options": "\nSet by user\nAutoincrement\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script"
},
{
"fieldname": "migration_hash",
@ -593,6 +595,7 @@
],
"icon": "fa fa-bolt",
"idx": 6,
"index_web_pages_for_search": 1,
"links": [
{
"group": "Views",
@ -670,10 +673,11 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-01-07 16:07:06.196534",
"modified": "2022-02-15 21:47:16.467217",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
"naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@ -703,5 +707,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -60,6 +60,7 @@ class DocType(Document):
self.check_developer_mode()
self.validate_autoname()
self.validate_name()
self.set_defaults_for_single_and_table()
@ -714,6 +715,18 @@ class DocType(Document):
self.name)
return max_idx and max_idx[0][0] or 0
def validate_autoname(self):
if not self.is_new():
doc_before_save = self.get_doc_before_save()
if doc_before_save:
if (self.autoname == "autoincrement" and doc_before_save.autoname != "autoincrement") \
or (self.autoname != "autoincrement" and doc_before_save.autoname == "autoincrement"):
frappe.throw(_("Cannot change to/from Autoincrement naming rule"))
else:
if self.autoname == "autoincrement":
self.allow_rename = 0
def validate_name(self, name=None):
if not name:
name = self.name

View file

@ -505,7 +505,23 @@ class TestDocType(unittest.TestCase):
dt.delete()
def new_doctype(name, unique=0, depends_on='', fields=None):
def test_autoincremented_doctype_transition(self):
frappe.delete_doc("testy_autoinc_dt")
dt = new_doctype("testy_autoinc_dt", autoincremented=True).insert(ignore_permissions=True)
dt.autoname = "hash"
try:
dt.save(ignore_permissions=True)
except frappe.ValidationError as e:
self.assertEqual(e.args[0], "Cannot change to/from Autoincrement naming rule")
else:
self.fail("Shouldnt be possible to transition autoincremented doctype to any other naming rule")
finally:
# cleanup
dt.delete(ignore_permissions=True)
def new_doctype(name, unique=0, depends_on='', fields=None, autoincremented=False):
doc = frappe.get_doc({
"doctype": "DocType",
"module": "Core",
@ -521,7 +537,8 @@ def new_doctype(name, unique=0, depends_on='', fields=None):
"role": "System Manager",
"read": 1,
}],
"name": name
"name": name,
"autoname": "autoincrement" if autoincremented else ""
})
if fields:

View file

@ -383,7 +383,7 @@ class TestFile(unittest.TestCase):
}).insert(ignore_permissions=True)
test_file.make_thumbnail()
self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg')
self.assertEqual(test_file.thumbnail_url, '/files/image_small.jpg')
# test web image without extension
test_file = frappe.get_doc({
@ -400,7 +400,7 @@ class TestFile(unittest.TestCase):
test_file.reload()
test_file.file_url = "/files/image_small.jpg"
test_file.make_thumbnail(suffix="xs", crop=True)
self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg')
self.assertEqual(test_file.thumbnail_url, '/files/image_small_xs.jpg')
frappe.clear_messages()
test_file.db_set('thumbnail_url', None)
@ -408,7 +408,7 @@ class TestFile(unittest.TestCase):
test_file.file_url = frappe.utils.get_url('unknown.jpg')
test_file.make_thumbnail(suffix="xs")
self.assertEqual(json.loads(frappe.message_log[0]).get("message"), f"File '{frappe.utils.get_url('unknown.jpg')}' not found")
self.assertEquals(test_file.thumbnail_url, None)
self.assertEqual(test_file.thumbnail_url, None)
def test_file_unzip(self):
file_path = frappe.get_app_path('frappe', 'www/_test/assets/file.zip')

View file

@ -7,7 +7,6 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from pypika.terms import PseudoColumn
class LogSettings(Document):
@ -19,7 +18,7 @@ class LogSettings(Document):
def clear_error_logs(self):
table = DocType("Error Log")
frappe.db.delete(table, filters=(
table.creation < PseudoColumn(f"({Now() - Interval(days=self.clear_error_log_after)})")
table.creation < (Now() - Interval(days=self.clear_error_log_after))
))
def clear_activity_logs(self):

View file

@ -1,8 +1,100 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2020, Frappe Technologies and Contributors
# Copyright (c) 2022, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
import unittest
class TestLogSettings(unittest.TestCase):
pass
from datetime import datetime
import frappe
from frappe.utils import now_datetime, add_to_date
from frappe.core.doctype.log_settings.log_settings import run_log_clean_up
from frappe.tests.utils import FrappeTestCase
class TestLogSettings(FrappeTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.db.set_single_value(
"Log Settings",
{
"clear_error_log_after": 1,
"clear_activity_log_after": 1,
"clear_email_queue_after": 1,
},
)
def setUp(self) -> None:
if self._testMethodName == "test_delete_logs":
self.datetime = frappe._dict()
self.datetime.current = now_datetime()
self.datetime.past = add_to_date(self.datetime.current, days=-4)
setup_test_logs(self.datetime.past)
def tearDown(self) -> None:
if self._testMethodName == "test_delete_logs":
del self.datetime
def test_delete_logs(self):
# make sure test data is present
activity_log_count = frappe.db.count(
"Activity Log", {"creation": ("<=", self.datetime.past)}
)
error_log_count = frappe.db.count(
"Error Log", {"creation": ("<=", self.datetime.past)}
)
email_queue_count = frappe.db.count(
"Email Queue", {"creation": ("<=", self.datetime.past)}
)
self.assertNotEqual(activity_log_count, 0)
self.assertNotEqual(error_log_count, 0)
self.assertNotEqual(email_queue_count, 0)
# run clean up job
run_log_clean_up()
# test if logs are deleted
activity_log_count = frappe.db.count(
"Activity Log", {"creation": ("<", self.datetime.past)}
)
error_log_count = frappe.db.count(
"Error Log", {"creation": ("<", self.datetime.past)}
)
email_queue_count = frappe.db.count(
"Email Queue", {"creation": ("<", self.datetime.past)}
)
self.assertEqual(activity_log_count, 0)
self.assertEqual(error_log_count, 0)
self.assertEqual(email_queue_count, 0)
def setup_test_logs(past: datetime) -> None:
activity_log = frappe.get_doc(
{
"doctype": "Activity Log",
"subject": "Test subject",
"full_name": "test user2",
}
).insert(ignore_permissions=True)
activity_log.db_set("creation", past)
error_log = frappe.get_doc(
{
"doctype": "Error Log",
"method": "test_method",
"error": "traceback",
}
).insert(ignore_permissions=True)
error_log.db_set("creation", past)
doc1 = frappe.get_doc(
{
"doctype": "Email Queue",
"sender": "test1@example.com",
"message": "This is a test email1",
"priority": 1,
"expose_recipients": "test@receiver.com",
}
).insert(ignore_permissions=True)
doc1.db_set("creation", past)

View file

@ -11,7 +11,7 @@ from frappe.modules import make_boilerplate
from frappe.core.doctype.page.page import delete_custom_role
from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from frappe.desk.reportview import append_totals_row
from frappe.utils.safe_exec import safe_exec
from frappe.utils.safe_exec import safe_exec, check_safe_sql_query
class Report(Document):
@ -110,8 +110,7 @@ class Report(Document):
if not self.query:
frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error'))
if not self.query.lower().startswith("select"):
frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error'))
check_safe_sql_query(self.query)
result = [list(t) for t in frappe.db.sql(self.query, filters)]
columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()]

View file

@ -1,17 +1,19 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import textwrap
import frappe, json, os
import unittest
from frappe.desk.query_report import run, save_report, add_total_row
from frappe.desk.reportview import delete_report, save_report as _save_report
from frappe.custom.doctype.customize_form.customize_form import reset_customization
from frappe.core.doctype.user_permission.test_user_permission import create_user
from frappe.tests.utils import FrappeTestCase
test_records = frappe.get_test_records('Report')
test_dependencies = ['User']
class TestReport(unittest.TestCase):
class TestReport(FrappeTestCase):
def test_report_builder(self):
if frappe.db.exists('Report', 'User Activity Report'):
frappe.delete_doc('Report', 'User Activity Report')
@ -335,3 +337,29 @@ result = [
self.assertEqual(result[-1][0], "Total")
self.assertEqual(result[-1][1], 200)
self.assertEqual(result[-1][2], 150.50)
def test_cte_in_query_report(self):
cte_query = textwrap.dedent("""
with enabled_users as (
select name
from `tabUser`
where enabled = 1
)
select * from enabled_users;
""")
report = frappe.get_doc({
"doctype": "Report",
"ref_doctype": "User",
"report_name": "Enabled Users List",
"report_type": "Query Report",
"is_standard": "No",
"query": cte_query,
}).insert()
if frappe.db.db_type == "mariadb":
col, rows = report.execute_query_report(filters={})
self.assertEqual(col[0], "name")
self.assertGreaterEqual(len(rows), 1)
elif frappe.db.db_type == "postgres":
self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={})

View file

@ -112,7 +112,10 @@ class TestServerScript(unittest.TestCase):
self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')
def test_permission_query(self):
self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False))
if frappe.conf.db_type == "mariadb":
self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False))
else:
self.assertTrue('where (1 = \'1\')' in frappe.db.get_list('ToDo', run=False))
self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))
def test_attribute_error(self):

View file

@ -253,8 +253,8 @@ class User(Document):
self.email_new_password(new_password)
except frappe.OutgoingEmailError:
print(frappe.get_traceback())
pass # email server not set, don't send email
# email server not set, don't send email
frappe.log_error(frappe.get_traceback())
@Document.hook
def validate_reset_password(self):

View file

@ -44,8 +44,9 @@ frappe.ui.form.on('User Permission', {
set_applicable_for_constraint: frm => {
frm.toggle_reqd('applicable_for', !frm.doc.apply_to_all_doctypes);
if (frm.doc.apply_to_all_doctypes && frm.doc.applicable_for) {
frm.set_value('applicable_for', null);
frm.set_value('applicable_for', null, null, true);
}
},

View file

@ -1,133 +1,159 @@
{
"accept_payment": 0,
"allow_comments": 0,
"allow_delete": 0,
"allow_edit": 1,
"allow_incomplete": 0,
"allow_multiple": 0,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]",
"creation": "2016-09-19 05:16:59.242754",
"doc_type": "User",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"introduction_text": "",
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2019-01-28 12:45:17.158069",
"modified_by": "Administrator",
"module": "Core",
"name": "edit-profile",
"owner": "Administrator",
"published": 1,
"route": "update-profile",
"show_in_grid": 0,
"show_sidebar": 1,
"sidebar_items": [],
"success_message": "Profile updated successfully.",
"success_url": "/me",
"title": "Update Profile",
"accept_payment": 0,
"allow_comments": 0,
"allow_delete": 0,
"allow_edit": 1,
"allow_incomplete": 0,
"allow_multiple": 0,
"allow_print": 0,
"amount": 0.0,
"amount_based_on_field": 0,
"apply_document_permissions": 0,
"breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]",
"creation": "2016-09-19 05:16:59.242754",
"doc_type": "User",
"docstatus": 0,
"doctype": "Web Form",
"idx": 0,
"introduction_text": "",
"is_multi_step_form": 0,
"is_standard": 1,
"login_required": 1,
"max_attachment_size": 0,
"modified": "2022-03-22 15:00:43.456738",
"modified_by": "Administrator",
"module": "Core",
"name": "edit-profile",
"owner": "Administrator",
"published": 1,
"route": "update-profile",
"route_to_success_link": 0,
"show_attachments": 0,
"show_in_grid": 0,
"show_sidebar": 0,
"sidebar_items": [],
"success_message": "Profile updated successfully.",
"success_url": "/me",
"title": "Update Profile",
"web_form_fields": [
{
"allow_read_on_all_link_options": 0,
"fieldname": "first_name",
"fieldtype": "Data",
"hidden": 0,
"label": "First Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 1,
"allow_read_on_all_link_options": 0,
"fieldname": "first_name",
"fieldtype": "Data",
"hidden": 0,
"label": "First Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 1,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "middle_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Middle Name (Optional)",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "middle_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Middle Name (Optional)",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "last_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Last Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "last_name",
"fieldtype": "Data",
"hidden": 0,
"label": "Last Name",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "user_image",
"fieldtype": "Attach Image",
"hidden": 0,
"label": "User Image",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "",
"fieldtype": "Column Break",
"hidden": 0,
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldtype": "Section Break",
"hidden": 0,
"label": "More Information",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "user_image",
"fieldtype": "Attach Image",
"hidden": 0,
"label": "Profile Picture",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "phone",
"fieldtype": "Data",
"hidden": 0,
"label": "Phone",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldtype": "Section Break",
"hidden": 0,
"label": "More Information",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "mobile_no",
"fieldtype": "Data",
"hidden": 0,
"label": "Mobile Number",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "phone",
"fieldtype": "Data",
"hidden": 0,
"label": "Phone",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
},
{
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "language",
"fieldtype": "Link",
"hidden": 0,
"label": "Language",
"max_length": 0,
"max_value": 0,
"options": "Language",
"read_only": 0,
"reqd": 0,
"allow_read_on_all_link_options": 0,
"fieldname": "mobile_no",
"fieldtype": "Data",
"hidden": 0,
"label": "Mobile Number",
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"fieldname": "",
"fieldtype": "Column Break",
"hidden": 0,
"max_length": 0,
"max_value": 0,
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
},
{
"allow_read_on_all_link_options": 0,
"description": "",
"fieldname": "language",
"fieldtype": "Link",
"hidden": 0,
"label": "Language",
"max_length": 0,
"max_value": 0,
"options": "Language",
"read_only": 0,
"reqd": 0,
"show_in_filter": 0
}
]

View file

@ -7,6 +7,7 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"is_system_generated",
"dt",
"module",
"label",
@ -425,13 +426,20 @@
"fieldtype": "Link",
"label": "Module (for export)",
"options": "Module Def"
},
{
"default": "0",
"fieldname": "is_system_generated",
"fieldtype": "Check",
"label": "Is System Generated",
"read_only": 1
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-02-14 15:42:21.885999",
"modified": "2022-02-28 22:22:54.893269",
"modified_by": "Administrator",
"module": "Custom",
"name": "Custom Field",

View file

@ -119,7 +119,7 @@ def create_custom_field_if_values_exist(doctype, df):
frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""):
create_custom_field(doctype, df)
def create_custom_field(doctype, df, ignore_validate=False):
def create_custom_field(doctype, df, ignore_validate=False, is_system_generated=True):
df = frappe._dict(df)
if not df.fieldname and df.label:
df.fieldname = frappe.scrub(df.label)
@ -130,8 +130,7 @@ def create_custom_field(doctype, df, ignore_validate=False):
"permlevel": 0,
"fieldtype": 'Data',
"hidden": 0,
# Looks like we always use this programatically?
# "is_standard": 1
"is_system_generated": is_system_generated
})
custom_field.update(df)
custom_field.flags.ignore_validate = ignore_validate

View file

@ -14,7 +14,6 @@ frappe.ui.form.on("Customize Form", {
},
onload: function(frm) {
frm.disable_save();
frm.set_query("doc_type", function() {
return {
translate_values: false,
@ -110,7 +109,7 @@ frappe.ui.form.on("Customize Form", {
},
refresh: function(frm) {
frm.disable_save();
frm.disable_save(true);
frm.page.clear_icons();
if (frm.doc.doc_type) {
@ -169,7 +168,7 @@ frappe.ui.form.on("Customize Form", {
doc_type = localStorage.getItem("customize_doctype");
}
if (doc_type) {
setTimeout(() => frm.set_value("doc_type", doc_type), 1000);
setTimeout(() => frm.set_value("doc_type", doc_type, false, true), 1000);
}
},
@ -243,7 +242,8 @@ frappe.ui.form.on("Customize Form Field", {
},
fields_add: function(frm, cdt, cdn) {
var f = frappe.model.get_doc(cdt, cdn);
f.is_custom_field = 1;
f.is_system_generated = false;
f.is_custom_field = true;
}
});
@ -341,11 +341,11 @@ frappe.customize_form.confirm = function(msg, frm) {
}
frappe.customize_form.clear_locals_and_refresh = function(frm) {
delete frm.doc.__unsaved;
// clear doctype from locals
frappe.model.clear_doc("DocType", frm.doc.doc_type);
delete frappe.meta.docfield_copy[frm.doc.doc_type];
frm.refresh();
}
};
extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm}));

View file

@ -402,7 +402,7 @@ class CustomizeForm(Document):
"property": prop,
"value": value,
"property_type": property_type
})
}, is_system_generated=False)
def get_existing_property_value(self, property_name, fieldname=None):
# check if there is any need to make property setter!
@ -487,12 +487,21 @@ def reset_customization(doctype):
setters = frappe.get_all("Property Setter", filters={
'doc_type': doctype,
'field_name': ['!=', 'naming_series'],
'property': ['!=', 'options']
'property': ['!=', 'options'],
'is_system_generated': False
}, pluck='name')
for setter in setters:
frappe.delete_doc("Property Setter", setter)
custom_fields = frappe.get_all("Custom Field", filters={
'dt': doctype,
'is_system_generated': False
}, pluck='name')
for field in custom_fields:
frappe.delete_doc("Custom Field", field)
frappe.clear_cache(doctype=doctype)
doctype_properties = {

View file

@ -6,6 +6,7 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"is_system_generated",
"help",
"sb0",
"doctype_or_field",
@ -103,13 +104,20 @@
{
"fieldname": "section_break_9",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "is_system_generated",
"fieldtype": "Check",
"label": "Is System Generated",
"read_only": 1
}
],
"icon": "fa fa-glass",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-12-14 14:15:41.929071",
"modified": "2022-02-28 22:24:12.377693",
"modified_by": "Administrator",
"module": "Custom",
"name": "Property Setter",

View file

@ -18,7 +18,8 @@ def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False
def drop_user_and_database(db_name, root_login=None, root_password=None):
import frappe
if frappe.conf.db_type == 'postgres':
pass
import frappe.database.postgres.setup_db
return frappe.database.postgres.setup_db.drop_user_and_database(db_name, root_login, root_password)
else:
import frappe.database.mariadb.setup_db
return frappe.database.mariadb.setup_db.drop_user_and_database(db_name, root_login, root_password)

View file

@ -10,7 +10,7 @@ import re
import string
from contextlib import contextmanager
from time import time
from typing import Dict, List, Tuple, Union
from typing import Dict, List, Optional, Tuple, Union
from pypika.terms import Criterion, NullValue, PseudoColumn
@ -119,6 +119,9 @@ class Database(object):
if not run:
return query
# remove \n \t from start and end of query
query = re.sub(r'^\s*|\s*$', '', query)
if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE)
@ -142,8 +145,6 @@ class Database(object):
self.log_query(query, values, debug, explain)
if values!=():
if isinstance(values, dict):
values = dict(values)
# MySQL-python==1.2.5 hack!
if not isinstance(values, (dict, tuple, list)):
@ -386,7 +387,7 @@ class Database(object):
"""
ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct)
order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct, limit=1)
if not run:
return ret
@ -395,7 +396,7 @@ class Database(object):
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):
run=True, pluck=False, distinct=False, limit=None):
"""Returns multiple document properties.
:param doctype: DocType name.
@ -425,14 +426,15 @@ class Database(object):
if isinstance(filters, list):
out = self._get_value_for_many_names(
doctype,
filters,
fieldname,
order_by,
doctype=doctype,
names=filters,
field=fieldname,
order_by=order_by,
debug=debug,
run=run,
pluck=pluck,
distinct=distinct,
limit=limit,
)
else:
@ -446,17 +448,18 @@ class Database(object):
if order_by:
order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by
out = self._get_values_from_table(
fields,
filters,
doctype,
as_dict,
debug,
order_by,
update,
fields=fields,
filters=filters,
doctype=doctype,
as_dict=as_dict,
debug=debug,
order_by=order_by,
update=update,
for_update=for_update,
run=run,
pluck=pluck,
distinct=distinct
distinct=distinct,
limit=limit,
)
except Exception as e:
if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)):
@ -558,7 +561,7 @@ class Database(object):
def get_list(*args, **kwargs):
return frappe.get_list(*args, **kwargs)
def set_single_value(self, doctype, fieldname, value, *args, **kwargs):
def set_single_value(self, doctype: str, fieldname: Union[str, Dict], value: Optional[Union[str, int]] = None, *args, **kwargs):
"""Set field value of Single DocType.
:param doctype: DocType of the single object
@ -625,6 +628,7 @@ class Database(object):
run=True,
pluck=False,
distinct=False,
limit=None,
):
field_objects = []
@ -643,6 +647,7 @@ class Database(object):
field_objects=field_objects,
fields=fields,
distinct=distinct,
limit=limit,
)
if (
fields == "*"
@ -656,7 +661,7 @@ 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):
def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False, limit=None):
names = list(filter(None, names))
if names:
return self.get_all(
@ -669,6 +674,7 @@ class Database(object):
as_list=1,
run=run,
distinct=distinct,
limit_page_length=limit
)
else:
return {}
@ -884,27 +890,39 @@ class Database(object):
return self.sql("select name from `tab{doctype}` limit 1".format(doctype=doctype))
def exists(self, dt, dn=None, cache=False):
"""Returns true if document exists.
"""Return the document name of a matching document, or None.
:param dt: DocType name.
:param dn: Document name or filter dict."""
if isinstance(dt, str):
if dt!="DocType" and dt==dn:
return True # single always exists (!)
try:
return self.get_value(dt, dn, "name", cache=cache)
except Exception:
return None
Note: `cache` only works if `dt` and `dn` are of type `str`.
elif isinstance(dt, dict) and dt.get('doctype'):
try:
conditions = []
for d in dt:
if d == 'doctype': continue
conditions.append([d, '=', dt[d]])
return self.get_all(dt['doctype'], filters=conditions, as_list=1)
except Exception:
return None
## Examples
Pass doctype and docname (only in this case we can cache the result)
```
exists("User", "jane@example.org", cache=True)
```
Pass a dict of filters including the `"doctype"` key:
```
exists({"doctype": "User", "full_name": "Jane Doe"})
```
Pass the doctype and a dict of filters:
```
exists("User", {"full_name": "Jane Doe"})
```
"""
if dt != "DocType" and dt == dn:
# single always exists (!)
return dn
if isinstance(dt, dict):
dt = dt.copy() # don't modify the original dict
dt, dn = dt.pop("doctype"), dt
return self.get_value(dt, dn, ignore=True, cache=cache)
def count(self, dt, filters=None, debug=False, cache=False):
"""Returns `COUNT(*)` for given DocType and filters."""

View file

@ -1,12 +1,16 @@
import frappe
from frappe import _
from frappe.database.schema import DBTable
from frappe.database.sequence import create_sequence
from frappe.model import log_types
class MariaDBTable(DBTable):
def create(self):
additional_definitions = ""
engine = self.meta.get("engine") or "InnoDB"
varchar_len = frappe.db.VARCHAR_LEN
name_column = f"name varchar({varchar_len}) primary key"
# columns
column_defs = self.get_column_definitions()
@ -29,9 +33,27 @@ class MariaDBTable(DBTable):
)
) + ',\n'
# creating sequence(s)
if (not self.meta.issingle and self.meta.autoname == "autoincrement")\
or self.doctype in log_types:
# NOTE: using a very small cache - as during backup, if the sequence was used in anyform,
# it drops the cache and uses the next non cached value in setval func and
# puts that in the backup file, which will start the counter
# from that value when inserting any new record in the doctype.
# By default the cache is 1000 which will mess up the sequence when
# using the system after a restore.
# issue link: https://jira.mariadb.org/browse/MDEV-21786
create_sequence(self.doctype, check_not_exists=True, cache=50)
# NOTE: not used nextval func as default as the ability to restore
# database with sequences has bugs in mariadb and gives a scary error.
# issue link: https://jira.mariadb.org/browse/MDEV-21786
name_column = "name bigint primary key"
# create table
query = f"""create table `{self.table_name}` (
name varchar({varchar_len}) not null primary key,
{name_column},
creation datetime(6),
modified datetime(6),
modified_by varchar({varchar_len}),

View file

@ -99,8 +99,13 @@ class PostgresDatabase(Database):
return db_size[0].get('database_size')
# pylint: disable=W0221
def sql(self, query, *args, **kwargs):
return super(PostgresDatabase, self).sql(modify_query(query), *args, **kwargs)
def sql(self, query, values=(), *args, **kwargs):
return super(PostgresDatabase, self).sql(
modify_query(query),
modify_values(values),
*args,
**kwargs
)
def get_tables(self, cached=True):
return [d[0] for d in self.sql("""select table_name
@ -333,10 +338,45 @@ def modify_query(query):
if re.search('from tab', query, flags=re.IGNORECASE):
query = re.sub(r'from tab([\w-]*)', r'from "tab\1"', query, flags=re.IGNORECASE)
# only find int (with/without signs), ignore decimals (with/without signs), ignore hashes (which start with numbers),
# drop .0 from decimals and add quotes around them
#
# >>> query = "c='abcd' , a >= 45, b = -45.0, c = 40, d=4500.0, e=3500.53, f=40psdfsd, g=9092094312, h=12.00023"
# >>> re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query)
# "c='abcd' , a >= '45', b = '-45', c = '40', d= '4500', e=3500.53, f=40psdfsd, g= '9092094312', h=12.00023
query = re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query)
return query
def modify_values(values):
def stringify_value(value):
if isinstance(value, int):
value = str(value)
elif isinstance(value, float):
truncated_float = int(value)
if value == truncated_float:
value = str(truncated_float)
return value
if not values:
return values
if isinstance(values, dict):
for k, v in values.items():
values[k] = stringify_value(v)
elif isinstance(values, (tuple, list)):
new_values = []
for val in values:
new_values.append(stringify_value(val))
values = new_values
else:
values = stringify_value(values)
return values
def replace_locate_with_strpos(query):
# strpos is the locate equivalent in postgres
if re.search(r'locate\(', query, flags=re.IGNORECASE):
query = re.sub(r'locate\(([^,]+),([^)]+)\)', r'strpos(\2, \1)', query, flags=re.IGNORECASE)
query = re.sub(r'locate\(([^,]+),([^)]+)(\)?)\)', r'strpos(\2\3, \1)', query, flags=re.IGNORECASE)
return query

View file

@ -2,10 +2,14 @@ import frappe
from frappe import _
from frappe.utils import cint, flt
from frappe.database.schema import DBTable, get_definition
from frappe.database.sequence import create_sequence
from frappe.model import log_types
class PostgresTable(DBTable):
def create(self):
varchar_len = frappe.db.VARCHAR_LEN
name_column = f"name varchar({varchar_len}) primary key"
additional_definitions = ""
# columns
@ -26,9 +30,21 @@ class PostgresTable(DBTable):
)
)
# creating sequence(s)
if (not self.meta.issingle and self.meta.autoname == "autoincrement")\
or self.doctype in log_types:
# The sequence cache is per connection.
# Since we're opening and closing connections for every transaction this results in skipping the cache
# to the next non-cached value hence not using cache in postgres.
# ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers
create_sequence(self.doctype, check_not_exists=True)
name_column = "name bigint primary key"
# TODO: set docstatus length
# create table
frappe.db.sql(f"""create table `{self.table_name}` (
name varchar({varchar_len}) not null primary key,
{name_column},
creation timestamp(6),
modified timestamp(6),
modified_by varchar({varchar_len}),

View file

@ -95,3 +95,11 @@ def get_root_connection(root_login=None, root_password=None):
frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password)
return frappe.local.flags.root_connection
def drop_user_and_database(db_name, root_login, root_password):
root_conn = get_root_connection(frappe.flags.root_login or root_login, frappe.flags.root_password or root_password)
root_conn.commit()
root_conn.sql(f"SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = %s", (db_name, ))
root_conn.sql(f"DROP DATABASE IF EXISTS {db_name}")
root_conn.sql(f"DROP USER IF EXISTS {db_name}")

View file

@ -0,0 +1,80 @@
from frappe import db, scrub
def create_sequence(
doctype_name: str,
*,
slug: str = "_id_seq",
check_not_exists: bool = False,
cycle: bool = False,
cache: int = 0,
start_value: int = 0,
increment_by: int = 0,
min_value: int = 0,
max_value: int = 0
) -> str:
query = "create sequence"
sequence_name = scrub(doctype_name + slug)
if check_not_exists:
query += " if not exists"
query += f" {sequence_name}"
if cache:
query += f" cache {cache}"
else:
# in postgres, the default is cache 1
if db.db_type == "mariadb":
query += " nocache"
if start_value:
# default is 1
query += f" start with {start_value}"
if increment_by:
# default is 1
query += f" increment by {increment_by}"
if min_value:
# default is 1
query += f" min value {min_value}"
if max_value:
query += f" max value {max_value}"
if not cycle:
if db.db_type == "mariadb":
query += " nocycle"
else:
query += " cycle"
db.sql(query)
return sequence_name
def get_next_val(doctype_name: str, slug: str = "_id_seq") -> int:
if db.db_type == "postgres":
return db.sql(f"select nextval(\'\"{scrub(doctype_name + slug)}\"\')")[0][0]
return db.sql(f"select nextval(`{scrub(doctype_name + slug)}`)")[0][0]
def set_next_val(
doctype_name: str,
next_val: int,
*,
slug: str = "_id_seq",
is_val_used :bool = False
) -> None:
if not is_val_used:
is_val_used = 0 if db.db_type == "mariadb" else "f"
else:
is_val_used = 1 if db.db_type == "mariadb" else "t"
if db.db_type == "postgres":
db.sql(f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, '{is_val_used}')")
else:
db.sql(f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})")

View file

@ -72,7 +72,8 @@ class ToDo(Document):
assignments = frappe.get_all("ToDo", filters={
"reference_type": self.reference_type,
"reference_name": self.reference_name,
"status": ("!=", "Cancelled")
"status": ("!=", "Cancelled"),
"allocated_to": ("is", "set")
}, pluck="allocated_to")
assignments.reverse()

View file

@ -10,6 +10,7 @@ import frappe.desk.form.meta
from frappe.model.utils.user_settings import get_user_settings
from frappe.permissions import get_doc_permissions
from frappe.desk.form.document_follow import is_document_followed
from frappe.utils.data import cstr
from frappe import _
from frappe import _dict
from urllib.parse import quote
@ -311,6 +312,7 @@ def get_assignments(dt, dn):
'reference_type': dt,
'reference_name': dn,
'status': ('!=', 'Cancelled'),
'allocated_to': ("is", "set")
})
@frappe.whitelist()
@ -355,7 +357,7 @@ def get_document_email(doctype, name):
return None
email = email.split("@")
return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(name), email[1])
return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(cstr(name)), email[1])
def get_automatic_email_link():
return frappe.db.get_value("Email Account", {"enable_incoming": 1, "enable_automatic_linking": 1}, "email_id")

View file

@ -352,14 +352,10 @@ def export_query():
)
return
columns = get_columns_dict(data.columns)
from frappe.utils.xlsxutils import make_xlsx
data["result"] = handle_duration_fieldtype_values(
data.get("result"), data.get("columns")
)
xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation)
format_duration_fields(data)
xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation)
xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths)
frappe.response["filename"] = report_name + ".xlsx"
@ -367,39 +363,18 @@ def export_query():
frappe.response["type"] = "binary"
def handle_duration_fieldtype_values(result, columns):
for i, col in enumerate(columns):
fieldtype = None
if isinstance(col, str):
col = col.split(":")
if len(col) > 1:
if col[1]:
fieldtype = col[1]
if "/" in fieldtype:
fieldtype, options = fieldtype.split("/")
else:
fieldtype = "Data"
else:
fieldtype = col.get("fieldtype")
def format_duration_fields(data: frappe._dict) -> None:
for i, col in enumerate(data.columns):
if col.get("fieldtype") != "Duration":
continue
if fieldtype == "Duration":
for entry in range(0, len(result)):
row = result[entry]
if isinstance(row, dict):
val_in_seconds = row[col.fieldname]
if val_in_seconds:
duration_val = format_duration(val_in_seconds)
row[col.fieldname] = duration_val
else:
val_in_seconds = row[i]
if val_in_seconds:
duration_val = format_duration(val_in_seconds)
row[i] = duration_val
return result
for row in data.result:
index = col.fieldname if isinstance(row, dict) else i
if row[index]:
row[index] = format_duration(row[index])
def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False):
def build_xlsx_data(data, visible_idx, include_indentation, ignore_visible_idx=False):
result = [[]]
column_widths = []

View file

@ -9,7 +9,6 @@ from frappe import _, is_whitelisted
import re
import wrapt
UNTRANSLATED_DOCTYPES = ["DocType", "Role"]
def sanitize_searchfield(searchfield):
blacklisted_keywords = ['select', 'delete', 'drop', 'update', 'case', 'and', 'or', 'like']
@ -114,6 +113,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
or_filters = []
translated_search_doctypes = frappe.get_hooks("translated_search_doctypes")
# build from doctype
if txt:
search_fields = ["name"]
@ -125,7 +125,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
for f in search_fields:
fmeta = meta.get_field(f.strip())
if (doctype not in UNTRANSLATED_DOCTYPES) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text",
if (doctype not in translated_search_doctypes) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text",
"Link", "Select", "Read Only", "Text Editor"])):
or_filters.append([doctype, f.strip(), "like", "%{0}%".format(txt)])
@ -160,7 +160,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read'
ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))
if doctype in UNTRANSLATED_DOCTYPES:
if doctype in translated_search_doctypes:
page_length = None
values = frappe.get_list(doctype,
@ -175,7 +175,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0,
as_list=not as_dict,
strict=False)
if doctype in UNTRANSLATED_DOCTYPES:
if doctype in translated_search_doctypes:
# Filtering the values array so that query is included in very element
values = (
v for v in values
@ -257,7 +257,7 @@ def scrub_custom_query(query, key, txt):
def relevance_sorter(key, query, as_dict):
value = _(key.name if as_dict else key[0])
return (
value.lower().startswith(query.lower()) is not True,
cstr(value).lower().startswith(query.lower()) is not True,
value
)

View file

@ -15,8 +15,6 @@ from frappe.utils.csvutils import to_csv
from frappe.utils.xlsxutils import make_xlsx
from frappe.desk.query_report import build_xlsx_data
max_reports_per_user = frappe.local.conf.max_reports_per_user or 3
class AutoEmailReport(Document):
def autoname(self):
@ -46,6 +44,8 @@ class AutoEmailReport(Document):
def validate_report_count(self):
'''check that there are only 3 enabled reports per user'''
count = frappe.db.sql('select count(*) from `tabAuto Email Report` where user=%s and enabled=1', self.user)[0][0]
max_reports_per_user = frappe.local.conf.max_reports_per_user or 3
if count > max_reports_per_user + (-1 if self.flags.in_insert else 0):
frappe.throw(_('Only {0} emailed reports are allowed per user').format(max_reports_per_user))
@ -104,7 +104,7 @@ class AutoEmailReport(Document):
report_data['columns'] = columns
report_data['result'] = data
xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True)
xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True)
xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths)
return xlsx_file.getvalue()
@ -113,7 +113,7 @@ class AutoEmailReport(Document):
report_data['columns'] = columns
report_data['result'] = data
xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True)
xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True)
return to_csv(xlsx_data)
else:

View file

@ -111,7 +111,6 @@ class EmailQueue(Document):
""" Send emails to recipients.
"""
if not self.can_send_now():
frappe.db.rollback()
return
with SendMailContext(self, is_background_task) as ctx:

View file

@ -51,7 +51,7 @@ class TestNewsletterMixin:
"reference_name": newsletter,
})
frappe.delete_doc("Newsletter", newsletter)
frappe.db.delete("Newsletter Email Group", newsletter)
frappe.db.delete("Newsletter Email Group", {"parent": newsletter})
newsletters.remove(newsletter)
def setup_email_group(self):

View file

@ -240,7 +240,7 @@ class TestNotification(unittest.TestCase):
self.assertTrue(email_queue)
# check if description is changed after alert since set_property_after_alert is set
self.assertEquals(todo.description, 'Changed by Notification')
self.assertEqual(todo.description, 'Changed by Notification')
recipients = [d.recipient for d in email_queue.recipients]
self.assertTrue('test2@example.com' in recipients)

View file

@ -1,10 +1,12 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe
from frappe import msgprint, _
from frappe.utils.verified_command import get_signed_params, verify_request
from frappe.utils import get_url, now_datetime, cint
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
def get_emails_sent_this_month(email_account=None):
"""Get count of emails sent from a specific email account.
@ -162,15 +164,16 @@ def get_queue():
by priority desc, creation asc
limit 500''', { 'now': now_datetime() }, as_dict=True)
def clear_outbox(days=None):
def clear_outbox(days: int = None) -> None:
"""Remove low priority older than 31 days in Outbox or configured in Log Settings.
Note: Used separate query to avoid deadlock
"""
if not days:
days=31
days = days or 31
email_queue = DocType("Email Queue")
email_queues = frappe.db.sql_list("""SELECT `name` FROM `tabEmail Queue`
WHERE `priority`=0 AND `modified` < (NOW() - INTERVAL '{0}' DAY)""".format(days))
email_queues = frappe.qb.from_(email_queue).select(email_queue.name).where(
email_queue.modified < (Now() - Interval(days=days))
).run(pluck=True)
if email_queues:
frappe.db.delete("Email Queue", {"name": ("in", email_queues)})

View file

@ -203,12 +203,17 @@ def get_unread_update_logs(consumer_name, dt, dn):
SELECT
update_log.name
FROM `tabEvent Update Log` update_log
JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = update_log.name
JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = %(log_name)s
WHERE
consumer.consumer = %(consumer)s
AND update_log.ref_doctype = %(dt)s
AND update_log.docname = %(dn)s
""", {'consumer': consumer_name, "dt": dt, "dn": dn}, as_dict=0)]
""", {
"consumer": consumer_name,
"dt": dt,
"dn": dn,
"log_name": "update_log.name" if frappe.conf.db_type == "mariadb" else "CAST(update_log.name AS VARCHAR)"
}, as_dict=0)]
logs = frappe.get_all(
'Event Update Log',

View file

@ -110,3 +110,6 @@ class InvalidAuthorizationPrefix(CSRFTokenError): pass
class InvalidAuthorizationToken(CSRFTokenError): pass
class InvalidDatabaseFile(ValidationError): pass
class ExecutableNotFound(FileNotFoundError): pass
class InvalidRemoteException(Exception):
pass

View file

@ -7,6 +7,7 @@ import json
import requests
import frappe
from frappe.utils.data import cstr
class AuthError(Exception):
@ -122,7 +123,7 @@ class FrappeClient(object):
'''Update a remote document
:param doc: dict or Document object to be updated remotely. `name` is mandatory for this'''
url = self.url + "/api/resource/" + doc.get("doctype") + "/" + doc.get("name")
url = self.url + "/api/resource/" + doc.get("doctype") + "/" + cstr(doc.get("name"))
res = self.session.put(url, data={"data":frappe.as_json(doc)}, verify=self.verify, headers=self.headers)
return frappe._dict(self.post_process(res))
@ -207,7 +208,7 @@ class FrappeClient(object):
if fields:
params["fields"] = json.dumps(fields)
res = self.session.get(self.url + "/api/resource/" + doctype + "/" + name,
res = self.session.get(self.url + "/api/resource/" + doctype + "/" + cstr(name),
params=params, verify=self.verify, headers=self.headers)
return self.post_process(res)

17
frappe/handler.py Executable file → Normal file
View file

@ -251,11 +251,10 @@ def ping():
def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
"""run a whitelisted controller method"""
import inspect
import json
from inspect import getfullargspec
if not args:
args = arg or ""
if not args and arg:
args = arg
if dt: # not called from a doctype (from a page)
if not dn:
@ -263,9 +262,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
doc = frappe.get_doc(dt, dn)
else:
if isinstance(docs, str):
docs = json.loads(docs)
docs = frappe.parse_json(docs)
doc = frappe.get_doc(docs)
doc._original_modified = doc.modified
doc.check_if_latest()
@ -274,16 +271,16 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
throw_permission_error()
try:
args = json.loads(args)
args = frappe.parse_json(args)
except ValueError:
args = args
pass
method_obj = getattr(doc, method)
fn = getattr(method_obj, '__func__', method_obj)
is_whitelisted(fn)
is_valid_http_method(fn)
fnargs = inspect.getfullargspec(method_obj).args
fnargs = getfullargspec(method_obj).args
if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"):
response = doc.run_method(method)

View file

@ -394,4 +394,6 @@ override_whitelisted_methods = {
"frappe.core.doctype.file.file.create_new_folder": "frappe.core.api.file.create_new_folder",
"frappe.core.doctype.file.file.move_file": "frappe.core.api.file.move_file",
"frappe.core.doctype.file.file.zip_files": "frappe.core.api.file.zip_files",
}
}
translated_search_doctypes = ["DocType", "Role", "Country", "Gender", "Salutation"]

View file

@ -5,10 +5,11 @@ import json
import os
import sys
from collections import OrderedDict
from typing import List, Dict
from typing import List, Dict, Tuple
import frappe
from frappe.defaults import _clear_cache
from frappe.utils import is_git_url
def _new_site(
@ -34,7 +35,6 @@ def _new_site(
from frappe.commands.scheduler import _is_scheduler_enabled
from frappe.utils import get_site_path, scheduler, touch_file
if not force and os.path.exists(site):
print("Site {0} already exists".format(site))
sys.exit(1)
@ -124,6 +124,86 @@ def install_db(root_login=None, root_password=None, db_name=None, source_sql=Non
frappe.flags.in_install_db = False
def find_org(org_repo: str) -> Tuple[str, str]:
""" find the org a repo is in
find_org()
ref -> https://github.com/frappe/bench/blob/develop/bench/utils/__init__.py#L390
:param org_repo:
:type org_repo: str
:raises InvalidRemoteException: if the org is not found
:return: organisation and repository
:rtype: Tuple[str, str]
"""
from frappe.exceptions import InvalidRemoteException
import requests
for org in ["frappe", "erpnext"]:
res = requests.head(f"https://api.github.com/repos/{org}/{org_repo}")
if res.ok:
return org, org_repo
raise InvalidRemoteException
def fetch_details_from_tag(_tag: str) -> Tuple[str, str, str]:
""" parse org, repo, tag from string
fetch_details_from_tag()
ref -> https://github.com/frappe/bench/blob/develop/bench/utils/__init__.py#L403
:param _tag: input string
:type _tag: str
:return: organisation, repostitory, tag
:rtype: Tuple[str, str, str]
"""
app_tag = _tag.split("@")
org_repo = app_tag[0].split("/")
try:
repo, tag = app_tag
except ValueError:
repo, tag = app_tag + [None]
try:
org, repo = org_repo
except Exception:
org, repo = find_org(org_repo[0])
return org, repo, tag
def parse_app_name(name: str) -> str:
"""parse repo name from name
__setup_details_from_git()
ref -> https://github.com/frappe/bench/blob/develop/bench/app.py#L114
:param name: git tag
:type name: str
:return: repository name
:rtype: str
"""
name = name.rstrip("/")
if os.path.exists(name):
repo = os.path.split(name)[-1]
elif is_git_url(name):
if name.startswith("git@") or name.startswith("ssh://"):
_repo = name.split(":")[1].rsplit("/", 1)[1]
else:
_repo = name.rsplit("/", 2)[2]
repo = _repo.split(".")[0]
else:
_, repo, _ = fetch_details_from_tag(name)
return repo
def install_app(name, verbose=False, set_as_patched=True):
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
from frappe.model.sync import sync_for
@ -140,7 +220,8 @@ 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:
install_app(app, verbose=verbose)
name = parse_app_name(name)
install_app(name, verbose=verbose)
frappe.flags.in_install = name
frappe.clear_cache()
@ -611,7 +692,7 @@ def is_downgrade(sql_file_path, verbose=False):
downgrade = backup_version > current_version
if verbose and downgrade:
print("Your site will be downgraded from Frappe {0} to {1}".format(current_version, backup_version))
print(f"Your site will be downgraded from Frappe {backup_version} to {current_version}")
return downgrade

View file

@ -475,7 +475,7 @@ class BaseDocument(object):
d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE)
# don't update name, as case might've been changed
name = d['name']
name = cstr(d['name'])
del d['name']
columns = list(d)
@ -963,10 +963,13 @@ class BaseDocument(object):
from frappe.model.meta import get_default_df
df = get_default_df(fieldname)
if not currency and df:
currency = self.get(df.get("options"))
if not frappe.db.exists('Currency', currency, cache=True):
currency = None
if (
df.fieldtype == "Currency"
and not currency
and (currency_field := df.get("options"))
and (currency_value := self.get(currency_field))
):
currency = frappe.db.get_value('Currency', currency_value, cache=True)
val = self.get(fieldname)

View file

@ -164,7 +164,8 @@ class DatabaseQuery(object):
# left join parent, child tables
for child in self.tables[1:]:
args.tables += f" {self.join} {child} on ({child}.parent = {self.tables[0]}.name)"
parent_name = self.cast_name(f"{self.tables[0]}.name")
args.tables += f" {self.join} {child} on ({child}.parent = {parent_name})"
if self.grouped_or_conditions:
self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})")
@ -318,21 +319,60 @@ class DatabaseQuery(object):
]
# add tables from fields
if self.fields:
for field in self.fields:
if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field):
for i, field in enumerate(self.fields):
# add cast in locate/strpos
func_found = False
for func in sql_functions:
if func in field.lower():
self.fields[i] = self.cast_name(field, func)
func_found = True
break
if func_found or not ("tab" in field and "." in field):
continue
table_name = field.split('.')[0]
if table_name.lower().startswith('group_concat('):
table_name = table_name[13:]
if table_name.lower().startswith('ifnull('):
table_name = table_name[7:]
if not table_name[0]=='`':
table_name = f"`{table_name}`"
if table_name not in self.tables:
self.append_table(table_name)
def cast_name(self, column: str, sql_function: str = "",) -> str:
if frappe.db.db_type == "postgres":
if "name" in column.lower():
if "cast(" not in column.lower() or "::" not in column:
if not sql_function:
return f"cast({column} as varchar)"
elif sql_function == "locate(":
return re.sub(
r'locate\(([^,]+),([^)]+)\)',
r'locate(\1, cast(\2 as varchar))',
column,
flags=re.IGNORECASE
)
elif sql_function == "strpos(":
return re.sub(
r'strpos\(([^,]+),([^)]+)\)',
r'strpos(cast(\1 as varchar), \2)',
column,
flags=re.IGNORECASE
)
elif sql_function == "ifnull(":
return re.sub(
r"ifnull\(([^,]+)",
r"ifnull(cast(\1 as varchar)",
column,
flags=re.IGNORECASE
)
return column
def append_table(self, table_name):
self.tables.append(table_name)
doctype = table_name[4:-1]
@ -423,6 +463,8 @@ class DatabaseQuery(object):
ifnull(`tabDocType`.`fieldname`, fallback) operator "value"
"""
# TODO: refactor
from frappe.boot import get_additional_filters_from_hooks
additional_filters_config = get_additional_filters_from_hooks()
f = get_filter(self.doctype, f, additional_filters_config)
@ -432,15 +474,16 @@ class DatabaseQuery(object):
self.append_table(tname)
if 'ifnull(' in f.fieldname:
column_name = f.fieldname
column_name = self.cast_name(f.fieldname, "ifnull(")
else:
column_name = f"{tname}.{f.fieldname}"
can_be_null = True
column_name = self.cast_name(f"{tname}.{f.fieldname}")
if f.operator.lower() in additional_filters_config:
f.update(get_additional_filter_field(additional_filters_config, f, f.value))
meta = frappe.get_meta(f.doctype)
can_be_null = True
# prepare in condition
if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'):
values = f.value or ''
@ -449,12 +492,8 @@ class DatabaseQuery(object):
# if not isinstance(values, (list, tuple)):
# values = values.split(",")
ref_doctype = f.doctype
if frappe.get_meta(f.doctype).get_field(f.fieldname) is not None :
ref_doctype = frappe.get_meta(f.doctype).get_field(f.fieldname).options
result=[]
field = meta.get_field(f.fieldname)
ref_doctype = field.options if field else f.doctype
lft, rgt = '', ''
if f.value:
@ -474,29 +513,30 @@ class DatabaseQuery(object):
}, order_by='`lft` DESC')
fallback = "''"
value = [frappe.db.escape((v.name or '').strip(), percent=False) for v in result]
value = [frappe.db.escape((cstr(v.name) or '').strip(), percent=False) for v in result]
if len(value):
value = f"({', '.join(value)})"
else:
value = "('')"
# changing operator to IN as the above code fetches all the parent / child values and convert into tuple
# which can be directly used with IN operator to query.
f.operator = 'not in' if f.operator.lower() in ('not ancestors of', 'not descendants of') else 'in'
elif f.operator.lower() in ('in', 'not in'):
values = f.value or ''
if isinstance(values, str):
values = values.split(",")
fallback = "''"
value = [frappe.db.escape((v or '').strip(), percent=False) for v in values]
value = [frappe.db.escape((cstr(v) or '').strip(), percent=False) for v in values]
if len(value):
value = f"({', '.join(value)})"
else:
value = "('')"
else:
df = frappe.get_meta(f.doctype).get("fields", {"fieldname": f.fieldname})
df = meta.get("fields", {"fieldname": f.fieldname})
df = df[0] if df else None
if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"):
@ -513,7 +553,8 @@ class DatabaseQuery(object):
fallback = "'0001-01-01 00:00:00'"
elif f.operator.lower() in ('between') and \
(f.fieldname in ('creation', 'modified') or (df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))):
(f.fieldname in ('creation', 'modified') or
(df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))):
value = get_between_date_filter(f.value, df)
fallback = "'0001-01-01 00:00:00'"
@ -528,7 +569,7 @@ class DatabaseQuery(object):
fallback = "''"
can_be_null = True
if 'ifnull' not in column_name:
if 'ifnull' not in column_name.lower():
column_name = f'ifnull({column_name}, {fallback})'
elif df and df.fieldtype=="Date":
@ -570,7 +611,7 @@ class DatabaseQuery(object):
value = f"{tname}.{quote}{f.value.name}{quote}"
# escape value
elif isinstance(value, str) and not f.operator.lower() == 'between':
elif isinstance(value, str) and f.operator.lower() != 'between':
value = f"{frappe.db.escape(value, percent=False)}"
if (

View file

@ -158,7 +158,7 @@ def update_naming_series(doc):
and getattr(doc, "naming_series", None):
revert_series_if_last(doc.naming_series, doc.name, doc)
elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash"):
elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"):
revert_series_if_last(doc.meta.autoname, doc.name, doc)
def delete_from_table(doctype, name, ignore_doctypes, doc):

View file

@ -1,14 +1,18 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from typing import Optional
from typing import Optional, TYPE_CHECKING, Union
import frappe
from frappe import _
from frappe.database.sequence import get_next_val, set_next_val
from frappe.utils import now_datetime, cint, cstr
import re
from frappe.model import log_types
from frappe.query_builder import DocType
if TYPE_CHECKING:
from frappe.model.meta import Meta
def set_new_name(doc):
"""
@ -24,11 +28,16 @@ def set_new_name(doc):
doc.run_method("before_naming")
autoname = frappe.get_meta(doc.doctype).autoname or ""
meta = frappe.get_meta(doc.doctype)
autoname = meta.autoname or ""
if autoname.lower() != "prompt" and not frappe.flags.in_import:
doc.name = None
if is_autoincremented(doc.doctype, meta):
doc.name = get_next_val(doc.doctype)
return
if getattr(doc, "amended_from", None):
_set_amended_name(doc)
return
@ -64,9 +73,37 @@ def set_new_name(doc):
doc.name = validate_name(
doc.doctype,
doc.name,
frappe.get_meta(doc.doctype).get_field("name_case")
meta.get_field("name_case")
)
def is_autoincremented(doctype: str, meta: "Meta" = None):
if doctype in log_types:
if frappe.local.autoincremented_status_map.get(frappe.local.site) is None or \
frappe.local.autoincremented_status_map[frappe.local.site] == -1:
if frappe.db.sql(
f"""select data_type FROM information_schema.columns
where column_name = 'name' and table_name = 'tab{doctype}'"""
)[0][0] == "bigint":
frappe.local.autoincremented_status_map[frappe.local.site] = 1
return True
else:
frappe.local.autoincremented_status_map[frappe.local.site] = 0
elif frappe.local.autoincremented_status_map[frappe.local.site]:
return True
else:
if not meta:
meta = frappe.get_meta(doctype)
if getattr(meta, "issingle", False):
return False
if meta.autoname == "autoincrement":
return True
return False
def set_name_from_naming_options(autoname, doc):
"""
Get a name based on the autoname field option
@ -284,9 +321,19 @@ def get_default_naming_series(doctype):
return None
def validate_name(doctype: str, name: str, case: Optional[str] = None):
def validate_name(doctype: str, name: Union[int, str], case: Optional[str] = None):
if not name:
frappe.throw(_("No Name Specified for {0}").format(doctype))
if isinstance(name, int):
if is_autoincremented(doctype):
# this will set the sequence val to be the provided name and set it to be used
# so that the sequence will start from the next val of the setted val(name)
set_next_val(doctype, name, is_val_used=True)
return name
frappe.throw(_("Invalid name type (integer) for varchar name column"), frappe.NameError)
if name.startswith("New "+doctype):
frappe.throw(_("There were some errors setting the name, please contact the administrator"), frappe.NameError)
if case == "Title Case":

View file

@ -272,7 +272,7 @@ def make_boilerplate(template, doc, opts=None):
frappe.utils.cstr(source.read()).format(
app_publisher=app_publisher,
year=frappe.utils.nowdate()[:4],
classname=doc.name.replace(" ", ""),
classname=doc.name.replace(" ", "").replace("-", ""),
base_class_import=base_class_import,
base_class=base_class,
doctype=doc.name, **opts,

View file

@ -197,4 +197,5 @@ frappe.patches.v14_0.copy_mail_data #08.03.21
frappe.patches.v14_0.update_github_endpoints #08-11-2021
frappe.patches.v14_0.remove_db_aggregation
frappe.patches.v14_0.update_color_names_in_kanban_board_column
frappe.patches.v14_0.update_is_system_generated_flag
frappe.patches.v14_0.update_auto_account_deletion_duration

View file

@ -0,0 +1,17 @@
import frappe
def execute():
# assuming all customization generated by Admin is system generated customization
custom_field = frappe.qb.DocType("Custom Field")
(
frappe.qb.update(custom_field)
.set(custom_field.is_system_generated, True)
.where(custom_field.owner == 'Administrator').run()
)
property_setter = frappe.qb.DocType("Property Setter")
(
frappe.qb.update(property_setter)
.set(property_setter.is_system_generated, True)
.where(property_setter.owner == 'Administrator').run()
)

View file

@ -594,4 +594,4 @@ def is_parent_valid(child_doctype, parent_doctype):
from frappe.core.utils import find
parent_meta = frappe.get_meta(parent_doctype)
child_table_field_exists = find(parent_meta.get_table_fields(), lambda d: d.options == child_doctype)
return not parent_meta.istable and child_table_field_exists
return not parent_meta.istable and child_table_field_exists

View file

@ -24,7 +24,7 @@ ul.tree-children {
}
.tree-link .node-parent,
.tree-link .node-leaf {
margin-right: 5px;
margin-right: 8px;
}
.tree-link.active i {
color: #5e64ff;

View file

@ -526,11 +526,13 @@ export default {
error: true
});
capture.show();
capture.submit(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
this.url_to_file(data_url, filename, 'image/png').then((file) =>
this.add_files([file])
);
capture.submit(data_urls => {
data_urls.forEach(data_url => {
let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`;
this.url_to_file(data_url, filename, 'image/png').then((file) =>
this.add_files([file])
);
});
});
},
show_google_drive_picker() {

View file

@ -37,8 +37,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
if(this.frm) {
me.parse_validate_and_set_in_model(null);
me.refresh();
me.frm.attachments.remove_attachment_by_filename(me.value, function() {
me.parse_validate_and_set_in_model(null);
me.frm.attachments.remove_attachment_by_filename(me.value, async () => {
await me.parse_validate_and_set_in_model(null);
me.refresh();
me.frm.doc.docstatus == 1 ? me.frm.save('Update') : me.frm.save();
});
@ -110,9 +110,9 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro
return this.value || null;
}
on_upload_complete(attachment) {
async on_upload_complete(attachment) {
if(this.frm) {
this.parse_validate_and_set_in_model(attachment.file_url);
await this.parse_validate_and_set_in_model(attachment.file_url);
this.frm.attachments.update_attachment(attachment);
this.frm.doc.docstatus == 1 ? this.frm.save('Update') : this.frm.save();
}

View file

@ -454,7 +454,10 @@ class FormTimeline extends BaseTimeline {
let edit_box = this.make_editable(edit_wrapper);
let content_wrapper = comment_wrapper.find('.content');
let more_actions_wrapper = comment_wrapper.find('.more-actions');
if (frappe.model.can_delete("Comment")) {
if (frappe.model.can_delete("Comment") && (
frappe.session.user == doc.owner ||
frappe.user.has_role("System Manager")
)) {
const delete_option = $(`
<li>
<a class="dropdown-item">

View file

@ -246,10 +246,12 @@ frappe.ui.form.Form = class FrappeForm {
var me = this;
// on main doc
frappe.model.on(me.doctype, "*", function(fieldname, value, doc) {
frappe.model.on(me.doctype, "*", function(fieldname, value, doc, skip_dirty_trigger=false) {
// set input
if(doc.name===me.docname) {
me.dirty();
if (cstr(doc.name) === me.docname) {
if (!skip_dirty_trigger) {
me.dirty();
}
let field = me.fields_dict[fieldname];
field && field.refresh(fieldname);
@ -953,10 +955,12 @@ frappe.ui.form.Form = class FrappeForm {
this.toolbar.set_primary_action();
}
disable_save() {
disable_save(set_dirty=false) {
// IMPORTANT: this function should be called in refresh event
this.save_disabled = true;
this.toolbar.current_status = null;
// field changes should make form dirty
this.set_dirty = set_dirty;
this.page.clear_primary_action();
}
@ -1447,7 +1451,7 @@ frappe.ui.form.Form = class FrappeForm {
return doc;
}
set_value(field, value, if_missing) {
set_value(field, value, if_missing, skip_dirty_trigger=false) {
var me = this;
var _set = function(f, v) {
var fieldobj = me.fields_dict[f];
@ -1467,7 +1471,7 @@ frappe.ui.form.Form = class FrappeForm {
me.refresh_field(f);
return Promise.resolve();
} else {
return frappe.model.set_value(me.doctype, me.doc.name, f, v);
return frappe.model.set_value(me.doctype, me.doc.name, f, v, me.fieldtype, skip_dirty_trigger);
}
}
} else {
@ -1697,13 +1701,17 @@ frappe.ui.form.Form = class FrappeForm {
}
update_in_all_rows(table_fieldname, fieldname, value) {
// update the child value in all tables where it is missing
if(!value) return;
var cl = this.doc[table_fieldname] || [];
for(var i = 0; i < cl.length; i++){
if(!cl[i][fieldname]) cl[i][fieldname] = value;
}
refresh_field("items");
// Update the `value` of the field named `fieldname` in all rows of the
// child table named `table_fieldname`.
// Do not overwrite existing values.
if (value === undefined) return;
frappe.model
.get_children(this.doc, table_fieldname)
.filter(child => !frappe.model.has_value(child.doctype, child.name, fieldname))
.forEach(child =>
frappe.model.set_value(child.doctype, child.name, fieldname, value)
);
}
get_sum(table_fieldname, fieldname) {

View file

@ -35,7 +35,7 @@ export default class Grid {
&& this.frm.meta.__form_grid_templates[this.df.fieldname]) {
this.template = this.frm.meta.__form_grid_templates[this.df.fieldname];
}
this.filter = {};
this.is_grid = true;
this.debounced_refresh = this.refresh.bind(this);
this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 100);
@ -274,6 +274,8 @@ export default class Grid {
}
make_head() {
if (this.prevent_build) return;
// labels
if (this.header_row) {
$(this.parent).find(".grid-heading-row .grid-row").remove();
@ -286,12 +288,42 @@ export default class Grid {
grid: this,
configure_columns: true
});
this.header_search = new GridRow({
parent: $(this.parent).find(".grid-heading-row"),
parent_df: this.df,
docfields: this.docfields,
frm: this.frm,
grid: this,
show_search: true
});
Object.keys(this.filter).length !== 0 &&
this.update_search_columns();
}
refresh(force) {
update_search_columns() {
for (const field in this.filter) {
if (this.filter[field] && !this.header_search.search_columns[field]) {
delete this.filter[field];
this.data = this.get_data(Object.keys(this.filter).length !== 0);
break;
}
if (this.filter[field] && this.filter[field].value) {
let $input = this.header_search.row_index.find('input');
if (field && field !== 'row-index') {
$input = this.header_search.search_columns[field].find('input');
}
$input.val(this.filter[field].value);
}
}
}
refresh() {
if (this.frm && this.frm.setting_dependency) return;
this.data = this.get_data();
this.data = this.get_data(Object.keys(this.filter).length !== 0);
!this.wrapper && this.make();
let $rows = $(this.parent).find('.rows');
@ -453,7 +485,7 @@ export default class Grid {
}
make_sortable($rows) {
new Sortable($rows.get(0), {
this.grid_sortable = new Sortable($rows.get(0), {
group: { name: this.df.fieldname },
handle: '.sortable-handle',
draggable: '.grid-row',
@ -484,14 +516,78 @@ export default class Grid {
$(this.frm.wrapper).trigger("grid-make-sortable", [this.frm]);
}
get_data() {
var data = this.frm ?
this.frm.doc[this.df.fieldname] || []
: this.df.data || this.get_modal_data();
// data.sort(function(a, b) { return a.idx - b.idx});
get_data(filter_field) {
let data = [];
if (filter_field) {
data = this.get_filtered_data();
} else {
data = this.frm ?
this.frm.doc[this.df.fieldname] || []
: this.df.data || this.get_modal_data();
}
return data;
}
get_filtered_data() {
if (!this.frm) return;
let all_data = this.frm.doc[this.df.fieldname];
for (const field in this.filter) {
all_data = all_data.filter(data => {
let {df, value} = this.filter[field];
return this.get_data_based_on_fieldtype(df, data, value.toLowerCase());
});
}
return all_data;
}
get_data_based_on_fieldtype(df, data, value) {
let fieldname = df.fieldname;
let fieldtype = df.fieldtype;
let fieldvalue = data[fieldname];
if (fieldtype === "Check") {
value = frappe.utils.string_to_boolean(value);
return (Boolean(fieldvalue) === value) && data;
} else if (fieldtype === "Sr No" && data.idx.toString().includes(value)) {
return data;
} else if (fieldtype === "Duration" && fieldvalue) {
let formatted_duration = frappe.utils.get_formatted_duration(fieldvalue);
if (formatted_duration.includes(value)) {
return data;
}
} else if (fieldtype === "Barcode" && fieldvalue) {
let barcode = fieldvalue.startsWith('<svg') ?
$(fieldvalue).attr('data-barcode-value') : fieldvalue;
if (barcode.toLowerCase().includes(value)) {
return data;
}
} else if (["Datetime", "Date"].includes(fieldtype) && fieldvalue) {
let user_formatted_date = frappe.datetime.str_to_user(fieldvalue);
if (user_formatted_date.includes(value)) {
return data;
}
} else if (["Currency", "Float", "Int", "Percent", "Rating"].includes(fieldtype)) {
let num = fieldvalue || 0;
if (fieldtype === "Rating") {
let out_of_rating = parseInt(df.options) || 5;
num = num * out_of_rating;
}
if (num.toString().indexOf(value) > -1) {
return data;
}
} else if (fieldvalue && fieldvalue.toLowerCase().includes(value)) {
return data;
}
}
get_modal_data() {
return this.df.get_data ? this.df.get_data().filter(data => {
if (!this.deleted_docs || !in_list(this.deleted_docs, data.name)) {
@ -775,18 +871,19 @@ export default class Grid {
}
setup_user_defined_columns() {
if (this.frm) {
let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView');
if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) {
this.user_defined_columns = user_settings[this.doctype].map(row => {
let column = frappe.meta.get_docfield(this.doctype, row.fieldname);
if (column) {
column.in_list_view = 1;
column.columns = row.columns;
return column;
}
});
}
if (!this.frm) return;
let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView');
if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) {
this.user_defined_columns = user_settings[this.doctype].map(row => {
let column = frappe.meta.get_docfield(this.doctype, row.fieldname);
if (column) {
column.in_list_view = 1;
column.columns = row.columns;
return column;
}
});
}
}

View file

@ -8,7 +8,7 @@ export default class GridRow {
this.set_docfields();
this.columns = {};
this.columns_list = [];
this.row_check_html = '<input type="checkbox" class="grid-row-check pull-left">';
this.row_check_html = '<input type="checkbox" class="grid-row-check">';
this.make();
}
make() {
@ -204,23 +204,65 @@ export default class GridRow {
}));
}
render_row(refresh) {
var me = this;
if (this.show_search && !this.show_search_row()) return;
let me = this;
this.set_row_index();
// index (1, 2, 3 etc)
if(!this.row_index) {
if (!this.row_index && !this.show_search) {
// REDESIGN-TODO: Make translation contextual, this No is Number
var txt = (this.doc ? this.doc.idx : __("No."));
this.row_index = $(
`<div class="row-index sortable-handle col">
this.row_check = $(
`<div class="row-check sortable-handle col">
${this.row_check_html}
<span class="hidden-xs">${txt}</span></div>`)
</div>`)
.appendTo(this.row);
this.row_index = $(
`<div class="row-index sortable-handle col hidden-xs">
<span>${txt}</span>
</div>`)
.appendTo(this.row)
.on('click', function(e) {
if(!$(e.target).hasClass('grid-row-check')) {
me.toggle_view();
}
});
} else if (this.show_search) {
this.row_check = $(
`<div class="row-check col search"></div>`
).appendTo(this.row);
this.row_index = $(
`<div class="row-index col search hidden-xs">
<input type="text" class="form-control input-xs text-center" >
</div>`
).appendTo(this.row);
this.row_index.find('input').on('keyup', frappe.utils.debounce((e) => {
let df = {
fieldtype: "Sr No"
};
this.grid.filter['row-index'] = {
df: df,
value: e.target.value
};
if (e.target.value == "") {
delete this.grid.filter['row-index'];
}
this.grid.grid_sortable
.option('disabled', Object.keys(this.grid.filter).length !== 0);
this.grid.prevent_build = true;
me.grid.refresh();
this.grid.prevent_build = false;
}, 500));
frappe.utils.only_allow_num_decimal(this.row_index.find('input'));
} else {
this.row_index.find('span').html(txt);
}
@ -546,6 +588,7 @@ export default class GridRow {
setup_columns() {
this.focus_set = false;
this.search_columns = {};
this.grid.setup_visible_columns();
this.grid.visible_columns.forEach((col, ci) => {
@ -561,8 +604,10 @@ export default class GridRow {
txt = __(txt);
}
let column;
if (!this.columns[df.fieldname]) {
if (!this.columns[df.fieldname] && !this.show_search) {
column = this.make_column(df, colsize, txt, ci);
} else if (!this.columns[df.fieldname] && this.show_search) {
column = this.make_search_column(df, colsize);
} else {
column = this.columns[df.fieldname];
this.refresh_field(df.fieldname, txt);
@ -580,6 +625,77 @@ export default class GridRow {
}
}
});
if (this.show_search) {
// last empty column
$(`<div class="col grid-static-col col-xs-1"></div>`)
.appendTo(this.row);
}
}
show_search_row() {
// show or remove search columns based on grid rows
this.show_search = this.frm && this.frm.doc &&
this.frm.doc[this.grid.df.fieldname] &&
this.frm.doc[this.grid.df.fieldname].length >= 20;
!this.show_search && this.wrapper.remove();
return this.show_search;
}
make_search_column(df, colsize) {
let title = "";
let input_class = "";
let is_disabled = "";
if (["Text", "Small Text"].includes(df.fieldtype)) {
input_class = "grid-overflow-no-ellipsis";
} else if (["Int", "Currency", "Float", "Percent"].includes(df.fieldtype)) {
input_class = "text-right";
} else if (df.fieldtype === "Check") {
title = __("1 = True & 0 = False");
input_class = "text-center";
} else if (df.fieldtype === 'Password') {
is_disabled = 'disabled';
title = __('Password cannot be filtered');
}
let $col = $('<div class="col grid-static-col col-xs-'+colsize+' search"></div>')
.appendTo(this.row);
let $search_input = $(`
<input
type="text"
class="form-control input-xs ${input_class}"
title="${title}"
data-fieldtype="${df.fieldtype}"
${is_disabled}
>
`).appendTo($col);
this.search_columns[df.fieldname] = $col;
$search_input.on('keyup', frappe.utils.debounce((e) => {
this.grid.filter[df.fieldname] = {
df: df,
value: e.target.value
};
if (e.target.value == '') {
delete this.grid.filter[df.fieldname];
}
this.grid.grid_sortable
.option('disabled', Object.keys(this.grid.filter).length !== 0);
this.grid.prevent_build = true;
this.grid.refresh();
this.grid.prevent_build = false;
}, 500));
["Currency", "Float", "Int", "Percent", "Rating"].includes(df.fieldtype) &&
frappe.utils.only_allow_num_decimal($search_input);
return $col;
}
make_column(df, colsize, txt, ci) {

View file

@ -44,8 +44,17 @@ frappe.ui.form.Attachments = class Attachments {
// add attachment objects
var attachments = this.get_attachments();
if(attachments.length) {
attachments.forEach(function(attachment) {
me.add_attachment(attachment)
let exists = {};
let unique_attachments = attachments.filter(attachment => {
return Object.prototype.hasOwnProperty.call(
exists,
attachment.file_name
)
? false
: (exists[attachment.file_name] = true);
});
unique_attachments.forEach(attachment => {
me.add_attachment(attachment);
});
} else {
this.attachments_label.removeClass("has-attachments");
@ -75,7 +84,19 @@ frappe.ui.form.Attachments = class Attachments {
remove_action = function(target_id) {
frappe.confirm(__("Are you sure you want to delete the attachment?"),
function() {
me.remove_attachment(target_id);
let target_attachment = me
.get_attachments()
.find(attachment => attachment.name === target_id);
let to_be_removed = me
.get_attachments()
.filter(
attachment =>
attachment.file_name ===
target_attachment.file_name
);
to_be_removed.forEach(attachment =>
me.remove_attachment(attachment.name)
);
}
);
return false;

View file

@ -534,14 +534,14 @@ frappe.ui.form.Toolbar = class Toolbar {
});
}
show_title_as_dirty() {
if(this.frm.save_disabled)
if (this.frm.save_disabled && !this.frm.set_dirty)
return;
if(this.frm.doc.__unsaved) {
if (this.frm.is_dirty()) {
this.page.set_indicator(__("Not Saved"), "orange");
}
$(this.frm.wrapper).attr("data-state", this.frm.doc.__unsaved ? "dirty" : "clean");
$(this.frm.wrapper).attr("data-state", this.frm.is_dirty() ? "dirty" : "clean");
}
show_jump_to_field_dialog() {

View file

@ -375,7 +375,7 @@ export default class ListSettings {
let me = this;
if (me.removed_fields) {
me.removed_fields.concat(fields);
me.removed_fields = me.removed_fields.concat(fields);
} else {
me.removed_fields = fields;
}

View file

@ -915,7 +915,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
return this.settings.get_form_link(doc);
}
const docname = doc.name.match(/[%'"#\s]/)
const docname = cstr(doc.name).match(/[%'"#\s]/)
? encodeURIComponent(doc.name)
: doc.name;

View file

@ -138,6 +138,7 @@ frappe.render_tree = function(opts) {
opts.base_url = frappe.urllib.get_base_url();
opts.landscape = false;
opts.print_css = frappe.boot.print_css;
opts.print_format_css_path = frappe.assets.bundled_asset('print_format.bundle.css');
var tree = frappe.render_template("print_tree", opts);
var w = window.open();

View file

@ -412,7 +412,7 @@ $.extend(frappe.model, {
}
},
set_value: function(doctype, docname, fieldname, value, fieldtype) {
set_value: function(doctype, docname, fieldname, value, fieldtype, skip_dirty_trigger=false) {
/* help: Set a value locally (if changed) and execute triggers */
var doc;
@ -438,11 +438,11 @@ $.extend(frappe.model, {
}
doc[key] = value;
tasks.push(() => frappe.model.trigger(key, value, doc));
tasks.push(() => frappe.model.trigger(key, value, doc, skip_dirty_trigger));
} else {
// execute link triggers (want to reselect to execute triggers)
if(in_list(["Link", "Dynamic Link"], fieldtype) && doc) {
tasks.push(() => frappe.model.trigger(key, value, doc));
tasks.push(() => frappe.model.trigger(key, value, doc, skip_dirty_trigger));
}
}
});
@ -467,7 +467,7 @@ $.extend(frappe.model, {
frappe.model.events[doctype][fieldname].push(fn);
},
trigger: function(fieldname, value, doc) {
trigger: function(fieldname, value, doc, skip_dirty_trigger=false) {
const tasks = [];
function enqueue_events(events) {
@ -477,7 +477,7 @@ $.extend(frappe.model, {
if (!fn) continue;
tasks.push(() => {
const return_value = fn(fieldname, value, doc);
const return_value = fn(fieldname, value, doc, skip_dirty_trigger);
// if the trigger returns a promise, return it,
// or use the default promise frappe.after_ajax

View file

@ -28,6 +28,24 @@ frappe._.get_data_uri = element => {
return data_uri;
};
function get_file_input() {
let input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.setAttribute("multiple", "");
return input;
}
function read(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* @description Frappe's Capture object.
*
@ -45,6 +63,9 @@ frappe.ui.Capture = class {
constructor(options = {}) {
this.options = frappe.ui.Capture.OPTIONS;
this.set_options(options);
this.facing_mode = "environment";
this.images = [];
}
set_options(options) {
@ -53,69 +74,71 @@ frappe.ui.Capture = class {
return this;
}
render() {
return navigator.mediaDevices.getUserMedia({ video: true }).then(stream => {
this.stream = stream;
show() {
this.build_dialog();
this.dialog = new frappe.ui.Dialog({
title: this.options.title,
animate: this.options.animate,
on_hide: () => this.stop_media_stream()
});
if (frappe.is_mobile()) {
this.show_for_mobile();
} else {
this.show_for_desktop();
}
}
this.dialog.get_close_btn().on('click', () => {
this.hide();
});
build_dialog() {
let me = this;
me.dialog = new frappe.ui.Dialog({
title: this.options.title,
animate: this.options.animate,
fields: [
{
fieldtype: "HTML",
fieldname: "capture"
},
{
fieldtype: "HTML",
fieldname: "total_count"
}
],
on_hide: this.stop_media_stream()
});
const set_take_photo_action = () => {
this.dialog.set_primary_action(__('Take Photo'), () => {
const data_url = frappe._.get_data_uri(video);
$e.find('.fc-p').attr('src', data_url);
me.$template = $(frappe.ui.Capture.TEMPLATE);
$e.find('.fc-s').hide();
$e.find('.fc-p').show();
let field = me.dialog.get_field("capture");
$(field.wrapper).html(me.$template);
this.dialog.set_secondary_action_label(__('Retake'));
this.dialog.get_secondary_btn().show();
this.dialog.set_primary_action(__('Submit'), () => {
this.hide();
if (this.callback) this.callback(data_url);
});
});
};
set_take_photo_action();
this.dialog.set_secondary_action(() => {
$e.find('.fc-p').hide();
$e.find('.fc-s').show();
this.dialog.get_secondary_btn().hide();
this.dialog.get_primary_btn().off('click');
set_take_photo_action();
});
this.dialog.get_secondary_btn().hide();
const $e = $(frappe.ui.Capture.TEMPLATE);
const video = $e.find('video')[0];
video.srcObject = this.stream;
video.play();
const $container = $(this.dialog.body);
$container.html($e);
me.dialog.get_close_btn().on('click', () => {
me.hide();
});
}
show() {
this.render()
show_for_mobile() {
let me = this;
if (!me.input) {
me.input = get_file_input();
}
me.input.onchange = async () => {
for (let file of me.input.files) {
let f = await read(file);
me.images.push(f);
}
me.render_preview();
me.dialog.show();
};
me.input.click();
}
show_for_desktop() {
let me = this;
this.render_stream()
.then(() => {
this.dialog.show();
me.dialog.show();
})
.catch(err => {
if (this.options.error) {
if (me.options.error) {
frappe.show_alert(frappe.ui.Capture.ERR_MESSAGE, 3);
}
@ -123,6 +146,159 @@ frappe.ui.Capture = class {
});
}
render_stream() {
let me = this;
let constraints = {
video: {
facingMode: this.facing_mode
}
};
return navigator.mediaDevices.getUserMedia(constraints).then(stream => {
me.stream = stream;
me.dialog.custom_actions.empty();
me.dialog.get_primary_btn().off('click');
me.setup_take_photo_action();
me.setup_preview_action();
me.setup_toggle_camera();
me.$template.find('.fc-stream-container').show();
me.$template.find('.fc-preview-container').hide();
me.video = me.$template.find('video')[0];
me.video.srcObject = me.stream;
me.video.load();
me.video.play();
});
}
render_preview() {
this.stop_media_stream();
this.$template.find('.fc-stream-container').hide();
this.$template.find('.fc-preview-container').show();
this.dialog.get_primary_btn().off('click');
let images = ``;
this.images.forEach((image, idx) => {
images += `
<div class="mt-1 p-1 rounded col-md-3 col-sm-4 col-xs-4" data-idx="${idx}">
<span class="capture-remove-btn" data-idx="${idx}">
${frappe.utils.icon("close", "lg")}
</span>
<img class="rounded" src="${image}" data-idx="${idx}">
</div>
`;
});
this.$template.find('.fc-preview-container').empty();
$(this.$template.find('.fc-preview-container')).html(
`<div class="row">
${images}
</div>`
);
this.setup_capture_action();
this.setup_submit_action();
this.setup_remove_action();
this.update_count();
this.dialog.custom_actions.empty();
}
setup_take_photo_action() {
let me = this;
this.dialog.set_primary_action(__('Take Photo'), () => {
const data_url = frappe._.get_data_uri(me.video);
me.images.push(data_url);
me.setup_preview_action();
me.update_count();
});
}
setup_preview_action() {
let me = this;
if (!this.images.length) {
return;
}
this.dialog.set_secondary_action_label(__("Preview"));
this.dialog.set_secondary_action(() => {
me.dialog.get_primary_btn().off('click');
me.render_preview();
});
}
setup_remove_action() {
let me = this;
let elements = this.$template[0].getElementsByClassName("capture-remove-btn");
elements.forEach(el => {
el.onclick = () => {
let idx = parseInt(el.getAttribute("data-idx"));
me.images.splice(idx, 1);
me.render_preview();
};
});
}
update_count() {
let field = this.dialog.get_field("total_count");
let msg = `${__("Total Images")}: <b>${this.images.length}`;
if (this.images.length === 0) {
msg = __("No Images");
}
$(field.wrapper).html(`
<div class="row mt-2">
<div class="offset-4 col-4 d-flex justify-content-center">${msg}</b></div>
</div>
`);
}
setup_toggle_camera() {
let me = this;
this.dialog.add_custom_action(__("Switch Camera"), () => {
me.facing_mode = me.facing_mode == "environment" ? "user" : "environment";
frappe.show_alert({
message: __("Switching Camera")
});
me.stop_media_stream();
me.render_stream();
}, "btn-switch");
}
setup_capture_action() {
let me = this;
this.dialog.set_secondary_action_label(__("Capture"));
this.dialog.set_secondary_action(() => {
if (frappe.is_mobile()) {
me.show_for_mobile();
} else {
me.render_stream();
}
});
}
setup_submit_action() {
let me = this;
this.dialog.set_primary_action(__('Submit'), () => {
me.hide();
if (me.callback) {
me.callback(me.images);
}
});
}
hide() {
if (this.dialog) this.dialog.hide();
this.stop_media_stream();
@ -148,11 +324,11 @@ frappe.ui.Capture.OPTIONS = {
frappe.ui.Capture.ERR_MESSAGE = __('Unable to load camera.');
frappe.ui.Capture.TEMPLATE = `
<div class="frappe-capture">
<div class="panel panel-default">
<div class="embed-responsive embed-responsive-16by9">
<img class="fc-p embed-responsive-item" style="object-fit: contain; display: none;"/>
<video class="fc-s embed-responsive-item">${frappe.ui.Capture.ERR_MESSAGE}</video>
</div>
<div class="embed-responsive embed-responsive-16by9 fc-stream-container">
<video class="fc-stream embed-responsive-item">${frappe.ui.Capture.ERR_MESSAGE}</video>
</div>
<div class="fc-preview-container px-2" style="display: none;">
</div>
</div>
`;

View file

@ -47,13 +47,17 @@ frappe.ui.Page = class Page {
}
setup_scroll_handler() {
window.addEventListener('scroll', () => {
if (document.documentElement.scrollTop) {
$('.page-head').toggleClass('drop-shadow', true);
let last_scroll = 0;
window.addEventListener('scroll', frappe.utils.throttle(() => {
$('.page-head').toggleClass('drop-shadow', !!document.documentElement.scrollTop);
let current_scroll = document.documentElement.scrollTop;
if (current_scroll > 0 && last_scroll <= current_scroll) {
$('.page-head').css("top", "-15px");
} else {
$('.page-head').removeClass('drop-shadow');
$('.page-head').css("top", "var(--navbar-height)");
}
});
last_scroll = current_scroll;
}), 500);
}
get_empty_state(title, message, primary_action) {

View file

@ -231,7 +231,7 @@ Object.assign(frappe.utils, {
if (tt && (tt.substr(0, 1)===">" || tt.substr(0, 4)==="&gt;")) {
part.push(t);
} else {
out.concat(part);
out = out.concat(part);
out.push(t);
part = [];
}
@ -1102,7 +1102,7 @@ Object.assign(frappe.utils, {
seconds: round(seconds % 60)
};
if (duration_options.hide_days) {
if (duration_options && duration_options.hide_days) {
total_duration.hours = round(seconds / 3600);
total_duration.days = 0;
}
@ -1462,5 +1462,23 @@ Object.assign(frappe.utils, {
console.log(error); // eslint-disable-line
return Promise.resolve(name);
}
},
only_allow_num_decimal(input) {
input.on('input', (e) => {
let self = $(e.target);
self.val(self.val().replace(/[^0-9.]/g, ''));
if ((e.which != 46 || self.val().indexOf('.') != -1) && (e.which < 48 || e.which > 57)) {
e.preventDefault();
}
});
},
string_to_boolean(string) {
switch (string.toLowerCase().trim()) {
case "t": case "true": case "y": case "yes": case "1": return true;
case "f": case "false": case "n": case "no": case "0": case null: return false;
default: return string;
}
}
});

View file

@ -29,7 +29,7 @@ frappe.views.CalendarView = class CalendarView extends frappe.views.ListView {
.then(() => {
this.page_title = __('{0} Calendar', [this.page_title]);
this.calendar_settings = frappe.views.calendar[this.doctype] || {};
this.calendar_name = frappe.utils.to_title_case(frappe.get_route()[3] || '');
this.calendar_name = frappe.get_route()[3];
});
}
@ -72,12 +72,17 @@ frappe.views.CalendarView = class CalendarView extends frappe.views.ListView {
const calendar_name = this.calendar_name;
return new Promise(resolve => {
if (calendar_name === 'Default') {
if (calendar_name === 'default') {
Object.assign(options, frappe.views.calendar[this.doctype]);
resolve(options);
} else {
frappe.model.with_doc('Calendar View', calendar_name, () => {
const doc = frappe.get_doc('Calendar View', calendar_name);
if (!doc) {
frappe.show_alert(__("{0} is not a valid Calendar. Redirecting to default Calendar.", [calendar_name.bold()]));
frappe.set_route("List", this.doctype, "Calendar", "default");
return;
}
Object.assign(options, {
field_map: {
id: "name",

View file

@ -1,91 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ title }}</title>
<link href="{{ base_url }}/assets/frappe/css/bootstrap.css" rel="stylesheet">
<link type="text/css" rel="stylesheet"
href="{{ base_url }}/assets/frappe/css/font-awesome.css">
<link rel="stylesheet" type="text/css" href="{{ base_url }}/assets/frappe/css/tree.css">
<style>
{{ print_css }}
</style>
<style>
.tree.opened::before,
.tree-node.opened::before,
.tree:last-child::after,
.tree-node:last-child::after {
z-index: 1;
border-left: 1px solid #d1d8dd;
background: none;
}
.tree a,
.tree-link {
text-decoration: none;
cursor: default;
}
.tree.opened > .tree-children > .tree-node > .tree-link::before,
.tree-node.opened > .tree-children > .tree-node > .tree-link::before {
border-top: 1px solid #d1d8dd;
z-index: 1;
background: none;
}
i.fa.fa-fw.fa-folder {
z-index: 2;
position: relative;
}
.tree:last-child::after, .tree-node:last-child::after {
display: none;
}
.tree-node-toolbar {
display: none;
}
i.octicon.octicon-primitive-dot.text-extra-muted {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d1d8dd;
display: inline-block;
position: relative;
z-index: 2;
}
@media (max-width: 767px) {
ul.tree-children {
padding-left: 20px;
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ title }}</title>
<link href="{{ base_url }}/assets/frappe/css/bootstrap.css" rel="stylesheet">
<link type="text/css" rel="stylesheet"
href="{{ base_url }}/assets/frappe/css/font-awesome.css">
<link rel="stylesheet" type="text/css" href="{{ base_url }}/assets/frappe/css/tree.css">
<link rel="stylesheet" type="text/css" href="{{ base_url }}{{ print_format_css_path }}">
<style>
{{ print_css }}
</style>
<style>
.tree.opened::before,
.tree-node.opened::before,
.tree:last-child::after,
.tree-node:last-child::after {
z-index: 1;
border-left: 1px solid #d1d8dd;
background: none;
}
.tree a,
.tree-link {
text-decoration: none;
cursor: default;
}
.tree.opened > .tree-children > .tree-node > .tree-link::before,
.tree-node.opened > .tree-children > .tree-node > .tree-link::before {
border-top: 1px solid #d1d8dd;
z-index: 1;
background: none;
}
i.fa.fa-fw.fa-folder {
z-index: 2;
position: relative;
}
.tree:last-child::after, .tree-node:last-child::after {
display: none;
}
.tree-node-toolbar {
display: none;
}
i.octicon.octicon-primitive-dot.text-extra-muted {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d1d8dd;
display: inline-block;
position: relative;
z-index: 2;
}
}
</style>
</head>
<body>
<div class="print-format-gutter">
{% if print_settings.repeat_header_footer %}
<div id="footer-html" class="visible-pdf">
{% if print_settings.letter_head && print_settings.letter_head.footer %}
<div class="letter-head-footer">
{{ print_settings.letter_head.footer }}
</div>
{% endif %}
<p class="text-center small page-number visible-pdf">
{{ __("Page {0} of {1}", [`<span class="page"></span>`, `<span class="topage"></span>`]) }}
</p>
</div>
{% endif %}
<div class="print-format {% if landscape %} landscape {% endif %}">
{% if print_settings.letter_head %}
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
<div class="letter-head">{{ print_settings.letter_head.header }}</div>
</div>
{% endif %}
<div class="tree opened">
{{ tree }}
@media (max-width: 767px) {
ul.tree-children {
padding-left: 20px;
}
}
</style>
</head>
<body>
<svg id="frappe-symbols" aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg">
<symbol viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" id="icon-primitive-dot">
<path d="M9.5 6a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0z"></path>
</symbol>
<symbol viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" id="icon-folder-open">
<path d="M8.024 6.5H3a.5.5 0 0 0-.5.5v8a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V9.5A.5.5 0 0 0 17 9h-6.783a.5.5 0 0 1-.417-.224L8.441 6.724a.5.5 0 0 0-.417-.224z" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="square"></path>
<path d="M3.88 4.5v-1a.5.5 0 0 1 .5-.5h11.24a.5.5 0 0 1 .5.5V7" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path>
</symbol>
<symbol viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" id="icon-folder-normal">
<path d="M2.5 4v10a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V6.5a1 1 0 0 0-1-1h-6.283a.5.5 0 0 1-.417-.224L8.441 3.224A.5.5 0 0 0 8.024 3H3.5a1 1 0 0 0-1 1z" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="square"></path>
</symbol>
</svg>
<div class="print-format-gutter">
{% if print_settings.repeat_header_footer %}
<div id="footer-html" class="visible-pdf">
{% if print_settings.letter_head && print_settings.letter_head.footer %}
<div class="letter-head-footer">
{{ print_settings.letter_head.footer }}
</div>
{% endif %}
<p class="text-center small page-number visible-pdf">
{{ __("Page {0} of {1}", [`<span class="page"></span>`, `<span class="topage"></span>`]) }}
</p>
</div>
</div>
</div>
</body>
{% endif %}
<div class="print-format {% if landscape %} landscape {% endif %}">
{% if print_settings.letter_head %}
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
<div class="letter-head">{{ print_settings.letter_head.header }}</div>
</div>
{% endif %}
<div class="tree opened">
{{ tree }}
</div>
</div>
</div>
</body>
</html>

View file

@ -1026,7 +1026,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
}
if (!docfield || docfield.report_hide) return;
let title = __(docfield ? docfield.label : toTitle(fieldname));
let title = __(docfield.label);
if (doctype !== this.doctype) {
title += ` (${__(doctype)})`;
}

View file

@ -25,7 +25,6 @@ export default class WebForm extends frappe.ui.FieldGroup {
this.setup_listeners();
if (this.introduction_text) this.set_form_description(this.introduction_text);
if (this.allow_print && !this.is_new) this.setup_print_button();
if (this.allow_delete && !this.is_new) this.setup_delete_button();
if (this.is_new) this.setup_cancel_button();
this.setup_primary_action();
this.setup_previous_next_button();
@ -79,9 +78,9 @@ export default class WebForm extends frappe.ui.FieldGroup {
}
$('.web-form-footer').after(`
<div id="form-step-footer" class="pull-right">
<button class="btn btn-primary btn-previous btn-sm ml-2">${__("Previous")}</button>
<button class="btn btn-primary btn-next btn-sm ml-2">${__("Next")}</button>
<div id="form-step-footer" class="text-right">
<button class="btn btn-default btn-previous btn-sm ml-2">${__("Previous")}</button>
<button class="btn btn-default btn-next btn-sm ml-2">${__("Next")}</button>
</div>
`);
@ -141,6 +140,7 @@ export default class WebForm extends frappe.ui.FieldGroup {
set_form_description(intro) {
let intro_wrapper = document.getElementById('introduction');
intro_wrapper.innerHTML = intro;
intro_wrapper.classList.remove('hidden');
}
add_button(name, type, action, wrapper_class=".web-form-actions") {
@ -164,25 +164,18 @@ export default class WebForm extends frappe.ui.FieldGroup {
this.save()
);
this.add_button_to_footer(this.button_label || __("Save", null, "Button in web form"), "primary", () =>
this.save()
);
if (!this.is_multi_step_form && $('.frappe-card').height() > 600) {
// add button on footer if page is long
this.add_button_to_footer(this.button_label || __("Save", null, "Button in web form"), "primary", () =>
this.save()
);
}
}
setup_cancel_button() {
this.add_button_to_header(__("Cancel", null, "Button in web form"), "light", () => this.cancel());
}
setup_delete_button() {
frappe.has_permission(this.doc_type, "", "delete", () => {
this.add_button_to_header(
frappe.utils.icon('delete'),
"danger",
() => this.delete()
);
});
}
setup_print_button() {
this.add_button_to_header(
frappe.utils.icon('print'),
@ -359,17 +352,6 @@ export default class WebForm extends frappe.ui.FieldGroup {
return true;
}
delete() {
frappe.call({
type: "POST",
method: "frappe.website.doctype.web_form.web_form.delete",
args: {
web_form_name: this.name,
docname: this.doc.name
}
});
}
print() {
window.open(`/printview?
doctype=${this.doc_type}
@ -386,21 +368,19 @@ export default class WebForm extends frappe.ui.FieldGroup {
window.location.href = data;
}
const success_dialog = new frappe.ui.Dialog({
title: __("Saved Successfully"),
secondary_action: () => {
if (this.success_url) {
window.location.href = this.success_url;
} else if(this.login_required) {
window.location.href =
window.location.pathname + "?name=" + data.name;
}
}
});
success_dialog.show();
const success_message =
this.success_message || __("Your information has been submitted");
success_dialog.set_message(success_message);
this.success_message || __("Submitted");
frappe.toast({message: success_message, indicator:'green'});
// redirect
setTimeout(() => {
if (this.success_url) {
window.location.href = this.success_url;
} else if(this.login_required) {
window.location.href =
window.location.pathname + "?name=" + data.name;
}
}, 2000);
}
}

View file

@ -6,7 +6,7 @@ export default class WebFormList {
constructor(opts) {
Object.assign(this, opts);
frappe.web_form_list = this;
this.wrapper = document.getElementById("datatable");
this.wrapper = document.getElementById("list-table");
this.make_actions();
this.make_filters();
$('.link-btn').remove();
@ -16,7 +16,8 @@ export default class WebFormList {
if (this.table) {
Array.from(this.table.tBodies).forEach(tbody => tbody.remove());
let check = document.getElementById('select-all');
check.checked = false;
if (check)
check.checked = false;
}
this.rows = [];
this.page_length = 20;
@ -131,9 +132,39 @@ export default class WebFormList {
this.make_table_head();
}
this.append_rows(this.data);
if (this.data.length) {
this.append_rows(this.data);
this.wrapper.appendChild(this.table);
} else {
let new_button = "";
let empty_state = document.createElement("div");
empty_state.classList.add("no-result", "text-muted", "flex", "justify-center", "align-center");
this.wrapper.appendChild(this.table);
frappe.has_permission(this.doctype, "", "create", () => {
new_button = `
<a
class="btn btn-primary btn-sm btn-new-doc hidden-xs"
href="${window.location.pathname}?new=1">
${__("Create a new {0}", [__(this.doctype)])}
</a>
`;
empty_state.innerHTML = `
<div class="text-center">
<div>
<img
src="/assets/frappe/images/ui-states/list-empty-state.svg"
alt="Generic Empty State"
class="null-state">
</div>
<p class="small mb-2">${__("No {0} found", [__(this.doctype)])}</p>
${new_button}
</div>
`;
this.wrapper.appendChild(empty_state);
});
}
}
make_table_head() {
@ -212,8 +243,7 @@ export default class WebFormList {
"btn",
"btn-secondary",
"btn-sm",
"ml-2",
"text-white"
"ml-2"
);
}
else if (type == "danger") {
@ -290,6 +320,7 @@ frappe.ui.WebFormListRow = class WebFormListRow {
make_row() {
// Add Checkboxes
let cell = this.row.insertCell();
cell.classList.add('list-col-checkbox');
this.checkbox = document.createElement("input");
this.checkbox.type = "checkbox";
@ -302,6 +333,7 @@ frappe.ui.WebFormListRow = class WebFormListRow {
// Add Serial Number
let serialNo = this.row.insertCell();
serialNo.classList.add('list-col-serial');
serialNo.innerText = this.serial_number;
this.columns.forEach(field => {

View file

@ -52,11 +52,11 @@ frappe.ready(function() {
const data = setup_fields(r.message);
let web_form_doc = data.web_form;
if (web_form_doc.name && web_form_doc.allow_edit === 0) {
if (!window.location.href.includes("?new=1")) {
window.location.replace(window.location.pathname + "?new=1");
}
}
// if (web_form_doc.name && web_form_doc.allow_edit === 0) {
// if (!window.location.href.includes("?new=1")) {
// window.location.replace(window.location.pathname + "?new=1");
// }
// }
let doc = r.message.doc || build_doc(r.message);
web_form.prepare(web_form_doc, r.message.doc && web_form_doc.allow_edit === 1 ? r.message.doc : {});
web_form.make();

View file

@ -460,3 +460,10 @@ button.data-pill {
justify-content: space-between;
align-items: center;
}
.capture-remove-btn {
position: absolute;
top: 0;
right: 0;
cursor: pointer;
}

View file

@ -89,6 +89,29 @@
height: 34px;
padding: 8px;
max-height: 200px;
&.search {
padding: 7px !important;
input {
height: -webkit-fill-available;
padding: 3px 7px;
}
}
}
.row-check {
height: 34px;
padding: 8px 3px !important;
text-align: center;
input {
margin-right: 0 !important;
}
&.search {
padding: 0 !important;
}
}
.grid-row-check {
@ -124,7 +147,6 @@
.grid-row > .row {
.col:last-child {
margin-right: calc(-1 * var(--margin-sm));
border-right: none;
}
@ -427,6 +449,7 @@
}
.page-number {
background-color: var(--fg-color);
padding: 0 3px;
}

View file

@ -111,8 +111,8 @@
}
.avatar-large {
width: 72px;
height: 72px;
width: 64px;
height: 64px;
.standard-image {
font-size: var(--text-2xl);

View file

@ -88,6 +88,7 @@
top: var(--navbar-height);
background: var(--bg-color);
margin-bottom: 5px;
transition: 0.5s top;
.page-head-content {
height: var(--page-head-height);
}

View file

@ -355,7 +355,7 @@ body[data-route^="Module"] .main-menu {
display: none;
}
input {
input:not([data-fieldtype='Check']) {
background: var(--control-bg-on-gray);
}

View file

@ -1,3 +1,13 @@
$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%;
}
@ -14,45 +24,80 @@ img {
height: auto;
}
h1, h2, h3, h4 {
font-weight: 600;
}
h1 {
font-size: $font-size-3xl;
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.025em;
margin-bottom: 1rem;
margin-top: 3rem;
margin-bottom: 0.75rem;
@include media-breakpoint-up(sm) {
font-size: $font-size-5xl;
line-height: 2.5rem;
font-size: $font-size-4xl;
margin-top: 3.5rem;
margin-bottom: 1.25rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-6xl;
line-height: 1;
font-size: $font-size-5xl;
margin-top: 4rem;
}
}
h2 {
font-size: $font-size-xl;
font-weight: 700;
font-size: $font-size-2xl;
margin-top: 2rem;
margin-bottom: 0.75rem;
@include media-breakpoint-up(sm) {
font-size: $font-size-2xl;
}
@include media-breakpoint-up(md) {
font-size: $font-size-3xl;
margin-top: 4rem;
margin-bottom: 1rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-4xl;
margin-top: 4rem;
}
}
h3 {
font-size: $font-size-base;
font-weight: 600;
font-size: $font-size-xl;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
@include media-breakpoint-up(sm) {
font-size: $font-size-lg;
font-size: $font-size-2xl;
margin-top: 2.5rem;
}
@include media-breakpoint-up(md) {
font-size: $font-size-xl;
@include media-breakpoint-up(xl) {
font-size: $font-size-3xl;
margin-top: 3.5rem;
}
}
h4 {
font-size: $font-size-lg;
margin-top: 1rem;
margin-bottom: 0.5rem;
@include media-breakpoint-up(sm) {
font-size: $font-size-xl;
margin-top: 1.25rem;
}
@include media-breakpoint-up(xl) {
font-size: $font-size-2xl;
margin-top: 1.75rem;
}
a {
color: $body-color;
}
}
.btn.btn-lg {
font-size: $font-size-lg;
}

View file

@ -57,12 +57,12 @@
.blog-card-footer {
display: flex;
align-items: center;
align-items: top;
margin-top: 0.5rem;
.avatar {
margin-top: 0.4rem;
margin-right: 0.5rem;
border-radius: 50%;
}
}
}
@ -119,106 +119,4 @@
}
}
}
.add-comment-button {
margin-left: 35px;
}
.timeline-dot {
width: 16px;
height: 16px;
border-radius: 50%;
position: absolute;
top: 8px;
left: 22px;
background-color: var(--fg-color);
border: 1px solid var(--dark-border-color);
&:before {
content: ' ';
background: var(--gray-600);
position: absolute;
top: 5px;
left: 5px;
border-radius: 50%;
height: 4px;
width: 4px;
}
}
.blog-comments {
.comment-form-wrapper {
display: none;
}
.add-comment-section {
.login-required {
padding: var(--padding-sm);
border-radius: var(--border-radius-sm);
box-shadow: var(--card-shadow);
}
.new-comment {
display: flex;
padding: var(--padding-lg);
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);
.new-comment-fields {
flex: 1;
.form-label {
font-weight: var(--text-bold);
}
.comment-text-area textarea {
resize: none;
}
@media (min-width: 576px) {
.comment-by {
padding-right: 0px !important;
padding-bottom: 0px !important;
}
}
}
}
}
#comment-list {
position: relative;
padding-left: var(--padding-xl);
&:before {
content: " ";
position: absolute;
top: var(--comment-timeline-top);
bottom: var(--comment-timeline-bottom);
border-left: 1px solid var(--dark-border-color);
}
.comment-row {
position: relative;
.comment-avatar {
position: absolute;
top: 10px;
left: -17px;
}
.comment-content {
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);
padding: var(--padding-md);
margin-left: 35px;
flex: 1;
.content p{
margin-bottom: 0px;
}
}
}
}
}
}

View file

@ -1,6 +1,8 @@
.web-footer {
padding: 5rem 0;
margin: 5rem 0;
min-height: 140px;
background-color: var(--fg-color);
border-top: 1px solid $border-color;
}
.footer-logo {
@ -76,8 +78,6 @@
}
.footer-info {
margin-top: 1rem;
border-top: 1px solid $border-color;
color: $text-muted;
font-size: $font-size-sm;
}
@ -98,4 +98,4 @@
font-size: $font-size-sm;
}
}
}
}

View file

@ -5,7 +5,6 @@
@import "../common/global";
@import "../common/icons";
@import "../common/alert";
@import 'base';
@import "../common/flex";
@import "../common/buttons";
@import "../common/modal";
@ -14,6 +13,7 @@
@import "../common/indicator";
@import "../common/controls";
@import "../common/awesomeplete";
@import 'base';
@import 'multilevel_dropdown';
@import 'website_image';
@import 'website_avatar';
@ -311,3 +311,16 @@ h5.modal-title {
.empty-list-icon {
height: 70px;
}
.null-state {
height: 60px;
width: auto;
margin-bottom: var(--margin-md);
img {
fill: var(--fg-color);
}
}
.no-result {
min-height: #{"calc(100vh - 284px)"};
}

View file

@ -1,30 +1,12 @@
$font-sizes-desktop: (
"sm": 0.75rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.41rem,
"2xl": 1.6rem,
"3xl": 2rem
);
$font-sizes-mobile: (
"sm": 0.75rem,
"base": 1rem,
"lg": 1.125rem,
"xl": 1.25rem,
"2xl": 1.5rem,
"3xl": 1.75rem
);
.section-markdown > .from-markdown {
max-width: 50rem;
margin: auto;
}
.from-markdown {
color: $gray-700;
line-height: 1.7;
letter-spacing: -0.011em;
> * + * {
margin-top: 0.75rem;
margin-bottom: 0;
}
> :first-child {
margin-top: 0;
@ -47,6 +29,10 @@ $font-sizes-mobile: (
list-style: decimal;
}
p, li {
font-size: $font-size-lg;
}
li {
padding-top: 1px;
padding-bottom: 1px;
@ -87,86 +73,6 @@ $font-sizes-mobile: (
font-weight: 600;
}
h1, h2, h3, h4, h5, h6 {
color: $gray-900;
}
h2, h3, h4, h5, h6 {
font-weight: 600;
}
h1 {
font-size: map-get($font-sizes-mobile, '3xl');
line-height: 1.5;
letter-spacing: -0.021em;
font-weight: 700;
@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, '3xl');
letter-spacing: -0.024em;
}
// for byline
& + p {
margin-top: 1.5rem;
font-size: map-get($font-sizes-mobile, 'xl');
letter-spacing: -0.014em;
line-height: 1.4;
@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, 'xl');
letter-spacing: -0.0175em;
}
}
}
h2 {
font-size: map-get($font-sizes-mobile, '2xl');
line-height: 1.56;
letter-spacing: -0.015em;
margin-top: 4rem;
@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, '2xl');
letter-spacing: -0.0195em;
}
}
h3 {
font-size: map-get($font-sizes-mobile, 'xl');
line-height: 1.56;
letter-spacing: -0.014em;
margin-top: 2.25rem;
@include media-breakpoint-up(md) {
font-size: map-get($font-sizes-desktop, 'xl');
letter-spacing: -0.0175em;
}
}
h4 {
font-size: map-get($font-sizes-mobile, 'lg');
line-height: 1.56;
letter-spacing: -0.014em;
margin-top: 2.5rem;
}
h5 {
font-size: map-get($font-sizes-mobile, 'base');
line-height: 1.5;
letter-spacing: -0.011em;
font-weight: 600;
margin-top: 2rem;
}
h6 {
font-size: map-get($font-sizes-mobile, 'sm');
line-height: 1.35;
font-weight: 600;
text-transform: uppercase;
margin-top: 1.5rem;
}
tr > td,
tr > th {
font-size: $font-size-sm;

View file

@ -27,15 +27,16 @@
}
}
.my-account-container {
max-width: 800px;
margin: auto;
}
.account-info {
background-color: var(--fg-color);
box-shadow: var(--card-shadow);
border-radius: var(--border-radius-md);
padding: var(--padding-sm) 25px;
max-width: 850px;
@include media-breakpoint-up(sm) {
margin-left: 0;
}
@include media-breakpoint-down(sm) {
padding: 0;
@ -97,21 +98,3 @@
border: 0;
}
}
//styles for third party apps page
//center wrt to outer most container and not immediate parent
.empty-apps-state {
position: relative;
padding-top: 10rem;
margin-left: -250px;
text-align: center;
@include media-breakpoint-down(sm) {
margin: auto;
padding-top: 5rem;
}
@include media-breakpoint-down(md) {
margin-left: 0;
}
}

View file

@ -1,4 +1,7 @@
.hero-content {
margin-top: 3rem;
margin-bottom: 3rem;
.btn-primary {
margin-top: 1rem;
margin-right: 0.5rem;
@ -15,16 +18,23 @@
.hero-title, .hero-subtitle {
max-width: 42rem;
margin-top: 0rem;
margin-bottom: 0.5rem;
}
.lead {
font-weight: normal;
font-size: 1.25rem;
margin-bottom: 1.5rem;
}
.hero-subtitle {
@extend .lead;
font-weight: 400;
color: $gray-600;
font-size: 1rem;
font-size: $font-size-lg;
@include media-breakpoint-up(sm) {
font-size: 1.25rem;
font-size: $font-size-xl;
}
}
@ -42,10 +52,10 @@
.section-description {
max-width: 56rem;
margin-top: 0.5rem;
font-size: $font-size-base;
font-size: $font-size-lg;
@include media-breakpoint-up(lg) {
font-size: $font-size-lg;
@include media-breakpoint-up(media-breakpoint-up) {
font-size: $font-size-xl;
}
}
@ -226,14 +236,10 @@
}
}
.section-markdown > .from-markdown {
max-width: 42rem;
}
.section-cta {
padding: 3rem 2rem;
text-align: center;
background-color: $primary-light;
background-color: $gray-200;
border-radius: 0.75rem;
@include media-breakpoint-up(sm) {
@ -248,12 +254,7 @@
.title {
margin: 0 auto;
max-width: 36rem;
font-size: $font-size-2xl;
font-weight: 800;
line-height: 1.25;
@include media-breakpoint-up(md) {
font-size: $font-size-4xl;
}
}
.subtitle {
max-width: 36rem;
@ -270,11 +271,15 @@
margin-top: 0.5rem;
font-size: $font-size-xs;
}
.action {
margin-top: 0;
margin-bottom: 0;
}
}
.section-small-cta {
padding: 1.8rem;
background-color: lighten($primary, 42%);
background-color: var(--gray-200);
border-radius: 0.75rem;
display: flex;
flex-direction: column;
@ -294,26 +299,27 @@
}
}
.title {
max-width: 36rem;
font-size: $font-size-xl;
font-weight: 800;
line-height: 1.25;
@include media-breakpoint-up(md) {
font-size: $font-size-2xl;
}
.section-title {
line-height: 1;
margin-bottom: 0.25rem;
}
.subtitle {
max-width: 36rem;
font-size: $font-size-base;
color: $gray-900;
margin-bottom: 1.2rem;
margin-bottom: 0.5rem;
@include media-breakpoint-up(md) {
font-size: $font-size-lg;
margin-bottom: 0px;
}
}
.action {
margin-top: 0;
margin-bottom: 0;
}
}
.section-cta-container {
@ -379,6 +385,20 @@
}
}
.testimonial-author {
margin-top: 1rem;
display: flex;
align-items: center;
.avatar {
margin-right: 0.5rem;
}
p {
margin-bottom: 0;
}
}
.split-section-content.align-top {
margin-top: 2rem;
}
@ -514,12 +534,12 @@
@include media-breakpoint-up(md) {
grid-template-columns: repeat(2, 1fr);
gap: 6rem;
gap: 3rem 5rem;
}
.feature-title {
font-size: $font-size-xl;
font-weight: bold;
font-size: $font-size-lg;
font-weight: 600;
@include media-breakpoint-up(md) {
font-size: $font-size-2xl;
@ -528,7 +548,7 @@
.feature-content {
font-size: $font-size-base;
margin-top: 1.75rem;
margin-top: 1.25rem;
@include media-breakpoint-up(xl) {
font-size: $font-size-lg;
@ -630,9 +650,14 @@
}
}
.section-title {
margin-top: 0;
margin-bottom: 0.5rem;
}
.section-title + .section-features, .section-description + .section-features {
&[data-columns="2"] {
margin-top: 3.75rem;
margin-top: 3rem;
}
&[data-columns="3"] {
@ -651,6 +676,14 @@
position: relative;
}
.feature-title {
margin-top: 0;
}
.feature-content {
line-height: 1.7;
}
.feature-title, .feature-content {
margin-bottom: 0;
}
@ -666,3 +699,19 @@
.section-with-embed .embed-container {
margin-top: 2rem;
}
.section-video-wrapper {
margin-bottom: 1rem;
}
.section-video {
aspect-ratio: 16 / 9;
width: 100%;
cursor: pointer;
}
.video-thumbnail {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}

View file

@ -58,7 +58,7 @@ $font-size-lg: 1.125rem !default;
$font-size-xl: 1.25rem !default;
$font-size-2xl: 1.5rem !default;
$font-size-3xl: 1.875rem !default;
$font-size-4xl: 2.25rem !default;
$font-size-4xl: 2.5rem !default;
$font-size-5xl: 3rem !default;
$font-size-6xl: 4rem !default;

View file

@ -1,28 +1,47 @@
@import "../common/form";
[data-doctype="Web Form"] {
.page-content-wrapper {
.page_content {
max-width: 800px;
margin: auto;
.frappe-card {
padding: 1rem;
h3 {
margin-top: 0;
margin-bottom: 0;
}
.web-form-head {
margin: 0 -1rem;
padding: 0 1rem 1rem 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
#introduction {
margin-bottom: 2rem;
}
#introduction p {
color: var(--text-muted);
}
.web-form-actions button {
margin-top: 0.1rem;
}
}
.frappe-card.list-card {
min-height: 400px;
}
.breadcrumb-container.container {
@include media-breakpoint-up(sm) {
padding-left: 0;
}
}
.container {
max-width: 800px;
&.my-4 {
background-color: var(--fg-color);
@include media-breakpoint-up(sm) {
padding: 1.8rem;
border-radius: var(--border-radius-md);
box-shadow: var(--card-shadow);
}
}
}
}
}
@ -57,13 +76,21 @@
}
}
.web-form-wrapper~#datatable {
.list-table {
margin-left: -1rem;
margin-right: -1rem;
.table {
thead {
th {
border: 0;
font-size: 13px;
font-weight: normal;
color: var(--text-muted)
color: var(--text-muted);
input[type="checkbox"] {
margin-bottom: -2px;
}
}
}
@ -71,8 +98,22 @@
color: var(--text-color);
td {
border-top: 1px solid var(--border-color);
font-size: 13px;
border-top: 1px solid var(--border-color);
}
}
input[type="checkbox"] {
margin-left: 0.5rem;
margin-top: 2px;
}
.list-col-checkbox {
width: 1rem;
}
.list-col-serial {
width: 1.5rem;
}
}
}
}

View file

@ -1,5 +1,5 @@
from pypika.functions import *
from pypika.terms import Function
from pypika.terms import Function, CustomFunction, ArithmeticExpression, Arithmetic
from frappe.query_builder.utils import ImportMapper, db_type_is
from frappe.query_builder.custom import GROUP_CONCAT, STRING_AGG, MATCH, TO_TSVECTOR
from frappe.database.query import Query
@ -25,6 +25,24 @@ Match = ImportMapper(
}
)
class _PostgresTimestamp(ArithmeticExpression):
def __init__(self, datepart, timepart, alias=None):
if isinstance(datepart, str):
datepart = Cast(datepart, "date")
if isinstance(timepart, str):
timepart = Cast(timepart, "time")
super().__init__(operator=Arithmetic.add,
left=datepart, right=timepart, alias=alias)
CombineDatetime = ImportMapper(
{
db_type_is.MARIADB: CustomFunction("TIMESTAMP", ["date", "time"]),
db_type_is.POSTGRES: _PostgresTimestamp,
}
)
def _aggregate(function, dt, fieldname, filters, **kwargs):
return (
@ -46,4 +64,4 @@ def _avg(dt, fieldname, filters=None, **kwargs):
return _aggregate(Avg, dt, fieldname, filters, **kwargs)
def _sum(dt, fieldname, filters=None, **kwargs):
return _aggregate(Sum, dt, fieldname, filters, **kwargs)
return _aggregate(Sum, dt, fieldname, filters, **kwargs)

View file

@ -54,6 +54,9 @@ def patch_query_execute():
This excludes the use of `frappe.db.sql` method while
executing the query object
"""
from frappe.utils.safe_exec import check_safe_sql_query
def execute_query(query, *args, **kwargs):
query, params = prepare_query(query)
return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep
@ -63,7 +66,7 @@ def patch_query_execute():
param_collector = NamedParameterWrapper()
query = query.get_sql(param_wrapper=param_collector)
if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"):
if frappe.flags.in_safe_exec and not check_safe_sql_query(query, throw=False):
callstack = inspect.stack()
if len(callstack) >= 3 and ".py" in callstack[2].filename:
# ignore any query builder methods called from python files
@ -77,7 +80,7 @@ def patch_query_execute():
#
# if frame2 is server script it wont have a filename and hence
# it shouldn't be allowed.
# ps. stack() returns `"<unknown>"` as filename.
# p.s. stack() returns `"<unknown>"` as filename if not a file.
pass
else:
raise frappe.PermissionError('Only SELECT SQL allowed in scripting')

View file

@ -2,6 +2,7 @@
# License: MIT. See LICENSE
import frappe
from frappe.utils.data import cstr
import os
import redis
@ -118,7 +119,7 @@ def get_user_info():
}
def get_doc_room(doctype, docname):
return ''.join([frappe.local.site, ':doc:', doctype, '/', docname])
return ''.join([frappe.local.site, ':doc:', doctype, '/', cstr(docname)])
def get_user_room(user):
return ''.join([frappe.local.site, ':user:', user])

View file

@ -1,18 +1,18 @@
{% macro avatar(user_id=None, css_style=None, size="avatar-small") %}
{% macro avatar(user_id=None, css_style=None, size="avatar-small", full_name=None, image=None) %}
{% set user_info = frappe.utils.get_user_info_for_avatar(user_id) %}
<span class="avatar {{ size }}" title="{{ user_info.name }}" style="{{ css_style or '' }}">
{% if user_info.image %}
<span class="avatar {{ size }}" title="{{ full_name or user_info.name }}" style="{{ css_style or '' }}">
{% if image or user_info.image %}
<img
class="avatar-frame standard-image"
src="{{ user_info.image }}"
title="{{ user_info.name }}">
src="{{ image or user_info.image }}"
title="{{ full_name or user_info.name }}">
</span>
{% else %}
<span
class="avatar-frame standard-image"
title="{{ user_info.name }}">
{{ frappe.utils.get_abbr(user_info.name).upper() }}
title="{{ full_name or user_info.name }}">
{{ frappe.utils.get_abbr(full_name or user_info.name).upper() }}
</span>
{% endif %}
</span>
{% endmacro %}
{% endmacro %}

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