Merge branch 'develop' of github.com:frappe/frappe into multistep_webforms

This commit is contained in:
hrwx 2021-12-17 16:41:14 +00:00
commit 03c3efdfad
203 changed files with 4635 additions and 2429 deletions

View file

@ -24,6 +24,8 @@ def docs_link_exists(body):
parts = parsed_url.path.split('/') parts = parsed_url.path.split('/')
if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos:
return True return True
if parsed_url.netloc in ["docs.erpnext.com", "frappeframework.com"]:
return True
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -17,7 +17,7 @@ if [ "$TYPE" == "server" ]; then
fi fi
if [ "$DB" == "mariadb" ];then if [ "$DB" == "mariadb" ];then
sudo apt install mariadb-client-10.3 sudo apt update && sudo apt install mariadb-client-10.3
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'"; mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'";
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"; mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";

View file

@ -32,6 +32,12 @@ jobs:
with: with:
python-version: '3.9' python-version: '3.9'
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 14
check-latest: true
- name: Check if build should be run - name: Check if build should be run
id: check-build id: check-build
run: | run: |

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ dist/
frappe/docs/current frappe/docs/current
frappe/public/dist frappe/public/dist
.vscode .vscode
.vs
node_modules node_modules
.kdev4/ .kdev4/
*.kdev4 *.kdev4

View file

@ -3,18 +3,18 @@
# These owners will be the default owners for everything in # These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence, # the repo. Unless a later match takes precedence,
* @frappe/frappe-review-team * @frappe/frappe-review-team
templates/ @surajshetty3416 templates/ @surajshetty3416
www/ @surajshetty3416 www/ @surajshetty3416
integrations/ @leela integrations/ @leela
patches/ @surajshetty3416 @gavindsouza patches/ @surajshetty3416 @gavindsouza
email/ @leela email/ @leela
event_streaming/ @ruchamahabal event_streaming/ @ruchamahabal
data_import* @netchampfaris data_import* @netchampfaris
core/ @surajshetty3416 core/ @surajshetty3416
database @gavindsouza database @gavindsouza
model @gavindsouza model @gavindsouza
requirements.txt @gavindsouza requirements.txt @gavindsouza
query_builder/ @gavindsouza query_builder/ @gavindsouza
commands/ @gavindsouza commands/ @gavindsouza
workspace @shariquerik workspace @shariquerik

View file

@ -11,6 +11,15 @@ coverage:
threshold: 0.5% threshold: 0.5%
flags: flags:
- server - server
patch:
default: false
server:
target: 85%
threshold: 0%
only_pulls: true
if_ci_failed: ignore
flags:
- server
comment: comment:
layout: "diff, flags" layout: "diff, flags"

View file

@ -10,6 +10,7 @@ context('Control Rating', () => {
fields: [{ fields: [{
'fieldname': 'rate', 'fieldname': 'rate',
'fieldtype': 'Rating', 'fieldtype': 'Rating',
'options': 7
}] }]
}); });
} }
@ -40,4 +41,14 @@ context('Control Rating', () => {
.invoke('trigger', 'mouseleave') .invoke('trigger', 'mouseleave')
.should('not.have.class', 'star-hover'); .should('not.have.class', 'star-hover');
}); });
it('check number of stars in rating', () => {
get_dialog_with_rating();
cy.get('div.rating')
.first()
.children('svg')
.should('have.length', 7);
});
}); });

View file

@ -92,15 +92,15 @@ context('Control Date, Time and DateTime', () => {
date_format: 'dd.mm.yyyy', date_format: 'dd.mm.yyyy',
time_format: 'HH:mm:ss', time_format: 'HH:mm:ss',
value: ' 02.12.2019 11:00:12', value: ' 02.12.2019 11:00:12',
doc_value: '2019-12-02 11:00:12', doc_value: '2019-12-02 00:30:12', // system timezone (America/New_York)
input_value: '02.12.2019 11:00:12' input_value: '02.12.2019 11:00:12' // admin timezone (Asia/Kolkata)
}, },
{ {
date_format: 'mm-dd-yyyy', date_format: 'mm-dd-yyyy',
time_format: 'HH:mm', time_format: 'HH:mm',
value: ' 12-02-2019 11:00:00', value: ' 12-02-2019 11:00:00',
doc_value: '2019-12-02 11:00:00', doc_value: '2019-12-02 00:30:00', // system timezone (America/New_York)
input_value: '12-02-2019 11:00' input_value: '12-02-2019 11:00' // admin timezone (Asia/Kolkata)
} }
]; ];
datetime_formats.forEach(d => { datetime_formats.forEach(d => {

View file

@ -0,0 +1,49 @@
context('Grid Keyboard Shortcut', () => {
let total_count = 0;
beforeEach(() => {
cy.login();
cy.visit('/app/doctype/User');
});
before(() => {
cy.login();
cy.visit('/app/doctype/User');
return cy.window().its('frappe').then(frappe => {
frappe.db.count('DocField', {
filters: {
'parent': 'User', 'parentfield': 'fields', 'parenttype': 'DocType'
}
}).then((r) => {
total_count = r;
});
});
});
it('Insert new row at the end', () => {
cy.add_new_row_in_grid('{ctrl}{shift}{downarrow}', (cy, total_count) => {
cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', `${total_count+1}`);
}, total_count);
});
it('Insert new row at the top', () => {
cy.add_new_row_in_grid('{ctrl}{shift}{uparrow}', (cy) => {
cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', '1');
});
});
it('Insert new row below', () => {
cy.add_new_row_in_grid('{ctrl}{downarrow}', (cy) => {
cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', '2');
});
});
it('Insert new row above', () => {
cy.add_new_row_in_grid('{ctrl}{uparrow}', (cy) => {
cy.get('[data-name="new-docfield-1"]').should('have.attr', 'data-idx', '1');
});
});
});
Cypress.Commands.add('add_new_row_in_grid', (shortcut_keys, callbackFn, total_count) => {
cy.get('.frappe-control[data-fieldname="fields"]').as('table');
cy.get('@table').find('.grid-body .col-xs-2').first().click();
cy.get('@table').find('.grid-body .col-xs-2')
.first().type(shortcut_keys);
callbackFn(cy, total_count);
});

View file

@ -7,18 +7,13 @@ context('List View', () => {
}); });
}); });
it('Keep checkbox checked after Bulk Update', () => { it('Keep checkbox checked after Refresh', () => {
cy.go_to_list('ToDo'); cy.go_to_list('ToDo');
cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true }); cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true });
cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); cy.get('.actions-btn-group button').contains('Actions').should('be.visible');
cy.get('.dropdown-menu li:visible .dropdown-item .menu-item-label[data-label="Edit"]').click(); cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh');
cy.get('button[data-original-title="Refresh"]').click();
cy.get('.modal-body .form-control[data-fieldname="field"]').first().select('Priority').wait(200); cy.wait('@list-refresh');
cy.get('.modal-footer .standard-actions .btn-primary').click();
cy.wait(500);
cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click();
cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible'); cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible');
}); });

View file

@ -28,7 +28,11 @@ from .exceptions import *
from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader)
from .utils.lazy_loader import lazy_import from .utils.lazy_loader import lazy_import
from frappe.query_builder import get_query_builder, patch_query_execute from frappe.query_builder import (
get_query_builder,
patch_query_execute,
patch_query_aggregation,
)
__version__ = '14.0.0-dev' __version__ = '14.0.0-dev'
@ -211,6 +215,7 @@ def init(site, sites_path=None, new_site=False):
setup_module_map() setup_module_map()
patch_query_execute() patch_query_execute()
patch_query_aggregation()
local.initialised = True local.initialised = True
@ -790,7 +795,9 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc
def is_table(doctype): def is_table(doctype):
"""Returns True if `istable` property (indicating child Table) is set for given DocType.""" """Returns True if `istable` property (indicating child Table) is set for given DocType."""
def get_tables(): def get_tables():
return db.sql_list("select name from tabDocType where istable=1") return db.get_values(
"DocType", filters={"istable": 1}, order_by=None, pluck=True
)
tables = cache().get_value("is_table", get_tables) tables = cache().get_value("is_table", get_tables)
return doctype in tables return doctype in tables

View file

@ -185,7 +185,9 @@ def make_form_dict(request):
if 'application/json' in (request.content_type or '') and request_data: if 'application/json' in (request.content_type or '') and request_data:
args = json.loads(request_data) args = json.loads(request_data)
else: else:
args = request.form or request.args args = {}
args.update(request.args or {})
args.update(request.form or {})
if not isinstance(args, dict): if not isinstance(args, dict):
frappe.throw(_("Invalid request arguments")) frappe.throw(_("Invalid request arguments"))

View file

@ -17,6 +17,7 @@ from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_p
from frappe.model.base_document import get_controller from frappe.model.base_document import get_controller
from frappe.social.doctype.post.post import frequently_visited_links from frappe.social.doctype.post.post import frequently_visited_links
from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo
from frappe.utils import get_time_zone
def get_bootinfo(): def get_bootinfo():
"""build and return boot info""" """build and return boot info"""
@ -58,6 +59,7 @@ def get_bootinfo():
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1}) bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings() bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings() bootinfo.notification_settings = get_notification_settings()
set_time_zone(bootinfo)
# ipinfo # ipinfo
if frappe.session.data.get('ipinfo'): if frappe.session.data.get('ipinfo'):
@ -220,8 +222,8 @@ def load_translations(bootinfo):
bootinfo["__messages"] = messages bootinfo["__messages"] = messages
def get_user_info(): def get_user_info():
user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', 'gender',
'gender', 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type'], 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type', 'time_zone'],
filters=dict(enabled=1)) filters=dict(enabled=1))
user_info_map = {d.name: d for d in user_info} user_info_map = {d.name: d for d in user_info}
@ -324,3 +326,9 @@ def get_desk_settings():
def get_notification_settings(): def get_notification_settings():
return frappe.get_cached_doc('Notification Settings', frappe.session.user) return frappe.get_cached_doc('Notification Settings', frappe.session.user)
def set_time_zone(bootinfo):
bootinfo.time_zone = {
"system": get_time_zone(),
"user": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None) or get_time_zone()
}

View file

@ -18,7 +18,7 @@ Requests via FrappeClient are also handled here.
@frappe.whitelist() @frappe.whitelist()
def get_list(doctype, fields=None, filters=None, order_by=None, def get_list(doctype, fields=None, filters=None, order_by=None,
limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True): limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True, or_filters=None):
'''Returns a list of records by filters, fields, ordering and limit '''Returns a list of records by filters, fields, ordering and limit
:param doctype: DocType of the data to be queried :param doctype: DocType of the data to be queried
@ -34,6 +34,7 @@ def get_list(doctype, fields=None, filters=None, order_by=None,
doctype=doctype, doctype=doctype,
fields=fields, fields=fields,
filters=filters, filters=filters,
or_filters=or_filters,
order_by=order_by, order_by=order_by,
limit_start=limit_start, limit_start=limit_start,
limit_page_length=limit_page_length, limit_page_length=limit_page_length,

View file

@ -447,11 +447,10 @@ def disable_user(context, email):
@pass_context @pass_context
def migrate(context, skip_failing=False, skip_search_index=False): def migrate(context, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations" "Run patches, sync schema and rebuild files/translations"
import re
from frappe.migrate import migrate from frappe.migrate import migrate
for site in context.sites: for site in context.sites:
print('Migrating', site) click.secho(f"Migrating {site}", fg="green")
frappe.init(site=site) frappe.init(site=site)
frappe.connect() frappe.connect()
try: try:
@ -697,8 +696,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
drop_user_and_database(frappe.conf.db_name, root_login, root_password) drop_user_and_database(frappe.conf.db_name, root_login, root_password)
if not archived_sites_path: archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites')
archived_sites_path = os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived_sites')
if not os.path.exists(archived_sites_path): if not os.path.exists(archived_sites_path):
os.mkdir(archived_sites_path) os.mkdir(archived_sites_path)
@ -829,39 +827,37 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte
@pass_context @pass_context
def browse(context, site, user=None): def browse(context, site, user=None):
'''Opens the site on web browser''' '''Opens the site on web browser'''
from frappe.auth import LoginManager from frappe.auth import CookieManager, LoginManager
from frappe.auth import CookieManager
import webbrowser
site = context.sites[0] if context.sites else site site = get_site(context, raise_err=False) or site
if not site: if not site:
click.echo('''Please provide site name\n\nUsage:\n\tbench browse [site-name]\nor\n\tbench --site [site-name] browse''') raise SiteNotSpecifiedError
return
site = site.lower() if site not in frappe.utils.get_sites():
click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True)
sys.exit(1)
if site in frappe.utils.get_sites(): frappe.init(site=site)
frappe.init(site=site) frappe.connect()
frappe.connect()
sid = '' sid = ''
if user: if user:
if frappe.conf.developer_mode or user == "Administrator": if frappe.conf.developer_mode or user == "Administrator":
frappe.utils.set_request(path="/") frappe.utils.set_request(path="/")
frappe.local.cookie_manager = CookieManager() frappe.local.cookie_manager = CookieManager()
frappe.local.login_manager = LoginManager() frappe.local.login_manager = LoginManager()
frappe.local.login_manager.login_as(user) frappe.local.login_manager.login_as(user)
sid = f'/app?sid={frappe.session.sid}' sid = f'/app?sid={frappe.session.sid}'
else: else:
print("Please enable developer mode to login as a user") click.echo("Please enable developer mode to login as a user")
url = f'{frappe.utils.get_site_url(site)}{sid}' url = f'{frappe.utils.get_site_url(site)}{sid}'
if user == "Administrator":
print(f'Login URL: {url}') if user == "Administrator":
webbrowser.open(url, new=2) click.echo(f'Login URL: {url}')
else:
click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site)) click.launch(url)
@click.command('start-recording') @click.command('start-recording')

View file

@ -51,6 +51,7 @@
"email_inbox", "email_inbox",
"message_id", "message_id",
"uid", "uid",
"imap_folder",
"email_status", "email_status",
"has_attachment", "has_attachment",
"feedback_section", "feedback_section",
@ -382,12 +383,19 @@
"label": "Timeline Links", "label": "Timeline Links",
"options": "Communication Link", "options": "Communication Link",
"permlevel": 2 "permlevel": 2
},
{
"fieldname": "imap_folder",
"fieldtype": "Data",
"hidden": 1,
"label": "IMAP Folder",
"read_only": 1
} }
], ],
"icon": "fa fa-comment", "icon": "fa fa-comment",
"idx": 1, "idx": 1,
"links": [], "links": [],
"modified": "2021-03-25 09:44:28.963538", "modified": "2021-11-30 09:03:25.728637",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Communication", "name": "Communication",

View file

@ -291,6 +291,7 @@ def create_email_account():
"unreplied_for_mins": 20, "unreplied_for_mins": 20,
"send_notification_to": "test_comm@example.com", "send_notification_to": "test_comm@example.com",
"pop3_server": "pop.test.example.com", "pop3_server": "pop.test.example.com",
"imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}],
"no_remaining":"0", "no_remaining":"0",
"enable_automatic_linking": 1 "enable_automatic_linking": 1
}).insert(ignore_permissions=True) }).insert(ignore_permissions=True)

View file

@ -199,7 +199,7 @@ class Importer:
new_doc = frappe.new_doc(self.doctype) new_doc = frappe.new_doc(self.doctype)
new_doc.update(doc) new_doc.update(doc)
if (meta.autoname or "").lower() != "prompt": if not doc.name and (meta.autoname or "").lower() != "prompt":
# name can only be set directly if autoname is prompt # name can only be set directly if autoname is prompt
new_doc.set("name", None) new_doc.set("name", None)

View file

@ -1,16 +1,6 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt // MIT License. See license.txt
// -------------
// Menu Display
// -------------
// $(cur_frm.wrapper).on("grid-row-render", function(e, grid_row) {
// if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") {
// $(grid_row.row).css({"font-weight": "bold"});
// }
// })
frappe.ui.form.on('DocType', { frappe.ui.form.on('DocType', {
refresh: function(frm) { refresh: function(frm) {
frm.set_query('role', 'permissions', function(doc) { frm.set_query('role', 'permissions', function(doc) {
@ -129,7 +119,7 @@ frappe.ui.form.on('DocType', {
} }
frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt');
} },
}); });
frappe.ui.form.on("DocField", { frappe.ui.form.on("DocField", {
@ -153,11 +143,10 @@ frappe.ui.form.on("DocField", {
curr_value.doctype = doctype; curr_value.doctype = doctype;
curr_value.fieldname = fieldname; curr_value.fieldname = fieldname;
} }
let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null;
let doctypes = frm.doc.fields let doctypes = frm.doc.fields
.filter(df => df.fieldtype == "Link") .filter(df => df.fieldtype == "Link")
.filter(df => df.options && df.options != curr_df_link_doctype) .filter(df => df.options && df.fieldname != row.fieldname)
.map(df => ({ .map(df => ({
label: `${df.options} (${df.fieldname})`, label: `${df.options} (${df.fieldname})`,
value: df.fieldname value: df.fieldname
@ -217,5 +206,11 @@ frappe.ui.form.on("DocField", {
$doctype_select.val(curr_value.doctype); $doctype_select.val(curr_value.doctype);
update_fieldname_options(); update_fieldname_options();
} }
},
fieldtype: function(frm) {
frm.trigger("max_attachments");
} }
}); });
extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm}));

File diff suppressed because it is too large Load diff

View file

@ -75,6 +75,7 @@ class DocType(Document):
self.make_repeatable() self.make_repeatable()
self.validate_nestedset() self.validate_nestedset()
self.validate_website() self.validate_website()
self.ensure_minimum_max_attachment_limit()
validate_links_table_fieldnames(self) validate_links_table_fieldnames(self)
if not self.is_new(): if not self.is_new():
@ -246,6 +247,22 @@ class DocType(Document):
# clear website cache # clear website cache
clear_cache() clear_cache()
def ensure_minimum_max_attachment_limit(self):
"""Ensure that max_attachments is *at least* bigger than number of attach fields."""
from frappe.model import attachment_fieldtypes
if not self.max_attachments:
return
total_attach_fields = len([d for d in self.fields if d.fieldtype in attachment_fieldtypes])
if total_attach_fields > self.max_attachments:
self.max_attachments = total_attach_fields
field_label = frappe.bold(self.meta.get_field("max_attachments").label)
frappe.msgprint(_("Number of attachment fields are more than {}, limit updated to {}.")
.format(field_label, total_attach_fields),
title=_("Insufficient attachment limit"), alert=True)
def change_modified_of_parent(self): def change_modified_of_parent(self):
"""Change the timestamp of parent DocType if the current one is a child to clear caches.""" """Change the timestamp of parent DocType if the current one is a child to clear caches."""
if frappe.flags.in_import: if frappe.flags.in_import:
@ -253,7 +270,7 @@ class DocType(Document):
parent_list = frappe.db.get_all('DocField', 'parent', parent_list = frappe.db.get_all('DocField', 'parent',
dict(fieldtype=['in', frappe.model.table_fields], options=self.name)) dict(fieldtype=['in', frappe.model.table_fields], options=self.name))
for p in parent_list: for p in parent_list:
frappe.db.sql('UPDATE `tabDocType` SET modified=%s WHERE `name`=%s', (now(), p.parent)) frappe.db.update("DocType", p.parent, {}, for_update=False)
def scrub_field_names(self): def scrub_field_names(self):
"""Sluggify fieldnames if not set from Label.""" """Sluggify fieldnames if not set from Label."""
@ -1057,6 +1074,11 @@ def validate_fields(meta):
if getattr(docfield, 'max_height', None) and (docfield.max_height[-2:] not in ('px', 'em')): if getattr(docfield, 'max_height', None) and (docfield.max_height[-2:] not in ('px', 'em')):
frappe.throw('Max for {} height must be in px, em, rem'.format(frappe.bold(docfield.fieldname))) frappe.throw('Max for {} height must be in px, em, rem'.format(frappe.bold(docfield.fieldname)))
def check_no_of_ratings(docfield):
if docfield.fieldtype == "Rating":
if docfield.options and (int(docfield.options) > 10 or int(docfield.options) < 3):
frappe.throw(_('Options for Rating field can range from 3 to 10'))
fields = meta.get("fields") fields = meta.get("fields")
fieldname_list = [d.fieldname for d in fields] fieldname_list = [d.fieldname for d in fields]
@ -1090,6 +1112,7 @@ def validate_fields(meta):
scrub_fetch_from(d) scrub_fetch_from(d)
validate_data_field_type(d) validate_data_field_type(d)
check_max_height(d) check_max_height(d)
check_no_of_ratings(d)
check_fold(fields) check_fold(fields)
check_search_fields(meta, fields) check_search_fields(meta, fields)

View file

@ -0,0 +1,50 @@
{
"actions": [],
"creation": "2021-08-23 17:21:28.345841",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"title",
"color",
"custom"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"reqd": 1
},
{
"default": "Blue",
"fieldname": "color",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Color",
"options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nYellow",
"reqd": 1
},
{
"default": "0",
"fieldname": "custom",
"fieldtype": "Check",
"hidden": 1,
"label": "Custom"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-12-14 14:14:55.716378",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType State",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class DocTypeState(Document):
pass

View file

@ -569,6 +569,24 @@ class File(Document):
frappe.local.rollback_observers.append(self) frappe.local.rollback_observers.append(self)
self.save() self.save()
@staticmethod
def zip_files(files):
from six import string_types
zip_file = io.BytesIO()
zf = zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED)
for _file in files:
if isinstance(_file, string_types):
_file = frappe.get_doc("File", _file)
if not isinstance(_file, File):
continue
if _file.is_folder:
continue
zf.writestr(_file.file_name, _file.get_content())
zf.close()
return zip_file.getvalue()
def on_doctype_update(): def on_doctype_update():
frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"])
@ -612,6 +630,16 @@ def move_file(file_list, new_parent, old_parent):
frappe.get_doc("File", old_parent).save() frappe.get_doc("File", old_parent).save()
frappe.get_doc("File", new_parent).save() frappe.get_doc("File", new_parent).save()
@frappe.whitelist()
def zip_files(files):
files = frappe.parse_json(files)
zipped_files = File.zip_files(files)
frappe.response["filename"] = "files.zip"
frappe.response["filecontent"] = zipped_files
frappe.response["type"] = "download"
def setup_folder_path(filename, new_parent): def setup_folder_path(filename, new_parent):
file = frappe.get_doc("File", filename) file = frappe.get_doc("File", filename)
file.folder = new_parent file.folder = new_parent
@ -940,20 +968,14 @@ def get_files_by_search_text(text):
def update_existing_file_docs(doc): def update_existing_file_docs(doc):
# Update is private and file url of all file docs that point to the same file # Update is private and file url of all file docs that point to the same file
frappe.db.sql(""" file_doctype = frappe.qb.DocType("File")
UPDATE `tabFile` (
SET frappe.qb.update(file_doctype)
file_url = %(file_url)s, .set(file_doctype.file_url, doc.file_url)
is_private = %(is_private)s .set(file_doctype.is_private, doc.is_private)
WHERE .where(file_doctype.content_hash == doc.content_hash)
content_hash = %(content_hash)s .where(file_doctype.name != doc.name)
and name != %(file_name)s ).run()
""", dict(
file_url=doc.file_url,
is_private=doc.is_private,
content_hash=doc.content_hash,
file_name=doc.name
))
def attach_files_to_document(doc, event): def attach_files_to_document(doc, event):
""" Runs on on_update hook of all documents. """ Runs on on_update hook of all documents.

View file

@ -1,19 +1,23 @@
// Copyright (c) 2020, Frappe Technologies and contributors // Copyright (c) 2020, Frappe Technologies and contributors
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on('Module Profile', { frappe.ui.form.on("Module Profile", {
refresh: function(frm) { refresh: function (frm) {
if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) { if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) {
if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) { if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) {
let module_area = $('<div style="min-height: 300px">') const module_area = $(frm.fields_dict.module_html.wrapper);
.appendTo(frm.fields_dict.module_html.wrapper);
frm.module_editor = new frappe.ModuleEditor(frm, module_area); frm.module_editor = new frappe.ModuleEditor(frm, module_area);
} }
} }
if (frm.module_editor) { if (frm.module_editor) {
frm.module_editor.refresh(); frm.module_editor.show();
}
},
validate: function (frm) {
if (frm.module_editor) {
frm.module_editor.set_modules_in_table();
} }
} }
}); });

View file

@ -34,11 +34,17 @@
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2021-01-03 15:36:52.622696", {
"link_doctype": "User",
"link_fieldname": "module_profile"
}
],
"modified": "2021-12-03 15:47:21.296443",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "Module Profile", "name": "Module Profile",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View file

@ -1,175 +1,80 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0, "autoname": "role_profile",
"allow_import": 0, "creation": "2017-08-31 04:16:38.764465",
"allow_rename": 0, "doctype": "DocType",
"autoname": "role_profile", "editable_grid": 1,
"beta": 0, "engine": "InnoDB",
"creation": "2017-08-31 04:16:38.764465", "field_order": [
"custom": 0, "role_profile",
"docstatus": 0, "roles_html",
"doctype": "DocType", "roles"
"document_type": "", ],
"editable_grid": 1,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "fieldname": "role_profile",
"allow_on_submit": 0, "fieldtype": "Data",
"bold": 0, "in_list_view": 1,
"collapsible": 0, "label": "Role Name",
"columns": 0, "reqd": 1,
"fieldname": "role_profile",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Role Name",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 1 "unique": 1
}, },
{ {
"allow_bulk_edit": 0, "fieldname": "roles_html",
"allow_on_submit": 0, "fieldtype": "HTML",
"bold": 0, "label": "Roles HTML",
"collapsible": 0, "read_only": 1
"columns": 0, },
"fieldname": "roles_html",
"fieldtype": "HTML",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Roles HTML",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "roles",
"allow_on_submit": 0, "fieldtype": "Table",
"bold": 0, "hidden": 1,
"collapsible": 0, "label": "Roles Assigned",
"columns": 0, "options": "Has Role",
"fieldname": "roles", "permlevel": 1,
"fieldtype": "Table", "print_hide": 1,
"hidden": 1, "read_only": 1
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Roles Assigned",
"length": 0,
"no_copy": 0,
"options": "Has Role",
"permlevel": 1,
"precision": "",
"print_hide": 1,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "links": [
"hide_heading": 0, {
"hide_toolbar": 0, "link_doctype": "User",
"idx": 0, "link_fieldname": "role_profile_name"
"image_view": 0, }
"in_create": 0, ],
"is_submittable": 0, "modified": "2021-12-03 15:45:45.270963",
"issingle": 0, "modified_by": "Administrator",
"istable": 0, "module": "Core",
"max_attachments": 0, "name": "Role Profile",
"modified": "2017-10-17 11:05:11.183066", "naming_rule": "Expression (old style)",
"modified_by": "Administrator", "owner": "Administrator",
"module": "Core",
"name": "Role Profile",
"name_case": "",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0, "create": 1,
"apply_user_permissions": 0, "delete": 1,
"cancel": 0, "email": 1,
"create": 1, "export": 1,
"delete": 1, "print": 1,
"email": 1, "read": 1,
"export": 1, "report": 1,
"if_owner": 0, "role": "System Manager",
"import": 0, "share": 1,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
}, },
{ {
"amend": 0, "email": 1,
"apply_user_permissions": 0, "export": 1,
"cancel": 0, "permlevel": 1,
"create": 0, "print": 1,
"delete": 0, "read": 1,
"email": 1, "report": 1,
"export": 1, "role": "System Manager",
"if_owner": 0, "share": 1,
"import": 0,
"permlevel": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1 "write": 1
} }
], ],
"quick_entry": 0, "sort_field": "modified",
"read_only": 0, "sort_order": "DESC",
"read_only_onload": 0, "title_field": "role_profile",
"show_name_in_global_search": 0, "track_changes": 1
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "role_profile",
"track_changes": 1,
"track_seen": 0
} }

View file

@ -10,7 +10,7 @@ from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
class TestScheduledJobType(unittest.TestCase): class TestScheduledJobType(unittest.TestCase):
def setUp(self): def setUp(self):
frappe.db.rollback() frappe.db.rollback()
frappe.db.sql('truncate `tabScheduled Job Type`') frappe.db.truncate("Scheduled Job Type")
sync_jobs() sync_jobs()
frappe.db.commit() frappe.db.commit()

View file

@ -41,7 +41,19 @@ def run_server_script_for_doc_event(doc, event):
if scripts: if scripts:
# run all scripts for this doctype + event # run all scripts for this doctype + event
for script_name in scripts: for script_name in scripts:
frappe.get_doc('Server Script', script_name).execute_doc(doc) try:
frappe.get_doc('Server Script', script_name).execute_doc(doc)
except Exception as e:
message = frappe._('Error executing Server Script {0}. Open Browser Console to see traceback.').format(
frappe.utils.get_link_to_form('Server Script', script_name)
)
exception = type(e)
if getattr(frappe, 'request', None):
# all exceptions throw 500 which is internal server error
# however server script error is a user error
# so we should throw 417 which is expectation failed
exception.http_status_code = 417
frappe.throw(title=frappe._('Server Script Error'), msg=message, exc=exception)
def get_server_script_map(): def get_server_script_map():
# fetch cached server script methods # fetch cached server script methods

View file

@ -76,7 +76,7 @@ class TestServerScript(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
frappe.db.commit() frappe.db.commit()
frappe.db.sql('truncate `tabServer Script`') frappe.db.truncate("Server Script")
frappe.get_doc('User', 'Administrator').add_roles('Script Manager') frappe.get_doc('User', 'Administrator').add_roles('Script Manager')
for script in scripts: for script in scripts:
script_doc = frappe.get_doc(doctype ='Server Script') script_doc = frappe.get_doc(doctype ='Server Script')
@ -88,7 +88,7 @@ class TestServerScript(unittest.TestCase):
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
frappe.db.commit() frappe.db.commit()
frappe.db.sql('truncate `tabServer Script`') frappe.db.truncate("Server Script")
frappe.cache().delete_value('server_script_map') frappe.cache().delete_value('server_script_map')
def setUp(self): def setUp(self):

View file

@ -32,5 +32,11 @@ frappe.ui.form.on("System Settings", {
frm.set_value('prepared_report_expiry_period', 7); frm.set_value('prepared_report_expiry_period', 7);
} }
} }
},
on_update: function(frm) {
if (frappe.boot.time_zone && frappe.boot.time_zone.system !== frm.doc.time_zone) {
// Clear cache after saving to refresh the values of boot.
frappe.ui.toolbar.clear_cache();
}
} }
}); });

View file

@ -66,7 +66,9 @@
"attach_view_link", "attach_view_link",
"prepared_report_section", "prepared_report_section",
"enable_prepared_report_auto_deletion", "enable_prepared_report_auto_deletion",
"prepared_report_expiry_period" "prepared_report_expiry_period",
"system_updates_section",
"disable_system_update_notification"
], ],
"fields": [ "fields": [
{ {
@ -95,6 +97,7 @@
"fieldname": "time_zone", "fieldname": "time_zone",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Time Zone", "label": "Time Zone",
"read_only": 1,
"reqd": 1 "reqd": 1
}, },
{ {
@ -462,12 +465,24 @@
"fieldname": "encrypt_backup", "fieldname": "encrypt_backup",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Encrypt Backups" "label": "Encrypt Backups"
},
{
"collapsible": 1,
"fieldname": "system_updates_section",
"fieldtype": "Section Break",
"label": "System Updates"
},
{
"default": "0",
"fieldname": "disable_system_update_notification",
"fieldtype": "Check",
"label": "Disable System Update Notification"
} }
], ],
"icon": "fa fa-cog", "icon": "fa fa-cog",
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2021-10-21 19:24:15.232430", "modified": "2021-11-29 18:09:53.601629",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "System Settings", "name": "System Settings",

View file

@ -9,6 +9,7 @@ from frappe.model.document import Document
from frappe.query_builder import DocType from frappe.query_builder import DocType
from frappe.utils import cint, now_datetime from frappe.utils import cint, now_datetime
class TransactionLog(Document): class TransactionLog(Document):
def before_insert(self): def before_insert(self):
index = get_current_index() index = get_current_index()
@ -29,18 +30,15 @@ class TransactionLog(Document):
def hash_line(self): def hash_line(self):
sha = hashlib.sha256() sha = hashlib.sha256()
sha.update( sha.update(
frappe.safe_encode(str(self.row_index)) + \ frappe.safe_encode(str(self.row_index))
frappe.safe_encode(str(self.timestamp)) + \ + frappe.safe_encode(str(self.timestamp))
frappe.safe_encode(str(self.data)) + frappe.safe_encode(str(self.data))
) )
return sha.hexdigest() return sha.hexdigest()
def hash_chain(self): def hash_chain(self):
sha = hashlib.sha256() sha = hashlib.sha256()
sha.update( sha.update(frappe.safe_encode(str(self.transaction_hash)) + frappe.safe_encode(str(self.previous_hash)))
frappe.safe_encode(str(self.transaction_hash)) + \
frappe.safe_encode(str(self.previous_hash))
)
return sha.hexdigest() return sha.hexdigest()

View file

@ -251,7 +251,7 @@ class TestUser(unittest.TestCase):
c = FrappeClient(url) c = FrappeClient(url)
res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
self.assertEqual(res1.status_code, 200) self.assertEqual(res1.status_code, 400)
self.assertEqual(res2.status_code, 417) self.assertEqual(res2.status_code, 417)
def test_user_rename(self): def test_user_rename(self):

View file

@ -50,7 +50,7 @@ frappe.ui.form.on('User', {
let d = frm.add_child("block_modules"); let d = frm.add_child("block_modules");
d.module = v.module; d.module = v.module;
}); });
frm.module_editor && frm.module_editor.refresh(); frm.module_editor && frm.module_editor.show();
} }
}); });
} }
@ -77,7 +77,12 @@ frappe.ui.form.on('User', {
} }
}, },
refresh: function(frm) { refresh: function(frm) {
var doc = frm.doc; let doc = frm.doc;
if (frm.is_new()) {
frm.set_value("time_zone", frappe.sys_defaults.time_zone);
}
if (in_list(['System User', 'Website User'], frm.doc.user_type) if (in_list(['System User', 'Website User'], frm.doc.user_type)
&& !frm.is_new() && !frm.roles_editor && frm.can_edit_roles) { && !frm.is_new() && !frm.roles_editor && frm.can_edit_roles) {
frm.reload_doc(); frm.reload_doc();
@ -180,7 +185,7 @@ frappe.ui.form.on('User', {
frm.roles_editor.show(); frm.roles_editor.show();
} }
frm.module_editor && frm.module_editor.refresh(); frm.module_editor && frm.module_editor.show();
if(frappe.session.user==doc.name) { if(frappe.session.user==doc.name) {
// update display settings // update display settings
@ -267,6 +272,12 @@ frappe.ui.form.on('User', {
} }
} }
}); });
},
on_update: function(frm) {
if (frappe.boot.time_zone && frappe.boot.time_zone.user !== frm.doc.time_zone) {
// Clear cache after saving to refresh the values of boot.
frappe.ui.toolbar.clear_cache();
}
} }
}); });

View file

@ -7,7 +7,7 @@ import frappe.defaults
import frappe.permissions import frappe.permissions
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import (cint, flt, has_gravatar, escape_html, format_datetime, from frappe.utils import (cint, flt, has_gravatar, escape_html, format_datetime,
now_datetime, get_formatted_email, today) now_datetime, get_formatted_email, today, get_time_zone)
from frappe import throw, msgprint, _ from frappe import throw, msgprint, _
from frappe.utils.password import update_password as _update_password, check_password, get_password_reset_limit from frappe.utils.password import update_password as _update_password, check_password, get_password_reset_limit
from frappe.desk.notifications import clear_notifications from frappe.desk.notifications import clear_notifications
@ -74,6 +74,7 @@ class User(Document):
self.validate_roles() self.validate_roles()
self.validate_allowed_modules() self.validate_allowed_modules()
self.validate_user_image() self.validate_user_image()
self.set_time_zone()
if self.language == "Loading...": if self.language == "Loading...":
self.language = None self.language = None
@ -213,15 +214,12 @@ class User(Document):
user_type_doc.update_modules_in_user(self) user_type_doc.update_modules_in_user(self)
def has_desk_access(self): def has_desk_access(self):
'''Return true if any of the set roles has desk access''' """Return true if any of the set roles has desk access"""
if not self.roles: if not self.roles:
return False return False
return len(frappe.db.sql("""select name role_table = DocType("Role")
from `tabRole` where desk_access=1 return frappe.db.count(role_table, ((role_table.desk_access == 1) & (role_table.name.isin([d.role for d in self.roles]))))
and name in ({0}) limit 1""".format(', '.join(['%s'] * len(self.roles))),
[d.role for d in self.roles]))
def share_with_self(self): def share_with_self(self):
frappe.share.add(self.doctype, self.name, self.name, write=1, share=1, frappe.share.add(self.doctype, self.name, self.name, write=1, share=1,
@ -230,11 +228,11 @@ class User(Document):
def validate_share(self, docshare): def validate_share(self, docshare):
pass pass
# if docshare.user == self.name: # if docshare.user == self.name:
# if self.user_type=="System User": # if self.user_type=="System User":
# if docshare.share != 1: # if docshare.share != 1:
# frappe.throw(_("Sorry! User should have complete access to their own record.")) # frappe.throw(_("Sorry! User should have complete access to their own record."))
# else: # else:
# frappe.throw(_("Sorry! Sharing with Website User is prohibited.")) # frappe.throw(_("Sorry! Sharing with Website User is prohibited."))
def send_password_notification(self, new_password): def send_password_notification(self, new_password):
try: try:
@ -279,12 +277,20 @@ class User(Document):
return link return link
def get_other_system_managers(self): def get_other_system_managers(self):
return frappe.db.sql("""select distinct `user`.`name` from `tabHas Role` as `user_role`, `tabUser` as `user` user_doctype = DocType("User").as_("user")
where user_role.role='System Manager' user_role_doctype = DocType("Has Role").as_("user_role")
and `user`.docstatus<2 return (
and `user`.enabled=1 frappe.qb.from_(user_doctype)
and `user_role`.parent = `user`.name .from_(user_role_doctype)
and `user_role`.parent not in ('Administrator', %s) limit 1""", (self.name,)) .select(user_doctype.name)
.where(user_role_doctype.role == 'System Manager')
.where(user_doctype.docstatus < 2)
.where(user_doctype.enabled == 1)
.where(user_role_doctype.parent == user_doctype.name)
.where(user_role_doctype.parent.notin(["Administrator", self.name]))
.limit(1)
.distinct()
).run()
def get_fullname(self): def get_fullname(self):
"""get first_name space last_name""" """get first_name space last_name"""
@ -358,8 +364,12 @@ class User(Document):
# delete todos # delete todos
frappe.db.delete("ToDo", {"owner": self.name}) frappe.db.delete("ToDo", {"owner": self.name})
frappe.db.sql("""UPDATE `tabToDo` SET `assigned_by`=NULL WHERE `assigned_by`=%s""", todo_table = DocType("ToDo")
(self.name,)) (
frappe.qb.update(todo_table)
.set(todo_table.assigned_by, None)
.where(todo_table.assigned_by == self.name)
).run()
# delete events # delete events
frappe.db.delete("Event", {"owner": self.name, "event_type": "Private"}) frappe.db.delete("Event", {"owner": self.name, "event_type": "Private"})
@ -425,10 +435,7 @@ class User(Document):
frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False) frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False)
# set email # set email
table = DocType("User") frappe.db.update("User", new_name, "email", new_name)
frappe.qb.update(table).where(
table.name == new_name
).set("email", new_name).run()
def append_roles(self, *roles): def append_roles(self, *roles):
"""Add roles to user""" """Add roles to user"""
@ -590,6 +597,10 @@ class User(Document):
return user return user
def set_time_zone(self):
if not self.time_zone:
self.time_zone = get_time_zone()
@frappe.whitelist() @frappe.whitelist()
def get_timezones(): def get_timezones():
import pytz import pytz
@ -698,28 +709,19 @@ def has_email_account(email):
@frappe.whitelist(allow_guest=False) @frappe.whitelist(allow_guest=False)
def get_email_awaiting(user): def get_email_awaiting(user):
waiting = frappe.db.sql("""select email_account,email_id waiting = frappe.get_all("User Email", fields=["email_account", "email_id"], filters={"awaiting_password": 1, "parent": user})
from `tabUser Email`
where awaiting_password = 1
and parent = %(user)s""", {"user":user}, as_dict=1)
if waiting: if waiting:
return waiting return waiting
else: else:
frappe.db.sql("""update `tabUser Email` user_email_table = DocType("User Email")
set awaiting_password =0 frappe.qb.update(user_email_table).set(user_email_table.user_email_table, 0).where(user_email_table.parent == user).run()
where parent = %(user)s""",{"user":user})
return False return False
def ask_pass_update(): def ask_pass_update():
# update the sys defaults as to awaiting users # update the sys defaults as to awaiting users
from frappe.utils import set_default from frappe.utils import set_default
doctype = DocType("User Email") password_list = frappe.get_all("User Email", filters={"awaiting_password": True}, pluck="parent", distinct=True)
users = frappe.qb.from_(doctype).where(doctype.awaiting_password == 1).select(
doctype.parent.as_("user")
).distinct().run(as_dict=True)
password_list = [ user.get("user") for user in users ]
set_default("email_user_password", u','.join(password_list)) set_default("email_user_password", u','.join(password_list))
def _get_user_for_update_password(key, old_password): def _get_user_for_update_password(key, old_password):
@ -811,6 +813,7 @@ def reset_password(user):
return frappe.msgprint(_("Password reset instructions have been sent to your email")) return frappe.msgprint(_("Password reset instructions have been sent to your email"))
except frappe.DoesNotExistError: except frappe.DoesNotExistError:
frappe.local.response['http_status_code'] = 400
frappe.clear_messages() frappe.clear_messages()
return 'not found' return 'not found'
@ -887,8 +890,7 @@ def get_active_users():
def get_website_users(): def get_website_users():
"""Returns total no. of website users""" """Returns total no. of website users"""
return frappe.db.sql("""select count(*) from `tabUser` return frappe.db.count("User", filters={"enabled": True, "user_type": "Website User"})
where enabled = 1 and user_type = 'Website User'""")[0][0]
def get_active_website_users(): def get_active_website_users():
"""Returns No. of website users who logged in, in the last 3 days""" """Returns No. of website users who logged in, in the last 3 days"""

View file

@ -0,0 +1,56 @@
{
"creation": "2021-11-23 12:38:52.807353",
"docstatus": 0,
"doctype": "Form Tour",
"first_document": 0,
"idx": 0,
"include_name_field": 1,
"is_standard": 1,
"modified": "2021-11-25 17:03:01.646360",
"modified_by": "Administrator",
"module": "Core",
"name": "Doctype",
"owner": "Administrator",
"reference_doctype": "DocType",
"save_on_complete": 1,
"steps": [
{
"description": "Select a Module to which this DocType would belong",
"field": "",
"fieldname": "module",
"fieldtype": "Link",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Module",
"parent_field": "",
"position": "Right",
"title": "Module"
},
{
"description": "Check this to make the DocType as Custom",
"field": "",
"fieldname": "custom",
"fieldtype": "Check",
"has_next_condition": 1,
"is_table_field": 0,
"label": "Custom?",
"next_step_condition": "eval: doc.custom",
"parent_field": "",
"position": "Left",
"title": "Custom "
},
{
"description": "A Field (or a docfield) defines a property of a DocType. You can define the column name, label, datatype and more for DocFields. For instance, a ToDo doctype has fields description, status and priority. These ultimately become columns in the database table tabToDo.",
"field": "",
"fieldname": "fields",
"fieldtype": "Table",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Fields",
"parent_field": "",
"position": "Top",
"title": "Fields"
}
],
"title": "Doctype"
}

View file

@ -2,6 +2,9 @@
# License: MIT. See LICENSE # License: MIT. See LICENSE
import frappe import frappe
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
def get_notification_config(): def get_notification_config():
return { return {
@ -39,28 +42,40 @@ def get_todays_events(as_list=False):
def get_unseen_likes(): def get_unseen_likes():
"""Returns count of unseen likes""" """Returns count of unseen likes"""
return frappe.db.sql("""select count(*) from `tabComment`
where comment_doctype = DocType("Comment")
comment_type='Like' return frappe.db.count(comment_doctype,
and modified >= (NOW() - INTERVAL '1' YEAR) filters=(
and owner is not null and owner!=%(user)s (comment_doctype.comment_type == "Like")
and reference_owner=%(user)s & (comment_doctype.modified >= Now() - Interval(years=1))
and seen=0 & (comment_doctype.owner.notnull())
""", {"user": frappe.session.user})[0][0] & (comment_doctype.owner != frappe.session.user)
& (comment_doctype.reference_owner == frappe.session.user)
& (comment_doctype.seen == 0)
)
)
def get_unread_emails(): def get_unread_emails():
"returns unread emails for a user" "returns count of unread emails for a user"
return frappe.db.sql("""\ communication_doctype = DocType("Communication")
SELECT count(*) user_doctype = DocType("User")
FROM `tabCommunication` distinct_email_accounts = (
WHERE communication_type='Communication' frappe.qb.from_(user_doctype)
AND communication_medium='Email' .select(user_doctype.email_account)
AND sent_or_received='Received' .where(user_doctype.parent == frappe.session.user)
AND email_status not in ('Spam', 'Trash') .distinct()
AND email_account in ( )
SELECT distinct email_account from `tabUser Email` WHERE parent=%(user)s
return frappe.db.count(communication_doctype,
filters=(
(communication_doctype.communication_type == "Communication")
& (communication_doctype.communication_medium == "Email")
& (communication_doctype.sent_or_received == "Received")
& (communication_doctype.email_status.notin(["spam", "Trash"]))
& (communication_doctype.email_account.isin(distinct_email_accounts))
& (communication_doctype.modified >= Now() - Interval(years=1))
& (communication_doctype.seen == 0)
) )
AND modified >= (NOW() - INTERVAL '1' YEAR) )
AND seen=0
""", {"user": frappe.session.user})[0][0]

View file

@ -8,6 +8,7 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.docfield import supports_translation from frappe.model.docfield import supports_translation
from frappe.model import core_doctypes_list from frappe.model import core_doctypes_list
from frappe.query_builder.functions import IfNull
class CustomField(Document): class CustomField(Document):
def autoname(self): def autoname(self):
@ -115,9 +116,7 @@ def get_fields_label(doctype=None):
def create_custom_field_if_values_exist(doctype, df): def create_custom_field_if_values_exist(doctype, df):
df = frappe._dict(df) df = frappe._dict(df)
if df.fieldname in frappe.db.get_table_columns(doctype) and \ if df.fieldname in frappe.db.get_table_columns(doctype) and \
frappe.db.sql("""select count(*) from `tab{doctype}` frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""):
where ifnull({fieldname},'')!=''""".format(doctype=doctype, fieldname=df.fieldname))[0][0]:
create_custom_field(doctype, df) create_custom_field(doctype, df)
def create_custom_field(doctype, df, ignore_validate=False): def create_custom_field(doctype, df, ignore_validate=False):

View file

@ -114,6 +114,7 @@ frappe.ui.form.on("Customize Form", {
frm.page.clear_icons(); frm.page.clear_icons();
if (frm.doc.doc_type) { if (frm.doc.doc_type) {
frm.page.set_title(__('Customize Form - {0}', [frm.doc.doc_type]));
frappe.customize_form.set_primary_action(frm); frappe.customize_form.set_primary_action(frm);
frm.add_custom_button( frm.add_custom_button(
@ -276,6 +277,21 @@ frappe.ui.form.on("DocType Action", {
} }
}); });
// can't delete standard states
frappe.ui.form.on("DocType State", {
before_states_remove: function(frm, doctype, name) {
let row = frappe.get_doc(doctype, name);
if (!(row.custom || row.__islocal)) {
frappe.msgprint(__("Cannot delete standard document state."));
throw "cannot delete standard document state";
}
},
states_add: function(frm, cdt, cdn) {
let f = frappe.model.get_doc(cdt, cdn);
f.custom = 1;
}
});
frappe.customize_form.set_primary_action = function(frm) { frappe.customize_form.set_primary_action = function(frm) {
frm.page.set_primary_action(__("Update"), function() { frm.page.set_primary_action(__("Update"), function() {
if (frm.doc.doc_type) { if (frm.doc.doc_type) {
@ -332,3 +348,4 @@ frappe.customize_form.clear_locals_and_refresh = function(frm) {
frm.refresh(); frm.refresh();
} }
extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm}));

View file

@ -41,6 +41,8 @@
"actions", "actions",
"document_links_section", "document_links_section",
"links", "links",
"document_states_section",
"states",
"section_break_8", "section_break_8",
"sort_field", "sort_field",
"column_break_10", "column_break_10",
@ -280,6 +282,20 @@
"fieldname": "autoname", "fieldname": "autoname",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Auto Name" "label": "Auto Name"
},
{
"collapsible": 1,
"collapsible_depends_on": "states",
"depends_on": "doc_type",
"fieldname": "document_states_section",
"fieldtype": "Section Break",
"label": "Document States"
},
{
"fieldname": "states",
"fieldtype": "Table",
"label": "States",
"options": "DocType State"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
@ -288,10 +304,11 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2021-06-21 19:01:06.920663", "modified": "2021-12-14 16:45:04.308690",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form", "name": "Customize Form",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -308,5 +325,6 @@
"search_fields": "doc_type", "search_fields": "doc_type",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View file

@ -72,7 +72,7 @@ class CustomizeForm(Document):
new_d[prop] = d.get(prop) new_d[prop] = d.get(prop)
self.append("fields", new_d) self.append("fields", new_d)
for fieldname in ('links', 'actions'): for fieldname in ('links', 'actions', 'states'):
for d in meta.get(fieldname): for d in meta.get(fieldname):
self.append(fieldname, d) self.append(fieldname, d)
@ -258,7 +258,8 @@ class CustomizeForm(Document):
''' '''
for doctype, fieldname, field_map in ( for doctype, fieldname, field_map in (
('DocType Link', 'links', doctype_link_properties), ('DocType Link', 'links', doctype_link_properties),
('DocType Action', 'actions', doctype_action_properties) ('DocType Action', 'actions', doctype_action_properties),
('DocType State', 'states', doctype_state_properties),
): ):
has_custom = False has_custom = False
items = [] items = []
@ -568,6 +569,11 @@ doctype_action_properties = {
'hidden': 'Check' 'hidden': 'Check'
} }
doctype_state_properties = {
'title': 'Data',
'color': 'Select'
}
ALLOWED_FIELDTYPE_CHANGE = ( ALLOWED_FIELDTYPE_CHANGE = (
('Currency', 'Float', 'Percent'), ('Currency', 'Float', 'Percent'),

View file

@ -37,7 +37,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Applied On", "label": "Applied On",
"options": "\nDocField\nDocType\nDocType Link\nDocType Action", "options": "\nDocField\nDocType\nDocType Link\nDocType Action\nDocType State",
"read_only_depends_on": "eval:!doc.__islocal", "read_only_depends_on": "eval:!doc.__islocal",
"reqd": 1 "reqd": 1
}, },
@ -109,7 +109,7 @@
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-09-04 12:46:17.860769", "modified": "2021-12-14 14:15:41.929071",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Property Setter", "name": "Property Setter",
@ -141,5 +141,6 @@
"search_fields": "doc_type,property", "search_fields": "doc_type,property",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View file

@ -0,0 +1,79 @@
{
"creation": "2021-11-23 12:22:32.922700",
"docstatus": 0,
"doctype": "Form Tour",
"first_document": 0,
"idx": 0,
"include_name_field": 0,
"is_standard": 1,
"modified": "2021-11-24 19:15:34.244244",
"modified_by": "Administrator",
"module": "Custom",
"name": "Custom Field",
"owner": "Administrator",
"reference_doctype": "Custom Field",
"save_on_complete": 1,
"steps": [
{
"description": "Select a Document for which you want the Custom Field",
"field": "",
"fieldname": "dt",
"fieldtype": "Link",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Document",
"parent_field": "",
"position": "Right",
"title": "Document"
},
{
"description": "Enter a Label for this field",
"field": "",
"fieldname": "label",
"fieldtype": "Data",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Label",
"parent_field": "",
"position": "Right",
"title": "Label"
},
{
"description": "Select the label after which you want to insert new field.",
"field": "",
"fieldname": "insert_after",
"fieldtype": "Select",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Insert After",
"parent_field": "",
"position": "Right",
"title": "Insert After"
},
{
"description": "Select an appropriate Field Type that suits your requirements",
"field": "",
"fieldname": "fieldtype",
"fieldtype": "Select",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Field Type",
"parent_field": "",
"position": "Left",
"title": "Field Type"
},
{
"description": "Check this to make it a mandatory field",
"field": "",
"fieldname": "reqd",
"fieldtype": "Check",
"has_next_condition": 0,
"is_table_field": 0,
"label": "Is Mandatory Field",
"parent_field": "",
"position": "Left",
"title": "Is Mandatory Field"
}
],
"title": "Custom Field"
}

View file

@ -0,0 +1,44 @@
{
"allow_roles": [
{
"role": "All"
}
],
"creation": "2021-11-23 12:21:11.384229",
"docstatus": 0,
"doctype": "Module Onboarding",
"documentation_url": "https://docs.erpnext.com/docs/v13/user/manual/en/customize-erpnext",
"idx": 0,
"is_complete": 0,
"modified": "2021-11-24 17:04:31.523715",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customization",
"owner": "Administrator",
"steps": [
{
"step": "Custom Field"
},
{
"step": "Custom Doctype"
},
{
"step": "Naming Series"
},
{
"step": "Workflows"
},
{
"step": "Role Permissions"
},
{
"step": "Print Format"
},
{
"step": "Report Builder"
}
],
"subtitle": "Custom Field, Custom Doctype, Naming Series, Role Permission, Workflow, Print Formats, Reports",
"success_message": "Customization onboarding is all done!",
"title": "Customization"
}

View file

@ -0,0 +1,21 @@
{
"action": "Create Entry",
"action_label": "Learn more about creating new DocTypes",
"creation": "2021-11-23 12:30:04.407568",
"description": "A DocType (Document Type) is used to insert forms in ERPNext. Forms such as Customer, Orders, and Invoices are Doctypes in the backend. You can also create new DocTypes to create new forms in ERPNext as per your business needs.",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-23 12:30:04.407568",
"modified_by": "Administrator",
"name": "Custom Doctype",
"owner": "Administrator",
"reference_document": "DocType",
"show_form_tour": 1,
"show_full_form": 1,
"title": "Custom Document Types",
"validate_action": 1
}

View file

@ -0,0 +1,21 @@
{
"action": "Create Entry",
"action_label": "Learn how to add Custom Fields",
"creation": "2021-11-23 12:21:09.479808",
"description": "Every form in ERPNext has a standard set of fields. If you need to capture some information, but there is no standard Field available for it, you can insert Custom Field for it.\n\nOnce custom fields are added, you can use them for reports and analytics charts as well.\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-23 12:21:09.479808",
"modified_by": "Administrator",
"name": "Custom Field",
"owner": "Administrator",
"reference_document": "Custom Field",
"show_form_tour": 1,
"show_full_form": 1,
"title": "Create Custom Fields",
"validate_action": 1
}

View file

@ -0,0 +1,20 @@
{
"action": "Watch Video",
"creation": "2021-11-23 13:57:45.091427",
"description": "Each document created in ERPNext can have a unique ID generated for it, using a prefix defined for it. Though each document has some prefix pre-configured, you can further customize it using tools like Naming Series Tool and Document Naming Rule.\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-24 15:04:14.662684",
"modified_by": "Administrator",
"name": "Naming Series",
"owner": "Administrator",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Setup Naming Series",
"validate_action": 1,
"video_url": "https://youtu.be/IGyISSfI1qU"
}

View file

@ -0,0 +1,21 @@
{
"action": "Create Entry",
"action_label": "Learn about Standard and Custom Print Formats",
"creation": "2021-11-23 15:04:12.728513",
"description": "Print Formats allow you can define looks for documents when printed or converted to PDF. You can also create a custom Print Format using drag-and-drop tools.",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-23 15:04:12.728513",
"modified_by": "Administrator",
"name": "Print Format",
"owner": "Administrator",
"reference_document": "Print Format",
"show_form_tour": 1,
"show_full_form": 1,
"title": "Customize Print Formats",
"validate_action": 1
}

View file

@ -0,0 +1,22 @@
{
"action": "Watch Video",
"action_label": "Learn more about Report Builders",
"creation": "2021-11-24 17:04:18.762838",
"description": "In each module, you will find a host of single-click reports, ranging from financial statements to sales and purchase analytics and stock tracking reports. If a required new report is not available out-of-the-box, you can create custom reports in ERPNext by pulling values from the same multiple ERPNext tables.\n",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-24 17:04:18.762838",
"modified_by": "Administrator",
"name": "Report Builder",
"owner": "Administrator",
"reference_document": "Report",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Generate Custom Reports",
"validate_action": 1,
"video_url": "https://youtu.be/TxJGUNarcQs"
}

View file

@ -0,0 +1,20 @@
{
"action": "Watch Video",
"creation": "2021-11-23 14:00:27.208500",
"description": "In ERPNext, you can add your Employees as Users, and give them restricted access. Tools like Role Permission and User Permission allow you to define rules which give restricted access to the user to masters and transactions.",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-24 15:04:14.615232",
"modified_by": "Administrator",
"name": "Role Permissions",
"owner": "Administrator",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Setup Limited Access for a User",
"validate_action": 1,
"video_url": "https://youtu.be/g3mk45o1zAg"
}

View file

@ -0,0 +1,20 @@
{
"action": "Watch Video",
"creation": "2021-11-23 13:58:58.530044",
"description": "Workflows allow you to define custom rules for the approval process of a particular document in ERPNext. You can also set complex Workflow Rules and set approval conditions.",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-11-24 15:04:14.632144",
"modified_by": "Administrator",
"name": "Workflows",
"owner": "Administrator",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Setup Approval Workflows",
"validate_action": 1,
"video_url": "https://youtu.be/yObJUg9FxFs"
}

View file

@ -1,6 +1,6 @@
{ {
"charts": [], "charts": [],
"content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Customize Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Custom Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Client Script\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Server Script\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Dashboards\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Form Customization\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other\", \"col\": 4}}]", "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Customize Form\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Custom Role\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Client Script\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Server Script\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports &amp; Masters\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Dashboards\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Form Customization\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other\",\"col\":4}}]",
"creation": "2020-03-02 15:15:03.839594", "creation": "2020-03-02 15:15:03.839594",
"docstatus": 0, "docstatus": 0,
"doctype": "Workspace", "doctype": "Workspace",
@ -123,7 +123,7 @@
"type": "Link" "type": "Link"
} }
], ],
"modified": "2021-08-05 12:15:57.486113", "modified": "2021-11-24 16:20:03.500885",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customization", "name": "Customization",

View file

@ -1,5 +1,4 @@
# -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors
# Copyright (c) 2017, Frappe Technologies and contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
import frappe import frappe
@ -8,6 +7,20 @@ from frappe.modules.export_file import export_to_files, create_init_py
from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.custom.doctype.custom_field.custom_field import create_custom_field
from frappe.model.document import Document from frappe.model.document import Document
def get_mapping_module(module, mapping_name):
app_name = frappe.db.get_value("Module Def", module, "app_name")
mapping_name = frappe.scrub(mapping_name)
module = frappe.scrub(module)
try:
return frappe.get_module(
f"{app_name}.{module}.data_migration_mapping.{mapping_name}"
)
except ImportError:
return None
class DataMigrationPlan(Document): class DataMigrationPlan(Document):
def on_update(self): def on_update(self):
# update custom fields in mappings # update custom fields in mappings
@ -54,26 +67,14 @@ class DataMigrationPlan(Document):
frappe.flags.ignore_in_install = False frappe.flags.ignore_in_install = False
def pre_process_doc(self, mapping_name, doc): def pre_process_doc(self, mapping_name, doc):
module = self.get_mapping_module(mapping_name) module = get_mapping_module(self.module, mapping_name)
if module and hasattr(module, 'pre_process'): if module and hasattr(module, 'pre_process'):
return module.pre_process(doc) return module.pre_process(doc)
return doc return doc
def post_process_doc(self, mapping_name, local_doc=None, remote_doc=None): def post_process_doc(self, mapping_name, local_doc=None, remote_doc=None):
module = self.get_mapping_module(mapping_name) module = get_mapping_module(self.module, mapping_name)
if module and hasattr(module, 'post_process'): if module and hasattr(module, 'post_process'):
return module.post_process(local_doc=local_doc, remote_doc=remote_doc) return module.post_process(local_doc=local_doc, remote_doc=remote_doc)
def get_mapping_module(self, mapping_name):
try:
module_def = frappe.get_doc("Module Def", self.module)
module = frappe.get_module('{app}.{module}.data_migration_mapping.{mapping_name}'.format(
app= module_def.app_name,
module=frappe.scrub(self.module),
mapping_name=frappe.scrub(mapping_name)
))
return module
except ImportError:
return None

View file

@ -171,10 +171,10 @@ class Database(object):
frappe.errprint(query) frappe.errprint(query)
elif self.is_deadlocked(e): elif self.is_deadlocked(e):
raise frappe.QueryDeadlockError raise frappe.QueryDeadlockError(e)
elif self.is_timedout(e): elif self.is_timedout(e):
raise frappe.QueryTimeoutError raise frappe.QueryTimeoutError(e)
if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)):
pass pass
@ -260,6 +260,7 @@ class Database(object):
self.commit() self.commit()
self.sql(query, debug=debug) self.sql(query, debug=debug)
def check_transaction_status(self, query): def check_transaction_status(self, query):
"""Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are """Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are
executed in one transaction. This is to ensure that writes are always flushed otherwise this executed in one transaction. This is to ensure that writes are always flushed otherwise this
@ -334,8 +335,21 @@ class Database(object):
"""Returns `get_value` with fieldname='*'""" """Returns `get_value` with fieldname='*'"""
return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache) return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache)
def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, def get_value(
debug=False, order_by=None, cache=False, for_update=False, run=True): self,
doctype,
filters=None,
fieldname="name",
ignore=None,
as_dict=False,
debug=False,
order_by="KEEP_DEFAULT_ORDERING",
cache=False,
for_update=False,
run=True,
pluck=False,
distinct=False,
):
"""Returns a document property or list of properties. """Returns a document property or list of properties.
:param doctype: DocType name. :param doctype: DocType name.
@ -362,7 +376,7 @@ class Database(object):
""" """
ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug, ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
order_by, cache=cache, for_update=for_update, run=run) order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct)
if not run: if not run:
return ret return ret
@ -370,7 +384,8 @@ class Database(object):
return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None
def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
debug=False, order_by=None, update=None, cache=False, for_update=False, run=True): debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False,
run=True, pluck=False, distinct=False):
"""Returns multiple document properties. """Returns multiple document properties.
:param doctype: DocType name. :param doctype: DocType name.
@ -379,7 +394,8 @@ class Database(object):
:param ignore: Don't raise exception if table, column is missing. :param ignore: Don't raise exception if table, column is missing.
:param as_dict: Return values as dict. :param as_dict: Return values as dict.
:param debug: Print query in error log. :param debug: Print query in error log.
:param order_by: Column to order by :param order_by: Column to order by,
:param distinct: Get Distinct results.
Example: Example:
@ -394,9 +410,20 @@ class Database(object):
(doctype, filters, fieldname) in self.value_cache: (doctype, filters, fieldname) in self.value_cache:
return self.value_cache[(doctype, filters, fieldname)] return self.value_cache[(doctype, filters, fieldname)]
if distinct:
order_by = None
if isinstance(filters, list): if isinstance(filters, list):
order_by = order_by or "modified_desc" out = self._get_value_for_many_names(
out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug, run=run) doctype,
filters,
fieldname,
order_by,
debug=debug,
run=run,
pluck=pluck,
distinct=distinct,
)
else: else:
fields = fieldname fields = fieldname
@ -408,9 +435,20 @@ class Database(object):
if (filters is not None) and (filters!=doctype or doctype=="DocType"): if (filters is not None) and (filters!=doctype or doctype=="DocType"):
try: try:
order_by = order_by or "modified" if order_by:
order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by
out = self._get_values_from_table( out = self._get_values_from_table(
fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update, run=run fields,
filters,
doctype,
as_dict,
debug,
order_by,
update,
for_update=for_update,
run=run,
pluck=pluck,
distinct=distinct
) )
except Exception as e: except Exception as e:
if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)): if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)):
@ -418,19 +456,30 @@ class Database(object):
out = None out = None
elif (not ignore) and frappe.db.is_table_missing(e): elif (not ignore) and frappe.db.is_table_missing(e):
# table not found, look in singles # table not found, look in singles
out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run, distinct=distinct)
else: else:
raise raise
else: else:
out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run, pluck=pluck, distinct=distinct)
if cache and isinstance(filters, str): if cache and isinstance(filters, str):
self.value_cache[(doctype, filters, fieldname)] = out self.value_cache[(doctype, filters, fieldname)] = out
return out return out
def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None, run=True): def get_values_from_single(
self,
fields,
filters,
doctype,
as_dict=False,
debug=False,
update=None,
run=True,
pluck=False,
distinct=False,
):
"""Get values from `tabSingles` (Single DocTypes) (internal). """Get values from `tabSingles` (Single DocTypes) (internal).
:param fields: List of fields, :param fields: List of fields,
@ -456,10 +505,13 @@ class Database(object):
return [map(values.get, fields)] return [map(values.get, fields)]
else: else:
r = self.sql("""select field, value r = self.query.get_sql(
from `tabSingles` where field in (%s) and doctype=%s""" "Singles",
% (', '.join(['%s'] * len(fields)), '%s'), filters={"field": ("in", tuple(fields)), "doctype": doctype},
tuple(fields) + (doctype,), as_dict=False, debug=debug, run=run) fields=["field", "value"],
distinct=distinct,
).run(pluck=pluck, debug=debug, as_dict=False)
if not run: if not run:
return r return r
if as_dict: if as_dict:
@ -484,14 +536,10 @@ class Database(object):
# Get coulmn and value of the single doctype Accounts Settings # Get coulmn and value of the single doctype Accounts Settings
account_settings = frappe.db.get_singles_dict("Accounts Settings") account_settings = frappe.db.get_singles_dict("Accounts Settings")
""" """
result = self.sql(""" result = self.query.get_sql(
SELECT field, value "Singles", filters={"doctype": doctype}, fields=["field", "value"]
FROM `tabSingles` ).run()
WHERE doctype = %s
""", doctype)
dict_ = frappe._dict(result) dict_ = frappe._dict(result)
return dict_ return dict_
@staticmethod @staticmethod
@ -520,8 +568,11 @@ class Database(object):
if fieldname in self.value_cache[doctype]: if fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname] return self.value_cache[doctype][fieldname]
val = self.sql("""select `value` from val = self.query.get_sql(
`tabSingles` where `doctype`=%s and `field`=%s""", (doctype, fieldname)) table="Singles",
filters={"doctype": doctype, "field": fieldname},
fields="value",
).run()
val = val[0][0] if val else None val = val[0][0] if val else None
df = frappe.get_meta(doctype).get_field(fieldname) df = frappe.get_meta(doctype).get_field(fieldname)
@ -539,40 +590,64 @@ class Database(object):
"""Alias for get_single_value""" """Alias for get_single_value"""
return self.get_single_value(*args, **kwargs) return self.get_single_value(*args, **kwargs)
def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, def _get_values_from_table(
update=None, for_update=False, run=True): self,
fields,
filters,
doctype,
as_dict,
debug,
order_by=None,
update=None,
for_update=False,
run=True,
pluck=False,
distinct=False,
):
field_objects = [] field_objects = []
if not isinstance(fields, Criterion): if not isinstance(fields, Criterion):
for field in fields: for field in fields:
if "(" in field or " as " in field: if "(" in str(field) or " as " in str(field):
field_objects.append(PseudoColumn(field)) field_objects.append(PseudoColumn(field))
else: else:
field_objects.append(field) field_objects.append(field)
criterion = self.query.build_conditions( query = self.query.get_sql(
table=doctype, filters=filters, orderby=order_by, for_update=for_update table=doctype,
filters=filters,
orderby=order_by,
for_update=for_update,
field_objects=field_objects,
fields=fields,
distinct=distinct,
) )
if isinstance(fields, (list, tuple)): if (
query = criterion.select(*field_objects) fields == "*"
and not isinstance(fields, (list, tuple))
and not isinstance(fields, Criterion)
):
as_dict = True
elif isinstance(fields, Criterion): r = self.sql(
query = criterion.select(fields) query, as_dict=as_dict, debug=debug, update=update, run=run, pluck=pluck
)
else:
if fields=="*":
query = criterion.select(fields)
as_dict = True
r = self.sql(query, as_dict=as_dict, debug=debug, update=update, run=run)
return r return r
def _get_value_for_many_names(self, doctype, names, field, debug=False, run=True): def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False):
names = list(filter(None, names)) names = list(filter(None, names))
if names: if names:
return self.get_all(doctype, return self.get_all(
doctype,
fields=field, fields=field,
filters=names, filters=names,
debug=debug, as_list=1, run=run) order_by=order_by,
pluck=pluck,
debug=debug,
as_list=1,
run=run,
distinct=distinct,
)
else: else:
return {} return {}
@ -788,25 +863,13 @@ class Database(object):
except Exception: except Exception:
return None return None
def min(self, dt, fieldname, filters=None, **kwargs):
return self.query.build_conditions(dt, filters=filters).select(Min(Column(fieldname))).run(**kwargs)[0][0] or 0
def max(self, dt, fieldname, filters=None, **kwargs):
return self.query.build_conditions(dt, filters=filters).select(Max(Column(fieldname))).run(**kwargs)[0][0] or 0
def avg(self, dt, fieldname, filters=None, **kwargs):
return self.query.build_conditions(dt, filters=filters).select(Avg(Column(fieldname))).run(**kwargs)[0][0] or 0
def sum(self, dt, fieldname, filters=None, **kwargs):
return self.query.build_conditions(dt, filters=filters).select(Sum(Column(fieldname))).run(**kwargs)[0][0] or 0
def count(self, dt, filters=None, debug=False, cache=False): def count(self, dt, filters=None, debug=False, cache=False):
"""Returns `COUNT(*)` for given DocType and filters.""" """Returns `COUNT(*)` for given DocType and filters."""
if cache and not filters: if cache and not filters:
cache_count = frappe.cache().get_value('doctype:count:{}'.format(dt)) cache_count = frappe.cache().get_value('doctype:count:{}'.format(dt))
if cache_count is not None: if cache_count is not None:
return cache_count return cache_count
query = self.query.build_conditions(table=dt, filters=filters).select(Count("*")) query = self.query.get_sql(table=dt, filters=filters, fields=Count("*"))
if filters: if filters:
count = self.sql(query, debug=debug)[0][0] count = self.sql(query, debug=debug)[0][0]
return count return count

View file

@ -43,7 +43,7 @@ class MariaDBDatabase(Database):
'Dynamic Link': ('varchar', self.VARCHAR_LEN), 'Dynamic Link': ('varchar', self.VARCHAR_LEN),
'Password': ('text', ''), 'Password': ('text', ''),
'Select': ('varchar', self.VARCHAR_LEN), 'Select': ('varchar', self.VARCHAR_LEN),
'Rating': ('int', '1'), 'Rating': ('decimal', '3,2'),
'Read Only': ('varchar', self.VARCHAR_LEN), 'Read Only': ('varchar', self.VARCHAR_LEN),
'Attach': ('text', ''), 'Attach': ('text', ''),
'Attach Image': ('text', ''), 'Attach Image': ('text', ''),

View file

@ -53,7 +53,7 @@ class PostgresDatabase(Database):
'Dynamic Link': ('varchar', self.VARCHAR_LEN), 'Dynamic Link': ('varchar', self.VARCHAR_LEN),
'Password': ('text', ''), 'Password': ('text', ''),
'Select': ('varchar', self.VARCHAR_LEN), 'Select': ('varchar', self.VARCHAR_LEN),
'Rating': ('smallint', None), 'Rating': ('decimal', '3,2'),
'Read Only': ('varchar', self.VARCHAR_LEN), 'Read Only': ('varchar', self.VARCHAR_LEN),
'Attach': ('text', ''), 'Attach': ('text', ''),
'Attach Image': ('text', ''), 'Attach Image': ('text', ''),

View file

@ -1,8 +1,10 @@
import operator import operator
import re
from typing import Any, Dict, List, Tuple, Union from typing import Any, Dict, List, Tuple, Union
import frappe import frappe
from frappe.query_builder import Criterion, Order, Field from frappe import _
from frappe.query_builder import Criterion, Field, Order
def like(key: str, value: str) -> frappe.qb: def like(key: str, value: str) -> frappe.qb:
@ -224,6 +226,7 @@ class Query:
""" """
conditions = self.get_condition(table, **kwargs) conditions = self.get_condition(table, **kwargs)
if not filters: if not filters:
conditions = self.add_conditions(conditions, **kwargs)
return conditions return conditions
for key in filters: for key in filters:
@ -245,7 +248,12 @@ class Query:
conditions = self.add_conditions(conditions, **kwargs) conditions = self.add_conditions(conditions, **kwargs)
return conditions return conditions
def build_conditions(self, table: str, filters: Union[Dict[str, Union[str, int]], str, int] = None, **kwargs) -> frappe.qb: def build_conditions(
self,
table: str,
filters: Union[Dict[str, Union[str, int]], str, int] = None,
**kwargs
) -> frappe.qb:
"""Build conditions for sql query """Build conditions for sql query
Args: Args:
@ -255,13 +263,67 @@ class Query:
Returns: Returns:
frappe.qb: frappe.qb conditions object frappe.qb: frappe.qb conditions object
""" """
if isinstance(filters, Criterion):
return self.criterion_query(table, filters, **kwargs)
if isinstance(filters, int) or isinstance(filters, str): if isinstance(filters, int) or isinstance(filters, str):
filters = {"name": str(filters)} filters = {"name": str(filters)}
if isinstance(filters, (list, tuple)): if isinstance(filters, Criterion):
return self.misc_query(table, filters, **kwargs) criterion = self.criterion_query(table, filters, **kwargs)
return self.dict_query(filters=filters, table=table, **kwargs) elif isinstance(filters, (list, tuple)):
criterion = self.misc_query(table, filters, **kwargs)
else:
criterion = self.dict_query(filters=filters, table=table, **kwargs)
return criterion
def get_sql(
self,
table: str,
fields: Union[List, Tuple],
filters: Union[Dict[str, Union[str, int]], str, int] = None,
**kwargs
):
criterion = self.build_conditions(table, filters, **kwargs)
if isinstance(fields, (list, tuple)):
query = criterion.select(*kwargs.get("field_objects", fields))
elif isinstance(fields, Criterion):
query = criterion.select(fields)
else:
query = criterion.select(fields)
return query
class Permission:
@classmethod
def check_permissions(cls, query, **kwargs):
if not isinstance(query, str):
query = query.get_sql()
doctype = cls.get_tables_from_query(query)
if isinstance(doctype, str):
doctype = [doctype]
for dt in doctype:
dt = re.sub("tab", "", dt)
if not frappe.has_permission(
dt,
"select",
user=kwargs.get("user"),
parent_doctype=kwargs.get("parent_doctype"),
) and not frappe.has_permission(
dt,
"read",
user=kwargs.get("user"),
parent_doctype=kwargs.get("parent_doctype"),
):
frappe.throw(
_("Insufficient Permission for {0}").format(frappe.bold(dt))
)
@staticmethod
def get_tables_from_query(query: str):
return [table for table in re.findall(r"\w+", query) if table.startswith("tab")]

View file

@ -5,7 +5,6 @@ from frappe.utils import cstr
queue_prefix = 'insert_queue_for_' queue_prefix = 'insert_queue_for_'
@frappe.whitelist()
def deferred_insert(doctype, records): def deferred_insert(doctype, records):
frappe.cache().rpush(queue_prefix + doctype, records) frappe.cache().rpush(queue_prefix + doctype, records)

View file

