Merge branch 'develop' into 32489-role-perm-based-masking

This commit is contained in:
mergify[bot] 2025-08-01 05:57:54 +00:00 committed by GitHub
commit c0aa39ee9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
301 changed files with 61169 additions and 59190 deletions

View file

@ -103,13 +103,10 @@ To setup the repository locally follow the steps mentioned below:
2. In a separate terminal window, run the following commands:
```
# Create a new site
bench new-site frappe.dev
# Map your site to localhost
bench --site frappe.dev add-to-hosts
bench new-site frappe.localhost
```
3. Open the URL `http://frappe.dev:8000/app` in your browser, you should see the app running
3. Open the URL `http://frappe.localhost:8000/app` in your browser, you should see the app running
## Learning and community

View file

@ -4,9 +4,9 @@ context("Awesome Bar", () => {
cy.login();
cy.visit("/app/todo"); // Make sure ToDo filters are cleared.
cy.clear_filters();
cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared.
cy.visit("/app/web-page"); // Make sure Blog Post filters are cleared.
cy.clear_filters();
cy.visit("/app/website"); // Go to some other page.
cy.visit("/app/build"); // Go to some other page.
});
beforeEach(() => {
@ -53,19 +53,19 @@ context("Awesome Bar", () => {
});
it("navigates to another doctype, filter not bleeding", () => {
cy.get("@awesome_bar").type("blog post");
cy.get("@awesome_bar").type("web page");
cy.wait(150); // Wait a bit before hitting enter.
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text").should("contain", "Blog Post");
cy.get(".title-text").should("contain", "Web Page");
cy.wait(200); // Wait a bit longer before checking the filter.
cy.location("search").should("be.empty");
});
it("navigates to new form", () => {
cy.get("@awesome_bar").type("new blog post");
cy.get("@awesome_bar").type("new web page");
cy.wait(150); // Wait a bit before hitting enter
cy.get("@awesome_bar").type("{enter}");
cy.get(".title-text:visible").should("have.text", "New Blog Post");
cy.get(".title-text:visible").should("have.text", "New Web Page");
});
it("calculates math expressions", () => {

View file

@ -51,4 +51,33 @@ context("List View", () => {
cy.get(".list-row-container:visible").should("contain", "Approved");
});
});
it("Adds a button to each list view row", () => {
// Get a ToDo with a reference name
cy.call("frappe.client.get_value", {
doctype: "ToDo",
filters: {
reference_name: ["is", "set"],
},
fieldname: "name",
}).then((r) => {
const todo_name = r.message.name;
cy.go_to_list("ToDo");
// Check if the 'Open' button is present in the ToDo list view
cy.get(".btn-default[data-name='" + todo_name + "']")
.should((el) => {
expect(el).to.exist;
})
.click();
cy.window()
.its("cur_frm")
.then((frm) => {
// Routes to the reference document
expect(frm.doc.doctype).to.equal("ToDo");
expect(frm.doc.name).to.not.equal(todo_name);
});
});
});
});

View file

@ -43,7 +43,7 @@ context("Sidebar", () => {
.window()
.its("frappe")
.then((frappe) => {
return frappe.call("frappe.tests.ui_test_helpers.create_blog_post");
return frappe.call("frappe.tests.ui_test_helpers.create_doctype_for_attachment");
});
});
@ -53,7 +53,7 @@ context("Sidebar", () => {
}).then((todo) => {
verify_attachment_visibility(`todo/${todo.message.name}`, true);
});
verify_attachment_visibility("blog-post/test-blog-attachment-post", false);
verify_attachment_visibility("test-blog-category/_Test Blog Category 2", false);
});
it("Verify attachment accessibility UX", () => {

View file

@ -8,7 +8,7 @@ context("Table MultiSelect", () => {
it("select value from multiselect dropdown", () => {
cy.new_form("Assignment Rule");
cy.fill_field("__newname", name);
cy.fill_field("document_type", "Blog Post");
cy.fill_field("document_type", "Web Page");
cy.get(".section-head").contains("Assignment Rules").scrollIntoView();
cy.fill_field("assign_condition", 'status=="Open"', "Code");
cy.get('input[data-fieldname="users"]').focus().as("input");

View file

@ -24,16 +24,12 @@ from collections.abc import Callable, Iterable
from typing import (
TYPE_CHECKING,
Any,
Generic,
Literal,
Optional,
TypeAlias,
TypeVar,
Union,
overload,
)
import click
import orjson
from werkzeug.datastructures import Headers
import frappe
@ -45,10 +41,11 @@ from frappe.utils.caching import deprecated_local_cache as local_cache
from frappe.utils.caching import request_cache, site_cache
from frappe.utils.data import as_unicode, bold, cint, cstr, safe_decode, safe_encode, sbool
from frappe.utils.local import Local, LocalProxy, release_local
from frappe.utils.translations import _, _lt, set_user_lang
# Local application imports
from .exceptions import *
from .types import Filters, FilterSignature, FilterTuple, _dict
from .types import _dict
from .utils.jinja import (
get_email_from_template,
get_jenv,
@ -62,7 +59,6 @@ __title__ = "Frappe Framework"
if TYPE_CHECKING: # pragma: no cover
from logging import Logger
from types import ModuleType
from werkzeug.wrappers import Request
@ -70,10 +66,8 @@ if TYPE_CHECKING: # pragma: no cover
from frappe.database.mariadb.mysqlclient import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase
from frappe.database.sqlite.database import SQLiteDatabase
from frappe.email.doctype.email_queue.email_queue import EmailQueue
from frappe.model.document import Document
from frappe.query_builder.builder import MariaDB, Postgres, SQLite
from frappe.types.lazytranslatedstring import _LazyTranslate
from frappe.utils.redis_wrapper import ClientCache, RedisWrapper
controllers: dict[str, type] = {}
@ -93,66 +87,6 @@ if _dev_server:
warnings.simplefilter("always", PendingDeprecationWarning)
def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
"""Return translated string in current lang, if exists.
Usage:
_('Change')
_('Change', context='Coins')
"""
from frappe.translate import get_all_translations
from frappe.utils import is_html, strip_html_tags
if not hasattr(local, "lang"):
local.lang = lang or "en"
if not lang:
lang = local.lang
non_translated_string = msg
if is_html(msg):
msg = strip_html_tags(msg)
# msg should always be unicode
msg = as_unicode(msg).strip()
translated_string = ""
all_translations = get_all_translations(lang)
if context:
string_key = f"{msg}:{context}"
translated_string = all_translations.get(string_key)
if not translated_string:
translated_string = all_translations.get(msg)
return translated_string or non_translated_string
def _lt(msg: str, lang: str | None = None, context: str | None = None) -> "_LazyTranslate":
"""Lazily translate a string.
This function returns a "lazy string" which when casted to string via some operation applies
translation first before casting.
This is only useful for translating strings in global scope or anything that potentially runs
before `frappe.init()`
Note: Result is not guaranteed to equivalent to pure strings for all operations.
"""
from .types.lazytranslatedstring import _LazyTranslate
return _LazyTranslate(msg, lang, context)
def set_user_lang(user: str, user_language: str | None = None) -> None:
"""Guess and set user language for the session. `frappe.local.lang`"""
from frappe.translate import get_user_lang
local.lang = get_user_lang(user) or user_language
# local-globals
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
# TODO: make session a dataclass instead of undtyped _dict
@ -427,20 +361,6 @@ def log(msg: str) -> None:
debug_log.append(as_unicode(msg))
def create_folder(path, with_init=False):
"""Create a folder in the given path and add an `__init__.py` file (optional).
:param path: Folder path.
:param with_init: Create `__init__.py` in the new folder."""
from frappe.utils import touch_file
if not os.path.exists(path):
os.makedirs(path)
if with_init:
touch_file(os.path.join(path, "__init__.py"))
def set_user(username: str):
"""Set current user.
@ -481,144 +401,22 @@ def get_request_header(key, default=None):
return request.headers.get(key, default)
def sendmail(
recipients=None,
sender="",
subject="No Subject",
message="No Message",
as_markdown=False,
delayed=True,
reference_doctype=None,
reference_name=None,
unsubscribe_method=None,
unsubscribe_params=None,
unsubscribe_message=None,
add_unsubscribe_link=1,
attachments=None,
content=None,
doctype=None,
name=None,
reply_to=None,
queue_separately=False,
cc=None,
bcc=None,
message_id=None,
in_reply_to=None,
send_after=None,
expose_recipients=None,
send_priority=1,
communication=None,
retry=1,
now=None,
read_receipt=None,
is_notification=False,
inline_images=None,
template=None,
args=None,
header=None,
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
x_priority: Literal[1, 3, 5] = 3,
email_headers=None,
) -> Optional["EmailQueue"]:
"""Send email using user's default **Email Account** or global default **Email Account**.
:param recipients: List of recipients.
:param sender: Email sender. Default is current user or default outgoing account.
:param subject: Email Subject.
:param message: (or `content`) Email Content.
:param as_markdown: Convert content markdown to HTML.
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
:param send_priority: Priority for Email Queue, default 1.
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
:param reference_name: (or `name`) Append as communication to this document name.
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
:param attachments: List of attachments.
:param reply_to: Reply-To Email Address.
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
:param send_after: Send after the given datetime.
:param expose_recipients: Display all recipients in the footer message - "This email was sent to"
:param communication: Communication link to be set in Email Queue record
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
:param template: Name of html template from templates/emails folder
:param args: Arguments for rendering the template
:param header: Append header in email
:param with_container: Wraps email inside a styled container
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
"""
if recipients is None:
recipients = []
if cc is None:
cc = []
if bcc is None:
bcc = []
text_content = None
if template:
message, text_content = get_email_from_template(template, args)
message = content or message
if as_markdown:
from frappe.utils import md_to_html
message = md_to_html(message)
if not delayed:
now = True
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
builder = QueueBuilder(
recipients=recipients,
sender=sender,
subject=subject,
message=message,
text_content=text_content,
reference_doctype=doctype or reference_doctype,
reference_name=name or reference_name,
add_unsubscribe_link=add_unsubscribe_link,
unsubscribe_method=unsubscribe_method,
unsubscribe_params=unsubscribe_params,
unsubscribe_message=unsubscribe_message,
attachments=attachments,
reply_to=reply_to,
cc=cc,
bcc=bcc,
message_id=message_id,
in_reply_to=in_reply_to,
send_after=send_after,
expose_recipients=expose_recipients,
send_priority=send_priority,
queue_separately=queue_separately,
communication=communication,
read_receipt=read_receipt,
is_notification=is_notification,
inline_images=inline_images,
header=header,
print_letterhead=print_letterhead,
with_container=with_container,
email_read_tracker_url=email_read_tracker_url,
x_priority=x_priority,
email_headers=email_headers,
)
# build email queue and send the email if send_now is True.
return builder.process(send_now=now)
whitelisted: set[Callable] = set()
guest_methods: set[Callable] = set()
xss_safe_methods: set[Callable] = set()
allowed_http_methods_for_whitelisted_func: dict[Callable, list[str]] = {}
def _in_request_or_test():
"""
Internal
Used by whitelist to determine whether type hints should be validated or not
"""
return getattr(local, "request", None) or in_test
def whitelist(allow_guest=False, xss_safe=False, methods=None):
"""
Decorator for whitelisting a function and making it accessible via HTTP.
@ -642,17 +440,8 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
# validate argument types only if request is present
in_request_or_test = lambda: getattr(local, "request", None) or in_test # noqa: E731
# get function from the unbound / bound method
# this is needed because functions can be compared, but not methods
method = None
if hasattr(fn, "__func__"):
method = validate_argument_types(fn, apply_condition=in_request_or_test)
fn = method.__func__
else:
fn = validate_argument_types(fn, apply_condition=in_request_or_test)
# validate argument types if request is present or in test context
fn = validate_argument_types(fn, apply_condition=_in_request_or_test)
whitelisted.add(fn)
allowed_http_methods_for_whitelisted_func[fn] = methods
@ -663,7 +452,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
if xss_safe:
xss_safe_methods.add(fn)
return method or fn
return fn
return innerfn
@ -743,7 +532,7 @@ def only_for(roles: list[str] | tuple[str] | str, message=False):
:param roles: Permitted role(s)
"""
if in_test or local.session.user == "Administrator":
if local.session.user == "Administrator":
return
if isinstance(roles, str):
@ -910,30 +699,6 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str:
return secrets.token_hex(math.ceil(length / 2))[:length]
def new_doc(
doctype: str,
*,
parent_doc: Optional["Document"] = None,
parentfield: str | None = None,
as_dict: bool = False,
**kwargs,
) -> "Document":
"""Return a new document of the given DocType with defaults set.
:param doctype: DocType of the new document.
:param parent_doc: [optional] add to parent document.
:param parentfield: [optional] add against this `parentfield`.
:param as_dict: [optional] return as dictionary instead of Document.
:param kwargs: [optional] You can specify fields as field=value pairs in function call.
"""
from frappe.model.create_new import get_new_doc
new_doc = get_new_doc(doctype, parent_doc, parentfield, as_dict=as_dict)
return new_doc.update(kwargs)
def set_value(doctype, docname, fieldname, value=None):
"""Set document value. Calls `frappe.client.set_value`"""
import frappe.client
@ -941,118 +706,6 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args: Any, **kwargs: Any) -> "Document":
"""Identical to `frappe.get_doc`, but return from cache if available."""
if (key := can_cache_doc(args)) and (doc := cache.get_value(key)):
return doc
# Not found in cache, fetch from DB
doc = get_doc(*args, **kwargs)
# Store in cache
if not key:
key = get_document_cache_key(doc.doctype, doc.name)
_set_document_in_cache(key, doc)
return doc
def _set_document_in_cache(key: str, doc: "Document") -> None:
cache.set_value(key, doc, expires_in_sec=3600)
def can_cache_doc(args) -> str | None:
"""
Determine if document should be cached based on get_doc params.
Return cache key if doc can be cached, None otherwise.
"""
if not args:
return
doctype = args[0]
name = doctype if len(args) == 1 or args[1] is None else args[1]
# Only cache if both doctype and name are strings
if isinstance(doctype, str) and isinstance(name, str):
return get_document_cache_key(doctype, name)
def get_document_cache_key(doctype: str, name: str):
return f"document_cache::{doctype}::{name}"
def clear_document_cache(doctype: str, name: str | None = None) -> None:
frappe.db.value_cache.pop(doctype, None)
def clear_in_redis():
if name is not None:
cache.delete_value(get_document_cache_key(doctype, name))
else:
cache.delete_keys(get_document_cache_key(doctype, ""))
clear_in_redis()
if hasattr(db, "after_commit"):
db.after_commit.add(clear_in_redis)
db.after_rollback.add(clear_in_redis)
if doctype == "System Settings" and hasattr(local, "system_settings"):
delattr(local, "system_settings")
if doctype == "Website Settings" and hasattr(local, "website_settings"):
delattr(local, "website_settings")
def get_cached_value(
doctype: str, name: str | dict, fieldname: str | Iterable[str] = "name", as_dict: bool = False
) -> Any:
try:
doc = get_cached_doc(doctype, name)
except DoesNotExistError:
clear_last_message()
return
if isinstance(fieldname, str):
if as_dict:
throw("Cannot make dict for single fieldname")
return doc.get(fieldname)
values = [doc.get(f) for f in fieldname]
if as_dict:
return _dict(zip(fieldname, values, strict=False))
return values
def get_single_value(setting: str, fieldname: str, /, *, as_dict: bool = False):
"""Return the cached value associated with the given fieldname from single DocType.
Usage:
telemetry_enabled = frappe.get_single_value("System Settings", "telemetry_enabled")
"""
return get_cached_value(setting, setting, fieldname=fieldname, as_dict=as_dict)
def get_last_doc(
doctype,
filters: FilterSignature | None = None,
order_by="creation desc",
*,
for_update=False,
):
"""Get last created document of this type."""
d = get_all(doctype, filters=filters, limit_page_length=1, order_by=order_by, pluck="name")
if d:
return get_doc(doctype, d[0], for_update=for_update)
else:
raise DoesNotExistError(doctype=doctype)
def get_single(doctype):
"""Return a `frappe.model.document.Document` object of the given Single doctype."""
return get_doc(doctype, doctype)
def get_meta_module(doctype):
import frappe.modules
@ -1096,11 +749,6 @@ def delete_doc(
)
def delete_doc_if_exists(doctype, name, force=0):
"""Delete document if exists."""
delete_doc(doctype, name, force=force, ignore_missing=True)
def reload_doctype(doctype, force=False, reset_permissions=False):
"""Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files."""
reload_doc(
@ -1164,7 +812,7 @@ def rename_doc(
)
def get_module(modulename: str) -> "ModuleType":
def get_module(modulename: str):
"""Return a module object for given Python module name using `importlib.import_module`."""
return importlib.import_module(modulename)
@ -1265,7 +913,7 @@ def get_installed_apps(*, _ensure_on_bench: bool = False) -> list[str]:
if not db:
connect()
installed = json.loads(db.get_global("installed_apps") or "[]")
installed = orjson.loads(db.get_global("installed_apps") or "[]")
if _ensure_on_bench:
all_apps = cache.get_value("all_apps", get_all_apps)
@ -1523,7 +1171,7 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
def make_property_setter(
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True, *, module=None
):
"""Create a new **Property Setter** (for overriding DocType and DocField properties).
@ -1562,6 +1210,7 @@ def make_property_setter(
"doctype": "Property Setter",
"doctype_or_field": args.doctype_or_field,
"doc_type": doctype,
"module": module,
"field_name": args.fieldname,
"row_name": args.row_name,
"property": args.property,
@ -1584,50 +1233,6 @@ def import_doc(path):
import_doc(path)
def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
"""No_copy fields also get copied."""
import copy
from types import MappingProxyType
from frappe.model.base_document import BaseDocument
def remove_no_copy_fields(d):
for df in d.meta.get("fields", {"no_copy": 1}):
if hasattr(d, df.fieldname):
d.set(df.fieldname, None)
fields_to_clear = ["name", "owner", "creation", "modified", "modified_by"]
if not in_test:
fields_to_clear.append("docstatus")
if isinstance(doc, BaseDocument) or hasattr(doc, "as_dict"):
d = doc.as_dict()
elif isinstance(doc, MappingProxyType): # global test record
d = dict(doc)
else:
d = doc
newdoc = get_doc(copy.deepcopy(d))
newdoc.set("__islocal", 1)
for fieldname in [*fields_to_clear, "amended_from", "amendment_date"]:
newdoc.set(fieldname, None)
if not ignore_no_copy:
remove_no_copy_fields(newdoc)
for d in newdoc.get_all_children():
d.set("__islocal", 1)
for fieldname in fields_to_clear:
d.set(fieldname, None)
if not ignore_no_copy:
remove_no_copy_fields(d)
return newdoc
def respond_as_web_page(
title,
html,
@ -1831,59 +1436,6 @@ def are_emails_muted():
from frappe.deprecation_dumpster import frappe_get_test_records as get_test_records
def attach_print(
doctype,
name,
file_name=None,
print_format=None,
style=None,
html=None,
doc=None,
lang=None,
print_letterhead=True,
password=None,
letterhead=None,
):
from frappe.translate import print_language
from frappe.utils import scrub_urls
from frappe.utils.pdf import get_pdf
print_settings = db.get_singles_dict("Print Settings")
kwargs = dict(
print_format=print_format,
style=style,
doc=doc,
no_letterhead=not print_letterhead,
letterhead=letterhead,
password=password,
)
local.flags.ignore_print_permissions = True
with print_language(lang or local.lang):
content = ""
if cint(print_settings.send_print_as_pdf):
ext = ".pdf"
kwargs["as_pdf"] = True
content = (
get_pdf(html, options={"password": password} if password else None)
if html
else get_print(doctype, name, **kwargs)
)
else:
ext = ".html"
content = html or scrub_urls(get_print(doctype, name, **kwargs)).encode("utf-8")
local.flags.ignore_print_permissions = False
if not file_name:
file_name = name
file_name = cstr(file_name).replace(" ", "").replace("/", "-") + ext
return {"fname": file_name, "fcontent": content}
def task(**task_kwargs):
def decorator_task(f):
f.enqueue = lambda **fun_kwargs: enqueue(f, **task_kwargs, **fun_kwargs)
@ -1920,16 +1472,20 @@ def logger(
)
def get_desk_link(doctype, name, show_title_with_name=False):
def get_desk_link(doctype, name, show_title_with_name=False, open_in_new_tab=False):
meta = get_meta(doctype)
title = get_value(doctype, name, meta.get_title_field())
if show_title_with_name and name != title:
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
else:
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {title_local}</a>'
target_attr = ' target="_blank"' if open_in_new_tab else ""
return html.format(doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title))
if show_title_with_name and name != title:
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
else:
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
return html.format(
doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title), target=target_attr
)
def get_website_settings(key):
@ -1997,17 +1553,33 @@ import frappe._optimizations
from frappe.cache_manager import clear_cache, reset_metadata_version
from frappe.config import get_common_site_config, get_conf, get_site_config
from frappe.core.doctype.system_settings.system_settings import get_system_settings
from frappe.model.document import get_doc, get_lazy_doc
from frappe.model.document import (
get_doc,
get_lazy_doc,
copy_doc,
new_doc,
get_cached_doc,
can_cache_doc,
get_document_cache_key,
clear_document_cache,
get_cached_value,
get_single_value,
get_last_doc,
get_single,
_set_document_in_cache,
)
from frappe.model.meta import get_meta
from frappe.realtime import publish_progress, publish_realtime
from frappe.utils import get_traceback, mock, parse_json, safe_eval
from frappe.utils import get_traceback, mock, parse_json, safe_eval, create_folder
from frappe.utils.background_jobs import enqueue, enqueue_doc
from frappe.utils.error import log_error
from frappe.utils.formatters import format_value
from frappe.utils.print_utils import get_print
from frappe.utils.print_utils import get_print, attach_print
from frappe.email import sendmail
# for backwards compatibility
format = format_value
delete_doc_if_exists = delete_doc
frappe._optimizations.optimize_all()
frappe._optimizations.register_fault_handler()

View file

@ -39,8 +39,6 @@ def handle_rpc_call(method: str):
def create_doc(doctype: str):
data = get_request_form_data()
data.pop("doctype", None)
if (name := data.get("name")) and isinstance(name, str):
frappe.flags.api_name_set = True
return frappe.new_doc(doctype, **data).insert()

View file

@ -171,9 +171,13 @@ def count(doctype: str) -> int:
def create_doc(doctype: str):
data = frappe.form_dict
data.pop("doctype", None)
if (name := data.get("name")) and isinstance(name, str):
frappe.flags.api_name_set = True
return frappe.new_doc(doctype, **data).insert().as_dict()
doc = frappe.new_doc(doctype, **data)
if (name := data.get("name")) and isinstance(name, str | int):
doc.flags.name_set = True
return doc.insert().as_dict()
def copy_doc(doctype: str, name: str, ignore_no_copy: bool = True):

View file

@ -5,6 +5,7 @@ import functools
import logging
import os
import orjson
from werkzeug.exceptions import HTTPException, NotFound
from werkzeug.middleware.profiler import ProfilerMiddleware
from werkzeug.middleware.proxy_fix import ProxyFix
@ -21,6 +22,7 @@ import frappe.recorder
import frappe.utils.response
from frappe import _
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, check_request_ip, validate_auth
from frappe.integrations.oauth2 import get_resource_url, handle_wellknown, is_oauth_metadata_enabled
from frappe.middlewares import StaticDataMiddleware
from frappe.permissions import handle_does_not_exist_error
from frappe.utils import CallbackManager, cint, get_site_name
@ -65,6 +67,11 @@ import frappe.website.website_generator # web page doctypes
# end: module pre-loading
# better werkzeug default
# this is necessary because frappe desk sends most requests as form data
# and some of them can exceed werkzeug's default limit of 500kb
Request.max_form_memory_size = None
def after_response_wrapper(app):
"""Wrap a WSGI application to call after_response hooks after we have responded.
@ -119,6 +126,9 @@ def application(request: Request):
elif request.path.startswith("/private/files/"):
response = frappe.utils.response.download_private_file(request.path)
elif request.path.startswith("/.well-known/") and request.method == "GET":
response = handle_wellknown(request.path)
elif request.method in ("GET", "HEAD", "POST"):
response = get_response()
@ -249,6 +259,9 @@ def process_response(response: Response):
if hasattr(frappe.local, "conf"):
set_cors_headers(response)
if response.status_code in (401, 403) and is_oauth_metadata_enabled("resource"):
set_authenticate_headers(response)
# Update custom headers added during request processing
response.headers.update(frappe.local.response_headers)
@ -262,10 +275,12 @@ def process_response(response: Response):
def set_cors_headers(response):
allowed_origins = frappe.conf.allow_cors
if hasattr(frappe.local, "allow_cors"):
allowed_origins = frappe.local.allow_cors
if not (
(allowed_origins := frappe.conf.allow_cors)
and (request := frappe.local.request)
and (origin := request.headers.get("Origin"))
allowed_origins and (request := frappe.local.request) and (origin := request.headers.get("Origin"))
):
return
@ -296,12 +311,17 @@ def set_cors_headers(response):
response.headers.update(cors_headers)
def make_form_dict(request: Request):
import json
def set_authenticate_headers(response: Response):
headers = {
"WWW-Authenticate": f'Bearer resource_metadata="{get_resource_url()}/.well-known/oauth-protected-resource"'
}
response.headers.update(headers)
def make_form_dict(request: Request):
request_data = request.get_data(as_text=True)
if request_data and request.is_json:
args = json.loads(request_data)
args = orjson.loads(request_data)
else:
args = {}
args.update(request.args or {})
@ -404,7 +424,7 @@ def sync_database():
# update session
if session := getattr(frappe.local, "session_obj", None):
session.update()
frappe.request.after_response.add(session.update)
# Always initialize sentry SDK if the DSN is sent

View file

@ -131,7 +131,7 @@ class LoginManager:
self.make_session(resume=True)
self.get_user_info()
self.set_user_info(resume=True)
except AttributeError:
except (AttributeError, frappe.DoesNotExistError):
self.user = "Guest"
self.get_user_info()
self.make_session()
@ -704,6 +704,9 @@ def validate_auth_via_api_keys(authorization_header):
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
if not api_key or not api_secret:
raise frappe.AuthenticationError
doctype = frappe_authorization_source or "User"
docname = frappe.db.get_value(
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
@ -711,8 +714,8 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
if not docname:
raise frappe.AuthenticationError
form_dict = frappe.local.form_dict
doc_secret = get_decrypted_password(doctype, docname, fieldname="api_secret")
if api_secret == doc_secret:
doc_secret = get_decrypted_password(doctype, docname, fieldname="api_secret", raise_exception=False)
if doc_secret and api_secret == doc_secret:
if doctype == "User":
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
else:

View file

@ -21,6 +21,9 @@
"repeat_on_last_day",
"column_break_12",
"next_schedule_date",
"section_break_looa",
"generate_separate_documents_for_each_assignee",
"assignee",
"section_break_16",
"repeat_on_days",
"notification",
@ -86,7 +89,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Frequency",
"options": "\nDaily\nWeekly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
"options": "\nDaily\nWeekly\nFortnightly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
"reqd": 1
},
{
@ -198,10 +201,26 @@
"depends_on": "eval:doc.frequency==='Weekly';",
"fieldname": "section_break_16",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "generate_separate_documents_for_each_assignee",
"fieldtype": "Check",
"label": "Generate Separate Documents For Each Assignee"
},
{
"fieldname": "section_break_looa",
"fieldtype": "Section Break"
},
{
"fieldname": "assignee",
"fieldtype": "Table MultiSelect",
"label": "Assignee",
"options": "Auto Repeat User"
}
],
"links": [],
"modified": "2025-01-20 14:15:55.287788",
"modified": "2025-06-09 18:20:23.775881",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat",
@ -245,10 +264,11 @@
"write": 1
}
],
"row_format": "Dynamic",
"search_fields": "reference_document",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "reference_document",
"track_changes": 1
}
}

View file

@ -13,7 +13,7 @@ from frappe.contacts.doctype.contact.contact import (
get_contacts_linking_to,
)
from frappe.core.doctype.communication.email import make
from frappe.desk.form import assign_to
from frappe.desk.form.assign_to import add as assign_to
from frappe.model.document import Document
from frappe.utils import (
add_days,
@ -49,11 +49,16 @@ class AutoRepeat(Document):
if TYPE_CHECKING:
from frappe.automation.doctype.auto_repeat_day.auto_repeat_day import AutoRepeatDay
from frappe.automation.doctype.auto_repeat_user.auto_repeat_user import AutoRepeatUser
from frappe.types import DF
assignee: DF.TableMultiSelect[AutoRepeatUser]
disabled: DF.Check
end_date: DF.Date | None
frequency: DF.Literal["", "Daily", "Weekly", "Monthly", "Quarterly", "Half-yearly", "Yearly"]
frequency: DF.Literal[
"", "Daily", "Weekly", "Fortnightly", "Monthly", "Quarterly", "Half-yearly", "Yearly"
]
generate_separate_documents_for_each_assignee: DF.Check
message: DF.Text | None
next_schedule_date: DF.Date | None
notify_by_email: DF.Check
@ -219,9 +224,16 @@ class AutoRepeat(Document):
def create_documents(self):
try:
new_doc = self.make_new_document()
if self.generate_separate_documents_for_each_assignee and self.assignee:
new_docs = self.make_new_documents()
else:
new_docs = self.make_new_document([assignee.user for assignee in self.assignee])
if self.notify_by_email and self.recipients:
self.send_notification(new_doc)
if isinstance(new_docs, list):
for new_doc in new_docs:
self.send_notification(new_doc)
else:
self.send_notification(new_docs)
except Exception:
error_log = self.log_error(
_("Auto repeat failed. Please enable auto repeat after fixing the issues.")
@ -232,7 +244,14 @@ class AutoRepeat(Document):
if self.reference_document and not frappe.in_test:
self.notify_error_to_user(error_log)
def make_new_document(self):
def make_new_documents(self):
docs = []
for assignee in self.assignee:
new_doc = self.make_new_document(assignee=[assignee.user])
docs.append(new_doc)
return docs
def make_new_document(self, assignee=None):
reference_doc = frappe.get_doc(self.reference_doctype, self.reference_document)
new_doc = frappe.copy_doc(reference_doc, ignore_no_copy=False)
self.update_doc(new_doc, reference_doc)
@ -242,7 +261,14 @@ class AutoRepeat(Document):
"label": _("via Auto Repeat"),
}
new_doc.insert(ignore_permissions=True)
if assignee:
args = {
"assign_to": assignee,
"doctype": self.reference_doctype,
"name": new_doc.name,
"description": new_doc.get_title(),
}
assign_to(args=args)
if self.submit_on_creation:
new_doc.submit()
@ -348,6 +374,8 @@ class AutoRepeat(Document):
def get_days(self, schedule_date):
if self.frequency == "Weekly":
days = self.get_offset_for_weekly_frequency(schedule_date)
elif self.frequency == "Fortnightly":
days = 14
else:
# daily frequency
days = 1

View file

@ -85,6 +85,32 @@ class TestAutoRepeat(IntegrationTestCase):
self.assertEqual(todo.get("description"), new_todo.get("description"))
def test_fortnightly_auto_repeat(self):
todo = frappe.get_doc(
doctype="ToDo", description="test fortnightly todo", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(
reference_doctype="ToDo",
frequency="Fortnightly",
reference_document=todo.name,
start_date=add_days(today(), -14),
)
self.assertEqual(doc.next_schedule_date, today())
data = get_auto_repeat_entries(getdate(today()))
create_repeated_entries(data)
frappe.db.commit()
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
new_todo = frappe.get_doc("ToDo", new_todo)
self.assertEqual(todo.get("description"), new_todo.get("description"))
def test_weekly_auto_repeat_with_weekdays(self):
todo = frappe.get_doc(
doctype="ToDo", description="test auto repeat with weekdays", assigned_by="Administrator"
@ -221,6 +247,68 @@ class TestAutoRepeat(IntegrationTestCase):
)
self.assertEqual(docnames[0].docstatus, 1)
def test_auto_repeat_assignee(self):
todo = frappe.get_doc(
doctype="ToDo", description="test assignee todo", assigned_by="Administrator"
).insert()
doc = make_auto_repeat(reference_document=todo.name)
doc.update(
{
"assignee": [
{"user": "Administrator"},
{"user": "Guest"},
]
}
)
doc.save()
self.assertEqual(doc.next_schedule_date, today())
data = get_auto_repeat_entries(getdate(today()))
create_repeated_entries(data)
frappe.db.commit()
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
new_todo = frappe.get_doc("ToDo", new_todo)
self.assertEqual(todo.get("description"), new_todo.get("description"))
self.assertListEqual(
sorted(list(new_todo.get_assigned_users())),
sorted(["Administrator", "Guest"]),
)
def test_auto_repeat_assignee_with_separate_documents(self):
todo = frappe.get_doc(
doctype="ToDo",
description="test assignee todo with multiple doc",
assigned_by="Administrator",
).insert()
doc = make_auto_repeat(reference_document=todo.name)
doc.update(
{
"assignee": [
{"user": "Administrator"},
{"user": "Guest"},
],
"generate_separate_documents_for_each_assignee": 1,
}
)
doc.save()
self.assertEqual(doc.next_schedule_date, today())
data = get_auto_repeat_entries(getdate(today()))
create_repeated_entries(data)
frappe.db.commit()
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
self.assertEqual(todo.auto_repeat, doc.name)
new_todo_count = frappe.db.count("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
self.assertEqual(new_todo_count, 2)
def make_auto_repeat(**args):
args = frappe._dict(args)

View file

@ -0,0 +1,35 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-06-09 18:19:22.034128",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"user"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
"label": "User",
"options": "User",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-06-09 18:19:41.543336",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat User",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -1,11 +1,11 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class NewsletterAttachment(Document):
class AutoRepeatUser(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@ -14,10 +14,10 @@ class NewsletterAttachment(Document):
if TYPE_CHECKING:
from frappe.types import DF
attachment: DF.Attach
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
user: DF.Link
# end: auto-generated types
pass

View file

@ -1,6 +1,6 @@
{
"charts": [],
"content": "[{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"EgtURZsoiF\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"wE9n7TIrAc\",\"type\":\"card\",\"data\":{\"card_name\":\"Alerts and Notifications\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"3imoh2oqsJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}},{\"id\":\"O7jrc2YQTN\",\"type\":\"card\",\"data\":{\"card_name\":\"Newsletter\",\"col\":4}}]",
"content": "[{\"id\":\"sR-UFcO7II\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Import Data\",\"col\":3}},{\"id\":\"IkcVmgWb3z\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"ToDo\",\"col\":3}},{\"id\":\"6wir-jZFRE\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"File\",\"col\":3}},{\"id\":\"45a1jzQkTm\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Assignment Rule\",\"col\":3}},{\"id\":\"EgtURZsoiF\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"0yceBIfhHM\",\"type\":\"card\",\"data\":{\"card_name\":\"Data\",\"col\":4}},{\"id\":\"42WbBA9rpj\",\"type\":\"card\",\"data\":{\"card_name\":\"Tools\",\"col\":4}},{\"id\":\"wE9n7TIrAc\",\"type\":\"card\",\"data\":{\"card_name\":\"Alerts and Notifications\",\"col\":4}},{\"id\":\"7_U7_xCOos\",\"type\":\"card\",\"data\":{\"card_name\":\"Email\",\"col\":4}},{\"id\":\"3imoh2oqsJ\",\"type\":\"card\",\"data\":{\"card_name\":\"Printing\",\"col\":4}},{\"id\":\"SlYKJZj5r3\",\"type\":\"card\",\"data\":{\"card_name\":\"Automation\",\"col\":4}}]",
"creation": "2020-03-02 14:53:24.980279",
"custom_blocks": [],
"docstatus": 0,
@ -105,74 +105,6 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email",
"link_count": 3,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
"link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
"link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
"link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
"link_count": 2,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
"link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
"link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
@ -320,9 +252,58 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email",
"link_count": 4,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
"link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
"link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
"link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
"link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
"modified": "2024-09-03 21:54:05.403066",
"modified": "2025-06-27 11:39:44.392114",
"modified_by": "Administrator",
"module": "Automation",
"name": "Tools",

View file

@ -0,0 +1,126 @@
import frappe
import frappe.utils
from frappe import _
from frappe.core.doctype.user_invitation.user_invitation import UserInvitation
@frappe.whitelist(methods=["POST"])
def invite_by_email(
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe"
) -> dict[str, list[str]]:
UserInvitation.validate_role(app_name)
# validate emails
frappe.utils.validate_email_address(emails, throw=True)
email_list = frappe.utils.split_emails(emails)
if not email_list:
frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite"))
# get relevant data from the database
accepted_invite_emails = frappe.db.get_all(
"User Invitation",
filters={"email": ["in", email_list], "status": "Accepted", "app_name": app_name},
pluck="email",
)
pending_invite_emails = frappe.db.get_all(
"User Invitation",
filters={"email": ["in", email_list], "status": "Pending", "app_name": app_name},
pluck="email",
)
# create invitation documents
to_invite = list(set(email_list) - set(accepted_invite_emails) - set(pending_invite_emails))
for email in to_invite:
frappe.get_doc(
doctype="User Invitation",
email=email,
roles=[dict(role=role) for role in roles],
app_name=app_name,
redirect_to_path=redirect_to_path,
).insert(ignore_permissions=True)
return {
"accepted_invite_emails": accepted_invite_emails,
"pending_invite_emails": pending_invite_emails,
"invited_emails": to_invite,
}
@frappe.whitelist(allow_guest=True, methods=["GET"])
def accept_invitation(key: str) -> None:
_accept_invitation(key, False)
# `app_name` is required for security
@frappe.whitelist(methods=["PATCH", "POST"])
def cancel_invitation(name: str, app_name: str):
UserInvitation.validate_role(app_name)
if not frappe.db.exists("User Invitation", name):
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
invitation = frappe.get_doc("User Invitation", name)
if invitation.app_name != app_name:
# message is not specific enough for security
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
if invitation.status == "Cancelled":
return {"cancelled_now": False}
if invitation.status != "Pending":
frappe.throw(title=_("Error"), msg=_("Invitation cannot be cancelled"))
invitation.flags.ignore_permissions = True
return {"cancelled_now": invitation.cancel_invite()}
@frappe.whitelist(methods=["GET"])
def get_pending_invitations(app_name: str):
UserInvitation.validate_role(app_name)
pending_invitations = frappe.db.get_all(
"User Invitation", fields=["name", "email"], filters={"status": "Pending", "app_name": app_name}
)
res = []
for pending_invitation in pending_invitations:
roles = frappe.db.get_all("User Role", fields=["role"], filters={"parent": pending_invitation.name})
res.append(
{
"name": pending_invitation.name,
"email": pending_invitation.email,
"roles": [r.role for r in roles],
}
)
return res
def _accept_invitation(key: str, in_test: bool) -> None:
# get invitation
hashed_key = frappe.utils.sha256_hash(key)
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
if not invitation_name:
frappe.throw(title=_("Error"), msg=_("Invalid key"))
invitation = frappe.get_doc("User Invitation", invitation_name)
# accept invitation
invitation.accept(ignore_permissions=True)
user = frappe.get_doc("User", invitation.email)
should_update_password = not user.last_password_reset_date and not bool(
frappe.get_system_settings("disable_user_pass_login")
)
# set redirect_to
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
if should_update_password:
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
# GET requests do not cause an implicit commit
frappe.db.commit() # nosemgrep
if not in_test and not should_update_password:
frappe.local.login_manager.login_as(invitation.email)
# set response
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = redirect_to

View file

@ -5,11 +5,16 @@ import json
import frappe
from frappe.templates.includes.comments.comments import add_comment
from frappe.tests import IntegrationTestCase
from frappe.tests.test_helpers import setup_for_tests
from frappe.tests.test_model_utils import set_user
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
EXTRA_TEST_RECORD_DEPENDENCIES = ["Web Page"]
class TestComment(IntegrationTestCase):
def setUp(self):
setup_for_tests()
def test_comment_creation(self):
test_doc = frappe.get_doc(doctype="ToDo", description="test")
test_doc.insert()
@ -42,16 +47,16 @@ class TestComment(IntegrationTestCase):
# test via blog
def test_public_comment(self):
test_blog = make_test_blog()
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
add_comment_args = {
"comment": "Good comment with 10 chars",
"comment_email": "test@test.com",
"comment_by": "Good Tester",
"reference_doctype": test_blog.doctype,
"reference_name": test_blog.name,
"route": test_blog.route,
"route": f"blog/{test_blog.doctype}/{test_blog.name}",
}
add_comment(**add_comment_args)
@ -64,7 +69,7 @@ class TestComment(IntegrationTestCase):
1,
)
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
add_comment_args.update(comment="pleez vizits my site http://mysite.com", comment_by="bad commentor")
add_comment(**add_comment_args)
@ -81,7 +86,7 @@ class TestComment(IntegrationTestCase):
)
# test for filtering html and css injection elements
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
add_comment_args.update(comment="<script>alert(1)</script>Comment", comment_by="hacker")
add_comment(**add_comment_args)
@ -96,26 +101,10 @@ class TestComment(IntegrationTestCase):
test_blog.delete()
@IntegrationTestCase.change_settings("Blog Settings", {"allow_guest_to_comment": 0})
def test_guest_cannot_comment(self):
test_blog = make_test_blog()
with set_user("Guest"):
self.assertEqual(
add_comment(
comment="Good comment with 10 chars",
comment_email="mail@example.org",
comment_by="Good Tester",
reference_doctype="Blog Post",
reference_name=test_blog.name,
route=test_blog.route,
),
None,
)
def test_user_not_logged_in(self):
some_system_user = frappe.db.get_value("User", {"name": ("not in", frappe.STANDARD_USERS)})
test_blog = make_test_blog()
test_blog = frappe.get_doc("Web Page", "test-web-page-1")
with set_user("Guest"):
self.assertRaises(
frappe.ValidationError,
@ -123,7 +112,7 @@ class TestComment(IntegrationTestCase):
comment="Good comment with 10 chars",
comment_email=some_system_user,
comment_by="Good Tester",
reference_doctype="Blog Post",
reference_doctype="Web Page",
reference_name=test_blog.name,
route=test_blog.route,
)

View file

@ -401,7 +401,11 @@ class Communication(Document, CommunicationEmailMixin):
return
for doctype, docname in parse_email([self.recipients, self.cc, self.bcc]):
if not frappe.db.get_value(doctype, docname, ignore=True):
# Both document and doctype names should be case insensitive in email addresses.
doctype = frappe.db.get_value("DocType", doctype)
if doctype:
docname = frappe.db.get_value(doctype, docname, ignore=True)
if not (doctype and docname):
continue
self.add_link(doctype, docname)

View file

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING
import frappe
import frappe.email.smtp
from frappe import _
from frappe.database.utils import commit_after_response
from frappe.email.email_body import get_message_id
from frappe.utils import (
cint,
@ -272,7 +273,7 @@ def add_attachments(name: str, attachments: Iterable[str | dict]) -> None:
@frappe.whitelist(allow_guest=True, methods=("GET",))
def mark_email_as_seen(name: str | None = None):
frappe.request.after_response.add(lambda: _mark_email_as_seen(name))
commit_after_response(lambda: _mark_email_as_seen(name))
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
@ -282,8 +283,6 @@ def _mark_email_as_seen(name):
except Exception:
frappe.log_error("Unable to mark as seen", None, "Communication", name)
frappe.db.commit() # nosemgrep: after_response requires explicit commit
def update_communication_as_read(name):
if not name or not isinstance(name, str):

View file

@ -1035,8 +1035,11 @@ class Column:
if self.df.fieldtype == "Link":
# find all values that dont exist
values = list({cstr(v) for v in self.column_values if v})
exists = [cstr(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})]
transform = (lambda v: cstr(v).lower()) if frappe.db.db_type == "mariadb" else cstr
values = list({transform(v) for v in self.column_values if v})
exists = [
transform(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})
]
not_exists = list(set(values) - set(exists))
if not_exists:
missing_values = ", ".join(not_exists)

View file

@ -77,6 +77,7 @@
"email_append_to",
"sender_field",
"sender_name_field",
"recipient_account_field",
"subject_field",
"fields_tab",
"fields_section",
@ -707,6 +708,12 @@
"fieldtype": "Int",
"label": "Rows Threshold for Grid Search",
"non_negative": 1
},
{
"depends_on": "email_append_to",
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
}
],
"grid_page_length": 50,
@ -785,7 +792,7 @@
"link_fieldname": "document_type"
}
],
"modified": "2025-06-24 07:46:34.380662",
"modified": "2025-07-19 12:23:16.296416",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",

View file

@ -158,6 +158,7 @@ class DocType(Document):
queue_in_background: DF.Check
quick_entry: DF.Check
read_only: DF.Check
recipient_account_field: DF.Data | None
restrict_to_domain: DF.Link | None
route: DF.Data | None
row_format: DF.Literal["Dynamic", "Compressed"]

View file

@ -47,6 +47,7 @@ frappe.ui.form.on("File", {
$preview = $(`<div class="img_preview">
<img
class="img-responsive"
style="max-width: 500px";
src="${frappe.utils.escape_html(frm.doc.file_url)}"
onerror="${frm.toggle_display("preview", false)}"
/>

View file

@ -17,8 +17,7 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Git Branch",
"read_only": 1,
"reqd": 1
"read_only": 1
},
{
"columns": 2,
@ -35,8 +34,7 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Application Version",
"read_only": 1,
"reqd": 1
"read_only": 1
},
{
"columns": 2,
@ -58,7 +56,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-05-22 12:26:49.523690",
"modified": "2025-05-27 12:26:49.523690",
"modified_by": "Administrator",
"module": "Core",
"name": "Installed Application",

View file

@ -18,7 +18,7 @@
],
"issingle": 1,
"links": [],
"modified": "2024-03-23 16:03:27.992755",
"modified": "2025-06-30 21:26:13.462828",
"modified_by": "Administrator",
"module": "Core",
"name": "Installed Applications",
@ -36,8 +36,8 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
"states": []
}

View file

@ -26,6 +26,8 @@ class InstalledApplications(Document):
# end: auto-generated types
def update_versions(self):
self.reload_doc_if_required()
app_wise_setup_details = self.get_app_wise_setup_details()
self.delete_key("installed_applications")
@ -52,6 +54,8 @@ class InstalledApplications(Document):
)
self.save()
frappe.clear_cache(doctype="System Settings")
frappe.db.set_single_value("System Settings", "setup_complete", frappe.is_setup_complete())
def get_app_wise_setup_details(self):
"""Get app wise setup details from the Installed Application doctype"""
@ -64,6 +68,14 @@ class InstalledApplications(Document):
)
)
def reload_doc_if_required(self):
if frappe.db.has_column("Installed Application", "is_setup_complete"):
return
frappe.reload_doc("core", "doctype", "installed_application")
frappe.reload_doc("core", "doctype", "installed_applications")
frappe.reload_doc("integrations", "doctype", "webhook")
@frappe.whitelist()
def update_installed_apps_order(new_order: list[str] | str):

View file

@ -175,12 +175,11 @@ class TestReport(IntegrationTestCase):
)
def test_report_permissions(self):
frappe.set_user("test@example.com")
frappe.db.delete("Has Role", {"parent": frappe.session.user, "role": "Test Has Role"})
frappe.db.commit()
# create role "Test Has Role"
if not frappe.db.exists("Role", "Test Has Role"):
frappe.get_doc({"doctype": "Role", "role_name": "Test Has Role"}).insert(ignore_permissions=True)
# create report "Test Report"
if not frappe.db.exists("Report", "Test Report"):
report = frappe.get_doc(
{
@ -195,13 +194,16 @@ class TestReport(IntegrationTestCase):
else:
report = frappe.get_doc("Report", "Test Report")
self.assertNotEqual(report.is_permitted(), True)
frappe.set_user("Administrator")
with self.set_user("test@example.com"):
# remove role "Test Has Role" from user if found
frappe.db.delete("Has Role", {"parent": frappe.session.user, "role": "Test Has Role"})
self.assertNotEqual(report.is_permitted(), True)
def test_report_custom_permissions(self):
frappe.set_user("test@example.com")
# delete custom role if exists
frappe.db.delete("Custom Role", {"report": "Test Custom Role Report"})
frappe.db.commit() # nosemgrep
# create report if not exists
if not frappe.db.exists("Report", "Test Custom Role Report"):
report = frappe.get_doc(
{
@ -216,8 +218,11 @@ class TestReport(IntegrationTestCase):
else:
report = frappe.get_doc("Report", "Test Custom Role Report")
self.assertEqual(report.is_permitted(), True)
# check report is permitted without custom role created
with self.set_user("test@example.com"):
self.assertEqual(report.is_permitted(), True)
# create custom role for report
frappe.get_doc(
{
"doctype": "Custom Role",
@ -227,8 +232,9 @@ class TestReport(IntegrationTestCase):
}
).insert(ignore_permissions=True)
self.assertNotEqual(report.is_permitted(), True)
frappe.set_user("Administrator")
# check report is not permitted with custom role created
with self.set_user("test@example.com"):
self.assertNotEqual(report.is_permitted(), True)
# test for the `_format` method if report data doesn't have sort_by parameter
def test_format_method(self):

View file

@ -129,6 +129,21 @@ select name from \`tabPerson\`
where tenant_id = 2
order by creation desc
</code></pre>
<hr>
<h4>Workflow Task</h4>
<p>Execute when a particular <a href="/app/workflow-action-master">Workflow Action Master</a> is executed.</p>
<p>Gets the document which the action is being applied on in the <code>doc</code> variable.</p>
<code><pre>
# create a customer with the same name as the given document
customer = frappe.new_doc("Customer")
customer.customer_name = doc.first_name + " " + doc.last_name # we get this from the workflow action
customer.customer_type = "Company"
c.save()
</code></pre>
`);
},
});

View file

@ -32,7 +32,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Script Type",
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI",
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI\nWorkflow Task",
"reqd": 1
},
{
@ -151,7 +151,7 @@
"link_fieldname": "server_script"
}
],
"modified": "2024-05-08 03:21:54.169380",
"modified": "2025-07-03 16:12:29.676150",
"modified_by": "Administrator",
"module": "Core",
"name": "Server Script",
@ -171,6 +171,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View file

@ -80,7 +80,9 @@ class ServerScript(Document):
rate_limit_seconds: DF.Int
reference_doctype: DF.Link | None
script: DF.Code
script_type: DF.Literal["DocType Event", "Scheduler Event", "Permission Query", "API"]
script_type: DF.Literal[
"DocType Event", "Scheduler Event", "Permission Query", "API", "Workflow Task"
]
# end: auto-generated types
def validate(self):
@ -216,6 +218,19 @@ class ServerScript(Document):
if locals["conditions"]:
return locals["conditions"]
def execute_workflow_task(self, doc: Document):
"""
Specific to Workflow Tasks via Workflow Action Master
"""
if self.script_type != "Workflow Task":
raise frappe.DoesNotExistError
safe_exec(
self.script,
_locals={"doc": doc},
script_filename=self.name,
)
@frappe.whitelist()
@http_cache(max_age=10 * 60, stale_while_revalidate=6 * 60 * 60)

View file

@ -78,6 +78,10 @@ def send_sms(receiver_list, msg, sender_name="", success_msg=True):
"success_msg": success_msg,
}
send_sms_hook_methods = frappe.get_hooks("send_sms")
if send_sms_hook_methods:
return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg)
if frappe.db.get_single_value("SMS Settings", "sms_gateway_url"):
send_via_gateway(arg)
else:

View file

@ -224,9 +224,6 @@ def load():
return {"timezones": get_all_timezones(), "defaults": defaults}
cache_key = frappe.get_document_cache_key("System Settings", "System Settings")
def get_system_settings(key: str):
"""Return the value associated with the given `key` from System Settings DocType."""
if not (system_settings := getattr(frappe.local, "system_settings", None)):
@ -241,7 +238,7 @@ def get_system_settings(key: str):
def clear_system_settings_cache():
frappe.client_cache.delete_value(cache_key)
frappe.client_cache.delete_value(frappe.get_document_cache_key("System Settings", "System Settings"))
frappe.cache.delete_value("system_settings")
frappe.cache.delete_value("time_zone")

View file

@ -101,6 +101,18 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Translator",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
@ -108,4 +120,4 @@
"states": [],
"title_field": "source_text",
"track_changes": 1
}
}

View file

@ -120,10 +120,6 @@ class TestUser(IntegrationTestCase):
self.assertEqual(frappe.db.get_value("User", "xxxtest@example.com"), None)
frappe.db.set_single_value("Website Settings", "_test", "_test_val")
self.assertEqual(frappe.db.get_value("Website Settings", None, "_test"), "_test_val")
self.assertEqual(frappe.db.get_value("Website Settings", "Website Settings", "_test"), "_test_val")
def test_high_permlevel_validations(self):
user = frappe.get_meta("User")
self.assertTrue("roles" in [d.fieldname for d in user.get_high_permlevel_fields()])

View file

@ -845,11 +845,6 @@
"link_doctype": "Contact",
"link_fieldname": "user"
},
{
"group": "Profile",
"link_doctype": "Blogger",
"link_fieldname": "user"
},
{
"group": "Logs",
"link_doctype": "Access Log",

View file

@ -20,6 +20,7 @@ from frappe.desk.notifications import clear_notifications
from frappe.model.document import Document
from frappe.query_builder import DocType
from frappe.rate_limiter import rate_limit
from frappe.sessions import clear_sessions
from frappe.utils import (
cint,
escape_html,
@ -628,6 +629,9 @@ class User(Document):
# set email
frappe.db.set_value("User", new_name, "email", new_name)
clear_sessions(user=old_name, force=True)
clear_sessions(user=new_name, force=True)
def append_roles(self, *roles):
"""Add roles to user"""
current_roles = {d.role for d in self.get("roles")}

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View file

@ -0,0 +1,106 @@
# User Invitation
## Index
- [Motivation](#motivation)
- [How to use it?](#how-to-use-it)
- [Whitelisted functions](#whitelisted-functions)
- [`invite_by_email`](#invite_by_email)
- [`accept_invitation`](#accept_invitation)
- [`get_pending_invitations`](#get_pending_invitations)
- [`cancel_invitation`](#cancel_invitation)
- [Normal flow](#normal-flow)
- [Important points](#important-points)
## Motivation
- Until now, there was no way to invite and create a new user based on a sent invitation that can be accepted or rejected by the invitee.
- Due to this, custom Framework applications have to implement a user invitation flow. But most of the rules around this flow are generic enough to let Framework store all of the common logic associated with a typical user invitation flow.
- This will help ensure consistency and prevent code duplication for custom Framework applications that need this type of feature.
## How to use it?
Define user invitation hooks in your app's `hooks.py` file. An example is shown below.
![user invitation hooks example](./user_invitation_hooks_example.png)
- `only_for`
Roles that are allowed to invite users to your app.
- `allowed_roles`
Roles that are allowed to be invited to your app.
- `after_accept`
Dot path of the function to execute after the user accepts the invitation.
```python
from frappe.model.document import Document
def after_accept(
invitation: Document,
user: Document,
user_inserted: bool
) -> None:
# your business logic here
```
> `after_accept` is optional and should be used only if required.
At this point, you can start using the whitelisted functions under the `apis` section (`frappe/core/api/user_invitation.py`). For more information, read [whitelisted functions](#whitelisted-functions).
By default, only `System Manager`s can create a new invitation, view the list of invitations, or view more details associated with a single invitation **using the desk**. To enable users with specific roles to perform the mentioned actions, you might want to provide `create`, `read`, and `write` access to the relevant roles.
Example - If a user having the `Agent Manager` role should be able to use all of the user invitation features using the desk, these should be enabled:
- User Invitation doctype
![user invitation doctype's role permissions manager entry](./user_invitation_doc_role_permissions_manager.png)
- Role doctype
![role doctype's role permissions manager entry](./role_doc_role_permissions_manager.png)
## Whitelisted functions
There are a few whitelisted functions that can be used to manage invitations. All of the whitelisted functions are in `frappe/core/api/user_invitation.py`.
### `invite_by_email`
Invite new emails to your application.
![invite by email api example](./invite_by_email_api_example.png)
> The invited email will receive an email with a link to accept the invitation.
### `accept_invitation`
Enables invitees to accept the sent invitations.
> This function should not be used directly. The only reason this function is whitelisted is because the sent invitations contain a link that the invitees use to accept the invitations.
### `get_pending_invitations`
Get all of the pending invitations associated with an installed Framework application.
![get pending invitations api example](./get_pending_invitations_api_example.png)
### `cancel_invitation`
Cancels a specific pending invitation associated with an installed Framework application.
![cancel invitation api example](./cancel_invitation_api_example.png)
## Normal flow
1. Invitations are created from the desk or by using the [`invite_by_email`](#invite_by_email) whitelisted function. An email is sent to the invited email with a link to accept the invitation.
2. The app administrator or anyone able to use the desk can cancel invitations. Once an invitation is cancelled, an email is sent to the creator of the invitation.
3. Once the invitation is accepted, a new user is created (if required) with the roles specified in the invitation and is redirected to the specified path.
4. If the invitee doesn't accept the invitation within three days, the invitation is marked as expired by a background job that executes every day. Currently, there is no way to customize the expiration time.
## Important points:
- There can't be multiple pending invitations for the same app.
- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`.
- To manually mark an invitation as expired, you can use the `expire` method on the invitation document.
- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document.

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -0,0 +1,254 @@
# Copyright (c) 2025, Frappe Technologies and Contributors
# See license.txt
import re
import frappe
import frappe.utils
from frappe.core.api.user_invitation import (
_accept_invitation,
cancel_invitation,
get_pending_invitations,
invite_by_email,
)
from frappe.core.doctype.user_invitation.user_invitation import mark_expired_invitations
from frappe.tests import IntegrationTestCase
emails = [
"test_user_invite1@example.com",
"test_user_invite2@example.com",
"test_user_invite3@example.com",
"test_user_invite4@example.com",
"test_user_invite5@example.com",
]
class IntegrationTestUserInvitation(IntegrationTestCase):
"""
Integration tests for UserInvitation.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
user = frappe.new_doc("User")
user.first_name = "Test"
user.last_name = "123"
user.email = emails[0]
user.append_roles("System Manager")
user.insert()
frappe.set_user(emails[0])
@classmethod
def tearDownClass(cls):
super().tearDownClass()
IntegrationTestUserInvitation.delete_all_invitations()
IntegrationTestUserInvitation.delete_all_user_roles()
frappe.db.delete("Email Queue")
for user_email in emails:
if frappe.db.exists("User", user_email):
frappe.delete_doc("User", user_email)
frappe.set_user("Administrator")
# some of the code under test commit internally
frappe.db.commit() # nosemgrep
@classmethod
def delete_all_user_roles(cls):
frappe.db.sql("DELETE FROM `tabUser Role`")
@classmethod
def delete_all_invitations(cls):
frappe.db.sql("DELETE FROM `tabUser Invitation`")
@classmethod
def delete_invitation(cls, name: str):
frappe.db.sql(f'DELETE FROM `tabUser Invitation` WHERE name = "{name}"')
def setUp(self):
super().setUp()
IntegrationTestUserInvitation.delete_all_invitations()
IntegrationTestUserInvitation.delete_all_user_roles()
frappe.db.delete("Email Queue")
def test_insert_invitation(self):
invitation = self.get_dummy_invitation()
self.assertEqual(len(self.get_email_names()), 0)
invitation.insert()
self.assertEqual(invitation.invited_by, frappe.session.user)
self.assertEqual(invitation.status, "Pending")
self.assertIsInstance(invitation.email_sent_at, str)
self.assertIsInstance(invitation.key, str)
self.assertIsInstance(invitation.roles, list)
sent_emails = self.get_email_messages()
self.assertEqual(len(sent_emails), 1)
self.assertIn("invited", sent_emails[0].message.lower())
def test_update_invitation_status_to_expired(self):
invitation = self.get_dummy_invitation()
invitation.insert()
self.assertEqual(len(self.get_email_names()), 1)
invitation.expire()
emails = self.get_email_messages(False)
self.assertEqual(len(emails), 2)
self.assertIn("expired", emails[0].message.lower())
def test_cancel_pending_invitation(self):
invitation = self.get_dummy_invitation()
invitation.insert()
self.assertEqual(len(self.get_email_names(False)), 1)
self.assertEqual(invitation.status, "Pending")
invitation.cancel_invite()
sent_emails = self.get_email_messages(False)
self.assertEqual(len(sent_emails), 2)
self.assertIn("cancelled", sent_emails[0].message.lower())
def test_cancel_accepted_invitation(self):
invitation = self.get_dummy_invitation()
invitation.insert()
self.assertEqual(len(self.get_email_names(False)), 1)
invitation.status = "Accepted"
invitation.save()
invitation.cancel_invite()
self.assertEqual(len(self.get_email_names(False)), 1)
def test_cancel_expired_invitation(self):
invitation = self.get_dummy_invitation()
invitation.insert()
self.assertEqual(len(self.get_email_names(False)), 1)
invitation.expire()
self.assertEqual(len(self.get_email_names(False)), 2)
invitation.cancel_invite()
self.assertEqual(len(self.get_email_names(False)), 2)
def test_mark_expired_invitations(self):
invitation = self.get_dummy_invitation()
invitation.insert()
# the status of invitations older than 3 days should be set to expired
invitation.db_set("creation", frappe.utils.add_days(frappe.utils.now(), -4))
mark_expired_invitations()
invitation.reload()
self.assertEqual(invitation.status, "Expired")
def test_invite_by_email_api(self):
accepted_invite_email = emails[1]
invitation = frappe.get_doc(
doctype="User Invitation",
email=accepted_invite_email,
roles=[dict(role="System Manager")],
redirect_to_path="/abc",
app_name="frappe",
).insert()
invitation.status = "Accepted"
invitation.save()
self.assertEqual(len(self.get_email_names(False)), 1)
pending_invite_email = emails[2]
frappe.get_doc(
doctype="User Invitation",
email=pending_invite_email,
roles=[dict(role="System Manager")],
redirect_to_path="/abc",
app_name="frappe",
).insert()
self.assertEqual(len(self.get_email_names(False)), 2)
email_to_invite = emails[3]
res = invite_by_email(
emails=", ".join([accepted_invite_email, pending_invite_email, email_to_invite]),
roles=["System Manager"],
redirect_to_path="/xyz",
)
self.assertSequenceEqual(res["accepted_invite_emails"], [accepted_invite_email])
self.assertSequenceEqual(res["pending_invite_emails"], [pending_invite_email])
self.assertSequenceEqual(res["invited_emails"], [email_to_invite])
self.assertEqual(len(self.get_email_names(False)), 3)
def test_accept_invitation_api_pass_redirect(self):
invitation = frappe.get_doc(
doctype="User Invitation",
email=emails[1],
roles=[dict(role="System Manager")],
redirect_to_path="/abc",
app_name="frappe",
).insert()
self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0)
self.assertEqual(len(self.get_email_names(False)), 1)
key = invitation._after_insert()
self.assertEqual(len(self.get_email_names(False)), 2)
_accept_invitation(key, True)
res = frappe.local.response
self.assertEqual(res.type, "redirect")
pattern = f"^{re.escape(frappe.utils.get_url(''))}/update-password\\?key=.+&redirect_to=/abc$"
self.assertRegex(res.location, pattern)
user = frappe.get_doc("User", invitation.email)
IntegrationTestUserInvitation.delete_invitation(invitation.name)
frappe.delete_doc("User", user.name)
def test_accept_invitation_api_direct_redirect(self):
invitation = frappe.get_doc(
doctype="User Invitation",
email=emails[1],
roles=[dict(role="System Manager")],
redirect_to_path="/abc",
app_name="frappe",
).insert()
self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0)
original_disable_user_pass_login = frappe.get_system_settings("disable_user_pass_login")
frappe.db.set_single_value("System Settings", "disable_user_pass_login", 1)
self.assertEqual(len(self.get_email_names(False)), 1)
key = invitation._after_insert()
self.assertEqual(len(self.get_email_names(False)), 2)
_accept_invitation(key, True)
frappe.db.set_single_value(
"System Settings", "disable_user_pass_login", original_disable_user_pass_login
)
res = frappe.local.response
self.assertEqual(res.type, "redirect")
pattern = f"^{re.escape(frappe.utils.get_url(''))}/abc$"
self.assertRegex(res.location, pattern)
user = frappe.get_doc("User", invitation.email)
IntegrationTestUserInvitation.delete_invitation(invitation.name)
frappe.delete_doc("User", user.name)
def test_get_pending_invitations_api(self):
invitation = self.get_dummy_invitation()
invitation.insert()
invitation.reload()
pending_invitations = get_pending_invitations("frappe")
self.assertEqual(len(pending_invitations), 1)
pending_invitation = pending_invitations[0]
self.assertEqual(pending_invitation["name"], invitation.name)
self.assertEqual(pending_invitation["email"], invitation.email)
roles = pending_invitation["roles"]
self.assertIsInstance(roles, list)
self.assertSequenceEqual(roles, [r.role for r in invitation.roles])
def test_cancel_invitation_api(self):
invitation = self.get_dummy_invitation()
invitation.insert()
invitation.reload()
self.assertEqual(invitation.status, "Pending")
self.assertEqual(len(self.get_email_names()), 1)
res = cancel_invitation(invitation.name, "frappe")
self.assertTrue(res["cancelled_now"])
invitation.reload()
self.assertEqual(invitation.status, "Cancelled")
self.assertEqual(len(self.get_email_names()), 2)
res = cancel_invitation(invitation.name, "frappe")
self.assertFalse(res["cancelled_now"])
self.assertEqual(len(self.get_email_names()), 2)
def get_dummy_invitation(self):
return frappe.get_doc(
doctype="User Invitation",
email=emails[1],
roles=[dict(role="System Manager")],
redirect_to_path="/abc",
app_name="frappe",
)
def get_email_names(self, sent_only=True):
filters = {"status": "Sent"} if sent_only else None
return frappe.db.get_all("Email Queue", filters=filters, fields=["name"])
def get_email_messages(self, sent_only=True):
filters = {"status": "Sent"} if sent_only else None
return frappe.db.get_all("Email Queue", filters=filters, fields=["message"])

View file

@ -0,0 +1,23 @@
// Copyright (c) 2025, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("User Invitation", {
refresh(frm) {
frappe.xcall("frappe.apps.get_apps").then((r) => {
const apps = r?.map((r) => r.name) ?? [];
const default_app = "frappe";
frm.set_df_property("app_name", "options", [default_app, ...apps]);
if (!frm.doc.app_name) {
frm.set_value("app_name", default_app);
}
});
if (frm.doc.__islocal || frm.doc.status !== "Pending") {
return;
}
frm.add_custom_button(__("Cancel"), () => {
frappe.confirm(__("Are you sure you want to cancel the invitation?"), () =>
frm.call("cancel_invite")
);
});
},
});

View file

@ -0,0 +1,143 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-07-07 14:19:31.014655",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"email",
"app_name",
"redirect_to_path",
"roles",
"status",
"invited_by",
"key",
"user",
"email_sent_at",
"accepted_at"
],
"fields": [
{
"fieldname": "email",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Email",
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "invited_by",
"fieldtype": "Link",
"hidden": 1,
"in_list_view": 1,
"label": "Invited By",
"options": "User",
"read_only": 1
},
{
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"hidden": 1,
"in_list_view": 1,
"label": "Status",
"options": "Pending\nAccepted\nExpired\nCancelled",
"read_only": 1
},
{
"fieldname": "email_sent_at",
"fieldtype": "Datetime",
"hidden": 1,
"label": "Email Sent At",
"read_only": 1
},
{
"fieldname": "accepted_at",
"fieldtype": "Datetime",
"hidden": 1,
"label": "Accepted At",
"read_only": 1
},
{
"fieldname": "user",
"fieldtype": "Link",
"hidden": 1,
"label": "User",
"options": "User",
"read_only": 1
},
{
"fieldname": "app_name",
"fieldtype": "Select",
"in_list_view": 1,
"label": "App Name",
"reqd": 1,
"set_only_once": 1
},
{
"fieldname": "redirect_to_path",
"fieldtype": "Data",
"label": "Redirect To Path",
"read_only_depends_on": "eval:doc.status!==\"Pending\"",
"reqd": 1
},
{
"fieldname": "key",
"fieldtype": "Data",
"hidden": 1,
"label": "Key",
"read_only": 1
},
{
"fieldname": "roles",
"fieldtype": "Table MultiSelect",
"label": "Roles",
"options": "User Role",
"read_only_depends_on": "eval:Boolean(doc.creation)",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-26 11:52:46.984800",
"modified_by": "Administrator",
"module": "Core",
"name": "User Invitation",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [
{
"color": "Green",
"title": "Accepted"
},
{
"color": "Orange",
"title": "Pending"
},
{
"color": "Yellow",
"title": "Expired"
},
{
"color": "Red",
"title": "Cancelled"
}
]
}

View file

@ -0,0 +1,241 @@
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
import frappe
import frappe.utils
from frappe import _
from frappe.model.document import Document
from frappe.permissions import get_roles
class UserInvitation(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.core.doctype.user_role.user_role import UserRole
from frappe.types import DF
accepted_at: DF.Datetime | None
app_name: DF.Literal[None]
email: DF.Data
email_sent_at: DF.Datetime | None
invited_by: DF.Link | None
key: DF.Data | None
redirect_to_path: DF.Data
roles: DF.TableMultiSelect[UserRole]
status: DF.Literal["Pending", "Accepted", "Expired", "Cancelled"]
user: DF.Link | None
# end: auto-generated types
def before_insert(self):
self._validate_invite()
self.invited_by = frappe.session.user
self.status = "Pending"
def after_insert(self):
self._after_insert()
def accept(self, ignore_permissions: bool = False):
accepted_now = self._accept()
if not accepted_now:
return
user, user_inserted = self._upsert_user(ignore_permissions)
self.save(ignore_permissions)
user.save(ignore_permissions)
self._run_after_accept_hooks(user, user_inserted)
@frappe.whitelist()
def cancel_invite(self):
if self.status != "Pending":
return False
self.status = "Cancelled"
self.save()
email_title = self._get_email_title()
frappe.sendmail(
recipients=self.email,
subject=_("Invitation to join {0} cancelled").format(email_title),
template="user_invitation_cancelled",
args={"title": email_title},
now=True,
)
return True
@frappe.whitelist()
def expire(self):
if self.status != "Pending":
return
self.status = "Expired"
self.save()
email_title = self._get_email_title()
invited_by_user = frappe.get_doc("User", self.invited_by)
frappe.sendmail(
recipients=invited_by_user.email,
subject=_("Invitation to join {0} expired").format(email_title),
template="user_invitation_expired",
args={"title": email_title},
now=False,
)
def _validate_invite(self):
self._validate_app_name()
self._validate_roles()
self._validate_email()
if frappe.db.get_value(
"User Invitation", filters={"email": self.email, "status": "Accepted", "app_name": self.app_name}
):
frappe.throw(title=_("Error"), msg=_("invitation already accepted"))
if frappe.db.get_value(
"User Invitation", filters={"email": self.email, "status": "Pending", "app_name": self.app_name}
):
frappe.throw(title=_("Error"), msg=_("invitation already exists"))
def _after_insert(self):
key = frappe.generate_hash()
self.db_set("key", frappe.utils.sha256_hash(key))
invite_link = frappe.utils.get_url(
f"/api/method/frappe.core.api.user_invitation.accept_invitation?key={key}"
)
email_title = self._get_email_title()
frappe.sendmail(
recipients=self.email,
subject=_("You've been invited to join {0}").format(email_title),
template="user_invitation",
args={"title": email_title, "invite_link": invite_link},
now=True,
)
self.db_set("email_sent_at", frappe.utils.now())
return key
def _accept(self):
if self.status == "Accepted":
return False
if self.status == "Expired":
frappe.throw(title=_("Error"), msg=_("Invitation is expired"))
if self.status == "Cancelled":
frappe.throw(title=_("Error"), msg=_("Invitation is cancelled"))
self.status = "Accepted"
self.accepted_at = frappe.utils.now()
self.user = self.email
return True
def _upsert_user(self, ignore_permissions: bool = False):
user: Document | None = None
user_inserted = False
if frappe.db.exists("User", self.user):
user = frappe.get_doc("User", self.user)
else:
user = frappe.new_doc("User")
user.user_type = "System User"
user.email = self.email
user.first_name = self.email.split("@")[0].title()
user.send_welcome_email = False
user.insert(ignore_permissions)
user_inserted = True
user.append_roles(*[r.role for r in self.roles])
return user, user_inserted
def _run_after_accept_hooks(self, user: Document, user_inserted: bool):
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
if not isinstance(user_invitation_hook, dict):
return
for dot_path in user_invitation_hook.get("after_accept") or []:
frappe.call(dot_path, invitation=self, user=user, user_inserted=user_inserted)
def _get_email_title(self):
return frappe.get_hooks("app_title", app_name=self.app_name)[0]
def _validate_app_name(self):
UserInvitation.validate_app_name(self.app_name)
def _validate_roles(self):
if self.app_name == "frappe":
return
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
allowed_roles: list[str] = []
if isinstance(user_invitation_hook, dict):
allowed_roles = user_invitation_hook.get("allowed_roles") or []
for r in self.roles:
if r.role in allowed_roles:
continue
frappe.throw(
title=_("Invalid role"),
msg=_("{0} is not an allowed role for {1}").format(r.role, self.app_name),
)
def _validate_email(self):
frappe.utils.validate_email_address(self.email, throw=True)
def get_redirect_to_path(self):
start_index = 1 if self.redirect_to_path.startswith("/") else 0
return self.redirect_to_path[start_index:]
@staticmethod
def validate_app_name(app_name: str):
if app_name not in frappe.get_installed_apps():
frappe.throw(title=_("Invalid app"), msg=_("application is not installed"))
@staticmethod
def validate_role(app_name: str) -> None:
UserInvitation.validate_app_name(app_name)
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name)
only_for: list[str] = []
if isinstance(user_invitation_hook, dict):
only_for = user_invitation_hook.get("only_for") or []
if "System Manager" not in only_for:
only_for.append("System Manager")
frappe.only_for(only_for)
def mark_expired_invitations() -> None:
days = 3
invitations_to_expire = frappe.db.get_all(
"User Invitation",
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
)
for invitation in invitations_to_expire:
invitation = frappe.get_doc("User Invitation", invitation.name)
invitation.expire()
# to avoid losing work in case the job times out without finishing
frappe.db.commit() # nosemgrep
def get_allowed_apps(user: Document | None) -> list[str]:
user_roles = set(get_user_roles(user))
allowed_apps: list[str] = []
for app in frappe.get_installed_apps():
user_invitation_hooks = frappe.get_hooks("user_invitation", app_name=app)
if not isinstance(user_invitation_hooks, dict):
continue
only_for = user_invitation_hooks.get("only_for") or []
if set(only_for) & user_roles:
allowed_apps.append(app)
return allowed_apps
def get_permission_query_conditions(user: Document | None) -> str | None:
user = get_user(user)
user_roles = get_user_roles(user)
if "System Manager" in user_roles:
return
allowed_apps = get_allowed_apps(user)
if not allowed_apps:
return "false"
allowed_apps_str = ", ".join([f'"{app}"' for app in allowed_apps])
return f"`tabUser Invitation`.app_name IN ({allowed_apps_str})"
def has_permission(
doc: UserInvitation, user: Document | None = None, permission_type: str | None = None
) -> bool:
return permission_type != "delete" and doc.app_name in get_allowed_apps(user)
def get_user_roles(user: Document | None) -> list[str]:
return get_roles(get_user(user))
def get_user(user: Document | None) -> Document:
return user or frappe.session.user

View file

@ -8,7 +8,7 @@ from frappe.core.doctype.user_permission.user_permission import (
)
from frappe.permissions import add_permission, has_user_permission
from frappe.tests import IntegrationTestCase
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
from frappe.tests.test_helpers import setup_for_tests
class TestUserPermission(IntegrationTestCase):
@ -23,6 +23,7 @@ class TestUserPermission(IntegrationTestCase):
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`")
frappe.delete_doc_if_exists("DocType", "Doc A")
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabDoc A`")
setup_for_tests()
def test_default_user_permission_validation(self):
user = create_user("test_default_permission@example.com")
@ -39,27 +40,27 @@ class TestUserPermission(IntegrationTestCase):
add_user_permissions(param)
# create a duplicate entry with default
perm_user = create_user("test_default_corectness2@example.com")
test_blog = make_test_blog()
param = get_params(perm_user, "Blog Post", test_blog.name, is_default=1, hide_descendants=1)
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
param = get_params(perm_user, "Test Blog Post", test_blog.name, is_default=1, hide_descendants=1)
add_user_permissions(param)
frappe.db.delete("User Permission", filters={"for_value": test_blog.name})
frappe.delete_doc("Blog Post", test_blog.name)
frappe.delete_doc("Test Blog Post", test_blog.name)
def test_default_user_permission(self):
frappe.set_user("Administrator")
user = create_user("test_user_perm1@example.com", "Website Manager")
for category in ["general", "public"]:
if not frappe.db.exists("Blog Category", category):
frappe.get_doc({"doctype": "Blog Category", "title": category}).insert()
if not frappe.db.exists("Test Blog Category", category):
frappe.get_doc({"doctype": "Test Blog Category", "title": category}).insert()
param = get_params(user, "Blog Category", "general", is_default=1)
param = get_params(user, "Test Blog Category", "general", is_default=1)
add_user_permissions(param)
param = get_params(user, "Blog Category", "public")
param = get_params(user, "Test Blog Category", "public")
add_user_permissions(param)
frappe.set_user("test_user_perm1@example.com")
doc = frappe.new_doc("Blog Post")
doc = frappe.new_doc("Test Blog Post")
self.assertEqual(doc.blog_category, "general")
frappe.set_user("Administrator")

View file

@ -1,32 +1,35 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-12-06 16:37:40.652468",
"creation": "2025-07-17 10:56:04.746455",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"attachment"
"role"
],
"fields": [
{
"fieldname": "attachment",
"fieldtype": "Attach",
"fieldname": "role",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Attachment",
"label": "Role",
"options": "Role",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-23 16:03:31.101104",
"modified": "2025-07-17 10:56:36.357715",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter Attachment",
"module": "Core",
"name": "User Role",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View file

@ -1,10 +1,11 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# License: MIT. See LICENSE
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class NewsletterEmailGroup(Document):
class UserRole(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@ -13,11 +14,10 @@ class NewsletterEmailGroup(Document):
if TYPE_CHECKING:
from frappe.types import DF
email_group: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
total_subscribers: DF.ReadOnly | None
role: DF.Link
# end: auto-generated types
pass

File diff suppressed because one or more lines are too long

View file

@ -49,6 +49,7 @@
"email_append_to",
"sender_field",
"sender_name_field",
"recipient_account_field",
"subject_field",
"section_break_8",
"sort_field",
@ -415,6 +416,12 @@
"fieldname": "protect_attached_files",
"fieldtype": "Check",
"label": "Protect Attached Files"
},
{
"depends_on": "email_append_to",
"fieldname": "recipient_account_field",
"fieldtype": "Data",
"label": "Recipient Account Field"
}
],
"hide_toolbar": 1,
@ -423,7 +430,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-03-27 18:22:32.618603",
"modified": "2025-07-19 12:23:41.564203",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",

View file

@ -74,6 +74,7 @@ class CustomizeForm(Document):
protect_attached_files: DF.Check
queue_in_background: DF.Check
quick_entry: DF.Check
recipient_account_field: DF.Data | None
search_fields: DF.Data | None
sender_field: DF.Data | None
sender_name_field: DF.Data | None

View file

@ -306,12 +306,11 @@ class Database:
):
raise
self.log_query(query, query_type, values, debug)
if debug:
time_end = time()
frappe.log(f"Execution time: {(time_end - time_start) * 1000:.3f} ms")
self.log_query(query, query_type, values, debug)
if auto_commit:
self.commit()
@ -552,7 +551,6 @@ class Database:
# returns default date_format
frappe.db.get_value("System Settings", None, "date_format")
"""
result = self.get_values(
doctype,
filters,
@ -731,9 +729,20 @@ class Database:
:param filters: Filters (dict).
:param doctype: DocType name.
"""
from frappe.model.meta import get_default_df
meta = frappe.get_meta(doctype)
def _cast(field, val):
df = meta.get_field(field) or get_default_df(field)
if not df:
return val
return cast_fieldtype(df.fieldtype, val)
if fields == "*" or isinstance(filters, dict):
# check if single doc matches with filters
values = self.get_singles_dict(doctype)
values = self.get_singles_dict(doctype, cast=True)
if isinstance(filters, dict):
for key, value in filters.items():
if values.get(key) != value:
@ -760,6 +769,9 @@ class Database:
return []
r = _dict(r)
for k, v in r.items():
r[k] = _cast(k, v)
if update:
r.update(update)
@ -864,7 +876,16 @@ class Database:
frappe.qb.into("Singles").columns("doctype", "field", "value").insert(*singles_data).run(debug=debug)
frappe.clear_document_cache(doctype, doctype)
def get_single_value(self, doctype: str, fieldname: str, cache: bool = True):
def get_single_value(
self,
doctype: str,
fieldname: str,
cache: bool = True,
*,
debug=False,
for_update=False,
run=True,
):
"""Get property of Single DocType. Cache locally by default
:param doctype: DocType of the single object whose value is requested
@ -875,17 +896,23 @@ class Database:
# Get the default value of the company from the Global Defaults doctype.
company = frappe.db.get_single_value('Global Defaults', 'default_company')
"""
if cache and fieldname in self.value_cache[doctype]:
from frappe.model.meta import get_default_df
if cache and not for_update and run and fieldname in self.value_cache[doctype]:
return self.value_cache[doctype][fieldname]
val = frappe.qb.get_query(
table="Singles",
filters={"doctype": doctype, "field": fieldname},
fields="value",
).run()
for_update=for_update,
).run(debug=debug, run=run)
if not run:
return val
val = val[0][0] if val else None
df = frappe.get_meta(doctype).get_field(fieldname)
df = frappe.get_meta(doctype).get_field(fieldname) or get_default_df(fieldname)
if not df:
frappe.throw(
@ -896,7 +923,8 @@ class Database:
val = cast_fieldtype(df.fieldtype, val)
self.value_cache[doctype][fieldname] = val
if cache and not for_update and run:
self.value_cache[doctype][fieldname] = val
return val
@ -1514,6 +1542,18 @@ class Database:
"""
raise NotImplementedError
def get_routines(self):
information_schema = frappe.qb.Schema("information_schema")
return (
frappe.qb.from_(information_schema.routines)
.select(information_schema.routines.routine_name)
.where(
(information_schema.routines.routine_type.isin(["FUNCTION", "PROCEDURE"]))
& (information_schema.routines.routine_schema.eq(frappe.conf.db_name))
)
.run(as_dict=1, pluck="routine_name")
)
@contextmanager
def savepoint(catch: type | tuple[type, ...] = Exception):

View file

@ -142,12 +142,19 @@ class MariaDBConnectionUtil:
if frappe.conf.local_infile:
conn_settings["local_infile"] = frappe.conf.local_infile
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
conn_settings["ssl"] = {
# Configure SSL settings
if frappe.conf.db_ssl_ca:
ssl_config = {
"ca": frappe.conf.db_ssl_ca,
"cert": frappe.conf.db_ssl_cert,
"key": frappe.conf.db_ssl_key,
"check_hostname": frappe.conf.db_ssl_check_hostname,
}
# Add client certificates for mutual SSL if available
if frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
ssl_config.update({"cert": frappe.conf.db_ssl_cert, "key": frappe.conf.db_ssl_key})
conn_settings["ssl"] = ssl_config
return conn_settings
@ -165,11 +172,11 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
self.db_type = "mariadb"
self.type_map = {
"Currency": ("decimal", "21,9"),
"Int": ("int", None),
"Int": ("int", "11"),
"Long Int": ("bigint", "20"),
"Float": ("decimal", "21,9"),
"Percent": ("decimal", "21,9"),
"Check": ("tinyint", None),
"Check": ("tinyint", 4),
"Small Text": ("text", ""),
"Long Text": ("longtext", ""),
"Code": ("longtext", ""),

View file

@ -143,13 +143,19 @@ class MariaDBConnectionUtil:
if frappe.conf.local_infile:
conn_settings["local_infile"] = frappe.conf.local_infile
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
conn_settings["ssl"] = {
# Configure SSL settings
if frappe.conf.db_ssl_ca:
ssl_config = {
"ca": frappe.conf.db_ssl_ca,
"cert": frappe.conf.db_ssl_cert,
"key": frappe.conf.db_ssl_key,
"check_hostname": frappe.conf.db_ssl_check_hostname,
}
# Add client certificates for mutual SSL if available
if frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
ssl_config.update({"cert": frappe.conf.db_ssl_cert, "key": frappe.conf.db_ssl_key})
conn_settings["ssl"] = ssl_config
return conn_settings
@ -198,11 +204,11 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
self.db_type = "mariadb"
self.type_map = {
"Currency": ("decimal", "21,9"),
"Int": ("int", None),
"Int": ("int", "11"),
"Long Int": ("bigint", "20"),
"Float": ("decimal", "21,9"),
"Percent": ("decimal", "21,9"),
"Check": ("tinyint", None),
"Check": ("tinyint", "4"),
"Small Text": ("text", ""),
"Long Text": ("longtext", ""),
"Code": ("longtext", ""),

View file

@ -175,6 +175,9 @@ class DBTable:
pass
NOT_NULL_TYPES = ("Check", "Int", "Currency", "Float", "Percent")
class DbColumn:
def __init__(
self,
@ -216,13 +219,14 @@ class DbColumn:
default = None
unique = False
if self.fieldtype in NOT_NULL_TYPES:
null = False
if self.fieldtype in ("Check", "Int"):
default = cint(self.default)
null = False
elif self.fieldtype in ("Currency", "Float", "Percent"):
default = flt(self.default)
null = False
elif (
self.default
@ -271,7 +275,10 @@ class DbColumn:
return
# type
if current_def["type"] != column_type:
if current_def["type"] != column_type and not (
# XXX: MariaDB JSON is same as longtext and information schema still returns longtext
current_def["type"] == "longtext" and column_type == "json" and frappe.db.db_type == "mariadb"
):
self.table.change_type.append(self)
# unique
@ -289,7 +296,11 @@ class DbColumn:
self.table.set_default.append(self)
# nullability
if self.not_nullable is not None and (self.not_nullable != current_def.get("not_nullable")):
if (
self.not_nullable is not None
and (self.not_nullable != current_def.get("not_nullable"))
and self.fieldtype not in NOT_NULL_TYPES
):
self.table.change_nullability.append(self)
# index should be applied or dropped irrespective of type change
@ -310,24 +321,36 @@ class DbColumn:
else:
# Strip quotes from default value
# eg. database returns default value as "'System Manager'"
cur_default = cur_default.lstrip("'").rstrip("'")
cur_default = cur_default.lstrip("'").rstrip("'").replace("\\\\", "\\")
fieldtype = self.fieldtype
db_field_type = frappe.db.type_map.get(fieldtype)
if fieldtype in ["Int", "Check"]:
cur_default = cint(cur_default)
new_default = cint(new_default)
elif fieldtype in ["Currency", "Float", "Percent"]:
cur_default = flt(cur_default)
new_default = flt(new_default)
elif fieldtype == "Time":
return self.default_changed_for_time(cur_default, new_default)
elif db_field_type and db_field_type[0] in ("varchar", "longtext", "text"):
new_default = cstr(new_default)
if not current_def.get("not_nullable"):
cur_default = cstr(cur_default)
return cur_default != new_default
def default_changed_for_decimal(self, current_def):
cur_default = current_def["default"]
if cur_default == "NULL":
cur_default = None
try:
if current_def["default"] in ("", None) and self.default in ("", None):
# both none, empty
if cur_default in ("", None) and self.default in ("", None):
return False
elif current_def["default"] in ("", None):
elif flt(cur_default) == 0.0 and flt(self.default) == 0.0:
return False
elif cur_default in ("", None):
try:
# check if new default value is valid
float(self.default)
@ -341,10 +364,28 @@ class DbColumn:
else:
# NOTE float() raise ValueError when "" or None is passed
return float(current_def["default"]) != float(self.default)
return float(cur_default) != float(self.default)
except TypeError:
return True
def default_changed_for_time(self, cur_default: str, new_default: str):
from datetime import datetime
# Normalize time values to HH:MM:SS.ssssss format, from formats: HH:MM:SS.ssssss, HH:MM:SS, HH:MM
def normalize_time(val):
if not val:
return None
for fmt in ("%H:%M:%S.%f", "%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(val, fmt).time().strftime("%H:%M:%S.%f")
except ValueError:
continue
return val
cur = normalize_time(cur_default)
new = normalize_time(new_default)
return cur != new
def validate_column_name(n):
if special_characters := SPECIAL_CHAR_PATTERN.findall(n):

View file

@ -477,7 +477,7 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
if not self.is_nested_transaction_error(e):
raise e
def commit(self):
def commit(self, chain=None):
"""Commit current transaction. Calls SQL `COMMIT`."""
if not self._conn:
self.connect()
@ -497,7 +497,7 @@ class SQLiteDatabase(SQLiteExceptionUtil, Database):
self.after_commit.run()
def rollback(self, *, save_point=None):
def rollback(self, *, save_point=None, chain=None):
"""`ROLLBACK` current transaction. Optionally rollback to a known save_point."""
if not self._conn:
self.connect()

View file

@ -8,6 +8,7 @@ from functools import cached_property, wraps
import frappe
from frappe.query_builder.builder import MariaDB, Postgres, SQLite
from frappe.query_builder.functions import Function
from frappe.utils import CallbackManager
Query = str | MariaDB | Postgres | SQLite
QueryValues = tuple | list | dict | None
@ -109,3 +110,49 @@ def dangerously_reconnect_on_connection_abort(func):
raise
return wrapper
class CommitAfterResponseManager(CallbackManager):
__slots__ = ()
def run(self):
db = getattr(frappe.local, "db", None)
if not db:
# try reconnecting to the database
frappe.connect(set_admin_as_user=False)
db = frappe.local.db
savepoint_name = "commit_after_response"
while self._functions:
_func = self._functions.popleft()
try:
db.savepoint(savepoint_name)
_func()
except Exception:
db.rollback(save_point=savepoint_name)
frappe.log_error(title="Error executing commit_after_response callback")
db.commit() # nosemgrep
def commit_after_response(func):
"""
Runs and commits some queries after response is sent.
Works only if in a request context and not in tests.
Calls function immediately otherwise.
"""
request = getattr(frappe.local, "request", False)
if not request or frappe.in_test:
func()
return
callback_manager = getattr(request, "commit_after_response", None)
if callback_manager is None:
# if no callback manager, create one
callback_manager = CommitAfterResponseManager()
request.commit_after_response = callback_manager
request.after_response.add(callback_manager.run)
callback_manager.add(func)

View file

@ -33,6 +33,7 @@
"time_interval",
"timeseries",
"type",
"show_values_over_chart",
"currency",
"filters_section",
"filters_json",
@ -293,10 +294,17 @@
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
},
{
"default": "0",
"depends_on": "eval: doc.type == \"Bar\" || doc.type == \"Line\"",
"fieldname": "show_values_over_chart",
"fieldtype": "Check",
"label": "Show Values over Chart"
}
],
"links": [],
"modified": "2025-02-01 21:06:05.808591",
"modified": "2025-06-08 22:49:08.587921",
"modified_by": "Administrator",
"module": "Desk",
"name": "Dashboard Chart",

View file

@ -10,7 +10,7 @@ from frappe.boot import get_allowed_report_names
from frappe.model.document import Document
from frappe.model.naming import append_number_if_name_exists
from frappe.modules.export_file import export_to_files
from frappe.utils import cint, get_datetime, getdate, has_common, now_datetime, nowdate
from frappe.utils import cint, flt, get_datetime, getdate, has_common, now_datetime, nowdate
from frappe.utils.dashboard import cache_source
from frappe.utils.data import format_date
from frappe.utils.dateutils import (
@ -56,7 +56,7 @@ def get_permission_query_conditions(user):
or `tabDashboard Chart`.`module` is NULL""".format(allowed_modules=",".join(allowed_modules))
return f"""
((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average', 'Group By')
and {doctype_condition})
or
(`tabDashboard Chart`.`chart_type` = 'Report'
@ -313,8 +313,8 @@ def get_result(data, timegrain, from_date, to_date, chart_type):
for d in result:
count = 0
while data_index < len(data) and getdate(data[data_index][0]) <= d[0]:
d[1] += cint(data[data_index][1])
count += cint(data[data_index][2])
d[1] += flt(data[data_index][1])
count += flt(data[data_index][2])
data_index += 1
if chart_type == "Average" and count != 0:
d[1] = d[1] / count

View file

@ -1,6 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
"""Use blog post test to test user permissions logic"""
import json
from datetime import date

View file

@ -6,7 +6,7 @@ from frappe.model.db_query import DatabaseQuery
from frappe.permissions import add_permission, reset_perms
from frappe.tests import IntegrationTestCase
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
EXTRA_TEST_RECORD_DEPENDENCIES = ["User", "Web Page"]
class TestToDo(IntegrationTestCase):
@ -93,8 +93,8 @@ class TestToDo(IntegrationTestCase):
frappe.set_user("Administrator")
test_user.add_roles("Blogger")
add_permission("ToDo", "Blogger")
test_user.add_roles("Website Manager")
add_permission("ToDo", "Website Manager")
frappe.set_user("test4@example.com")
@ -103,7 +103,7 @@ class TestToDo(IntegrationTestCase):
self.assertFalse(todo1.has_permission("write"))
frappe.set_user("Administrator")
test_user.remove_roles("Blogger")
test_user.remove_roles("Website Manager")
reset_perms("ToDo")
clear_permissions_cache("ToDo")
frappe.db.rollback()

View file

@ -187,11 +187,32 @@ def run_setup_success(args): # nosemgrep
def get_stages_hooks(args): # nosemgrep
stages = []
for method in frappe.get_hooks("setup_wizard_stages"):
stages += frappe.get_attr(method)(args)
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
for app_name in installed_apps:
setup_wizard_stages = frappe.get_hooks(app_name=app_name).get("setup_wizard_stages")
if not setup_wizard_stages:
continue
for method in setup_wizard_stages:
_stages = frappe.get_attr(method)(args)
update_app_details_in_stages(_stages, app_name)
stages += _stages
return stages
def update_app_details_in_stages(_stages, app_name):
for stage in _stages:
for key in stage:
if key != "tasks":
continue
for task in stage[key]:
if task.get("app_name") is None:
task["app_name"] = app_name
def get_setup_complete_hooks(args): # nosemgrep
return [
{
@ -348,7 +369,9 @@ def _get_default_roles() -> set[str]:
def disable_future_access():
frappe.db.set_default("desktop:home_page", "workspace")
# Enable onboarding after install
frappe.clear_cache(doctype="System Settings")
frappe.db.set_single_value("System Settings", "enable_onboarding", 1)
frappe.db.set_single_value("System Settings", "setup_complete", frappe.is_setup_complete())
@frappe.whitelist()

View file

@ -330,6 +330,7 @@ def export_query():
include_indentation = form_params.include_indentation
include_filters = form_params.include_filters
visible_idx = form_params.visible_idx
include_hidden_columns = form_params.include_hidden_columns
if isinstance(visible_idx, str):
visible_idx = json.loads(visible_idx)
@ -347,11 +348,20 @@ def export_query():
format_fields(data)
xlsx_data, column_widths = build_xlsx_data(
data, visible_idx, include_indentation, include_filters=include_filters
data,
visible_idx,
include_indentation,
include_filters=include_filters,
include_hidden_columns=include_hidden_columns,
)
if file_format_type == "CSV":
content = get_csv_bytes(xlsx_data, csv_params)
from frappe.utils.xlsxutils import handle_html
content = get_csv_bytes(
[[handle_html(frappe.as_unicode(v)) if isinstance(v, str) else v for v in r] for r in xlsx_data],
csv_params,
)
file_extension = "csv"
elif file_format_type == "Excel":
from frappe.utils.xlsxutils import make_xlsx
@ -393,7 +403,14 @@ def format_fields(data: frappe._dict) -> None:
row[index] = round(row[index], col.get("precision"))
def build_xlsx_data(data, visible_idx, include_indentation, include_filters=False, ignore_visible_idx=False):
def build_xlsx_data(
data,
visible_idx,
include_indentation,
include_filters=False,
ignore_visible_idx=False,
include_hidden_columns=False,
):
EXCEL_TYPES = (
str,
bool,
@ -433,7 +450,7 @@ def build_xlsx_data(data, visible_idx, include_indentation, include_filters=Fals
column_data = []
for column in data.columns:
if column.get("hidden"):
if column.get("hidden") and not cint(include_hidden_columns):
continue
column_data.append(_(column.get("label")))
column_width = cint(column.get("width", 0))
@ -449,7 +466,7 @@ def build_xlsx_data(data, visible_idx, include_indentation, include_filters=Fals
row_data = []
if isinstance(row, dict):
for col_idx, column in enumerate(data.columns):
if column.get("hidden"):
if column.get("hidden") and not cint(include_hidden_columns):
continue
label = column.get("label")
fieldname = column.get("fieldname")

View file

@ -1,8 +1,13 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from typing import TYPE_CHECKING, Literal, Optional
import frappe
if TYPE_CHECKING:
from frappe.email.doctype.email_queue.email_queue import EmailQueue
def sendmail_to_system_managers(subject, content):
frappe.sendmail(recipients=get_system_managers(), subject=subject, content=content)
@ -92,3 +97,137 @@ def get_communication_doctype(doctype, txt, searchfield, start, page_len, filter
]
return [[dt] for dt in com_doctypes if txt.lower().replace("%", "") in dt.lower() and dt in can_read]
def sendmail(
recipients=None,
sender="",
subject="No Subject",
message="No Message",
as_markdown=False,
delayed=True,
reference_doctype=None,
reference_name=None,
unsubscribe_method=None,
unsubscribe_params=None,
unsubscribe_message=None,
add_unsubscribe_link=1,
attachments=None,
content=None,
doctype=None,
name=None,
reply_to=None,
queue_separately=False,
cc=None,
bcc=None,
message_id=None,
in_reply_to=None,
send_after=None,
expose_recipients=None,
send_priority=1,
communication=None,
retry=1,
now=None,
read_receipt=None,
is_notification=False,
inline_images=None,
template=None,
args=None,
header=None,
print_letterhead=False,
with_container=False,
email_read_tracker_url=None,
x_priority: Literal[1, 3, 5] = 3,
email_headers=None,
) -> Optional["EmailQueue"]:
"""Send email using user's default **Email Account** or global default **Email Account**.
:param recipients: List of recipients.
:param sender: Email sender. Default is current user or default outgoing account.
:param subject: Email Subject.
:param message: (or `content`) Email Content.
:param as_markdown: Convert content markdown to HTML.
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
:param send_priority: Priority for Email Queue, default 1.
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
:param reference_name: (or `name`) Append as communication to this document name.
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
:param attachments: List of attachments.
:param reply_to: Reply-To Email Address.
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
:param send_after: Send after the given datetime.
:param expose_recipients: Display all recipients in the footer message - "This email was sent to"
:param communication: Communication link to be set in Email Queue record
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
:param template: Name of html template from templates/emails folder
:param args: Arguments for rendering the template
:param header: Append header in email
:param with_container: Wraps email inside a styled container
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
"""
from frappe.utils.jinja import get_email_from_template
if recipients is None:
recipients = []
if cc is None:
cc = []
if bcc is None:
bcc = []
text_content = None
if template:
message, text_content = get_email_from_template(template, args)
message = content or message
if as_markdown:
from frappe.utils import md_to_html
message = md_to_html(message)
if not delayed:
now = True
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
builder = QueueBuilder(
recipients=recipients,
sender=sender,
subject=subject,
message=message,
text_content=text_content,
reference_doctype=doctype or reference_doctype,
reference_name=name or reference_name,
add_unsubscribe_link=add_unsubscribe_link,
unsubscribe_method=unsubscribe_method,
unsubscribe_params=unsubscribe_params,
unsubscribe_message=unsubscribe_message,
attachments=attachments,
reply_to=reply_to,
cc=cc,
bcc=bcc,
message_id=message_id,
in_reply_to=in_reply_to,
send_after=send_after,
expose_recipients=expose_recipients,
send_priority=send_priority,
queue_separately=queue_separately,
communication=communication,
read_receipt=read_receipt,
is_notification=is_notification,
inline_images=inline_images,
header=header,
print_letterhead=print_letterhead,
with_container=with_container,
email_read_tracker_url=email_read_tracker_url,
x_priority=x_priority,
email_headers=email_headers,
)
# build email queue and send the email if send_now is True.
return builder.process(send_now=now)

View file

@ -182,7 +182,7 @@
"fieldname": "format",
"fieldtype": "Select",
"label": "Format",
"options": "HTML\nXLSX\nCSV",
"options": "HTML\nXLSX\nCSV\nPDF",
"reqd": 1
},
{
@ -220,7 +220,7 @@
}
],
"links": [],
"modified": "2024-03-23 16:01:28.131581",
"modified": "2025-07-04 17:33:36.750217",
"modified_by": "Administrator",
"module": "Email",
"name": "Auto Email Report",
@ -251,8 +251,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -9,6 +9,7 @@ from email.utils import formataddr
import frappe
from frappe import _
from frappe.desk.query_report import build_xlsx_data
from frappe.email.email_body import get_formatted_html
from frappe.model.document import Document
from frappe.model.naming import append_number_if_name_exists
from frappe.utils import (
@ -29,6 +30,7 @@ from frappe.utils import (
validate_email_address,
)
from frappe.utils.csvutils import to_csv
from frappe.utils.pdf import get_pdf
from frappe.utils.xlsxutils import make_xlsx
@ -51,7 +53,7 @@ class AutoEmailReport(Document):
enabled: DF.Check
filter_meta: DF.Text | None
filters: DF.Text | None
format: DF.Literal["HTML", "XLSX", "CSV"]
format: DF.Literal["HTML", "XLSX", "CSV", "PDF"]
frequency: DF.Literal["Daily", "Weekdays", "Weekly", "Monthly"]
from_date_field: DF.Literal[None]
no_of_rows: DF.Int
@ -109,7 +111,7 @@ class AutoEmailReport(Document):
def validate_report_format(self):
"""check if user has select correct report format"""
valid_report_formats = ["HTML", "XLSX", "CSV"]
valid_report_formats = ["HTML", "XLSX", "CSV", "PDF"]
if self.format not in valid_report_formats:
frappe.throw(
_("{0} is not a valid report format. Report format should one of the following {1}").format(
@ -184,6 +186,16 @@ class AutoEmailReport(Document):
xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True)
return to_csv(xlsx_data)
elif self.format == "PDF":
columns, data = make_links(columns, data)
columns = update_field_types(columns)
options = {}
if len(columns) > 8:
options["orientation"] = "landscape"
html = get_formatted_html(subject=self.name, message=self.get_html_table(columns, data))
return get_pdf(html, options)
else:
frappe.throw(_("Invalid Output Format"))

View file

@ -1,6 +1,9 @@
# Copyright (c) 2015, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import json
from io import BytesIO
from pypdf import PdfReader
import frappe
from frappe.tests import IntegrationTestCase
@ -28,6 +31,11 @@ class TestAutoEmailReport(IntegrationTestCase):
data = auto_email_report.get_report_content()
auto_email_report.format = "PDF"
data = auto_email_report.get_report_content()
PdfReader(stream=BytesIO(data))
def test_dynamic_date_filters(self):
auto_email_report = get_auto_email_report()

View file

@ -62,15 +62,6 @@ frappe.ui.form.on("Email Group", {
},
__("Action")
);
frm.add_custom_button(
__("New Newsletter"),
function () {
frappe.route_options = { email_group: frm.doc.name };
frappe.new_doc("Newsletter");
},
__("Action")
);
}
frm.trigger("preview_welcome_url");

View file

@ -38,10 +38,19 @@ class EmailGroup(Document):
"""Extract Email Addresses from given doctype and add them to the current list"""
meta = frappe.get_meta(doctype)
email_field = next(
d.fieldname
for d in meta.fields
if d.fieldtype in ("Data", "Small Text", "Text", "Code") and d.options == "Email"
(
d.fieldname
for d in meta.fields
if d.fieldtype in ("Data", "Small Text", "Text", "Code") and d.options == "Email"
),
None,
)
if not email_field:
frappe.throw(
_("No Email field found in {0}").format(doctype),
title=_("Invalid Doctype"),
)
unsubscribed_field = "unsubscribed" if meta.get_field("unsubscribed") else None
added = 0

View file

@ -1,16 +0,0 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
from frappe.exceptions import ValidationError
class NewsletterAlreadySentError(ValidationError):
pass
class NoRecipientFoundError(ValidationError):
pass
class NewsletterNotSavedError(ValidationError):
pass

View file

@ -1,229 +0,0 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.ui.form.on("Newsletter", {
refresh(frm) {
let doc = frm.doc;
let can_write = frappe.boot.user.can_write.includes(doc.doctype);
if (!frm.is_new() && !frm.is_dirty() && !doc.email_sent && can_write) {
frm.add_custom_button(
__("Send a test email"),
() => {
frm.events.send_test_email(frm);
},
__("Preview")
);
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);
}
});
},
__("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.events.send_emails(frm);
}
);
return;
}
frappe.confirm(
__("Are you sure you want to send this newsletter now?"),
() => {
frm.events.send_emails(frm);
}
);
},
__("Send")
);
frm.add_custom_button(
__("Schedule sending"),
() => {
frm.events.schedule_send_dialog(frm);
},
__("Send")
);
}
frm.events.update_sending_status(frm);
if (frm.is_new() && !doc.sender_email) {
let { fullname, email } = frappe.user_info(doc.owner);
frm.set_value("sender_email", email);
frm.set_value("sender_name", fullname);
}
frm.trigger("update_schedule_message");
},
send_emails(frm) {
frappe.dom.freeze(__("Queuing emails..."));
frm.call("send_emails").then(() => {
frm.refresh();
frappe.dom.unfreeze();
frappe.show_alert(
__("Queued {0} emails", [frappe.utils.shorten_number(frm.doc.total_recipients)])
);
});
},
schedule_send_dialog(frm) {
let hours = frappe.utils.range(24);
let time_slots = hours.map((hour) => {
return `${(hour + "").padStart(2, "0")}:00`;
});
let d = new frappe.ui.Dialog({
title: __("Schedule Newsletter"),
fields: [
{
label: __("Date"),
fieldname: "date",
fieldtype: "Date",
options: {
minDate: new Date(),
},
reqd: true,
},
{
label: __("Time"),
fieldname: "time",
fieldtype: "Select",
options: time_slots,
reqd: true,
},
],
primary_action_label: __("Schedule"),
primary_action({ date, time }) {
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) {
let d = new frappe.ui.Dialog({
title: __("Send Test Email"),
fields: [
{
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();
},
async update_sending_status(frm) {
if (frm.doc.email_sent && frm.$wrapper.is(":visible") && !frm.waiting_for_request) {
frm.waiting_for_request = true;
let res = await frm.call("get_sending_status");
frm.waiting_for_request = false;
let stats = res.message;
stats && frm.events.update_sending_progress(frm, stats);
if (
stats.sent + stats.error >= frm.doc.total_recipients ||
(!stats.total && !stats.emails_queued)
) {
frm.sending_status && clearInterval(frm.sending_status);
frm.sending_status = null;
return;
}
}
if (frm.sending_status) return;
frm.sending_status = setInterval(() => frm.events.update_sending_status(frm), 5000);
},
update_sending_progress(frm, stats) {
if (stats.sent + stats.error >= frm.doc.total_recipients || !frm.doc.email_sent) {
frm.doc.email_sent && frm.page.set_indicator(__("Sent"), "green");
frm.dashboard.hide_progress();
return;
}
if (stats.total) {
frm.page.set_indicator(__("Sending"), "blue");
frm.dashboard.show_progress(
__("Sending emails"),
(stats.sent * 100) / frm.doc.total_recipients,
__("{0} of {1} sent", [stats.sent, frm.doc.total_recipients])
);
} else if (stats.emails_queued) {
frm.page.set_indicator(__("Queued"), "blue");
}
},
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

@ -1,282 +0,0 @@
{
"actions": [],
"allow_guest_to_view": 1,
"allow_rename": 1,
"creation": "2013-01-10 16:34:31",
"description": "Create and send emails to a specific group of subscribers periodically.",
"doctype": "DocType",
"document_type": "Other",
"engine": "InnoDB",
"field_order": [
"status_section",
"email_sent_at",
"column_break_3",
"total_recipients",
"column_break_12",
"total_views",
"email_sent",
"from_section",
"sender_name",
"column_break_5",
"sender_email",
"column_break_7",
"send_from",
"recipients",
"email_group",
"subject_section",
"subject",
"newsletter_content",
"content_type",
"message",
"message_md",
"message_html",
"campaign",
"attachments",
"send_unsubscribe_link",
"send_webview_link",
"schedule_settings_section",
"scheduled_to_send",
"schedule_sending",
"schedule_send",
"publish_as_a_web_page_section",
"published",
"route"
],
"fields": [
{
"fieldname": "email_group",
"fieldtype": "Table",
"in_standard_filter": 1,
"label": "Audience",
"options": "Newsletter Email Group",
"reqd": 1
},
{
"fieldname": "send_from",
"fieldtype": "Data",
"ignore_xss_filter": 1,
"label": "Sender",
"read_only": 1
},
{
"default": "0",
"fieldname": "email_sent",
"fieldtype": "Check",
"hidden": 1,
"label": "Email Sent",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "newsletter_content",
"fieldtype": "Section Break",
"label": "Content"
},
{
"fieldname": "subject",
"fieldtype": "Small Text",
"in_global_search": 1,
"in_list_view": 1,
"label": "Subject",
"reqd": 1
},
{
"depends_on": "eval: doc.content_type === 'Rich Text'",
"fieldname": "message",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Message",
"mandatory_depends_on": "eval: doc.content_type === 'Rich Text'"
},
{
"default": "1",
"fieldname": "send_unsubscribe_link",
"fieldtype": "Check",
"label": "Send Unsubscribe Link"
},
{
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"label": "Published"
},
{
"depends_on": "published",
"fieldname": "route",
"fieldtype": "Data",
"label": "Route",
"read_only": 1
},
{
"fieldname": "scheduled_to_send",
"fieldtype": "Int",
"hidden": 1,
"label": "Scheduled To Send"
},
{
"fieldname": "recipients",
"fieldtype": "Section Break",
"label": "To"
},
{
"depends_on": "eval: doc.schedule_sending",
"fieldname": "schedule_send",
"fieldtype": "Datetime",
"label": "Send Email At",
"read_only": 1,
"read_only_depends_on": "eval: doc.email_sent"
},
{
"fieldname": "content_type",
"fieldtype": "Select",
"label": "Content Type",
"options": "Rich Text\nMarkdown\nHTML"
},
{
"depends_on": "eval:doc.content_type === 'Markdown'",
"fieldname": "message_md",
"fieldtype": "Markdown Editor",
"label": "Message (Markdown)",
"mandatory_depends_on": "eval:doc.content_type === 'Markdown'"
},
{
"depends_on": "eval:doc.content_type === 'HTML'",
"fieldname": "message_html",
"fieldtype": "HTML Editor",
"label": "Message (HTML)",
"mandatory_depends_on": "eval:doc.content_type === 'HTML'"
},
{
"default": "0",
"fieldname": "schedule_sending",
"fieldtype": "Check",
"label": "Schedule sending at a later time",
"read_only_depends_on": "eval: doc.email_sent"
},
{
"default": "0",
"fieldname": "send_webview_link",
"fieldtype": "Check",
"label": "Send Web View Link"
},
{
"fieldname": "from_section",
"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"
},
{
"default": "0",
"fieldname": "total_views",
"fieldtype": "Int",
"label": "Total Views",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "campaign",
"fieldtype": "Link",
"label": "Campaign",
"options": "UTM Campaign"
}
],
"has_web_view": 1,
"icon": "fa fa-envelope",
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"make_attachments_public": 1,
"modified": "2024-11-12 12:41:02.569631",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Newsletter Manager",
"share": 1,
"write": 1
}
],
"route": "newsletters",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "subject",
"track_changes": 1
}

View file

@ -1,457 +0,0 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
import frappe
import frappe.utils
from frappe import _
from frappe.email.doctype.email_group.email_group import add_subscribers
from frappe.rate_limiter import rate_limit
from frappe.utils.safe_exec import is_job_queued
from frappe.utils.verified_command import get_signed_params, verify_request
from frappe.website.website_generator import WebsiteGenerator
from .exceptions import NewsletterAlreadySentError, NewsletterNotSavedError, NoRecipientFoundError
class Newsletter(WebsiteGenerator):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.email.doctype.newsletter_attachment.newsletter_attachment import NewsletterAttachment
from frappe.email.doctype.newsletter_email_group.newsletter_email_group import NewsletterEmailGroup
from frappe.types import DF
attachments: DF.Table[NewsletterAttachment]
campaign: DF.Link | None
content_type: DF.Literal["Rich Text", "Markdown", "HTML"]
email_group: DF.Table[NewsletterEmailGroup]
email_sent: DF.Check
email_sent_at: DF.Datetime | None
message: DF.TextEditor | None
message_html: DF.HTMLEditor | None
message_md: DF.MarkdownEditor | None
published: DF.Check
route: DF.Data | None
schedule_send: DF.Datetime | None
schedule_sending: DF.Check
scheduled_to_send: DF.Int
send_from: DF.Data | None
send_unsubscribe_link: DF.Check
send_webview_link: DF.Check
sender_email: DF.Data
sender_name: DF.Data | None
subject: DF.SmallText
total_recipients: DF.Int
total_views: DF.Int
# end: auto-generated types
def validate(self):
self.route = f"newsletters/{self.name}"
self.validate_sender_address()
self.validate_publishing()
self.validate_scheduling_date()
@property
def newsletter_recipients(self) -> list[str]:
if getattr(self, "_recipients", None) is None:
self._recipients = self.get_recipients()
return self._recipients
@frappe.whitelist()
def get_sending_status(self):
count_by_status = frappe.get_all(
"Email Queue",
filters={"reference_doctype": self.doctype, "reference_name": self.name},
fields=["status", "count(name) as count"],
group_by="status",
order_by="status",
)
sent = 0
error = 0
total = 0
for row in count_by_status:
if row.status == "Sent":
sent = row.count
elif row.status == "Error":
error = row.count
total += row.count
emails_queued = is_job_queued(
job_name=frappe.utils.get_job_name("send_bulk_emails_for", self.doctype, self.name),
queue="long",
)
return {"sent": sent, "error": error, "total": total, "emails_queued": emails_queued}
@frappe.whitelist()
def send_test_email(self, email):
test_emails = frappe.utils.validate_email_address(email, throw=True)
self.send_newsletter(emails=test_emails, test_email=True)
frappe.msgprint(_("Test email sent to {0}").format(email), alert=True)
@frappe.whitelist()
def find_broken_links(self):
import requests
from bs4 import BeautifulSoup
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 Exception:
broken_links.append(url)
return broken_links
@frappe.whitelist()
def send_emails(self):
"""queue sending emails to recipients"""
self.schedule_sending = False
self.schedule_send = None
self.queue_all()
def validate_send(self):
"""Validate if Newsletter can be sent."""
self.validate_newsletter_status()
self.validate_newsletter_recipients()
def validate_newsletter_status(self):
if self.email_sent:
frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError)
if self.get("__islocal"):
frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError)
def validate_newsletter_recipients(self):
if not self.newsletter_recipients:
frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError)
def validate_sender_address(self):
"""Validate self.send_from is a valid email address or not."""
if self.sender_email:
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_publishing(self):
if self.send_webview_link and not self.published:
frappe.throw(_("Newsletter must be published to send webview link in email"))
def validate_scheduling_date(self):
if getattr(frappe.flags, "is_scheduler_running", False):
return
if (
self.schedule_sending
and frappe.utils.get_datetime(self.schedule_send) < frappe.utils.now_datetime()
):
frappe.throw(_("Past dates are not allowed for Scheduling."))
def get_linked_email_queue(self) -> list[str]:
"""Get list of email queue linked to this newsletter."""
return frappe.get_all(
"Email Queue",
filters={
"reference_doctype": self.doctype,
"reference_name": self.name,
},
pluck="name",
)
def get_queued_recipients(self) -> list[str]:
"""Recipients who have already been queued for receiving the newsletter."""
return frappe.get_all(
"Email Queue Recipient",
filters={
"parent": ("in", self.get_linked_email_queue()),
},
pluck="recipient",
)
def get_pending_recipients(self) -> list[str]:
"""Get list of pending recipients of the newsletter. These
recipients may not have receive the newsletter in the previous iteration.
"""
queued_recipients = set(self.get_queued_recipients())
return [x for x in self.newsletter_recipients if x not in queued_recipients]
def queue_all(self):
"""Queue Newsletter to all the recipients generated from the `Email Group` table"""
self.validate()
self.validate_send()
recipients = self.get_pending_recipients()
self.send_newsletter(emails=recipients)
self.email_sent = True
self.email_sent_at = frappe.utils.now()
self.total_recipients = len(recipients)
self.save()
def get_newsletter_attachments(self) -> list[dict[str, str]]:
"""Get list of attachments on current Newsletter"""
return [{"file_url": row.attachment} for row in self.attachments]
def send_newsletter(self, emails: list[str], test_email: bool = False):
"""Trigger email generation for `emails` and add it in Email Queue."""
attachments = self.get_newsletter_attachments()
sender = self.send_from or frappe.utils.get_formatted_email(self.owner)
args = self.as_dict()
args["message"] = self.get_message(medium="email")
is_auto_commit_set = bool(frappe.db.auto_commit_on_many_writes)
frappe.db.auto_commit_on_many_writes = not frappe.in_test
frappe.sendmail(
subject=self.subject,
sender=sender,
recipients=emails,
attachments=attachments,
template="newsletter",
add_unsubscribe_link=self.send_unsubscribe_link,
unsubscribe_method="/unsubscribe",
reference_doctype=self.doctype,
reference_name=self.name,
queue_separately=True,
send_priority=0,
args=args,
email_read_tracker_url=None
if test_email
else "/api/method/frappe.email.doctype.newsletter.newsletter.newsletter_email_read",
)
frappe.db.auto_commit_on_many_writes = is_auto_commit_set
def get_message(self, medium=None) -> str:
message = self.message
if self.content_type == "Markdown":
message = frappe.utils.md_to_html(self.message_md)
if self.content_type == "HTML":
message = self.message_html
html = frappe.render_template(message, {"doc": self.as_dict()})
return self.add_source(html, medium=medium)
def add_source(self, html: str, medium="None") -> str:
"""Add source to the site links in the newsletter content."""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
links = soup.find_all("a")
for link in links:
href = link.get("href")
if href and not href.startswith("#"):
if not frappe.utils.is_site_link(href):
continue
new_href = frappe.utils.add_trackers_to_url(
href, source="Newsletter", campaign=self.campaign, medium=medium
)
link["href"] = new_href
return str(soup)
def get_recipients(self) -> list[str]:
"""Get recipients from Email Group"""
emails = frappe.get_all(
"Email Group Member",
filters={"unsubscribed": 0, "email_group": ("in", self.get_email_groups())},
pluck="email",
)
return list(set(emails))
def get_email_groups(self) -> list[str]:
# wondering why the 'or'? i can't figure out why both aren't equivalent - @gavin
return [x.email_group for x in self.email_group] or frappe.get_all(
"Newsletter Email Group",
filters={"parent": self.name, "parenttype": "Newsletter"},
pluck="email_group",
)
def get_attachments(self) -> list[dict[str, str]]:
return frappe.get_all(
"File",
fields=["name", "file_name", "file_url", "is_private"],
filters={
"attached_to_name": self.name,
"attached_to_doctype": "Newsletter",
"is_private": 0,
},
)
def confirmed_unsubscribe(email, group):
"""unsubscribe the email(user) from the mailing list(email_group)"""
frappe.flags.ignore_permissions = True
doc = frappe.get_doc("Email Group Member", {"email": email, "email_group": group})
if not doc.unsubscribed:
doc.unsubscribed = 1
doc.save(ignore_permissions=True)
@frappe.whitelist(allow_guest=True)
@rate_limit(limit=10, seconds=60 * 60)
def subscribe(email, email_group=None):
"""API endpoint to subscribe an email to a particular email group. Triggers a confirmation email."""
if email_group is None:
email_group = get_default_email_group()
# build subscription confirmation URL
api_endpoint = frappe.utils.get_url(
"/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription"
)
signed_params = get_signed_params({"email": email, "email_group": email_group})
confirm_subscription_url = f"{api_endpoint}?{signed_params}"
# fetch custom template if available
email_confirmation_template = frappe.db.get_value(
"Email Group", email_group, "confirmation_email_template"
)
# build email and send
if email_confirmation_template:
args = {"email": email, "confirmation_url": confirm_subscription_url, "email_group": email_group}
email_template = frappe.get_doc("Email Template", email_confirmation_template)
email_subject = email_template.subject
content = frappe.render_template(email_template.response, args)
else:
email_subject = _("Confirm Your Email")
translatable_content = (
_("Thank you for your interest in subscribing to our updates"),
_("Please verify your Email Address"),
confirm_subscription_url,
_("Click here to verify"),
)
content = """
<p>{}. {}.</p>
<p><a href="{}">{}</a></p>
""".format(*translatable_content)
frappe.sendmail(
email,
subject=email_subject,
content=content,
)
@frappe.whitelist(allow_guest=True)
def confirm_subscription(email, email_group=None):
"""API endpoint to confirm email subscription.
This endpoint is called when user clicks on the link sent to their mail.
"""
if not verify_request():
return
if email_group is None:
email_group = get_default_email_group()
try:
group = frappe.get_doc("Email Group", email_group)
except frappe.DoesNotExistError:
group = frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert(
ignore_permissions=True
)
frappe.flags.ignore_permissions = True
add_subscribers(email_group, email)
frappe.db.commit()
welcome_url = group.get_welcome_url(email)
if welcome_url:
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = welcome_url
else:
frappe.respond_as_web_page(
_("Confirmed"),
_("{0} has been successfully added to the Email Group.").format(email),
indicator_color="green",
)
def get_list_context(context=None):
context.update(
{
"show_search": True,
"no_breadcrumbs": True,
"title": _("Newsletters"),
"filters": {"published": 1},
"row_template": "email/doctype/newsletter/templates/newsletter_row.html",
}
)
def send_scheduled_email():
"""Send scheduled newsletter to the recipients."""
frappe.flags.is_scheduler_running = True
scheduled_newsletter = frappe.get_all(
"Newsletter",
filters={
"schedule_send": ("<=", frappe.utils.now_datetime()),
"email_sent": False,
"schedule_sending": True,
},
ignore_ifnull=True,
pluck="name",
)
for newsletter_name in scheduled_newsletter:
try:
newsletter = frappe.get_doc("Newsletter", newsletter_name)
newsletter.queue_all()
except Exception:
frappe.db.rollback()
# wasn't able to send emails :(
frappe.db.set_value("Newsletter", newsletter_name, "email_sent", 0)
newsletter.log_error("Failed to send newsletter")
if not frappe.in_test:
frappe.db.commit()
frappe.flags.is_scheduler_running = False
@frappe.whitelist(allow_guest=True)
def newsletter_email_read(recipient_email=None, reference_doctype=None, reference_name=None):
if not (recipient_email and reference_name):
return
verify_request()
try:
doc = frappe.get_cached_doc("Newsletter", reference_name)
if doc.add_viewed(recipient_email, force=True, unique_views=True):
newsletter = frappe.qb.DocType("Newsletter")
(
frappe.qb.update(newsletter)
.set(newsletter.total_views, newsletter.total_views + 1)
.where(newsletter.name == doc.name)
).run()
except Exception:
frappe.log_error(
title=f"Unable to mark as viewed for {recipient_email}",
reference_doctype="Newsletter",
reference_name=reference_name,
)
finally:
frappe.response.update(frappe.utils.get_imaginary_pixel_response())
def get_default_email_group():
return _("Website", lang=frappe.db.get_default("language"))

View file

@ -1,12 +0,0 @@
frappe.listview_settings["Newsletter"] = {
add_fields: ["subject", "email_sent", "schedule_sending"],
get_indicator: function (doc) {
if (doc.email_sent) {
return [__("Sent"), "green", "email_sent,=,1"];
} else if (doc.schedule_sending) {
return [__("Scheduled"), "purple", "email_sent,=,0|schedule_sending,=,1"];
} else {
return [__("Not Sent"), "gray", "email_sent,=,0"];
}
},
};

View file

@ -1,65 +0,0 @@
{% extends "templates/web.html" %}
{% block title %} {{ doc.subject }} {% endblock %}
{% block page_content %}
<style>
.blog-container {
max-width: 720px;
margin: auto;
}
.blog-header {
font-weight: 700;
font-size: 1.5em;
}
.blog-info {
text-align:center;
margin-top: 30px;
}
.blog-text {
padding-top: 50px;
padding-bottom: 50px;
font-size: 15px;
line-height: 1.5;
}
.blog-text p {
margin-bottom: 30px;
}
</style>
<div class="blog-container">
<article class="blog-content" itemscope>
<div class="blog-info">
<h1 itemprop="headline" class="blog-header">{{ doc.subject }}</h1>
<p class="post-by text-muted">
{{ frappe.format_date(doc.modified) }}
</p>
</div>
<div itemprop="articleBody" class="longform blog-text">
{{ doc.get_message(medium="web_page") }}
</div>
</article>
{% if doc.attachments %}
<div>
<div class="row text-muted">
<div class="col-sm-12 h6 text-uppercase">
{{ _("Attachments") }}
</div>
</div>
<div class="row">
<div class="col-sm-12">
{% for attachment in doc.attachments %}
<p class="small">
<a href="{{ attachment.attachment }}" target="_blank">
{{ attachment.attachment }}
</a>
</p>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -1,15 +0,0 @@
<div class="web-list-item transaction-list-item">
<a href = "{{ route }}/">
<div class="row">
<div class="col-sm-8 text-left bold">
{{ doc.subject }}
</div>
<div class="col-sm-4">
<div class="text-muted text-right"
title="{{ frappe.utils.format_datetime(doc.modified, "medium") }}">
{{ frappe.utils.pretty_date(doc.modified) }}
</div>
</div>
</div>
</a>
</div>

View file

@ -1,252 +0,0 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
from random import choice
from unittest.mock import MagicMock, PropertyMock, patch
import frappe
from frappe.email.doctype.newsletter.exceptions import (
NewsletterAlreadySentError,
NoRecipientFoundError,
)
from frappe.email.doctype.newsletter.newsletter import (
Newsletter,
confirmed_unsubscribe,
send_scheduled_email,
)
from frappe.email.queue import flush
from frappe.tests import IntegrationTestCase
from frappe.utils import add_days, getdate
emails = [
"test_subscriber1@example.com",
"test_subscriber2@example.com",
"test_subscriber3@example.com",
"test1@example.com",
]
newsletters = []
def get_dotted_path(obj: type) -> str:
klass = obj.__class__
module = klass.__module__
if module == "builtins":
return klass.__qualname__ # avoid outputs like 'builtins.str'
return f"{module}.{klass.__qualname__}"
class TestNewsletterMixin:
def setUp(self):
frappe.set_user("Administrator")
self.setup_email_group()
def tearDown(self):
frappe.set_user("Administrator")
for newsletter in newsletters:
frappe.db.delete(
"Email Queue",
{
"reference_doctype": "Newsletter",
"reference_name": newsletter,
},
)
frappe.delete_doc("Newsletter", newsletter)
frappe.db.delete("Newsletter Email Group", {"parent": newsletter})
newsletters.remove(newsletter)
def setup_email_group(self):
if not frappe.db.exists("Email Group", "_Test Email Group"):
frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert()
for email in emails:
doctype = "Email Group Member"
email_filters = {"email": email, "email_group": "_Test Email Group"}
savepoint = "setup_email_group"
frappe.db.savepoint(savepoint)
try:
frappe.get_doc(
{
"doctype": doctype,
**email_filters,
}
).insert(ignore_if_duplicate=True)
except Exception:
frappe.db.rollback(save_point=savepoint)
frappe.db.set_value(doctype, email_filters, "unsubscribed", 0)
frappe.db.release_savepoint(savepoint)
def send_newsletter(self, published=0, schedule_send=None) -> str | None:
frappe.db.delete("Email Queue")
frappe.db.delete("Email Queue Recipient")
frappe.db.delete("Newsletter")
newsletter_options = {
"published": published,
"schedule_sending": bool(schedule_send),
"schedule_send": schedule_send,
}
newsletter = self.get_newsletter(**newsletter_options)
if schedule_send:
send_scheduled_email()
else:
newsletter.send_emails()
return newsletter.name
return newsletter
@staticmethod
def get_newsletter(**kwargs) -> "Newsletter":
"""Generate and return Newsletter object"""
doctype = "Newsletter"
newsletter_content = {
"subject": "_Test Newsletter",
"sender_name": "Test Sender",
"sender_email": "test_sender@example.com",
"content_type": "Rich Text",
"message": "Testing my news.",
}
similar_newsletters = frappe.get_all(doctype, newsletter_content, pluck="name")
for similar_newsletter in similar_newsletters:
frappe.delete_doc(doctype, similar_newsletter)
newsletter = frappe.get_doc({"doctype": doctype, **newsletter_content, **kwargs})
newsletter.append("email_group", {"email_group": "_Test Email Group"})
newsletter.save(ignore_permissions=True)
newsletter.reload()
newsletters.append(newsletter.name)
attached_files = frappe.get_all(
"File",
{
"attached_to_doctype": newsletter.doctype,
"attached_to_name": newsletter.name,
},
pluck="name",
)
for file in attached_files:
frappe.delete_doc("File", file)
return newsletter
class TestNewsletter(TestNewsletterMixin, IntegrationTestCase):
def test_send(self):
self.send_newsletter()
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4)
recipients = {e.recipients[0].recipient for e in email_queue_list}
self.assertTrue(set(emails).issubset(recipients))
def test_unsubscribe(self):
name = self.send_newsletter()
to_unsubscribe = choice(emails)
group = frappe.get_all("Newsletter Email Group", filters={"parent": name}, fields=["email_group"])
flush()
confirmed_unsubscribe(to_unsubscribe, group[0].email_group)
name = self.send_newsletter()
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 3)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
if email != to_unsubscribe:
self.assertTrue(email in recipients)
def test_schedule_send(self):
newsletter = self.send_newsletter(schedule_send=add_days(getdate(), 1))
newsletter.db_set("schedule_send", add_days(getdate(), -1)) # Set date in past
send_scheduled_email()
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4)
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
self.assertTrue(email in recipients)
def test_newsletter_send_test_email(self):
"""Test "Send Test Email" functionality of Newsletter"""
newsletter = self.get_newsletter()
test_email = choice(emails)
newsletter.send_test_email(test_email)
self.assertFalse(newsletter.email_sent)
newsletter.save = MagicMock()
self.assertFalse(newsletter.save.called)
# check if the test email is in the queue
email_queue = frappe.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):
"""Test for Newsletter's stats on onload event"""
newsletter = self.get_newsletter()
newsletter.email_sent = True
result = newsletter.get_sending_status()
self.assertTrue("total" in result)
self.assertTrue("sent" in result)
def test_already_sent_newsletter(self):
newsletter = self.get_newsletter()
newsletter.send_emails()
with self.assertRaises(NewsletterAlreadySentError):
newsletter.send_emails()
def test_newsletter_with_no_recipient(self):
newsletter = self.get_newsletter()
property_path = f"{get_dotted_path(newsletter)}.newsletter_recipients"
with patch(property_path, new_callable=PropertyMock) as mock_newsletter_recipients:
mock_newsletter_recipients.return_value = []
with self.assertRaises(NoRecipientFoundError):
newsletter.send_emails()
def test_send_scheduled_email_error_handling(self):
newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1))
job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all"
m = MagicMock(side_effect=frappe.OutgoingEmailError)
with self.assertRaises(frappe.OutgoingEmailError):
with patch(job_path, new_callable=m):
send_scheduled_email()
newsletter.reload()
self.assertEqual(newsletter.email_sent, 0)
def test_retry_partially_sent_newsletter(self):
frappe.db.delete("Email Queue")
frappe.db.delete("Email Queue Recipient")
frappe.db.delete("Newsletter")
newsletter = self.get_newsletter()
newsletter.send_emails()
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4)
# delete a queue document to emulate partial send
queue_recipient_name = email_queue_list[0].recipients[0].recipient
email_queue_list[0].delete()
newsletter.email_sent = False
# make sure the pending recipient is only the one which has been deleted
self.assertEqual(newsletter.get_pending_recipients(), [queue_recipient_name])
# retry
newsletter.send_emails()
self.assertEqual(frappe.db.count("Email Queue"), 4)
self.assertTrue(newsletter.email_sent)

View file

@ -1,43 +0,0 @@
{
"actions": [],
"creation": "2017-02-26 16:20:52.654136",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"email_group",
"total_subscribers"
],
"fields": [
{
"columns": 7,
"fieldname": "email_group",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Email Group",
"options": "Email Group",
"reqd": 1
},
{
"columns": 3,
"fetch_from": "email_group.total_subscribers",
"fieldname": "total_subscribers",
"fieldtype": "Read Only",
"in_list_view": 1,
"label": "Total Subscribers"
}
],
"istable": 1,
"links": [],
"modified": "2024-03-23 16:03:31.190219",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter Email Group",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -205,9 +205,12 @@ frappe.ui.form.on("Notification", {
return dialog;
});
}
frm.trigger("set_up_filters_editor");
},
document_type: function (frm) {
frappe.notification.setup_fieldname_select(frm);
frm.trigger("set_up_filters_editor");
},
view_properties: function (frm) {
frappe.route_options = { doc_type: frm.doc.document_type };
@ -246,4 +249,41 @@ frappe.ui.form.on("Notification", {
frm.set_df_property("channel", "description", ` `);
}
},
condition_type: function (frm) {
if (frm.doc.condition_type === "Filters") {
frm.set_value("condition", "");
} else {
frm.set_value("filters", "");
}
frm.trigger("set_up_filters_editor");
},
set_up_filters_editor(frm) {
const parent = frm.get_field("filters_editor").$wrapper;
parent.empty();
if (!frm.doc.document_type || frm.doc.condition_type !== "Filters") {
return;
}
const filters =
frm.doc.filters && frm.doc.filters !== "[]" ? JSON.parse(frm.doc.filters) : [];
frappe.model.with_doctype(frm.doc.document_type, () => {
const filter_group = new frappe.ui.FilterGroup({
parent: parent,
doctype: frm.doc.document_type,
on_change: () => {
frappe.model.set_value(
frm.doc.doctype,
frm.doc.name,
"filters",
JSON.stringify(filter_group.get_filters())
);
},
});
filter_group.add_filters_to_filter_group(filters);
});
},
});

View file

@ -13,7 +13,7 @@
"column_break_2",
"channel",
"slack_webhook_url",
"filters",
"filters_section",
"subject",
"event",
"document_type",
@ -29,7 +29,10 @@
"send_system_notification",
"sender_email",
"section_break_9",
"condition_type",
"filters_editor",
"condition",
"filters",
"column_break_6",
"html_7",
"property_section",
@ -79,8 +82,11 @@
},
{
"fieldname": "filters",
"fieldtype": "Section Break",
"label": "Filters"
"fieldtype": "Code",
"hidden": 1,
"label": "Filters",
"options": "JSON",
"read_only": 1
},
{
"depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)",
@ -178,6 +184,7 @@
"fieldtype": "Section Break"
},
{
"depends_on": "eval:doc.condition_type===\"Python\"",
"description": "Optional: The alert will be sent if this expression is true",
"fieldname": "condition",
"fieldtype": "Code",
@ -190,6 +197,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.condition_type===\"Python\"",
"fieldname": "html_7",
"fieldtype": "HTML",
"options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total &gt; 40000\n</pre>\n"
@ -311,12 +319,31 @@
"fieldtype": "Datetime",
"label": "Last Run",
"read_only": 1
},
{
"default": "Python",
"fieldname": "condition_type",
"fieldtype": "Select",
"label": "Condition Type",
"options": "Python\nFilters"
},
{
"depends_on": "eval:doc.condition_type===\"Filters\"",
"fieldname": "filters_editor",
"fieldtype": "HTML",
"label": "Filters Editor"
},
{
"fieldname": "filters_section",
"fieldtype": "Section Break",
"label": "Filters"
}
],
"grid_page_length": 50,
"icon": "fa fa-envelope",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-12 10:28:35.077180",
"modified": "2025-05-10 21:03:15.561558",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",
@ -334,9 +361,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "subject",
"track_changes": 1
}
}

View file

@ -15,6 +15,7 @@ from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send
from frappe.model.document import Document
from frappe.modules.utils import export_module_json, get_doc_module
from frappe.utils import add_to_date, cast, now_datetime, nowdate, validate_email_address
from frappe.utils.data import evaluate_filters
from frappe.utils.jinja import validate_template
from frappe.utils.safe_exec import get_safe_globals
@ -36,6 +37,7 @@ class Notification(Document):
attach_print: DF.Check
channel: DF.Literal["Email", "Slack", "System Notification", "SMS"]
condition: DF.Code | None
condition_type: DF.Literal["Python", "Filters"]
date_changed: DF.Literal[None]
datetime_changed: DF.Literal[None]
datetime_last_run: DF.Datetime | None
@ -56,6 +58,7 @@ class Notification(Document):
"Method",
"Custom",
]
filters: DF.Code | None
is_standard: DF.Check
message: DF.Code | None
message_type: DF.Literal["Markdown", "HTML", "Plain Text"]
@ -88,12 +91,15 @@ class Notification(Document):
@frappe.whitelist()
def preview_meets_condition(self, preview_document):
if not self.condition:
if not self.condition and not self.filters:
return _("Yes")
try:
doc = frappe.get_cached_doc(self.document_type, preview_document)
context = get_context(doc)
return _("Yes") if frappe.safe_eval(self.condition, eval_locals=context) else _("No")
if self.condition_type == "Python":
context = get_context(doc)
return _("Yes") if frappe.safe_eval(self.condition, eval_locals=context) else _("No")
elif self.condition_type == "Filters":
return _("Yes") if evaluate_filters(doc, json.loads(self.filters)) else _("No")
except Exception as e:
frappe.local.message_log = []
return _("Failed to evaluate conditions: {}").format(e)
@ -135,6 +141,9 @@ class Notification(Document):
# END: PreviewRenderer API
def before_save(self):
self.remove_invalid_condition()
def validate(self):
if self.channel in ("Email", "Slack", "System Notification"):
validate_template(self.subject)
@ -159,6 +168,7 @@ class Notification(Document):
self.validate_forbidden_document_types()
self.validate_condition()
self.validate_filters()
self.validate_standard()
clear_notification_cache()
@ -192,13 +202,29 @@ def get_context(context):
_("Cannot edit Standard Notification. To edit, please disable this and duplicate it")
)
def remove_invalid_condition(self):
if self.condition_type == "Filters":
self.condition = None
elif self.condition_type == "Python":
self.filters = None
def validate_condition(self):
if not self.condition:
return
temp_doc = frappe.new_doc(self.document_type)
if self.condition:
try:
frappe.safe_eval(self.condition, None, get_context(temp_doc.as_dict()))
except Exception:
frappe.throw(_("The Condition '{0}' is invalid").format(self.condition))
try:
frappe.safe_eval(self.condition, None, get_context(temp_doc.as_dict()))
except Exception:
frappe.throw(_("The Condition '{0}' is invalid").format(self.condition))
def validate_filters(self):
if not self.filters:
return
filters = json.loads(self.filters)
dummy_doc = frappe.new_doc(self.document_type)
evaluate_filters(dummy_doc, filters)
def validate_forbidden_document_types(self):
if self.document_type in FORBIDDEN_DOCUMENT_TYPES or (
@ -232,10 +258,18 @@ def get_context(context):
],
)
filters = json.loads(self.filters) if self.condition_type == "Filters" and self.filters else None
for d in doc_list:
doc = frappe.get_lazy_doc(self.document_type, d.name)
if self.condition and not frappe.safe_eval(self.condition, None, get_context(doc)):
if (
self.condition_type == "Python"
and self.condition
and not frappe.safe_eval(self.condition, None, get_context(doc))
):
continue
elif filters and not evaluate_filters(doc, filters):
continue
docs.append(doc)
@ -281,10 +315,18 @@ def get_context(context):
self.db_set("datetime_last_run", now) # set reference now for next run
filters = json.loads(self.filters) if self.condition_type == "Filters" and self.filters else None
for d in doc_list:
doc = frappe.get_lazy_doc(self.document_type, d.name)
if self.condition and not frappe.safe_eval(self.condition, None, get_context(doc)):
if (
self.condition_type == "Python"
and self.condition
and not frappe.safe_eval(self.condition, None, get_context(doc))
):
continue
elif filters and not evaluate_filters(doc, filters):
continue
docs.append(doc)
@ -705,9 +747,12 @@ def evaluate_alert(doc: Document, alert, event=None):
context = get_context(doc)
if alert.condition:
if alert.condition_type == "Python" and alert.condition:
if not frappe.safe_eval(alert.condition, None, context):
return
elif alert.condition_type == "Filters" and alert.filters:
if not evaluate_filters(doc, json.loads(alert.filters)):
return
if event == "Value Change" and not doc.is_new():
if not frappe.db.has_column(doc.doctype, alert.value_changed):

View file

@ -1,8 +1,8 @@
# Copyright (c) 2018, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import json
from contextlib import contextmanager
from datetime import timedelta
import frappe
import frappe.utils
@ -500,6 +500,36 @@ class TestNotification(IntegrationTestCase):
self.assertTrue("test1@example.com" in recipients)
self.assertEqual(notification.enabled, 1)
def test_filters_condition(self):
"""Test Notification with Condition Type 'Filters'."""
frappe.delete_doc_if_exists("Notification", "Test Filters Condition")
notification = frappe.new_doc("Notification")
notification.name = "Test Filters Condition"
notification.subject = "Test Filters Condition"
notification.document_type = "ToDo"
notification.event = "Save"
notification.condition_type = "Filters"
notification.filters = json.dumps([["status", "=", "Open"]])
notification.message = "Test message"
notification.channel = "Email"
notification.append("recipients", {"receiver_by_document_field": "allocated_to"})
notification.save()
todo = frappe.new_doc("ToDo")
todo.description = "Checking email notification with filters condition"
todo.allocated_to = "test1@example.com"
todo.save()
email_queue = frappe.get_doc(
"Email Queue", {"reference_doctype": "ToDo", "reference_name": todo.name}
)
self.assertTrue(email_queue)
recipients = [d.recipient for d in email_queue.recipients]
self.assertTrue("test1@example.com" in recipients)
self.assertEqual(notification.enabled, 1)
# ruff: noqa: RUF001
"""
@ -520,7 +550,7 @@ Ran 3 tests in 2.677s
OK
"""
# from datetime import timedelta
# from frappe.utils import add_to_date, now_datetime
# class TestNotificationOffsetRange(IntegrationTestCase):
# def setUp(self):

