@@ -1,7 +1,7 @@ | |||||
# imports - standard imports | # imports - standard imports | ||||
import os | import os | ||||
import sys | |||||
import shutil | import shutil | ||||
import sys | |||||
# imports - third party imports | # imports - third party imports | ||||
import click | import click | ||||
@@ -65,11 +65,11 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None, | |||||
"Restore site database from an sql file" | "Restore site database from an sql file" | ||||
from frappe.installer import ( | from frappe.installer import ( | ||||
_new_site, | _new_site, | ||||
extract_sql_from_archive, | |||||
extract_files, | extract_files, | ||||
extract_sql_from_archive, | |||||
is_downgrade, | is_downgrade, | ||||
is_partial, | is_partial, | ||||
validate_database_sql | |||||
validate_database_sql, | |||||
) | ) | ||||
from frappe.utils.backups import Backup | from frappe.utils.backups import Backup | ||||
if not os.path.exists(sql_file_path): | if not os.path.exists(sql_file_path): | ||||
@@ -207,7 +207,7 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None, | |||||
@click.option('--encryption-key', help='Backup encryption key') | @click.option('--encryption-key', help='Backup encryption key') | ||||
@pass_context | @pass_context | ||||
def partial_restore(context, sql_file_path, verbose, encryption_key=None): | def partial_restore(context, sql_file_path, verbose, encryption_key=None): | ||||
from frappe.installer import partial_restore, extract_sql_from_archive | |||||
from frappe.installer import extract_sql_from_archive, partial_restore | |||||
from frappe.utils.backups import Backup | from frappe.utils.backups import Backup | ||||
if not os.path.exists(sql_file_path): | if not os.path.exists(sql_file_path): | ||||
@@ -545,7 +545,7 @@ def _use(site, sites_path='.'): | |||||
def use(site, sites_path='.'): | def use(site, sites_path='.'): | ||||
if os.path.exists(os.path.join(sites_path, site)): | if os.path.exists(os.path.join(sites_path, site)): | ||||
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: | |||||
with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: | |||||
sitefile.write(site) | sitefile.write(site) | ||||
print("Current Site set to {}".format(site)) | print("Current Site set to {}".format(site)) | ||||
else: | else: | ||||
@@ -751,6 +751,7 @@ def set_admin_password(context, admin_password=None, logout_all_sessions=False): | |||||
def set_user_password(site, user, password, logout_all_sessions=False): | def set_user_password(site, user, password, logout_all_sessions=False): | ||||
import getpass | import getpass | ||||
from frappe.utils.password import update_password | from frappe.utils.password import update_password | ||||
try: | try: | ||||
@@ -881,15 +882,16 @@ def stop_recording(context): | |||||
raise SiteNotSpecifiedError | raise SiteNotSpecifiedError | ||||
@click.command('ngrok') | @click.command('ngrok') | ||||
@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.') | |||||
@pass_context | @pass_context | ||||
def start_ngrok(context): | |||||
def start_ngrok(context, bind_tls): | |||||
from pyngrok import ngrok | from pyngrok import ngrok | ||||
site = get_site(context) | site = get_site(context) | ||||
frappe.init(site=site) | frappe.init(site=site) | ||||
port = frappe.conf.http_port or frappe.conf.webserver_port | port = frappe.conf.http_port or frappe.conf.webserver_port | ||||
tunnel = ngrok.connect(addr=str(port), host_header=site) | |||||
tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls) | |||||
print(f'Public URL: {tunnel.public_url}') | print(f'Public URL: {tunnel.public_url}') | ||||
print('Inspect logs at http://localhost:4040') | print('Inspect logs at http://localhost:4040') | ||||
@@ -18,6 +18,7 @@ from urllib.parse import unquote | |||||
from frappe.utils.user import is_system_user | from frappe.utils.user import is_system_user | ||||
from frappe.contacts.doctype.contact.contact import get_contact_name | from frappe.contacts.doctype.contact.contact import get_contact_name | ||||
from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule | from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule | ||||
from parse import compile | |||||
exclude_from_linked_with = True | exclude_from_linked_with = True | ||||
@@ -114,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin): | |||||
frappe.publish_realtime('new_message', self.as_dict(), | frappe.publish_realtime('new_message', self.as_dict(), | ||||
user=self.reference_name, after_commit=True) | user=self.reference_name, after_commit=True) | ||||
def set_signature_in_email_content(self): | |||||
"""Set sender's User.email_signature or default outgoing's EmailAccount.signature to the email | |||||
""" | |||||
if not self.content: | |||||
return | |||||
quill_parser = compile('<div class="ql-editor read-mode">{}</div>') | |||||
email_body = quill_parser.parse(self.content) | |||||
if not email_body: | |||||
return | |||||
email_body = email_body[0] | |||||
user_email_signature = frappe.db.get_value( | |||||
"User", | |||||
self.sender, | |||||
"email_signature", | |||||
) if self.sender else None | |||||
signature = user_email_signature or frappe.db.get_value( | |||||
"Email Account", | |||||
{"default_outgoing": 1, "add_signature": 1}, | |||||
"signature", | |||||
) | |||||
if not signature: | |||||
return | |||||
_signature = quill_parser.parse(signature)[0] if "ql-editor" in signature else None | |||||
if (_signature or signature) not in self.content: | |||||
self.content = f'{self.content}</p><br><p class="signature">{signature}' | |||||
def before_save(self): | |||||
if not self.flags.skip_add_signature: | |||||
self.set_signature_in_email_content() | |||||
def on_update(self): | def on_update(self): | ||||
# add to _comment property of the doctype, so it shows up in | # add to _comment property of the doctype, so it shows up in | ||||
# comments count for the list view | # comments count for the list view | ||||
@@ -22,12 +22,30 @@ OUTGOING_EMAIL_ACCOUNT_MISSING = _(""" | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent", | |||||
sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False, | |||||
print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None, | |||||
flags=None, read_receipt=None, print_letterhead=True, email_template=None, communication_type=None, | |||||
ignore_permissions=False) -> Dict[str, str]: | |||||
"""Make a new communication. | |||||
def make( | |||||
doctype=None, | |||||
name=None, | |||||
content=None, | |||||
subject=None, | |||||
sent_or_received="Sent", | |||||
sender=None, | |||||
sender_full_name=None, | |||||
recipients=None, | |||||
communication_medium="Email", | |||||
send_email=False, | |||||
print_html=None, | |||||
print_format=None, | |||||
attachments="[]", | |||||
send_me_a_copy=False, | |||||
cc=None, | |||||
bcc=None, | |||||
read_receipt=None, | |||||
print_letterhead=True, | |||||
email_template=None, | |||||
communication_type=None, | |||||
**kwargs, | |||||
) -> Dict[str, str]: | |||||
"""Make a new communication. Checks for email permissions for specified Document. | |||||
:param doctype: Reference DocType. | :param doctype: Reference DocType. | ||||
:param name: Reference Document name. | :param name: Reference Document name. | ||||
@@ -44,17 +62,71 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = | |||||
:param send_me_a_copy: Send a copy to the sender (default **False**). | :param send_me_a_copy: Send a copy to the sender (default **False**). | ||||
:param email_template: Template which is used to compose mail . | :param email_template: Template which is used to compose mail . | ||||
""" | """ | ||||
is_error_report = (doctype=="User" and name==frappe.session.user and subject=="Error Report") | |||||
send_me_a_copy = cint(send_me_a_copy) | |||||
if kwargs: | |||||
from frappe.utils.commands import warn | |||||
warn( | |||||
f"Options {kwargs} used in frappe.core.doctype.communication.email.make " | |||||
"are deprecated or unsupported", | |||||
category=DeprecationWarning | |||||
) | |||||
if doctype and name and not frappe.has_permission(doctype=doctype, ptype="email", doc=name): | |||||
raise frappe.PermissionError( | |||||
f"You are not allowed to send emails related to: {doctype} {name}" | |||||
) | |||||
return _make( | |||||
doctype=doctype, | |||||
name=name, | |||||
content=content, | |||||
subject=subject, | |||||
sent_or_received=sent_or_received, | |||||
sender=sender, | |||||
sender_full_name=sender_full_name, | |||||
recipients=recipients, | |||||
communication_medium=communication_medium, | |||||
send_email=send_email, | |||||
print_html=print_html, | |||||
print_format=print_format, | |||||
attachments=attachments, | |||||
send_me_a_copy=cint(send_me_a_copy), | |||||
cc=cc, | |||||
bcc=bcc, | |||||
read_receipt=read_receipt, | |||||
print_letterhead=print_letterhead, | |||||
email_template=email_template, | |||||
communication_type=communication_type, | |||||
add_signature=False, | |||||
) | |||||
if not ignore_permissions: | |||||
if doctype and name and not is_error_report and not frappe.has_permission(doctype, "email", name) and not (flags or {}).get('ignore_doctype_permissions'): | |||||
raise frappe.PermissionError("You are not allowed to send emails related to: {doctype} {name}".format( | |||||
doctype=doctype, name=name)) | |||||
if not sender: | |||||
sender = get_formatted_email(frappe.session.user) | |||||
def _make( | |||||
doctype=None, | |||||
name=None, | |||||
content=None, | |||||
subject=None, | |||||
sent_or_received="Sent", | |||||
sender=None, | |||||
sender_full_name=None, | |||||
recipients=None, | |||||
communication_medium="Email", | |||||
send_email=False, | |||||
print_html=None, | |||||
print_format=None, | |||||
attachments="[]", | |||||
send_me_a_copy=False, | |||||
cc=None, | |||||
bcc=None, | |||||
read_receipt=None, | |||||
print_letterhead=True, | |||||
email_template=None, | |||||
communication_type=None, | |||||
add_signature=True, | |||||
) -> Dict[str, str]: | |||||
"""Internal method to make a new communication that ignores Permission checks. | |||||
""" | |||||
sender = sender or get_formatted_email(frappe.session.user) | |||||
recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients | recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients | ||||
cc = list_to_str(cc) if isinstance(cc, list) else cc | cc = list_to_str(cc) if isinstance(cc, list) else cc | ||||
bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc | bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc | ||||
@@ -77,7 +149,9 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = | |||||
"read_receipt":read_receipt, | "read_receipt":read_receipt, | ||||
"has_attachment": 1 if attachments else 0, | "has_attachment": 1 if attachments else 0, | ||||
"communication_type": communication_type, | "communication_type": communication_type, | ||||
}).insert(ignore_permissions=True) | |||||
}) | |||||
comm.flags.skip_add_signature = not add_signature | |||||
comm.insert(ignore_permissions=True) | |||||
# if not committed, delayed task doesn't find the communication | # if not committed, delayed task doesn't find the communication | ||||
if attachments: | if attachments: | ||||
@@ -87,17 +161,21 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = | |||||
if cint(send_email): | if cint(send_email): | ||||
if not comm.get_outgoing_email_account(): | if not comm.get_outgoing_email_account(): | ||||
frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError) | |||||
frappe.throw( | |||||
msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError | |||||
) | |||||
comm.send_email(print_html=print_html, print_format=print_format, | |||||
send_me_a_copy=send_me_a_copy, print_letterhead=print_letterhead) | |||||
comm.send_email( | |||||
print_html=print_html, | |||||
print_format=print_format, | |||||
send_me_a_copy=send_me_a_copy, | |||||
print_letterhead=print_letterhead, | |||||
) | |||||
emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) | emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) | ||||
return { | |||||
"name": comm.name, | |||||
"emails_not_sent_to": ", ".join(emails_not_sent_to) | |||||
} | |||||
return {"name": comm.name, "emails_not_sent_to": ", ".join(emails_not_sent_to)} | |||||
def validate_email(doc: "Communication") -> None: | def validate_email(doc: "Communication") -> None: | ||||
"""Validate Email Addresses of Recipients and CC""" | """Validate Email Addresses of Recipients and CC""" | ||||
@@ -668,8 +668,7 @@ | |||||
"link_fieldname": "user" | "link_fieldname": "user" | ||||
} | } | ||||
], | ], | ||||
"max_attachments": 5, | |||||
"modified": "2022-01-03 11:53:25.250822", | |||||
"modified": "2022-03-09 01:47:56.745069", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Core", | "module": "Core", | ||||
"name": "User", | "name": "User", | ||||
@@ -1,9 +1,10 @@ | |||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors | |||||
# License: MIT. See LICENSE | # License: MIT. See LICENSE | ||||
import json | import json | ||||
from collections import defaultdict | from collections import defaultdict | ||||
import itertools | import itertools | ||||
from typing import List | |||||
from typing import Dict, List, Optional | |||||
import frappe | import frappe | ||||
import frappe.desk.form.load | import frappe.desk.form.load | ||||
@@ -367,7 +368,7 @@ def get_exempted_doctypes(): | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): | |||||
def get_linked_docs(doctype: str, name: str, linkinfo: Optional[Dict] = None) -> Dict[str, List]: | |||||
if isinstance(linkinfo, str): | if isinstance(linkinfo, str): | ||||
# additional fields are added in linkinfo | # additional fields are added in linkinfo | ||||
linkinfo = json.loads(linkinfo) | linkinfo = json.loads(linkinfo) | ||||
@@ -377,23 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): | |||||
if not linkinfo: | if not linkinfo: | ||||
return results | return results | ||||
if for_doctype: | |||||
links = frappe.get_doc(doctype, name).get_link_filters(for_doctype) | |||||
if links: | |||||
linkinfo = links | |||||
if for_doctype in linkinfo: | |||||
# only get linked with for this particular doctype | |||||
linkinfo = { for_doctype: linkinfo.get(for_doctype) } | |||||
else: | |||||
return results | |||||
for dt, link in linkinfo.items(): | for dt, link in linkinfo.items(): | ||||
filters = [] | filters = [] | ||||
link["doctype"] = dt | link["doctype"] = dt | ||||
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) | |||||
try: | |||||
link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) | |||||
except Exception as e: | |||||
if isinstance(e, frappe.DoesNotExistError): | |||||
if frappe.local.message_log: | |||||
frappe.local.message_log.pop() | |||||
continue | |||||
linkmeta = link_meta_bundle[0] | linkmeta = link_meta_bundle[0] | ||||
if not linkmeta.has_permission(): | |||||
continue | |||||
if not linkmeta.get("issingle"): | if not linkmeta.get("issingle"): | ||||
fields = [d.fieldname for d in linkmeta.get("fields", { | fields = [d.fieldname for d in linkmeta.get("fields", { | ||||
"in_list_view": 1, | "in_list_view": 1, | ||||
@@ -456,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): | |||||
return results | return results | ||||
@frappe.whitelist() | |||||
def get(doctype, docname): | |||||
linked_doctypes = get_linked_doctypes(doctype=doctype) | |||||
return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes) | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): | def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): | ||||
"""add list of doctypes this doctype is 'linked' with. | """add list of doctypes this doctype is 'linked' with. | ||||
@@ -470,6 +476,7 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): | |||||
else: | else: | ||||
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) | return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) | ||||
def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): | def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): | ||||
ret = {} | ret = {} | ||||
# find fields where this doctype is linked | # find fields where this doctype is linked | ||||
@@ -499,6 +506,7 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False) | |||||
return ret | return ret | ||||
def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): | def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): | ||||
filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]] | filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]] | ||||
@@ -529,6 +537,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): | |||||
return ret | return ret | ||||
def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False): | def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False): | ||||
ret = {} | ret = {} | ||||
@@ -406,7 +406,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visi | |||||
for column in data.columns: | for column in data.columns: | ||||
if column.get("hidden"): | if column.get("hidden"): | ||||
continue | continue | ||||
result[0].append(column.get("label")) | |||||
result[0].append(_(column.get("label"))) | |||||
column_width = cint(column.get('width', 0)) | column_width = cint(column.get('width', 0)) | ||||
# to convert into scale accepted by openpyxl | # to convert into scale accepted by openpyxl | ||||
column_width /= 10 | column_width /= 10 | ||||
@@ -236,8 +236,7 @@ | |||||
"index_web_pages_for_search": 1, | "index_web_pages_for_search": 1, | ||||
"is_published_field": "published", | "is_published_field": "published", | ||||
"links": [], | "links": [], | ||||
"max_attachments": 3, | |||||
"modified": "2021-12-06 20:09:37.963141", | |||||
"modified": "2022-03-09 01:48:16.741603", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Email", | "module": "Email", | ||||
"name": "Newsletter", | "name": "Newsletter", | ||||
@@ -186,7 +186,7 @@ def get_context(context): | |||||
def send_an_email(self, doc, context): | def send_an_email(self, doc, context): | ||||
from email.utils import formataddr | from email.utils import formataddr | ||||
from frappe.core.doctype.communication.email import make as make_communication | |||||
from frappe.core.doctype.communication.email import _make as make_communication | |||||
subject = self.subject | subject = self.subject | ||||
if "{" in subject: | if "{" in subject: | ||||
subject = frappe.render_template(self.subject, context) | subject = frappe.render_template(self.subject, context) | ||||
@@ -216,7 +216,8 @@ def get_context(context): | |||||
# Add mail notification to communication list | # Add mail notification to communication list | ||||
# No need to add if it is already a communication. | # No need to add if it is already a communication. | ||||
if doc.doctype != 'Communication': | if doc.doctype != 'Communication': | ||||
make_communication(doctype=doc.doctype, | |||||
make_communication( | |||||
doctype=doc.doctype, | |||||
name=doc.name, | name=doc.name, | ||||
content=message, | content=message, | ||||
subject=subject, | subject=subject, | ||||
@@ -228,7 +229,7 @@ def get_context(context): | |||||
cc=cc, | cc=cc, | ||||
bcc=bcc, | bcc=bcc, | ||||
communication_type='Automated Message', | communication_type='Automated Message', | ||||
ignore_permissions=True) | |||||
) | |||||
def send_a_slack_msg(self, doc, context): | def send_a_slack_msg(self, doc, context): | ||||
send_slack_message( | send_slack_message( | ||||
@@ -259,17 +259,12 @@ def get_formatted_html(subject, message, footer=None, print_html=None, | |||||
email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) | email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) | ||||
signature = None | |||||
if "<!-- signature-included -->" not in message: | |||||
signature = get_signature(email_account) | |||||
rendered_email = frappe.get_template("templates/emails/standard.html").render({ | rendered_email = frappe.get_template("templates/emails/standard.html").render({ | ||||
"brand_logo": get_brand_logo(email_account) if with_container or header else None, | "brand_logo": get_brand_logo(email_account) if with_container or header else None, | ||||
"with_container": with_container, | "with_container": with_container, | ||||
"site_url": get_url(), | "site_url": get_url(), | ||||
"header": get_header(header), | "header": get_header(header), | ||||
"content": message, | "content": message, | ||||
"signature": signature, | |||||
"footer": get_footer(email_account, footer), | "footer": get_footer(email_account, footer), | ||||
"title": subject, | "title": subject, | ||||
"print_html": print_html, | "print_html": print_html, | ||||
@@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None, | |||||
if unsubscribe_link: | if unsubscribe_link: | ||||
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html) | html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html) | ||||
html = inline_style_in_html(html) | |||||
return html | |||||
return inline_style_in_html(html) | |||||
@frappe.whitelist() | @frappe.whitelist() | ||||
def get_email_html(template, args, subject, header=None, with_container=False): | def get_email_html(template, args, subject, header=None, with_container=False): | ||||
@@ -221,7 +221,8 @@ scheduler_events = { | |||||
"frappe.deferred_insert.save_to_db", | "frappe.deferred_insert.save_to_db", | ||||
"frappe.desk.form.document_follow.send_hourly_updates", | "frappe.desk.form.document_follow.send_hourly_updates", | ||||
"frappe.integrations.doctype.google_calendar.google_calendar.sync", | "frappe.integrations.doctype.google_calendar.google_calendar.sync", | ||||
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email" | |||||
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email", | |||||
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request" | |||||
], | ], | ||||
"daily": [ | "daily": [ | ||||
"frappe.email.queue.set_expiry_for_email_queue", | "frappe.email.queue.set_expiry_for_email_queue", | ||||
@@ -240,8 +241,7 @@ 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.website.doctype.personal_data_deletion_request.personal_data_deletion_request.process_data_deletion_request" | |||||
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up" | |||||
], | ], | ||||
"daily_long": [ | "daily_long": [ | ||||
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", | "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", | ||||
@@ -1003,8 +1003,6 @@ class Document(BaseDocument): | |||||
- `on_cancel` for **Cancel** | - `on_cancel` for **Cancel** | ||||
- `update_after_submit` for **Update after Submit**""" | - `update_after_submit` for **Update after Submit**""" | ||||
doc_before_save = self.get_doc_before_save() | |||||
if self._action=="save": | if self._action=="save": | ||||
self.run_method("on_update") | self.run_method("on_update") | ||||
elif self._action=="submit": | elif self._action=="submit": | ||||
@@ -43,8 +43,8 @@ def update_document_title( | |||||
title_field = doc.meta.get_title_field() | title_field = doc.meta.get_title_field() | ||||
title_updated = (title_field != "name") and (updated_title != doc.get(title_field)) | |||||
name_updated = updated_name != doc.name | |||||
title_updated = updated_title and (title_field != "name") and (updated_title != doc.get(title_field)) | |||||
name_updated = updated_name and (updated_name != doc.name) | |||||
if name_updated: | if name_updated: | ||||
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) | docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) | ||||
@@ -197,3 +197,4 @@ frappe.patches.v14_0.copy_mail_data #08.03.21 | |||||
frappe.patches.v14_0.update_github_endpoints #08-11-2021 | frappe.patches.v14_0.update_github_endpoints #08-11-2021 | ||||
frappe.patches.v14_0.remove_db_aggregation | frappe.patches.v14_0.remove_db_aggregation | ||||
frappe.patches.v14_0.update_color_names_in_kanban_board_column | frappe.patches.v14_0.update_color_names_in_kanban_board_column | ||||
frappe.patches.v14_0.update_auto_account_deletion_duration |
@@ -0,0 +1,5 @@ | |||||
import frappe | |||||
def execute(): | |||||
days = frappe.db.get_single_value("Website Settings", "auto_account_deletion") | |||||
frappe.db.set_value("Website Settings", None, "auto_account_deletion", days * 24) |
@@ -1,9 +1,8 @@ | |||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors | |||||
// MIT License. See license.txt | |||||
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors | |||||
// MIT License. See LICENSE | |||||
frappe.ui.form.LinkedWith = class LinkedWith { | frappe.ui.form.LinkedWith = class LinkedWith { | ||||
constructor(opts) { | constructor(opts) { | ||||
$.extend(this, opts); | $.extend(this, opts); | ||||
} | } | ||||
@@ -21,29 +20,23 @@ frappe.ui.form.LinkedWith = class LinkedWith { | |||||
} | } | ||||
make_dialog() { | make_dialog() { | ||||
this.dialog = new frappe.ui.Dialog({ | this.dialog = new frappe.ui.Dialog({ | ||||
title: __("Linked With") | title: __("Linked With") | ||||
}); | }); | ||||
this.dialog.on_page_show = () => { | this.dialog.on_page_show = () => { | ||||
// execute ajax calls sequentially | |||||
// 1. get linked doctypes | |||||
// 2. load all doctypes | |||||
// 3. load linked docs | |||||
this.get_linked_doctypes() | |||||
.then(() => this.load_doctypes()) | |||||
.then(() => this.links_not_permitted_or_missing()) | |||||
.then(() => this.get_linked_docs()) | |||||
.then(() => this.make_html()); | |||||
frappe.xcall( | |||||
"frappe.desk.form.linked_with.get", | |||||
{"doctype": cur_frm.doctype, "docname": cur_frm.docname}, | |||||
).then(r => { | |||||
this.frm.__linked_docs = r; | |||||
}).then(() => this.make_html()); | |||||
}; | }; | ||||
} | } | ||||
make_html() { | make_html() { | ||||
const linked_docs = this.frm.__linked_docs; | |||||
let html = ''; | let html = ''; | ||||
const linked_docs = this.frm.__linked_docs; | |||||
const linked_doctypes = Object.keys(linked_docs); | const linked_doctypes = Object.keys(linked_docs); | ||||
if (linked_doctypes.length === 0) { | if (linked_doctypes.length === 0) { | ||||
@@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith { | |||||
$(this.dialog.body).html(html); | $(this.dialog.body).html(html); | ||||
} | } | ||||
load_doctypes() { | |||||
const already_loaded = Object.keys(locals.DocType); | |||||
let doctypes_to_load = []; | |||||
if (this.frm.__linked_doctypes) { | |||||
doctypes_to_load = | |||||
Object.keys(this.frm.__linked_doctypes) | |||||
.filter(doctype => !already_loaded.includes(doctype)); | |||||
} | |||||
// load all doctypes asynchronously using with_doctype | |||||
const promises = doctypes_to_load.map(dt => { | |||||
return frappe.model.with_doctype(dt, () => { | |||||
if(frappe.listview_settings[dt]) { | |||||
// add additional fields to __linked_doctypes | |||||
this.frm.__linked_doctypes[dt].add_fields = | |||||
frappe.listview_settings[dt].add_fields; | |||||
} | |||||
}); | |||||
}); | |||||
return Promise.all(promises); | |||||
} | |||||
links_not_permitted_or_missing() { | |||||
let links = null; | |||||
if (this.frm.__linked_doctypes) { | |||||
links = | |||||
Object.keys(this.frm.__linked_doctypes) | |||||
.filter(frappe.model.can_get_report); | |||||
} | |||||
let flag; | |||||
if(!links) { | |||||
$(this.dialog.body).html(`${this.frm.__linked_doctypes | |||||
? __("Not enough permission to see links") | |||||
: __("Not Linked to any record")}`); | |||||
flag = true; | |||||
} | |||||
flag = false; | |||||
// reject Promise if not_permitted or missing | |||||
return new Promise( | |||||
(resolve, reject) => flag ? reject() : resolve() | |||||
); | |||||
} | |||||
get_linked_doctypes() { | |||||
return new Promise((resolve) => { | |||||
if (this.frm.__linked_doctypes) { | |||||
resolve(); | |||||
} | |||||
frappe.call({ | |||||
method: "frappe.desk.form.linked_with.get_linked_doctypes", | |||||
args: { | |||||
doctype: this.frm.doctype | |||||
}, | |||||
callback: (r) => { | |||||
this.frm.__linked_doctypes = r.message; | |||||
resolve(); | |||||
} | |||||
}); | |||||
}); | |||||
} | |||||
get_linked_docs() { | |||||
return frappe.call({ | |||||
method: "frappe.desk.form.linked_with.get_linked_docs", | |||||
args: { | |||||
doctype: this.frm.doctype, | |||||
name: this.frm.docname, | |||||
linkinfo: this.frm.__linked_doctypes, | |||||
for_doctype: this.for_doctype | |||||
}, | |||||
callback: (r) => { | |||||
this.frm.__linked_docs = r.message || {}; | |||||
} | |||||
}); | |||||
} | |||||
make_doc_head(heading) { | make_doc_head(heading) { | ||||
return ` | return ` | ||||
<header class="level list-row list-row-head text-muted small"> | <header class="level list-row list-row-head text-muted small"> | ||||
@@ -1343,7 +1343,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { | |||||
if (file_format === 'CSV') { | if (file_format === 'CSV') { | ||||
const column_row = this.columns.reduce((acc, col) => { | const column_row = this.columns.reduce((acc, col) => { | ||||
if (!col.hidden) { | if (!col.hidden) { | ||||
acc.push(col.label); | |||||
acc.push(__(col.label)); | |||||
} | } | ||||
return acc; | return acc; | ||||
}, []); | }, []); | ||||
@@ -37,7 +37,6 @@ | |||||
<tr> | <tr> | ||||
<td valign="top"> | <td valign="top"> | ||||
<p>{{ content }}</p> | <p>{{ content }}</p> | ||||
<p class="signature">{{ signature }}</p> | |||||
</td> | </td> | ||||
</tr> | </tr> | ||||
</table> | </table> | ||||
@@ -213,8 +213,7 @@ | |||||
"index_web_pages_for_search": 1, | "index_web_pages_for_search": 1, | ||||
"is_published_field": "published", | "is_published_field": "published", | ||||
"links": [], | "links": [], | ||||
"max_attachments": 5, | |||||
"modified": "2021-11-23 10:42:01.759723", | |||||
"modified": "2022-03-09 01:48:25.227295", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Website", | "module": "Website", | ||||
"name": "Blog Post", | "name": "Blog Post", | ||||
@@ -7,7 +7,7 @@ import re | |||||
import frappe | import frappe | ||||
from frappe import _ | from frappe import _ | ||||
from frappe.model.document import Document | from frappe.model.document import Document | ||||
from frappe.utils import get_fullname, date_diff, get_datetime | |||||
from frappe.utils import get_fullname, time_diff_in_hours, get_datetime | |||||
from frappe.utils.user import get_system_managers | from frappe.utils.user import get_system_managers | ||||
from frappe.utils.verified_command import get_signed_params, verify_request | from frappe.utils.verified_command import get_signed_params, verify_request | ||||
import json | import json | ||||
@@ -353,8 +353,8 @@ def process_data_deletion_request(): | |||||
for request in requests: | for request in requests: | ||||
doc = frappe.get_doc("Personal Data Deletion Request", request) | doc = frappe.get_doc("Personal Data Deletion Request", request) | ||||
if date_diff(get_datetime(), doc.creation) >= auto_account_deletion: | |||||
doc.add_comment("Comment", _("The User record for this request has been auto-deleted due to inactivity.")) | |||||
if time_diff_in_hours(get_datetime(), doc.creation) >= auto_account_deletion: | |||||
doc.add_comment("Comment", _("The User record for this request has been auto-deleted due to inactivity by system admins.")) | |||||
doc.trigger_data_deletion() | doc.trigger_data_deletion() | ||||
def remove_unverified_record(): | def remove_unverified_record(): | ||||
@@ -4,10 +4,10 @@ | |||||
import frappe | import frappe | ||||
import unittest | import unittest | ||||
from frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request import ( | from frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request import ( | ||||
remove_unverified_record, | |||||
remove_unverified_record, process_data_deletion_request | |||||
) | ) | ||||
from frappe.website.doctype.personal_data_download_request.test_personal_data_download_request import ( | from frappe.website.doctype.personal_data_download_request.test_personal_data_download_request import ( | ||||
create_user_if_not_exists, | |||||
create_user_if_not_exists | |||||
) | ) | ||||
from datetime import datetime, timedelta | from datetime import datetime, timedelta | ||||
@@ -58,3 +58,15 @@ class TestPersonalDataDeletionRequest(unittest.TestCase): | |||||
self.assertFalse( | self.assertFalse( | ||||
frappe.db.exists("Personal Data Deletion Request", self.delete_request.name) | frappe.db.exists("Personal Data Deletion Request", self.delete_request.name) | ||||
) | ) | ||||
def test_process_auto_request(self): | |||||
frappe.db.set_value("Website Settings", None, "auto_account_deletion", "1") | |||||
date_time_obj = datetime.strptime( | |||||
self.delete_request.creation, "%Y-%m-%d %H:%M:%S.%f" | |||||
) + timedelta(hours=-2) | |||||
self.delete_request.db_set("creation", date_time_obj) | |||||
self.delete_request.db_set("status", "Pending Approval") | |||||
process_data_deletion_request() | |||||
self.delete_request.reload() | |||||
self.assertEqual(self.delete_request.status, "Deleted") |
@@ -338,8 +338,7 @@ | |||||
"index_web_pages_for_search": 1, | "index_web_pages_for_search": 1, | ||||
"is_published_field": "published", | "is_published_field": "published", | ||||
"links": [], | "links": [], | ||||
"max_attachments": 20, | |||||
"modified": "2022-01-03 13:01:48.182645", | |||||
"modified": "2022-03-09 01:45:28.548671", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Website", | "module": "Website", | ||||
"name": "Web Page", | "name": "Web Page", | ||||
@@ -404,10 +404,10 @@ | |||||
"label": "Show Account Deletion Link in My Account Page" | "label": "Show Account Deletion Link in My Account Page" | ||||
}, | }, | ||||
{ | { | ||||
"default": "3", | |||||
"default": "72", | |||||
"fieldname": "auto_account_deletion", | "fieldname": "auto_account_deletion", | ||||
"fieldtype": "Int", | "fieldtype": "Int", | ||||
"label": "Auto Account Deletion within (Days)" | |||||
"label": "Auto Account Deletion within (Hours)" | |||||
}, | }, | ||||
{ | { | ||||
"fieldname": "footer_powered", | "fieldname": "footer_powered", | ||||
@@ -420,8 +420,7 @@ | |||||
"index_web_pages_for_search": 1, | "index_web_pages_for_search": 1, | ||||
"issingle": 1, | "issingle": 1, | ||||
"links": [], | "links": [], | ||||
"max_attachments": 10, | |||||
"modified": "2022-02-28 23:05:42.493192", | |||||
"modified": "2022-03-09 01:47:31.094462", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Website", | "module": "Website", | ||||
"name": "Website Settings", | "name": "Website Settings", | ||||
@@ -446,4 +445,4 @@ | |||||
"sort_order": "ASC", | "sort_order": "ASC", | ||||
"states": [], | "states": [], | ||||
"track_changes": 1 | "track_changes": 1 | ||||
} | |||||
} |
@@ -5,7 +5,7 @@ frappe.ready(function() { | |||||
callback: (data) => { | callback: (data) => { | ||||
if (data.message) { | if (data.message) { | ||||
const intro_wrapper = $('#introduction .ql-editor.read-mode'); | const intro_wrapper = $('#introduction .ql-editor.read-mode'); | ||||
const sla_description = __("Note: Your request for account deletion will be fulfilled within {0} days.", [data.message]); | |||||
const sla_description = __("Note: Your request for account deletion will be fulfilled within {0} hours.", [data.message]); | |||||
const sla_description_wrapper = `<br><b>${sla_description}</b>`; | const sla_description_wrapper = `<br><b>${sla_description}</b>`; | ||||
intro_wrapper.html(intro_wrapper.html() + sla_description_wrapper); | intro_wrapper.html(intro_wrapper.html() + sla_description_wrapper); | ||||
} | } | ||||
@@ -5,6 +5,7 @@ import unittest | |||||
from frappe.utils import random_string | from frappe.utils import random_string | ||||
from frappe.model.workflow import apply_workflow, WorkflowTransitionError, WorkflowPermissionError, get_common_transition_actions | from frappe.model.workflow import apply_workflow, WorkflowTransitionError, WorkflowPermissionError, get_common_transition_actions | ||||
from frappe.test_runner import make_test_records | from frappe.test_runner import make_test_records | ||||
from frappe.query_builder import DocType | |||||
class TestWorkflow(unittest.TestCase): | class TestWorkflow(unittest.TestCase): | ||||
@@ -15,9 +16,31 @@ class TestWorkflow(unittest.TestCase): | |||||
def setUp(self): | def setUp(self): | ||||
self.workflow = create_todo_workflow() | self.workflow = create_todo_workflow() | ||||
frappe.set_user('Administrator') | frappe.set_user('Administrator') | ||||
if self._testMethodName == "test_if_workflow_actions_were_processed_using_user": | |||||
if not frappe.db.has_column('Workflow Action', 'user'): | |||||
# mariadb would raise this statement would create an implicit commit | |||||
# if we do not commit before alter statement | |||||
# nosemgrep | |||||
frappe.db.commit() | |||||
frappe.db.multisql({ | |||||
'mariadb': 'ALTER TABLE `tabWorkflow Action` ADD COLUMN user varchar(140)', | |||||
'postgres': 'ALTER TABLE "tabWorkflow Action" ADD COLUMN "user" varchar(140)' | |||||
}) | |||||
frappe.cache().delete_value('table_columns') | |||||
def tearDown(self): | def tearDown(self): | ||||
frappe.delete_doc('Workflow', 'Test ToDo') | frappe.delete_doc('Workflow', 'Test ToDo') | ||||
if self._testMethodName == "test_if_workflow_actions_were_processed_using_user": | |||||
if frappe.db.has_column('Workflow Action', 'user'): | |||||
# mariadb would raise this statement would create an implicit commit | |||||
# if we do not commit before alter statement | |||||
# nosemgrep | |||||
frappe.db.commit() | |||||
frappe.db.multisql({ | |||||
'mariadb': 'ALTER TABLE `tabWorkflow Action` DROP COLUMN user', | |||||
'postgres': 'ALTER TABLE "tabWorkflow Action" DROP COLUMN "user"' | |||||
}) | |||||
frappe.cache().delete_value('table_columns') | |||||
def test_default_condition(self): | def test_default_condition(self): | ||||
'''test default condition is set''' | '''test default condition is set''' | ||||
@@ -75,7 +98,7 @@ class TestWorkflow(unittest.TestCase): | |||||
actions = get_common_transition_actions([todo1, todo2], 'ToDo') | actions = get_common_transition_actions([todo1, todo2], 'ToDo') | ||||
self.assertListEqual(actions, ['Review']) | self.assertListEqual(actions, ['Review']) | ||||
def test_if_workflow_actions_were_processed(self): | |||||
def test_if_workflow_actions_were_processed_using_role(self): | |||||
frappe.db.delete("Workflow Action") | frappe.db.delete("Workflow Action") | ||||
user = frappe.get_doc('User', 'test2@example.com') | user = frappe.get_doc('User', 'test2@example.com') | ||||
user.add_roles('Test Approver', 'System Manager') | user.add_roles('Test Approver', 'System Manager') | ||||
@@ -93,6 +116,32 @@ class TestWorkflow(unittest.TestCase): | |||||
self.assertEqual(workflow_actions[0].status, 'Completed') | self.assertEqual(workflow_actions[0].status, 'Completed') | ||||
frappe.set_user('Administrator') | frappe.set_user('Administrator') | ||||
def test_if_workflow_actions_were_processed_using_user(self): | |||||
frappe.db.delete("Workflow Action") | |||||
user = frappe.get_doc('User', 'test2@example.com') | |||||
user.add_roles('Test Approver', 'System Manager') | |||||
frappe.set_user('test2@example.com') | |||||
doc = self.test_default_condition() | |||||
workflow_actions = frappe.get_all('Workflow Action', fields=['*']) | |||||
self.assertEqual(len(workflow_actions), 1) | |||||
# test if status of workflow actions are updated on approval | |||||
WorkflowAction = DocType("Workflow Action") | |||||
WorkflowActionPermittedRole = DocType("Workflow Action Permitted Role") | |||||
frappe.qb.update(WorkflowAction).set(WorkflowAction.user, 'test2@example.com').run() | |||||
frappe.qb.update(WorkflowActionPermittedRole).set(WorkflowActionPermittedRole.role, '').run() | |||||
self.test_approve(doc) | |||||
user.remove_roles('Test Approver', 'System Manager') | |||||
workflow_actions = frappe.get_all('Workflow Action', fields=['status']) | |||||
self.assertEqual(len(workflow_actions), 1) | |||||
self.assertEqual(workflow_actions[0].status, 'Completed') | |||||
frappe.set_user('Administrator') | |||||
def test_update_docstatus(self): | def test_update_docstatus(self): | ||||
todo = create_new_todo() | todo = create_new_todo() | ||||
apply_workflow(todo, 'Approve') | apply_workflow(todo, 'Approve') | ||||
@@ -8,9 +8,12 @@ | |||||
"status", | "status", | ||||
"reference_name", | "reference_name", | ||||
"reference_doctype", | "reference_doctype", | ||||
"user", | |||||
"workflow_state", | "workflow_state", | ||||
"completed_by" | |||||
"column_break_5", | |||||
"completed_by_role", | |||||
"completed_by", | |||||
"permitted_roles", | |||||
"user" | |||||
], | ], | ||||
"fields": [ | "fields": [ | ||||
{ | { | ||||
@@ -24,16 +27,14 @@ | |||||
"fieldname": "reference_name", | "fieldname": "reference_name", | ||||
"fieldtype": "Dynamic Link", | "fieldtype": "Dynamic Link", | ||||
"label": "Reference Name", | "label": "Reference Name", | ||||
"options": "reference_doctype", | |||||
"search_index": 1 | |||||
"options": "reference_doctype" | |||||
}, | }, | ||||
{ | { | ||||
"fieldname": "reference_doctype", | "fieldname": "reference_doctype", | ||||
"fieldtype": "Link", | "fieldtype": "Link", | ||||
"in_list_view": 1, | "in_list_view": 1, | ||||
"label": "Reference Document Type", | "label": "Reference Document Type", | ||||
"options": "DocType", | |||||
"search_index": 1 | |||||
"options": "DocType" | |||||
}, | }, | ||||
{ | { | ||||
"fieldname": "user", | "fieldname": "user", | ||||
@@ -47,18 +48,38 @@ | |||||
"fieldname": "workflow_state", | "fieldname": "workflow_state", | ||||
"fieldtype": "Data", | "fieldtype": "Data", | ||||
"hidden": 1, | "hidden": 1, | ||||
"label": "Workflow State", | |||||
"search_index": 1 | |||||
"label": "Workflow State" | |||||
}, | }, | ||||
{ | { | ||||
"depends_on": "eval: doc.completed_by", | |||||
"fieldname": "completed_by", | "fieldname": "completed_by", | ||||
"fieldtype": "Link", | "fieldtype": "Link", | ||||
"label": "Completed By", | |||||
"options": "User" | |||||
"label": "Completed By User", | |||||
"options": "User", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "column_break_5", | |||||
"fieldtype": "Column Break" | |||||
}, | |||||
{ | |||||
"depends_on": "eval: doc.completed_by_role", | |||||
"fieldname": "completed_by_role", | |||||
"fieldtype": "Link", | |||||
"label": "Completed By Role", | |||||
"options": "Role", | |||||
"read_only": 1 | |||||
}, | |||||
{ | |||||
"fieldname": "permitted_roles", | |||||
"fieldtype": "Table MultiSelect", | |||||
"label": "Permitted Roles", | |||||
"options": "Workflow Action Permitted Role", | |||||
"read_only": 1 | |||||
} | } | ||||
], | ], | ||||
"links": [], | "links": [], | ||||
"modified": "2021-07-01 09:07:52.848618", | |||||
"modified": "2022-02-23 21:06:45.122258", | |||||
"modified_by": "Administrator", | "modified_by": "Administrator", | ||||
"module": "Workflow", | "module": "Workflow", | ||||
"name": "Workflow Action", | "name": "Workflow Action", | ||||
@@ -72,6 +93,7 @@ | |||||
], | ], | ||||
"sort_field": "modified", | "sort_field": "modified", | ||||
"sort_order": "DESC", | "sort_order": "DESC", | ||||
"states": [], | |||||
"title_field": "reference_name", | "title_field": "reference_name", | ||||
"track_changes": 1 | "track_changes": 1 | ||||
} | } |
@@ -13,24 +13,46 @@ from frappe.model.workflow import apply_workflow, get_workflow_name, has_approva | |||||
from frappe.desk.notifications import clear_doctype_notifications | from frappe.desk.notifications import clear_doctype_notifications | ||||
from frappe.utils.user import get_users_with_role | from frappe.utils.user import get_users_with_role | ||||
from frappe.utils.data import get_link_to_form | from frappe.utils.data import get_link_to_form | ||||
from frappe.query_builder import DocType | |||||
class WorkflowAction(Document): | class WorkflowAction(Document): | ||||
pass | pass | ||||
def on_doctype_update(): | def on_doctype_update(): | ||||
frappe.db.add_index("Workflow Action", ["status", "user"]) | |||||
# The search order in any use case is no ["reference_name", "reference_doctype", "status"] | |||||
# The index scan would happen from left to right | |||||
# so even if status is not in the where clause the index will be used | |||||
frappe.db.add_index("Workflow Action", ["reference_name", "reference_doctype", "status"]) | |||||
def get_permission_query_conditions(user): | def get_permission_query_conditions(user): | ||||
if not user: user = frappe.session.user | if not user: user = frappe.session.user | ||||
if user == "Administrator": return "" | if user == "Administrator": return "" | ||||
return "(`tabWorkflow Action`.`user`='{user}')".format(user=user) | |||||
roles = frappe.get_roles(user) | |||||
WorkflowAction = DocType("Workflow Action") | |||||
WorkflowActionPermittedRole = DocType("Workflow Action Permitted Role") | |||||
permitted_workflow_actions = (frappe.qb.from_(WorkflowAction) | |||||
.join(WorkflowActionPermittedRole) | |||||
.on(WorkflowAction.name == WorkflowActionPermittedRole.parent) | |||||
.select(WorkflowAction.name) | |||||
.where(WorkflowActionPermittedRole.role.isin(roles)) | |||||
).get_sql() | |||||
return f"""(`tabWorkflow Action`.`name` in ({permitted_workflow_actions}) | |||||
or `tabWorkflow Action`.`user`='{user}') | |||||
and `tabWorkflow Action`.`status`='Open'""" | |||||
def has_permission(doc, user): | def has_permission(doc, user): | ||||
if user not in ['Administrator', doc.user]: | |||||
return False | |||||
user_roles = set(frappe.get_roles(user)) | |||||
permitted_roles = {permitted_role.role for permitted_role in doc.permitted_roles} | |||||
return user == "Administrator" or user_roles.intersection(permitted_roles) | |||||
def process_workflow_actions(doc, state): | def process_workflow_actions(doc, state): | ||||
workflow = get_workflow_name(doc.get('doctype')) | workflow = get_workflow_name(doc.get('doctype')) | ||||
@@ -42,19 +64,18 @@ def process_workflow_actions(doc, state): | |||||
if is_workflow_action_already_created(doc): return | if is_workflow_action_already_created(doc): return | ||||
clear_old_workflow_actions(doc) | |||||
update_completed_workflow_actions(doc) | |||||
update_completed_workflow_actions(doc, workflow=workflow, workflow_state=get_doc_workflow_state(doc)) | |||||
clear_doctype_notifications('Workflow Action') | clear_doctype_notifications('Workflow Action') | ||||
next_possible_transitions = get_next_possible_transitions(workflow, get_doc_workflow_state(doc), doc) | next_possible_transitions = get_next_possible_transitions(workflow, get_doc_workflow_state(doc), doc) | ||||
if not next_possible_transitions: return | if not next_possible_transitions: return | ||||
user_data_map = get_users_next_action_data(next_possible_transitions, doc) | |||||
user_data_map, roles = get_users_next_action_data(next_possible_transitions, doc) | |||||
if not user_data_map: return | if not user_data_map: return | ||||
create_workflow_actions_for_users(user_data_map.keys(), doc) | |||||
create_workflow_actions_for_roles(roles, doc) | |||||
if send_email_alert(workflow): | if send_email_alert(workflow): | ||||
enqueue(send_workflow_action_email, queue='short', users_data=list(user_data_map.values()), doc=doc) | enqueue(send_workflow_action_email, queue='short', users_data=list(user_data_map.values()), doc=doc) | ||||
@@ -132,20 +153,85 @@ def return_link_expired_page(doc, doc_workflow_state): | |||||
frappe.bold(frappe.get_value('User', doc.get("modified_by"), 'full_name')) | frappe.bold(frappe.get_value('User', doc.get("modified_by"), 'full_name')) | ||||
), indicator_color='blue') | ), indicator_color='blue') | ||||
def clear_old_workflow_actions(doc, user=None): | |||||
def update_completed_workflow_actions(doc, user=None, workflow=None, workflow_state=None): | |||||
allowed_roles = get_allowed_roles(user, workflow, workflow_state) | |||||
# There is no transaction leading upto this state | |||||
# so no older actions to complete | |||||
if not allowed_roles: | |||||
return | |||||
if workflow_action := get_workflow_action_by_role(doc, allowed_roles): | |||||
update_completed_workflow_actions_using_role(doc, user, allowed_roles, workflow_action) | |||||
else: | |||||
# backwards compatibility | |||||
# for workflow actions saved using user | |||||
clear_old_workflow_actions_using_user(doc, user) | |||||
update_completed_workflow_actions_using_user(doc, user) | |||||
def get_allowed_roles(user, workflow, workflow_state): | |||||
user = user if user else frappe.session.user | user = user if user else frappe.session.user | ||||
frappe.db.delete("Workflow Action", { | |||||
"reference_doctype": doc.get("doctype"), | |||||
"reference_name": doc.get("name"), | |||||
"user": ("!=", user), | |||||
"status": "Open" | |||||
}) | |||||
def update_completed_workflow_actions(doc, user=None): | |||||
allowed_roles = frappe.get_all('Workflow Transition', | |||||
fields='allowed', | |||||
filters=[ | |||||
['parent', '=', workflow], | |||||
['next_state', '=', workflow_state] | |||||
], | |||||
pluck = 'allowed') | |||||
user_roles = set(frappe.get_roles(user)) | |||||
return set(allowed_roles).intersection(user_roles) | |||||
def get_workflow_action_by_role(doc, allowed_roles): | |||||
WorkflowAction = DocType("Workflow Action") | |||||
WorkflowActionPermittedRole = DocType("Workflow Action Permitted Role") | |||||
return (frappe.qb.from_(WorkflowAction).join(WorkflowActionPermittedRole) | |||||
.on(WorkflowAction.name == WorkflowActionPermittedRole.parent) | |||||
.select(WorkflowAction.name, WorkflowActionPermittedRole.role) | |||||
.where((WorkflowAction.reference_name == doc.get('name')) | |||||
& (WorkflowAction.reference_doctype == doc.get('doctype')) | |||||
& (WorkflowAction.status == 'Open') | |||||
& (WorkflowActionPermittedRole.role.isin(list(allowed_roles)))) | |||||
.orderby(WorkflowActionPermittedRole.role).limit(1)).run(as_dict=True) | |||||
def update_completed_workflow_actions_using_role(doc, user=None, allowed_roles = set(), workflow_action=None): | |||||
user = user if user else frappe.session.user | user = user if user else frappe.session.user | ||||
frappe.db.sql("""UPDATE `tabWorkflow Action` SET `status`='Completed', `completed_by`=%s | |||||
WHERE `reference_doctype`=%s AND `reference_name`=%s AND `user`=%s AND `status`='Open'""", | |||||
(user, doc.get('doctype'), doc.get('name'), user)) | |||||
WorkflowAction = DocType("Workflow Action") | |||||
if not workflow_action: | |||||
return | |||||
(frappe.qb.update(WorkflowAction) | |||||
.set(WorkflowAction.status, 'Completed') | |||||
.set(WorkflowAction.completed_by, user) | |||||
.set(WorkflowAction.completed_by_role, workflow_action[0].role) | |||||
.where(WorkflowAction.name == workflow_action[0].name) | |||||
).run() | |||||
def clear_old_workflow_actions_using_user(doc, user=None): | |||||
user = user if user else frappe.session.user | |||||
if frappe.db.has_column('Workflow Action', 'user'): | |||||
frappe.db.delete("Workflow Action", { | |||||
"reference_name": doc.get("name"), | |||||
"reference_doctype": doc.get("doctype"), | |||||
"status": "Open", | |||||
"user": ("!=", user) | |||||
}) | |||||
def update_completed_workflow_actions_using_user(doc, user=None): | |||||
user = user or frappe.session.user | |||||
if frappe.db.has_column('Workflow Action', 'user'): | |||||
WorkflowAction = DocType("Workflow Action") | |||||
(frappe.qb.update(WorkflowAction) | |||||
.set(WorkflowAction.status, 'Completed') | |||||
.set(WorkflowAction.completed_by, user) | |||||
.where((WorkflowAction.reference_name == doc.get('name')) | |||||
& (WorkflowAction.reference_doctype == doc.get('doctype')) | |||||
& (WorkflowAction.status == 'Open') | |||||
& (WorkflowAction.user == user)) | |||||
).run() | |||||
def get_next_possible_transitions(workflow_name, state, doc=None): | def get_next_possible_transitions(workflow_name, state, doc=None): | ||||
transitions = frappe.get_all('Workflow Transition', | transitions = frappe.get_all('Workflow Transition', | ||||
@@ -167,8 +253,10 @@ def get_next_possible_transitions(workflow_name, state, doc=None): | |||||
return transitions_to_return | return transitions_to_return | ||||
def get_users_next_action_data(transitions, doc): | def get_users_next_action_data(transitions, doc): | ||||
roles = set() | |||||
user_data_map = {} | user_data_map = {} | ||||
for transition in transitions: | for transition in transitions: | ||||
roles.add(transition.allowed) | |||||
users = get_users_with_role(transition.allowed) | users = get_users_with_role(transition.allowed) | ||||
filtered_users = filter_allowed_users(users, doc, transition) | filtered_users = filter_allowed_users(users, doc, transition) | ||||
for user in filtered_users: | for user in filtered_users: | ||||
@@ -182,19 +270,24 @@ def get_users_next_action_data(transitions, doc): | |||||
'action_name': transition.action, | 'action_name': transition.action, | ||||
'action_link': get_workflow_action_url(transition.action, doc, user) | 'action_link': get_workflow_action_url(transition.action, doc, user) | ||||
})) | })) | ||||
return user_data_map | |||||
return user_data_map, roles | |||||
def create_workflow_actions_for_users(users, doc): | |||||
for user in users: | |||||
frappe.get_doc({ | |||||
'doctype': 'Workflow Action', | |||||
'reference_doctype': doc.get('doctype'), | |||||
'reference_name': doc.get('name'), | |||||
'workflow_state': get_doc_workflow_state(doc), | |||||
'status': 'Open', | |||||
'user': user | |||||
}).insert(ignore_permissions=True) | |||||
def create_workflow_actions_for_roles(roles, doc): | |||||
workflow_action = frappe.get_doc({ | |||||
'doctype': 'Workflow Action', | |||||
'reference_doctype': doc.get('doctype'), | |||||
'reference_name': doc.get('name'), | |||||
'workflow_state': get_doc_workflow_state(doc), | |||||
'status': 'Open', | |||||
}) | |||||
for role in roles: | |||||
workflow_action.append('permitted_roles', { | |||||
'role': role | |||||
}) | |||||
workflow_action.insert(ignore_permissions=True) | |||||
def send_workflow_action_email(users_data, doc): | def send_workflow_action_email(users_data, doc): | ||||
common_args = get_common_email_args(doc) | common_args = get_common_email_args(doc) | ||||
@@ -249,18 +342,20 @@ def get_confirm_workflow_action_url(doc, action, user): | |||||
def is_workflow_action_already_created(doc): | def is_workflow_action_already_created(doc): | ||||
return frappe.db.exists({ | return frappe.db.exists({ | ||||
'doctype': 'Workflow Action', | 'doctype': 'Workflow Action', | ||||
'reference_doctype': doc.get('doctype'), | |||||
'reference_name': doc.get('name'), | 'reference_name': doc.get('name'), | ||||
'workflow_state': get_doc_workflow_state(doc) | |||||
'reference_doctype': doc.get('doctype'), | |||||
'workflow_state': get_doc_workflow_state(doc), | |||||
}) | }) | ||||
def clear_workflow_actions(doctype, name): | def clear_workflow_actions(doctype, name): | ||||
if not (doctype and name): | if not (doctype and name): | ||||
return | return | ||||
frappe.db.delete("Workflow Action", { | |||||
"reference_doctype": doctype, | |||||
"reference_name": name | |||||
}) | |||||
frappe.db.delete("Workflow Action", filters = { | |||||
"reference_name": name, | |||||
"reference_doctype": doctype, | |||||
} | |||||
) | |||||
def get_doc_workflow_state(doc): | def get_doc_workflow_state(doc): | ||||
workflow_name = get_workflow_name(doc.get('doctype')) | workflow_name = get_workflow_name(doc.get('doctype')) | ||||
workflow_state_field = get_workflow_state_field(workflow_name) | workflow_state_field = get_workflow_state_field(workflow_name) | ||||
@@ -0,0 +1,33 @@ | |||||
{ | |||||
"actions": [], | |||||
"allow_rename": 1, | |||||
"autoname": "hash", | |||||
"creation": "2022-02-21 20:28:05.662187", | |||||
"doctype": "DocType", | |||||
"editable_grid": 1, | |||||
"engine": "InnoDB", | |||||
"field_order": [ | |||||
"role" | |||||
], | |||||
"fields": [ | |||||
{ | |||||
"fieldname": "role", | |||||
"fieldtype": "Link", | |||||
"label": "Role", | |||||
"options": "Role" | |||||
} | |||||
], | |||||
"index_web_pages_for_search": 1, | |||||
"istable": 1, | |||||
"links": [], | |||||
"modified": "2022-02-21 20:28:05.662187", | |||||
"modified_by": "Administrator", | |||||
"module": "Workflow", | |||||
"name": "Workflow Action Permitted Role", | |||||
"naming_rule": "Random", | |||||
"owner": "Administrator", | |||||
"permissions": [], | |||||
"sort_field": "modified", | |||||
"sort_order": "DESC", | |||||
"states": [] | |||||
} |
@@ -0,0 +1,8 @@ | |||||
# Copyright (c) 2022, Frappe Technologies and contributors | |||||
# For license information, please see license.txt | |||||
# import frappe | |||||
from frappe.model.document import Document | |||||
class WorkflowActionPermittedRole(Document): | |||||
pass |
@@ -29,6 +29,7 @@ maxminddb-geolite2==2018.703 | |||||
num2words~=0.5.10 | num2words~=0.5.10 | ||||
oauthlib~=3.1.0 | oauthlib~=3.1.0 | ||||
openpyxl~=3.0.7 | openpyxl~=3.0.7 | ||||
parse~=1.19.0 | |||||
passlib~=1.7.4 | passlib~=1.7.4 | ||||
paytmchecksum~=1.7.0 | paytmchecksum~=1.7.0 | ||||
pdfkit~=0.6.1 | pdfkit~=0.6.1 | ||||