@ -15,10 +15,13 @@ frappe.ui.form.on('Form Tour', {
frm.add_custom_button(__('Show Tour'), async () => { frm.add_custom_button(__('Show Tour'), async () => {
const issingle = await check_if_single(frm.doc.reference_doctype); const issingle = await check_if_single(frm.doc.reference_doctype);
const name = await get_first_document(frm.doc.reference_doctype);
let route_changed = null; let route_changed = null;
if (issingle) { if (issingle) {
route_changed = frappe.set_route('Form', frm.doc.reference_doctype); route_changed = frappe.set_route('Form', frm.doc.reference_doctype);
} else if (frm.doc.first_document) {
route_changed = frappe.set_route('Form', frm.doc.reference_doctype, name);
} else { } else {
route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new'); route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new');
} }
@ -120,4 +123,15 @@ function get_child_field(child_table, child_name, fieldname) {
async function check_if_single(doctype) { async function check_if_single(doctype) {
const { message } = await frappe.db.get_value('DocType', doctype, 'issingle'); const { message } = await frappe.db.get_value('DocType', doctype, 'issingle');
return message.issingle || 0; return message.issingle || 0;
} }
async function get_first_document(doctype) {
let docname;
await frappe.db.get_list(doctype, { order_by: "creation" }).then(res => {
if (Array.isArray(res) && res.length)
docname = res[0].name;
});
return docname || 'new';
}

View file

@ -9,8 +9,11 @@
"title", "title",
"reference_doctype", "reference_doctype",
"module", "module",
"column_break_6",
"is_standard", "is_standard",
"save_on_complete", "save_on_complete",
"first_document",
"include_name_field",
"section_break_3", "section_break_3",
"steps" "steps"
], ],
@ -62,14 +65,32 @@
"label": "Module", "label": "Module",
"options": "Module Def", "options": "Module Def",
"read_only": 1 "read_only": 1
},
{
"fieldname": "column_break_6",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "first_document",
"fieldtype": "Check",
"label": "Show First Document Tour"
},
{
"default": "0",
"depends_on": "eval:!doc.first_document",
"fieldname": "include_name_field",
"fieldtype": "Check",
"label": "Include Name Field"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-06-06 20:32:54.068774", "modified": "2021-11-24 12:03:45.449311",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Form Tour", "name": "Form Tour",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View file

@ -33,7 +33,7 @@ class GlobalSearchSettings(Document):
def get_doctypes_for_global_search(): def get_doctypes_for_global_search():
def get_from_db(): def get_from_db():
doctypes = frappe.get_list("Global Search DocType", fields=["document_type"], order_by="idx ASC") doctypes = frappe.get_all("Global Search DocType", fields=["document_type"], order_by="idx ASC")
return [d.document_type for d in doctypes] or [] return [d.document_type for d in doctypes] or []
return frappe.cache().hget("global_search", "search_priorities", get_from_db) return frappe.cache().hget("global_search", "search_priorities", get_from_db)

View file

@ -1,155 +1,55 @@
{ {
"allow_copy": 0, "actions": [],
"allow_import": 0, "creation": "2016-10-19 12:26:42.569185",
"allow_rename": 0, "doctype": "DocType",
"beta": 0, "editable_grid": 1,
"creation": "2016-10-19 12:26:42.569185", "engine": "InnoDB",
"custom": 0, "field_order": [
"docstatus": 0, "column_name",
"doctype": "DocType", "status",
"document_type": "", "indicator",
"editable_grid": 1, "order"
"engine": "InnoDB", ],
"fields": [ "fields": [
{ {
"allow_on_submit": 0, "fieldname": "column_name",
"bold": 0, "fieldtype": "Data",
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Column Name"
"fieldname": "column_name", },
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Column Name",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "default": "Active",
"bold": 0, "fieldname": "status",
"collapsible": 0, "fieldtype": "Select",
"columns": 0, "in_list_view": 1,
"default": "Active", "label": "Status",
"fieldname": "status", "options": "Active\nArchived"
"fieldtype": "Select", },
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Status",
"length": 0,
"no_copy": 0,
"options": "Active\nArchived",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "default": "Gray",
"bold": 0, "fieldname": "indicator",
"collapsible": 0, "fieldtype": "Select",
"columns": 0, "in_list_view": 1,
"default": "darkgrey", "label": "Indicator",
"fieldname": "indicator", "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nRed\nYellow"
"fieldtype": "Select", },
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Indicator",
"length": 0,
"no_copy": 0,
"options": "blue\norange\nred\ngreen\ndarkgrey\npurple\nyellow\nlightblue",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "order",
"bold": 0, "fieldtype": "Code",
"collapsible": 0, "label": "Order"
"columns": 0,
"fieldname": "order",
"fieldtype": "Code",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Order",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
} }
], ],
"hide_heading": 0, "istable": 1,
"hide_toolbar": 0, "links": [],
"idx": 0, "modified": "2021-12-14 13:13:38.804259",
"image_view": 0, "modified_by": "Administrator",
"in_create": 0, "module": "Desk",
"name": "Kanban Board Column",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0, "permissions": [],
"istable": 1, "quick_entry": 1,
"max_attachments": 0, "sort_field": "modified",
"modified": "2017-01-17 15:23:43.520379", "sort_order": "DESC",
"modified_by": "Administrator", "states": [],
"module": "Desk", "track_changes": 1
"name": "Kanban Board Column",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
} }

View file

@ -1,3 +0,0 @@
.like-disabled-input{
background-color: #fff;
}

View file

@ -2,6 +2,17 @@
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on("Onboarding Step", { frappe.ui.form.on("Onboarding Step", {
setup: function(frm) {
frm.set_query("form_tour", function() {
return {
filters: {
reference_doctype: frm.doc.reference_document
}
};
});
},
refresh: function(frm) { refresh: function(frm) {
frappe.boot.developer_mode && frappe.boot.developer_mode &&
frm.set_intro( frm.set_intro(

View file

@ -20,6 +20,7 @@
"reference_document", "reference_document",
"show_full_form", "show_full_form",
"show_form_tour", "show_form_tour",
"form_tour",
"is_single", "is_single",
"reference_report", "reference_report",
"report_reference_doctype", "report_reference_doctype",
@ -206,13 +207,21 @@
"fieldname": "show_form_tour", "fieldname": "show_form_tour",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Show Form Tour" "label": "Show Form Tour"
},
{
"depends_on": "show_form_tour",
"fieldname": "form_tour",
"fieldtype": "Link",
"label": "Form Tour",
"options": "Form Tour"
} }
], ],
"links": [], "links": [],
"modified": "2020-10-30 14:54:06.646513", "modified": "2021-12-02 10:56:04.448580",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Onboarding Step", "name": "Onboarding Step",
"naming_rule": "Set by user",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View file

@ -1,9 +1,13 @@
# Copyright (c) 2021, Frappe Technologies and contributors # Copyright (c) 2021, Frappe Technologies and contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE
import json
import frappe import frappe
from frappe.deferred_insert import deferred_insert as _deferred_insert
from frappe.model.document import Document from frappe.model.document import Document
class RouteHistory(Document): class RouteHistory(Document):
pass pass
@ -35,3 +39,16 @@ def flush_old_route_records():
"modified": ("<=", last_record_to_keep[0].modified), "modified": ("<=", last_record_to_keep[0].modified),
"user": user "user": user
}) })
@frappe.whitelist()
def deferred_insert(routes):
routes = [
{
"user": frappe.session.user,
"route": route.get("route"),
"creation": route.get("creation"),
}
for route in frappe.parse_json(routes)
]
_deferred_insert("Route History", json.dumps(routes))

View file

@ -2,6 +2,8 @@
# License: MIT. See LICENSE # License: MIT. See LICENSE
import json import json
from collections import defaultdict from collections import defaultdict
import itertools
from typing import List
import frappe import frappe
import frappe.desk.form.load import frappe.desk.form.load
@ -12,69 +14,296 @@ from frappe.modules import load_doctype_module
@frappe.whitelist() @frappe.whitelist()
def get_submitted_linked_docs(doctype, name, docs=None, visited=None): def get_submitted_linked_docs(doctype: str, name: str) -> List[tuple]:
""" Get all the nested submitted documents those are present in referencing tables (dependent tables).
:param doctype: Document type
:param name: Name of the document
Usecase:
* User should be able to cancel the linked documents along with the one user trying to cancel.
Case1: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to sd2-n2 and sd2-n2 is linked to sd3-n3,
Getting submittable linked docs of `sd1-n1`should give both sd2-n2 and sd3-n3.
Case2: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to d2-n2 and d2-n2 is linked to sd3-n3,
Getting submittable linked docs of `sd1-n1`should give None. (because d2-n2 is not a submittable doctype)
Case3: If document sd1-n1 (document name n1 from submittable doctype sd1) is linked to d2-n2 & sd2-n2. d2-n2 is linked to sd3-n3.
Getting submittable linked docs of `sd1-n1`should give sd2-n2.
Logic:
-----
1. We can find linked documents only if we know how the doctypes are related.
2. As we need only submittable documents, we can limit doctype relations search to submittable doctypes by
finding the relationships(Foreign key references) across submittable doctypes.
3. Searching for links is going to be a tree like structure where at every level,
you will be finding documents using parent document and parent document links.
""" """
Get all nested submitted linked doctype linkinfo tree = SubmittableDocumentTree(doctype, name)
visited_documents = tree.get_all_children()
docs = []
Arguments: for dt, names in visited_documents.items():
doctype (str) - The doctype for which get all linked doctypes docs.extend([{'doctype': dt, 'name': name, 'docstatus': 1} for name in names])
name (str) - The docname for which get all linked doctypes
Keyword Arguments:
docs (list of dict) - (Optional) Get list of dictionary for linked doctype.
Returns:
dict - Return list of documents and link count
"""
if not docs:
docs = []
if not visited:
visited = {}
if doctype not in visited:
visited[doctype] = []
if name in visited[doctype]:
return
linkinfo = get_linked_doctypes(doctype)
linked_docs = get_linked_docs(doctype, name, linkinfo)
link_count = 0
visited[doctype].append(name)
for link_doctype, link_names in linked_docs.items():
for link in link_names:
if link['name'] == name:
continue
docinfo = link.update({"doctype": link_doctype})
validated_doc = validate_linked_doc(docinfo)
if not validated_doc:
continue
link_count += 1
links = get_submitted_linked_docs(link_doctype, link.name, docs, visited)
if links:
docs.append({
"doctype": link_doctype,
"name": link.name,
"docstatus": link.docstatus,
"link_count": links.get("count")
})
# sort linked documents by ascending number of links
docs.sort(key=lambda doc: doc.get("link_count"))
return { return {
"docs": docs, "docs": docs,
"count": link_count "count": len(docs)
} }
class SubmittableDocumentTree:
def __init__(self, doctype: str, name: str):
"""Construct a tree for the submitable linked documents.
* Node has properties like doctype and docnames. Represented as Node(doctype, docnames).
* Nodes are linked by doctype relationships like table, link and dynamic links.
* Node is referenced(linked) by many other documents and those are the child nodes.
NOTE: child document is a property of child node (not same as Frappe child docs of a table field).
"""
self.root_doctype = doctype
self.root_docname = name
# Documents those are yet to be visited for linked documents.
self.to_be_visited_documents = {doctype: [name]}
self.visited_documents = defaultdict(list)
self._submittable_doctypes = None # All submittable doctypes in the system
self._references_across_doctypes = None # doctype wise links/references
def get_all_children(self):
"""Get all nodes of a tree except the root node (all the nested submitted
documents those are present in referencing tables (dependent tables).
"""
while self.to_be_visited_documents:
next_level_children = defaultdict(list)
for parent_dt in list(self.to_be_visited_documents):
parent_docs = self.to_be_visited_documents.get(parent_dt)
if not parent_docs:
del self.to_be_visited_documents[parent_dt]
continue
child_docs = self.get_next_level_children(parent_dt, parent_docs)
self.visited_documents[parent_dt].extend(parent_docs)
for linked_dt, linked_names in child_docs.items():
not_visited_child_docs = set(linked_names) - set(self.visited_documents.get(linked_dt, []))
next_level_children[linked_dt].extend(not_visited_child_docs)
self.to_be_visited_documents = next_level_children
# Remove root node from visited documents
if self.root_docname in self.visited_documents.get(self.root_doctype, []):
self.visited_documents[self.root_doctype].remove(self.root_docname)
return self.visited_documents
def get_next_level_children(self, parent_dt, parent_names):
"""Get immediate children of a Node(parent_dt, parent_names)
"""
referencing_fields = self.get_doctype_references(parent_dt)
child_docs = defaultdict(list)
for field in referencing_fields:
links = get_referencing_documents(parent_dt, parent_names.copy(), field, get_parent_if_child_table_doc=True,
parent_filters=[('docstatus', '=', 1)], allowed_parents=self.get_link_sources()) or {}
for dt, names in links.items():
child_docs[dt].extend(names)
return child_docs
def get_doctype_references(self, doctype):
"""Get references for a given document.
"""
if self._references_across_doctypes is None:
get_links_to = self.get_document_sources()
limit_link_doctypes = self.get_link_sources()
self._references_across_doctypes = get_references_across_doctypes(
get_links_to, limit_link_doctypes)
return self._references_across_doctypes.get(doctype, [])
def get_document_sources(self):
"""Returns list of doctypes from where we access submittable documents.
"""
return list(set(self.get_link_sources() + [self.root_doctype]))
def get_link_sources(self):
"""limit doctype links to these doctypes.
"""
return list(set(self.get_submittable_doctypes()) - set(get_exempted_doctypes() or []))
def get_submittable_doctypes(self) -> List[str]:
"""Returns list of submittable doctypes.
"""
if not self._submittable_doctypes:
self._submittable_doctypes = frappe.db.get_all('DocType', {'is_submittable': 1}, pluck='name')
return self._submittable_doctypes
def get_child_tables_of_doctypes(doctypes: List[str]=None):
"""Returns child tables by doctype.
"""
filters=[['fieldtype','=', 'Table']]
filters_for_docfield = filters
filters_for_customfield = filters
if doctypes:
filters_for_docfield = filters + [['parent', 'in', tuple(doctypes)]]
filters_for_customfield = filters + [['dt', 'in', tuple(doctypes)]]
links = frappe.get_all("DocField",
fields=["parent", "fieldname", "options as child_table"],
filters=filters_for_docfield,
as_list=1)
links+= frappe.get_all("Custom Field",
fields=["dt as parent", "fieldname", "options as child_table"],
filters=filters_for_customfield,
as_list=1)
child_tables_by_doctype = defaultdict(list)
for doctype, fieldname, child_table in links:
child_tables_by_doctype[doctype].append(
{'doctype': doctype, 'fieldname': fieldname, 'child_table': child_table})
return child_tables_by_doctype
def get_references_across_doctypes(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None) -> List:
"""Find doctype wise foreign key references.
:param to_doctypes: Get links of these doctypes.
:param limit_link_doctypes: limit links to these doctypes.
* Include child table, link and dynamic link references.
"""
if limit_link_doctypes:
child_tables_by_doctype = get_child_tables_of_doctypes(limit_link_doctypes)
all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())]
limit_link_doctypes = limit_link_doctypes + all_child_tables
else:
child_tables_by_doctype = get_child_tables_of_doctypes()
all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())]
references_by_link_fields = get_references_across_doctypes_by_link_field(to_doctypes, limit_link_doctypes)
references_by_dlink_fields = get_references_across_doctypes_by_dynamic_link_field(to_doctypes, limit_link_doctypes)
references = references_by_link_fields.copy()
for k, v in references_by_dlink_fields.items():
references.setdefault(k, []).extend(v)
for doctype, links in references.items():
for link in links:
link['is_child'] = (link['doctype'] in all_child_tables)
return references
def get_references_across_doctypes_by_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None):
"""Find doctype wise foreign key references based on link fields.
:param to_doctypes: Get links to these doctypes.
:param limit_link_doctypes: limit links to these doctypes.
"""
filters=[['fieldtype','=', 'Link']]
if to_doctypes:
filters += [['options', 'in', tuple(to_doctypes)]]
filters_for_docfield = filters[:]
filters_for_customfield = filters[:]
if limit_link_doctypes:
filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]]
filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]]
links = frappe.get_all("DocField",
fields=["parent", "fieldname", "options as linked_to"],
filters=filters_for_docfield,
as_list=1)
links+= frappe.get_all("Custom Field",
fields=["dt as parent", "fieldname", "options as linked_to"],
filters=filters_for_customfield,
as_list=1)
links_by_doctype = defaultdict(list)
for doctype, fieldname, linked_to in links:
links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname})
return links_by_doctype
def get_references_across_doctypes_by_dynamic_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None):
"""Find doctype wise foreign key references based on dynamic link fields.
:param to_doctypes: Get links to these doctypes.
:param limit_link_doctypes: limit links to these doctypes.
"""
filters=[['fieldtype','=', 'Dynamic Link']]
filters_for_docfield = filters[:]
filters_for_customfield = filters[:]
if limit_link_doctypes:
filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]]
filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]]
# find dynamic links of parents
links = frappe.get_all("DocField",
fields=["parent as doctype", "fieldname", "options as doctype_fieldname"],
filters=filters_for_docfield,
as_list=1)
links += frappe.get_all("Custom Field",
fields=["dt as doctype", "fieldname", "options as doctype_fieldname"],
filters=filters_for_customfield,
as_list=1)
links_by_doctype = defaultdict(list)
for doctype, fieldname, doctype_fieldname in links:
try:
filters = [[doctype_fieldname, 'in', to_doctypes]] if to_doctypes else []
for linked_to in frappe.db.get_all(doctype, pluck=doctype_fieldname, filters = filters, distinct=1):
if linked_to:
links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname, 'doctype_fieldname': doctype_fieldname})
except frappe.db.ProgrammingError:
# TODO: FIXME
continue
return links_by_doctype
def get_referencing_documents(reference_doctype: str, reference_names: List[str],
link_info: dict, get_parent_if_child_table_doc: bool=True,
parent_filters: List[list]=None, child_filters=None, allowed_parents=None):
"""Get linked documents based on link_info.
:param reference_doctype: reference doctype to find links
:param reference_names: reference document names to find links for
:param link_info: linking details to get the linked documents
Ex: {'doctype': 'Purchase Invoice Advance', 'fieldname': 'reference_name',
'doctype_fieldname': 'reference_type', 'is_child': True}
:param get_parent_if_child_table_doc: Get parent record incase linked document is a child table record.
:param parent_filters: filters to apply on if not a child table.
:param child_filters: apply filters if it is a child table.
:param allowed_parents: list of parents allowed in case of get_parent_if_child_table_doc
is enabled.
"""
from_table = link_info['doctype']
filters = [[link_info['fieldname'], 'in', tuple(reference_names)]]
if link_info.get('doctype_fieldname'):
filters.append([link_info['doctype_fieldname'], '=', reference_doctype])
if not link_info.get('is_child'):
filters.extend(parent_filters or [])
return {from_table: frappe.db.get_all(from_table, filters, pluck='name')}
filters.extend(child_filters or [])
res = frappe.db.get_all(from_table, filters = filters, fields = ['name', 'parenttype', 'parent'])
documents = defaultdict(list)
for parent, rows in itertools.groupby(res, key = lambda row: row['parenttype']):
if allowed_parents and parent not in allowed_parents:
continue
filters = (parent_filters or []) + [['name', 'in', tuple([row.parent for row in rows])]]
documents[parent].extend(frappe.db.get_all(parent, filters=filters, pluck='name') or [])
return documents
@frappe.whitelist() @frappe.whitelist()
def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=None): def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=None):
@ -109,7 +338,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None):
Returns: Returns:
bool: True if linked document passes all validations, else False bool: True if linked document passes all validations, else False
""" """
#ignore doctype to cancel #ignore doctype to cancel
if docinfo.get("doctype") in (ignore_doctypes_on_cancel_all or []): if docinfo.get("doctype") in (ignore_doctypes_on_cancel_all or []):
return False return False
@ -132,7 +360,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None):
def get_exempted_doctypes(): def get_exempted_doctypes():
""" Get list of doctypes exempted from being auto-cancelled """ """ Get list of doctypes exempted from being auto-cancelled """
auto_cancel_exempt_doctypes = [] auto_cancel_exempt_doctypes = []
for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'): for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'):
auto_cancel_exempt_doctypes.append(doctypes) auto_cancel_exempt_doctypes.append(doctypes)
@ -183,11 +410,11 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
try: try:
if link.get("filters"): if link.get("filters"):
ret = frappe.get_list(doctype=dt, fields=fields, filters=link.get("filters")) ret = frappe.get_all(doctype=dt, fields=fields, filters=link.get("filters"))
elif link.get("get_parent"): elif link.get("get_parent"):
if me and me.parent and me.parenttype == dt: if me and me.parent and me.parenttype == dt:
ret = frappe.get_list(doctype=dt, fields=fields, ret = frappe.get_all(doctype=dt, fields=fields,
filters=[[dt, "name", '=', me.parent]]) filters=[[dt, "name", '=', me.parent]])
else: else:
ret = None ret = None
@ -199,7 +426,7 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if link.get("doctype_fieldname"): if link.get("doctype_fieldname"):
filters.append([link.get('child_doctype'), link.get("doctype_fieldname"), "=", doctype]) filters.append([link.get('child_doctype'), link.get("doctype_fieldname"), "=", doctype])
ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True) ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True)
else: else:
link_fieldnames = link.get("fieldname") link_fieldnames = link.get("fieldname")
@ -210,7 +437,7 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
# dynamic link # dynamic link
if link.get("doctype_fieldname"): if link.get("doctype_fieldname"):
filters.append([dt, link.get("doctype_fieldname"), "=", doctype]) filters.append([dt, link.get("doctype_fieldname"), "=", doctype])
ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters) ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters)
else: else:
ret = None ret = None

View file

@ -197,6 +197,8 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
callback: (r) => { callback: (r) => {
if (r.message.status === 'ok') { if (r.message.status === 'ok') {
this.post_setup_success(); this.post_setup_success();
} else if (r.message.status === 'registered') {
this.update_setup_message(__("starting the setup..."));
} else if (r.message.fail !== undefined) { } else if (r.message.fail !== undefined) {
this.abort_setup(r.message.fail); this.abort_setup(r.message.fail);
} }
@ -238,6 +240,9 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides {
if (data.fail_msg) { if (data.fail_msg) {
this.abort_setup(data.fail_msg); this.abort_setup(data.fail_msg);
} }
if (data.status === 'ok') {
this.post_setup_success();
}
}) })
} }

View file

@ -54,9 +54,17 @@ def setup_complete(args):
return {'status': 'ok'} return {'status': 'ok'}
args = parse_args(args) args = parse_args(args)
stages = get_setup_stages(args) stages = get_setup_stages(args)
is_background_task = frappe.conf.get('trigger_site_setup_in_background')
if is_background_task:
process_setup_stages.enqueue(stages=stages, user_input=args, is_background_task=True)
return {'status': 'registered'}
else:
return process_setup_stages(stages, args)
@frappe.task()
def process_setup_stages(stages, user_input, is_background_task=False):
try: try:
frappe.flags.in_setup_wizard = True frappe.flags.in_setup_wizard = True
current_task = None current_task = None
@ -68,11 +76,16 @@ def setup_complete(args):
current_task = task current_task = task
task.get('fn')(task.get('args')) task.get('fn')(task.get('args'))
except Exception: except Exception:
handle_setup_exception(args) handle_setup_exception(user_input)
return {'status': 'fail', 'fail': current_task.get('fail_msg')} if not is_background_task:
return {'status': 'fail', 'fail': current_task.get('fail_msg')}
frappe.publish_realtime('setup_task',
{'status': 'fail', "fail_msg": current_task.get('fail_msg')}, user=frappe.session.user)
else: else:
run_setup_success(args) run_setup_success(user_input)
return {'status': 'ok'} if not is_background_task:
return {'status': 'ok'}
frappe.publish_realtime('setup_task', {"status": 'ok'}, user=frappe.session.user)
finally: finally:
frappe.flags.in_setup_wizard = False frappe.flags.in_setup_wizard = False

View file

@ -17,21 +17,15 @@ class UserProfile {
show() { show() {
let route = frappe.get_route(); let route = frappe.get_route();
this.user_id = route[1] || frappe.session.user; this.user_id = route[1] || frappe.session.user;
frappe.dom.freeze(__('Loading user profile') + '...');
//validate if user frappe.db.exists('User', this.user_id).then(exists => {
if (route.length > 1) { frappe.dom.unfreeze();
frappe.dom.freeze(__('Loading user profile') + '...'); if (exists) {
frappe.db.exists('User', this.user_id).then(exists => { this.make_user_profile();
frappe.dom.unfreeze(); } else {
if (exists) { frappe.msgprint(__('User does not exist'));
this.make_user_profile(); }
} else { });
frappe.msgprint(__('User does not exist'));
}
});
} else {
frappe.set_route('user-profile', frappe.session.user);
}
} }
make_user_profile() { make_user_profile() {
@ -74,8 +68,7 @@ class UserProfile {
primary_action_label: __('Go'), primary_action_label: __('Go'),
primary_action: ({ user }) => { primary_action: ({ user }) => {
dialog.hide(); dialog.hide();
this.user_id = user; frappe.set_route('user-profile', user);
this.make_user_profile();
} }
}); });
dialog.show(); dialog.show();

View file

@ -51,10 +51,10 @@
<p><a class="edit-profile-link">{%=__("Edit Profile") %}</a></p> <p><a class="edit-profile-link">{%=__("Edit Profile") %}</a></p>
<p><a class="user-settings-link">{%=__("User Settings") %}</a></p> <p><a class="user-settings-link">{%=__("User Settings") %}</a></p>
<p> <p>
<a class="leaderboard-link" href="#leaderboard/User" <a class="leaderboard-link" href="/app/leaderboard/User"
>{%=__("Leaderboard") %}</a >{%=__("Leaderboard") %}</a
> >
</p> </p>
</div> </div>
</div> </div>
</div> </div>

View file

@ -109,6 +109,15 @@ frappe.ui.form.on("Email Account", {
onload: function(frm) { onload: function(frm) {
frm.set_df_property("append_to", "only_select", true); frm.set_df_property("append_to", "only_select", true);
frm.set_query("append_to", "frappe.email.doctype.email_account.email_account.get_append_to"); frm.set_query("append_to", "frappe.email.doctype.email_account.email_account.get_append_to");
frm.set_query("append_to", "imap_folder", function() {
return {
query: "frappe.email.doctype.email_account.email_account.get_append_to"
};
});
if (frm.doc.__islocal) {
frm.add_child("imap_folder", {"folder_name": "INBOX"});
frm.refresh_field("imap_folder");
}
}, },
refresh: function(frm) { refresh: function(frm) {
@ -117,7 +126,7 @@ frappe.ui.form.on("Email Account", {
frm.events.notify_if_unreplied(frm); frm.events.notify_if_unreplied(frm);
frm.events.show_gmail_message_for_less_secure_apps(frm); frm.events.show_gmail_message_for_less_secure_apps(frm);
if(frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) { if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) {
delete frappe.route_flags.delete_user_from_locals; delete frappe.route_flags.delete_user_from_locals;
delete locals['User'][frappe.route_flags.linked_user]; delete locals['User'][frappe.route_flags.linked_user];
} }
@ -125,7 +134,7 @@ frappe.ui.form.on("Email Account", {
show_gmail_message_for_less_secure_apps: function(frm) { show_gmail_message_for_less_secure_apps: function(frm) {
frm.dashboard.clear_headline(); frm.dashboard.clear_headline();
if(frm.doc.service==="GMail") { if (frm.doc.service==="GMail") {
frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \ frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \
apps in Gmail settings. <a target="_blank" \ apps in Gmail settings. <a target="_blank" \
href="https://support.google.com/accounts/answer/6010255?hl=en">Read this for details</a>'); href="https://support.google.com/accounts/answer/6010255?hl=en">Read this for details</a>');
@ -137,8 +146,8 @@ frappe.ui.form.on("Email Account", {
frm.events.update_domain(frm); frm.events.update_domain(frm);
}, },
update_domain: function(frm){ update_domain: function(frm) {
if (!frm.doc.email_id && !frm.doc.service){ if (!frm.doc.email_id && !frm.doc.service) {
return; return;
} }
@ -148,7 +157,7 @@ frappe.ui.form.on("Email Account", {
args: { args: {
"email_id": frm.doc.email_id "email_id": frm.doc.email_id
}, },
callback: function (r) { callback: function(r) {
if (r.message) { if (r.message) {
frm.events.set_domain_fields(frm, r.message); frm.events.set_domain_fields(frm, r.message);
} }
@ -157,7 +166,7 @@ frappe.ui.form.on("Email Account", {
}, },
set_domain_fields: function(frm, args) { set_domain_fields: function(frm, args) {
if(!args){ if (!args) {
args = frappe.route_flags.set_domain_values? frappe.route_options: {}; args = frappe.route_flags.set_domain_values? frappe.route_options: {};
} }
@ -172,10 +181,8 @@ frappe.ui.form.on("Email Account", {
email_sync_option: function(frm) { email_sync_option: function(frm) {
// confirm if the ALL sync option is selected // confirm if the ALL sync option is selected
if(frm.doc.email_sync_option == "ALL"){ if (frm.doc.email_sync_option == "ALL") {
var msg = __("You are selecting Sync Option as ALL, It will resync all \ var msg = __("You are selecting Sync Option as ALL, It will resync all read as well as unread message from server. This may also cause the duplication of Communication (emails).");
read as well as unread message from server. This may also cause the duplication\
of Communication (emails).");
frappe.confirm(msg, null, function() { frappe.confirm(msg, null, function() {
frm.set_value("email_sync_option", "UNSEEN"); frm.set_value("email_sync_option", "UNSEEN");
}); });
@ -184,8 +191,7 @@ frappe.ui.form.on("Email Account", {
warn_autoreply_on_incoming: function(frm) { warn_autoreply_on_incoming: function(frm) {
if (frm.doc.enable_incoming && frm.doc.enable_auto_reply && frm.doc.__islocal) { if (frm.doc.enable_incoming && frm.doc.enable_auto_reply && frm.doc.__islocal) {
var msg = __("Enabling auto reply on an incoming email account will send automated replies \ var msg = __("Enabling auto reply on an incoming email account will send automated replies to all the synchronized emails. Do you wish to continue?");
to all the synchronized emails. Do you wish to continue?");
frappe.confirm(msg, null, function() { frappe.confirm(msg, null, function() {
frm.set_value("enable_auto_reply", 0); frm.set_value("enable_auto_reply", 0);
frappe.show_alert({message: __("Disabled Auto Reply"), indicator: "blue"}); frappe.show_alert({message: __("Disabled Auto Reply"), indicator: "blue"});

View file

@ -31,6 +31,8 @@
"attachment_limit", "attachment_limit",
"email_sync_option", "email_sync_option",
"initial_sync_count", "initial_sync_count",
"section_break_25",
"imap_folder",
"section_break_12", "section_break_12",
"append_emails_to_sent_folder", "append_emails_to_sent_folder",
"append_to", "append_to",
@ -204,7 +206,7 @@
"label": "Attachment Limit (MB)" "label": "Attachment Limit (MB)"
}, },
{ {
"depends_on": "enable_incoming", "depends_on": "eval: doc.enable_incoming && !doc.use_imap",
"description": "Append as communication against this DocType (must have fields, \"Status\", \"Subject\")", "description": "Append as communication against this DocType (must have fields, \"Status\", \"Subject\")",
"fieldname": "append_to", "fieldname": "append_to",
"fieldtype": "Link", "fieldtype": "Link",
@ -562,15 +564,28 @@
"fieldname": "account_section", "fieldname": "account_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Account" "label": "Account"
},
{
"depends_on": "eval: doc.use_imap && doc.enable_incoming",
"fieldname": "imap_folder",
"fieldtype": "Table",
"label": "IMAP Folder",
"options": "IMAP Folder"
},
{
"fieldname": "section_break_25",
"fieldtype": "Section Break",
"label": "IMAP Details"
} }
], ],
"icon": "fa fa-inbox", "icon": "fa fa-inbox",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-09-21 16:44:25.728637", "modified": "2021-11-30 09:03:25.728637",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Email Account", "name": "Email Account",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View file

@ -67,6 +67,10 @@ class EmailAccount(Document):
else: else:
self.login_id = None self.login_id = None
# validate the imap settings
if self.enable_incoming and self.use_imap and len(self.imap_folder) <= 0:
frappe.throw(_("You need to set one IMAP folder for {0}").format(frappe.bold(self.email_id)))
duplicate_email_account = frappe.get_all("Email Account", filters={ duplicate_email_account = frappe.get_all("Email Account", filters={
"email_id": self.email_id, "email_id": self.email_id,
"name": ("!=", self.name) "name": ("!=", self.name)
@ -100,10 +104,11 @@ class EmailAccount(Document):
for e in self.get_unreplied_notification_emails(): for e in self.get_unreplied_notification_emails():
validate_email_address(e, True) validate_email_address(e, True)
if self.enable_incoming and self.append_to: for folder in self.imap_folder:
valid_doctypes = [d[0] for d in get_append_to()] if self.enable_incoming and folder.append_to:
if self.append_to not in valid_doctypes: valid_doctypes = [d[0] for d in get_append_to()]
frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) if folder.append_to not in valid_doctypes:
frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
def validate_smtp_conn(self): def validate_smtp_conn(self):
if not self.smtp_server: if not self.smtp_server:
@ -177,13 +182,13 @@ class EmailAccount(Document):
return None return None
args = frappe._dict({ args = frappe._dict({
"email_account_name": self.email_account_name,
"email_account": self.name, "email_account": self.name,
"host": self.email_server, "host": self.email_server,
"use_ssl": self.use_ssl, "use_ssl": self.use_ssl,
"username": getattr(self, "login_id", None) or self.email_id, "username": getattr(self, "login_id", None) or self.email_id,
"use_imap": self.use_imap, "use_imap": self.use_imap,
"email_sync_rule": email_sync_rule, "email_sync_rule": email_sync_rule,
"uid_validity": self.uidvalidity,
"incoming_port": get_port(self), "incoming_port": get_port(self),
"initial_sync_count": self.initial_sync_count or 100 "initial_sync_count": self.initial_sync_count or 100
}) })
@ -457,6 +462,14 @@ class EmailAccount(Document):
"""retrive and return inbound mails. """retrive and return inbound mails.
""" """
mails = []
def process_mail(messages):
for index, message in enumerate(messages.get("latest_messages", [])):
uid = messages['uid_list'][index] if messages.get('uid_list') else None
seen_status = 1 if messages.get('seen_status', {}).get(uid) == 'SEEN' else 0
mails.append(InboundMail(message, self, uid, seen_status))
if frappe.local.flags.in_test: if frappe.local.flags.in_test:
return [InboundMail(msg, self) for msg in test_mails or []] return [InboundMail(msg, self) for msg in test_mails or []]
@ -466,17 +479,23 @@ class EmailAccount(Document):
email_sync_rule = self.build_email_sync_rule() email_sync_rule = self.build_email_sync_rule()
try: try:
email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule) email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule)
messages = email_server.get_messages() or {} if self.use_imap:
# process all given imap folder
for folder in self.imap_folder:
email_server.select_imap_folder(folder.folder_name)
email_server.settings['uid_validity'] = folder.uidvalidity
messages = email_server.get_messages(folder=folder.folder_name) or {}
process_mail(messages)
else:
# process the pop3 account
messages = email_server.get_messages() or {}
process_mail(messages)
# close connection to mailserver
email_server.logout()
except Exception: except Exception:
frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name)) frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name))
return [] return []
mails = []
for index, message in enumerate(messages.get("latest_messages", [])):
uid = messages['uid_list'][index] if messages.get('uid_list') else None
seen_status = 1 if messages.get('seen_status', {}).get(uid)=='SEEN' else 0
mails.append(InboundMail(message, self, uid, seen_status))
return mails return mails
def handle_bad_emails(self, uid, raw, reason): def handle_bad_emails(self, uid, raw, reason):
@ -530,7 +549,11 @@ class EmailAccount(Document):
def on_trash(self): def on_trash(self):
"""Clear communications where email account is linked""" """Clear communications where email account is linked"""
frappe.db.sql("update `tabCommunication` set email_account='' where email_account=%s", self.name) Communication = frappe.qb.DocType("Communication")
frappe.qb.update(Communication) \
.set(Communication.email_account, "") \
.where(Communication.email_account == self.name).run()
remove_user_email_inbox(email_account=self.name) remove_user_email_inbox(email_account=self.name)
def after_rename(self, old, new, merge=False): def after_rename(self, old, new, merge=False):
@ -547,23 +570,26 @@ class EmailAccount(Document):
else: else:
return self.email_sync_option or "UNSEEN" return self.email_sync_option or "UNSEEN"
def mark_emails_as_read_unread(self): def mark_emails_as_read_unread(self, email_server=None, folder_name="INBOX"):
""" mark Email Flag Queue of self.email_account mails as read""" """ mark Email Flag Queue of self.email_account mails as read"""
if not self.use_imap: if not self.use_imap:
return return
flags = frappe.db.sql("""select name, communication, uid, action from EmailFlagQ = frappe.qb.DocType("Email Flag Queue")
`tabEmail Flag Queue` where is_completed=0 and email_account={email_account} flags = (
""".format(email_account=frappe.db.escape(self.name)), as_dict=True) frappe.qb.from_(EmailFlagQ)
.select(EmailFlagQ.name, EmailFlagQ.communication, EmailFlagQ.uid, EmailFlagQ.action)
.where(EmailFlagQ.is_completed == 0)
.where(EmailFlagQ.email_account == frappe.db.escape(self.name))
).run(as_dict=True)
uid_list = { flag.get("uid", None): flag.get("action", "Read") for flag in flags } uid_list = { flag.get("uid", None): flag.get("action", "Read") for flag in flags }
if flags and uid_list: if flags and uid_list:
email_server = self.get_incoming_server() if not email_server:
email_server = self.get_incoming_server()
if not email_server: if not email_server:
return return
email_server.update_flag(folder_name, uid_list=uid_list)
email_server.update_flag(uid_list=uid_list)
# mark communication as read # mark communication as read
docnames = ",".join("'%s'"%flag.get("communication") for flag in flags \ docnames = ",".join("'%s'"%flag.get("communication") for flag in flags \
@ -576,16 +602,20 @@ class EmailAccount(Document):
self.set_communication_seen_status(docnames, seen=0) self.set_communication_seen_status(docnames, seen=0)
docnames = ",".join([ "'%s'"%flag.get("name") for flag in flags ]) docnames = ",".join([ "'%s'"%flag.get("name") for flag in flags ])
frappe.db.sql(""" update `tabEmail Flag Queue` set is_completed=1
where name in ({docnames})""".format(docnames=docnames)) EmailFlagQueue = frappe.qb.DocType("Email Flag Queue")
frappe.qb.update(EmailFlagQueue) \
.set(EmailFlagQueue.is_completed, 1) \
.where(EmailFlagQueue.name.isin(docnames)).run()
def set_communication_seen_status(self, docnames, seen=0): def set_communication_seen_status(self, docnames, seen=0):
""" mark Email Flag Queue of self.email_account mails as read""" """ mark Email Flag Queue of self.email_account mails as read"""
if not docnames: if not docnames:
return return
Communication = frappe.qb.from_("Communication")
frappe.db.sql(""" update `tabCommunication` set seen={seen} frappe.qb.update(Communication) \
where name in ({docnames})""".format(docnames=docnames, seen=seen)) .set(Communication.seen == seen) \
.where(Communication.name.isin(docnames)).run()
def check_automatic_linking_email_account(self): def check_automatic_linking_email_account(self):
if self.enable_automatic_linking: if self.enable_automatic_linking:
@ -651,15 +681,19 @@ def test_internet(host="8.8.8.8", port=53, timeout=3):
def notify_unreplied(): def notify_unreplied():
"""Sends email notifications if there are unreplied Communications """Sends email notifications if there are unreplied Communications
and `notify_if_unreplied` is set as true.""" and `notify_if_unreplied` is set as true."""
for email_account in frappe.get_all("Email Account", "name", filters={"enable_incoming": 1, "notify_if_unreplied": 1}): for email_account in frappe.get_all("Email Account", "name", filters={"enable_incoming": 1, "notify_if_unreplied": 1}):
email_account = frappe.get_doc("Email Account", email_account.name) email_account = frappe.get_doc("Email Account", email_account.name)
if email_account.append_to:
if email_account.use_imap:
append_to = [folder.get("append_to") for folder in email_account.imap_folder]
else:
append_to = email_account.append_to
if append_to:
# get open communications younger than x mins, for given doctype # get open communications younger than x mins, for given doctype
for comm in frappe.get_all("Communication", "name", filters=[ for comm in frappe.get_all("Communication", "name", filters=[
{"sent_or_received": "Received"}, {"sent_or_received": "Received"},
{"reference_doctype": email_account.append_to}, {"reference_doctype": ("in", append_to)},
{"unread_notification_sent": 0}, {"unread_notification_sent": 0},
{"email_account":email_account.name}, {"email_account":email_account.name},
{"creation": ("<", datetime.now() - timedelta(seconds = (email_account.unreplied_for_mins or 30) * 60))}, {"creation": ("<", datetime.now() - timedelta(seconds = (email_account.unreplied_for_mins or 30) * 60))},
@ -702,9 +736,6 @@ def pull_from_email_account(email_account):
email_account = frappe.get_doc("Email Account", email_account) email_account = frappe.get_doc("Email Account", email_account)
email_account.receive() email_account.receive()
# mark Email Flag Queue mail as read
email_account.mark_emails_as_read_unread()
def get_max_email_uid(email_account): def get_max_email_uid(email_account):
# get maximum uid of emails # get maximum uid of emails
max_uid = 1 max_uid = 1
@ -761,12 +792,12 @@ def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_ou
update_user_email_settings = True update_user_email_settings = True
if update_user_email_settings: if update_user_email_settings:
frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, UserEmail = frappe.qb.DocType("User Email")
enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { frappe.qb.update(UserEmail) \
"email_account": email_account, .set(UserEmail.awaiting_password, (awaiting_password or 0)) \
"enable_outgoing": enable_outgoing, .set(UserEmail.enable_outgoing, enable_outgoing) \
"awaiting_password": awaiting_password or 0 .where(UserEmail.email_account == email_account).run()
})
else: else:
users = " and ".join([frappe.bold(user.get("name")) for user in user_names]) users = " and ".join([frappe.bold(user.get("name")) for user in user_names])
frappe.msgprint(_("Enabled email inbox for user {0}").format(users)) frappe.msgprint(_("Enabled email inbox for user {0}").format(users))
@ -800,4 +831,4 @@ def set_email_password(email_account, user, password):
frappe.db.rollback() frappe.db.rollback()
return False return False
return True return True

View file

@ -25,6 +25,7 @@ class TestEmailAccount(unittest.TestCase):
email_account = frappe.get_doc("Email Account", "_Test Email Account 1") email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
email_account.db_set("enable_incoming", 1) email_account.db_set("enable_incoming", 1)
email_account.db_set("enable_auto_reply", 1) email_account.db_set("enable_auto_reply", 1)
email_account.db_set("use_imap", 1)
@classmethod @classmethod
def tearDownClass(cls): def tearDownClass(cls):
@ -229,6 +230,22 @@ class TestEmailAccount(unittest.TestCase):
email_account.handle_bad_emails(uid=-1, raw=mail_content, reason="Testing") email_account.handle_bad_emails(uid=-1, raw=mail_content, reason="Testing")
self.assertTrue(frappe.db.get_value("Unhandled Email", {'message_id': message_id})) self.assertTrue(frappe.db.get_value("Unhandled Email", {'message_id': message_id}))
def test_imap_folder(self):
# assert tests if imap_folder >= 1 and imap is checked
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
self.assertTrue(email_account.use_imap)
self.assertTrue(email_account.enable_incoming)
self.assertTrue(len(email_account.imap_folder) > 0)
def test_imap_folder_missing(self):
# Test the Exception in validate() that verifies the imap_folder list
email_account = frappe.get_doc("Email Account", "_Test Email Account 1")
email_account.imap_folder = []
with self.assertRaises(Exception):
email_account.validate()
class TestInboundMail(unittest.TestCase): class TestInboundMail(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):

View file

@ -4,7 +4,6 @@
"is_global": 1, "is_global": 1,
"doctype": "Email Account", "doctype": "Email Account",
"domain":"example.com", "domain":"example.com",
"append_to": "ToDo",
"email_account_name": "_Test Email Account 1", "email_account_name": "_Test Email Account 1",
"enable_outgoing": 1, "enable_outgoing": 1,
"smtp_server": "test.example.com", "smtp_server": "test.example.com",
@ -20,6 +19,8 @@
"send_notification_to": "test_unreplied@example.com", "send_notification_to": "test_unreplied@example.com",
"pop3_server": "pop.test.example.com", "pop3_server": "pop.test.example.com",
"no_remaining":"0", "no_remaining":"0",
"append_to": "ToDo",
"imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}],
"track_email_status": 1 "track_email_status": 1
}, },
{ {

View file

@ -1,213 +1,67 @@
{ {
"allow_copy": 1, "actions": [],
"allow_guest_to_view": 0, "allow_copy": 1,
"allow_import": 0, "creation": "2016-04-20 15:29:39.785172",
"allow_rename": 0, "doctype": "DocType",
"beta": 0, "engine": "InnoDB",
"creation": "2016-04-20 15:29:39.785172", "field_order": [
"custom": 0, "is_completed",
"docstatus": 0, "communication",
"doctype": "DocType", "action",
"document_type": "", "email_account",
"editable_grid": 0, "uid"
],
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "default": "0",
"allow_on_submit": 0, "fieldname": "is_completed",
"bold": 0, "fieldtype": "Check",
"collapsible": 0, "label": "Is Completed",
"columns": 0, "read_only": 1
"fieldname": "is_completed", },
"fieldtype": "Check",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Is Completed",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "communication",
"allow_on_submit": 0, "fieldtype": "Data",
"bold": 0, "label": "Communication"
"collapsible": 0, },
"columns": 0,
"fieldname": "communication",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Communication",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "action",
"allow_on_submit": 0, "fieldtype": "Select",
"bold": 0, "label": "Action",
"collapsible": 0, "options": "Read\nUnread"
"columns": 0, },
"fieldname": "action",
"fieldtype": "Select",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Action",
"length": 0,
"no_copy": 0,
"options": "Read\nUnread",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "email_account",
"allow_on_submit": 0, "fieldtype": "Data",
"bold": 0, "hidden": 1,
"collapsible": 0, "label": "Email Account"
"columns": 0, },
"fieldname": "email_account",
"fieldtype": "Data",
"hidden": 1,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Email Account",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "fieldname": "uid",
"allow_on_submit": 0, "fieldtype": "Data",
"bold": 0, "hidden": 1,
"collapsible": 0, "label": "UID"
"columns": 0,
"fieldname": "uid",
"fieldtype": "Data",
"hidden": 1,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "UID",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "in_create": 1,
"hide_heading": 0, "links": [],
"hide_toolbar": 0, "modified": "2021-11-30 09:51:34.489932",
"idx": 0, "modified_by": "Administrator",
"image_view": 0, "module": "Email",
"in_create": 1, "name": "Email Flag Queue",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2017-09-20 15:27:12.142079",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Flag Queue",
"name_case": "",
"owner": "Administrator",
"permissions": [ "permissions": [
{ {
"amend": 0, "delete": 1,
"apply_user_permissions": 0, "email": 1,
"cancel": 0, "export": 1,
"create": 0, "print": 1,
"delete": 1, "read": 1,
"email": 1, "report": 1,
"export": 1, "role": "System Manager",
"if_owner": 0, "share": 1
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 0
} }
], ],
"quick_entry": 0, "sort_field": "modified",
"read_only": 0, "sort_order": "DESC"
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 0,
"track_seen": 0
} }

View file

@ -18,7 +18,7 @@ from frappe import _, safe_encode, task
from frappe.model.document import Document from frappe.model.document import Document
from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message
from frappe.email.email_body import add_attachment, get_formatted_html, get_email from frappe.email.email_body import add_attachment, get_formatted_html, get_email
from frappe.utils import cint, split_emails, add_days, nowdate, cstr from frappe.utils import cint, split_emails, add_days, nowdate, cstr, get_hook_method
from frappe.email.doctype.email_account.email_account import EmailAccount from frappe.email.doctype.email_account.email_account import EmailAccount
@ -121,9 +121,13 @@ class EmailQueue(Document):
continue continue
message = ctx.build_message(recipient.recipient) message = ctx.build_message(recipient.recipient)
if not frappe.flags.in_test: method = get_hook_method('override_email_send')
ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) if method:
ctx.add_to_sent_list(recipient) method(self, self.sender, recipient.recipient, message)
else:
if not frappe.flags.in_test:
ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message)
ctx.add_to_sent_list(recipient)
if frappe.flags.in_test: if frappe.flags.in_test:
frappe.flags.sent_mail = message frappe.flags.sent_mail = message
@ -283,9 +287,14 @@ class SendMailContext:
if attachment.get('fcontent'): if attachment.get('fcontent'):
continue continue
fid = attachment.get("fid") file_filters = {}
if fid: if attachment.get('fid'):
_file = frappe.get_doc("File", fid) file_filters['name'] = attachment.get('fid')
elif attachment.get('file_url'):
file_filters['file_url'] = attachment.get('file_url')
if file_filters:
_file = frappe.get_doc("File", file_filters)
fcontent = _file.get_content() fcontent = _file.get_content()
attachment.update({ attachment.update({
'fname': _file.file_name, 'fname': _file.file_name,
@ -293,6 +302,7 @@ class SendMailContext:
'parent': message_obj 'parent': message_obj
}) })
attachment.pop("fid", None) attachment.pop("fid", None)
attachment.pop("file_url", None)
add_attachment(**attachment) add_attachment(**attachment)
elif attachment.get("print_format_attachment") == 1: elif attachment.get("print_format_attachment") == 1:
@ -503,7 +513,7 @@ class QueueBuilder:
if self._attachments: if self._attachments:
# store attachments with fid or print format details, to be attached on-demand later # store attachments with fid or print format details, to be attached on-demand later
for att in self._attachments: for att in self._attachments:
if att.get('fid'): if att.get('fid') or att.get('file_url'):
attachments.append(att) attachments.append(att)
elif att.get("print_format_attachment") == 1: elif att.get("print_format_attachment") == 1:
if not att.get('lang', None): if not att.get('lang', None):

View file

@ -0,0 +1,53 @@
{
"actions": [],
"creation": "2021-09-21 11:38:13.521979",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"folder_name",
"append_to",
"uidvalidity",
"uidnext"
],
"fields": [
{
"fieldname": "folder_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Folder Name",
"reqd": 1
},
{
"fieldname": "append_to",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Append To",
"options": "DocType"
},
{
"fieldname": "uidvalidity",
"fieldtype": "Data",
"hidden": 1,
"label": "UIDVALIDITY"
},
{
"fieldname": "uidnext",
"fieldtype": "Data",
"hidden": 1,
"label": "UIDNEXT"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-09-21 11:53:00.811236",
"modified_by": "Administrator",
"module": "Email",
"name": "IMAP Folder",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View file

@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class IMAPFolder(Document):
pass

View file

@ -4,69 +4,137 @@
frappe.ui.form.on('Newsletter', { frappe.ui.form.on('Newsletter', {
refresh(frm) { refresh(frm) {
let doc = frm.doc; let doc = frm.doc;
if (!doc.__islocal && !cint(doc.email_sent) && !doc.__unsaved let can_write = in_list(frappe.boot.user.can_write, doc.doctype);
&& in_list(frappe.boot.user.can_write, doc.doctype)) { if (!frm.is_new() && !frm.is_dirty() && !doc.email_sent && can_write) {
frm.add_custom_button(__('Send Now'), function() { frm.add_custom_button(__('Send a test email'), () => {
frappe.confirm(__("Do you really want to send this email newsletter?"), function() { frm.events.send_test_email(frm);
frm.call('send_emails').then(() => { }, __('Preview'));
frm.refresh();
}); frm.add_custom_button(__('Check broken links'), () => {
frm.dashboard.set_headline(__('Checking broken links...'));
frm.call('find_broken_links').then(r => {
frm.dashboard.set_headline('');
let links = r.message;
if (links && links.length) {
let html = '<ul>' + links.map(link => `<li>${link}</li>`).join('') + '</ul>';
frm.dashboard.set_headline(__("Following links are broken in the email content: {0}", [html]));
} else {
frm.dashboard.set_headline(__("No broken links found in the email content"));
setTimeout(() => {
frm.dashboard.set_headline('');
}, 3000);
}
}); });
}, "fa fa-play", "btn-success"); }, __('Preview'));
frm.add_custom_button(__('Send now'), () => {
if (frm.doc.schedule_send) {
frappe.confirm(__("This newsletter was scheduled to send on a later date. Are you sure you want to send it now?"), function () {
frm.call('send_emails').then(() => frm.refresh());
});
return;
}
frappe.confirm(__("Are you sure you want to send this newsletter now?"), function () {
frm.call('send_emails').then(() => frm.refresh());
});
}, __('Send'));
frm.add_custom_button(__('Schedule sending'), () => {
frm.events.schedule_send_dialog(frm);
}, __('Send'));
} }
frm.events.setup_dashboard(frm); frm.events.setup_dashboard(frm);
frm.events.setup_sending_status(frm);
if (doc.__islocal && !doc.send_from) { if (frm.is_new() && !doc.sender_email) {
let { fullname, email } = frappe.user_info(doc.owner); let { fullname, email } = frappe.user_info(doc.owner);
frm.set_value('send_from', `${fullname} <${email}>`); frm.set_value('sender_email', email);
frm.set_value('sender_name', fullname);
} }
frm.trigger('update_schedule_message');
}, },
onload_post_render(frm) { schedule_send_dialog(frm) {
frm.trigger('setup_schedule_send'); let hours = frappe.utils.range(24);
}, let time_slots = hours.map(hour => {
return `${(hour + '').padStart(2, '0')}:00`;
setup_schedule_send(frm) { });
let today = new Date(); let d = new frappe.ui.Dialog({
title: __('Schedule Newsletter'),
// setting datepicker options to set min date & min time fields: [
today.setHours(today.getHours() + 1 ); {
frm.get_field('schedule_send').$input.datepicker({ label: __('Date'),
maxMinutes: 0, fieldname: 'date',
minDate: today, fieldtype: 'Date',
timeFormat: 'hh:00:00', options: {
onSelect: function (fd, d, picker) { minDate: new Date()
if (!d) return; }
var date = d.toDateString(); },
if (date === today.toDateString()) { {
picker.update({ label: __('Time'),
minHours: (today.getHours() + 1) fieldname: 'time',
}); fieldtype: 'Select',
} else { options: time_slots,
picker.update({ },
minHours: 0 ],
}); primary_action_label: __('Schedule'),
} primary_action({ date, time }) {
frm.get_field('schedule_send').$input.trigger('change'); frm.set_value('schedule_sending', 1);
frm.set_value('schedule_send', `${date} ${time}:00`);
d.hide();
frm.save();
},
secondary_action_label: __('Cancel Scheduling'),
secondary_action() {
frm.set_value('schedule_sending', 0);
frm.set_value('schedule_send', '');
d.hide();
frm.save();
} }
}); });
if (frm.doc.schedule_sending) {
let parts = frm.doc.schedule_send.split(' ');
if (parts.length === 2) {
let [date, time] = parts;
d.set_value('date', date);
d.set_value('time', time.slice(0, 5));
}
}
d.show();
},
send_test_email(frm) {
const $tp = frm.get_field('schedule_send').datepicker.timepicker; let d = new frappe.ui.Dialog({
$tp.$minutes.parent().css('display', 'none'); title: __('Send Test Email'),
$tp.$minutesText.css('display', 'none'); fields: [
$tp.$minutesText.prev().css('display', 'none'); {
$tp.$seconds.parent().css('display', 'none'); label: __('Email'),
fieldname: 'email',
fieldtype: 'Data',
options: 'Email',
}
],
primary_action_label: __('Send'),
primary_action({ email }) {
d.get_primary_btn().text(__('Sending...')).prop('disabled', true);
frm.call('send_test_email', { email })
.then(() => {
d.get_primary_btn().text(__('Send again')).prop('disabled', false);
});
}
});
d.show();
}, },
setup_dashboard(frm) { setup_dashboard(frm) {
if(!frm.doc.__islocal && cint(frm.doc.email_sent) if (!frm.doc.__islocal && cint(frm.doc.email_sent)
&& frm.doc.__onload && frm.doc.__onload.status_count) { && frm.doc.__onload && frm.doc.__onload.status_count) {
var stat = frm.doc.__onload.status_count; var stat = frm.doc.__onload.status_count;
var total = frm.doc.scheduled_to_send; var total = frm.doc.scheduled_to_send;
if(total) { if (total) {
$.each(stat, function(k, v) { $.each(stat, function (k, v) {
stat[k] = flt(v * 100 / total, 2) + '%'; stat[k] = flt(v * 100 / total, 2) + '%';
}); });
@ -94,5 +162,58 @@ frappe.ui.form.on('Newsletter', {
]); ]);
} }
} }
},
setup_sending_status(frm) {
frm.call('get_sending_status').then(r => {
if (r.message) {
frm.events.update_sending_progress(frm, r.message.sent, r.message.total);
}
if (r.message.sent >= r.message.total) {
return;
}
if (frm.sending_status) return;
frm.sending_status = setInterval(() => {
if (frm.doc.email_sent && frm.$wrapper.is(':visible')) {
frm.call('get_sending_status').then(r => {
if (r.message) {
let { sent, total } = r.message;
frm.events.update_sending_progress(frm, sent, total);
if (sent >= total) {
clearInterval(frm.sending_status);
frm.sending_status = null;
return;
}
}
});
}
}, 5000);
});
},
update_sending_progress(frm, sent, total) {
if (sent >= total) {
frm.dashboard.hide_progress();
return;
}
frm.dashboard.show_progress(__('Sending emails'), sent * 100 / total, __("{0} of {1} sent", [sent, total]));
},
on_hide(frm) {
if (frm.sending_status) {
clearInterval(frm.sending_status);
frm.sending_status = null;
}
},
update_schedule_message(frm) {
if (!frm.doc.email_sent && frm.doc.schedule_send) {
let datetime = frappe.datetime.global_date_format(frm.doc.schedule_send);
frm.dashboard.set_headline_alert(__('This newsletter is scheduled to be sent on {0}', [datetime.bold()]));
} else {
frm.dashboard.clear_headline();
}
} }
}); });

View file

@ -7,48 +7,59 @@
"document_type": "Other", "document_type": "Other",
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"status_section",
"email_sent_at",
"column_break_3",
"total_recipients",
"column_break_12",
"email_sent",
"from_section",
"sender_name",
"column_break_5",
"sender_email",
"column_break_7",
"send_from", "send_from",
"schedule_sending",
"schedule_send",
"recipients", "recipients",
"email_group", "email_group",
"email_sent", "subject_section",
"newsletter_content",
"subject", "subject",
"newsletter_content",
"content_type", "content_type",
"message", "message",
"message_md", "message_md",
"message_html", "message_html",
"section_break_13", "attachments",
"send_unsubscribe_link", "send_unsubscribe_link",
"send_attachments",
"column_break_9",
"published",
"send_webview_link", "send_webview_link",
"route", "schedule_settings_section",
"test_the_newsletter", "scheduled_to_send",
"test_email_id", "schedule_sending",
"test_send", "schedule_send",
"scheduled_to_send" "publish_as_a_web_page_section",
"published",
"route"
], ],
"fields": [ "fields": [
{ {
"fieldname": "email_group", "fieldname": "email_group",
"fieldtype": "Table", "fieldtype": "Table",
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Email Group", "label": "Audience",
"options": "Newsletter Email Group" "options": "Newsletter Email Group",
"reqd": 1
}, },
{ {
"fieldname": "send_from", "fieldname": "send_from",
"fieldtype": "Data", "fieldtype": "Data",
"ignore_xss_filter": 1, "ignore_xss_filter": 1,
"label": "Sender" "label": "Sender",
"read_only": 1
}, },
{ {
"default": "0", "default": "0",
"fieldname": "email_sent", "fieldname": "email_sent",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1,
"label": "Email Sent", "label": "Email Sent",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
@ -87,32 +98,12 @@
"label": "Published" "label": "Published"
}, },
{ {
"depends_on": "published",
"fieldname": "route", "fieldname": "route",
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1,
"label": "Route", "label": "Route",
"read_only": 1 "read_only": 1
}, },
{
"collapsible": 1,
"fieldname": "test_the_newsletter",
"fieldtype": "Section Break",
"label": "Testing"
},
{
"description": "A Lead with this Email Address should exist",
"fieldname": "test_email_id",
"fieldtype": "Data",
"label": "Test Email Address",
"options": "Email"
},
{
"depends_on": "eval: doc.test_email_id",
"fieldname": "test_send",
"fieldtype": "Button",
"label": "Test",
"options": "test_send"
},
{ {
"fieldname": "scheduled_to_send", "fieldname": "scheduled_to_send",
"fieldtype": "Int", "fieldtype": "Int",
@ -122,21 +113,16 @@
{ {
"fieldname": "recipients", "fieldname": "recipients",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Recipients" "label": "To"
}, },
{ {
"depends_on": "eval: doc.schedule_sending", "depends_on": "eval: doc.schedule_sending",
"fieldname": "schedule_send", "fieldname": "schedule_send",
"fieldtype": "Datetime", "fieldtype": "Datetime",
"label": "Schedule Send", "label": "Send Email At",
"read_only": 1,
"read_only_depends_on": "eval: doc.email_sent" "read_only_depends_on": "eval: doc.email_sent"
}, },
{
"default": "0",
"fieldname": "send_attachments",
"fieldtype": "Check",
"label": "Send Attachments"
},
{ {
"fieldname": "content_type", "fieldname": "content_type",
"fieldtype": "Select", "fieldtype": "Select",
@ -161,23 +147,87 @@
"default": "0", "default": "0",
"fieldname": "schedule_sending", "fieldname": "schedule_sending",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Schedule Sending", "label": "Schedule sending at a later time",
"read_only_depends_on": "eval: doc.email_sent" "read_only_depends_on": "eval: doc.email_sent"
}, },
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
},
{ {
"default": "0", "default": "0",
"depends_on": "published",
"fieldname": "send_webview_link", "fieldname": "send_webview_link",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Send Web View Link" "label": "Send Web View Link"
}, },
{ {
"fieldname": "section_break_13", "fieldname": "from_section",
"fieldtype": "Section Break" "fieldtype": "Section Break",
"label": "From"
},
{
"fieldname": "sender_name",
"fieldtype": "Data",
"label": "Sender Name"
},
{
"fieldname": "sender_email",
"fieldtype": "Data",
"label": "Sender Email",
"options": "Email",
"reqd": 1
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_7",
"fieldtype": "Column Break"
},
{
"fieldname": "subject_section",
"fieldtype": "Section Break",
"label": "Subject"
},
{
"fieldname": "publish_as_a_web_page_section",
"fieldtype": "Section Break",
"label": "Publish as a web page"
},
{
"depends_on": "schedule_sending",
"fieldname": "schedule_settings_section",
"fieldtype": "Section Break",
"label": "Scheduled Sending"
},
{
"fieldname": "attachments",
"fieldtype": "Table",
"label": "Attachments",
"options": "Newsletter Attachment"
},
{
"fieldname": "email_sent_at",
"fieldtype": "Datetime",
"label": "Email Sent At",
"read_only": 1
},
{
"fieldname": "total_recipients",
"fieldtype": "Int",
"label": "Total Recipients",
"read_only": 1
},
{
"depends_on": "email_sent",
"fieldname": "status_section",
"fieldtype": "Section Break",
"label": "Status"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
} }
], ],
"has_web_view": 1, "has_web_view": 1,
@ -187,7 +237,7 @@
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 3, "max_attachments": 3,
"modified": "2021-02-22 14:33:56.095380", "modified": "2021-12-06 20:09:37.963141",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Newsletter", "name": "Newsletter",

View file

@ -15,13 +15,11 @@ from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, Newsl
class Newsletter(WebsiteGenerator): class Newsletter(WebsiteGenerator):
def onload(self):
self.setup_newsletter_status()
def validate(self): def validate(self):
self.route = f"newsletters/{self.name}" self.route = f"newsletters/{self.name}"
self.validate_sender_address() self.validate_sender_address()
self.validate_recipient_address() self.validate_recipient_address()
self.validate_publishing()
@property @property
def newsletter_recipients(self) -> List[str]: def newsletter_recipients(self) -> List[str]:
@ -30,29 +28,55 @@ class Newsletter(WebsiteGenerator):
return self._recipients return self._recipients
@frappe.whitelist() @frappe.whitelist()
def test_send(self): def get_sending_status(self):
test_emails = frappe.utils.split_emails(self.test_email_id) count_by_status = frappe.get_all("Email Queue",
self.queue_all(test_emails=test_emails) filters={"reference_doctype": self.doctype, "reference_name": self.name},
frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) fields=["status", "count(name) as count"],
group_by="status",
order_by="status"
)
sent = 0
total = 0
for row in count_by_status:
if row.status == "Sent":
sent = row.count
total += row.count
return {'sent': sent, 'total': total}
@frappe.whitelist()
def send_test_email(self, email):
test_emails = frappe.utils.validate_email_address(email, throw=True)
self.send_newsletter(emails=test_emails)
frappe.msgprint(_("Test email sent to {0}").format(email), alert=True)
@frappe.whitelist()
def find_broken_links(self):
from bs4 import BeautifulSoup
import requests
html = self.get_message()
soup = BeautifulSoup(html, "html.parser")
links = soup.find_all("a")
images = soup.find_all("img")
broken_links = []
for el in links + images:
url = el.attrs.get("href") or el.attrs.get("src")
try:
response = requests.head(url, verify=False, timeout=5)
if response.status_code >= 400:
broken_links.append(url)
except:
broken_links.append(url)
return broken_links
@frappe.whitelist() @frappe.whitelist()
def send_emails(self): def send_emails(self):
"""send emails to leads and customers""" """queue sending emails to recipients"""
self.schedule_sending = False
self.schedule_send = None
self.queue_all() self.queue_all()
frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) frappe.msgprint(_("Email queued to {0} recipients").format(self.total_recipients))
def setup_newsletter_status(self):
"""Setup analytical status for current Newsletter. Can be accessible from desk.
"""
if self.email_sent:
status_count = frappe.get_all("Email Queue",
filters={"reference_doctype": self.doctype, "reference_name": self.name},
fields=["status", "count(name)"],
group_by="status",
order_by="status",
as_list=True,
)
self.get("__onload").status_count = dict(status_count)
def validate_send(self): def validate_send(self):
"""Validate if Newsletter can be sent. """Validate if Newsletter can be sent.
@ -75,8 +99,9 @@ class Newsletter(WebsiteGenerator):
def validate_sender_address(self): def validate_sender_address(self):
"""Validate self.send_from is a valid email address or not. """Validate self.send_from is a valid email address or not.
""" """
if self.send_from: if self.sender_email:
frappe.utils.validate_email_address(self.send_from, throw=True) frappe.utils.validate_email_address(self.sender_email, throw=True)
self.send_from = f"{self.sender_name} <{self.sender_email}>" if self.sender_name else self.sender_email
def validate_recipient_address(self): def validate_recipient_address(self):
"""Validate if self.newsletter_recipients are all valid email addresses or not. """Validate if self.newsletter_recipients are all valid email addresses or not.
@ -84,6 +109,10 @@ class Newsletter(WebsiteGenerator):
for recipient in self.newsletter_recipients: for recipient in self.newsletter_recipients:
frappe.utils.validate_email_address(recipient, throw=True) frappe.utils.validate_email_address(recipient, throw=True)
def validate_publishing(self):
if self.send_webview_link and not self.published:
frappe.throw(_("Newsletter must be published to send webview link in email"))
def get_linked_email_queue(self) -> List[str]: def get_linked_email_queue(self) -> List[str]:
"""Get list of email queue linked to this newsletter. """Get list of email queue linked to this newsletter.
""" """
@ -116,45 +145,24 @@ class Newsletter(WebsiteGenerator):
x for x in self.newsletter_recipients if x not in self.get_success_recipients() x for x in self.newsletter_recipients if x not in self.get_success_recipients()
] ]
def queue_all(self, test_emails: List[str] = None): def queue_all(self):
"""Queue Newsletter to all the recipients generated from the `Email Group` """Queue Newsletter to all the recipients generated from the `Email Group` table
table
Args:
test_email (List[str], optional): Send test Newsletter to the passed set of emails.
Defaults to None.
""" """
if test_emails: self.validate()
for test_email in test_emails: self.validate_send()
frappe.utils.validate_email_address(test_email, throw=True)
else:
self.validate()
self.validate_send()
newsletter_recipients = test_emails or self.get_pending_recipients() recipients = self.get_pending_recipients()
self.send_newsletter(emails=newsletter_recipients) self.send_newsletter(emails=recipients)
if not test_emails: self.email_sent = True
self.email_sent = True self.email_sent_at = frappe.utils.now()
self.schedule_send = frappe.utils.now_datetime() self.total_recipients = len(recipients)
self.scheduled_to_send = len(newsletter_recipients) self.save()
self.save()
def get_newsletter_attachments(self) -> List[Dict[str, str]]: def get_newsletter_attachments(self) -> List[Dict[str, str]]:
"""Get list of attachments on current Newsletter """Get list of attachments on current Newsletter
""" """
attachments = [] return [{"file_url": row.attachment} for row in self.attachments]
if self.send_attachments:
files = frappe.get_all(
"File",
filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name},
order_by="creation desc",
pluck="name",
)
attachments.extend({"fid": file} for file in files)
return attachments
def send_newsletter(self, emails: List[str]): def send_newsletter(self, emails: List[str]):
"""Trigger email generation for `emails` and add it in Email Queue. """Trigger email generation for `emails` and add it in Email Queue.
@ -186,12 +194,13 @@ class Newsletter(WebsiteGenerator):
frappe.db.auto_commit_on_many_writes = is_auto_commit_set frappe.db.auto_commit_on_many_writes = is_auto_commit_set
def get_message(self) -> str: def get_message(self) -> str:
if self.content_type == "HTML": message = self.message
return frappe.render_template(self.message_html, {"doc": self.as_dict()})
if self.content_type == "Markdown": if self.content_type == "Markdown":
return frappe.utils.markdown(self.message_md) message = frappe.utils.md_to_html(self.message_md)
# fallback to Rich Text if self.content_type == "HTML":
return self.message message = self.message_html
return frappe.render_template(message, {"doc": self.as_dict()})
def get_recipients(self) -> List[str]: def get_recipients(self) -> List[str]:
"""Get recipients from Email Group""" """Get recipients from Email Group"""
@ -223,21 +232,6 @@ class Newsletter(WebsiteGenerator):
}, },
) )
def get_context(self, context):
newsletters = get_newsletter_list("Newsletter", None, None, 0)
if newsletters:
newsletter_list = [d.name for d in newsletters]
if self.name not in newsletter_list:
frappe.redirect_to_message(
_("Permission Error"), _("You are not permitted to view the newsletter.")
)
frappe.local.flags.redirect_location = frappe.local.response.location
raise frappe.Redirect
else:
context.attachments = self.get_attachments()
context.no_cache = 1
context.show_sidebar = True
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def confirmed_unsubscribe(email, group): def confirmed_unsubscribe(email, group):
@ -320,35 +314,14 @@ def confirm_subscription(email, email_group=_("Website")):
def get_list_context(context=None): def get_list_context(context=None):
context.update({ context.update({
"show_sidebar": True,
"show_search": True, "show_search": True,
'no_breadcrumbs': True, "no_breadcrumbs": True,
"title": _("Newsletter"), "title": _("Newsletters"),
"get_list": get_newsletter_list, "filters": {"published": 1},
"row_template": "email/doctype/newsletter/templates/newsletter_row.html", "row_template": "email/doctype/newsletter/templates/newsletter_row.html",
}) })
def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"):
email_group_list = frappe.db.sql('''SELECT eg.name
FROM `tabEmail Group` eg, `tabEmail Group Member` egm
WHERE egm.unsubscribed=0
AND eg.name=egm.email_group
AND egm.email = %s''', frappe.session.user)
email_group_list = [d[0] for d in email_group_list]
if email_group_list:
return frappe.db.sql('''SELECT n.name, n.subject, n.message, n.modified
FROM `tabNewsletter` n, `tabNewsletter Email Group` neg
WHERE n.name = neg.parent
AND n.email_sent=1
AND n.published=1
AND neg.email_group in ({0})
ORDER BY n.modified DESC LIMIT {1} OFFSET {2}
'''.format(','.join(['%s'] * len(email_group_list)),
limit_page_length, limit_start), email_group_list, as_dict=1)
def send_scheduled_email(): def send_scheduled_email():
"""Send scheduled newsletter to the recipients.""" """Send scheduled newsletter to the recipients."""
scheduled_newsletter = frappe.get_all( scheduled_newsletter = frappe.get_all(

View file

@ -1,6 +1,6 @@
{% extends "templates/web.html" %} {% extends "templates/web.html" %}
{% block title %} {{ _("Newsletter") }} {% endblock %} {% block title %} {{ doc.subject }} {% endblock %}
{% block page_content %} {% block page_content %}
<style> <style>
@ -36,11 +36,11 @@
</p> </p>
</div> </div>
<div itemprop="articleBody" class="longform blog-text"> <div itemprop="articleBody" class="longform blog-text">
{{ doc.message }} {{ doc.get_message() }}
</div> </div>
</article> </article>
{% if attachments %} {% if doc.attachments %}
<div> <div>
<div class="row text-muted"> <div class="row text-muted">
<div class="col-sm-12 h6 text-uppercase"> <div class="col-sm-12 h6 text-uppercase">
@ -49,10 +49,10 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
{% for attachment in attachments %} {% for attachment in doc.attachments %}
<p class="small"> <p class="small">
<a href="{{ attachment.file_url }}" target="blank"> <a href="{{ attachment.attachment }}" target="_blank">
{{ attachment.file_name }} {{ attachment.attachment }}
</a> </a>
</p> </p>
{% endfor %} {% endfor %}

View file

@ -14,7 +14,6 @@ from frappe.email.doctype.newsletter.exceptions import (
from frappe.email.doctype.newsletter.newsletter import ( from frappe.email.doctype.newsletter.newsletter import (
Newsletter, Newsletter,
confirmed_unsubscribe, confirmed_unsubscribe,
get_newsletter_list,
send_scheduled_email send_scheduled_email
) )
from frappe.email.queue import flush from frappe.email.queue import flush
@ -101,7 +100,8 @@ class TestNewsletterMixin:
doctype = "Newsletter" doctype = "Newsletter"
newsletter_content = { newsletter_content = {
"subject": "_Test Newsletter", "subject": "_Test Newsletter",
"send_from": "Test Sender <test_sender@example.com>", "sender_name": "Test Sender",
"sender_email": "test_sender@example.com",
"content_type": "Rich Text", "content_type": "Rich Text",
"message": "Testing my news.", "message": "Testing my news.",
} }
@ -157,21 +157,6 @@ class TestNewsletter(TestNewsletterMixin, unittest.TestCase):
if email != to_unsubscribe: if email != to_unsubscribe:
self.assertTrue(email in recipients) self.assertTrue(email in recipients)
def test_portal(self):
self.send_newsletter(published=1)
frappe.set_user("test1@example.com")
newsletter_list = get_newsletter_list("Newsletter", None, None, 0)
self.assertEqual(len(newsletter_list), 1)
def test_newsletter_context(self):
context = frappe._dict()
newsletter_name = self.send_newsletter(published=1)
frappe.set_user("test2@example.com")
doc = frappe.get_doc("Newsletter", newsletter_name)
doc.get_context(context)
self.assertEqual(context.no_cache, 1)
self.assertTrue("attachments" not in list(context))
def test_schedule_send(self): def test_schedule_send(self):
self.send_newsletter(schedule_send=add_days(getdate(), -1)) self.send_newsletter(schedule_send=add_days(getdate(), -1))
@ -181,26 +166,32 @@ class TestNewsletter(TestNewsletterMixin, unittest.TestCase):
for email in emails: for email in emails:
self.assertTrue(email in recipients) self.assertTrue(email in recipients)
def test_newsletter_test_send(self): def test_newsletter_send_test_email(self):
"""Test "Test Send" functionality of Newsletter """Test "Send Test Email" functionality of Newsletter
""" """
newsletter = self.get_newsletter() newsletter = self.get_newsletter()
newsletter.test_email_id = choice(emails) test_email = choice(emails)
newsletter.test_send() newsletter.send_test_email(test_email)
self.assertFalse(newsletter.email_sent) self.assertFalse(newsletter.email_sent)
newsletter.save = MagicMock() newsletter.save = MagicMock()
self.assertFalse(newsletter.save.called) self.assertFalse(newsletter.save.called)
# check if the test email is in the queue
email_queue = frappe.db.get_all('Email Queue', filters=[
['reference_doctype', '=', 'Newsletter'],
['reference_name', '=', newsletter.name],
['Email Queue Recipient', 'recipient', '=', test_email]
])
self.assertTrue(email_queue)
def test_newsletter_status(self): def test_newsletter_status(self):
"""Test for Newsletter's stats on onload event """Test for Newsletter's stats on onload event
""" """
newsletter = self.get_newsletter() newsletter = self.get_newsletter()
newsletter.email_sent = True newsletter.email_sent = True
# had to use run_onload as calling .onload directly bought weird errors result = newsletter.get_sending_status()
# like TestNewsletter has no attribute "_TestNewsletter__onload" self.assertTrue('total' in result)
run_onload(newsletter) self.assertTrue('sent' in result)
self.assertIsInstance(newsletter.get("__onload").status_count, dict)
def test_already_sent_newsletter(self): def test_already_sent_newsletter(self):
newsletter = self.get_newsletter() newsletter = self.get_newsletter()
@ -218,22 +209,6 @@ class TestNewsletter(TestNewsletterMixin, unittest.TestCase):
with self.assertRaises(NoRecipientFoundError): with self.assertRaises(NoRecipientFoundError):
newsletter.send_emails() newsletter.send_emails()
def test_send_newsletter_with_attachments(self):
newsletter = self.get_newsletter()
newsletter.reload()
file_attachment = frappe.get_doc({
"doctype": "File",
"file_name": "test1.txt",
"attached_to_doctype": newsletter.doctype,
"attached_to_name": newsletter.name,
"content": frappe.mock("paragraph")
})
file_attachment.save()
newsletter.send_attachments = True
newsletter_attachments = newsletter.get_newsletter_attachments()
self.assertEqual(len(newsletter_attachments), 1)
self.assertEqual(newsletter_attachments[0]["fid"], file_attachment.name)
def test_send_scheduled_email_error_handling(self): def test_send_scheduled_email_error_handling(self):
newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1)) newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1))
job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all" job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all"

View file

@ -0,0 +1,31 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-12-06 16:37:40.652468",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"attachment"
],
"fields": [
{
"fieldname": "attachment",
"fieldtype": "Attach",
"in_list_view": 1,
"label": "Attachment",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-12-06 16:37:47.481057",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter Attachment",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC"
}

View file

@ -0,0 +1,8 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class NewsletterAttachment(Document):
pass

View file

@ -1,106 +1,42 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0, "creation": "2017-02-26 16:20:52.654136",
"allow_import": 0, "doctype": "DocType",
"allow_rename": 0, "editable_grid": 1,
"beta": 0, "engine": "InnoDB",
"creation": "2017-02-26 16:20:52.654136", "field_order": [
"custom": 0, "email_group",
"docstatus": 0, "total_subscribers"
"doctype": "DocType", ],
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "columns": 7,
"allow_on_submit": 0, "fieldname": "email_group",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Email Group",
"fieldname": "email_group", "options": "Email Group",
"fieldtype": "Link", "reqd": 1
"hidden": 0, },
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Email Group",
"length": 0,
"no_copy": 0,
"options": "Email Group",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
},
{ {
"allow_bulk_edit": 0, "columns": 3,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fetch_from": "email_group.total_subscribers", "fetch_from": "email_group.total_subscribers",
"fieldname": "total_subscribers", "fieldname": "total_subscribers",
"fieldtype": "Read Only", "fieldtype": "Read Only",
"hidden": 0, "in_list_view": 1,
"ignore_user_permissions": 0, "label": "Total Subscribers"
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Total Subscribers",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"translatable": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "istable": 1,
"hide_heading": 0, "links": [],
"hide_toolbar": 0, "modified": "2021-12-06 20:12:08.420240",
"idx": 0, "modified_by": "Administrator",
"image_view": 0, "module": "Email",
"in_create": 0, "name": "Newsletter Email Group",
"is_submittable": 0, "owner": "Administrator",
"issingle": 0, "permissions": [],
"istable": 1, "quick_entry": 1,
"max_attachments": 0, "sort_field": "modified",
"modified": "2018-05-16 22:42:55.437367", "sort_order": "DESC",
"modified_by": "Administrator", "track_changes": 1
"module": "Email",
"name": "Newsletter Email Group",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
} }

View file

@ -111,7 +111,17 @@ class EmailServer:
frappe.msgprint(_('Invalid User Name or Support Password. Please rectify and try again.')) frappe.msgprint(_('Invalid User Name or Support Password. Please rectify and try again.'))
raise raise
def get_messages(self): def select_imap_folder(self, folder):
self.imap.select(folder)
def logout(self):
if cint(self.settings.use_imap):
self.imap.logout()
else:
self.pop.quit()
return
def get_messages(self, folder="INBOX"):
"""Returns new email messages in a list.""" """Returns new email messages in a list."""
if not (self.check_mails() or self.connect()): if not (self.check_mails() or self.connect()):
return [] return []
@ -126,7 +136,8 @@ class EmailServer:
self.latest_messages = [] self.latest_messages = []
self.seen_status = {} self.seen_status = {}
self.uid_reindexed = False self.uid_reindexed = False
uid_list = email_list = self.get_new_mails()
uid_list = email_list = self.get_new_mails(folder)
if not email_list: if not email_list:
return return
@ -160,13 +171,6 @@ class EmailServer:
else: else:
raise raise
finally:
# no matter the exception, pop should quit if connected
if cint(self.settings.use_imap):
self.imap.logout()
else:
self.pop.quit()
out = { "latest_messages": self.latest_messages } out = { "latest_messages": self.latest_messages }
if self.settings.use_imap: if self.settings.use_imap:
out.update({ out.update({
@ -177,15 +181,15 @@ class EmailServer:
return out return out
def get_new_mails(self): def get_new_mails(self, folder):
"""Return list of new mails""" """Return list of new mails"""
if cint(self.settings.use_imap): if cint(self.settings.use_imap):
email_list = [] email_list = []
self.check_imap_uidvalidity() self.check_imap_uidvalidity(folder)
readonly = False if self.settings.email_sync_rule == "UNSEEN" else True readonly = False if self.settings.email_sync_rule == "UNSEEN" else True
self.imap.select("Inbox", readonly=readonly) self.imap.select(folder, readonly=readonly)
response, message = self.imap.uid('search', None, self.settings.email_sync_rule) response, message = self.imap.uid('search', None, self.settings.email_sync_rule)
if message[0]: if message[0]:
email_list = message[0].split() email_list = message[0].split()
@ -194,11 +198,11 @@ class EmailServer:
return email_list return email_list
def check_imap_uidvalidity(self): def check_imap_uidvalidity(self, folder):
# compare the UIDVALIDITY of email account and imap server # compare the UIDVALIDITY of email account and imap server
uid_validity = self.settings.uid_validity uid_validity = self.settings.uid_validity
response, message = self.imap.status("Inbox", "(UIDVALIDITY UIDNEXT)") response, message = self.imap.status(folder, "(UIDVALIDITY UIDNEXT)")
current_uid_validity = self.parse_imap_response("UIDVALIDITY", message[0]) or 0 current_uid_validity = self.parse_imap_response("UIDVALIDITY", message[0]) or 0
uidnext = int(self.parse_imap_response("UIDNEXT", message[0]) or "1") uidnext = int(self.parse_imap_response("UIDNEXT", message[0]) or "1")
@ -206,14 +210,26 @@ class EmailServer:
if not uid_validity or uid_validity != current_uid_validity: if not uid_validity or uid_validity != current_uid_validity:
# uidvalidity changed & all email uids are reindexed by server # uidvalidity changed & all email uids are reindexed by server
frappe.db.sql( Communication = frappe.qb.DocType("Communication")
"""update `tabCommunication` set uid=-1 where communication_medium='Email' frappe.qb.update(Communication) \
and email_account=%s""", (self.settings.email_account,) .set(Communication.uid, -1) \
) .where(Communication.communication_medium == "Email") \
frappe.db.sql( .where(Communication.email_account == self.settings.email_account).run()
"""update `tabEmail Account` set uidvalidity=%s, uidnext=%s where
name=%s""", (current_uid_validity, uidnext, self.settings.email_account) if self.settings.use_imap:
) # new update for the IMAP Folder DocType
IMAPFolder = frappe.qb.DocType("IMAP Folder")
frappe.qb.update(IMAPFolder) \
.set(IMAPFolder.uidvalidity, current_uid_validity) \
.set(IMAPFolder.uidnext, uidnext) \
.where(IMAPFolder.parent == self.settings.email_account_name) \
.where(IMAPFolder.folder_name == folder).run()
else:
EmailAccount = frappe.qb.DocType("Email Account")
frappe.qb.update(EmailAccount) \
.set(EmailAccount.uidvalidity, current_uid_validity) \
.set(EmailAccount.uidnext, uidnext) \
.where(EmailAccount.name == self.settings.email_account_name).run()
# uid validity not found pulling emails for first time # uid validity not found pulling emails for first time
if not uid_validity: if not uid_validity:
@ -232,6 +248,7 @@ class EmailServer:
def parse_imap_response(self, cmd, response): def parse_imap_response(self, cmd, response):
pattern = r"(?<={cmd} )[0-9]*".format(cmd=cmd) pattern = r"(?<={cmd} )[0-9]*".format(cmd=cmd)
match = re.search(pattern, response.decode('utf-8'), re.U | re.I) match = re.search(pattern, response.decode('utf-8'), re.U | re.I)
if match: if match:
return match.group(0) return match.group(0)
else: else:
@ -340,16 +357,15 @@ class EmailServer:
return error_msg return error_msg
def update_flag(self, uid_list=None): def update_flag(self, folder, uid_list=None):
""" set all uids mails the flag as seen """ """ set all uids mails the flag as seen """
if not uid_list: if not uid_list:
return return
if not self.connect(): if not self.connect():
return return
self.imap.select("Inbox") self.imap.select(folder)
for uid, operation in uid_list.items(): for uid, operation in uid_list.items():
if not uid: continue if not uid: continue

View file

@ -16,11 +16,12 @@ class TestSMTP(unittest.TestCase):
make_server(port, 0, 1) make_server(port, 0, 1)
def test_get_email_account(self): def test_get_email_account(self):
existing_email_accounts = frappe.get_all("Email Account", fields = ["name", "enable_outgoing", "default_outgoing", "append_to"]) existing_email_accounts = frappe.get_all("Email Account", fields = ["name", "enable_outgoing", "default_outgoing","append_to", "use_imap"])
unset_details = { unset_details = {
"enable_outgoing": 0, "enable_outgoing": 0,
"default_outgoing": 0, "default_outgoing": 0,
"append_to": None "append_to": None,
"use_imap": 0
} }
for email_account in existing_email_accounts: for email_account in existing_email_accounts:
frappe.db.set_value('Email Account', email_account['name'], unset_details) frappe.db.set_value('Email Account', email_account['name'], unset_details)
@ -60,7 +61,8 @@ def create_email_account(email_id, password, enable_outgoing, default_outgoing=0
"enable_incoming": 1, "enable_incoming": 1,
"append_to":append_to, "append_to":append_to,
"is_dummy_password": 1, "is_dummy_password": 1,
"smtp_server": "localhost" "smtp_server": "localhost",
"use_imap": 0
} }
email_account = frappe.new_doc('Email Account') email_account = frappe.new_doc('Email Account')

View file

@ -54,6 +54,11 @@ class EventProducer(Document):
self.db_set('incoming_change', 0) self.db_set('incoming_change', 0)
self.reload() self.reload()
def on_trash(self):
last_update = frappe.db.get_value('Event Producer Last Update', dict(event_producer=self.name))
if last_update:
frappe.delete_doc('Event Producer Last Update', last_update)
def check_url(self): def check_url(self):
valid_url_schemes = ("http", "https") valid_url_schemes = ("http", "https")
frappe.utils.validate_url(self.producer_url, throw=True, valid_schemes=valid_url_schemes) frappe.utils.validate_url(self.producer_url, throw=True, valid_schemes=valid_url_schemes)

View file

@ -255,7 +255,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
response = doc.run_method(method, **args) response = doc.run_method(method, **args)
frappe.response.docs.append(doc) frappe.response.docs.append(doc)
if not response: if response is None:
return return
# build output as csv # build output as csv

View file

@ -240,7 +240,8 @@ scheduler_events = {
"frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed", "frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed",
"frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails",
"frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports", "frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports",
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up" "frappe.core.doctype.log_settings.log_settings.run_log_clean_up",
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request"
], ],
"daily_long": [ "daily_long": [
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily",

View file

@ -324,7 +324,7 @@ def _delete_doctypes(doctypes: List[str], dry_run: bool) -> None:
print(f"* dropping Table for '{doctype}'...") print(f"* dropping Table for '{doctype}'...")
if not dry_run: if not dry_run:
frappe.delete_doc("DocType", doctype, ignore_on_trash=True) frappe.delete_doc("DocType", doctype, ignore_on_trash=True)
frappe.db.sql_ddl(f"drop table `tab{doctype}`") frappe.db.sql_ddl(f"DROP TABLE IF EXISTS `tab{doctype}`")
def post_install(rebuild_website=False): def post_install(rebuild_website=False):

View file

@ -38,6 +38,11 @@ data_fieldtypes = (
'Icon' 'Icon'
) )
attachment_fieldtypes = (
'Attach',
'Attach Image',
)
no_value_fields = ( no_value_fields = (
'Section Break', 'Section Break',
'Column Break', 'Column Break',

View file

@ -338,7 +338,7 @@ class BaseDocument(object):
return self.meta.get_field(fieldname).options return self.meta.get_field(fieldname).options
except AttributeError: except AttributeError:
if self.doctype == 'DocType': if self.doctype == 'DocType':
return dict(links='DocType Link', actions='DocType Action').get(fieldname) return dict(links='DocType Link', actions='DocType Action', states='DocType State').get(fieldname)
raise raise
def get_parentfield_of_doctype(self, doctype): def get_parentfield_of_doctype(self, doctype):

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