Merge branch 'develop' into fix/add-missing-messages-to-load-translations
This commit is contained in:
commit
439215b91e
148 changed files with 19074 additions and 21411 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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"];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
70
frappe/integrations/README.md
Normal file
70
frappe/integrations/README.md
Normal 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.
|
||||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
166
frappe/integrations/doctype/oauth_settings/oauth_settings.json
Normal file
166
frappe/integrations/doctype/oauth_settings/oauth_settings.json
Normal 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": []
|
||||
}
|
||||
30
frappe/integrations/doctype/oauth_settings/oauth_settings.py
Normal file
30
frappe/integrations/doctype/oauth_settings/oauth_settings.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
11538
frappe/locale/hr.po
11538
frappe/locale/hr.po
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
7838
frappe/locale/th.po
7838
frappe/locale/th.po
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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
frappe/printing/doctype/print_heading/test_records.json
Normal file
5
frappe/printing/doctype/print_heading/test_records.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[
|
||||
{
|
||||
"print_heading": "_Test Print Heading"
|
||||
}
|
||||
]
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue