Merge branch 'develop' into fix/add-missing-messages-to-load-translations

This commit is contained in:
Soham Kulkarni 2025-07-11 15:42:05 +05:30 committed by GitHub
commit 439215b91e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 19074 additions and 21411 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

@ -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)
@ -1584,50 +1232,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 +1435,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)
@ -1997,17 +1548,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

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

@ -145,7 +145,8 @@ def remove_apps_with_incomplete_dependencies(bootinfo):
remove_apps.add(app)
for app in remove_apps:
bootinfo.setup_wizard_not_required_apps.remove(app)
if app in bootinfo.setup_wizard_not_required_apps:
bootinfo.setup_wizard_not_required_apps.remove(app)
def get_letter_heads():

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)})]
values = list({cstr(v).lower() for v in self.column_values if v})
exists = [
cstr(d.name).lower()
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

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

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

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

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

@ -165,11 +165,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

@ -198,11 +198,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 (
@ -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

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

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

@ -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,32 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2021-12-06 16:37:40.652468",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"attachment"
],
"fields": [
{
"fieldname": "attachment",
"fieldtype": "Attach",
"in_list_view": 1,
"label": "Attachment",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-23 16:03:31.101104",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter Attachment",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -1,23 +0,0 @@
# Copyright (c) 2021, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class NewsletterAttachment(Document):
# 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.types import DF
attachment: DF.Attach
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
# end: auto-generated types
pass

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

@ -1,23 +0,0 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# License: MIT. See LICENSE
from frappe.model.document import Document
class NewsletterEmailGroup(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.types import DF
email_group: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
total_subscribers: DF.ReadOnly | None
# end: auto-generated types
pass

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

View file

@ -56,17 +56,12 @@ 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"
@ -225,9 +220,7 @@ scheduler_events = {
"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.
@ -362,7 +355,6 @@ global_search_doctypes = {
{"doctype": "Dashboard"},
{"doctype": "Country"},
{"doctype": "Currency"},
{"doctype": "Newsletter"},
{"doctype": "Letter Head"},
{"doctype": "Workflow"},
{"doctype": "Web Page"},
@ -421,6 +413,7 @@ before_request = [
"frappe.recorder.record",
"frappe.monitor.start",
"frappe.rate_limiter.apply",
"frappe.integrations.oauth2.set_cors_for_privileged_requests",
]
after_request = [

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

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

View file

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("OAuth Settings", {
// refresh(frm) {
// },
// });

View file

@ -0,0 +1,166 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-07-03 12:04:14.759362",
"description": "A Frappe Framework instance can function as an OAuth Client, Resource, or Authorization server. This DocType contains settings related to all three.",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"authorization_tab",
"authorization_server_section",
"show_auth_server_metadata",
"skip_authorization",
"column_break_ogmd",
"enable_dynamic_client_registration",
"allowed_public_client_origins",
"resource_tab",
"config_section",
"show_protected_resource_metadata",
"column_break_wlfj",
"show_social_login_key_as_authorization_server",
"resource_server_section",
"resource_name",
"resource_policy_uri",
"column_break_zyte",
"resource_documentation",
"resource_tos_uri",
"scopes_supported"
],
"fields": [
{
"description": "These fields are used to provide resource server metadata to clients querying the \"well known protected resource\" end point.",
"fieldname": "resource_server_section",
"fieldtype": "Section Break",
"label": "Metadata"
},
{
"default": "Frappe Framework Application",
"description": "Human-readable name intended for display to the end user.",
"fieldname": "resource_name",
"fieldtype": "Data",
"label": "Resource Name"
},
{
"fieldname": "column_break_zyte",
"fieldtype": "Column Break"
},
{
"default": "https://docs.frappe.io/framework",
"description": "URL of a human-readable page with info that developers might need.",
"fieldname": "resource_documentation",
"fieldtype": "Data",
"label": "Resource Documentation"
},
{
"description": "URL of human-readable page with info on requirements about how the client can use the data.",
"fieldname": "resource_policy_uri",
"fieldtype": "Data",
"label": "Resource Policy URI"
},
{
"description": "URL of human-readable page with info about the protected resource's terms of service.",
"fieldname": "resource_tos_uri",
"fieldtype": "Data",
"label": "Resource TOS URI"
},
{
"fieldname": "authorization_server_section",
"fieldtype": "Section Break"
},
{
"default": "1",
"description": "Allows clients to fetch metadata from the <code>/.well-known/oauth-authorization-server</code> endpoint. Reference: <a href=\"https://datatracker.ietf.org/doc/html/rfc8414\">RFC8414</a>",
"fieldname": "show_auth_server_metadata",
"fieldtype": "Check",
"label": "Show Auth Server Metadata"
},
{
"default": "1",
"description": "Allows clients to fetch metadata from the <code>/.well-known/oauth-protected-resource</code> endpoint. Reference: <a href=\"https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata\">RFC9728</a>",
"fieldname": "show_protected_resource_metadata",
"fieldtype": "Check",
"label": "Show Protected Resource Metadata"
},
{
"description": "New line separated list of scope values.",
"fieldname": "scopes_supported",
"fieldtype": "Small Text",
"label": "Scopes Supported"
},
{
"default": "1",
"description": "Allows clients to register themselves without manual intervention. Registration creates a <b>OAuth Client</b> entry. Reference: <a href=\"https://datatracker.ietf.org/doc/html/rfc7591\">RFC7591</a>",
"fieldname": "enable_dynamic_client_registration",
"fieldtype": "Check",
"label": "Enable Dynamic Client Registration"
},
{
"default": "0",
"description": "Allows skipping authorization if a user has active tokens.",
"fieldname": "skip_authorization",
"fieldtype": "Check",
"label": "Skip Authorization"
},
{
"default": "0",
"description": "Allows enabled Social Login Key Base URL to be shown as authorization server.",
"fieldname": "show_social_login_key_as_authorization_server",
"fieldtype": "Check",
"label": "Show Social Login Key as Authorization Server"
},
{
"fieldname": "column_break_ogmd",
"fieldtype": "Column Break"
},
{
"fieldname": "authorization_tab",
"fieldtype": "Tab Break",
"label": "Authorization"
},
{
"fieldname": "resource_tab",
"fieldtype": "Tab Break",
"label": "Resource"
},
{
"fieldname": "config_section",
"fieldtype": "Section Break",
"label": "Config"
},
{
"fieldname": "column_break_wlfj",
"fieldtype": "Column Break"
},
{
"description": "New line separated list of allowed public client URLs (eg <code>https://frappe.io</code>), or <code>*</code> to accept all.\n<br>\nPublic clients are restricted by default.",
"fieldname": "allowed_public_client_origins",
"fieldtype": "Small Text",
"label": "Allowed Public Client Origins"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-04 15:01:45.453238",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class OAuthSettings(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.types import DF
allowed_public_client_origins: DF.SmallText | None
enable_dynamic_client_registration: DF.Check
resource_documentation: DF.Data | None
resource_name: DF.Data | None
resource_policy_uri: DF.Data | None
resource_tos_uri: DF.Data | None
scopes_supported: DF.SmallText | None
show_auth_server_metadata: DF.Check
show_protected_resource_metadata: DF.Check
show_social_login_key_as_authorization_server: DF.Check
skip_authorization: DF.Check
# end: auto-generated types
pass

View file

@ -0,0 +1,20 @@
# Copyright (c) 2025, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestOAuthSettings(IntegrationTestCase):
"""
Integration tests for OAuthSettings.
Use this class for testing interactions between multiple components.
"""
pass

View file

@ -20,6 +20,7 @@
"base_url",
"configuration_section",
"sign_ups",
"show_in_resource_metadata",
"client_urls",
"authorize_url",
"access_token_url",
@ -172,11 +173,19 @@
"fieldtype": "Select",
"label": "Sign ups",
"options": "\nAllow\nDeny"
},
{
"default": "1",
"description": "Allows clients to view this as an Authorization Server when querying the <code>/.well-known/oauth-protected-resource</code> end point.",
"fieldname": "show_in_resource_metadata",
"fieldtype": "Check",
"label": "Show in Resource Metadata"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-06 15:22:46.342392",
"modified": "2025-07-03 12:47:01.696817",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Social Login Key",
@ -195,9 +204,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "provider_name",
"track_changes": 1
}
}

View file

@ -54,6 +54,7 @@ class SocialLoginKey(Document):
icon: DF.Data | None
provider_name: DF.Data
redirect_url: DF.Data | None
show_in_resource_metadata: DF.Check
sign_ups: DF.Literal["", "Allow", "Deny"]
social_login_provider: DF.Literal[
"Custom",

View file

@ -1,12 +1,22 @@
import datetime
import json
from urllib.parse import quote, urlencode
from typing import Literal, cast
from urllib.parse import quote, urlencode, urlparse
from oauthlib.oauth2 import FatalClientError, OAuth2Error
from oauthlib.openid.connect.core.endpoints.pre_configured import Server as WebApplicationServer
from pydantic import ValidationError
from werkzeug import Response
from werkzeug.exceptions import NotFound
import frappe
from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import (
import frappe.utils
from frappe import oauth
from frappe.integrations.utils import (
OAuth2DynamicClientMetadata,
create_new_oauth_client,
get_oauth_settings,
validate_dynamic_client_metadata,
)
from frappe.oauth import (
OAuthWebRequestValidator,
@ -15,6 +25,14 @@ from frappe.oauth import (
get_userinfo,
)
ENDPOINTS = {
"token_endpoint": "/api/method/frappe.integrations.oauth2.get_token",
"userinfo_endpoint": "/api/method/frappe.integrations.oauth2.openid_profile",
"revocation_endpoint": "/api/method/frappe.integrations.oauth2.revoke_token",
"authorization_endpoint": "/api/method/frappe.integrations.oauth2.authorize",
"introspection_endpoint": "/api/method/frappe.integrations.oauth2.introspect_token",
}
def get_oauth_server():
if not getattr(frappe.local, "oauth_server", None):
@ -179,17 +197,18 @@ def openid_profile(*args, **kwargs):
return generate_json_error_response(e)
@frappe.whitelist(allow_guest=True)
def openid_configuration():
def get_openid_configuration():
response = Response()
response.mimetype = "application/json"
frappe_server_url = get_server_url()
frappe.local.response = frappe._dict(
response.data = frappe.as_json(
{
"issuer": frappe_server_url,
"authorization_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.authorize",
"token_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.get_token",
"userinfo_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.openid_profile",
"revocation_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.revoke_token",
"introspection_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.introspect_token",
"authorization_endpoint": f"{frappe_server_url}{ENDPOINTS['authorization_endpoint']}",
"token_endpoint": f"{frappe_server_url}{ENDPOINTS['token_endpoint']}",
"userinfo_endpoint": f"{frappe_server_url}{ENDPOINTS['userinfo_endpoint']}",
"revocation_endpoint": f"{frappe_server_url}{ENDPOINTS['revocation_endpoint']}",
"introspection_endpoint": f"{frappe_server_url}{ENDPOINTS['introspection_endpoint']}",
"response_types_supported": [
"code",
"token",
@ -202,6 +221,7 @@ def openid_configuration():
"id_token_signing_alg_values_supported": ["HS256"],
}
)
return response
@frappe.whitelist(allow_guest=True)
@ -244,3 +264,293 @@ def introspect_token(token=None, token_type_hint=None):
except Exception:
frappe.local.response = frappe._dict({"active": False})
def handle_wellknown(path: str):
"""Path handler for GET requests to /.well-known/ endpoints. Invoked in app.py"""
if path.startswith("/.well-known/openid-configuration"):
return get_openid_configuration()
if path.startswith("/.well-known/oauth-authorization-server") and is_oauth_metadata_enabled(
"auth_server"
):
return get_authorization_server_metadata()
if path.startswith("/.well-known/oauth-protected-resource") and is_oauth_metadata_enabled("resource"):
return get_protected_resource_metadata()
raise NotFound
def get_authorization_server_metadata():
"""
Creates response for the /.well-known/oauth-authorization-server endpoint.
Reference: https://datatracker.ietf.org/doc/html/rfc8414
"""
response = Response()
response.mimetype = "application/json"
response.data = frappe.as_json(_get_authorization_server_metadata())
frappe.local.allow_cors = "*"
return response
def _get_authorization_server_metadata():
"""
Responds with the authorization server metadata.
Reference: https://datatracker.ietf.org/doc/html/rfc8414#section-2
Note:
Value for response_types_supported does not include token because, PKCE
token flow is not supported. Responding with token in the redirect URL
is an unsafe practice, so code is the only supported response type.
"""
issuer = get_resource_url()
metadata = dict(
issuer=issuer,
authorization_endpoint=f"{issuer}{ENDPOINTS['authorization_endpoint']}",
token_endpoint=f"{issuer}{ENDPOINTS['token_endpoint']}",
response_types_supported=["code"],
response_modes_supported=["query"],
grant_types_supported=["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported=["none", "client_secret_basic"],
service_documentation="https://docs.frappe.io/framework/user/en/guides/integration/how_to_set_up_oauth#add-a-client-app",
revocation_endpoint=f"{issuer}{ENDPOINTS['revocation_endpoint']}",
revocation_endpoint_auth_methods_supported=["client_secret_basic"],
introspection_endpoint=f"{issuer}{ENDPOINTS['introspection_endpoint']}",
userinfo_endpoint=f"{issuer}{ENDPOINTS['userinfo_endpoint']}",
code_challenge_methods_supported=["S256"],
)
if frappe.get_cached_value("OAuth Settings", "OAuth Settings", "enable_dynamic_client_registration"):
metadata["registration_endpoint"] = f"{issuer}/api/method/frappe.integrations.oauth2.register_client"
return metadata
@frappe.whitelist(allow_guest=True, methods=["POST"])
def register_client():
"""
Registers an OAuth client.
Reference: https://datatracker.ietf.org/doc/html/rfc7591
"""
if not frappe.get_cached_value("OAuth Settings", "OAuth Settings", "enable_dynamic_client_registration"):
raise NotFound
response = Response()
response.mimetype = "application/json"
data = frappe.request.json
if data is None:
response.status_code = 400
response.data = frappe.as_json(
{
"error": "invalid_client_metadata",
"error_description": "Request body is empty",
}
)
return response
try:
client = OAuth2DynamicClientMetadata.model_validate(data)
except ValidationError as e:
response.status_code = 400
response.data = frappe.as_json({"error": "invalid_client_metadata", "error_description": str(e)})
return response
"""
Note:
A check for existing client cannot be done unless a software_statement (JWT)
is issued. Use of software_statement is not yet implemented.
Doing an exists check based on just client_name or other replicable
parameters risks leaking client_id and client_secret. So it's better to
issue a new client.
"""
if error := validate_dynamic_client_metadata(client):
response.status_code = 400
response.data = frappe.as_json({"error": "invalid_client_metadata", "error_description": error})
return response
doc = create_new_oauth_client(client)
response_data = {
"client_id": doc.client_id,
"client_secret": doc.client_secret,
"client_id_issued_at": doc.client_id_issued_at(),
"client_secret_expires_at": 0,
# Response should include registered metadata
"client_name": doc.app_name,
"client_uri": doc.client_uri,
"grant_types": ["authorization_code"],
"response_types": ["code"],
"logo_uri": doc.logo_uri,
"tos_uri": doc.tos_uri,
"policy_uri": doc.policy_uri,
"software_id": doc.software_id,
"software_version": doc.software_version,
"scope": doc.scopes,
"redirect_uris": doc.redirect_uris.split("\n") if doc.redirect_uris else None,
"contacts": doc.contacts.split("\n") if doc.contacts else None,
}
if doc.is_public_client():
del response_data["client_secret"]
_del_none_values(response_data)
response.status_code = 201 # Created
response.data = frappe.as_json(response_data)
return response
def get_protected_resource_metadata():
"""
Creates response for the /.well-known/oauth-protected-resource endpoint.
Reference: https://datatracker.ietf.org/doc/html/rfc9728
"""
response = Response()
response.mimetype = "application/json"
response.data = frappe.as_json(_get_protected_resource_metadata())
return response
def _get_protected_resource_metadata():
from frappe.integrations.doctype.oauth_settings.oauth_settings import OAuthSettings
oauth_settings = cast(OAuthSettings, frappe.get_cached_doc("OAuth Settings", ignore_permissions=True))
resource = get_resource_url()
authorization_servers = [resource]
if oauth_settings.show_social_login_key_as_authorization_server:
authorization_servers.extend(
frappe.get_list(
"Social Login Key",
filters={
"enable_social_login": True,
"show_in_resource_metadata": True,
},
pluck="base_url",
ignore_permissions=True,
)
)
metadata = dict(
resource=resource,
authorization_servers=authorization_servers,
bearer_methods_supported=["header"],
resource_name=oauth_settings.resource_name,
resource_documentation=oauth_settings.resource_documentation,
resource_policy_uri=oauth_settings.resource_policy_uri,
resource_tos_uri=oauth_settings.resource_tos_uri,
)
if oauth_settings.scopes_supported is not None:
scopes = []
for _s in oauth_settings.scopes_supported.split("\n"):
s = _s.strip()
if s is None:
continue
scopes.append(s)
if scopes:
metadata["scopes_supported"] = scopes
_del_none_values(metadata)
return metadata
def is_oauth_metadata_enabled(label: Literal["resource", "auth_server"]):
if label not in ["resource", "auth_server"]:
return False
fieldname = "show_auth_server_metadata"
if label == "resource":
fieldname = "show_protected_resource_metadata"
return bool(
frappe.get_cached_value(
"OAuth Settings",
"OAuth Settings",
fieldname,
)
)
def get_resource_url():
"""Uses request URL to reflect the resource URL"""
request_url = urlparse(frappe.request.url)
return f"{request_url.scheme}://{request_url.netloc}"
def _del_none_values(d: dict):
for k in list(d.keys()):
if k in d and d[k] is None:
del d[k]
def set_cors_for_privileged_requests():
"""
Called in before_request hook, prevents failure of privileged requests,
for OPTIONS and:
1. GET requests on /.well-known/
2. POST requests on /api/method/frappe.integrations.oauth2.register_client
Point 2. also depends on OAuth Settings for dynamic client registration.
Without these, registration requests from public clients will fail due to
preflight requests failing.
"""
if (
frappe.conf.allow_cors == "*"
or not frappe.local.request
or not frappe.local.request.headers.get("Origin")
):
return
if frappe.request.path.startswith("/.well-known/") and frappe.request.method in ("GET", "OPTIONS"):
frappe.local.allow_cors = "*"
return
if (
frappe.request.path.startswith("/api/method/frappe.integrations.oauth2.register_client")
and frappe.request.method in ("POST", "OPTIONS")
and frappe.get_cached_value(
"OAuth Settings",
"OAuth Settings",
"enable_dynamic_client_registration",
)
):
_set_allowed_cors()
return
if (
frappe.request.path.startswith(ENDPOINTS["token_endpoint"])
or frappe.request.path.startswith(ENDPOINTS["revocation_endpoint"])
or frappe.request.path.startswith(ENDPOINTS["introspection_endpoint"])
or frappe.request.path.startswith(ENDPOINTS["userinfo_endpoint"])
) and frappe.request.method in ("POST", "OPTIONS"):
_set_allowed_cors()
return
def _set_allowed_cors():
allowed = frappe.get_cached_value(
"OAuth Settings",
"OAuth Settings",
"allowed_public_client_origins",
)
if not allowed:
return
allowed = allowed.strip().splitlines()
if "*" in allowed:
frappe.local.allow_cors = "*"
else:
frappe.local.allow_cors = allowed

View file

@ -3,12 +3,48 @@
import datetime
import json
from typing import Any, cast
from urllib.parse import parse_qs
from pydantic import BaseModel, HttpUrl
import frappe
from frappe.integrations.doctype.oauth_client.oauth_client import OAuthClient
from frappe.utils import get_request_session
class OAuth2DynamicClientMetadata(BaseModel):
"""
OAuth 2.0 Dynamic Client Registration Metadata.
As defined in RFC7591 - OAuth 2.0 Dynamic Client Registration Protocol
https://datatracker.ietf.org/doc/html/rfc7591#section-2
"""
# Used to identify the client to the authorization server
redirect_uris: list[HttpUrl]
token_endpoint_auth_method: str | None = "client_secret_basic"
grant_types: list[str] | None = ["authorization_code"]
response_types: list[str] | None = ["code"]
# Client identifiers shown to user
client_name: str
scope: str | None = None
client_uri: HttpUrl | None = None
logo_uri: HttpUrl | None = None
# Client contact and other information for the client
contacts: list[str] | None = None
tos_uri: HttpUrl | None = None
policy_uri: HttpUrl | None = None
software_id: str | None = None
software_version: str | None = None
# JSON Web Key Set (JWKS) not used here
jwks_uri: HttpUrl | None = None
jwks: dict | None = None
def make_request(method: str, url: str, auth=None, headers=None, data=None, json=None, params=None):
auth = auth or ""
data = data or {}
@ -164,3 +200,73 @@ def get_json(obj):
def json_handler(obj):
if isinstance(obj, datetime.date | datetime.timedelta | datetime.datetime):
return str(obj)
def validate_dynamic_client_metadata(client: OAuth2DynamicClientMetadata):
invalidation_reasons = []
if len(client.redirect_uris) == 0:
invalidation_reasons.append("redirect_uris is required")
if client.grant_types and not set(client.grant_types).issubset({"authorization_code", "refresh_token"}):
invalidation_reasons.append("only 'authorization_code' and 'refresh_token' grant types are supported")
if client.response_types and not all(rt == "code" for rt in client.response_types):
invalidation_reasons.append("only 'code' response_type is supported")
if not frappe.conf.developer_mode and any(c.scheme != "https" for c in client.redirect_uris):
invalidation_reasons.append("redirect_uris must be https")
if invalidation_reasons:
return ",\n".join(invalidation_reasons)
return None
def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
doc = cast(OAuthClient, frappe.get_doc({"doctype": "OAuth Client"}))
redirect_uris = [str(uri) for uri in client.redirect_uris]
doc.app_name = client.client_name
doc.scopes = client.scope or "all"
doc.redirect_uris = "\n".join(redirect_uris)
doc.default_redirect_uri = redirect_uris[0]
doc.response_type = "Code"
doc.grant_type = "Authorization Code"
doc.skip_authorization = False
if client.client_uri:
doc.client_uri = client.client_uri.encoded_string()
if client.logo_uri:
doc.logo_uri = client.logo_uri.encoded_string()
if client.tos_uri:
doc.tos_uri = client.tos_uri.encoded_string()
if client.policy_uri:
doc.policy_uri = client.policy_uri.encoded_string()
if client.contacts:
doc.contacts = "\n".join(client.contacts)
if client.software_id:
doc.software_id = client.software_id
if client.software_version:
doc.software_version = client.software_version
if client.token_endpoint_auth_method == "none":
doc.token_endpoint_auth_method = "None"
if client.token_endpoint_auth_method == "client_secret_post":
doc.token_endpoint_auth_method = "Client Secret Post"
doc.save(ignore_permissions=True)
return doc
def get_oauth_settings():
"""Return OAuth settings."""
settings: dict[str, Any] = frappe._dict({"skip_authorization": None})
if frappe.get_cached_value("OAuth Settings", "OAuth Settings", "skip_authorization"):
settings["skip_authorization"] = "Auto" # based on legacy OAuth Provider Settings value
elif value := frappe.get_cached_value(
"OAuth Provider Settings", "OAuth Provider Settings", "skip_authorization"
):
settings["skip_authorization"] = value
return settings

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -78,8 +78,6 @@ class SiteMigration:
frappe.flags.touched_tables = set()
self.touched_tables_file = frappe.get_site_path("touched_tables.json")
frappe.clear_cache()
add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data")
frappe.clear_cache()
if os.path.exists(self.touched_tables_file):
os.remove(self.touched_tables_file)

View file

@ -1129,8 +1129,9 @@ from {tables}
frappe.throw(_("Illegal SQL Query"))
for field in parameters.split(","):
if field.count('"') % 2 or field.count("'") % 2 or field.count("`") % 2:
frappe.throw(_("Invalid field name: {0}").format(field))
field = field.strip()
function = field.split("(", 1)[0].rstrip().lower()
full_field_name = "." in field and field.startswith("`tab")
if full_field_name:
@ -1140,9 +1141,10 @@ from {tables}
tbl = tbl[4:-1]
frappe.throw(_("Please select atleast 1 column from {0} to sort/group").format(tbl))
# Check if the function is used anywhere in the field
if any(func in function for func in blacklisted_sql_functions):
frappe.throw(_("Cannot use {0} in order/group by").format(function))
# Check for SQL function using regex with word boundaries and optional whitespace before parenthesis
for func in blacklisted_sql_functions:
if re.search(r"\b" + re.escape(func) + r"\s*\(", field.lower()):
frappe.throw(_("Cannot use {0} in order/group by").format(field))
def add_limit(self):
if self.limit_page_length:

View file

@ -18,6 +18,7 @@ import frappe
from frappe import _, is_whitelisted, msgprint
from frappe.core.doctype.file.utils import relink_mismatched_files
from frappe.core.doctype.server_script.server_script_utils import run_server_script_for_doc_event
from frappe.database.utils import commit_after_response
from frappe.desk.form.document_follow import follow_document
from frappe.integrations.doctype.webhook import run_webhooks
from frappe.model import optional_fields, table_fields
@ -27,6 +28,7 @@ from frappe.model.naming import set_new_name, validate_name
from frappe.model.utils import is_virtual_doctype, simple_singledispatch
from frappe.model.workflow import set_workflow_state_on_action, validate_workflow
from frappe.types import DF
from frappe.types.filter import FilterSignature
from frappe.utils import compare, cstr, date_diff, file_lock, flt, get_table_name, now
from frappe.utils.data import get_absolute_url, get_datetime, get_timedelta, getdate
from frappe.utils.global_search import update_global_search
@ -37,7 +39,7 @@ if TYPE_CHECKING:
from frappe.core.doctype.docfield.docfield import DocField
DOCUMENT_LOCK_EXPIRTY = 3 * 60 * 60 # All locks expire in 3 hours automatically
DOCUMENT_LOCK_EXPIRY = 3 * 60 * 60 # All locks expire in 3 hours automatically
DOCUMENT_LOCK_SOFT_EXPIRY = 30 * 60 # Let users force-unlock after 30 minutes
@ -204,7 +206,7 @@ class Document(BaseDocument):
if not file_lock.lock_exists(signature):
return False
if file_lock.lock_age(signature) > DOCUMENT_LOCK_EXPIRTY:
if file_lock.lock_age(signature) > DOCUMENT_LOCK_EXPIRY:
return False
return True
@ -646,7 +648,7 @@ class Document(BaseDocument):
def set_new_name(self, force=False, set_name=None, set_child_names=True):
"""Calls `frappe.naming.set_new_name` for parent and child docs."""
if (frappe.flags.api_name_set or self.flags.name_set) and not force:
if self.flags.name_set and not force:
return
autoname = self.meta.autoname or ""
@ -1628,10 +1630,11 @@ class Document(BaseDocument):
if user not in _seen:
_seen.append(user)
frappe.db.set_value(
self.doctype, self.name, "_seen", json.dumps(_seen), update_modified=False
commit_after_response(
lambda: frappe.db.set_value(
self.doctype, self.name, "_seen", json.dumps(_seen), update_modified=False
)
)
frappe.local.flags.commit = True
def add_viewed(self, user=None, force=False, unique_views=False):
"""Add a view log for the current document"""
@ -1657,8 +1660,7 @@ class Document(BaseDocument):
if frappe.flags.read_only:
view_log.deferred_insert()
else:
view_log.insert(ignore_permissions=True)
frappe.local.flags.commit = True
commit_after_response(lambda: view_log.insert(ignore_permissions=True))
return view_log
@ -1753,7 +1755,7 @@ class Document(BaseDocument):
signature = self.get_signature()
if file_lock.lock_exists(signature):
lock_exists = True
if file_lock.lock_age(signature) > DOCUMENT_LOCK_EXPIRTY:
if file_lock.lock_age(signature) > DOCUMENT_LOCK_EXPIRY:
file_lock.delete_lock(signature)
lock_exists = False
if timeout:
@ -2023,3 +2025,183 @@ class LazyChildTable:
return __dict[fieldname]
# Note: Don't implement __set__ method! https://docs.python.org/3/howto/descriptor.html#descriptor-protocol
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 frappe.in_test:
fields_to_clear.append("docstatus")
if isinstance(doc, BaseDocument):
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 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 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 := frappe.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:
frappe.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:
frappe.cache.delete_value(get_document_cache_key(doctype, name))
else:
frappe.cache.delete_keys(get_document_cache_key(doctype, ""))
clear_in_redis()
if hasattr(frappe.db, "after_commit"):
frappe.db.after_commit.add(clear_in_redis)
frappe.db.after_rollback.add(clear_in_redis)
if doctype == "System Settings" and hasattr(frappe.local, "system_settings"):
delattr(frappe.local, "system_settings")
if doctype == "Website Settings" and hasattr(frappe.local, "website_settings"):
delattr(frappe.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 frappe.DoesNotExistError:
frappe.clear_last_message()
return
if isinstance(fieldname, str):
if as_dict:
frappe.throw("Cannot make dict for single fieldname")
return doc.get(fieldname)
values = [doc.get(f) for f in fieldname]
if as_dict:
return frappe._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 = frappe.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 frappe.DoesNotExistError(doctype=doctype)
def get_single(doctype):
"""Return a `frappe.model.document.Document` object of the given Single doctype."""
return get_doc(doctype, doctype)

View file

@ -109,11 +109,12 @@ def sync_for(app_name, force=0, reset_permissions=False):
if l:
for i, doc_path in enumerate(files):
import_file_by_path(
imported = import_file_by_path(
doc_path, force=force, ignore_version=True, reset_permissions=reset_permissions
)
frappe.db.commit()
if imported:
frappe.db.commit(chain=True)
# show progress bar
update_progress_bar(f"Updating DocTypes for {app_name}", i, l)

View file

@ -1,9 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import hashlib
import json
import os
import orjson
import frappe
from frappe.model.base_document import get_controller
from frappe.modules import get_module_path, scrub_dt_dn
@ -108,9 +109,10 @@ def import_file_by_path(
docs = read_doc_from_file(path)
except OSError:
print(f"{path} missing")
return
return False
calculated_hash = calculate_hash(path)
imported = False
if docs:
if not isinstance(docs, list):
@ -147,6 +149,7 @@ def import_file_by_path(
reset_permissions=reset_permissions,
path=path,
)
imported = True
if doc["doctype"] == "DocType":
doctype_table = DocType("DocType")
@ -163,7 +166,7 @@ def import_file_by_path(
if new_modified_timestamp:
update_modified(new_modified_timestamp, doc)
return True
return imported
def read_doc_from_file(path):
@ -171,7 +174,7 @@ def read_doc_from_file(path):
if os.path.exists(path):
with open(path) as f:
try:
doc = json.loads(f.read())
doc = orjson.loads(f.read())
except ValueError:
print(f"bad json: {path}")
raise

View file

@ -176,7 +176,7 @@ def execute_patch(patchmodule: str, method=None, methodargs=None):
)
start_time = time.monotonic()
frappe.db.begin()
frappe.db.commit()
frappe.db.auto_commit_on_many_writes = 0
try:
if patchmodule:

View file

@ -1,5 +1,5 @@
[pre_model_sync]
frappe.patches.v16_0.enable_setup_complete
frappe.patches.v16_0.enable_setup_complete #01-07-2025 re-run-patch
frappe.patches.v15_0.remove_implicit_primary_key
frappe.patches.v12_0.remove_deprecated_fields_from_doctype #3
execute:frappe.utils.global_search.setup_global_search_table()
@ -195,7 +195,6 @@ frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report
execute:frappe.reload_doc("desk", "doctype", "Form Tour")
execute:frappe.delete_doc('Page', 'recorder', ignore_missing=True, force=True)
frappe.patches.v14_0.modify_value_column_size_for_singles
frappe.patches.v15_0.migrate_to_utm
frappe.integrations.doctype.oauth_bearer_token.patches.clear_old_tokens
[post_model_sync]
@ -246,4 +245,5 @@ frappe.patches.v16_0.move_role_desk_settings_to_user
frappe.printing.doctype.print_format.patches.sets_wkhtmltopdf_as_default_for_pdf_generator_field
frappe.patches.v14_0.fix_user_settings_collation
execute:frappe.call("frappe.core.doctype.system_settings.system_settings.sync_system_settings")
frappe.patches.v15_0.migrate_to_utm
frappe.patches.v16_0.add_module_deprecation_warning

View file

@ -5,6 +5,7 @@ def execute():
module_app_map = {
"Social Module/ Energy Points System": ("eps", "system"),
"Offsite Backup Integrations (Google Drive, S3, Dropbox)": ("offsite_backups", "intergration"),
"Newsletter": {"newsletter", "functionality"},
}
for module, (app, system_type) in module_app_map.items():
click.secho(

View file

@ -6,11 +6,17 @@ def execute():
frappe.reload_doc("core", "doctype", "installed_applications")
is_setup_complete = frappe.db.get_single_value("System Settings", "setup_complete")
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
for app_name in frappe.get_all("Installed Application", pluck="app_name"):
if app_name not in installed_apps:
continue
if frappe.get_all(
"User", filters={"name": ("not in", ["Guest", "Administrator"])}, pluck="name", limit=1
):
is_setup_complete = 1
apps_details = frappe._dict({})
for details in frappe.utils.get_installed_apps_info():
apps_details[details.get("app_name")] = details
installed_apps = frappe.get_installed_apps(_ensure_on_bench=True)
for app_name in installed_apps:
has_setup_wizard = 0
if app_name == "frappe":
has_setup_wizard = 1
@ -18,11 +24,27 @@ def execute():
has_setup_wizard = 1
if has_setup_wizard:
frappe.db.set_value(
"Installed Application",
{"app_name": app_name},
{
"has_setup_wizard": 1,
"is_setup_complete": is_setup_complete,
},
)
if not frappe.db.exists("Installed Application", {"app_name": app_name}):
apps_detail = apps_details.get(app_name, {})
frappe.get_doc(
{
"doctype": "Installed Application",
"app_name": app_name,
"has_setup_wizard": 1,
"is_setup_complete": 1,
"app_version": apps_detail.get("version", ""),
"git_branch": apps_detail.get("branch", ""),
"parent": "Installed Applications",
"parenttype": "installed_applications",
}
).insert(ignore_permissions=True)
else:
frappe.db.set_value(
"Installed Application",
{"app_name": app_name},
{
"has_setup_wizard": 1,
"is_setup_complete": is_setup_complete,
},
)

View file

@ -23,7 +23,7 @@ frappe.ui.form.on("Print Format", {
},
render_buttons: function (frm) {
frm.page.clear_inner_toolbar();
if (!frm.is_new()) {
if (!frm.is_new() && frm.doc.print_format_for === "Doctype") {
if (!frm.doc.custom_format) {
frm.add_custom_button(__("Edit Format"), function () {
if (!frm.doc.doc_type) {
@ -71,6 +71,11 @@ frappe.ui.form.on("Print Format", {
doc_type: function (frm) {
frm.trigger("hide_absolute_value_field");
},
print_format_for: function (frm) {
if (frm.doc.print_format_for === "Report") {
frm.set_value("print_format_type", "JS");
}
},
hide_absolute_value_field: function (frm) {
// TODO: make it work with frm.doc.doc_type
// Problem: frm isn't updated in some random cases

View file

@ -6,7 +6,9 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"print_format_for",
"doc_type",
"report",
"module",
"default_print_language",
"column_break_3",
@ -43,14 +45,15 @@
],
"fields": [
{
"depends_on": "eval:doc.print_format_for == \"DocType\"",
"fieldname": "doc_type",
"fieldtype": "Link",
"in_filter": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "DocType",
"options": "DocType",
"reqd": 1
"mandatory_depends_on": "eval:doc.print_format_for == \"DocType\"",
"options": "DocType"
},
{
"fieldname": "module",
@ -88,7 +91,7 @@
"label": "Custom Format"
},
{
"depends_on": "custom_format",
"depends_on": "eval:doc.custom_format || doc.print_format_for == \"Report\"",
"fieldname": "section_break_6",
"fieldtype": "Section Break"
},
@ -98,16 +101,18 @@
"fieldname": "print_format_type",
"fieldtype": "Select",
"label": "Print Format Type",
"options": "Jinja\nJS"
"options": "Jinja\nJS",
"read_only_depends_on": "eval:doc.print_format_for == \"Report\""
},
{
"default": "0",
"depends_on": "custom_format",
"fieldname": "raw_printing",
"fieldtype": "Check",
"label": "Raw Printing"
},
{
"depends_on": "eval:!doc.raw_printing",
"depends_on": "eval:(!doc.raw_printing) || (doc.print_format_for == \"Report\")",
"fieldname": "html",
"fieldtype": "Code",
"label": "HTML",
@ -263,12 +268,30 @@
"fieldtype": "Select",
"label": "PDF Generator",
"options": "wkhtmltopdf"
},
{
"default": "DocType",
"fieldname": "print_format_for",
"fieldtype": "Select",
"label": "Print Format For",
"options": "DocType\nReport"
},
{
"depends_on": "eval:doc.print_format_for == \"Report\"",
"fieldname": "report",
"fieldtype": "Link",
"in_filter": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Report",
"mandatory_depends_on": "eval:doc.print_format_for == \"Report\"",
"options": "Report"
}
],
"icon": "fa fa-print",
"idx": 1,
"links": [],
"modified": "2025-02-14 14:49:39.181074",
"modified": "2025-07-02 11:07:42.812225",
"modified_by": "Administrator",
"module": "Printing",
"name": "Print Format",
@ -291,8 +314,9 @@
"select": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -26,7 +26,7 @@ class PrintFormat(Document):
custom_format: DF.Check
default_print_language: DF.Link | None
disabled: DF.Check
doc_type: DF.Link
doc_type: DF.Link | None
font: DF.Data | None
font_size: DF.Int
format_data: DF.Code | None
@ -43,9 +43,11 @@ class PrintFormat(Document):
pdf_generator: DF.Literal["wkhtmltopdf"]
print_format_builder: DF.Check
print_format_builder_beta: DF.Check
print_format_for: DF.Literal["DocType", "Report"]
print_format_type: DF.Literal["Jinja", "JS"]
raw_commands: DF.Code | None
raw_printing: DF.Check
report: DF.Link | None
show_section_headings: DF.Check
standard: DF.Literal["No", "Yes"]
# end: auto-generated types
@ -58,6 +60,10 @@ class PrintFormat(Document):
)
self.set_onload("print_templates", templates)
def before_save(self):
if self.print_format_for == "Report":
self.print_format_type = "JS"
def get_html(self, docname, letterhead=None):
return get_html(self.doc_type, docname, self.name, letterhead)
@ -91,6 +97,9 @@ class PrintFormat(Document):
if self.custom_format and not self.html and not self.raw_printing:
frappe.throw(_("{0} is required").format(frappe.bold(_("HTML"))), frappe.MandatoryError)
if self.print_format_for == "Report" and not self.report:
frappe.throw(_("{0} is required").format(frappe.bold(_("Report"))), frappe.MandatoryError)
def extract_images(self):
from frappe.core.doctype.file.utils import extract_images_from_html

View file

@ -37,10 +37,11 @@
"icon": "fa fa-font",
"idx": 1,
"links": [],
"modified": "2024-03-23 16:03:35.269553",
"modified": "2025-06-26 05:40:55.559700",
"modified_by": "Administrator",
"module": "Printing",
"name": "Print Heading",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@ -56,12 +57,13 @@
},
{
"read": 1,
"role": "All"
"role": "Desk User"
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"search_fields": "print_heading",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View file

@ -0,0 +1,5 @@
[
{
"print_heading": "_Test Print Heading"
}
]

View file

@ -93,10 +93,14 @@ class AssetManager {
let fetched_assets = {};
async function fetch_item(path) {
// Add the version to the URL to bust the cache for non-bundled assets
let url = new URL(path, window.location.origin);
if (!path.includes(".bundle.") && !url.searchParams.get("v")) {
// Add the version to the URL to bust the cache for non-bundled assets
if (
url.hostname === window.location.hostname &&
!path.includes(".bundle.") &&
!url.searchParams.get("v")
) {
url.searchParams.append("v", version_string);
}
const response = await fetch(url.toString());

View file

@ -20,10 +20,20 @@ frappe.ui.form.ControlDuration = class ControlDuration extends frappe.ui.form.Co
</div>`
);
this.$wrapper.append(this.$picker);
this.build_numeric_input("days", this.duration_options.hide_days, 0, "Days");
this.build_numeric_input("hours", false, 0, "Hours");
this.build_numeric_input("minutes", false, 0, "Minutes");
this.build_numeric_input("seconds", this.duration_options.hide_seconds, 0, "Seconds");
this.build_numeric_input(
"days",
this.duration_options.hide_days,
0,
__("Days", null, "Duration")
);
this.build_numeric_input("hours", false, 0, __("Hours", null, "Duration"));
this.build_numeric_input("minutes", false, 0, __("Minutes", null, "Duration"));
this.build_numeric_input(
"seconds",
this.duration_options.hide_seconds,
0,
__("Seconds", null, "Duration")
);
this.set_duration_picker_value(this.value);
this.$picker.hide();
this.bind_events();
@ -45,7 +55,7 @@ frappe.ui.form.ControlDuration = class ControlDuration extends frappe.ui.form.Co
let $control = $(`
<div class="col duration-col">
<div class="row duration-row duration-label">${__(label)}</div>
<div class="row duration-row duration-label">${label}</div>
</div>`);
if (hidden) {

View file

@ -16,7 +16,7 @@ frappe.ui.form.ControlMultiSelectList = class ControlMultiSelectList extends (
</div>
<li class="text-right">
<button class="btn btn-primary btn-xs clear-selections text-nowrap">
Clear All
${__("Clear All")}
</button>
</li>
</ul>

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