View file

@ -1,5 +1,6 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from datetime import timedelta
import frappe
from frappe import _, msgprint
@ -177,3 +178,27 @@ def get_queue():
{"now": now_datetime()},
as_dict=True,
)
def retry_sending_emails():
emails_in_sending = frappe.get_all(
"Email Queue", filters={"status": "Sending"}, fields=["name", "modified"]
)
for e in emails_in_sending:
if now_datetime() - e["modified"] > timedelta(minutes=15):
update_fields = {}
email_queue = frappe.get_doc("Email Queue", e["name"])
sent_to_atleast_one_recipient = any(
rec.recipient for rec in email_queue.recipients if rec.is_mail_sent()
)
if email_queue.retry < cint(frappe.db.get_system_setting("email_retry_limit")) or 3:
update_fields.update(
{
"status": "Partially Sent" if sent_to_atleast_one_recipient else "Not Sent",
"retry": email_queue.retry + 1,
}
)
else:
update_fields.update({"status": "Error"})
update_fields.update({"error": "Retry Limit Exceeded"})
email_queue.update_status(**update_fields, commit=True)

View file

@ -162,10 +162,14 @@ class EmailServer:
return res[0] == "OK" # The folder exists TODO: handle other responses too
def logout(self):
if cint(self.settings.use_imap):
self.imap.logout()
else:
self.pop.quit()
try:
if cint(self.settings.use_imap):
self.imap.logout()
else:
self.pop.quit()
except imaplib.IMAP4.abort:
self.connect()
self.logout()
return
def get_messages(self, folder="INBOX"):
@ -846,6 +850,9 @@ class InboundMail(Email):
if email_fields.sender_name_field:
parent.set(email_fields.sender_name_field, frappe.as_unicode(self.from_real_name))
if email_fields.recipient_account_field:
parent.set(email_fields.recipient_account_field, self.email_account.name)
parent.flags.ignore_mandatory = True
try:
@ -887,7 +894,7 @@ class InboundMail(Email):
"""Return Email related fields of a doctype."""
fields = frappe._dict()
email_fields = ["subject_field", "sender_field", "sender_name_field"]
email_fields = ["subject_field", "sender_field", "sender_name_field", "recipient_account_field"]
meta = frappe.get_meta(doctype)
for field in email_fields:

View file

@ -47,11 +47,9 @@ class TestSMTP(IntegrationTestCase):
password="password",
enable_outgoing=1,
default_outgoing=1,
append_to="Blog Post",
)
self.assertEqual(
EmailAccount.find_outgoing(match_by_doctype="Blog Post").email_id, "append_to@gmail.com"
append_to="Todo",
)
self.assertEqual(EmailAccount.find_outgoing(match_by_doctype="Todo").email_id, "append_to@gmail.com")
# add back the mail_server
frappe.conf["mail_server"] = mail_server

View file

@ -107,12 +107,23 @@ class FrappeClient:
headers=self.headers,
)
def get_list(self, doctype, fields='["name"]', filters=None, limit_start=0, limit_page_length=None):
def get_list(
self,
doctype,
fields='["name"]',
filters=None,
limit_start=0,
limit_page_length=None,
order_by=None,
group_by=None,
):
"""Return list of records of a particular type."""
if not isinstance(fields, str):
fields = json.dumps(fields)
params = {
"fields": fields,
"order_by": order_by,
"group_by": group_by,
}
if filters:
params["filters"] = json.dumps(filters)

View file

@ -68,6 +68,16 @@ def extract(fileobj, *args, **kwargs):
for shortcut in data.get("shortcuts", [])
if shortcut.get("format")
)
yield from (
(
None,
"_",
quick_list.get("label"),
[f"Label of a quick_list in the {workspace_name} Workspace"],
)
for quick_list in data.get("quick_lists", [])
if quick_list.get("label")
)
content = json.loads(data.get("content", "[]"))
for item in content:

View file

@ -3,7 +3,7 @@ import os
from . import __version__ as app_version
app_name = "frappe"
app_title = "Framework"
app_title = "Frappe Framework"
app_publisher = "Frappe Technologies"
app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node"
app_license = "MIT"
@ -54,19 +54,13 @@ web_include_icons = [
email_css = ["email.bundle.css"]
website_route_rules = [
{"from_route": "/blog/<category>", "to_route": "Blog Post"},
{"from_route": "/kb/<category>", "to_route": "Help Article"},
{"from_route": "/newsletters", "to_route": "Newsletter"},
{"from_route": "/profile", "to_route": "me"},
{"from_route": "/app/<path:app_path>", "to_route": "app"},
]
website_redirects = [
{"source": r"/desk(.*)", "target": r"/app\1"},
{
"source": "/.well-known/openid-configuration",
"target": "/api/method/frappe.integrations.oauth2.openid_configuration",
},
]
base_template = "templates/base.html"
@ -116,6 +110,7 @@ permission_query_conditions = {
"Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions",
"Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.get_permission_query_condition",
"File": "frappe.core.doctype.file.file.get_permission_query_conditions",
"User Invitation": "frappe.core.doctype.user_invitation.user_invitation.get_permission_query_conditions",
}
has_permission = {
@ -133,6 +128,7 @@ has_permission = {
"File": "frappe.core.doctype.file.file.has_permission",
"Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.has_permission",
"Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.has_permission",
"User Invitation": "frappe.core.doctype.user_invitation.user_invitation.has_permission",
}
has_website_permission = {"Address": "frappe.contacts.doctype.address.address.has_website_permission"}
@ -158,6 +154,7 @@ doc_events = {
"frappe.automation.doctype.assignment_rule.assignment_rule.update_due_date",
"frappe.core.doctype.user_type.user_type.apply_permissions_for_non_standard_user_type",
"frappe.core.doctype.permission_log.permission_log.make_perm_log",
"frappe.search.sqlite_search.update_doc_index",
],
"after_rename": "frappe.desk.notifications.clear_doctype_notifications",
"on_cancel": [
@ -168,6 +165,7 @@ doc_events = {
"on_trash": [
"frappe.desk.notifications.clear_doctype_notifications",
"frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions",
"frappe.search.sqlite_search.delete_doc_index",
],
"on_update_after_submit": [
"frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions",
@ -210,6 +208,7 @@ scheduler_events = {
"frappe.deferred_insert.save_to_db",
"frappe.automation.doctype.reminder.reminder.send_reminders",
"frappe.model.utils.link_count.update_link_count",
"frappe.search.sqlite_search.build_index_if_not_exists",
],
# 10 minutes
"0/10 * * * *": [
@ -222,12 +221,11 @@ scheduler_events = {
},
"all": [
"frappe.email.queue.flush",
"frappe.email.queue.retry_sending_emails",
"frappe.monitor.flush",
"frappe.integrations.doctype.google_calendar.google_calendar.sync",
],
"hourly": [
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email",
],
"hourly": [],
# Maintenance queue happen roughly once an hour but don't align with wall-clock time of *:00
# Use these for when you don't care about when the job runs but just need some guarantee for
# frequency.
@ -254,6 +252,7 @@ scheduler_events = {
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record",
"frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry",
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up",
"frappe.core.doctype.user_invitation.user_invitation.mark_expired_invitations",
],
"weekly_long": [
"frappe.desk.form.document_follow.send_weekly_updates",
@ -282,7 +281,10 @@ setup_wizard_exception = [
]
before_migrate = ["frappe.core.doctype.patch_log.patch_log.before_migrate"]
after_migrate = ["frappe.website.doctype.website_theme.website_theme.after_migrate"]
after_migrate = [
"frappe.website.doctype.website_theme.website_theme.after_migrate",
"frappe.search.sqlite_search.build_index_in_background",
]
otp_methods = ["OTP App", "Email", "SMS"]
@ -358,11 +360,9 @@ global_search_doctypes = {
{"doctype": "ToDo"},
{"doctype": "Note"},
{"doctype": "Event"},
{"doctype": "Blog Post"},
{"doctype": "Dashboard"},
{"doctype": "Country"},
{"doctype": "Currency"},
{"doctype": "Newsletter"},
{"doctype": "Letter Head"},
{"doctype": "Workflow"},
{"doctype": "Web Page"},
@ -421,6 +421,7 @@ before_request = [
"frappe.recorder.record",
"frappe.monitor.start",
"frappe.rate_limiter.apply",
"frappe.integrations.oauth2.set_cors_for_privileged_requests",
]
after_request = [
@ -572,3 +573,7 @@ persistent_cache_keys = [
"rate-limit-counter-*",
"rl:*",
]
user_invitation = {
"only_for": ["System Manager"],
}

View file

@ -75,6 +75,7 @@ def _new_site(
except Exception:
enable_scheduler = False
clear_site_locks()
make_site_dirs()
if rollback_callback:
rollback_callback.add(lambda: shutil.rmtree(frappe.get_site_path()))
@ -447,6 +448,7 @@ def _delete_modules(modules: list[str], dry_run: bool) -> list[str]:
if not dry_run:
if doctype.issingle:
frappe.delete_doc(doctype.name, doctype.name, ignore_on_trash=True, force=True)
frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True, force=True)
else:
drop_doctypes.append(doctype.name)
@ -671,6 +673,14 @@ def get_conf_params(db_name=None, db_password=None):
return {"db_name": db_name, "db_password": db_password}
def clear_site_locks():
import shutil
from pathlib import Path
path = Path(frappe.get_site_path("locks"))
shutil.rmtree(path, ignore_errors=True)
def make_site_dirs():
for dir_path in [
os.path.join("public", "files"),

View file

@ -0,0 +1,70 @@
# Integrations
## OAuth 2
Frappe Framework uses [`oauthlib`](https://github.com/oauthlib/oauthlib) to manage OAuth2 requirements. A Frappe instance can function as all of these:
1. **Resource Server**: contains resources, for example the data in your DocTypes.
2. **Authorization Server**: server that issues tokens to access some resource.
3. **Client**: app that requires access to some resource on a resource server.
DocTypes pertaining to the above roles:
1. **Common**
- **OAuth Settings**: allows configuring certain OAuth features pertaining to the three roles.
2. **Authorization Server**
- **OAuth Client**: keeps records of _clients_ registered with the frappe instance.
- **OAuth Bearer Token**: tokens given out to registered _clients_ are maintained here.
- **OAuth Authorization Code**: keeps track of OAuth codes a client responds with in exchange for a token.
- **OAuth Provider Settings**: allows skipping authorization. `[DEPRECATED]` use **OAuth Settings** instead.
3. **Client**
- **Connected App**: keeps records of _authorization servers_ against whom this frappe instance is registered as a _client_ so some resource can be accessed. Eg. a users Google Drive account.
- **Social Key Login**: similar to **Connected App**, but for the purpose of logging into the frappe instance. Eg. a users Google account to enable "Login with Google".
- **Token Cache**: tokens received by the Frappe instance when accessing a **Connected App**.
### Features
Additional features over `oauthlib` that have implemented in the Framework:
- **Dynamic Client Registration**: allows a client to register itself without manual configuration by the resource owner. [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591)
- **Authorization Server Metadata Discovery**: allows a client to view the instance's auth server (itself) metadata such as auth end points. [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414)
- **Resource Server Metadata Discovery**: allows a client to view the instance's resource server metadata such as documentation, auth servers, etc. [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728)
### Additional Docs
Documentation of various OAuth2 features:
1. [How to setup OAuth 2?](https://docs.frappe.io/framework/user/en/guides/integration/how_to_set_up_oauth)
2. [OAuth 2](https://docs.frappe.io/framework/user/en/guides/integration/rest_api/oauth-2)
3. [Token Based Authentication](https://docs.frappe.io/framework/user/en/guides/integration/rest_api/token_based_authentication)
4. [Using Frappe as OAuth Service](https://docs.frappe.io/framework/user/en/using_frappe_as_oauth_service)
5. [Social Login Key](https://docs.frappe.io/framework/user/en/guides/integration/social_login_key)
6. [Connected App](https://docs.frappe.io/framework/user/en/guides/app-development/connected-app)
> [!WARNING]
>
> Some of these might be outdated, it is always recommended to check the code
> when in doubt.
### OAuth Settings
A Single doctype that allows configuring OAuth2 related features. It is
recommended to open the DocType page itself as each field and section has a
sufficiently descriptive help text.
The settings allow toggling the following features:
- Authorization check when active token is present using the _Skip Authorization_ field. _**Note**: Keep this unchecked in production._
- **Authorization Server Metadata Discovery**: by toggling the _Show Auth Server Metadata_ field.
- **Dynamic Client Registration**: by toggling the _Enable Dynamic Client Registration_ field.
- **Resource Server Metadata Discovery**: by toggling the _Show Protected Resource Metadata_.
The remaining fields (in the **Resource** section) are used only when responding to requests on `/.well-known/oauth-protected-resource`
> **Regarding Public Clients**
>
> Public clients, for example an SPA, have restricted access by default. This
> restriction is applied by use of CORS.
>
> To side-step this restriction for certain trusted clients, you may add their
> hostnames to the **Allowed Public Client Origins** field.

View file

@ -613,7 +613,7 @@ class Test_OpenLDAP(LDAP_TestCase, TestCase):
"ldap_group": "Administrators",
"erpnext_role": "System Manager",
},
{"doctype": "LDAP Group Mapping", "ldap_group": "Users", "erpnext_role": "Blogger"},
{"doctype": "LDAP Group Mapping", "ldap_group": "Users", "erpnext_role": "Website Manager"},
{"doctype": "LDAP Group Mapping", "ldap_group": "Group3", "erpnext_role": "Accounts User"},
]
LDAP_USERNAME_FIELD = "uid"
@ -637,7 +637,7 @@ class Test_ActiveDirectory(LDAP_TestCase, TestCase):
"ldap_group": "Domain Administrators",
"erpnext_role": "System Manager",
},
{"doctype": "LDAP Group Mapping", "ldap_group": "Domain Users", "erpnext_role": "Blogger"},
{"doctype": "LDAP Group Mapping", "ldap_group": "Domain Users", "erpnext_role": "Website Manager"},
{
"doctype": "LDAP Group Mapping",
"ldap_group": "Enterprise Administrators",

View file

@ -7,19 +7,29 @@
"engine": "InnoDB",
"field_order": [
"client_id",
"app_name",
"user",
"allowed_roles",
"cb_1",
"client_secret",
"skip_authorization",
"sb_1",
"scopes",
"cb_3",
"redirect_uris",
"default_redirect_uri",
"skip_authorization",
"client_metadata_section",
"app_name",
"scopes",
"column_break_htfq",
"redirect_uris",
"section_break_ggiv",
"client_uri",
"software_id",
"tos_uri",
"contacts",
"column_break_ziii",
"logo_uri",
"software_version",
"policy_uri",
"sb_advanced",
"grant_type",
"token_endpoint_auth_method",
"cb_2",
"response_type"
],
@ -27,13 +37,13 @@
{
"fieldname": "client_id",
"fieldtype": "Data",
"label": "App Client ID",
"label": "Client ID",
"read_only": 1
},
{
"fieldname": "app_name",
"fieldtype": "Data",
"label": "App Name",
"label": "App Name (Client Name)",
"reqd": 1
},
{
@ -50,7 +60,7 @@
{
"fieldname": "client_secret",
"fieldtype": "Data",
"label": "App Client Secret",
"label": "Client Secret",
"read_only": 1
},
{
@ -60,10 +70,6 @@
"fieldtype": "Check",
"label": "Skip Authorization"
},
{
"fieldname": "sb_1",
"fieldtype": "Section Break"
},
{
"default": "all openid",
"description": "A list of resources which the Client App will have access to after the user allows it.<br> e.g. project",
@ -72,10 +78,6 @@
"label": "Scopes",
"reqd": 1
},
{
"fieldname": "cb_3",
"fieldtype": "Column Break"
},
{
"description": "URIs for receiving authorization code once the user allows access, as well as failure responses. Typically a REST endpoint exposed by the Client App.\n<br>e.g. http://hostname/api/method/frappe.integrations.oauth2_logins.login_via_facebook",
"fieldname": "redirect_uris",
@ -121,10 +123,85 @@
"fieldtype": "Table MultiSelect",
"label": "Allowed Roles",
"options": "OAuth Client Role"
},
{
"fieldname": "client_metadata_section",
"fieldtype": "Section Break",
"label": "Client Metadata"
},
{
"depends_on": "eval: doc.client_uri",
"description": "URL of a web page providing information about the client.",
"fieldname": "client_uri",
"fieldtype": "Data",
"label": "Client URI"
},
{
"fieldname": "column_break_htfq",
"fieldtype": "Column Break"
},
{
"depends_on": "eval: doc.client_uri",
"description": "URL that references a logo for the client.",
"fieldname": "logo_uri",
"fieldtype": "Data",
"label": "Logo URI"
},
{
"fieldname": "section_break_ggiv",
"fieldtype": "Section Break"
},
{
"depends_on": "eval: doc.software_id",
"description": "Unique ID assigned by the client developer used to identify the client software to be dynamically registered.\n<br>\n<b>Should remain same</b> across multiple versions or updates of the software.",
"fieldname": "software_id",
"fieldtype": "Data",
"label": "Software ID"
},
{
"fieldname": "column_break_ziii",
"fieldtype": "Column Break"
},
{
"depends_on": "eval: doc.software_version",
"description": "A version identifier string for the client software.\n<br>\nThe value of the should change on any update of the client software with the same Software ID.",
"fieldname": "software_version",
"fieldtype": "Data",
"label": "Software Version"
},
{
"depends_on": "eval: doc.tos_uri",
"description": "URL that points to a human-readable terms of service document for the client. Should be shown to end-user before authorizing.",
"fieldname": "tos_uri",
"fieldtype": "Data",
"label": "TOS URI"
},
{
"depends_on": "eval: doc.policy_uri",
"description": "URL that points to a human-readable policy document for the client. Should be shown to end-user before authorizing.",
"fieldname": "policy_uri",
"fieldtype": "Data",
"label": "Policy URI"
},
{
"depends_on": "eval: doc.contacts",
"description": "New lines separated list of strings representing ways to contact people responsible for this client, typically email addresses.",
"fieldname": "contacts",
"fieldtype": "Small Text",
"label": "Contacts"
},
{
"default": "Client Secret Basic",
"description": "Value of \"None\" implies a public client. In such a case Client Secret is not given to the client and token exchange makes use of PKCE.",
"fieldname": "token_endpoint_auth_method",
"fieldtype": "Select",
"label": "Token Endpoint Auth Method",
"options": "Client Secret Basic\nClient Secret Post\nNone"
}
],
"grid_page_length": 50,
"links": [],
"modified": "2024-04-29 12:07:07.946980",
"modified": "2025-07-04 14:07:36.146393",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Client",
@ -143,6 +220,7 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],

View file

@ -1,7 +1,11 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# License: MIT. See LICENSE
import datetime
import time
import frappe
import frappe.utils
from frappe import _
from frappe.model.document import Document
from frappe.permissions import SYSTEM_USER_ROLE
@ -21,12 +25,20 @@ class OAuthClient(Document):
app_name: DF.Data
client_id: DF.Data | None
client_secret: DF.Data | None
client_uri: DF.Data | None
contacts: DF.SmallText | None
default_redirect_uri: DF.Data
grant_type: DF.Literal["Authorization Code", "Implicit"]
logo_uri: DF.Data | None
policy_uri: DF.Data | None
redirect_uris: DF.Text | None
response_type: DF.Literal["Code", "Token"]
scopes: DF.Text
skip_authorization: DF.Check
software_id: DF.Data | None
software_version: DF.Data | None
token_endpoint_auth_method: DF.Literal["Client Secret Basic", "Client Secret Post", "None"]
tos_uri: DF.Data | None
user: DF.Link | None
# end: auto-generated types
@ -55,3 +67,18 @@ class OAuthClient(Document):
"""Returns true if session user is allowed to use this client."""
allowed_roles = {d.role for d in self.allowed_roles}
return bool(allowed_roles & set(frappe.get_roles()))
def is_public_client(self) -> bool:
return self.token_endpoint_auth_method == "None"
def client_id_issued_at(self) -> int:
"""Returns UNIX timestamp (seconds since epoch) of the client creation time."""
if isinstance(self.creation, datetime.datetime):
return int(self.creation.timestamp())
try:
d = datetime.datetime.fromisoformat(self.creation)
return int(d.timestamp())
except Exception:
return int(frappe.utils.now_datetime().timestamp())

View file

@ -19,10 +19,3 @@ class OAuthProviderSettings(Document):
# end: auto-generated types
pass
def get_oauth_settings():
"""Return OAuth settings."""
return frappe._dict(
{"skip_authorization": frappe.db.get_single_value("OAuth Provider Settings", "skip_authorization")}
)

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