Merge branch 'develop' into 32489-role-perm-based-masking
This commit is contained in:
commit
c0aa39ee9a
301 changed files with 61169 additions and 59190 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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ context("Awesome Bar", () => {
|
|||
cy.login();
|
||||
cy.visit("/app/todo"); // Make sure ToDo filters are cleared.
|
||||
cy.clear_filters();
|
||||
cy.visit("/app/blog-post"); // Make sure Blog Post filters are cleared.
|
||||
cy.visit("/app/web-page"); // Make sure Blog Post filters are cleared.
|
||||
cy.clear_filters();
|
||||
cy.visit("/app/website"); // Go to some other page.
|
||||
cy.visit("/app/build"); // Go to some other page.
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -53,19 +53,19 @@ context("Awesome Bar", () => {
|
|||
});
|
||||
|
||||
it("navigates to another doctype, filter not bleeding", () => {
|
||||
cy.get("@awesome_bar").type("blog post");
|
||||
cy.get("@awesome_bar").type("web page");
|
||||
cy.wait(150); // Wait a bit before hitting enter.
|
||||
cy.get("@awesome_bar").type("{enter}");
|
||||
cy.get(".title-text").should("contain", "Blog Post");
|
||||
cy.get(".title-text").should("contain", "Web Page");
|
||||
cy.wait(200); // Wait a bit longer before checking the filter.
|
||||
cy.location("search").should("be.empty");
|
||||
});
|
||||
|
||||
it("navigates to new form", () => {
|
||||
cy.get("@awesome_bar").type("new blog post");
|
||||
cy.get("@awesome_bar").type("new web page");
|
||||
cy.wait(150); // Wait a bit before hitting enter
|
||||
cy.get("@awesome_bar").type("{enter}");
|
||||
cy.get(".title-text:visible").should("have.text", "New Blog Post");
|
||||
cy.get(".title-text:visible").should("have.text", "New Web Page");
|
||||
});
|
||||
|
||||
it("calculates math expressions", () => {
|
||||
|
|
|
|||
|
|
@ -51,4 +51,33 @@ context("List View", () => {
|
|||
cy.get(".list-row-container:visible").should("contain", "Approved");
|
||||
});
|
||||
});
|
||||
|
||||
it("Adds a button to each list view row", () => {
|
||||
// Get a ToDo with a reference name
|
||||
cy.call("frappe.client.get_value", {
|
||||
doctype: "ToDo",
|
||||
filters: {
|
||||
reference_name: ["is", "set"],
|
||||
},
|
||||
fieldname: "name",
|
||||
}).then((r) => {
|
||||
const todo_name = r.message.name;
|
||||
cy.go_to_list("ToDo");
|
||||
|
||||
// Check if the 'Open' button is present in the ToDo list view
|
||||
cy.get(".btn-default[data-name='" + todo_name + "']")
|
||||
.should((el) => {
|
||||
expect(el).to.exist;
|
||||
})
|
||||
.click();
|
||||
|
||||
cy.window()
|
||||
.its("cur_frm")
|
||||
.then((frm) => {
|
||||
// Routes to the reference document
|
||||
expect(frm.doc.doctype).to.equal("ToDo");
|
||||
expect(frm.doc.name).to.not.equal(todo_name);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ context("Sidebar", () => {
|
|||
.window()
|
||||
.its("frappe")
|
||||
.then((frappe) => {
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_blog_post");
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_doctype_for_attachment");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ context("Sidebar", () => {
|
|||
}).then((todo) => {
|
||||
verify_attachment_visibility(`todo/${todo.message.name}`, true);
|
||||
});
|
||||
verify_attachment_visibility("blog-post/test-blog-attachment-post", false);
|
||||
verify_attachment_visibility("test-blog-category/_Test Blog Category 2", false);
|
||||
});
|
||||
|
||||
it("Verify attachment accessibility UX", () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ context("Table MultiSelect", () => {
|
|||
it("select value from multiselect dropdown", () => {
|
||||
cy.new_form("Assignment Rule");
|
||||
cy.fill_field("__newname", name);
|
||||
cy.fill_field("document_type", "Blog Post");
|
||||
cy.fill_field("document_type", "Web Page");
|
||||
cy.get(".section-head").contains("Assignment Rules").scrollIntoView();
|
||||
cy.fill_field("assign_condition", 'status=="Open"', "Code");
|
||||
cy.get('input[data-fieldname="users"]').focus().as("input");
|
||||
|
|
|
|||
|
|
@ -24,16 +24,12 @@ from collections.abc import Callable, Iterable
|
|||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
Optional,
|
||||
TypeAlias,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
import click
|
||||
import orjson
|
||||
from werkzeug.datastructures import Headers
|
||||
|
||||
import frappe
|
||||
|
|
@ -45,10 +41,11 @@ from frappe.utils.caching import deprecated_local_cache as local_cache
|
|||
from frappe.utils.caching import request_cache, site_cache
|
||||
from frappe.utils.data import as_unicode, bold, cint, cstr, safe_decode, safe_encode, sbool
|
||||
from frappe.utils.local import Local, LocalProxy, release_local
|
||||
from frappe.utils.translations import _, _lt, set_user_lang
|
||||
|
||||
# Local application imports
|
||||
from .exceptions import *
|
||||
from .types import Filters, FilterSignature, FilterTuple, _dict
|
||||
from .types import _dict
|
||||
from .utils.jinja import (
|
||||
get_email_from_template,
|
||||
get_jenv,
|
||||
|
|
@ -62,7 +59,6 @@ __title__ = "Frappe Framework"
|
|||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from logging import Logger
|
||||
from types import ModuleType
|
||||
|
||||
from werkzeug.wrappers import Request
|
||||
|
||||
|
|
@ -70,10 +66,8 @@ if TYPE_CHECKING: # pragma: no cover
|
|||
from frappe.database.mariadb.mysqlclient import MariaDBDatabase
|
||||
from frappe.database.postgres.database import PostgresDatabase
|
||||
from frappe.database.sqlite.database import SQLiteDatabase
|
||||
from frappe.email.doctype.email_queue.email_queue import EmailQueue
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.builder import MariaDB, Postgres, SQLite
|
||||
from frappe.types.lazytranslatedstring import _LazyTranslate
|
||||
from frappe.utils.redis_wrapper import ClientCache, RedisWrapper
|
||||
|
||||
controllers: dict[str, type] = {}
|
||||
|
|
@ -93,66 +87,6 @@ if _dev_server:
|
|||
warnings.simplefilter("always", PendingDeprecationWarning)
|
||||
|
||||
|
||||
def _(msg: str, lang: str | None = None, context: str | None = None) -> str:
|
||||
"""Return translated string in current lang, if exists.
|
||||
Usage:
|
||||
_('Change')
|
||||
_('Change', context='Coins')
|
||||
"""
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import is_html, strip_html_tags
|
||||
|
||||
if not hasattr(local, "lang"):
|
||||
local.lang = lang or "en"
|
||||
|
||||
if not lang:
|
||||
lang = local.lang
|
||||
|
||||
non_translated_string = msg
|
||||
|
||||
if is_html(msg):
|
||||
msg = strip_html_tags(msg)
|
||||
|
||||
# msg should always be unicode
|
||||
msg = as_unicode(msg).strip()
|
||||
|
||||
translated_string = ""
|
||||
|
||||
all_translations = get_all_translations(lang)
|
||||
if context:
|
||||
string_key = f"{msg}:{context}"
|
||||
translated_string = all_translations.get(string_key)
|
||||
|
||||
if not translated_string:
|
||||
translated_string = all_translations.get(msg)
|
||||
|
||||
return translated_string or non_translated_string
|
||||
|
||||
|
||||
def _lt(msg: str, lang: str | None = None, context: str | None = None) -> "_LazyTranslate":
|
||||
"""Lazily translate a string.
|
||||
|
||||
|
||||
This function returns a "lazy string" which when casted to string via some operation applies
|
||||
translation first before casting.
|
||||
|
||||
This is only useful for translating strings in global scope or anything that potentially runs
|
||||
before `frappe.init()`
|
||||
|
||||
Note: Result is not guaranteed to equivalent to pure strings for all operations.
|
||||
"""
|
||||
from .types.lazytranslatedstring import _LazyTranslate
|
||||
|
||||
return _LazyTranslate(msg, lang, context)
|
||||
|
||||
|
||||
def set_user_lang(user: str, user_language: str | None = None) -> None:
|
||||
"""Guess and set user language for the session. `frappe.local.lang`"""
|
||||
from frappe.translate import get_user_lang
|
||||
|
||||
local.lang = get_user_lang(user) or user_language
|
||||
|
||||
|
||||
# local-globals
|
||||
ConfType: TypeAlias = _dict[str, Any] # type: ignore[no-any-explicit]
|
||||
# TODO: make session a dataclass instead of undtyped _dict
|
||||
|
|
@ -427,20 +361,6 @@ def log(msg: str) -> None:
|
|||
debug_log.append(as_unicode(msg))
|
||||
|
||||
|
||||
def create_folder(path, with_init=False):
|
||||
"""Create a folder in the given path and add an `__init__.py` file (optional).
|
||||
|
||||
:param path: Folder path.
|
||||
:param with_init: Create `__init__.py` in the new folder."""
|
||||
from frappe.utils import touch_file
|
||||
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path)
|
||||
|
||||
if with_init:
|
||||
touch_file(os.path.join(path, "__init__.py"))
|
||||
|
||||
|
||||
def set_user(username: str):
|
||||
"""Set current user.
|
||||
|
||||
|
|
@ -481,144 +401,22 @@ def get_request_header(key, default=None):
|
|||
return request.headers.get(key, default)
|
||||
|
||||
|
||||
def sendmail(
|
||||
recipients=None,
|
||||
sender="",
|
||||
subject="No Subject",
|
||||
message="No Message",
|
||||
as_markdown=False,
|
||||
delayed=True,
|
||||
reference_doctype=None,
|
||||
reference_name=None,
|
||||
unsubscribe_method=None,
|
||||
unsubscribe_params=None,
|
||||
unsubscribe_message=None,
|
||||
add_unsubscribe_link=1,
|
||||
attachments=None,
|
||||
content=None,
|
||||
doctype=None,
|
||||
name=None,
|
||||
reply_to=None,
|
||||
queue_separately=False,
|
||||
cc=None,
|
||||
bcc=None,
|
||||
message_id=None,
|
||||
in_reply_to=None,
|
||||
send_after=None,
|
||||
expose_recipients=None,
|
||||
send_priority=1,
|
||||
communication=None,
|
||||
retry=1,
|
||||
now=None,
|
||||
read_receipt=None,
|
||||
is_notification=False,
|
||||
inline_images=None,
|
||||
template=None,
|
||||
args=None,
|
||||
header=None,
|
||||
print_letterhead=False,
|
||||
with_container=False,
|
||||
email_read_tracker_url=None,
|
||||
x_priority: Literal[1, 3, 5] = 3,
|
||||
email_headers=None,
|
||||
) -> Optional["EmailQueue"]:
|
||||
"""Send email using user's default **Email Account** or global default **Email Account**.
|
||||
|
||||
|
||||
:param recipients: List of recipients.
|
||||
:param sender: Email sender. Default is current user or default outgoing account.
|
||||
:param subject: Email Subject.
|
||||
:param message: (or `content`) Email Content.
|
||||
:param as_markdown: Convert content markdown to HTML.
|
||||
:param delayed: Send via scheduled email sender **Email Queue**. Don't send immediately. Default is true
|
||||
:param send_priority: Priority for Email Queue, default 1.
|
||||
:param reference_doctype: (or `doctype`) Append as communication to this DocType.
|
||||
:param reference_name: (or `name`) Append as communication to this document name.
|
||||
:param unsubscribe_method: Unsubscribe url with options email, doctype, name. e.g. `/api/method/unsubscribe`
|
||||
:param unsubscribe_params: Unsubscribe paramaters to be loaded on the unsubscribe_method [optional] (dict).
|
||||
:param attachments: List of attachments.
|
||||
:param reply_to: Reply-To Email Address.
|
||||
:param message_id: Used for threading. If a reply is received to this email, Message-Id is sent back as In-Reply-To in received email.
|
||||
:param in_reply_to: Used to send the Message-Id of a received email back as In-Reply-To.
|
||||
:param send_after: Send after the given datetime.
|
||||
:param expose_recipients: Display all recipients in the footer message - "This email was sent to"
|
||||
:param communication: Communication link to be set in Email Queue record
|
||||
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
|
||||
:param template: Name of html template from templates/emails folder
|
||||
:param args: Arguments for rendering the template
|
||||
:param header: Append header in email
|
||||
:param with_container: Wraps email inside a styled container
|
||||
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
|
||||
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
|
||||
"""
|
||||
|
||||
if recipients is None:
|
||||
recipients = []
|
||||
if cc is None:
|
||||
cc = []
|
||||
if bcc is None:
|
||||
bcc = []
|
||||
|
||||
text_content = None
|
||||
if template:
|
||||
message, text_content = get_email_from_template(template, args)
|
||||
|
||||
message = content or message
|
||||
|
||||
if as_markdown:
|
||||
from frappe.utils import md_to_html
|
||||
|
||||
message = md_to_html(message)
|
||||
|
||||
if not delayed:
|
||||
now = True
|
||||
|
||||
from frappe.email.doctype.email_queue.email_queue import QueueBuilder
|
||||
|
||||
builder = QueueBuilder(
|
||||
recipients=recipients,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
message=message,
|
||||
text_content=text_content,
|
||||
reference_doctype=doctype or reference_doctype,
|
||||
reference_name=name or reference_name,
|
||||
add_unsubscribe_link=add_unsubscribe_link,
|
||||
unsubscribe_method=unsubscribe_method,
|
||||
unsubscribe_params=unsubscribe_params,
|
||||
unsubscribe_message=unsubscribe_message,
|
||||
attachments=attachments,
|
||||
reply_to=reply_to,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
message_id=message_id,
|
||||
in_reply_to=in_reply_to,
|
||||
send_after=send_after,
|
||||
expose_recipients=expose_recipients,
|
||||
send_priority=send_priority,
|
||||
queue_separately=queue_separately,
|
||||
communication=communication,
|
||||
read_receipt=read_receipt,
|
||||
is_notification=is_notification,
|
||||
inline_images=inline_images,
|
||||
header=header,
|
||||
print_letterhead=print_letterhead,
|
||||
with_container=with_container,
|
||||
email_read_tracker_url=email_read_tracker_url,
|
||||
x_priority=x_priority,
|
||||
email_headers=email_headers,
|
||||
)
|
||||
|
||||
# build email queue and send the email if send_now is True.
|
||||
return builder.process(send_now=now)
|
||||
|
||||
|
||||
whitelisted: set[Callable] = set()
|
||||
guest_methods: set[Callable] = set()
|
||||
xss_safe_methods: set[Callable] = set()
|
||||
allowed_http_methods_for_whitelisted_func: dict[Callable, list[str]] = {}
|
||||
|
||||
|
||||
def _in_request_or_test():
|
||||
"""
|
||||
Internal
|
||||
|
||||
Used by whitelist to determine whether type hints should be validated or not
|
||||
"""
|
||||
|
||||
return getattr(local, "request", None) or in_test
|
||||
|
||||
|
||||
def whitelist(allow_guest=False, xss_safe=False, methods=None):
|
||||
"""
|
||||
Decorator for whitelisting a function and making it accessible via HTTP.
|
||||
|
|
@ -642,17 +440,8 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
|
|||
|
||||
global whitelisted, guest_methods, xss_safe_methods, allowed_http_methods_for_whitelisted_func
|
||||
|
||||
# validate argument types only if request is present
|
||||
in_request_or_test = lambda: getattr(local, "request", None) or in_test # noqa: E731
|
||||
|
||||
# get function from the unbound / bound method
|
||||
# this is needed because functions can be compared, but not methods
|
||||
method = None
|
||||
if hasattr(fn, "__func__"):
|
||||
method = validate_argument_types(fn, apply_condition=in_request_or_test)
|
||||
fn = method.__func__
|
||||
else:
|
||||
fn = validate_argument_types(fn, apply_condition=in_request_or_test)
|
||||
# validate argument types if request is present or in test context
|
||||
fn = validate_argument_types(fn, apply_condition=_in_request_or_test)
|
||||
|
||||
whitelisted.add(fn)
|
||||
allowed_http_methods_for_whitelisted_func[fn] = methods
|
||||
|
|
@ -663,7 +452,7 @@ def whitelist(allow_guest=False, xss_safe=False, methods=None):
|
|||
if xss_safe:
|
||||
xss_safe_methods.add(fn)
|
||||
|
||||
return method or fn
|
||||
return fn
|
||||
|
||||
return innerfn
|
||||
|
||||
|
|
@ -743,7 +532,7 @@ def only_for(roles: list[str] | tuple[str] | str, message=False):
|
|||
:param roles: Permitted role(s)
|
||||
"""
|
||||
|
||||
if in_test or local.session.user == "Administrator":
|
||||
if local.session.user == "Administrator":
|
||||
return
|
||||
|
||||
if isinstance(roles, str):
|
||||
|
|
@ -910,30 +699,6 @@ def generate_hash(txt: str | None = None, length: int = 56) -> str:
|
|||
return secrets.token_hex(math.ceil(length / 2))[:length]
|
||||
|
||||
|
||||
def new_doc(
|
||||
doctype: str,
|
||||
*,
|
||||
parent_doc: Optional["Document"] = None,
|
||||
parentfield: str | None = None,
|
||||
as_dict: bool = False,
|
||||
**kwargs,
|
||||
) -> "Document":
|
||||
"""Return a new document of the given DocType with defaults set.
|
||||
|
||||
:param doctype: DocType of the new document.
|
||||
:param parent_doc: [optional] add to parent document.
|
||||
:param parentfield: [optional] add against this `parentfield`.
|
||||
:param as_dict: [optional] return as dictionary instead of Document.
|
||||
:param kwargs: [optional] You can specify fields as field=value pairs in function call.
|
||||
"""
|
||||
|
||||
from frappe.model.create_new import get_new_doc
|
||||
|
||||
new_doc = get_new_doc(doctype, parent_doc, parentfield, as_dict=as_dict)
|
||||
|
||||
return new_doc.update(kwargs)
|
||||
|
||||
|
||||
def set_value(doctype, docname, fieldname, value=None):
|
||||
"""Set document value. Calls `frappe.client.set_value`"""
|
||||
import frappe.client
|
||||
|
|
@ -941,118 +706,6 @@ def set_value(doctype, docname, fieldname, value=None):
|
|||
return frappe.client.set_value(doctype, docname, fieldname, value)
|
||||
|
||||
|
||||
def get_cached_doc(*args: Any, **kwargs: Any) -> "Document":
|
||||
"""Identical to `frappe.get_doc`, but return from cache if available."""
|
||||
if (key := can_cache_doc(args)) and (doc := cache.get_value(key)):
|
||||
return doc
|
||||
|
||||
# Not found in cache, fetch from DB
|
||||
doc = get_doc(*args, **kwargs)
|
||||
|
||||
# Store in cache
|
||||
if not key:
|
||||
key = get_document_cache_key(doc.doctype, doc.name)
|
||||
|
||||
_set_document_in_cache(key, doc)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
def _set_document_in_cache(key: str, doc: "Document") -> None:
|
||||
cache.set_value(key, doc, expires_in_sec=3600)
|
||||
|
||||
|
||||
def can_cache_doc(args) -> str | None:
|
||||
"""
|
||||
Determine if document should be cached based on get_doc params.
|
||||
Return cache key if doc can be cached, None otherwise.
|
||||
"""
|
||||
|
||||
if not args:
|
||||
return
|
||||
|
||||
doctype = args[0]
|
||||
name = doctype if len(args) == 1 or args[1] is None else args[1]
|
||||
|
||||
# Only cache if both doctype and name are strings
|
||||
if isinstance(doctype, str) and isinstance(name, str):
|
||||
return get_document_cache_key(doctype, name)
|
||||
|
||||
|
||||
def get_document_cache_key(doctype: str, name: str):
|
||||
return f"document_cache::{doctype}::{name}"
|
||||
|
||||
|
||||
def clear_document_cache(doctype: str, name: str | None = None) -> None:
|
||||
frappe.db.value_cache.pop(doctype, None)
|
||||
|
||||
def clear_in_redis():
|
||||
if name is not None:
|
||||
cache.delete_value(get_document_cache_key(doctype, name))
|
||||
else:
|
||||
cache.delete_keys(get_document_cache_key(doctype, ""))
|
||||
|
||||
clear_in_redis()
|
||||
if hasattr(db, "after_commit"):
|
||||
db.after_commit.add(clear_in_redis)
|
||||
db.after_rollback.add(clear_in_redis)
|
||||
|
||||
if doctype == "System Settings" and hasattr(local, "system_settings"):
|
||||
delattr(local, "system_settings")
|
||||
|
||||
if doctype == "Website Settings" and hasattr(local, "website_settings"):
|
||||
delattr(local, "website_settings")
|
||||
|
||||
|
||||
def get_cached_value(
|
||||
doctype: str, name: str | dict, fieldname: str | Iterable[str] = "name", as_dict: bool = False
|
||||
) -> Any:
|
||||
try:
|
||||
doc = get_cached_doc(doctype, name)
|
||||
except DoesNotExistError:
|
||||
clear_last_message()
|
||||
return
|
||||
|
||||
if isinstance(fieldname, str):
|
||||
if as_dict:
|
||||
throw("Cannot make dict for single fieldname")
|
||||
return doc.get(fieldname)
|
||||
|
||||
values = [doc.get(f) for f in fieldname]
|
||||
if as_dict:
|
||||
return _dict(zip(fieldname, values, strict=False))
|
||||
return values
|
||||
|
||||
|
||||
def get_single_value(setting: str, fieldname: str, /, *, as_dict: bool = False):
|
||||
"""Return the cached value associated with the given fieldname from single DocType.
|
||||
|
||||
Usage:
|
||||
telemetry_enabled = frappe.get_single_value("System Settings", "telemetry_enabled")
|
||||
"""
|
||||
return get_cached_value(setting, setting, fieldname=fieldname, as_dict=as_dict)
|
||||
|
||||
|
||||
def get_last_doc(
|
||||
doctype,
|
||||
filters: FilterSignature | None = None,
|
||||
order_by="creation desc",
|
||||
*,
|
||||
for_update=False,
|
||||
):
|
||||
"""Get last created document of this type."""
|
||||
d = get_all(doctype, filters=filters, limit_page_length=1, order_by=order_by, pluck="name")
|
||||
if d:
|
||||
return get_doc(doctype, d[0], for_update=for_update)
|
||||
else:
|
||||
raise DoesNotExistError(doctype=doctype)
|
||||
|
||||
|
||||
def get_single(doctype):
|
||||
"""Return a `frappe.model.document.Document` object of the given Single doctype."""
|
||||
return get_doc(doctype, doctype)
|
||||
|
||||
|
||||
def get_meta_module(doctype):
|
||||
import frappe.modules
|
||||
|
||||
|
|
@ -1096,11 +749,6 @@ def delete_doc(
|
|||
)
|
||||
|
||||
|
||||
def delete_doc_if_exists(doctype, name, force=0):
|
||||
"""Delete document if exists."""
|
||||
delete_doc(doctype, name, force=force, ignore_missing=True)
|
||||
|
||||
|
||||
def reload_doctype(doctype, force=False, reset_permissions=False):
|
||||
"""Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files."""
|
||||
reload_doc(
|
||||
|
|
@ -1164,7 +812,7 @@ def rename_doc(
|
|||
)
|
||||
|
||||
|
||||
def get_module(modulename: str) -> "ModuleType":
|
||||
def get_module(modulename: str):
|
||||
"""Return a module object for given Python module name using `importlib.import_module`."""
|
||||
return importlib.import_module(modulename)
|
||||
|
||||
|
|
@ -1265,7 +913,7 @@ def get_installed_apps(*, _ensure_on_bench: bool = False) -> list[str]:
|
|||
if not db:
|
||||
connect()
|
||||
|
||||
installed = json.loads(db.get_global("installed_apps") or "[]")
|
||||
installed = orjson.loads(db.get_global("installed_apps") or "[]")
|
||||
|
||||
if _ensure_on_bench:
|
||||
all_apps = cache.get_value("all_apps", get_all_apps)
|
||||
|
|
@ -1523,7 +1171,7 @@ def get_newargs(fn: Callable, kwargs: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def make_property_setter(
|
||||
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True
|
||||
args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True, *, module=None
|
||||
):
|
||||
"""Create a new **Property Setter** (for overriding DocType and DocField properties).
|
||||
|
||||
|
|
@ -1562,6 +1210,7 @@ def make_property_setter(
|
|||
"doctype": "Property Setter",
|
||||
"doctype_or_field": args.doctype_or_field,
|
||||
"doc_type": doctype,
|
||||
"module": module,
|
||||
"field_name": args.fieldname,
|
||||
"row_name": args.row_name,
|
||||
"property": args.property,
|
||||
|
|
@ -1584,50 +1233,6 @@ def import_doc(path):
|
|||
import_doc(path)
|
||||
|
||||
|
||||
def copy_doc(doc: "Document", ignore_no_copy: bool = True) -> "Document":
|
||||
"""No_copy fields also get copied."""
|
||||
import copy
|
||||
from types import MappingProxyType
|
||||
|
||||
from frappe.model.base_document import BaseDocument
|
||||
|
||||
def remove_no_copy_fields(d):
|
||||
for df in d.meta.get("fields", {"no_copy": 1}):
|
||||
if hasattr(d, df.fieldname):
|
||||
d.set(df.fieldname, None)
|
||||
|
||||
fields_to_clear = ["name", "owner", "creation", "modified", "modified_by"]
|
||||
|
||||
if not in_test:
|
||||
fields_to_clear.append("docstatus")
|
||||
|
||||
if isinstance(doc, BaseDocument) or hasattr(doc, "as_dict"):
|
||||
d = doc.as_dict()
|
||||
elif isinstance(doc, MappingProxyType): # global test record
|
||||
d = dict(doc)
|
||||
else:
|
||||
d = doc
|
||||
|
||||
newdoc = get_doc(copy.deepcopy(d))
|
||||
newdoc.set("__islocal", 1)
|
||||
for fieldname in [*fields_to_clear, "amended_from", "amendment_date"]:
|
||||
newdoc.set(fieldname, None)
|
||||
|
||||
if not ignore_no_copy:
|
||||
remove_no_copy_fields(newdoc)
|
||||
|
||||
for d in newdoc.get_all_children():
|
||||
d.set("__islocal", 1)
|
||||
|
||||
for fieldname in fields_to_clear:
|
||||
d.set(fieldname, None)
|
||||
|
||||
if not ignore_no_copy:
|
||||
remove_no_copy_fields(d)
|
||||
|
||||
return newdoc
|
||||
|
||||
|
||||
def respond_as_web_page(
|
||||
title,
|
||||
html,
|
||||
|
|
@ -1831,59 +1436,6 @@ def are_emails_muted():
|
|||
from frappe.deprecation_dumpster import frappe_get_test_records as get_test_records
|
||||
|
||||
|
||||
def attach_print(
|
||||
doctype,
|
||||
name,
|
||||
file_name=None,
|
||||
print_format=None,
|
||||
style=None,
|
||||
html=None,
|
||||
doc=None,
|
||||
lang=None,
|
||||
print_letterhead=True,
|
||||
password=None,
|
||||
letterhead=None,
|
||||
):
|
||||
from frappe.translate import print_language
|
||||
from frappe.utils import scrub_urls
|
||||
from frappe.utils.pdf import get_pdf
|
||||
|
||||
print_settings = db.get_singles_dict("Print Settings")
|
||||
|
||||
kwargs = dict(
|
||||
print_format=print_format,
|
||||
style=style,
|
||||
doc=doc,
|
||||
no_letterhead=not print_letterhead,
|
||||
letterhead=letterhead,
|
||||
password=password,
|
||||
)
|
||||
|
||||
local.flags.ignore_print_permissions = True
|
||||
|
||||
with print_language(lang or local.lang):
|
||||
content = ""
|
||||
if cint(print_settings.send_print_as_pdf):
|
||||
ext = ".pdf"
|
||||
kwargs["as_pdf"] = True
|
||||
content = (
|
||||
get_pdf(html, options={"password": password} if password else None)
|
||||
if html
|
||||
else get_print(doctype, name, **kwargs)
|
||||
)
|
||||
else:
|
||||
ext = ".html"
|
||||
content = html or scrub_urls(get_print(doctype, name, **kwargs)).encode("utf-8")
|
||||
|
||||
local.flags.ignore_print_permissions = False
|
||||
|
||||
if not file_name:
|
||||
file_name = name
|
||||
file_name = cstr(file_name).replace(" ", "").replace("/", "-") + ext
|
||||
|
||||
return {"fname": file_name, "fcontent": content}
|
||||
|
||||
|
||||
def task(**task_kwargs):
|
||||
def decorator_task(f):
|
||||
f.enqueue = lambda **fun_kwargs: enqueue(f, **task_kwargs, **fun_kwargs)
|
||||
|
|
@ -1920,16 +1472,20 @@ def logger(
|
|||
)
|
||||
|
||||
|
||||
def get_desk_link(doctype, name, show_title_with_name=False):
|
||||
def get_desk_link(doctype, name, show_title_with_name=False, open_in_new_tab=False):
|
||||
meta = get_meta(doctype)
|
||||
title = get_value(doctype, name, meta.get_title_field())
|
||||
|
||||
if show_title_with_name and name != title:
|
||||
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
|
||||
else:
|
||||
html = '<a href="/app/Form/{doctype}/{name}" style="font-weight: bold;">{doctype_local} {title_local}</a>'
|
||||
target_attr = ' target="_blank"' if open_in_new_tab else ""
|
||||
|
||||
return html.format(doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title))
|
||||
if show_title_with_name and name != title:
|
||||
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {name}: {title_local}</a>'
|
||||
else:
|
||||
html = '<a href="/app/Form/{doctype}/{name}"{target} style="font-weight: bold;">{doctype_local} {title_local}</a>'
|
||||
|
||||
return html.format(
|
||||
doctype=doctype, name=name, doctype_local=_(doctype), title_local=_(title), target=target_attr
|
||||
)
|
||||
|
||||
|
||||
def get_website_settings(key):
|
||||
|
|
@ -1997,17 +1553,33 @@ import frappe._optimizations
|
|||
from frappe.cache_manager import clear_cache, reset_metadata_version
|
||||
from frappe.config import get_common_site_config, get_conf, get_site_config
|
||||
from frappe.core.doctype.system_settings.system_settings import get_system_settings
|
||||
from frappe.model.document import get_doc, get_lazy_doc
|
||||
from frappe.model.document import (
|
||||
get_doc,
|
||||
get_lazy_doc,
|
||||
copy_doc,
|
||||
new_doc,
|
||||
get_cached_doc,
|
||||
can_cache_doc,
|
||||
get_document_cache_key,
|
||||
clear_document_cache,
|
||||
get_cached_value,
|
||||
get_single_value,
|
||||
get_last_doc,
|
||||
get_single,
|
||||
_set_document_in_cache,
|
||||
)
|
||||
from frappe.model.meta import get_meta
|
||||
from frappe.realtime import publish_progress, publish_realtime
|
||||
from frappe.utils import get_traceback, mock, parse_json, safe_eval
|
||||
from frappe.utils import get_traceback, mock, parse_json, safe_eval, create_folder
|
||||
from frappe.utils.background_jobs import enqueue, enqueue_doc
|
||||
from frappe.utils.error import log_error
|
||||
from frappe.utils.formatters import format_value
|
||||
from frappe.utils.print_utils import get_print
|
||||
from frappe.utils.print_utils import get_print, attach_print
|
||||
from frappe.email import sendmail
|
||||
|
||||
# for backwards compatibility
|
||||
format = format_value
|
||||
delete_doc_if_exists = delete_doc
|
||||
|
||||
frappe._optimizations.optimize_all()
|
||||
frappe._optimizations.register_fault_handler()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ class LoginManager:
|
|||
self.make_session(resume=True)
|
||||
self.get_user_info()
|
||||
self.set_user_info(resume=True)
|
||||
except AttributeError:
|
||||
except (AttributeError, frappe.DoesNotExistError):
|
||||
self.user = "Guest"
|
||||
self.get_user_info()
|
||||
self.make_session()
|
||||
|
|
@ -704,6 +704,9 @@ def validate_auth_via_api_keys(authorization_header):
|
|||
|
||||
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
|
||||
"""frappe_authorization_source to provide api key and secret for a doctype apart from User"""
|
||||
if not api_key or not api_secret:
|
||||
raise frappe.AuthenticationError
|
||||
|
||||
doctype = frappe_authorization_source or "User"
|
||||
docname = frappe.db.get_value(
|
||||
doctype=doctype, filters={"api_key": api_key, "enabled": True}, fieldname=["name"]
|
||||
|
|
@ -711,8 +714,8 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
|
|||
if not docname:
|
||||
raise frappe.AuthenticationError
|
||||
form_dict = frappe.local.form_dict
|
||||
doc_secret = get_decrypted_password(doctype, docname, fieldname="api_secret")
|
||||
if api_secret == doc_secret:
|
||||
doc_secret = get_decrypted_password(doctype, docname, fieldname="api_secret", raise_exception=False)
|
||||
if doc_secret and api_secret == doc_secret:
|
||||
if doctype == "User":
|
||||
user = frappe.db.get_value(doctype="User", filters={"api_key": api_key}, fieldname=["name"])
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@
|
|||
"repeat_on_last_day",
|
||||
"column_break_12",
|
||||
"next_schedule_date",
|
||||
"section_break_looa",
|
||||
"generate_separate_documents_for_each_assignee",
|
||||
"assignee",
|
||||
"section_break_16",
|
||||
"repeat_on_days",
|
||||
"notification",
|
||||
|
|
@ -86,7 +89,7 @@
|
|||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Frequency",
|
||||
"options": "\nDaily\nWeekly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
|
||||
"options": "\nDaily\nWeekly\nFortnightly\nMonthly\nQuarterly\nHalf-yearly\nYearly",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
@ -198,10 +201,26 @@
|
|||
"depends_on": "eval:doc.frequency==='Weekly';",
|
||||
"fieldname": "section_break_16",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "generate_separate_documents_for_each_assignee",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate Separate Documents For Each Assignee"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_looa",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "assignee",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Assignee",
|
||||
"options": "Auto Repeat User"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2025-01-20 14:15:55.287788",
|
||||
"modified": "2025-06-09 18:20:23.775881",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Auto Repeat",
|
||||
|
|
@ -245,10 +264,11 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "reference_document",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "reference_document",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from frappe.contacts.doctype.contact.contact import (
|
|||
get_contacts_linking_to,
|
||||
)
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form import assign_to
|
||||
from frappe.desk.form.assign_to import add as assign_to
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
|
|
@ -49,11 +49,16 @@ class AutoRepeat(Document):
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.automation.doctype.auto_repeat_day.auto_repeat_day import AutoRepeatDay
|
||||
from frappe.automation.doctype.auto_repeat_user.auto_repeat_user import AutoRepeatUser
|
||||
from frappe.types import DF
|
||||
|
||||
assignee: DF.TableMultiSelect[AutoRepeatUser]
|
||||
disabled: DF.Check
|
||||
end_date: DF.Date | None
|
||||
frequency: DF.Literal["", "Daily", "Weekly", "Monthly", "Quarterly", "Half-yearly", "Yearly"]
|
||||
frequency: DF.Literal[
|
||||
"", "Daily", "Weekly", "Fortnightly", "Monthly", "Quarterly", "Half-yearly", "Yearly"
|
||||
]
|
||||
generate_separate_documents_for_each_assignee: DF.Check
|
||||
message: DF.Text | None
|
||||
next_schedule_date: DF.Date | None
|
||||
notify_by_email: DF.Check
|
||||
|
|
@ -219,9 +224,16 @@ class AutoRepeat(Document):
|
|||
|
||||
def create_documents(self):
|
||||
try:
|
||||
new_doc = self.make_new_document()
|
||||
if self.generate_separate_documents_for_each_assignee and self.assignee:
|
||||
new_docs = self.make_new_documents()
|
||||
else:
|
||||
new_docs = self.make_new_document([assignee.user for assignee in self.assignee])
|
||||
if self.notify_by_email and self.recipients:
|
||||
self.send_notification(new_doc)
|
||||
if isinstance(new_docs, list):
|
||||
for new_doc in new_docs:
|
||||
self.send_notification(new_doc)
|
||||
else:
|
||||
self.send_notification(new_docs)
|
||||
except Exception:
|
||||
error_log = self.log_error(
|
||||
_("Auto repeat failed. Please enable auto repeat after fixing the issues.")
|
||||
|
|
@ -232,7 +244,14 @@ class AutoRepeat(Document):
|
|||
if self.reference_document and not frappe.in_test:
|
||||
self.notify_error_to_user(error_log)
|
||||
|
||||
def make_new_document(self):
|
||||
def make_new_documents(self):
|
||||
docs = []
|
||||
for assignee in self.assignee:
|
||||
new_doc = self.make_new_document(assignee=[assignee.user])
|
||||
docs.append(new_doc)
|
||||
return docs
|
||||
|
||||
def make_new_document(self, assignee=None):
|
||||
reference_doc = frappe.get_doc(self.reference_doctype, self.reference_document)
|
||||
new_doc = frappe.copy_doc(reference_doc, ignore_no_copy=False)
|
||||
self.update_doc(new_doc, reference_doc)
|
||||
|
|
@ -242,7 +261,14 @@ class AutoRepeat(Document):
|
|||
"label": _("via Auto Repeat"),
|
||||
}
|
||||
new_doc.insert(ignore_permissions=True)
|
||||
|
||||
if assignee:
|
||||
args = {
|
||||
"assign_to": assignee,
|
||||
"doctype": self.reference_doctype,
|
||||
"name": new_doc.name,
|
||||
"description": new_doc.get_title(),
|
||||
}
|
||||
assign_to(args=args)
|
||||
if self.submit_on_creation:
|
||||
new_doc.submit()
|
||||
|
||||
|
|
@ -348,6 +374,8 @@ class AutoRepeat(Document):
|
|||
def get_days(self, schedule_date):
|
||||
if self.frequency == "Weekly":
|
||||
days = self.get_offset_for_weekly_frequency(schedule_date)
|
||||
elif self.frequency == "Fortnightly":
|
||||
days = 14
|
||||
else:
|
||||
# daily frequency
|
||||
days = 1
|
||||
|
|
|
|||
|
|
@ -85,6 +85,32 @@ class TestAutoRepeat(IntegrationTestCase):
|
|||
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
|
||||
def test_fortnightly_auto_repeat(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test fortnightly todo", assigned_by="Administrator"
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(
|
||||
reference_doctype="ToDo",
|
||||
frequency="Fortnightly",
|
||||
reference_document=todo.name,
|
||||
start_date=add_days(today(), -14),
|
||||
)
|
||||
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
new_todo = frappe.get_doc("ToDo", new_todo)
|
||||
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
|
||||
def test_weekly_auto_repeat_with_weekdays(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test auto repeat with weekdays", assigned_by="Administrator"
|
||||
|
|
@ -221,6 +247,68 @@ class TestAutoRepeat(IntegrationTestCase):
|
|||
)
|
||||
self.assertEqual(docnames[0].docstatus, 1)
|
||||
|
||||
def test_auto_repeat_assignee(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo", description="test assignee todo", assigned_by="Administrator"
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(reference_document=todo.name)
|
||||
doc.update(
|
||||
{
|
||||
"assignee": [
|
||||
{"user": "Administrator"},
|
||||
{"user": "Guest"},
|
||||
]
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo = frappe.db.get_value("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
new_todo = frappe.get_doc("ToDo", new_todo)
|
||||
self.assertEqual(todo.get("description"), new_todo.get("description"))
|
||||
self.assertListEqual(
|
||||
sorted(list(new_todo.get_assigned_users())),
|
||||
sorted(["Administrator", "Guest"]),
|
||||
)
|
||||
|
||||
def test_auto_repeat_assignee_with_separate_documents(self):
|
||||
todo = frappe.get_doc(
|
||||
doctype="ToDo",
|
||||
description="test assignee todo with multiple doc",
|
||||
assigned_by="Administrator",
|
||||
).insert()
|
||||
|
||||
doc = make_auto_repeat(reference_document=todo.name)
|
||||
doc.update(
|
||||
{
|
||||
"assignee": [
|
||||
{"user": "Administrator"},
|
||||
{"user": "Guest"},
|
||||
],
|
||||
"generate_separate_documents_for_each_assignee": 1,
|
||||
}
|
||||
)
|
||||
doc.save()
|
||||
self.assertEqual(doc.next_schedule_date, today())
|
||||
data = get_auto_repeat_entries(getdate(today()))
|
||||
create_repeated_entries(data)
|
||||
frappe.db.commit()
|
||||
|
||||
todo = frappe.get_doc(doc.reference_doctype, doc.reference_document)
|
||||
self.assertEqual(todo.auto_repeat, doc.name)
|
||||
|
||||
new_todo_count = frappe.db.count("ToDo", {"auto_repeat": doc.name, "name": ("!=", todo.name)}, "name")
|
||||
|
||||
self.assertEqual(new_todo_count, 2)
|
||||
|
||||
|
||||
def make_auto_repeat(**args):
|
||||
args = frappe._dict(args)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-06-09 18:19:22.034128",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"user"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-09 18:19:41.543336",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Automation",
|
||||
"name": "Auto Repeat User",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2021, Frappe Technologies and contributors
|
||||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class NewsletterAttachment(Document):
|
||||
class AutoRepeatUser(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
|
@ -14,10 +14,10 @@ class NewsletterAttachment(Document):
|
|||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
attachment: DF.Attach
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
user: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
126
frappe/core/api/user_invitation.py
Normal file
126
frappe/core/api/user_invitation.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.core.doctype.user_invitation.user_invitation import UserInvitation
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def invite_by_email(
|
||||
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe"
|
||||
) -> dict[str, list[str]]:
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
# validate emails
|
||||
frappe.utils.validate_email_address(emails, throw=True)
|
||||
email_list = frappe.utils.split_emails(emails)
|
||||
if not email_list:
|
||||
frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite"))
|
||||
|
||||
# get relevant data from the database
|
||||
accepted_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"email": ["in", email_list], "status": "Accepted", "app_name": app_name},
|
||||
pluck="email",
|
||||
)
|
||||
pending_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"email": ["in", email_list], "status": "Pending", "app_name": app_name},
|
||||
pluck="email",
|
||||
)
|
||||
|
||||
# create invitation documents
|
||||
to_invite = list(set(email_list) - set(accepted_invite_emails) - set(pending_invite_emails))
|
||||
for email in to_invite:
|
||||
frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=email,
|
||||
roles=[dict(role=role) for role in roles],
|
||||
app_name=app_name,
|
||||
redirect_to_path=redirect_to_path,
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return {
|
||||
"accepted_invite_emails": accepted_invite_emails,
|
||||
"pending_invite_emails": pending_invite_emails,
|
||||
"invited_emails": to_invite,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True, methods=["GET"])
|
||||
def accept_invitation(key: str) -> None:
|
||||
_accept_invitation(key, False)
|
||||
|
||||
|
||||
# `app_name` is required for security
|
||||
@frappe.whitelist(methods=["PATCH", "POST"])
|
||||
def cancel_invitation(name: str, app_name: str):
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
if not frappe.db.exists("User Invitation", name):
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
|
||||
|
||||
invitation = frappe.get_doc("User Invitation", name)
|
||||
if invitation.app_name != app_name:
|
||||
# message is not specific enough for security
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
|
||||
|
||||
if invitation.status == "Cancelled":
|
||||
return {"cancelled_now": False}
|
||||
|
||||
if invitation.status != "Pending":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation cannot be cancelled"))
|
||||
|
||||
invitation.flags.ignore_permissions = True
|
||||
return {"cancelled_now": invitation.cancel_invite()}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
def get_pending_invitations(app_name: str):
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
pending_invitations = frappe.db.get_all(
|
||||
"User Invitation", fields=["name", "email"], filters={"status": "Pending", "app_name": app_name}
|
||||
)
|
||||
res = []
|
||||
for pending_invitation in pending_invitations:
|
||||
roles = frappe.db.get_all("User Role", fields=["role"], filters={"parent": pending_invitation.name})
|
||||
res.append(
|
||||
{
|
||||
"name": pending_invitation.name,
|
||||
"email": pending_invitation.email,
|
||||
"roles": [r.role for r in roles],
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def _accept_invitation(key: str, in_test: bool) -> None:
|
||||
# get invitation
|
||||
hashed_key = frappe.utils.sha256_hash(key)
|
||||
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
|
||||
if not invitation_name:
|
||||
frappe.throw(title=_("Error"), msg=_("Invalid key"))
|
||||
invitation = frappe.get_doc("User Invitation", invitation_name)
|
||||
|
||||
# accept invitation
|
||||
invitation.accept(ignore_permissions=True)
|
||||
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
should_update_password = not user.last_password_reset_date and not bool(
|
||||
frappe.get_system_settings("disable_user_pass_login")
|
||||
)
|
||||
|
||||
# set redirect_to
|
||||
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
|
||||
if should_update_password:
|
||||
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
|
||||
|
||||
# GET requests do not cause an implicit commit
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
if not in_test and not should_update_password:
|
||||
frappe.local.login_manager.login_as(invitation.email)
|
||||
|
||||
# set response
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = redirect_to
|
||||
|
|
@ -5,11 +5,16 @@ import json
|
|||
import frappe
|
||||
from frappe.templates.includes.comments.comments import add_comment
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.tests.test_helpers import setup_for_tests
|
||||
from frappe.tests.test_model_utils import set_user
|
||||
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["Web Page"]
|
||||
|
||||
|
||||
class TestComment(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
setup_for_tests()
|
||||
|
||||
def test_comment_creation(self):
|
||||
test_doc = frappe.get_doc(doctype="ToDo", description="test")
|
||||
test_doc.insert()
|
||||
|
|
@ -42,16 +47,16 @@ class TestComment(IntegrationTestCase):
|
|||
|
||||
# test via blog
|
||||
def test_public_comment(self):
|
||||
test_blog = make_test_blog()
|
||||
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
|
||||
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
add_comment_args = {
|
||||
"comment": "Good comment with 10 chars",
|
||||
"comment_email": "test@test.com",
|
||||
"comment_by": "Good Tester",
|
||||
"reference_doctype": test_blog.doctype,
|
||||
"reference_name": test_blog.name,
|
||||
"route": test_blog.route,
|
||||
"route": f"blog/{test_blog.doctype}/{test_blog.name}",
|
||||
}
|
||||
add_comment(**add_comment_args)
|
||||
|
||||
|
|
@ -64,7 +69,7 @@ class TestComment(IntegrationTestCase):
|
|||
1,
|
||||
)
|
||||
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
|
||||
add_comment_args.update(comment="pleez vizits my site http://mysite.com", comment_by="bad commentor")
|
||||
add_comment(**add_comment_args)
|
||||
|
|
@ -81,7 +86,7 @@ class TestComment(IntegrationTestCase):
|
|||
)
|
||||
|
||||
# test for filtering html and css injection elements
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
|
||||
add_comment_args.update(comment="<script>alert(1)</script>Comment", comment_by="hacker")
|
||||
add_comment(**add_comment_args)
|
||||
|
|
@ -96,26 +101,10 @@ class TestComment(IntegrationTestCase):
|
|||
|
||||
test_blog.delete()
|
||||
|
||||
@IntegrationTestCase.change_settings("Blog Settings", {"allow_guest_to_comment": 0})
|
||||
def test_guest_cannot_comment(self):
|
||||
test_blog = make_test_blog()
|
||||
with set_user("Guest"):
|
||||
self.assertEqual(
|
||||
add_comment(
|
||||
comment="Good comment with 10 chars",
|
||||
comment_email="mail@example.org",
|
||||
comment_by="Good Tester",
|
||||
reference_doctype="Blog Post",
|
||||
reference_name=test_blog.name,
|
||||
route=test_blog.route,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def test_user_not_logged_in(self):
|
||||
some_system_user = frappe.db.get_value("User", {"name": ("not in", frappe.STANDARD_USERS)})
|
||||
|
||||
test_blog = make_test_blog()
|
||||
test_blog = frappe.get_doc("Web Page", "test-web-page-1")
|
||||
with set_user("Guest"):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
|
|
@ -123,7 +112,7 @@ class TestComment(IntegrationTestCase):
|
|||
comment="Good comment with 10 chars",
|
||||
comment_email=some_system_user,
|
||||
comment_by="Good Tester",
|
||||
reference_doctype="Blog Post",
|
||||
reference_doctype="Web Page",
|
||||
reference_name=test_blog.name,
|
||||
route=test_blog.route,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)})]
|
||||
transform = (lambda v: cstr(v).lower()) if frappe.db.db_type == "mariadb" else cstr
|
||||
values = list({transform(v) for v in self.column_values if v})
|
||||
exists = [
|
||||
transform(d.name) for d in frappe.get_all(self.df.options, filters={"name": ("in", values)})
|
||||
]
|
||||
not_exists = list(set(values) - set(exists))
|
||||
if not_exists:
|
||||
missing_values = ", ".join(not_exists)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"email_append_to",
|
||||
"sender_field",
|
||||
"sender_name_field",
|
||||
"recipient_account_field",
|
||||
"subject_field",
|
||||
"fields_tab",
|
||||
"fields_section",
|
||||
|
|
@ -707,6 +708,12 @@
|
|||
"fieldtype": "Int",
|
||||
"label": "Rows Threshold for Grid Search",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "email_append_to",
|
||||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
|
|
@ -785,7 +792,7 @@
|
|||
"link_fieldname": "document_type"
|
||||
}
|
||||
],
|
||||
"modified": "2025-06-24 07:46:34.380662",
|
||||
"modified": "2025-07-19 12:23:16.296416",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ class DocType(Document):
|
|||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
read_only: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
restrict_to_domain: DF.Link | None
|
||||
route: DF.Data | None
|
||||
row_format: DF.Literal["Dynamic", "Compressed"]
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ frappe.ui.form.on("File", {
|
|||
$preview = $(`<div class="img_preview">
|
||||
<img
|
||||
class="img-responsive"
|
||||
style="max-width: 500px";
|
||||
src="${frappe.utils.escape_html(frm.doc.file_url)}"
|
||||
onerror="${frm.toggle_display("preview", false)}"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -129,6 +129,21 @@ select name from \`tabPerson\`
|
|||
where tenant_id = 2
|
||||
order by creation desc
|
||||
</code></pre>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Workflow Task</h4>
|
||||
<p>Execute when a particular <a href="/app/workflow-action-master">Workflow Action Master</a> is executed.</p>
|
||||
<p>Gets the document which the action is being applied on in the <code>doc</code> variable.</p>
|
||||
<code><pre>
|
||||
# create a customer with the same name as the given document
|
||||
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = doc.first_name + " " + doc.last_name # we get this from the workflow action
|
||||
customer.customer_type = "Company"
|
||||
|
||||
c.save()
|
||||
</code></pre>
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Script Type",
|
||||
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI",
|
||||
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI\nWorkflow Task",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
"link_fieldname": "server_script"
|
||||
}
|
||||
],
|
||||
"modified": "2024-05-08 03:21:54.169380",
|
||||
"modified": "2025-07-03 16:12:29.676150",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Server Script",
|
||||
|
|
@ -171,6 +171,7 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ class ServerScript(Document):
|
|||
rate_limit_seconds: DF.Int
|
||||
reference_doctype: DF.Link | None
|
||||
script: DF.Code
|
||||
script_type: DF.Literal["DocType Event", "Scheduler Event", "Permission Query", "API"]
|
||||
script_type: DF.Literal[
|
||||
"DocType Event", "Scheduler Event", "Permission Query", "API", "Workflow Task"
|
||||
]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
|
|
@ -216,6 +218,19 @@ class ServerScript(Document):
|
|||
if locals["conditions"]:
|
||||
return locals["conditions"]
|
||||
|
||||
def execute_workflow_task(self, doc: Document):
|
||||
"""
|
||||
Specific to Workflow Tasks via Workflow Action Master
|
||||
"""
|
||||
if self.script_type != "Workflow Task":
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
safe_exec(
|
||||
self.script,
|
||||
_locals={"doc": doc},
|
||||
script_filename=self.name,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@http_cache(max_age=10 * 60, stale_while_revalidate=6 * 60 * 60)
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ def send_sms(receiver_list, msg, sender_name="", success_msg=True):
|
|||
"success_msg": success_msg,
|
||||
}
|
||||
|
||||
send_sms_hook_methods = frappe.get_hooks("send_sms")
|
||||
if send_sms_hook_methods:
|
||||
return frappe.get_attr(send_sms_hook_methods[-1])(receiver_list, msg, sender_name, success_msg)
|
||||
|
||||
if frappe.db.get_single_value("SMS Settings", "sms_gateway_url"):
|
||||
send_via_gateway(arg)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,18 @@
|
|||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Translator",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
|
|
@ -108,4 +120,4 @@
|
|||
"states": [],
|
||||
"title_field": "source_text",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -845,11 +845,6 @@
|
|||
"link_doctype": "Contact",
|
||||
"link_fieldname": "user"
|
||||
},
|
||||
{
|
||||
"group": "Profile",
|
||||
"link_doctype": "Blogger",
|
||||
"link_fieldname": "user"
|
||||
},
|
||||
{
|
||||
"group": "Logs",
|
||||
"link_doctype": "Access Log",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from frappe.desk.notifications import clear_notifications
|
|||
from frappe.model.document import Document
|
||||
from frappe.query_builder import DocType
|
||||
from frappe.rate_limiter import rate_limit
|
||||
from frappe.sessions import clear_sessions
|
||||
from frappe.utils import (
|
||||
cint,
|
||||
escape_html,
|
||||
|
|
@ -628,6 +629,9 @@ class User(Document):
|
|||
# set email
|
||||
frappe.db.set_value("User", new_name, "email", new_name)
|
||||
|
||||
clear_sessions(user=old_name, force=True)
|
||||
clear_sessions(user=new_name, force=True)
|
||||
|
||||
def append_roles(self, *roles):
|
||||
"""Add roles to user"""
|
||||
current_roles = {d.role for d in self.get("roles")}
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 217 KiB |
106
frappe/core/doctype/user_invitation/internal_doc/index.md
Normal file
106
frappe/core/doctype/user_invitation/internal_doc/index.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# User Invitation
|
||||
|
||||
## Index
|
||||
|
||||
- [Motivation](#motivation)
|
||||
- [How to use it?](#how-to-use-it)
|
||||
- [Whitelisted functions](#whitelisted-functions)
|
||||
- [`invite_by_email`](#invite_by_email)
|
||||
- [`accept_invitation`](#accept_invitation)
|
||||
- [`get_pending_invitations`](#get_pending_invitations)
|
||||
- [`cancel_invitation`](#cancel_invitation)
|
||||
- [Normal flow](#normal-flow)
|
||||
- [Important points](#important-points)
|
||||
|
||||
## Motivation
|
||||
|
||||
- Until now, there was no way to invite and create a new user based on a sent invitation that can be accepted or rejected by the invitee.
|
||||
- Due to this, custom Framework applications have to implement a user invitation flow. But most of the rules around this flow are generic enough to let Framework store all of the common logic associated with a typical user invitation flow.
|
||||
- This will help ensure consistency and prevent code duplication for custom Framework applications that need this type of feature.
|
||||
|
||||
## How to use it?
|
||||
|
||||
Define user invitation hooks in your app's `hooks.py` file. An example is shown below.
|
||||
|
||||

|
||||
|
||||
- `only_for`
|
||||
|
||||
Roles that are allowed to invite users to your app.
|
||||
|
||||
- `allowed_roles`
|
||||
|
||||
Roles that are allowed to be invited to your app.
|
||||
|
||||
- `after_accept`
|
||||
|
||||
Dot path of the function to execute after the user accepts the invitation.
|
||||
|
||||
```python
|
||||
from frappe.model.document import Document
|
||||
|
||||
def after_accept(
|
||||
invitation: Document,
|
||||
user: Document,
|
||||
user_inserted: bool
|
||||
) -> None:
|
||||
# your business logic here
|
||||
```
|
||||
|
||||
> `after_accept` is optional and should be used only if required.
|
||||
|
||||
At this point, you can start using the whitelisted functions under the `apis` section (`frappe/core/api/user_invitation.py`). For more information, read [whitelisted functions](#whitelisted-functions).
|
||||
|
||||
By default, only `System Manager`s can create a new invitation, view the list of invitations, or view more details associated with a single invitation **using the desk**. To enable users with specific roles to perform the mentioned actions, you might want to provide `create`, `read`, and `write` access to the relevant roles.
|
||||
|
||||
Example - If a user having the `Agent Manager` role should be able to use all of the user invitation features using the desk, these should be enabled:
|
||||
|
||||
- User Invitation doctype
|
||||

|
||||
|
||||
- Role doctype
|
||||

|
||||
|
||||
## Whitelisted functions
|
||||
|
||||
There are a few whitelisted functions that can be used to manage invitations. All of the whitelisted functions are in `frappe/core/api/user_invitation.py`.
|
||||
|
||||
### `invite_by_email`
|
||||
|
||||
Invite new emails to your application.
|
||||
|
||||

|
||||
|
||||
> The invited email will receive an email with a link to accept the invitation.
|
||||
|
||||
### `accept_invitation`
|
||||
|
||||
Enables invitees to accept the sent invitations.
|
||||
|
||||
> This function should not be used directly. The only reason this function is whitelisted is because the sent invitations contain a link that the invitees use to accept the invitations.
|
||||
|
||||
### `get_pending_invitations`
|
||||
|
||||
Get all of the pending invitations associated with an installed Framework application.
|
||||
|
||||

|
||||
|
||||
### `cancel_invitation`
|
||||
|
||||
Cancels a specific pending invitation associated with an installed Framework application.
|
||||
|
||||

|
||||
|
||||
## Normal flow
|
||||
|
||||
1. Invitations are created from the desk or by using the [`invite_by_email`](#invite_by_email) whitelisted function. An email is sent to the invited email with a link to accept the invitation.
|
||||
2. The app administrator or anyone able to use the desk can cancel invitations. Once an invitation is cancelled, an email is sent to the creator of the invitation.
|
||||
3. Once the invitation is accepted, a new user is created (if required) with the roles specified in the invitation and is redirected to the specified path.
|
||||
4. If the invitee doesn't accept the invitation within three days, the invitation is marked as expired by a background job that executes every day. Currently, there is no way to customize the expiration time.
|
||||
|
||||
## Important points:
|
||||
|
||||
- There can't be multiple pending invitations for the same app.
|
||||
- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`.
|
||||
- To manually mark an invitation as expired, you can use the `expire` method on the invitation document.
|
||||
- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
254
frappe/core/doctype/user_invitation/test_user_invitation.py
Normal file
254
frappe/core/doctype/user_invitation/test_user_invitation.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
import re
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe.core.api.user_invitation import (
|
||||
_accept_invitation,
|
||||
cancel_invitation,
|
||||
get_pending_invitations,
|
||||
invite_by_email,
|
||||
)
|
||||
from frappe.core.doctype.user_invitation.user_invitation import mark_expired_invitations
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
emails = [
|
||||
"test_user_invite1@example.com",
|
||||
"test_user_invite2@example.com",
|
||||
"test_user_invite3@example.com",
|
||||
"test_user_invite4@example.com",
|
||||
"test_user_invite5@example.com",
|
||||
]
|
||||
|
||||
|
||||
class IntegrationTestUserInvitation(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for UserInvitation.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
user = frappe.new_doc("User")
|
||||
user.first_name = "Test"
|
||||
user.last_name = "123"
|
||||
user.email = emails[0]
|
||||
user.append_roles("System Manager")
|
||||
user.insert()
|
||||
frappe.set_user(emails[0])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
IntegrationTestUserInvitation.delete_all_invitations()
|
||||
IntegrationTestUserInvitation.delete_all_user_roles()
|
||||
frappe.db.delete("Email Queue")
|
||||
for user_email in emails:
|
||||
if frappe.db.exists("User", user_email):
|
||||
frappe.delete_doc("User", user_email)
|
||||
frappe.set_user("Administrator")
|
||||
# some of the code under test commit internally
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
@classmethod
|
||||
def delete_all_user_roles(cls):
|
||||
frappe.db.sql("DELETE FROM `tabUser Role`")
|
||||
|
||||
@classmethod
|
||||
def delete_all_invitations(cls):
|
||||
frappe.db.sql("DELETE FROM `tabUser Invitation`")
|
||||
|
||||
@classmethod
|
||||
def delete_invitation(cls, name: str):
|
||||
frappe.db.sql(f'DELETE FROM `tabUser Invitation` WHERE name = "{name}"')
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
IntegrationTestUserInvitation.delete_all_invitations()
|
||||
IntegrationTestUserInvitation.delete_all_user_roles()
|
||||
frappe.db.delete("Email Queue")
|
||||
|
||||
def test_insert_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
self.assertEqual(len(self.get_email_names()), 0)
|
||||
invitation.insert()
|
||||
self.assertEqual(invitation.invited_by, frappe.session.user)
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
self.assertIsInstance(invitation.email_sent_at, str)
|
||||
self.assertIsInstance(invitation.key, str)
|
||||
self.assertIsInstance(invitation.roles, list)
|
||||
sent_emails = self.get_email_messages()
|
||||
self.assertEqual(len(sent_emails), 1)
|
||||
self.assertIn("invited", sent_emails[0].message.lower())
|
||||
|
||||
def test_update_invitation_status_to_expired(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names()), 1)
|
||||
invitation.expire()
|
||||
emails = self.get_email_messages(False)
|
||||
self.assertEqual(len(emails), 2)
|
||||
self.assertIn("expired", emails[0].message.lower())
|
||||
|
||||
def test_cancel_pending_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
invitation.cancel_invite()
|
||||
sent_emails = self.get_email_messages(False)
|
||||
self.assertEqual(len(sent_emails), 2)
|
||||
self.assertIn("cancelled", sent_emails[0].message.lower())
|
||||
|
||||
def test_cancel_accepted_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
invitation.status = "Accepted"
|
||||
invitation.save()
|
||||
invitation.cancel_invite()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
|
||||
def test_cancel_expired_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
invitation.expire()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
invitation.cancel_invite()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
|
||||
def test_mark_expired_invitations(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
# the status of invitations older than 3 days should be set to expired
|
||||
invitation.db_set("creation", frappe.utils.add_days(frappe.utils.now(), -4))
|
||||
mark_expired_invitations()
|
||||
invitation.reload()
|
||||
self.assertEqual(invitation.status, "Expired")
|
||||
|
||||
def test_invite_by_email_api(self):
|
||||
accepted_invite_email = emails[1]
|
||||
invitation = frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=accepted_invite_email,
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
invitation.status = "Accepted"
|
||||
invitation.save()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
pending_invite_email = emails[2]
|
||||
frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=pending_invite_email,
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
email_to_invite = emails[3]
|
||||
res = invite_by_email(
|
||||
emails=", ".join([accepted_invite_email, pending_invite_email, email_to_invite]),
|
||||
roles=["System Manager"],
|
||||
redirect_to_path="/xyz",
|
||||
)
|
||||
self.assertSequenceEqual(res["accepted_invite_emails"], [accepted_invite_email])
|
||||
self.assertSequenceEqual(res["pending_invite_emails"], [pending_invite_email])
|
||||
self.assertSequenceEqual(res["invited_emails"], [email_to_invite])
|
||||
self.assertEqual(len(self.get_email_names(False)), 3)
|
||||
|
||||
def test_accept_invitation_api_pass_redirect(self):
|
||||
invitation = frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=emails[1],
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0)
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
key = invitation._after_insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
_accept_invitation(key, True)
|
||||
res = frappe.local.response
|
||||
self.assertEqual(res.type, "redirect")
|
||||
pattern = f"^{re.escape(frappe.utils.get_url(''))}/update-password\\?key=.+&redirect_to=/abc$"
|
||||
self.assertRegex(res.location, pattern)
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
IntegrationTestUserInvitation.delete_invitation(invitation.name)
|
||||
frappe.delete_doc("User", user.name)
|
||||
|
||||
def test_accept_invitation_api_direct_redirect(self):
|
||||
invitation = frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=emails[1],
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0)
|
||||
original_disable_user_pass_login = frappe.get_system_settings("disable_user_pass_login")
|
||||
frappe.db.set_single_value("System Settings", "disable_user_pass_login", 1)
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
key = invitation._after_insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
_accept_invitation(key, True)
|
||||
frappe.db.set_single_value(
|
||||
"System Settings", "disable_user_pass_login", original_disable_user_pass_login
|
||||
)
|
||||
res = frappe.local.response
|
||||
self.assertEqual(res.type, "redirect")
|
||||
pattern = f"^{re.escape(frappe.utils.get_url(''))}/abc$"
|
||||
self.assertRegex(res.location, pattern)
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
IntegrationTestUserInvitation.delete_invitation(invitation.name)
|
||||
frappe.delete_doc("User", user.name)
|
||||
|
||||
def test_get_pending_invitations_api(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
invitation.reload()
|
||||
pending_invitations = get_pending_invitations("frappe")
|
||||
self.assertEqual(len(pending_invitations), 1)
|
||||
pending_invitation = pending_invitations[0]
|
||||
self.assertEqual(pending_invitation["name"], invitation.name)
|
||||
self.assertEqual(pending_invitation["email"], invitation.email)
|
||||
roles = pending_invitation["roles"]
|
||||
self.assertIsInstance(roles, list)
|
||||
self.assertSequenceEqual(roles, [r.role for r in invitation.roles])
|
||||
|
||||
def test_cancel_invitation_api(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
invitation.reload()
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
self.assertEqual(len(self.get_email_names()), 1)
|
||||
res = cancel_invitation(invitation.name, "frappe")
|
||||
self.assertTrue(res["cancelled_now"])
|
||||
invitation.reload()
|
||||
self.assertEqual(invitation.status, "Cancelled")
|
||||
self.assertEqual(len(self.get_email_names()), 2)
|
||||
res = cancel_invitation(invitation.name, "frappe")
|
||||
self.assertFalse(res["cancelled_now"])
|
||||
self.assertEqual(len(self.get_email_names()), 2)
|
||||
|
||||
def get_dummy_invitation(self):
|
||||
return frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=emails[1],
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
)
|
||||
|
||||
def get_email_names(self, sent_only=True):
|
||||
filters = {"status": "Sent"} if sent_only else None
|
||||
return frappe.db.get_all("Email Queue", filters=filters, fields=["name"])
|
||||
|
||||
def get_email_messages(self, sent_only=True):
|
||||
filters = {"status": "Sent"} if sent_only else None
|
||||
return frappe.db.get_all("Email Queue", filters=filters, fields=["message"])
|
||||
23
frappe/core/doctype/user_invitation/user_invitation.js
Normal file
23
frappe/core/doctype/user_invitation/user_invitation.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) 2025, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("User Invitation", {
|
||||
refresh(frm) {
|
||||
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
||||
const apps = r?.map((r) => r.name) ?? [];
|
||||
const default_app = "frappe";
|
||||
frm.set_df_property("app_name", "options", [default_app, ...apps]);
|
||||
if (!frm.doc.app_name) {
|
||||
frm.set_value("app_name", default_app);
|
||||
}
|
||||
});
|
||||
if (frm.doc.__islocal || frm.doc.status !== "Pending") {
|
||||
return;
|
||||
}
|
||||
frm.add_custom_button(__("Cancel"), () => {
|
||||
frappe.confirm(__("Are you sure you want to cancel the invitation?"), () =>
|
||||
frm.call("cancel_invite")
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
143
frappe/core/doctype/user_invitation/user_invitation.json
Normal file
143
frappe/core/doctype/user_invitation/user_invitation.json
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-07-07 14:19:31.014655",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"email",
|
||||
"app_name",
|
||||
"redirect_to_path",
|
||||
"roles",
|
||||
"status",
|
||||
"invited_by",
|
||||
"key",
|
||||
"user",
|
||||
"email_sent_at",
|
||||
"accepted_at"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email",
|
||||
"reqd": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "invited_by",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Invited By",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Pending",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "Pending\nAccepted\nExpired\nCancelled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "email_sent_at",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Email Sent At",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "accepted_at",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Accepted At",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "app_name",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "App Name",
|
||||
"reqd": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "redirect_to_path",
|
||||
"fieldtype": "Data",
|
||||
"label": "Redirect To Path",
|
||||
"read_only_depends_on": "eval:doc.status!==\"Pending\"",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "key",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Key",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "roles",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Roles",
|
||||
"options": "User Role",
|
||||
"read_only_depends_on": "eval:Boolean(doc.creation)",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-26 11:52:46.984800",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User Invitation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [
|
||||
{
|
||||
"color": "Green",
|
||||
"title": "Accepted"
|
||||
},
|
||||
{
|
||||
"color": "Orange",
|
||||
"title": "Pending"
|
||||
},
|
||||
{
|
||||
"color": "Yellow",
|
||||
"title": "Expired"
|
||||
},
|
||||
{
|
||||
"color": "Red",
|
||||
"title": "Cancelled"
|
||||
}
|
||||
]
|
||||
}
|
||||
241
frappe/core/doctype/user_invitation/user_invitation.py
Normal file
241
frappe/core/doctype/user_invitation/user_invitation.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.permissions import get_roles
|
||||
|
||||
|
||||
class UserInvitation(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.core.doctype.user_role.user_role import UserRole
|
||||
from frappe.types import DF
|
||||
|
||||
accepted_at: DF.Datetime | None
|
||||
app_name: DF.Literal[None]
|
||||
email: DF.Data
|
||||
email_sent_at: DF.Datetime | None
|
||||
invited_by: DF.Link | None
|
||||
key: DF.Data | None
|
||||
redirect_to_path: DF.Data
|
||||
roles: DF.TableMultiSelect[UserRole]
|
||||
status: DF.Literal["Pending", "Accepted", "Expired", "Cancelled"]
|
||||
user: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
self._validate_invite()
|
||||
self.invited_by = frappe.session.user
|
||||
self.status = "Pending"
|
||||
|
||||
def after_insert(self):
|
||||
self._after_insert()
|
||||
|
||||
def accept(self, ignore_permissions: bool = False):
|
||||
accepted_now = self._accept()
|
||||
if not accepted_now:
|
||||
return
|
||||
user, user_inserted = self._upsert_user(ignore_permissions)
|
||||
self.save(ignore_permissions)
|
||||
user.save(ignore_permissions)
|
||||
self._run_after_accept_hooks(user, user_inserted)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_invite(self):
|
||||
if self.status != "Pending":
|
||||
return False
|
||||
self.status = "Cancelled"
|
||||
self.save()
|
||||
email_title = self._get_email_title()
|
||||
frappe.sendmail(
|
||||
recipients=self.email,
|
||||
subject=_("Invitation to join {0} cancelled").format(email_title),
|
||||
template="user_invitation_cancelled",
|
||||
args={"title": email_title},
|
||||
now=True,
|
||||
)
|
||||
return True
|
||||
|
||||
@frappe.whitelist()
|
||||
def expire(self):
|
||||
if self.status != "Pending":
|
||||
return
|
||||
self.status = "Expired"
|
||||
self.save()
|
||||
email_title = self._get_email_title()
|
||||
invited_by_user = frappe.get_doc("User", self.invited_by)
|
||||
frappe.sendmail(
|
||||
recipients=invited_by_user.email,
|
||||
subject=_("Invitation to join {0} expired").format(email_title),
|
||||
template="user_invitation_expired",
|
||||
args={"title": email_title},
|
||||
now=False,
|
||||
)
|
||||
|
||||
def _validate_invite(self):
|
||||
self._validate_app_name()
|
||||
self._validate_roles()
|
||||
self._validate_email()
|
||||
if frappe.db.get_value(
|
||||
"User Invitation", filters={"email": self.email, "status": "Accepted", "app_name": self.app_name}
|
||||
):
|
||||
frappe.throw(title=_("Error"), msg=_("invitation already accepted"))
|
||||
if frappe.db.get_value(
|
||||
"User Invitation", filters={"email": self.email, "status": "Pending", "app_name": self.app_name}
|
||||
):
|
||||
frappe.throw(title=_("Error"), msg=_("invitation already exists"))
|
||||
|
||||
def _after_insert(self):
|
||||
key = frappe.generate_hash()
|
||||
self.db_set("key", frappe.utils.sha256_hash(key))
|
||||
invite_link = frappe.utils.get_url(
|
||||
f"/api/method/frappe.core.api.user_invitation.accept_invitation?key={key}"
|
||||
)
|
||||
email_title = self._get_email_title()
|
||||
frappe.sendmail(
|
||||
recipients=self.email,
|
||||
subject=_("You've been invited to join {0}").format(email_title),
|
||||
template="user_invitation",
|
||||
args={"title": email_title, "invite_link": invite_link},
|
||||
now=True,
|
||||
)
|
||||
self.db_set("email_sent_at", frappe.utils.now())
|
||||
return key
|
||||
|
||||
def _accept(self):
|
||||
if self.status == "Accepted":
|
||||
return False
|
||||
if self.status == "Expired":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation is expired"))
|
||||
if self.status == "Cancelled":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation is cancelled"))
|
||||
self.status = "Accepted"
|
||||
self.accepted_at = frappe.utils.now()
|
||||
self.user = self.email
|
||||
return True
|
||||
|
||||
def _upsert_user(self, ignore_permissions: bool = False):
|
||||
user: Document | None = None
|
||||
user_inserted = False
|
||||
if frappe.db.exists("User", self.user):
|
||||
user = frappe.get_doc("User", self.user)
|
||||
else:
|
||||
user = frappe.new_doc("User")
|
||||
user.user_type = "System User"
|
||||
user.email = self.email
|
||||
user.first_name = self.email.split("@")[0].title()
|
||||
user.send_welcome_email = False
|
||||
user.insert(ignore_permissions)
|
||||
user_inserted = True
|
||||
user.append_roles(*[r.role for r in self.roles])
|
||||
return user, user_inserted
|
||||
|
||||
def _run_after_accept_hooks(self, user: Document, user_inserted: bool):
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
|
||||
if not isinstance(user_invitation_hook, dict):
|
||||
return
|
||||
for dot_path in user_invitation_hook.get("after_accept") or []:
|
||||
frappe.call(dot_path, invitation=self, user=user, user_inserted=user_inserted)
|
||||
|
||||
def _get_email_title(self):
|
||||
return frappe.get_hooks("app_title", app_name=self.app_name)[0]
|
||||
|
||||
def _validate_app_name(self):
|
||||
UserInvitation.validate_app_name(self.app_name)
|
||||
|
||||
def _validate_roles(self):
|
||||
if self.app_name == "frappe":
|
||||
return
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
|
||||
allowed_roles: list[str] = []
|
||||
if isinstance(user_invitation_hook, dict):
|
||||
allowed_roles = user_invitation_hook.get("allowed_roles") or []
|
||||
for r in self.roles:
|
||||
if r.role in allowed_roles:
|
||||
continue
|
||||
frappe.throw(
|
||||
title=_("Invalid role"),
|
||||
msg=_("{0} is not an allowed role for {1}").format(r.role, self.app_name),
|
||||
)
|
||||
|
||||
def _validate_email(self):
|
||||
frappe.utils.validate_email_address(self.email, throw=True)
|
||||
|
||||
def get_redirect_to_path(self):
|
||||
start_index = 1 if self.redirect_to_path.startswith("/") else 0
|
||||
return self.redirect_to_path[start_index:]
|
||||
|
||||
@staticmethod
|
||||
def validate_app_name(app_name: str):
|
||||
if app_name not in frappe.get_installed_apps():
|
||||
frappe.throw(title=_("Invalid app"), msg=_("application is not installed"))
|
||||
|
||||
@staticmethod
|
||||
def validate_role(app_name: str) -> None:
|
||||
UserInvitation.validate_app_name(app_name)
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name)
|
||||
only_for: list[str] = []
|
||||
if isinstance(user_invitation_hook, dict):
|
||||
only_for = user_invitation_hook.get("only_for") or []
|
||||
if "System Manager" not in only_for:
|
||||
only_for.append("System Manager")
|
||||
frappe.only_for(only_for)
|
||||
|
||||
|
||||
def mark_expired_invitations() -> None:
|
||||
days = 3
|
||||
invitations_to_expire = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
|
||||
)
|
||||
for invitation in invitations_to_expire:
|
||||
invitation = frappe.get_doc("User Invitation", invitation.name)
|
||||
invitation.expire()
|
||||
# to avoid losing work in case the job times out without finishing
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
|
||||
def get_allowed_apps(user: Document | None) -> list[str]:
|
||||
user_roles = set(get_user_roles(user))
|
||||
allowed_apps: list[str] = []
|
||||
for app in frappe.get_installed_apps():
|
||||
user_invitation_hooks = frappe.get_hooks("user_invitation", app_name=app)
|
||||
if not isinstance(user_invitation_hooks, dict):
|
||||
continue
|
||||
only_for = user_invitation_hooks.get("only_for") or []
|
||||
if set(only_for) & user_roles:
|
||||
allowed_apps.append(app)
|
||||
return allowed_apps
|
||||
|
||||
|
||||
def get_permission_query_conditions(user: Document | None) -> str | None:
|
||||
user = get_user(user)
|
||||
user_roles = get_user_roles(user)
|
||||
if "System Manager" in user_roles:
|
||||
return
|
||||
allowed_apps = get_allowed_apps(user)
|
||||
if not allowed_apps:
|
||||
return "false"
|
||||
allowed_apps_str = ", ".join([f'"{app}"' for app in allowed_apps])
|
||||
return f"`tabUser Invitation`.app_name IN ({allowed_apps_str})"
|
||||
|
||||
|
||||
def has_permission(
|
||||
doc: UserInvitation, user: Document | None = None, permission_type: str | None = None
|
||||
) -> bool:
|
||||
return permission_type != "delete" and doc.app_name in get_allowed_apps(user)
|
||||
|
||||
|
||||
def get_user_roles(user: Document | None) -> list[str]:
|
||||
return get_roles(get_user(user))
|
||||
|
||||
|
||||
def get_user(user: Document | None) -> Document:
|
||||
return user or frappe.session.user
|
||||
|
|
@ -8,7 +8,7 @@ from frappe.core.doctype.user_permission.user_permission import (
|
|||
)
|
||||
from frappe.permissions import add_permission, has_user_permission
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
|
||||
from frappe.tests.test_helpers import setup_for_tests
|
||||
|
||||
|
||||
class TestUserPermission(IntegrationTestCase):
|
||||
|
|
@ -23,6 +23,7 @@ class TestUserPermission(IntegrationTestCase):
|
|||
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`")
|
||||
frappe.delete_doc_if_exists("DocType", "Doc A")
|
||||
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabDoc A`")
|
||||
setup_for_tests()
|
||||
|
||||
def test_default_user_permission_validation(self):
|
||||
user = create_user("test_default_permission@example.com")
|
||||
|
|
@ -39,27 +40,27 @@ class TestUserPermission(IntegrationTestCase):
|
|||
add_user_permissions(param)
|
||||
# create a duplicate entry with default
|
||||
perm_user = create_user("test_default_corectness2@example.com")
|
||||
test_blog = make_test_blog()
|
||||
param = get_params(perm_user, "Blog Post", test_blog.name, is_default=1, hide_descendants=1)
|
||||
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
|
||||
param = get_params(perm_user, "Test Blog Post", test_blog.name, is_default=1, hide_descendants=1)
|
||||
add_user_permissions(param)
|
||||
frappe.db.delete("User Permission", filters={"for_value": test_blog.name})
|
||||
frappe.delete_doc("Blog Post", test_blog.name)
|
||||
frappe.delete_doc("Test Blog Post", test_blog.name)
|
||||
|
||||
def test_default_user_permission(self):
|
||||
frappe.set_user("Administrator")
|
||||
user = create_user("test_user_perm1@example.com", "Website Manager")
|
||||
for category in ["general", "public"]:
|
||||
if not frappe.db.exists("Blog Category", category):
|
||||
frappe.get_doc({"doctype": "Blog Category", "title": category}).insert()
|
||||
if not frappe.db.exists("Test Blog Category", category):
|
||||
frappe.get_doc({"doctype": "Test Blog Category", "title": category}).insert()
|
||||
|
||||
param = get_params(user, "Blog Category", "general", is_default=1)
|
||||
param = get_params(user, "Test Blog Category", "general", is_default=1)
|
||||
add_user_permissions(param)
|
||||
|
||||
param = get_params(user, "Blog Category", "public")
|
||||
param = get_params(user, "Test Blog Category", "public")
|
||||
add_user_permissions(param)
|
||||
|
||||
frappe.set_user("test_user_perm1@example.com")
|
||||
doc = frappe.new_doc("Blog Post")
|
||||
doc = frappe.new_doc("Test Blog Post")
|
||||
|
||||
self.assertEqual(doc.blog_category, "general")
|
||||
frappe.set_user("Administrator")
|
||||
|
|
|
|||
|
|
@ -1,32 +1,35 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2021-12-06 16:37:40.652468",
|
||||
"creation": "2025-07-17 10:56:04.746455",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"attachment"
|
||||
"role"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "attachment",
|
||||
"fieldtype": "Attach",
|
||||
"fieldname": "role",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Attachment",
|
||||
"label": "Role",
|
||||
"options": "Role",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-23 16:03:31.101104",
|
||||
"modified": "2025-07-17 10:56:36.357715",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Newsletter Attachment",
|
||||
"module": "Core",
|
||||
"name": "User Role",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
# Copyright (c) 2015, Frappe Technologies and contributors
|
||||
# License: MIT. See LICENSE
|
||||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class NewsletterEmailGroup(Document):
|
||||
class UserRole(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
|
|
@ -13,11 +14,10 @@ class NewsletterEmailGroup(Document):
|
|||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
email_group: DF.Link
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
total_subscribers: DF.ReadOnly | None
|
||||
role: DF.Link
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -49,6 +49,7 @@
|
|||
"email_append_to",
|
||||
"sender_field",
|
||||
"sender_name_field",
|
||||
"recipient_account_field",
|
||||
"subject_field",
|
||||
"section_break_8",
|
||||
"sort_field",
|
||||
|
|
@ -415,6 +416,12 @@
|
|||
"fieldname": "protect_attached_files",
|
||||
"fieldtype": "Check",
|
||||
"label": "Protect Attached Files"
|
||||
},
|
||||
{
|
||||
"depends_on": "email_append_to",
|
||||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
|
|
@ -423,7 +430,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-27 18:22:32.618603",
|
||||
"modified": "2025-07-19 12:23:41.564203",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom",
|
||||
"name": "Customize Form",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class CustomizeForm(Document):
|
|||
protect_attached_files: DF.Check
|
||||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
search_fields: DF.Data | None
|
||||
sender_field: DF.Data | None
|
||||
sender_name_field: DF.Data | None
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -142,12 +142,19 @@ class MariaDBConnectionUtil:
|
|||
if frappe.conf.local_infile:
|
||||
conn_settings["local_infile"] = frappe.conf.local_infile
|
||||
|
||||
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
|
||||
conn_settings["ssl"] = {
|
||||
# Configure SSL settings
|
||||
if frappe.conf.db_ssl_ca:
|
||||
ssl_config = {
|
||||
"ca": frappe.conf.db_ssl_ca,
|
||||
"cert": frappe.conf.db_ssl_cert,
|
||||
"key": frappe.conf.db_ssl_key,
|
||||
"check_hostname": frappe.conf.db_ssl_check_hostname,
|
||||
}
|
||||
|
||||
# Add client certificates for mutual SSL if available
|
||||
if frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
|
||||
ssl_config.update({"cert": frappe.conf.db_ssl_cert, "key": frappe.conf.db_ssl_key})
|
||||
|
||||
conn_settings["ssl"] = ssl_config
|
||||
|
||||
return conn_settings
|
||||
|
||||
|
||||
|
|
@ -165,11 +172,11 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
|
|||
self.db_type = "mariadb"
|
||||
self.type_map = {
|
||||
"Currency": ("decimal", "21,9"),
|
||||
"Int": ("int", None),
|
||||
"Int": ("int", "11"),
|
||||
"Long Int": ("bigint", "20"),
|
||||
"Float": ("decimal", "21,9"),
|
||||
"Percent": ("decimal", "21,9"),
|
||||
"Check": ("tinyint", None),
|
||||
"Check": ("tinyint", 4),
|
||||
"Small Text": ("text", ""),
|
||||
"Long Text": ("longtext", ""),
|
||||
"Code": ("longtext", ""),
|
||||
|
|
|
|||
|
|
@ -143,13 +143,19 @@ class MariaDBConnectionUtil:
|
|||
if frappe.conf.local_infile:
|
||||
conn_settings["local_infile"] = frappe.conf.local_infile
|
||||
|
||||
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
|
||||
conn_settings["ssl"] = {
|
||||
# Configure SSL settings
|
||||
if frappe.conf.db_ssl_ca:
|
||||
ssl_config = {
|
||||
"ca": frappe.conf.db_ssl_ca,
|
||||
"cert": frappe.conf.db_ssl_cert,
|
||||
"key": frappe.conf.db_ssl_key,
|
||||
"check_hostname": frappe.conf.db_ssl_check_hostname,
|
||||
}
|
||||
|
||||
# Add client certificates for mutual SSL if available
|
||||
if frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
|
||||
ssl_config.update({"cert": frappe.conf.db_ssl_cert, "key": frappe.conf.db_ssl_key})
|
||||
|
||||
conn_settings["ssl"] = ssl_config
|
||||
|
||||
return conn_settings
|
||||
|
||||
|
||||
|
|
@ -198,11 +204,11 @@ class MariaDBDatabase(MariaDBConnectionUtil, MariaDBExceptionUtil, Database):
|
|||
self.db_type = "mariadb"
|
||||
self.type_map = {
|
||||
"Currency": ("decimal", "21,9"),
|
||||
"Int": ("int", None),
|
||||
"Int": ("int", "11"),
|
||||
"Long Int": ("bigint", "20"),
|
||||
"Float": ("decimal", "21,9"),
|
||||
"Percent": ("decimal", "21,9"),
|
||||
"Check": ("tinyint", None),
|
||||
"Check": ("tinyint", "4"),
|
||||
"Small Text": ("text", ""),
|
||||
"Long Text": ("longtext", ""),
|
||||
"Code": ("longtext", ""),
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
@ -56,7 +56,7 @@ def get_permission_query_conditions(user):
|
|||
or `tabDashboard Chart`.`module` is NULL""".format(allowed_modules=",".join(allowed_modules))
|
||||
|
||||
return f"""
|
||||
((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
|
||||
((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average', 'Group By')
|
||||
and {doctype_condition})
|
||||
or
|
||||
(`tabDashboard Chart`.`chart_type` = 'Report'
|
||||
|
|
@ -313,8 +313,8 @@ def get_result(data, timegrain, from_date, to_date, chart_type):
|
|||
for d in result:
|
||||
count = 0
|
||||
while data_index < len(data) and getdate(data[data_index][0]) <= d[0]:
|
||||
d[1] += cint(data[data_index][1])
|
||||
count += cint(data[data_index][2])
|
||||
d[1] += flt(data[data_index][1])
|
||||
count += flt(data[data_index][2])
|
||||
data_index += 1
|
||||
if chart_type == "Average" and count != 0:
|
||||
d[1] = d[1] / count
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
"""Use blog post test to test user permissions logic"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from frappe.model.db_query import DatabaseQuery
|
|||
from frappe.permissions import add_permission, reset_perms
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["User", "Web Page"]
|
||||
|
||||
|
||||
class TestToDo(IntegrationTestCase):
|
||||
|
|
@ -93,8 +93,8 @@ class TestToDo(IntegrationTestCase):
|
|||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
test_user.add_roles("Blogger")
|
||||
add_permission("ToDo", "Blogger")
|
||||
test_user.add_roles("Website Manager")
|
||||
add_permission("ToDo", "Website Manager")
|
||||
|
||||
frappe.set_user("test4@example.com")
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ class TestToDo(IntegrationTestCase):
|
|||
self.assertFalse(todo1.has_permission("write"))
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
test_user.remove_roles("Blogger")
|
||||
test_user.remove_roles("Website Manager")
|
||||
reset_perms("ToDo")
|
||||
clear_permissions_cache("ToDo")
|
||||
frappe.db.rollback()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -330,6 +330,7 @@ def export_query():
|
|||
include_indentation = form_params.include_indentation
|
||||
include_filters = form_params.include_filters
|
||||
visible_idx = form_params.visible_idx
|
||||
include_hidden_columns = form_params.include_hidden_columns
|
||||
|
||||
if isinstance(visible_idx, str):
|
||||
visible_idx = json.loads(visible_idx)
|
||||
|
|
@ -347,11 +348,20 @@ def export_query():
|
|||
|
||||
format_fields(data)
|
||||
xlsx_data, column_widths = build_xlsx_data(
|
||||
data, visible_idx, include_indentation, include_filters=include_filters
|
||||
data,
|
||||
visible_idx,
|
||||
include_indentation,
|
||||
include_filters=include_filters,
|
||||
include_hidden_columns=include_hidden_columns,
|
||||
)
|
||||
|
||||
if file_format_type == "CSV":
|
||||
content = get_csv_bytes(xlsx_data, csv_params)
|
||||
from frappe.utils.xlsxutils import handle_html
|
||||
|
||||
content = get_csv_bytes(
|
||||
[[handle_html(frappe.as_unicode(v)) if isinstance(v, str) else v for v in r] for r in xlsx_data],
|
||||
csv_params,
|
||||
)
|
||||
file_extension = "csv"
|
||||
elif file_format_type == "Excel":
|
||||
from frappe.utils.xlsxutils import make_xlsx
|
||||
|
|
@ -393,7 +403,14 @@ def format_fields(data: frappe._dict) -> None:
|
|||
row[index] = round(row[index], col.get("precision"))
|
||||
|
||||
|
||||
def build_xlsx_data(data, visible_idx, include_indentation, include_filters=False, ignore_visible_idx=False):
|
||||
def build_xlsx_data(
|
||||
data,
|
||||
visible_idx,
|
||||
include_indentation,
|
||||
include_filters=False,
|
||||
ignore_visible_idx=False,
|
||||
include_hidden_columns=False,
|
||||
):
|
||||
EXCEL_TYPES = (
|
||||
str,
|
||||
bool,
|
||||
|
|
@ -433,7 +450,7 @@ def build_xlsx_data(data, visible_idx, include_indentation, include_filters=Fals
|
|||
|
||||
column_data = []
|
||||
for column in data.columns:
|
||||
if column.get("hidden"):
|
||||
if column.get("hidden") and not cint(include_hidden_columns):
|
||||
continue
|
||||
column_data.append(_(column.get("label")))
|
||||
column_width = cint(column.get("width", 0))
|
||||
|
|
@ -449,7 +466,7 @@ def build_xlsx_data(data, visible_idx, include_indentation, include_filters=Fals
|
|||
row_data = []
|
||||
if isinstance(row, dict):
|
||||
for col_idx, column in enumerate(data.columns):
|
||||
if column.get("hidden"):
|
||||
if column.get("hidden") and not cint(include_hidden_columns):
|
||||
continue
|
||||
label = column.get("label")
|
||||
fieldname = column.get("fieldname")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -62,15 +62,6 @@ frappe.ui.form.on("Email Group", {
|
|||
},
|
||||
__("Action")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("New Newsletter"),
|
||||
function () {
|
||||
frappe.route_options = { email_group: frm.doc.name };
|
||||
frappe.new_doc("Newsletter");
|
||||
},
|
||||
__("Action")
|
||||
);
|
||||
}
|
||||
|
||||
frm.trigger("preview_welcome_url");
|
||||
|
|
|
|||
|
|
@ -38,10 +38,19 @@ class EmailGroup(Document):
|
|||
"""Extract Email Addresses from given doctype and add them to the current list"""
|
||||
meta = frappe.get_meta(doctype)
|
||||
email_field = next(
|
||||
d.fieldname
|
||||
for d in meta.fields
|
||||
if d.fieldtype in ("Data", "Small Text", "Text", "Code") and d.options == "Email"
|
||||
(
|
||||
d.fieldname
|
||||
for d in meta.fields
|
||||
if d.fieldtype in ("Data", "Small Text", "Text", "Code") and d.options == "Email"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not email_field:
|
||||
frappe.throw(
|
||||
_("No Email field found in {0}").format(doctype),
|
||||
title=_("Invalid Doctype"),
|
||||
)
|
||||
|
||||
unsubscribed_field = "unsubscribed" if meta.get_field("unsubscribed") else None
|
||||
added = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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
|
||||
}
|
||||
|
|
@ -205,9 +205,12 @@ frappe.ui.form.on("Notification", {
|
|||
return dialog;
|
||||
});
|
||||
}
|
||||
|
||||
frm.trigger("set_up_filters_editor");
|
||||
},
|
||||
document_type: function (frm) {
|
||||
frappe.notification.setup_fieldname_select(frm);
|
||||
frm.trigger("set_up_filters_editor");
|
||||
},
|
||||
view_properties: function (frm) {
|
||||
frappe.route_options = { doc_type: frm.doc.document_type };
|
||||
|
|
@ -246,4 +249,41 @@ frappe.ui.form.on("Notification", {
|
|||
frm.set_df_property("channel", "description", ` `);
|
||||
}
|
||||
},
|
||||
condition_type: function (frm) {
|
||||
if (frm.doc.condition_type === "Filters") {
|
||||
frm.set_value("condition", "");
|
||||
} else {
|
||||
frm.set_value("filters", "");
|
||||
}
|
||||
|
||||
frm.trigger("set_up_filters_editor");
|
||||
},
|
||||
set_up_filters_editor(frm) {
|
||||
const parent = frm.get_field("filters_editor").$wrapper;
|
||||
parent.empty();
|
||||
|
||||
if (!frm.doc.document_type || frm.doc.condition_type !== "Filters") {
|
||||
return;
|
||||
}
|
||||
|
||||
const filters =
|
||||
frm.doc.filters && frm.doc.filters !== "[]" ? JSON.parse(frm.doc.filters) : [];
|
||||
|
||||
frappe.model.with_doctype(frm.doc.document_type, () => {
|
||||
const filter_group = new frappe.ui.FilterGroup({
|
||||
parent: parent,
|
||||
doctype: frm.doc.document_type,
|
||||
on_change: () => {
|
||||
frappe.model.set_value(
|
||||
frm.doc.doctype,
|
||||
frm.doc.name,
|
||||
"filters",
|
||||
JSON.stringify(filter_group.get_filters())
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
filter_group.add_filters_to_filter_group(filters);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
"column_break_2",
|
||||
"channel",
|
||||
"slack_webhook_url",
|
||||
"filters",
|
||||
"filters_section",
|
||||
"subject",
|
||||
"event",
|
||||
"document_type",
|
||||
|
|
@ -29,7 +29,10 @@
|
|||
"send_system_notification",
|
||||
"sender_email",
|
||||
"section_break_9",
|
||||
"condition_type",
|
||||
"filters_editor",
|
||||
"condition",
|
||||
"filters",
|
||||
"column_break_6",
|
||||
"html_7",
|
||||
"property_section",
|
||||
|
|
@ -79,8 +82,11 @@
|
|||
},
|
||||
{
|
||||
"fieldname": "filters",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Filters"
|
||||
"fieldtype": "Code",
|
||||
"hidden": 1,
|
||||
"label": "Filters",
|
||||
"options": "JSON",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)",
|
||||
|
|
@ -178,6 +184,7 @@
|
|||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.condition_type===\"Python\"",
|
||||
"description": "Optional: The alert will be sent if this expression is true",
|
||||
"fieldname": "condition",
|
||||
"fieldtype": "Code",
|
||||
|
|
@ -190,6 +197,7 @@
|
|||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.condition_type===\"Python\"",
|
||||
"fieldname": "html_7",
|
||||
"fieldtype": "HTML",
|
||||
"options": "<p><strong>Condition Examples:</strong></p>\n<pre>doc.status==\"Open\"<br>doc.due_date==nowdate()<br>doc.total > 40000\n</pre>\n"
|
||||
|
|
@ -311,12 +319,31 @@
|
|||
"fieldtype": "Datetime",
|
||||
"label": "Last Run",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Python",
|
||||
"fieldname": "condition_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Condition Type",
|
||||
"options": "Python\nFilters"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.condition_type===\"Filters\"",
|
||||
"fieldname": "filters_editor",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Filters Editor"
|
||||
},
|
||||
{
|
||||
"fieldname": "filters_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Filters"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"icon": "fa fa-envelope",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-12 10:28:35.077180",
|
||||
"modified": "2025-05-10 21:03:15.561558",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Email",
|
||||
"name": "Notification",
|
||||
|
|
@ -334,9 +361,10 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "subject",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send
|
|||
from frappe.model.document import Document
|
||||
from frappe.modules.utils import export_module_json, get_doc_module
|
||||
from frappe.utils import add_to_date, cast, now_datetime, nowdate, validate_email_address
|
||||
from frappe.utils.data import evaluate_filters
|
||||
from frappe.utils.jinja import validate_template
|
||||
from frappe.utils.safe_exec import get_safe_globals
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ class Notification(Document):
|
|||
attach_print: DF.Check
|
||||
channel: DF.Literal["Email", "Slack", "System Notification", "SMS"]
|
||||
condition: DF.Code | None
|
||||
condition_type: DF.Literal["Python", "Filters"]
|
||||
date_changed: DF.Literal[None]
|
||||
datetime_changed: DF.Literal[None]
|
||||
datetime_last_run: DF.Datetime | None
|
||||
|
|
@ -56,6 +58,7 @@ class Notification(Document):
|
|||
"Method",
|
||||
"Custom",
|
||||
]
|
||||
filters: DF.Code | None
|
||||
is_standard: DF.Check
|
||||
message: DF.Code | None
|
||||
message_type: DF.Literal["Markdown", "HTML", "Plain Text"]
|
||||
|
|
@ -88,12 +91,15 @@ class Notification(Document):
|
|||
|
||||
@frappe.whitelist()
|
||||
def preview_meets_condition(self, preview_document):
|
||||
if not self.condition:
|
||||
if not self.condition and not self.filters:
|
||||
return _("Yes")
|
||||
try:
|
||||
doc = frappe.get_cached_doc(self.document_type, preview_document)
|
||||
context = get_context(doc)
|
||||
return _("Yes") if frappe.safe_eval(self.condition, eval_locals=context) else _("No")
|
||||
if self.condition_type == "Python":
|
||||
context = get_context(doc)
|
||||
return _("Yes") if frappe.safe_eval(self.condition, eval_locals=context) else _("No")
|
||||
elif self.condition_type == "Filters":
|
||||
return _("Yes") if evaluate_filters(doc, json.loads(self.filters)) else _("No")
|
||||
except Exception as e:
|
||||
frappe.local.message_log = []
|
||||
return _("Failed to evaluate conditions: {}").format(e)
|
||||
|
|
@ -135,6 +141,9 @@ class Notification(Document):
|
|||
|
||||
# END: PreviewRenderer API
|
||||
|
||||
def before_save(self):
|
||||
self.remove_invalid_condition()
|
||||
|
||||
def validate(self):
|
||||
if self.channel in ("Email", "Slack", "System Notification"):
|
||||
validate_template(self.subject)
|
||||
|
|
@ -159,6 +168,7 @@ class Notification(Document):
|
|||
|
||||
self.validate_forbidden_document_types()
|
||||
self.validate_condition()
|
||||
self.validate_filters()
|
||||
self.validate_standard()
|
||||
clear_notification_cache()
|
||||
|
||||
|
|
@ -192,13 +202,29 @@ def get_context(context):
|
|||
_("Cannot edit Standard Notification. To edit, please disable this and duplicate it")
|
||||
)
|
||||
|
||||
def remove_invalid_condition(self):
|
||||
if self.condition_type == "Filters":
|
||||
self.condition = None
|
||||
elif self.condition_type == "Python":
|
||||
self.filters = None
|
||||
|
||||
def validate_condition(self):
|
||||
if not self.condition:
|
||||
return
|
||||
|
||||
temp_doc = frappe.new_doc(self.document_type)
|
||||
if self.condition:
|
||||
try:
|
||||
frappe.safe_eval(self.condition, None, get_context(temp_doc.as_dict()))
|
||||
except Exception:
|
||||
frappe.throw(_("The Condition '{0}' is invalid").format(self.condition))
|
||||
try:
|
||||
frappe.safe_eval(self.condition, None, get_context(temp_doc.as_dict()))
|
||||
except Exception:
|
||||
frappe.throw(_("The Condition '{0}' is invalid").format(self.condition))
|
||||
|
||||
def validate_filters(self):
|
||||
if not self.filters:
|
||||
return
|
||||
|
||||
filters = json.loads(self.filters)
|
||||
dummy_doc = frappe.new_doc(self.document_type)
|
||||
evaluate_filters(dummy_doc, filters)
|
||||
|
||||
def validate_forbidden_document_types(self):
|
||||
if self.document_type in FORBIDDEN_DOCUMENT_TYPES or (
|
||||
|
|
@ -232,10 +258,18 @@ def get_context(context):
|
|||
],
|
||||
)
|
||||
|
||||
filters = json.loads(self.filters) if self.condition_type == "Filters" and self.filters else None
|
||||
|
||||
for d in doc_list:
|
||||
doc = frappe.get_lazy_doc(self.document_type, d.name)
|
||||
|
||||
if self.condition and not frappe.safe_eval(self.condition, None, get_context(doc)):
|
||||
if (
|
||||
self.condition_type == "Python"
|
||||
and self.condition
|
||||
and not frappe.safe_eval(self.condition, None, get_context(doc))
|
||||
):
|
||||
continue
|
||||
elif filters and not evaluate_filters(doc, filters):
|
||||
continue
|
||||
|
||||
docs.append(doc)
|
||||
|
|
@ -281,10 +315,18 @@ def get_context(context):
|
|||
|
||||
self.db_set("datetime_last_run", now) # set reference now for next run
|
||||
|
||||
filters = json.loads(self.filters) if self.condition_type == "Filters" and self.filters else None
|
||||
|
||||
for d in doc_list:
|
||||
doc = frappe.get_lazy_doc(self.document_type, d.name)
|
||||
|
||||
if self.condition and not frappe.safe_eval(self.condition, None, get_context(doc)):
|
||||
if (
|
||||
self.condition_type == "Python"
|
||||
and self.condition
|
||||
and not frappe.safe_eval(self.condition, None, get_context(doc))
|
||||
):
|
||||
continue
|
||||
elif filters and not evaluate_filters(doc, filters):
|
||||
continue
|
||||
|
||||
docs.append(doc)
|
||||
|
|
@ -705,9 +747,12 @@ def evaluate_alert(doc: Document, alert, event=None):
|
|||
|
||||
context = get_context(doc)
|
||||
|
||||
if alert.condition:
|
||||
if alert.condition_type == "Python" and alert.condition:
|
||||
if not frappe.safe_eval(alert.condition, None, context):
|
||||
return
|
||||
elif alert.condition_type == "Filters" and alert.filters:
|
||||
if not evaluate_filters(doc, json.loads(alert.filters)):
|
||||
return
|
||||
|
||||
if event == "Value Change" and not doc.is_new():
|
||||
if not frappe.db.has_column(doc.doctype, alert.value_changed):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Copyright (c) 2018, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
|
|
@ -500,6 +500,36 @@ class TestNotification(IntegrationTestCase):
|
|||
self.assertTrue("test1@example.com" in recipients)
|
||||
self.assertEqual(notification.enabled, 1)
|
||||
|
||||
def test_filters_condition(self):
|
||||
"""Test Notification with Condition Type 'Filters'."""
|
||||
frappe.delete_doc_if_exists("Notification", "Test Filters Condition")
|
||||
|
||||
notification = frappe.new_doc("Notification")
|
||||
notification.name = "Test Filters Condition"
|
||||
notification.subject = "Test Filters Condition"
|
||||
notification.document_type = "ToDo"
|
||||
notification.event = "Save"
|
||||
notification.condition_type = "Filters"
|
||||
notification.filters = json.dumps([["status", "=", "Open"]])
|
||||
notification.message = "Test message"
|
||||
notification.channel = "Email"
|
||||
notification.append("recipients", {"receiver_by_document_field": "allocated_to"})
|
||||
notification.save()
|
||||
|
||||
todo = frappe.new_doc("ToDo")
|
||||
todo.description = "Checking email notification with filters condition"
|
||||
todo.allocated_to = "test1@example.com"
|
||||
todo.save()
|
||||
|
||||
email_queue = frappe.get_doc(
|
||||
"Email Queue", {"reference_doctype": "ToDo", "reference_name": todo.name}
|
||||
)
|
||||
self.assertTrue(email_queue)
|
||||
|
||||
recipients = [d.recipient for d in email_queue.recipients]
|
||||
self.assertTrue("test1@example.com" in recipients)
|
||||
self.assertEqual(notification.enabled, 1)
|
||||
|
||||
|
||||
# ruff: noqa: RUF001
|
||||
"""
|
||||
|
|
@ -520,7 +550,7 @@ Ran 3 tests in 2.677s
|
|||
OK
|
||||
"""
|
||||
|
||||
|
||||
# from datetime import timedelta
|
||||
# from frappe.utils import add_to_date, now_datetime
|
||||
# class TestNotificationOffsetRange(IntegrationTestCase):
|
||||
# def setUp(self):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
|
|
@ -177,3 +178,27 @@ def get_queue():
|
|||
{"now": now_datetime()},
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
|
||||
def retry_sending_emails():
|
||||
emails_in_sending = frappe.get_all(
|
||||
"Email Queue", filters={"status": "Sending"}, fields=["name", "modified"]
|
||||
)
|
||||
for e in emails_in_sending:
|
||||
if now_datetime() - e["modified"] > timedelta(minutes=15):
|
||||
update_fields = {}
|
||||
email_queue = frappe.get_doc("Email Queue", e["name"])
|
||||
sent_to_atleast_one_recipient = any(
|
||||
rec.recipient for rec in email_queue.recipients if rec.is_mail_sent()
|
||||
)
|
||||
if email_queue.retry < cint(frappe.db.get_system_setting("email_retry_limit")) or 3:
|
||||
update_fields.update(
|
||||
{
|
||||
"status": "Partially Sent" if sent_to_atleast_one_recipient else "Not Sent",
|
||||
"retry": email_queue.retry + 1,
|
||||
}
|
||||
)
|
||||
else:
|
||||
update_fields.update({"status": "Error"})
|
||||
update_fields.update({"error": "Retry Limit Exceeded"})
|
||||
email_queue.update_status(**update_fields, commit=True)
|
||||
|
|
|
|||
|
|
@ -162,10 +162,14 @@ class EmailServer:
|
|||
return res[0] == "OK" # The folder exists TODO: handle other responses too
|
||||
|
||||
def logout(self):
|
||||
if cint(self.settings.use_imap):
|
||||
self.imap.logout()
|
||||
else:
|
||||
self.pop.quit()
|
||||
try:
|
||||
if cint(self.settings.use_imap):
|
||||
self.imap.logout()
|
||||
else:
|
||||
self.pop.quit()
|
||||
except imaplib.IMAP4.abort:
|
||||
self.connect()
|
||||
self.logout()
|
||||
return
|
||||
|
||||
def get_messages(self, folder="INBOX"):
|
||||
|
|
@ -846,6 +850,9 @@ class InboundMail(Email):
|
|||
if email_fields.sender_name_field:
|
||||
parent.set(email_fields.sender_name_field, frappe.as_unicode(self.from_real_name))
|
||||
|
||||
if email_fields.recipient_account_field:
|
||||
parent.set(email_fields.recipient_account_field, self.email_account.name)
|
||||
|
||||
parent.flags.ignore_mandatory = True
|
||||
|
||||
try:
|
||||
|
|
@ -887,7 +894,7 @@ class InboundMail(Email):
|
|||
"""Return Email related fields of a doctype."""
|
||||
fields = frappe._dict()
|
||||
|
||||
email_fields = ["subject_field", "sender_field", "sender_name_field"]
|
||||
email_fields = ["subject_field", "sender_field", "sender_name_field", "recipient_account_field"]
|
||||
meta = frappe.get_meta(doctype)
|
||||
|
||||
for field in email_fields:
|
||||
|
|
|
|||
|
|
@ -47,11 +47,9 @@ class TestSMTP(IntegrationTestCase):
|
|||
password="password",
|
||||
enable_outgoing=1,
|
||||
default_outgoing=1,
|
||||
append_to="Blog Post",
|
||||
)
|
||||
self.assertEqual(
|
||||
EmailAccount.find_outgoing(match_by_doctype="Blog Post").email_id, "append_to@gmail.com"
|
||||
append_to="Todo",
|
||||
)
|
||||
self.assertEqual(EmailAccount.find_outgoing(match_by_doctype="Todo").email_id, "append_to@gmail.com")
|
||||
|
||||
# add back the mail_server
|
||||
frappe.conf["mail_server"] = mail_server
|
||||
|
|
|
|||
|
|
@ -107,12 +107,23 @@ class FrappeClient:
|
|||
headers=self.headers,
|
||||
)
|
||||
|
||||
def get_list(self, doctype, fields='["name"]', filters=None, limit_start=0, limit_page_length=None):
|
||||
def get_list(
|
||||
self,
|
||||
doctype,
|
||||
fields='["name"]',
|
||||
filters=None,
|
||||
limit_start=0,
|
||||
limit_page_length=None,
|
||||
order_by=None,
|
||||
group_by=None,
|
||||
):
|
||||
"""Return list of records of a particular type."""
|
||||
if not isinstance(fields, str):
|
||||
fields = json.dumps(fields)
|
||||
params = {
|
||||
"fields": fields,
|
||||
"order_by": order_by,
|
||||
"group_by": group_by,
|
||||
}
|
||||
if filters:
|
||||
params["filters"] = json.dumps(filters)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,16 @@ def extract(fileobj, *args, **kwargs):
|
|||
for shortcut in data.get("shortcuts", [])
|
||||
if shortcut.get("format")
|
||||
)
|
||||
yield from (
|
||||
(
|
||||
None,
|
||||
"_",
|
||||
quick_list.get("label"),
|
||||
[f"Label of a quick_list in the {workspace_name} Workspace"],
|
||||
)
|
||||
for quick_list in data.get("quick_lists", [])
|
||||
if quick_list.get("label")
|
||||
)
|
||||
|
||||
content = json.loads(data.get("content", "[]"))
|
||||
for item in content:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import os
|
|||
from . import __version__ as app_version
|
||||
|
||||
app_name = "frappe"
|
||||
app_title = "Framework"
|
||||
app_title = "Frappe Framework"
|
||||
app_publisher = "Frappe Technologies"
|
||||
app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node"
|
||||
app_license = "MIT"
|
||||
|
|
@ -54,19 +54,13 @@ web_include_icons = [
|
|||
email_css = ["email.bundle.css"]
|
||||
|
||||
website_route_rules = [
|
||||
{"from_route": "/blog/<category>", "to_route": "Blog Post"},
|
||||
{"from_route": "/kb/<category>", "to_route": "Help Article"},
|
||||
{"from_route": "/newsletters", "to_route": "Newsletter"},
|
||||
{"from_route": "/profile", "to_route": "me"},
|
||||
{"from_route": "/app/<path:app_path>", "to_route": "app"},
|
||||
]
|
||||
|
||||
website_redirects = [
|
||||
{"source": r"/desk(.*)", "target": r"/app\1"},
|
||||
{
|
||||
"source": "/.well-known/openid-configuration",
|
||||
"target": "/api/method/frappe.integrations.oauth2.openid_configuration",
|
||||
},
|
||||
]
|
||||
|
||||
base_template = "templates/base.html"
|
||||
|
|
@ -116,6 +110,7 @@ permission_query_conditions = {
|
|||
"Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions",
|
||||
"Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.get_permission_query_condition",
|
||||
"File": "frappe.core.doctype.file.file.get_permission_query_conditions",
|
||||
"User Invitation": "frappe.core.doctype.user_invitation.user_invitation.get_permission_query_conditions",
|
||||
}
|
||||
|
||||
has_permission = {
|
||||
|
|
@ -133,6 +128,7 @@ has_permission = {
|
|||
"File": "frappe.core.doctype.file.file.has_permission",
|
||||
"Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.has_permission",
|
||||
"Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.has_permission",
|
||||
"User Invitation": "frappe.core.doctype.user_invitation.user_invitation.has_permission",
|
||||
}
|
||||
|
||||
has_website_permission = {"Address": "frappe.contacts.doctype.address.address.has_website_permission"}
|
||||
|
|
@ -158,6 +154,7 @@ doc_events = {
|
|||
"frappe.automation.doctype.assignment_rule.assignment_rule.update_due_date",
|
||||
"frappe.core.doctype.user_type.user_type.apply_permissions_for_non_standard_user_type",
|
||||
"frappe.core.doctype.permission_log.permission_log.make_perm_log",
|
||||
"frappe.search.sqlite_search.update_doc_index",
|
||||
],
|
||||
"after_rename": "frappe.desk.notifications.clear_doctype_notifications",
|
||||
"on_cancel": [
|
||||
|
|
@ -168,6 +165,7 @@ doc_events = {
|
|||
"on_trash": [
|
||||
"frappe.desk.notifications.clear_doctype_notifications",
|
||||
"frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions",
|
||||
"frappe.search.sqlite_search.delete_doc_index",
|
||||
],
|
||||
"on_update_after_submit": [
|
||||
"frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions",
|
||||
|
|
@ -210,6 +208,7 @@ scheduler_events = {
|
|||
"frappe.deferred_insert.save_to_db",
|
||||
"frappe.automation.doctype.reminder.reminder.send_reminders",
|
||||
"frappe.model.utils.link_count.update_link_count",
|
||||
"frappe.search.sqlite_search.build_index_if_not_exists",
|
||||
],
|
||||
# 10 minutes
|
||||
"0/10 * * * *": [
|
||||
|
|
@ -222,12 +221,11 @@ scheduler_events = {
|
|||
},
|
||||
"all": [
|
||||
"frappe.email.queue.flush",
|
||||
"frappe.email.queue.retry_sending_emails",
|
||||
"frappe.monitor.flush",
|
||||
"frappe.integrations.doctype.google_calendar.google_calendar.sync",
|
||||
],
|
||||
"hourly": [
|
||||
"frappe.email.doctype.newsletter.newsletter.send_scheduled_email",
|
||||
],
|
||||
"hourly": [],
|
||||
# Maintenance queue happen roughly once an hour but don't align with wall-clock time of *:00
|
||||
# Use these for when you don't care about when the job runs but just need some guarantee for
|
||||
# frequency.
|
||||
|
|
@ -254,6 +252,7 @@ scheduler_events = {
|
|||
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record",
|
||||
"frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry",
|
||||
"frappe.core.doctype.log_settings.log_settings.run_log_clean_up",
|
||||
"frappe.core.doctype.user_invitation.user_invitation.mark_expired_invitations",
|
||||
],
|
||||
"weekly_long": [
|
||||
"frappe.desk.form.document_follow.send_weekly_updates",
|
||||
|
|
@ -282,7 +281,10 @@ setup_wizard_exception = [
|
|||
]
|
||||
|
||||
before_migrate = ["frappe.core.doctype.patch_log.patch_log.before_migrate"]
|
||||
after_migrate = ["frappe.website.doctype.website_theme.website_theme.after_migrate"]
|
||||
after_migrate = [
|
||||
"frappe.website.doctype.website_theme.website_theme.after_migrate",
|
||||
"frappe.search.sqlite_search.build_index_in_background",
|
||||
]
|
||||
|
||||
otp_methods = ["OTP App", "Email", "SMS"]
|
||||
|
||||
|
|
@ -358,11 +360,9 @@ global_search_doctypes = {
|
|||
{"doctype": "ToDo"},
|
||||
{"doctype": "Note"},
|
||||
{"doctype": "Event"},
|
||||
{"doctype": "Blog Post"},
|
||||
{"doctype": "Dashboard"},
|
||||
{"doctype": "Country"},
|
||||
{"doctype": "Currency"},
|
||||
{"doctype": "Newsletter"},
|
||||
{"doctype": "Letter Head"},
|
||||
{"doctype": "Workflow"},
|
||||
{"doctype": "Web Page"},
|
||||
|
|
@ -421,6 +421,7 @@ before_request = [
|
|||
"frappe.recorder.record",
|
||||
"frappe.monitor.start",
|
||||
"frappe.rate_limiter.apply",
|
||||
"frappe.integrations.oauth2.set_cors_for_privileged_requests",
|
||||
]
|
||||
|
||||
after_request = [
|
||||
|
|
@ -572,3 +573,7 @@ persistent_cache_keys = [
|
|||
"rate-limit-counter-*",
|
||||
"rl:*",
|
||||
]
|
||||
|
||||
user_invitation = {
|
||||
"only_for": ["System Manager"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -613,7 +613,7 @@ class Test_OpenLDAP(LDAP_TestCase, TestCase):
|
|||
"ldap_group": "Administrators",
|
||||
"erpnext_role": "System Manager",
|
||||
},
|
||||
{"doctype": "LDAP Group Mapping", "ldap_group": "Users", "erpnext_role": "Blogger"},
|
||||
{"doctype": "LDAP Group Mapping", "ldap_group": "Users", "erpnext_role": "Website Manager"},
|
||||
{"doctype": "LDAP Group Mapping", "ldap_group": "Group3", "erpnext_role": "Accounts User"},
|
||||
]
|
||||
LDAP_USERNAME_FIELD = "uid"
|
||||
|
|
@ -637,7 +637,7 @@ class Test_ActiveDirectory(LDAP_TestCase, TestCase):
|
|||
"ldap_group": "Domain Administrators",
|
||||
"erpnext_role": "System Manager",
|
||||
},
|
||||
{"doctype": "LDAP Group Mapping", "ldap_group": "Domain Users", "erpnext_role": "Blogger"},
|
||||
{"doctype": "LDAP Group Mapping", "ldap_group": "Domain Users", "erpnext_role": "Website Manager"},
|
||||
{
|
||||
"doctype": "LDAP Group Mapping",
|
||||
"ldap_group": "Enterprise Administrators",
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue