Merge branch 'develop' into add-action-confirm-on-workflow

This commit is contained in:
Theerayut A. 2025-07-29 23:43:11 +07:00 committed by theerayut
commit 1b962c8569
129 changed files with 4582 additions and 2550 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,35 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-07-17 10:56:04.746455",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"role"
],
"fields": [
{
"fieldname": "role",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Role",
"options": "Role",
"reqd": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-17 10:56:36.357715",
"modified_by": "Administrator",
"module": "Core",
"name": "User Role",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,23 @@
# Copyright (c) 2025, Frappe Technologies and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class UserRole(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
role: DF.Link
# end: auto-generated types
pass

View file

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

View file

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

View file

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

View file

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

View file

@ -850,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:
@ -891,7 +894,7 @@ class InboundMail(Email):
"""Return Email related fields of a doctype."""
fields = frappe._dict()
email_fields = ["subject_field", "sender_field", "sender_name_field"]
email_fields = ["subject_field", "sender_field", "sender_name_field", "recipient_account_field"]
meta = frappe.get_meta(doctype)
for field in email_fields:

View file

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

View file

@ -54,7 +54,6 @@ 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": "/profile", "to_route": "me"},
{"from_route": "/app/<path:app_path>", "to_route": "app"},
@ -111,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 = {
@ -128,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"}
@ -153,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": [
@ -163,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",
@ -205,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 * * * *": [
@ -248,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",
@ -276,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"]
@ -352,7 +360,6 @@ global_search_doctypes = {
{"doctype": "ToDo"},
{"doctype": "Note"},
{"doctype": "Event"},
{"doctype": "Blog Post"},
{"doctype": "Dashboard"},
{"doctype": "Country"},
{"doctype": "Currency"},
@ -566,3 +573,7 @@ persistent_cache_keys = [
"rate-limit-counter-*",
"rl:*",
]
user_invitation = {
"only_for": ["System Manager"],
}

View file

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

View file

@ -56,7 +56,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Doc Event",
"options": "after_insert\non_update\non_submit\non_cancel\non_trash\non_update_after_submit\non_change",
"options": "after_insert\non_update\non_submit\non_cancel\non_trash\non_update_after_submit\non_change\nworkflow_transition",
"set_only_once": 1
},
{
@ -189,7 +189,7 @@
"link_fieldname": "webhook"
}
],
"modified": "2024-10-28 12:21:52.172428",
"modified": "2025-07-18 18:22:38.276809",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook",

View file

@ -49,6 +49,7 @@ class Webhook(Document):
"on_trash",
"on_update_after_submit",
"on_change",
"workflow_transition",
]
webhook_doctype: DF.Link
webhook_headers: DF.Table[WebhookHeader]
@ -68,6 +69,9 @@ class Webhook(Document):
def on_update(self):
frappe.client_cache.delete_value("webhooks")
def execute_for_doc(self, doc: Document):
enqueue_webhook(doc, self)
def validate_docevent(self):
if self.webhook_doctype:
is_submittable = frappe.get_value("DocType", self.webhook_doctype, "is_submittable")
@ -144,7 +148,8 @@ def get_context(doc):
def enqueue_webhook(doc, webhook) -> None:
request_url = headers = data = r = None
try:
webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name"))
if not isinstance(webhook, Document):
webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name"))
request_url = webhook.request_url
if webhook.is_dynamic_url:
request_url = frappe.render_template(webhook.request_url, get_context(doc))
@ -180,6 +185,9 @@ def enqueue_webhook(doc, webhook) -> None:
sleep(3 * i + 1)
if i != 2:
continue
else:
if webhook.webhook_docevent == "workflow_transition":
raise e
def log_request(

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-07-20 09:35+0000\n"
"PO-Revision-Date: 2025-07-22 21:46\n"
"PO-Revision-Date: 2025-07-26 22:36\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@ -22765,7 +22765,7 @@ msgstr "فیلدها را انتخاب کنید"
#: frappe/public/js/frappe/data_import/data_exporter.js:147
msgid "Select Fields To Insert"
msgstr "فیلدهایی را برای درج انتخاب کنید"
msgstr "انتخاب فیلدها برای درج"
#: frappe/public/js/frappe/data_import/data_exporter.js:148
msgid "Select Fields To Update"
@ -27753,25 +27753,25 @@ msgstr "از % برای هر مقدار غیر خالی استفاده کنید.
#. Label of the ascii_encode_password (Check) field in DocType 'Email Account'
#: frappe/email/doctype/email_account/email_account.json
msgid "Use ASCII encoding for password"
msgstr "از رمزگذاری ASCII برای گذرواژه استفاده کنید"
msgstr "استفاده از رمزگذاری ASCII برای گذرواژه"
#. Label of the use_first_day_of_period (Check) field in DocType 'Auto Email
#. Report'
#: frappe/email/doctype/auto_email_report/auto_email_report.json
msgid "Use First Day of Period"
msgstr "از اولین روز دوره استفاده کنید"
msgstr "استفاده از اولین روز دوره"
#. Label of the use_html (Check) field in DocType 'Email Template'
#: frappe/email/doctype/email_template/email_template.json
msgid "Use HTML"
msgstr "از HTML استفاده کنید"
msgstr "استفاده از HTML"
#. Label of the use_imap (Check) field in DocType 'Email Account'
#. Label of the use_imap (Check) field in DocType 'Email Domain'
#: frappe/email/doctype/email_account/email_account.json
#: frappe/email/doctype/email_domain/email_domain.json
msgid "Use IMAP"
msgstr "از IMAP استفاده کنید"
msgstr "استفاده از IMAP"
#. Label of the use_number_format_from_currency (Check) field in DocType
#. 'System Settings'
@ -27796,21 +27796,21 @@ msgstr "استفاده از نمودار گزارش"
#: frappe/email/doctype/email_account/email_account.json
#: frappe/email/doctype/email_domain/email_domain.json
msgid "Use SSL"
msgstr "از SSL استفاده کنید"
msgstr "استفاده از SSL"
#. Label of the use_starttls (Check) field in DocType 'Email Account'
#. Label of the use_starttls (Check) field in DocType 'Email Domain'
#: frappe/email/doctype/email_account/email_account.json
#: frappe/email/doctype/email_domain/email_domain.json
msgid "Use STARTTLS"
msgstr "از STARTTLS استفاده کنید"
msgstr "استفاده از STARTTLS"
#. Label of the use_tls (Check) field in DocType 'Email Account'
#. Label of the use_tls (Check) field in DocType 'Email Domain'
#: frappe/email/doctype/email_account/email_account.json
#: frappe/email/doctype/email_domain/email_domain.json
msgid "Use TLS"
msgstr "از TLS استفاده کنید"
msgstr "استفاده از TLS"
#: frappe/utils/password_strength.py:44
msgid "Use a few words, avoid common phrases."
@ -29168,7 +29168,7 @@ msgstr "عمل گردش کار"
#. Description of a DocType
#: frappe/workflow/doctype/workflow_action_master/workflow_action_master.json
msgid "Workflow Action Master"
msgstr "استاد اکشن گردش کار"
msgstr "مدیر اکشن گردش کار"
#. Label of the workflow_action_name (Data) field in DocType 'Workflow Action
#. Master'
@ -29939,7 +29939,7 @@ msgstr ""
#: frappe/core/doctype/prepared_report/prepared_report.js:57
msgid "Your CSV file is being generated and will appear in the Attachments section once ready. Additionally, you will get notified when the file is available for download."
msgstr ""
msgstr "فایل CSV شما در حال تولید است و به محض آماده شدن در بخش پیوست‌ها نمایش داده خواهد شد. علاوه بر این، هنگامی که فایل برای دانلود در دسترس قرار گرفت، به شما اطلاع داده خواهد شد."
#: frappe/desk/page/setup_wizard/setup_wizard.js:397
msgid "Your Country"
@ -30130,7 +30130,7 @@ msgstr "ایجاد كردن"
#. Option for the 'Indicator Color' (Select) field in DocType 'Workspace'
#: frappe/desk/doctype/workspace/workspace.json
msgid "cyan"
msgstr "فیروزه ای"
msgstr "فیروزهای"
#: frappe/public/js/frappe/form/controls/duration.js:218
#: frappe/public/js/frappe/utils/utils.js:1119
@ -30318,7 +30318,7 @@ msgstr "آیکون"
#. Inspector'
#: frappe/core/doctype/permission_inspector/permission_inspector.json
msgid "import"
msgstr "درون‌ریزی"
msgstr "درون‌بُرد"
#. Description of the 'Read Time' (Int) field in DocType 'Blog Post'
#: frappe/website/doctype/blog_post/blog_post.json
@ -30693,7 +30693,7 @@ msgstr "از طریق قانون واگذاری"
#: frappe/automation/doctype/auto_repeat/auto_repeat.py:242
msgid "via Auto Repeat"
msgstr ""
msgstr "از طریق تکرار خودکار"
#: frappe/core/doctype/data_import/importer.py:271
#: frappe/core/doctype/data_import/importer.py:292
@ -30985,7 +30985,7 @@ msgstr "{0} این را ایجاد کرد"
#: frappe/public/js/frappe/form/footer/version_timeline_content_builder.js:250
msgctxt "Form timeline"
msgid "{0} created this document {1}"
msgstr ""
msgstr "{0} این سند را ایجاد کرد {1}"
#: frappe/public/js/frappe/utils/pretty_date.js:33
msgid "{0} d"

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-07-20 09:35+0000\n"
"PO-Revision-Date: 2025-07-22 21:46\n"
"PO-Revision-Date: 2025-07-25 22:40\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Indonesian\n"
"MIME-Version: 1.0\n"
@ -14087,7 +14087,7 @@ msgstr ""
#: frappe/public/js/frappe/ui/filters/filter.js:652
msgid "Last 6 Months"
msgstr ""
msgstr "6 Bulan Terakhir"
#: frappe/public/js/frappe/ui/filters/filter.js:624
msgid "Last 7 Days"
@ -16521,7 +16521,7 @@ msgstr ""
#: frappe/public/js/frappe/ui/filters/filter.js:704
msgid "Next 6 Months"
msgstr ""
msgstr "6 Bulan ke Depan"
#: frappe/public/js/frappe/ui/filters/filter.js:680
msgid "Next 7 Days"
@ -16550,11 +16550,11 @@ msgstr ""
#: frappe/public/js/frappe/ui/filters/filter.js:696
msgid "Next Month"
msgstr ""
msgstr "Bulan Depan"
#: frappe/public/js/frappe/ui/filters/filter.js:700
msgid "Next Quarter"
msgstr ""
msgstr "Kuartal Depan"
#. Label of the next_schedule_date (Date) field in DocType 'Auto Repeat'
#: frappe/automation/doctype/auto_repeat/auto_repeat.json
@ -16584,11 +16584,11 @@ msgstr ""
#: frappe/public/js/frappe/ui/filters/filter.js:692
msgid "Next Week"
msgstr ""
msgstr "Minggu Depan"
#: frappe/public/js/frappe/ui/filters/filter.js:708
msgid "Next Year"
msgstr ""
msgstr "Tahun Depan"
#: frappe/public/js/frappe/form/workflow.js:45
msgid "Next actions"
@ -26071,19 +26071,19 @@ msgstr "Papan Kanban ini akan menjadi pribadi"
#: frappe/public/js/frappe/ui/filters/filter.js:666
msgid "This Month"
msgstr ""
msgstr "Bulan Ini"
#: frappe/public/js/frappe/ui/filters/filter.js:670
msgid "This Quarter"
msgstr ""
msgstr "Kuartal Ini"
#: frappe/public/js/frappe/ui/filters/filter.js:662
msgid "This Week"
msgstr ""
msgstr "Minggu Ini"
#: frappe/public/js/frappe/ui/filters/filter.js:674
msgid "This Year"
msgstr ""
msgstr "Tahun Ini"
#: frappe/custom/doctype/customize_form/customize_form.js:220
msgid "This action is irreversible. Do you wish to continue?"
@ -26789,7 +26789,7 @@ msgstr "Token hilang"
#: frappe/public/js/frappe/ui/filters/filter.js:739
msgid "Tomorrow"
msgstr ""
msgstr "Besok"
#: frappe/desk/doctype/bulk_update/bulk_update.py:68
#: frappe/model/workflow.py:254
@ -29474,7 +29474,7 @@ msgstr "Ya"
#: frappe/public/js/frappe/ui/filters/filter.js:727
msgid "Yesterday"
msgstr ""
msgstr "Kemarin"
#: frappe/public/js/frappe/utils/user.js:33
msgctxt "Name of the current user. For example: You edited this 5 hours ago."
@ -31412,7 +31412,7 @@ msgstr "{0} minggu yang lalu"
#: frappe/public/js/frappe/utils/pretty_date.js:39
msgid "{0} y"
msgstr "{0} t"
msgstr ""
#: frappe/public/js/frappe/utils/pretty_date.js:72
msgid "{0} years ago"

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-07-20 09:35+0000\n"
"PO-Revision-Date: 2025-07-21 21:50\n"
"PO-Revision-Date: 2025-07-23 21:53\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Serbian (Cyrillic)\n"
"MIME-Version: 1.0\n"
@ -11910,7 +11910,7 @@ msgstr "Сакривена поља"
#: frappe/public/js/frappe/views/reports/query_report.js:1641
msgid "Hidden columns include: {0}"
msgstr ""
msgstr "Сакривене колоне укључују: {0}"
#. Option for the 'Page Number' (Select) field in DocType 'Print Format'
#: frappe/printing/doctype/print_format/print_format.json
@ -12863,7 +12863,7 @@ msgstr "Укључи филтере"
#: frappe/public/js/frappe/views/reports/query_report.js:1639
msgid "Include hidden columns"
msgstr ""
msgstr "Укључи сакривене колоне"
#: frappe/public/js/frappe/views/reports/query_report.js:1611
msgid "Include indentation"
@ -27320,7 +27320,7 @@ msgstr "Преводи"
#. Name of a role
#: frappe/core/doctype/translation/translation.json
msgid "Translator"
msgstr ""
msgstr "Преводилац"
#. Option for the 'Email Status' (Select) field in DocType 'Communication'
#: frappe/core/doctype/communication/communication.json

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-07-20 09:35+0000\n"
"PO-Revision-Date: 2025-07-21 21:50\n"
"PO-Revision-Date: 2025-07-23 21:53\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Serbian (Latin)\n"
"MIME-Version: 1.0\n"
@ -11911,7 +11911,7 @@ msgstr "Sakrivena polja"
#: frappe/public/js/frappe/views/reports/query_report.js:1641
msgid "Hidden columns include: {0}"
msgstr ""
msgstr "Sakrivene kolone uključuju: {0}"
#. Option for the 'Page Number' (Select) field in DocType 'Print Format'
#: frappe/printing/doctype/print_format/print_format.json
@ -12864,7 +12864,7 @@ msgstr "Uključi filtere"
#: frappe/public/js/frappe/views/reports/query_report.js:1639
msgid "Include hidden columns"
msgstr ""
msgstr "Uključi sakrivene kolone"
#: frappe/public/js/frappe/views/reports/query_report.js:1611
msgid "Include indentation"
@ -27321,7 +27321,7 @@ msgstr "Prevodi"
#. Name of a role
#: frappe/core/doctype/translation/translation.json
msgid "Translator"
msgstr ""
msgstr "Prevodilac"
#. Option for the 'Email Status' (Select) field in DocType 'Communication'
#: frappe/core/doctype/communication/communication.json

View file

@ -14,6 +14,9 @@ if TYPE_CHECKING:
from frappe.workflow.doctype.workflow.workflow import Workflow
DEFAULT_WORKFLOW_TASKS = ["Webhook", "Server Script"]
class WorkflowStateError(frappe.ValidationError):
pass
@ -126,6 +129,59 @@ def apply_workflow(doc, action):
if next_state.update_field:
doc.set(next_state.update_field, next_state.update_value)
if transition.transition_tasks:
workflow_transitions = frappe.db.get_all(
"Workflow Transition Task",
{"parent": transition.transition_tasks, "enabled": True},
["task", "link", "asynchronous"],
order_by="idx",
)
"""app-specific actions defined by the user
Example:
def create_customer(doc):
<your-code>
this goes in the hooks.py
workflow_methods = [{"name": "Create a customer", "method":
"frappe.dotted.path.create_customer"}]
"""
tasks = {i["name"]: i["method"] for i in frappe.get_hooks("workflow_methods")}
sync_tasks = []
async_tasks = []
for workflow_transition in workflow_transitions:
# edge-case with user-defined server scripts
if workflow_transition.task in DEFAULT_WORKFLOW_TASKS:
match workflow_transition.task:
case "Webhook":
webhook = frappe.get_doc("Webhook", workflow_transition.link)
task_method = webhook.execute_for_doc
case "Server Script":
server_script = frappe.get_doc("Server Script", workflow_transition.link)
task_method = server_script.execute_workflow_task
else: # normal app-defined tasks
try:
task_method = frappe.get_attr(tasks[workflow_transition.task])
except KeyError:
frappe.throw(_('There is no task called "{}"').format(workflow_transition.task))
if workflow_transition.asynchronous:
async_tasks.append(task_method)
else:
sync_tasks.append(task_method)
# will execute in the same transaction as the rest of the transition
for sync_task in sync_tasks:
sync_task(doc)
# will spawn separate background jobs. Use for asynchronous, optional tasks.
for async_task in async_tasks:
frappe.enqueue(async_task, doc=doc, enqueue_after_commit=True)
new_docstatus = DocStatus(next_state.doc_status or 0)
if doc.docstatus.is_draft() and new_docstatus.is_draft():
doc.save()

View file

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

View file

@ -4,7 +4,7 @@ let isFCUser = false;
$(document).ready(function () {
if (
frappe.boot.is_fc_site &&
frappe.boot.setup_complete === 1 &&
!!frappe.boot.setup_complete &&
!frappe.is_mobile() &&
frappe.user.has_role("System Manager")
) {

View file

@ -1743,7 +1743,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
row.is_total_row = true;
return row;
}, {});
if (!totalRow?.currency && rows[0]?.currency) {
totalRow.currency = rows[0].currency;
}
rows.push(totalRow);
}
return rows;

View file

@ -61,11 +61,15 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
}
setup_events() {
const me = this;
if (this.list_view_settings?.disable_auto_refresh) {
return;
}
frappe.realtime.doctype_subscribe(this.doctype);
frappe.realtime.on("list_update", (data) => this.on_update(data));
this.page.actions_btn_group.on("show.bs.dropdown", () => {
me.toggle_workflow_actions();
});
}
setup_page() {

View file

@ -18,7 +18,7 @@ let properties = computed(() => {
if (store.workflow.selected && "action" in store.workflow.selected.data) {
title.value = __("Transition Properties");
return store.transitionfields.filter((df) =>
["action", "allowed", "allow_self_approval", "action_confirm", "condition"].includes(df.fieldname)
["action", "allowed", "allow_self_approval", "enable_action_confirmation", "condition", "transition_tasks"].includes(df.fieldname)
);
} else if (store.workflow.selected && "state" in store.workflow.selected.data) {
title.value = __("State Properties");

View file

@ -1,138 +0,0 @@
:root {
--comment-timeline-bottom: 60px;
--comment-timeline-top: 8px;
}
.blog-list {
display: flex;
flex-wrap: wrap;
margin-right: -15px;
margin-left: -15px;
&.result {
border-bottom: none;
}
}
.blog-list-content {
margin-bottom: 3rem;
}
.blog-card {
margin-bottom: 2rem;
position: relative;
width: 100%;
.card {
border: 1px solid var(--border-color);
}
.card-body {
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-img-top {
width: 100%;
overflow: hidden;
height: 12rem;
img {
min-height: 12rem;
min-width: 100%;
object-fit: cover;
}
.default-cover {
height: 100%;
width: 100%;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
background: $gray-200;
font-size: 1.2rem;
font-weight: 500;
color: $gray-600;
}
}
.blog-card-footer {
display: flex;
align-items: top;
margin-top: 0.5rem;
.avatar {
margin-top: 0.4rem;
margin-right: 0.5rem;
}
}
}
.blog-container {
font-size: 1rem;
max-width: 800px;
margin: 0px auto;
.blog-title {
margin-top: 1rem;
@include media-breakpoint-up(xl) {
line-height: 1;
font-size: $font-size-4xl;
}
}
.blog-footer {
display: flex;
justify-content: space-between;
color: $text-muted;
margin-top: 3rem;
}
.blog-intro {
font-size: 1.125rem;
font-weight: 400;
}
.blog-content {
margin-bottom: 1rem;
.blog-header {
margin-bottom: 3rem;
margin-top: 5rem;
}
.from-markdown a {
text-decoration: underline;
}
}
.blog-comments {
margin-top: 1rem;
margin-bottom: 5rem;
}
.feedback-item svg {
vertical-align: sub;
}
.blog-feedback {
display: inline-flex;
.like-icon {
cursor: pointer;
use {
stroke: var(--gray-800);
--icon-stroke: transparent;
}
}
.like-icon.liked {
use {
stroke: var(--gray-800);
--icon-stroke: var(--red-500);
}
}
}
}

View file

@ -21,7 +21,6 @@
@import "website_avatar";
@import "web_form";
@import "page_builder";
@import "blog";
@import "markdown";
@import "sidebar";
@import "portal";

View file

@ -3,6 +3,7 @@
import frappe
from frappe.search.full_text_search import FullTextSearch
from frappe.search.sqlite_search import SQLiteSearch
from frappe.search.website_search import WebsiteSearch
from frappe.utils import cint

View file

@ -0,0 +1,470 @@
# SQLite Search Framework
SQLite Search is a full-text search framework for Frappe applications that provides advanced search capabilities using SQLite's FTS5 (Full-Text Search) engine. It offers features like spelling correction, time-based recency scoring, custom ranking, permission-aware filtering, and extensible scoring pipelines.
## Table of Contents
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [Configuration](#configuration)
- [Features & Customization](#features--customization)
- [API Reference](#api-reference)
## Quick Start
### 1. Create a Search Class
Create a search implementation by extending `SQLiteSearch`:
```python
# my_app/search.py
from frappe.search.sqlite_search import SQLiteSearch
class MyAppSearch(SQLiteSearch):
# Database file name
INDEX_NAME = "my_app_search.db"
# Define the search schema
INDEX_SCHEMA = {
"metadata_fields": ["project", "owner", "status"],
"tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'",
}
# Define which doctypes to index and their field mappings
INDEXABLE_DOCTYPES = {
"Task": {
"fields": ["name", {"title": "subject"}, {"content": "description"}, "modified", "project", "owner", "status"],
},
"Issue": {
"fields": ["name", "title", "description", {"modified": "last_updated"}, "project", "owner"],
"filters": {"status": ("!=", "Closed")}, # Only index non-closed issues
},
}
def get_search_filters(self):
"""Return permission filters for current user"""
# Get projects accessible to current user
accessible_projects = frappe.get_all(
"Project",
filters={"owner": frappe.session.user},
pluck="name"
)
if not accessible_projects:
return {"project": []} # No access
return {"project": accessible_projects}
```
### 2. Register the Search Class
Add your search class to hooks.py:
```python
# my_app/hooks.py
sqlite_search = ['my_app.search.MyAppSearch']
```
### 3. Create API Endpoint
Create a whitelisted method to expose search functionality:
```python
# my_app/api.py
import frappe
from my_app.search import MyAppSearch
@frappe.whitelist()
def search(query, filters=None):
search = MyAppSearch()
result = search.search(query, filters=filters)
return result
```
### 4. Build the Index
Build the search index programmatically or via console:
```python
from my_app.search import MyAppSearch
search = MyAppSearch()
search.build_index()
```
## How It Works
### 1. Indexing Process
#### Full Index Building
When you call `build_index()`, the framework performs a complete index rebuild:
1. **Database Preparation**: Creates a temporary SQLite database with FTS5 tables configured according to your schema
2. **Document Collection**: Queries all specified doctypes using the configured field mappings and filters
3. **Document Processing**: For each document:
- Extracts and maps fields according to `INDEXABLE_DOCTYPES` configuration
- Cleans HTML content using BeautifulSoup to extract plain text
- Applies custom document preparation logic if `prepare_document()` is overridden
- Validates required fields (title, content) are present
4. **Batch Insertion**: Inserts processed documents into the FTS5 index in batches for performance
5. **Vocabulary Building**: Constructs a spelling correction dictionary from all indexed text
6. **Atomic Replacement**: Replaces the existing index database with the new one atomically
#### Individual Document Indexing
For real-time updates using `index_doc()` or `remove_doc()`:
1. **Single Document Processing**: Retrieves and processes one document using the same field mapping logic
2. **Incremental Update**: Updates the existing FTS5 index by inserting, updating, or deleting the specific document
3. **Vocabulary Update**: Updates the spelling dictionary with new terms from the document
### 2. Search Process
When a user performs a search using `search()`, the framework executes these steps:
1. **Permission Filtering**: Calls `get_search_filters()` to determine what documents the current user can access
2. **Query Preprocessing**:
- Validates the search query is not empty
- Combines user-provided filters with permission filters
3. **Spelling Correction**:
- Analyzes query terms against the vocabulary dictionary
- Uses trigram similarity to suggest corrections for misspelled words
- Expands the original query with corrected terms
4. **FTS5 Query Execution**:
- Constructs an FTS5-compatible query string
- Executes the full-text search against the SQLite database
- Applies metadata filters (status, owner, project, etc.)
- Retrieves raw results with BM25 scores
5. **Results Processing**:
- **Custom Scoring**: Applies the scoring pipeline to calculate final relevance scores
- Base BM25 score processing
- Title matching boosts (exact and partial matches)
- Recency boosting based on document age
- Custom scoring functions (doctype-specific, priority-based, etc.)
- **Ranking**: Sorts results by final scores and assigns rank positions
- **Content Formatting**: Generates content snippets and highlights matching terms
## Configuration
### INDEX_SCHEMA
Defines the structure of your search index:
```python
INDEX_SCHEMA = {
# Text fields that will be searchable (defaults to ["title", "content"])
"text_fields": ["title", "content"],
# Metadata fields stored alongside text content for filtering
"metadata_fields": ["project", "owner", "status", "priority"],
# FTS5 tokenizer configuration
"tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_@.'"
}
```
### INDEXABLE_DOCTYPES
Specifies which doctypes to index and how to map their fields:
```python
INDEXABLE_DOCTYPES = {
"Task": {
# Field mapping
"fields": [
"name",
{"title": "subject"}, # Maps subject field to title
{"content": "description"}, # Maps description field to content
{"modified": "creation"}, # Use creation instead of modified for recency boost
"project",
"owner"
],
# Optional filters to limit which records are indexed
"filters": {
"status": ("!=", "Cancelled"),
"docstatus": ("!=", 2)
}
}
}
```
### Field Mapping Rules
- **String fields**: Direct mapping `"field_name"`
- **Aliased fields**: Dictionary mapping `{"schema_field": "doctype_field"}`
- **Required fields**: `title` and `content` fields must be present or explicitly mapped (e.g., `{"title": "subject"}`)
- **Auto-added fields**: `doctype` and `name` are automatically included
- **Modified field**: Added automatically if used in any doctype configuration. Used for recency boosting - if you want to use a different timestamp field (like `creation` or `last_updated`), map it to `modified` using `{"modified": "creation"}`
## Features & Customization
### Permission Filtering
Implement `get_search_filters()` to control access:
```python
def get_search_filters(self):
"""Return filters based on user permissions"""
user = frappe.session.user
if user == "Administrator":
return {} # No restrictions
# Example: User can only see their own and public documents
return {
"owner": user,
"status": ["Active", "Published"]
}
```
### Custom Scoring
Create custom scoring functions to influence search relevance:
```python
class MyAppSearch(SQLiteSearch):
...
@SQLiteSearch.scoring_function
def _get_priority_boost(self, row, query, query_words):
"""Boost high-priority items"""
priority = row.get("priority", "Medium")
if priority == "High":
return 1.5
if priority == "Medium":
return 1.1
return 1.0
```
### Recency Boosting
The framework automatically provides time-based recency boosting using the `modified` field:
```python
# The modified field is used for calculating document age
# Recent documents get higher scores:
# - Last 24 hours: 1.8x boost
# - Last 7 days: 1.5x boost
# - Last 30 days: 1.2x boost
# - Last 90 days: 1.1x boost
# - Older documents: gradually decreasing boost
# If your doctype uses a different timestamp field, map it to modified:
INDEXABLE_DOCTYPES = {
"GP Discussion": {
"fields": ["name", "title", "content", {"modified": "last_post_at"}, "project"],
},
"Article": {
"fields": ["name", "title", "content", {"modified": "published_date"}, "category"],
}
}
```
### Document Preparation
Override `prepare_document()` for custom document processing:
```python
def prepare_document(self, doc):
"""Custom document preparation"""
document = super().prepare_document(doc)
if not document:
return None
# Add computed fields
if doc.doctype == "Task":
# Combine multiple fields into content
content_parts = [
doc.description or "",
doc.notes or "",
"\n".join([comment.content for comment in doc.get("comments", [])])
]
document["content"] = "\n".join(filter(None, content_parts))
# set fields that might be stored in another table
document["category"] = get_category_for_task(doc)
return document
```
### Spelling Correction
The framework includes built-in spelling correction using trigram similarity:
```python
# Spelling correction happens automatically
search_result = search.search("projetc managment") # Will find "project management"
# Access correction information
print(search_result["summary"]["corrected_words"])
# Output: {"projetc": "project", "managment": "management"}
```
### Content Processing
HTML content is automatically cleaned and processed using BeautifulSoup:
```python
# Complex HTML content like this:
html_content = """
<div class="article">
<h1>API Documentation</h1>
<p>Learn how to integrate with our <a href="/api">REST API</a>.</p>
<img src="/images/api-flow.png" alt="API workflow diagram" />
<ul>
<li><strong>Authentication:</strong> Use <code>Bearer tokens</code></li>
<li>Rate limiting: <em>1000 requests/hour</em></li>
</ul>
<blockquote>See our <a href="/examples">code examples</a> for details.</blockquote>
<table><tr><td>Method</td><td>POST</td></tr></table>
<script>analytics.track('page_view');</script>
<style>.hidden { display: none; }</style>
</div>
"""
# Is automatically converted to clean, searchable plain text:
"""
API Documentation
Learn how to integrate with our REST API.
Authentication: Use Bearer tokens
Rate limiting: 1000 requests/hour
See our code examples for details.
Method POST
"""
# The cleaning process:
# 1. Removes all HTML tags (<div>, <h1>, <strong>, <code>, etc.)
# 2. Strips out scripts, styles, and non-content elements
# 3. Extracts link text while removing href URLs
# 4. Normalizes whitespace and line breaks
```
### Title-Only Search
```python
results = search.search("project update", title_only=True)
```
### Advanced Filtering
```python
accessible_projects = ['PROJ001', 'PROJ002', ...]
filters = {
"project": accessible_projects, # Multiple values (IN clause)
"owner": current_user, # Single value (= clause)
}
results = search.search("bug fix", filters=filters)
```
### Automatic Index Handling
The framework handles index building and maintenance automatically when you register your search class:
```python
# hooks.py
sqlite_search = ['my_app.search.MyAppSearch']
```
**What the framework does automatically:**
1. **Post-Migration Index Building**: Builds the search index automatically after running `bench migrate`
2. **Periodic Index Verification**: Checks every 15 minutes that the index exists and rebuilds if missing
3. **Real-time Document Updates**: Automatically calls `index_doc()` and `remove_doc()` on document lifecycle events (insert, update, delete) for all doctypes defined in your `INDEXABLE_DOCTYPES`
## Manual Index Handling
If you prefer to have manual control over the lifecycle of indexing, then you can simply opt out of automatic index handling by not registering the search class in `sqlite_search` hook.
```python
from my_app.search import MyAppSearch
def build_index_in_background():
"""Manually trigger background index building"""
search = MyAppSearch()
if search.is_search_enabled() and not search.index_exists():
frappe.enqueue("my_app.search.build_index", queue="long")
# hooks.py
scheduler_events = {
# Custom scheduler (if you want different timing)
"daily": ["my_app.search.build_index_if_not_exists"],
}
```
## API Reference
#### `search(query, title_only=False, filters=None)`
Main search method that returns formatted results.
**Parameters:**
- `query` (str): Search query text
- `title_only` (bool): Search only in title fields
- `filters` (dict): Additional filters to apply
**Returns:**
```python
{
"results": [
{
"doctype": "Task",
"name": "TASK-001",
"title": "Fix login bug",
"content": "User cannot login after password reset...",
"score": 0.85,
"original_rank": 3, # original bm25 rank
"rank": 1, # modified rank after custom scoring pipeline
# ... other metadata fields
}
],
"summary": {
"duration": 0.023,
"total_matches": 15,
"returned_matches": 15,
"corrected_words": {"loggin": "login"},
"corrected_query": "Fix login bug",
"title_only": False,
"filtered_matches": 15,
"applied_filters": {"status": ["Open"]}
}
}
```
#### `build_index()`
Build the complete search index from scratch.
#### `index_doc(doctype, docname)`
Index a single document.
#### `remove_doc(doctype, docname)`
Remove a single document from the index.
#### `is_search_enabled()`
Check if search is enabled (override to add disable logic).
#### `index_exists()`
Check if the search index exists.
#### `get_search_filters()`
**Must be implemented by subclasses.** Return filters for the current user.
**Returns:**
```python
{
"field_name": "value", # Single value
"field_name": ["val1", "val2"], # Multiple values
}
```
#### `scoring_function()`
Use the `@SQLiteSearch.scoring_function` decorator to mark a function as a scoring function.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
<span style = "display: block; margin-bottom: 1.5rem;">
{{ _("Hello,") }}
</span>
<span style = "display: block; margin-bottom: 1rem;">
{{ _("You've been invited to join {0}.").format(title) }}
</span>
<span style = "display: block; margin-bottom: 0.5rem;">
{{ _("Click below to get started:") }}
</span>
<a
href = "{{ invite_link }}"
target = "_blank"
class = "btn btn-primary"
style = "display: block; margin-bottom: 1rem; width: fit-content"
>
{{ _("Accept Invitation") }}
</a>
<span>
{{ _("If you have any questions, reach out to your system administrator.") }}
</span>

View file

@ -0,0 +1,9 @@
<span style = "display: block; margin-bottom: 1.5rem;">
{{ _("Hello,") }}
</span>
<span style = "display: block; margin-bottom: 1rem;">
{{ _("Your invitation to join {0} has been cancelled by the site administrator.").format(title) }}
</span>
<span style = "display: block;">
{{ _("If this was a mistake or you need access again, please reach out to your team.") }}
</span>

View file

@ -0,0 +1,9 @@
<span style = "display: block; margin-bottom: 1.5rem;">
{{ _("Hello,") }}
</span>
<span style = "display: block; margin-bottom: 1rem;">
{{ _("Your invitation to join {0} has expired.").format(title) }}
</span>
<span style = "display: block;">
{{ _("You can ask your team to resend the invitation if you'd still like to join.") }}
</span>

View file

@ -1,14 +0,0 @@
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
<div class="media">
{{ avatar(full_name=blogger_info.full_name, image=blogger_info.avatar, size='avatar-large') }}
<div class="media-body ml-3">
<h5 class="mt-0">
<a href="/blog?blogger={{ blogger_info.name }}" class="text-dark">{{ blogger_info.full_name }}</a>
</h5>
{% if blogger_info.bio %}
<p class="text-muted">{{ blogger_info.bio }}</p>
{% endif %}
</div>
</div>

View file

@ -1,12 +0,0 @@
{% if blog_title and not (form_dict.txt or form_dict.by) %}
<div class="page-hero border-bottom">
<div class="container">
<h1 class="page-title">
{{ blog_title }}
</h1>
{% if blog_introduction -%}
<p class="blog-introduction">{{ blog_introduction }}</p>
{%- endif %}
</div>
</div>
{% endif %}

View file

@ -6,7 +6,6 @@ import frappe
from frappe import _, scrub
from frappe.rate_limiter import rate_limit
from frappe.utils.html_utils import clean_html
from frappe.website.doctype.blog_settings.blog_settings import get_comment_limit
from frappe.website.utils import clear_cache
URLS_COMMENT_PATTERN = re.compile(
@ -15,17 +14,32 @@ URLS_COMMENT_PATTERN = re.compile(
EMAIL_PATTERN = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)", re.IGNORECASE)
def get_limit():
method = frappe.get_hooks("comment_rate_limit")
if not method:
return 5
else:
limit = frappe.call(method[0])
return limit
@frappe.whitelist(allow_guest=True)
@rate_limit(key="reference_name", limit=get_comment_limit, seconds=60 * 60)
# @rate_limit(key="reference_name", limit=get_limit, seconds=60 * 60)
def add_comment(comment, comment_email, comment_by, reference_doctype, reference_name, route):
if frappe.session.user == "Guest":
if reference_doctype not in ("Blog Post", "Web Page"):
allowed_doctypes = ["Web Page"]
comments_permission_config = frappe.get_hooks("has_comment_permission")
guest_allowed = False
if len(comments_permission_config):
if comments_permission_config["doctype"]:
allowed_doctypes.append(comments_permission_config["doctype"][0])
check_permission_method = comments_permission_config["method"]
guest_allowed = frappe.call(check_permission_method[0], ref_doctype=reference_doctype)
if reference_doctype not in allowed_doctypes:
return
if reference_doctype == "Blog Post" and not frappe.db.get_single_value(
"Blog Settings", "allow_guest_to_comment"
):
return
if not guest_allowed:
frappe.throw(_("Please login to post a comment."))
if frappe.db.exists("User", comment_email):
frappe.throw(_("Please login to post a comment."))
@ -47,28 +61,6 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference
if route:
clear_cache(route)
if doc.get("route"):
url = f"{frappe.utils.get_request_site_address()}/{doc.route}#{comment.name}"
else:
url = f"{frappe.utils.get_request_site_address()}/app/{scrub(doc.doctype)}/{doc.name}#comment-{comment.name}"
content = comment.content + "<p><a href='{}' style='font-size: 80%'>{}</a></p>".format(
url, _("View Comment")
)
if doc.doctype != "Blog Post" or doc.enable_email_notification:
# notify creator
creator_email = frappe.db.get_value("User", doc.owner, "email") or doc.owner
subject = _("New Comment on {0}: {1}").format(doc.doctype, doc.get_title())
frappe.sendmail(
recipients=creator_email,
subject=subject,
message=content,
reference_doctype=doc.doctype,
reference_name=doc.name,
)
# revert with template if all clear (no backlinks)
template = frappe.get_template("templates/includes/comments/comment.html")
return template.render({"comment": comment.as_dict()})

View file

@ -1,41 +0,0 @@
<div id="likes" class="feedback-item likes mr-3">
<span class="like-icon"></span>
<span class="like-count"></span>
</div>
<script type="text/javascript">
frappe.ready(() => {
let like = parseInt("{{ like or 0 }}");
let like_count = parseInt("{{ like_count or 0 }}");
let toggle_like_icon = function(active) {
active ? $('.like-icon').addClass('liked') : $('.like-icon').removeClass('liked');
}
$('.like-icon').append(`<svg class="icon icon-sm"><use href="#icon-heart"></use></svg>`)
toggle_like_icon(like);
$('.like-count').text(like_count);
$('.like-icon').click(() => {
update_like();
})
let update_like = function() {
like = !like;
like ? like_count++ : like_count--;
toggle_like_icon(like);
$('.like-count').text(like_count);
return frappe.call({
method: "frappe.templates.includes.likes.likes.like",
args: {
reference_doctype: "{{ reference_doctype or doctype }}",
reference_name: "{{ reference_name or name }}",
like,
route: "{{ pathname }}",
}
});
}
});
</script>

View file

@ -1,78 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe
from frappe import _
from frappe.rate_limiter import rate_limit
from frappe.website.doctype.blog_settings.blog_settings import get_like_limit
from frappe.website.utils import clear_cache
@frappe.whitelist(allow_guest=True)
@rate_limit(key="reference_name", limit=get_like_limit, seconds=60 * 60)
def like(reference_doctype, reference_name, like, route=""):
like = frappe.parse_json(like)
ref_doc = frappe.get_doc(reference_doctype, reference_name)
if ref_doc.disable_likes == 1:
return
if like:
liked = add_like(reference_doctype, reference_name)
else:
liked = delete_like(reference_doctype, reference_name)
# since likes are embedded in the page, clear the web cache
if route:
clear_cache(route)
if like and ref_doc.enable_email_notification:
ref_doc_title = ref_doc.get_title()
subject = _("Like on {0}: {1}").format(reference_doctype, ref_doc_title)
content = _("You have received a ❤️ like on your blog post")
message = f"<p>{content} <b>{ref_doc_title}</b></p>"
message = message + "<p><a href='{}/{}#likes' style='font-size: 80%'>{}</a></p>".format(
frappe.utils.get_request_site_address(), ref_doc.route, _("View Blog Post")
)
# notify creator
frappe.sendmail(
recipients=frappe.db.get_value("User", ref_doc.owner, "email") or ref_doc.owner,
subject=subject,
message=message,
reference_doctype=ref_doc.doctype,
reference_name=ref_doc.name,
)
return liked
def add_like(reference_doctype, reference_name):
user = frappe.session.user
like = frappe.new_doc("Comment")
like.comment_type = "Like"
like.comment_email = user
like.reference_doctype = reference_doctype
like.reference_name = reference_name
like.content = "Liked by: " + user
if user == "Guest":
like.ip_address = frappe.local.request_ip
like.save(ignore_permissions=True)
return True
def delete_like(reference_doctype, reference_name):
user = frappe.session.user
filters = {
"comment_type": "Like",
"comment_email": user,
"reference_doctype": reference_doctype,
"reference_name": reference_name,
}
if user == "Guest":
filters["ip_address"] = frappe.local.request_ip
frappe.db.delete("Comment", filters)
return False

View file

@ -188,7 +188,7 @@ class TestMethodAPIV2(FrappeAPITestCase):
def test_shorthand_controller_methods(self):
shorthand_response = self.get(self.method("User", "get_all_roles"), {"sid": self.sid})
self.assertIn("Blogger", shorthand_response.json["data"])
self.assertIn("Website Manager", shorthand_response.json["data"])
expanded_response = self.get(
self.method("frappe.core.doctype.user.user.get_all_roles"), {"sid": self.sid}

View file

@ -15,10 +15,11 @@ from frappe.model.db_query import DatabaseQuery, get_between_date_filter
from frappe.permissions import add_user_permission, clear_user_permissions_for_doctype
from frappe.query_builder import Column
from frappe.tests import IntegrationTestCase
from frappe.tests.test_helpers import setup_for_tests
from frappe.tests.test_query_builder import db_type_is, run_only_if
from frappe.utils.testutils import add_custom_field, clear_custom_fields
EXTRA_TEST_RECORD_DEPENDENCIES = ["User", "Blog Post", "Blog Category", "Blogger"]
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
@contextmanager
@ -41,15 +42,16 @@ def setup_test_user(set_user=False):
@contextmanager
def setup_patched_blog_post():
add_child_table_to_blog_post()
make_property_setter("Blog Post", "published", "permlevel", 1, "Int")
reset("Blog Post")
add("Blog Post", "Website Manager", 1)
update("Blog Post", "Website Manager", 1, "write", 1)
make_property_setter("Test Blog Post", "published", "permlevel", 1, "Int")
reset("Test Blog Post")
add("Test Blog Post", "Website Manager", 1)
update("Test Blog Post", "Website Manager", 1, "write", 1)
yield
class TestDBQuery(IntegrationTestCase):
def setUp(self):
setup_for_tests()
frappe.set_user("Administrator")
def test_basic(self):
@ -192,14 +194,14 @@ class TestDBQuery(IntegrationTestCase):
todo.delete()
def test_build_match_conditions(self):
clear_user_permissions_for_doctype("Blog Post", "test2@example.com")
clear_user_permissions_for_doctype("Test Blog Post", "test2@example.com")
test2user = frappe.get_doc("User", "test2@example.com")
test2user.add_roles("Blogger")
frappe.set_user("test2@example.com")
# this will get match conditions for Blog Post
build_match_conditions = DatabaseQuery("Blog Post").build_match_conditions
build_match_conditions = DatabaseQuery("Test Blog Post").build_match_conditions
# Before any user permission is applied
# get as filters
@ -207,20 +209,20 @@ class TestDBQuery(IntegrationTestCase):
# get as conditions
self.assertEqual(build_match_conditions(as_condition=True), "")
add_user_permission("Blog Post", "-test-blog-post", "test2@example.com", True)
add_user_permission("Blog Post", "-test-blog-post-1", "test2@example.com", True)
add_user_permission("Test Blog Post", "_Test Blog Post", "test2@example.com", True)
add_user_permission("Test Blog Post", "_Test Blog Post 1", "test2@example.com", True)
# After applying user permission
# get as filters
self.assertTrue(
{"Blog Post": ["-test-blog-post-1", "-test-blog-post"]}
{"Test Blog Post": ["_Test Blog Post 1", "_Test Blog Post"]}
in build_match_conditions(as_condition=False)
)
# get as conditions
if frappe.db.db_type == "mariadb":
assertion_string = """(((ifnull(`tabBlog Post`.`name`, '')='' or `tabBlog Post`.`name` in ('-test-blog-post-1', '-test-blog-post'))))"""
assertion_string = """(((ifnull(`tabTest Blog Post`.`name`, '')='' or `tabTest Blog Post`.`name` in ('_Test Blog Post 1', '_Test Blog Post'))))"""
else:
assertion_string = """(((ifnull(cast(`tabBlog Post`.`name` as varchar), '')='' or cast(`tabBlog Post`.`name` as varchar) in ('-test-blog-post-1', '-test-blog-post'))))"""
assertion_string = """(((ifnull(cast(`tabBlog Post`.`name` as varchar), '')='' or cast(`tabBlog Post`.`name` as varchar) in ('_Test Blog Post 1', '_Test Blog Post'))))"""
self.assertEqual(build_match_conditions(as_condition=True), assertion_string)
@ -848,7 +850,7 @@ class TestDBQuery(IntegrationTestCase):
def test_permlevel_fields(self):
with setup_patched_blog_post(), setup_test_user(set_user=True):
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "published"],
limit=1,
@ -858,7 +860,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 1)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "`published`"],
limit=1,
@ -868,9 +870,9 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 1)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "`tabBlog Post`.`published`"],
fields=["name", "`tabTest Blog Post`.`published`"],
limit=1,
)
self.assertFalse("published" in data[0])
@ -878,7 +880,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 1)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "`tabTest Child`.`test_field`"],
limit=1,
@ -888,7 +890,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 1)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "MAX(`published`)"],
limit=1,
@ -897,7 +899,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 1)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "LAST(published)"],
limit=1,
@ -906,7 +908,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 1)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "MAX(`modified`)"],
limit=1,
@ -916,7 +918,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 2)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "now() abhi"],
limit=1,
@ -925,7 +927,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 2)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "'LABEL'"],
limit=1,
@ -935,7 +937,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 2)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "COUNT(*) as count"],
limit=1,
@ -946,7 +948,7 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 2)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
fields=["name", "COUNT(*) count"],
limit=1,
@ -957,17 +959,18 @@ class TestDBQuery(IntegrationTestCase):
self.assertEqual(len(data[0]), 2)
data = frappe.get_list(
"Blog Post",
"Test Blog Post",
fields=[
"name",
"blogger.full_name as blogger_full_name",
"blog_category.description",
"blog_category.title",
],
limit=1,
)
print(data[0])
self.assertTrue("name" in data[0])
self.assertTrue("blogger_full_name" in data[0])
self.assertTrue("description" in data[0])
self.assertTrue("title" in data[0])
def test_cast_name(self):
from frappe.core.doctype.doctype.test_doctype import new_doctype
@ -1296,10 +1299,10 @@ class TestReportView(IntegrationTestCase):
user.remove_roles(*user_roles)
user.add_roles("Blogger")
make_property_setter("Blog Post", "published", "permlevel", 1, "Int")
reset("Blog Post")
add("Blog Post", "Website Manager", 1)
update("Blog Post", "Website Manager", 1, "write", 1)
make_property_setter("Test Blog Post", "published", "permlevel", 1, "Int")
reset("Test Blog Post")
add("Test Blog Post", "Website Manager", 1)
update("Test Blog Post", "Website Manager", 1, "write", 1)
frappe.set_user(user.name)
@ -1308,7 +1311,7 @@ class TestReportView(IntegrationTestCase):
frappe.local.form_dict = frappe._dict(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"fields": ["published", "title", "`tabTest Child`.`test_field`"],
}
)
@ -1318,7 +1321,7 @@ class TestReportView(IntegrationTestCase):
self.assertListEqual(response["keys"], ["title"])
frappe.local.form_dict = frappe._dict(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"fields": ["*"],
}
)
@ -1335,7 +1338,7 @@ class TestReportView(IntegrationTestCase):
# Admin should be able to see access all fields
frappe.local.form_dict = frappe._dict(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"fields": ["published", "title", "`tabTest Child`.`test_field`"],
}
)
@ -1377,7 +1380,7 @@ class TestReportView(IntegrationTestCase):
frappe.local.request.method = "POST"
frappe.local.form_dict = frappe._dict(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"fields": ["published", "title", "`tabTest Child`.`test_field`"],
}
)
@ -1387,7 +1390,7 @@ class TestReportView(IntegrationTestCase):
self.assertListEqual(response["keys"], ["title"])
frappe.local.form_dict = frappe._dict(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"fields": ["*"],
}
)
@ -1396,7 +1399,7 @@ class TestReportView(IntegrationTestCase):
self.assertNotIn("published", response["keys"])
# If none of the fields are accessible then result should be empty
self.assertEqual(frappe.get_list("Blog Post", "published"), [])
self.assertEqual(frappe.get_list("Test Blog Post", "published"), [])
def test_reportview_get_admin(self):
# Admin should be able to see access all fields
@ -1405,7 +1408,7 @@ class TestReportView(IntegrationTestCase):
frappe.local.request.method = "POST"
frappe.local.form_dict = frappe._dict(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"fields": ["published", "title", "`tabTest Child`.`test_field`"],
}
)
@ -1441,8 +1444,8 @@ def add_child_table_to_blog_post():
)
child_table.insert(ignore_permissions=True, ignore_if_duplicate=True)
clear_custom_fields("Blog Post")
add_custom_field("Blog Post", "child_table", "Table", child_table.name)
clear_custom_fields("Test Blog Post")
add_custom_field("Test Blog Post", "child_table", "Table", child_table.name)
def create_event(subject="_Test Event", starts_on=None):

View file

@ -73,7 +73,7 @@ class TestDefaults(IntegrationTestCase):
@run_only_if(db_type_is.MARIADB)
def test_user_permission_defaults(self):
# Create user permission
create_user("user_default_test@example.com", "Blogger")
create_user("user_default_test@example.com", "Website Manager")
frappe.set_user("user_default_test@example.com")
set_global_default("Country", "")
clear_user_default("Country")

View file

@ -5,10 +5,9 @@ from frappe.core.page.permission_manager.permission_manager import add, reset, u
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.form.load import get_docinfo, getdoc, getdoctype
from frappe.tests import IntegrationTestCase
from frappe.tests.test_helpers import setup_for_tests
from frappe.utils.file_manager import save_file
EXTRA_TEST_RECORD_DEPENDENCIES = ["Blog Category", "Blogger"]
class TestFormLoad(IntegrationTestCase):
def test_load(self):
@ -23,10 +22,11 @@ class TestFormLoad(IntegrationTestCase):
self.assertTrue(meta.get("__calendar_js"))
def test_fieldlevel_permissions_in_load(self):
setup_for_tests()
blog = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category-1",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category 1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger 1",
"content": "Test Blog Content",
@ -43,8 +43,8 @@ class TestFormLoad(IntegrationTestCase):
user.remove_roles(*user_roles)
user.add_roles("Blogger")
blog_post_property_setter = make_property_setter("Blog Post", "published", "permlevel", 1, "Int")
reset("Blog Post")
blog_post_property_setter = make_property_setter("Test Blog Post", "published", "permlevel", 1, "Int")
reset("Test Blog Post")
# test field level permission before role level permissions are defined
frappe.set_user(user.name)
@ -63,8 +63,8 @@ class TestFormLoad(IntegrationTestCase):
# test field level permission after role level permissions are defined
frappe.set_user("Administrator")
add("Blog Post", "Website Manager", 1)
update("Blog Post", "Website Manager", 1, "write", 1)
add("Test Blog Post", "Website Manager", 1)
update("Test Blog Post", "Website Manager", 1, "write", 1)
frappe.set_user(user.name)
blog_doc = get_blog(blog.name)
@ -86,7 +86,7 @@ class TestFormLoad(IntegrationTestCase):
user.add_roles("Website Manager")
frappe.set_user(user.name)
doc = frappe.get_doc("Blog Post", blog.name)
doc = frappe.get_doc("Test Blog Post", blog.name)
doc.published = 1
doc.save()
@ -196,5 +196,5 @@ class TestFormLoad(IntegrationTestCase):
def get_blog(blog_name):
frappe.response.docs = []
getdoc("Blog Post", blog_name)
getdoc("Test Blog Post", blog_name)
return frappe.response.docs[0]

View file

@ -0,0 +1,271 @@
import frappe
def create_test_blog_post():
test_blog_doc = frappe.get_doc(
{
"doctype": "DocType",
"name": "Test Blog Post",
"allow_guest_to_view": 1,
"module": "Custom",
"custom": 1,
"title_field": "title",
"autoname": "field:title",
"naming_rule": "By fieldname",
"make_attachments_public": 1,
"owner": "Administrator",
"fields": [
{
"fieldname": "blog_category",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Test Blog Category",
"options": "Test Blog Category",
"reqd": 1,
},
{
"fieldname": "blogger",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Test Blogger",
"options": "Test Blogger",
"reqd": 1,
},
{
"description": "Description for listing page, in plain text, only a couple of lines. (max 200 characters)",
"fieldname": "blog_intro",
"fieldtype": "Small Text",
"label": "Blog Intro",
},
{
"depends_on": "eval:doc.content_type === 'Rich Text'",
"fieldname": "content",
"fieldtype": "Text Editor",
"ignore_xss_filter": 1,
"in_global_search": 1,
"label": "Content",
},
{
"fieldname": "title",
"fieldtype": "Data",
"in_global_search": 1,
"label": "Title",
"no_copy": 1,
"reqd": 1,
},
{
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"hidden": 1,
"label": "Published",
},
],
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1,
},
{
"create": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Blogger",
"share": 1,
"write": 1,
},
],
}
)
test_blog_doc.insert(ignore_if_duplicate=True, ignore_links=True)
create_test_blog_records()
def create_test_blog_records():
test_blog_records = [
{
"blog_category": "_Test Blog Category",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger",
"content": "Test Blog Content",
"doctype": "Test Blog Post",
"title": "_Test Blog Post",
"published": 1,
},
{
"blog_category": "_Test Blog Category 1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger",
"content": "Test Blog Content",
"doctype": "Test Blog Post",
"title": "_Test Blog Post 1",
"published": 1,
},
{
"blog_category": "_Test Blog Category 1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger 1",
"content": "Test Blog Content",
"doctype": "Test Blog Post",
"title": "_Test Blog Post 2",
"published": 0,
},
{
"blog_category": "_Test Blog Category 1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger 2",
"content": "Test Blog Content",
"doctype": "Test Blog Post",
"title": "_Test Blog Post 3",
"published": 0,
},
]
for r in test_blog_records:
frappe.get_doc(r).insert(ignore_if_duplicate=True, ignore_links=True)
def create_test_blog_category():
frappe.get_doc(
{
"doctype": "DocType",
"autoname": "field:title",
"name": "Test Blog Category",
"module": "Custom",
"custom": 1,
"make_attachments_public": 1,
"naming_rule": "By fieldname",
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"no_copy": 1,
"reqd": 1,
},
{
"default": "1",
"fieldname": "published",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Published",
},
{
"depends_on": "published",
"fieldname": "route",
"fieldtype": "Data",
"label": "Route",
"read_only": 1,
"unique": 1,
},
],
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1,
},
{"email": 1, "print": 1, "read": 1, "role": "Blogger"},
],
}
).insert(ignore_if_duplicate=True, ignore_links=True)
create_blog_category_records()
def create_blog_category_records():
test_blog_category_records = [
{"doctype": "Test Blog Category", "parent_website_route": "blog", "title": "_Test Blog Category"},
{"doctype": "Test Blog Category", "parent_website_route": "blog", "title": "_Test Blog Category 1"},
{"doctype": "Test Blog Category", "parent_website_route": "blog", "title": "_Test Blog Category 2"},
]
for r in test_blog_category_records:
frappe.get_doc(r).insert(ignore_if_duplicate=True, ignore_links=True)
def create_test_blogger():
frappe.get_doc(
{
"doctype": "DocType",
"name": "Test Blogger",
"module": "Custom",
"custom": 1,
"autoname": "field:short_name",
"make_attachments_public": 1,
"naming_rule": "By fieldname",
"fields": [
{"default": "0", "fieldname": "disabled", "fieldtype": "Check", "label": "Disabled"},
{
"description": "Will be used in url (usually first name).",
"fieldname": "short_name",
"fieldtype": "Data",
"label": "Short Name",
"reqd": 1,
"unique": 1,
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Full Name",
"reqd": 1,
},
{"fieldname": "user", "fieldtype": "Link", "label": "User", "options": "User"},
{"fieldname": "bio", "fieldtype": "Small Text", "label": "Bio"},
{"fieldname": "avatar", "fieldtype": "Attach Image", "label": "Avatar"},
],
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1,
},
{"email": 1, "print": 1, "read": 1, "role": "Blogger", "share": 1, "write": 1},
],
}
).insert(ignore_if_duplicate=True, ignore_links=True)
create_test_blogger_records()
def create_test_blogger_records():
test_blogger_records = [
{"doctype": "Test Blogger", "full_name": "_Test Blogger", "short_name": "_Test Blogger"},
{"doctype": "Test Blogger", "full_name": "_Test Blogger 1", "short_name": "_Test Blogger 1"},
{"doctype": "Test Blogger", "full_name": "_Test Blogger 2", "short_name": "_Test Blogger 2"},
]
for r in test_blogger_records:
frappe.get_doc(r).insert(ignore_if_duplicate=True, ignore_links=True)
def setup_for_tests():
frappe.set_user("Administrator")
frappe.delete_doc_if_exists("DocType", "Test Blog Post")
frappe.delete_doc_if_exists("DocType", "Test Blog Category")
frappe.delete_doc_if_exists("DocType", "Test Blogger")
create_test_blog_category()
create_test_blogger()
create_test_blog_post()

View file

@ -5,10 +5,12 @@
import frappe
import frappe.defaults
import frappe.model.meta
import frappe.permissions
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.core.doctype.user_permission.user_permission import clear_user_permissions
from frappe.core.page.permission_manager.permission_manager import add, remove, reset, update
from frappe.desk.form.load import getdoc
from frappe.installer import _delete_doctypes
from frappe.permissions import (
ALL_USER_ROLE,
AUTOMATIC_ROLES,
@ -23,17 +25,19 @@ from frappe.permissions import (
update_permission_property,
)
from frappe.tests import IntegrationTestCase
from frappe.tests.test_helpers import setup_for_tests
from frappe.tests.utils import make_test_records_for_doctype
from frappe.utils.data import now_datetime
EXTRA_TEST_RECORD_DEPENDENCIES = ["Blogger", "Blog Post", "User", "Contact", "Salutation"]
EXTRA_TEST_RECORD_DEPENDENCIES = ["User", "Contact", "Salutation"]
class TestPermissions(IntegrationTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.clear_cache(doctype="Blog Post")
setup_for_tests()
frappe.clear_cache(doctype="Test Blog Post")
user = frappe.get_doc("User", "test1@example.com")
user.add_roles("Website Manager")
user.add_roles("System Manager")
@ -48,10 +52,10 @@ class TestPermissions(IntegrationTestCase):
user.add_roles("Website Manager")
def setUp(self):
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
reset("Blogger")
reset("Blog Post")
reset("Test Blogger")
reset("Test Blog Post")
frappe.db.delete("User Permission")
@ -59,11 +63,11 @@ class TestPermissions(IntegrationTestCase):
def tearDown(self):
frappe.set_user("Administrator")
frappe.db.set_value("Blogger", "_Test Blogger 1", "user", None)
frappe.db.set_value("Test Blogger", "_Test Blogger 1", "user", None)
clear_user_permissions_for_doctype("Blog Category")
clear_user_permissions_for_doctype("Blog Post")
clear_user_permissions_for_doctype("Blogger")
clear_user_permissions_for_doctype("Test Blog Category")
clear_user_permissions_for_doctype("Test Blog Post")
clear_user_permissions_for_doctype("Test Blogger")
@staticmethod
def set_strict_user_permissions(ignore):
@ -73,119 +77,124 @@ class TestPermissions(IntegrationTestCase):
ss.save()
def test_basic_permission(self):
post = frappe.get_doc("Blog Post", "-test-blog-post")
post = frappe.get_doc("Test Blog Post", "_Test Blog Post")
self.assertTrue(post.has_permission("read"))
def test_select_permission(self):
# grant only select perm to blog post
add_permission("Blog Post", "Sales User", 0)
update_permission_property("Blog Post", "Sales User", 0, "select", 1)
update_permission_property("Blog Post", "Sales User", 0, "read", 0)
update_permission_property("Blog Post", "Sales User", 0, "write", 0)
add_permission("Test Blog Post", "Sales User", 0)
update_permission_property("Test Blog Post", "Sales User", 0, "select", 1)
update_permission_property("Test Blog Post", "Sales User", 0, "read", 0)
update_permission_property("Test Blog Post", "Sales User", 0, "write", 0)
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
frappe.set_user("test3@example.com")
# validate select perm
post = frappe.get_doc("Blog Post", "-test-blog-post")
post = frappe.get_doc("Test Blog Post", "_Test Blog Post")
self.assertTrue(post.has_permission("select"))
# validate does not have read and write perm
self.assertFalse(post.has_permission("read"))
self.assertRaises(frappe.PermissionError, post.save)
permitted_record = frappe.get_list("Blog Post", fields="*", limit=1)[0]
full_record = frappe.get_all("Blog Post", fields="*", limit=1)[0]
permitted_record = frappe.get_list("Test Blog Post", fields="*", limit=1)[0]
full_record = frappe.get_all("Test Blog Post", fields="*", limit=1)[0]
self.assertNotEqual(permitted_record, full_record)
self.assertSequenceSubset(post.meta.get_search_fields(), permitted_record)
def test_user_permissions_in_doc(self):
add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category 1", "test2@example.com")
frappe.set_user("test2@example.com")
post = frappe.get_doc("Blog Post", "-test-blog-post")
post = frappe.get_doc("Test Blog Post", "_Test Blog Post")
self.assertFalse(post.has_permission("read"))
self.assertFalse(get_doc_permissions(post).get("read"))
post1 = frappe.get_doc("Blog Post", "-test-blog-post-1")
post1 = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
self.assertTrue(post1.has_permission("read"))
self.assertTrue(get_doc_permissions(post1).get("read"))
def test_user_permissions_in_report(self):
add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category 1", "test2@example.com")
frappe.set_user("test2@example.com")
names = [d.name for d in frappe.get_list("Blog Post", fields=["name", "blog_category"])]
names = [d.name for d in frappe.get_list("Test Blog Post", fields=["name", "blog_category"])]
self.assertTrue("-test-blog-post-1" in names)
self.assertFalse("-test-blog-post" in names)
self.assertTrue("_Test Blog Post 1" in names)
self.assertFalse("_Test Blog Post" in names)
def test_default_values(self):
doc = frappe.new_doc("Blog Post")
doc = frappe.new_doc("Test Blog Post")
self.assertFalse(doc.get("blog_category"))
# Fetch default based on single user permission
add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category 1", "test2@example.com")
frappe.set_user("test2@example.com")
doc = frappe.new_doc("Blog Post")
self.assertEqual(doc.get("blog_category"), "-test-blog-category-1")
doc = frappe.new_doc("Test Blog Post")
self.assertEqual(doc.get("blog_category"), "_Test Blog Category 1")
# Don't fetch default if user permissions is more than 1
add_user_permission(
"Blog Category", "-test-blog-category", "test2@example.com", ignore_permissions=True
"Test Blog Category", "_Test Blog Category", "test2@example.com", ignore_permissions=True
)
frappe.clear_cache()
doc = frappe.new_doc("Blog Post")
doc = frappe.new_doc("Test Blog Post")
self.assertFalse(doc.get("blog_category"))
# Fetch user permission set as default from multiple user permission
add_user_permission(
"Blog Category",
"-test-blog-category-2",
"Test Blog Category",
"_Test Blog Category 2",
"test2@example.com",
ignore_permissions=True,
is_default=1,
)
frappe.clear_cache()
doc = frappe.new_doc("Blog Post")
self.assertEqual(doc.get("blog_category"), "-test-blog-category-2")
doc = frappe.new_doc("Test Blog Post")
self.assertEqual(doc.get("blog_category"), "_Test Blog Category 2")
def test_user_link_match_doc(self):
blogger = frappe.get_doc("Blogger", "_Test Blogger 1")
blogger = frappe.get_doc("Test Blogger", "_Test Blogger 1")
blogger.user = "test2@example.com"
blogger.save()
frappe.permissions.add_user_permission("Test Blogger", blogger.name, blogger.user)
frappe.set_user("test2@example.com")
post = frappe.get_doc("Blog Post", "-test-blog-post-2")
post = frappe.get_doc("Test Blog Post", "_Test Blog Post 2")
self.assertTrue(post.has_permission("read"))
post1 = frappe.get_doc("Blog Post", "-test-blog-post-1")
post1 = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
self.assertFalse(post1.has_permission("read"))
def test_user_link_match_report(self):
blogger = frappe.get_doc("Blogger", "_Test Blogger 1")
blogger = frappe.get_doc("Test Blogger", "_Test Blogger 1")
blogger.user = "test2@example.com"
blogger.save()
frappe.permissions.add_user_permission("Test Blogger", blogger.name, blogger.user)
frappe.set_user("test2@example.com")
names = [d.name for d in frappe.get_list("Blog Post", fields=["name", "owner"])]
self.assertTrue("-test-blog-post-2" in names)
self.assertFalse("-test-blog-post-1" in names)
names = [d.name for d in frappe.get_list("Test Blog Post", fields=["name", "owner"])]
self.assertTrue("_Test Blog Post 2" in names)
self.assertFalse("_Test Blog Post 1" in names)
def test_set_user_permissions(self):
frappe.set_user("test1@example.com")
add_user_permission("Blog Post", "-test-blog-post", "test2@example.com")
add_user_permission("Test Blog Post", "_Test Blog Post", "test2@example.com")
def test_not_allowed_to_set_user_permissions(self):
frappe.set_user("test2@example.com")
# this user can't add user permissions
self.assertRaises(
frappe.PermissionError, add_user_permission, "Blog Post", "-test-blog-post", "test2@example.com"
frappe.PermissionError,
add_user_permission,
"Test Blog Post",
"_Test Blog Post",
"test2@example.com",
)
def test_read_if_explicit_user_permissions_are_set(self):
@ -194,11 +203,11 @@ class TestPermissions(IntegrationTestCase):
frappe.set_user("test2@example.com")
# user can only access permitted blog post
doc = frappe.get_doc("Blog Post", "-test-blog-post")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post")
self.assertTrue(doc.has_permission("read"))
# and not this one
doc = frappe.get_doc("Blog Post", "-test-blog-post-1")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
self.assertFalse(doc.has_permission("read"))
def test_not_allowed_to_remove_user_permissions(self):
@ -210,24 +219,24 @@ class TestPermissions(IntegrationTestCase):
self.assertRaises(
frappe.PermissionError,
remove_user_permission,
"Blog Post",
"-test-blog-post",
"Test Blog Post",
"_Test Blog Post",
"test2@example.com",
)
def test_user_permissions_if_applied_on_doc_being_evaluated(self):
frappe.set_user("test2@example.com")
doc = frappe.get_doc("Blog Post", "-test-blog-post-1")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
self.assertTrue(doc.has_permission("read"))
frappe.set_user("test1@example.com")
add_user_permission("Blog Post", "-test-blog-post", "test2@example.com")
add_user_permission("Test Blog Post", "_Test Blog Post", "test2@example.com")
frappe.set_user("test2@example.com")
doc = frappe.get_doc("Blog Post", "-test-blog-post-1")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
self.assertFalse(doc.has_permission("read"))
doc = frappe.get_doc("Blog Post", "-test-blog-post")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post")
self.assertTrue(doc.has_permission("read"))
def test_set_standard_fields_manually(self):
@ -257,8 +266,8 @@ class TestPermissions(IntegrationTestCase):
self.assertRaises(frappe.CannotChangeConstantError, user.save)
def test_set_only_once(self):
blog_post = frappe.get_meta("Blog Post")
doc = frappe.get_doc("Blog Post", "-test-blog-post-1")
blog_post = frappe.get_meta("Test Blog Post")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
doc.db_set("title", "Old")
blog_post.get_field("title").set_only_once = 1
doc.title = "New"
@ -268,7 +277,7 @@ class TestPermissions(IntegrationTestCase):
def test_set_only_once_child_table_rows(self):
doctype_meta = frappe.get_meta("DocType")
doctype_meta.get_field("fields").set_only_once = 1
doc = frappe.get_doc("DocType", "Blog Post")
doc = frappe.get_doc("DocType", "Test Blog Post")
# remove last one
doc.fields = doc.fields[:-1]
@ -278,51 +287,50 @@ class TestPermissions(IntegrationTestCase):
def test_set_only_once_child_table_row_value(self):
doctype_meta = frappe.get_meta("DocType")
doctype_meta.get_field("fields").set_only_once = 1
doc = frappe.get_doc("DocType", "Blog Post")
doc = frappe.get_doc("DocType", "Test Blog Post")
# change one property from the child table
doc.fields[-1].fieldtype = "Check"
doc.fields[-3].fieldtype = "Check"
self.assertRaises(frappe.CannotChangeConstantError, doc.save)
frappe.clear_cache(doctype="DocType")
def test_set_only_once_child_table_okay(self):
doctype_meta = frappe.get_meta("DocType")
doctype_meta.get_field("fields").set_only_once = 1
doc = frappe.get_doc("DocType", "Blog Post")
doc = frappe.get_doc("DocType", "Test Blog Post")
doc.load_doc_before_save()
self.assertFalse(doc.validate_set_only_once())
frappe.clear_cache(doctype="DocType")
def test_user_permission_doctypes(self):
add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com")
add_user_permission("Blogger", "_Test Blogger 1", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category 1", "test2@example.com")
add_user_permission("Test Blogger", "_Test Blogger 1", "test2@example.com")
frappe.set_user("test2@example.com")
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
doc = frappe.get_doc("Blog Post", "-test-blog-post")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post")
self.assertFalse(doc.has_permission("read"))
doc = frappe.get_doc("Blog Post", "-test-blog-post-2")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post 2")
self.assertTrue(doc.has_permission("read"))
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
def if_owner_setup(self):
update("Blog Post", "Blogger", 0, "if_owner", 1)
update("Test Blog Post", "Blogger", 0, "if_owner", 1)
add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com")
add_user_permission("Blogger", "_Test Blogger 1", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category 1", "test2@example.com")
add_user_permission("Test Blogger", "_Test Blogger 1", "test2@example.com")
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
def test_insert_if_owner_with_user_permissions(self):
"""If `If Owner` is checked for a Role, check if that document
is allowed to be read, updated, submitted, etc. except be created,
even if the document is restricted based on User Permissions."""
frappe.delete_doc("Blog Post", "-test-blog-post-title")
frappe.delete_doc("Test Blog Post", "-test-blog-post-title")
self.if_owner_setup()
@ -330,8 +338,8 @@ class TestPermissions(IntegrationTestCase):
doc = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category",
"blogger": "_Test Blogger 1",
"title": "_Test Blog Post Title",
"content": "_Test Blog Post Content",
@ -341,34 +349,35 @@ class TestPermissions(IntegrationTestCase):
self.assertRaises(frappe.PermissionError, doc.insert)
frappe.set_user("test1@example.com")
add_user_permission("Blog Category", "-test-blog-category", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category", "test2@example.com")
frappe.set_user("test2@example.com")
doc.insert()
frappe.set_user("Administrator")
remove_user_permission("Blog Category", "-test-blog-category", "test2@example.com")
remove_user_permission("Test Blog Category", "_Test Blog Category", "test2@example.com")
frappe.clear_cache()
frappe.set_user("test2@example.com")
doc = frappe.get_doc(doc.doctype, doc.name)
self.assertTrue(doc.has_permission("read"))
self.assertTrue(doc.has_permission("write"))
self.assertFalse(doc.has_permission("create"))
# delete created record
frappe.set_user("Administrator")
frappe.delete_doc("Blog Post", "-test-blog-post-title")
frappe.delete_doc("Test Blog Post", "_Test Blog Post Title")
def test_ignore_user_permissions_if_missing(self):
"""If there are no user permissions, then allow as per role"""
add_user_permission("Blog Category", "-test-blog-category", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category", "test2@example.com")
frappe.set_user("test2@example.com")
doc = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category-2",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category 2",
"blogger": "_Test Blogger 1",
"title": "_Test Blog Post Title",
"content": "_Test Blog Post Content",
@ -378,7 +387,7 @@ class TestPermissions(IntegrationTestCase):
self.assertFalse(doc.has_permission("write"))
frappe.set_user("Administrator")
remove_user_permission("Blog Category", "-test-blog-category", "test2@example.com")
remove_user_permission("Test Blog Category", "_Test Blog Category", "test2@example.com")
frappe.set_user("test2@example.com")
self.assertTrue(doc.has_permission("write"))
@ -428,9 +437,9 @@ class TestPermissions(IntegrationTestCase):
clear_user_permissions_for_doctype("Contact")
def test_user_permission_is_not_applied_if_user_roles_does_not_have_permission(self):
add_user_permission("Blog Post", "-test-blog-post-1", "test3@example.com")
add_user_permission("Test Blog Post", "_Test Blog Post 1", "test3@example.com")
frappe.set_user("test3@example.com")
doc = frappe.get_doc("Blog Post", "-test-blog-post-1")
doc = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
self.assertFalse(doc.has_permission("read"))
frappe.set_user("Administrator")
@ -444,20 +453,22 @@ class TestPermissions(IntegrationTestCase):
def test_contextual_user_permission(self):
# should be applicable for across all doctypes
add_user_permission("Blogger", "_Test Blogger", "test2@example.com")
add_user_permission("Test Blogger", "_Test Blogger", "test2@example.com")
# should be applicable only while accessing Blog Post
add_user_permission("Blogger", "_Test Blogger 1", "test2@example.com", applicable_for="Blog Post")
add_user_permission(
"Test Blogger", "_Test Blogger 1", "test2@example.com", applicable_for="Test Blog Post"
)
# should be applicable only while accessing User
add_user_permission("Blogger", "_Test Blogger 2", "test2@example.com", applicable_for="User")
add_user_permission("Test Blogger", "_Test Blogger 2", "test2@example.com", applicable_for="User")
posts = frappe.get_all("Blog Post", fields=["name", "blogger"])
posts = frappe.get_all("Test Blog Post", fields=["name", "blogger"])
# Get all posts for admin
self.assertEqual(len(posts), 4)
frappe.set_user("test2@example.com")
posts = frappe.get_list("Blog Post", fields=["name", "blogger"])
posts = frappe.get_list("Test Blog Post", fields=["name", "blogger"])
# Should get only posts with allowed blogger via user permission
# only '_Test Blogger', '_Test Blogger 1' are allowed in Blog Post
@ -473,32 +484,32 @@ class TestPermissions(IntegrationTestCase):
def test_if_owner_permission_overrides_properly(self):
# check if user is not granted access if the user is not the owner of the doc
# Blogger has only read access on the blog post unless he is the owner of the blog
update("Blog Post", "Blogger", 0, "if_owner", 1)
update("Blog Post", "Blogger", 0, "read", 1, 1)
update("Blog Post", "Blogger", 0, "write", 1, 1)
update("Blog Post", "Blogger", 0, "delete", 1, 1)
update("Test Blog Post", "Blogger", 0, "if_owner", 1)
update("Test Blog Post", "Blogger", 0, "read", 1, 1)
update("Test Blog Post", "Blogger", 0, "write", 1, 1)
update("Test Blog Post", "Blogger", 0, "delete", 1, 1)
# currently test2 user has not created any document
# still he should be able to do get_list query which should
# not raise permission error but simply return empty list
frappe.set_user("test2@example.com")
self.assertEqual(frappe.get_list("Blog Post"), [])
self.assertEqual(frappe.get_list("Test Blog Post"), [])
frappe.set_user("Administrator")
# creates a custom docperm with just read access
# now any user can read any blog post (but other rights are limited to the blog post owner)
add_permission("Blog Post", "Blogger")
frappe.clear_cache(doctype="Blog Post")
add_permission("Test Blog Post", "Blogger")
frappe.clear_cache(doctype="Test Blog Post")
frappe.delete_doc("Blog Post", "-test-blog-post-title")
frappe.delete_doc("Test Blog Post", "_Test Blog Post Title")
frappe.set_user("test1@example.com")
doc = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category",
"blogger": "_Test Blogger 1",
"title": "_Test Blog Post Title",
"content": "_Test Blog Post Content",
@ -523,21 +534,21 @@ class TestPermissions(IntegrationTestCase):
self.assertTrue(doc.has_permission("delete"))
# delete the created doc
frappe.delete_doc("Blog Post", "-test-blog-post-title")
frappe.delete_doc("Test Blog Post", "_Test Blog Post Title")
def test_if_owner_permission_on_getdoc(self):
update("Blog Post", "Blogger", 0, "if_owner", 1)
update("Blog Post", "Blogger", 0, "read", 1)
update("Blog Post", "Blogger", 0, "write", 1)
update("Blog Post", "Blogger", 0, "delete", 1)
frappe.clear_cache(doctype="Blog Post")
update("Test Blog Post", "Blogger", 0, "if_owner", 1)
update("Test Blog Post", "Blogger", 0, "read", 1)
update("Test Blog Post", "Blogger", 0, "write", 1)
update("Test Blog Post", "Blogger", 0, "delete", 1)
frappe.clear_cache(doctype="Test Blog Post")
frappe.set_user("test1@example.com")
doc = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category",
"blogger": "_Test Blogger 1",
"title": "_Test Blog Post Title New",
"content": "_Test Blog Post Content",
@ -546,18 +557,18 @@ class TestPermissions(IntegrationTestCase):
doc.insert()
getdoc("Blog Post", doc.name)
getdoc("Test Blog Post", doc.name)
doclist = [d.name for d in frappe.response.docs]
self.assertTrue(doc.name in doclist)
frappe.set_user("test2@example.com")
self.assertRaises(frappe.PermissionError, getdoc, "Blog Post", doc.name)
self.assertRaises(frappe.PermissionError, getdoc, "Test Blog Post", doc.name)
def test_if_owner_permission_on_get_list(self):
doc = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category",
"blogger": "_Test Blogger 1",
"title": "_Test If Owner Permissions on Get List",
"content": "_Test Blog Post Content",
@ -566,39 +577,39 @@ class TestPermissions(IntegrationTestCase):
doc.insert(ignore_if_duplicate=True)
update("Blog Post", "Blogger", 0, "if_owner", 1)
update("Blog Post", "Blogger", 0, "read", 1)
update("Test Blog Post", "Blogger", 0, "if_owner", 1)
update("Test Blog Post", "Blogger", 0, "read", 1)
user = frappe.get_doc("User", "test2@example.com")
user.add_roles("Website Manager")
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
frappe.set_user("test2@example.com")
self.assertIn(doc.name, frappe.get_list("Blog Post", pluck="name"))
self.assertIn(doc.name, frappe.get_list("Test Blog Post", pluck="name"))
# Become system manager to remove role
frappe.set_user("test1@example.com")
user.remove_roles("Website Manager")
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
frappe.set_user("test2@example.com")
self.assertNotIn(doc.name, frappe.get_list("Blog Post", pluck="name"))
self.assertNotIn(doc.name, frappe.get_list("Test Blog Post", pluck="name"))
def test_if_owner_permission_on_delete(self):
update("Blog Post", "Blogger", 0, "if_owner", 1)
update("Blog Post", "Blogger", 0, "read", 1, 1)
update("Blog Post", "Blogger", 0, "write", 1, 1)
update("Blog Post", "Blogger", 0, "delete", 1, 1)
update("Test Blog Post", "Blogger", 0, "if_owner", 1)
update("Test Blog Post", "Blogger", 0, "read", 1, 1)
update("Test Blog Post", "Blogger", 0, "write", 1, 1)
update("Test Blog Post", "Blogger", 0, "delete", 1, 1)
# Remove delete perm
update("Blog Post", "Website Manager", 0, "delete", 0)
update("Test Blog Post", "Website Manager", 0, "delete", 0)
frappe.clear_cache(doctype="Blog Post")
frappe.clear_cache(doctype="Test Blog Post")
with self.set_user("test2@example.com"):
doc = frappe.get_doc(
{
"doctype": "Blog Post",
"blog_category": "-test-blog-category",
"doctype": "Test Blog Post",
"blog_category": "_Test Blog Category",
"blogger": "_Test Blogger 1",
"title": "_Test Blog Post Title New 1",
"content": "_Test Blog Post Content",
@ -607,46 +618,46 @@ class TestPermissions(IntegrationTestCase):
doc.insert()
getdoc("Blog Post", doc.name)
getdoc("Test Blog Post", doc.name)
doclist = [d.name for d in frappe.response.docs]
self.assertTrue(doc.name in doclist)
with self.set_user("testperm@example.com"):
# Website Manager able to read
getdoc("Blog Post", doc.name)
getdoc("Test Blog Post", doc.name)
doclist = [d.name for d in frappe.response.docs]
self.assertTrue(doc.name in doclist)
# Website Manager should not be able to delete
self.assertRaises(frappe.PermissionError, frappe.delete_doc, "Blog Post", doc.name)
self.assertRaises(frappe.PermissionError, frappe.delete_doc, "Test Blog Post", doc.name)
with self.set_user("test2@example.com"):
frappe.delete_doc("Blog Post", "-test-blog-post-title-new-1")
frappe.delete_doc("Test Blog Post", "_Test Blog Post Title New 1")
update("Blog Post", "Website Manager", 0, "delete", 1, 1)
update("Test Blog Post", "Website Manager", 0, "delete", 1, 1)
def test_clear_user_permissions(self):
current_user = frappe.session.user
frappe.set_user("Administrator")
clear_user_permissions_for_doctype("Blog Category", "test2@example.com")
clear_user_permissions_for_doctype("Blog Post", "test2@example.com")
clear_user_permissions_for_doctype("Test Blog Category", "test2@example.com")
clear_user_permissions_for_doctype("Test Blog Post", "test2@example.com")
add_user_permission("Blog Post", "-test-blog-post-1", "test2@example.com")
add_user_permission("Blog Post", "-test-blog-post-2", "test2@example.com")
add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com")
add_user_permission("Test Blog Post", "_Test Blog Post 1", "test2@example.com")
add_user_permission("Test Blog Post", "_Test Blog Post 2", "test2@example.com")
add_user_permission("Test Blog Category", "_Test Blog Category 1", "test2@example.com")
deleted_user_permission_count = clear_user_permissions("test2@example.com", "Blog Post")
deleted_user_permission_count = clear_user_permissions("test2@example.com", "Test Blog Post")
self.assertEqual(deleted_user_permission_count, 2)
blog_post_user_permission_count = frappe.db.count(
"User Permission", filters={"user": "test2@example.com", "allow": "Blog Post"}
"User Permission", filters={"user": "test2@example.com", "allow": "Test Blog Post"}
)
self.assertEqual(blog_post_user_permission_count, 0)
blog_category_user_permission_count = frappe.db.count(
"User Permission", filters={"user": "test2@example.com", "allow": "Blog Category"}
"User Permission", filters={"user": "test2@example.com", "allow": "Test Blog Category"}
)
self.assertEqual(blog_category_user_permission_count, 1)

View file

@ -13,10 +13,11 @@ from frappe.tests.test_db_query import (
setup_patched_blog_post,
setup_test_user,
)
from frappe.tests.test_helpers import setup_for_tests
from frappe.tests.test_query_builder import db_type_is, run_only_if
from frappe.utils.nestedset import get_ancestors_of, get_descendants_of
EXTRA_TEST_RECORD_DEPENDENCIES = ["User", "Blog Post", "Blog Category", "Blogger"]
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
def create_tree_docs():
@ -63,6 +64,9 @@ def create_tree_docs():
class TestQuery(IntegrationTestCase):
def setUp(self):
setup_for_tests()
@run_only_if(db_type_is.MARIADB)
def test_multiple_tables_in_filters(self):
self.assertEqual(
@ -719,31 +723,30 @@ class TestQuery(IntegrationTestCase):
def test_build_match_conditions(self):
from frappe.permissions import add_user_permission, clear_user_permissions_for_doctype
clear_user_permissions_for_doctype("Blog Post", "test2@example.com")
clear_user_permissions_for_doctype("Test Blog Post", "test2@example.com")
test2user = frappe.get_doc("User", "test2@example.com")
test2user.add_roles("Blogger")
frappe.set_user("test2@example.com")
# Before any user permission is applied, there should be no conditions
query = frappe.qb.get_query("Blog Post", ignore_permissions=False)
query = frappe.qb.get_query("Test Blog Post", ignore_permissions=False)
self.assertNotIn("(`tabBlog Post`.`name` in (", str(query))
# Add user permissions
add_user_permission("Blog Post", "-test-blog-post", "test2@example.com", True)
add_user_permission("Blog Post", "-test-blog-post-1", "test2@example.com", True)
add_user_permission("Test Blog Post", "_Test Blog Post", "test2@example.com", True)
add_user_permission("Test Blog Post", "_Test Blog Post 1", "test2@example.com", True)
# After applying user permission, condition should be in query
query = str(frappe.qb.get_query("Blog Post", ignore_permissions=False))
query = str(frappe.qb.get_query("Test Blog Post", ignore_permissions=False))
# Check for user permission condition in the query string
if frappe.db.db_type == "mariadb":
self.assertIn("`name` IS NULL OR `name` IN ('-test-blog-post-1','-test-blog-post')", query)
self.assertIn("`name` IS NULL OR `name` IN ('_Test Blog Post 1','_Test Blog Post')", query)
elif frappe.db.db_type == "postgres":
self.assertIn("\"name\" IS NULL OR \"name\" IN ('-test-blog-post-1','-test-blog-post')", query)
self.assertIn("\"name\" IS NULL OR \"name\" IN ('_Test Blog Post 1','_Test Blog Post')", query)
frappe.set_user("Administrator")
clear_user_permissions_for_doctype("Blog Post", "test2@example.com")
clear_user_permissions_for_doctype("Test Blog Post", "test2@example.com")
test2user.remove_roles("Blogger")
def test_ignore_permissions_for_query(self):
@ -763,17 +766,17 @@ class TestQuery(IntegrationTestCase):
# Create a test blog post
test_post = frappe.get_doc(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"title": "Test Permission Post",
"content": "Test Content",
"blog_category": "-test-blog-category",
"blog_category": "_Test Blog Category",
"published": 1,
}
).insert(ignore_permissions=True, ignore_mandatory=True)
# Without proper permission, published field should be filtered out
data = frappe.qb.get_query(
"Blog Post",
"Test Blog Post",
filters={"name": test_post.name},
fields=["name", "published", "title"],
ignore_permissions=False,
@ -787,7 +790,7 @@ class TestQuery(IntegrationTestCase):
# With Administrator, all fields should be accessible
frappe.set_user("Administrator")
data = frappe.qb.get_query(
"Blog Post",
"Test Blog Post",
filters={"name": test_post.name},
fields=["name", "published", "title"],
ignore_permissions=False,
@ -1055,10 +1058,10 @@ class TestQuery(IntegrationTestCase):
# Create a test blog post
test_post = frappe.get_doc(
{
"doctype": "Blog Post",
"doctype": "Test Blog Post",
"title": "Test Filter Permission Post",
"content": "Test Content",
"blog_category": "-test-blog-category",
"blog_category": "_Test Blog Category",
"published": 1, # permlevel 1
}
).insert(ignore_permissions=True, ignore_mandatory=True, ignore_if_duplicate=True)
@ -1067,7 +1070,7 @@ class TestQuery(IntegrationTestCase):
# Try filtering on permitted field (title - permlevel 0)
try:
frappe.qb.get_query(
"Blog Post",
"Test Blog Post",
filters={"title": test_post.title},
ignore_permissions=False,
user=user.name,
@ -1078,7 +1081,7 @@ class TestQuery(IntegrationTestCase):
# Try filtering on non-permitted field (published - permlevel 1)
with self.assertRaises(frappe.PermissionError) as cm:
frappe.qb.get_query(
"Blog Post",
"Test Blog Post",
filters={"published": 1},
ignore_permissions=False,
user=user.name,

View file

@ -5,11 +5,6 @@ from frappe.utils import get_html_for_route
class TestSitemap(IntegrationTestCase):
def test_sitemap(self):
from frappe.tests.utils import make_test_records
make_test_records("Blog Post")
blogs = frappe.get_all("Blog Post", {"published": 1}, ["route"], limit=1)
xml = get_html_for_route("sitemap.xml")
self.assertTrue("/about</loc>" in xml)
self.assertTrue("/contact</loc>" in xml)
self.assertTrue(blogs[0].route in xml)

View file

@ -0,0 +1,505 @@
import os
import sqlite3
import time
from typing import ClassVar
from unittest.mock import patch
import frappe
from frappe.search.sqlite_search import SQLiteSearch, SQLiteSearchIndexMissingError
from frappe.tests import IntegrationTestCase
class TestSQLiteSearch(SQLiteSearch):
"""Test implementation of SQLiteSearch for testing purposes."""
INDEX_NAME = "test_search.db"
INDEX_SCHEMA: ClassVar = {
"text_fields": ["title", "content"],
"metadata_fields": ["doctype", "name", "owner", "modified"],
"tokenizer": "unicode61 remove_diacritics 2",
}
INDEXABLE_DOCTYPES: ClassVar = {
"Note": {
"fields": ["name", "title", "content", "owner", {"modified": "creation"}],
},
"ToDo": {
"fields": ["name", {"title": "description"}, {"content": "description"}, "owner", "modified"],
},
"User": {
"fields": ["name", {"title": "full_name"}, {"content": "email"}, "name", "modified"],
"filters": {"enabled": 1},
},
}
def get_search_filters(self):
"""Return permission filters - for testing, allow all documents."""
if frappe.session.user == "Administrator":
return {}
# Simulate user-specific filtering
return {"owner": frappe.session.user}
class TestSQLiteSearchAPI(IntegrationTestCase):
"""Test suite for SQLiteSearch public API functionality."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.search = TestSQLiteSearch()
# Clean up any existing test database
cls.search.drop_index()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
# Clean up test database
cls.search.drop_index()
def setUp(self):
"""Set up test data for each test."""
super().setUp()
# Create test documents
self.test_notes = []
self.test_todos = []
# Create test notes with different content
note_data = [
{"title": "Python Programming Guide", "content": "Learn Python basics and advanced concepts"},
{"title": "Project Management Tips", "content": "How to manage software projects effectively"},
{"title": "Cooking Recipe Collection", "content": "Delicious recipes for home cooking"},
{
"title": "Machine Learning Tutorial",
"content": "Introduction to ML algorithms and Python implementation",
},
]
for data in note_data:
note = frappe.get_doc({"doctype": "Note", "title": data["title"], "content": data["content"]})
note.insert()
self.test_notes.append(note)
# Create test todos
todo_data = [
{"description": "Review Python code for search functionality"},
{"description": "Update project documentation"},
{"description": "Plan team meeting agenda"},
]
for data in todo_data:
todo = frappe.get_doc({"doctype": "ToDo", "description": data["description"], "status": "Open"})
todo.insert()
self.test_todos.append(todo)
def tearDown(self):
"""Clean up test data after each test."""
# Delete test documents
for note in self.test_notes:
try:
note.delete()
except Exception:
pass
for todo in self.test_todos:
try:
todo.delete()
except Exception:
pass
super().tearDown()
def test_index_lifecycle_and_status_methods(self):
"""Test index building, existence checking, and status validation."""
# Initially index should not exist
self.search.drop_index() # Ensure clean state
self.assertFalse(self.search.index_exists())
# Should raise error when trying to search without index
with self.assertRaises(SQLiteSearchIndexMissingError):
self.search.raise_if_not_indexed()
# Build index
self.search.build_index()
# Now index should exist
self.assertTrue(self.search.index_exists())
# Should not raise error now
try:
self.search.raise_if_not_indexed()
except SQLiteSearchIndexMissingError:
self.fail("raise_if_not_indexed() raised exception when index exists")
# Verify database file exists and has correct tables
self.assertTrue(os.path.exists(self.search.db_path))
conn = sqlite3.connect(self.search.db_path)
cursor = conn.cursor()
# Check if FTS table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='search_fts'")
self.assertTrue(cursor.fetchone())
# Check if vocabulary tables exist
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='search_vocabulary'")
self.assertTrue(cursor.fetchone())
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='search_trigrams'")
self.assertTrue(cursor.fetchone())
conn.close()
# Test drop_index method
self.search.drop_index()
self.assertFalse(self.search.index_exists())
self.assertFalse(os.path.exists(self.search.db_path))
# Dropping non-existent index should not raise error
self.search.drop_index() # Should not raise error
def test_basic_search_functionality(self):
"""Test core search functionality with various query types."""
# Build index first
self.search.build_index()
# Test basic text search
results = self.search.search("Python")
self.assertGreater(len(results["results"]), 0)
self.assertIn("Python", results["results"][0]["title"] + results["results"][0]["content"])
# Verify result structure
result = results["results"][0]
required_fields = [
"id",
"title",
"content",
"doctype",
"name",
"score",
"original_rank",
"modified_rank",
]
for field in required_fields:
self.assertIn(field, result)
# Test case-insensitive search
results_lower = self.search.search("python")
results_upper = self.search.search("PYTHON")
self.assertEqual(len(results_lower["results"]), len(results_upper["results"]))
# Test partial word matching
results = self.search.search("prog") # Should match "Programming"
self.assertGreater(len(results["results"]), 0)
# Test multi-word search
results = self.search.search("Python programming")
self.assertGreater(len(results["results"]), 0)
# Test empty query
results = self.search.search("")
self.assertEqual(len(results["results"]), 0)
# Test title-only search
results = self.search.search("Python", title_only=True)
self.assertGreater(len(results["results"]), 0)
for result in results["results"]:
self.assertIn("Python", result["title"])
def test_search_filtering_and_permissions(self):
"""Test search filtering and permission-based result filtering."""
self.search.build_index()
# Test basic filtering by doctype
results = self.search.search("", filters={"doctype": "Note"})
for result in results["results"]:
self.assertEqual(result["doctype"], "Note")
# Test filtering with list values
results = self.search.search("", filters={"doctype": ["Note", "ToDo"]})
for result in results["results"]:
self.assertIn(result["doctype"], ["Note", "ToDo"])
# Test empty filter list (should return no results)
results = self.search.search("", filters={"doctype": []})
self.assertEqual(len(results["results"]), 0)
# Test permission filtering by switching users
original_user = frappe.session.user
try:
# Create a test user and switch to them
test_user_email = "test_search_user@example.com"
if not frappe.db.exists("User", test_user_email):
test_user = frappe.get_doc(
{
"doctype": "User",
"email": test_user_email,
"first_name": "Test",
"last_name": "User",
"enabled": 1,
}
)
test_user.insert()
frappe.set_user(test_user_email)
# Search should now filter by owner (based on our test implementation)
results = self.search.search("Python")
# Results should be limited based on permission filters
self.assertIsInstance(results["results"], list)
finally:
frappe.set_user(original_user)
def test_advanced_scoring_and_ranking(self):
"""Test scoring pipeline, ranking, and result ordering."""
self.search.build_index()
# Search for a term that appears in multiple documents
results = self.search.search("Python")
# Verify results are sorted by score (descending)
scores = [result["score"] for result in results["results"]]
self.assertEqual(scores, sorted(scores, reverse=True))
# Verify both original and modified rankings are present
for i, result in enumerate(results["results"]):
self.assertEqual(result["modified_rank"], i + 1)
self.assertIsInstance(result["original_rank"], int)
self.assertGreater(result["original_rank"], 0)
# Test title boost - documents with search term in title should rank higher
results = self.search.search("Programming")
title_match_found = False
for result in results["results"]:
if "Programming" in result["title"]:
title_match_found = True
# Title matches should have higher scores
self.assertGreater(result["score"], 1.0)
break
self.assertTrue(title_match_found, "No title matches found for scoring test")
# Test that BM25 score is included
for result in results["results"]:
self.assertIn("bm25_score", result)
self.assertIsInstance(result["bm25_score"], (int, float))
def test_spelling_correction_and_query_expansion(self):
"""Test spelling correction and query expansion functionality."""
self.search.build_index()
# Test with a misspelled word that should be corrected
results = self.search.search("Pythom") # Misspelled "Python"
# Check if corrections were applied
summary = results["summary"]
if summary.get("corrected_words"):
self.assertIsInstance(summary["corrected_words"], dict)
self.assertIsInstance(summary["corrected_query"], str)
# Even with misspelling, we should get some results due to correction
# (This might not always work depending on vocabulary, so we test gracefully)
self.assertIsInstance(results["results"], list)
# Test with a completely made-up word
results = self.search.search("xyzabc123nonexistent")
# Should return empty results or minimal results
self.assertLessEqual(len(results["results"]), 1)
def test_document_indexing_operations(self):
"""Test individual document indexing and removal operations."""
self.search.build_index()
# Create a new document after index is built
new_note = frappe.get_doc(
{
"doctype": "Note",
"title": "Newly Added Document",
"content": "This document was added after initial indexing",
}
)
new_note.insert()
try:
# Initially, the new document shouldn't be in search results
results = self.search.search("Newly Added Document")
initial_count = len(results["results"])
# Index the new document
self.search.index_doc("Note", new_note.name)
# Now it should be findable
results = self.search.search("Newly Added Document")
self.assertGreater(len(results["results"]), initial_count)
# Verify the document is in results
found = False
for result in results["results"]:
if result["name"] == new_note.name:
found = True
break
self.assertTrue(found, "Newly indexed document not found in search results")
# Remove the document from index
self.search.remove_doc("Note", new_note.name)
# Should not be findable anymore
results = self.search.search("Newly Added Document")
found = False
for result in results["results"]:
if result["name"] == new_note.name:
found = True
break
self.assertFalse(found, "Removed document still found in search results")
finally:
new_note.delete()
def test_search_result_summary_and_metadata(self):
"""Test search result summary and metadata information."""
self.search.build_index()
results = self.search.search("Python")
summary = results["summary"]
# Verify summary structure
required_summary_fields = [
"total_matches",
"filtered_matches",
"returned_matches",
"duration",
"title_only",
"applied_filters",
]
for field in required_summary_fields:
self.assertIn(field, summary)
# Verify summary values make sense
self.assertIsInstance(summary["duration"], (int, float))
self.assertGreater(summary["duration"], 0)
self.assertEqual(summary["total_matches"], summary["filtered_matches"])
self.assertEqual(summary["filtered_matches"], len(results["results"]))
self.assertFalse(summary["title_only"])
self.assertEqual(summary["applied_filters"], {})
# Test with filters applied
results = self.search.search("Python", filters={"doctype": "Note"})
summary = results["summary"]
self.assertEqual(summary["applied_filters"], {"doctype": "Note"})
# Test title-only search
results = self.search.search("Python", title_only=True)
summary = results["summary"]
self.assertTrue(summary["title_only"])
def test_configuration_and_schema_validation(self):
"""Test configuration validation and schema handling."""
# Test invalid configuration
class InvalidSearchClass(SQLiteSearch):
# Missing required INDEX_SCHEMA
INDEXABLE_DOCTYPES: ClassVar = {"Note": {"fields": ["name", "title"]}}
def get_search_filters(self):
return {}
with self.assertRaises(ValueError):
InvalidSearchClass()
# Test invalid doctype configuration
class InvalidDoctypeConfig(SQLiteSearch):
INDEX_SCHEMA: ClassVar = {"text_fields": ["title", "content"]}
INDEXABLE_DOCTYPES: ClassVar = {
"Note": {
# Missing 'fields' key
"title_field": "title"
}
}
def get_search_filters(self):
return {}
with self.assertRaises(ValueError):
InvalidDoctypeConfig()
def test_content_processing_and_html_handling(self):
"""Test content processing including HTML tag removal and text normalization."""
self.search.build_index()
# Create a note with HTML content
html_note = frappe.get_doc(
{
"doctype": "Note",
"title": "HTML Content Test",
"content": "<p>This is <strong>bold</strong> text with <a href='http://example.com'>links</a> and <br> line breaks.</p>",
}
)
html_note.insert()
try:
# Index the document
self.search.index_doc("Note", html_note.name)
# Search should find processed content
results = self.search.search("bold text links")
# Should find the document
found = False
for result in results["results"]:
if result["name"] == html_note.name:
found = True
# Content should be processed (HTML tags removed)
self.assertNotIn("<p>", result["content"])
self.assertNotIn("<strong>", result["content"])
self.assertIn("bold", result["content"])
self.assertNotIn(
"<a href='http://example.com'>", result["content"]
) # Links should be replaced
break
self.assertTrue(found, "HTML content document not found in search")
finally:
html_note.delete()
def test_search_disabled_state(self):
"""Test behavior when search is disabled."""
# Create a search class with search disabled
class DisabledSearch(TestSQLiteSearch):
def is_search_enabled(self):
return False
disabled_search = DisabledSearch()
disabled_search.drop_index() # Ensure clean state
# Should return empty results when disabled
results = disabled_search.search("Python")
self.assertEqual(len(results["results"]), 0)
# Build index should do nothing when disabled
disabled_search.build_index() # Should not raise error but do nothing
self.assertFalse(disabled_search.index_exists())
@patch("frappe.enqueue")
def test_background_operations(self, mock_enqueue):
"""Test background job integration and module-level functions."""
from frappe.search.sqlite_search import (
build_index_in_background,
get_search_classes,
)
# Test getting search classes
with patch("frappe.get_hooks") as mock_get_hooks:
mock_get_hooks.return_value = ["frappe.tests.test_sqlite_search.TestSQLiteSearch"]
classes = get_search_classes()
self.assertEqual(len(classes), 1)
self.assertEqual(classes[0], TestSQLiteSearch)
# Test background index building
with patch("frappe.get_hooks") as mock_get_hooks:
mock_get_hooks.return_value = ["frappe.tests.test_sqlite_search.TestSQLiteSearch"]
build_index_in_background()
# Should have enqueued a background job
self.assertTrue(mock_enqueue.called)

View file

@ -1,6 +1,7 @@
import frappe
from frappe import _
from frappe.permissions import AUTOMATIC_ROLES
from frappe.tests.test_helpers import create_test_blog_category
from frappe.utils import add_to_date, now
UI_TEST_USER = "frappe@example.com"
@ -85,6 +86,13 @@ def prepare_webform_test():
frappe.delete_doc_if_exists("Web Form", "note")
@whitelist_for_tests
def create_doctype_for_attachment():
create_test_blog_category()
doc = frappe.get_doc("Test Blog Category", "_Test Blog Category 2")
return doc
@whitelist_for_tests
def create_communication_record():
doc = frappe.get_doc(
@ -397,33 +405,6 @@ def insert_translations():
frappe.get_doc(doc).insert(ignore_if_duplicate=True)
@whitelist_for_tests
def create_blog_post():
blog_category = frappe.get_doc(
{"name": "general", "doctype": "Blog Category", "title": "general"}
).insert(ignore_if_duplicate=True)
blogger = frappe.get_doc(
{
"name": "attachment blogger",
"doctype": "Blogger",
"full_name": "attachment blogger",
"short_name": "attachment blogger",
}
).insert(ignore_if_duplicate=True)
return frappe.get_doc(
{
"name": "test-blog-attachment-post",
"doctype": "Blog Post",
"title": "test-blog-attachment-post",
"blog_category": blog_category.name,
"blogger": blogger.name,
"content_type": "Rich Text",
},
).insert(ignore_if_duplicate=True)
@whitelist_for_tests
def create_test_user(username=None):
name = username or UI_TEST_USER

View file

@ -142,6 +142,9 @@ def validate_phone_number(phone_number, throw=False):
if not phone_number:
return False
if not isinstance(phone_number, str):
phone_number = str(phone_number)
phone_number = phone_number.strip()
match = PHONE_NUMBER_PATTERN.match(phone_number)

View file

@ -42,10 +42,9 @@ def get_decrypted_password(doctype, name, fieldname="password", raise_exception=
return None
elif raise_exception:
if raise_exception:
frappe.throw(
_("Password not found for {0} {1} {2}").format(doctype, name, fieldname),
frappe.AuthenticationError,
)

View file

@ -1 +0,0 @@
Blog category.

View file

@ -1,6 +0,0 @@
// Copyright (c) 2016, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Blog Category", {
refresh: function (frm) {},
});

View file

@ -1,94 +0,0 @@
{
"actions": [],
"allow_guest_to_view": 1,
"allow_import": 1,
"allow_rename": 1,
"creation": "2013-03-08 09:41:11",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"published",
"title",
"route",
"preview_image",
"description"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Title",
"no_copy": 1,
"reqd": 1
},
{
"default": "1",
"fieldname": "published",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Published"
},
{
"depends_on": "published",
"fieldname": "route",
"fieldtype": "Data",
"label": "Route",
"read_only": 1,
"unique": 1
},
{
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description"
},
{
"fieldname": "preview_image",
"fieldtype": "Attach Image",
"label": "Preview Image"
}
],
"has_web_view": 1,
"icon": "fa fa-tag",
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [
{
"link_doctype": "Blog Post",
"link_fieldname": "blog_category"
}
],
"make_attachments_public": 1,
"modified": "2024-08-15 19:03:00.345431",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Category",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"print": 1,
"read": 1,
"role": "Blogger"
}
],
"quick_entry": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "title",
"track_changes": 1
}

View file

@ -1,33 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from frappe.website.utils import clear_cache
from frappe.website.website_generator import WebsiteGenerator
class BlogCategory(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.types import DF
description: DF.SmallText | None
preview_image: DF.AttachImage | None
published: DF.Check
route: DF.Data | None
title: DF.Data
# end: auto-generated types
def autoname(self):
# to override autoname of WebsiteGenerator
self.name = self.scrub(self.title)
def on_update(self):
clear_cache()
def set_route(self):
# Override blog route since it has to been templated
self.route = "blog/" + self.name

View file

@ -1,9 +0,0 @@
{% extends "templates/pages/blog.html" %}
{% block title %}{{ title }}{% endblock %}
{% block script %}
<script>
window.category = "{{ docname }}";
</script>
{% endblock %}

View file

@ -1,4 +0,0 @@
<div>
<a href={{ route }}>{{ title }}</a>
</div>
<!-- this is a sample default list template -->

View file

@ -1,8 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.tests import IntegrationTestCase
class TestBlogCategory(IntegrationTestCase):
pass

View file

@ -1,17 +0,0 @@
[
{
"doctype": "Blog Category",
"parent_website_route": "blog",
"title": "_Test Blog Category"
},
{
"doctype": "Blog Category",
"parent_website_route": "blog",
"title": "_Test Blog Category 1"
},
{
"doctype": "Blog Category",
"parent_website_route": "blog",
"title": "_Test Blog Category 2"
}
]

View file

@ -1 +0,0 @@
Blog post for "Blogs" section of website.

View file

@ -1,67 +0,0 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Blog Post", {
refresh: function (frm) {
frappe.db.get_single_value("Blog Settings", "show_cta_in_blog").then((value) => {
frm.set_df_property("hide_cta", "hidden", !value);
});
frm.trigger("add_publish_button");
generate_google_search_preview(frm);
},
title: function (frm) {
generate_google_search_preview(frm);
frm.trigger("set_route");
},
meta_description: function (frm) {
generate_google_search_preview(frm);
},
blog_intro: function (frm) {
generate_google_search_preview(frm);
},
blog_category(frm) {
frm.trigger("set_route");
},
set_route(frm) {
if (frm.doc.route) return;
if (frm.doc.title && frm.doc.blog_category) {
frm.call("make_route").then((r) => {
frm.set_value("route", r.message);
});
}
},
add_publish_button(frm) {
frm.add_custom_button(frm.doc.published ? __("Unpublish") : __("Publish"), () => {
frm.set_value("published", !frm.doc.published);
frm.save();
});
},
});
function generate_google_search_preview(frm) {
if (!(frm.doc.meta_title || frm.doc.title)) return;
let google_preview = frm.get_field("google_preview");
let seo_title = (frm.doc.meta_title || frm.doc.title).slice(0, 60);
let seo_description = (frm.doc.meta_description || frm.doc.blog_intro || "").slice(0, 160);
let date = frm.doc.published_on ? moment(frm.doc.published_on).format("ll") + "-" : "";
let route_array = frm.doc.route ? frm.doc.route.split("/") : [];
route_array.pop();
google_preview.html(`
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=swap" rel="stylesheet">
<div style="font-family: Open Sans; padding: 15px; border: 1px solid #d1d8dd !important; border-radius: 6px;">
<cite style="font-size: 14px; padding-top: 1px; line-height: 1.3; color: #202124; font-style: normal;">
${frappe.boot.sitename}
<span style="color: #5f6368;"> ${route_array.join(" ")}</span>
</cite>
<div style="font-size: 20px; line-height: 1.3; color: #1a0dab; padding-top: 4px; margin-bottom: 3px;">
${seo_title}
</div>
<p style="color: #545454; max-width: 48em; line-height: 1.58; font-size:14px;">
<span style="color: #70757a;">${date}</span> ${seo_description}
</p>
</div>
`);
}

View file

@ -1,253 +0,0 @@
{
"actions": [],
"allow_guest_to_view": 1,
"allow_import": 1,
"creation": "2023-08-03 19:53:03.782490",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"title",
"blog_category",
"blogger",
"route",
"read_time",
"column_break_3",
"published_on",
"published",
"featured",
"hide_cta",
"enable_email_notification",
"disable_comments",
"disable_likes",
"section_break_5",
"blog_intro",
"content_type",
"content",
"content_md",
"content_html",
"email_sent",
"meta_tags",
"meta_title",
"meta_description",
"column_break_18",
"meta_image",
"section_break_20",
"google_preview"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"in_global_search": 1,
"label": "Title",
"no_copy": 1,
"reqd": 1
},
{
"fieldname": "published_on",
"fieldtype": "Date",
"label": "Published On"
},
{
"default": "0",
"fieldname": "published",
"fieldtype": "Check",
"hidden": 1,
"label": "Published"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "blog_category",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Blog Category",
"options": "Blog Category",
"reqd": 1
},
{
"fieldname": "blogger",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Blogger",
"options": "Blogger",
"reqd": 1
},
{
"fieldname": "route",
"fieldtype": "Data",
"label": "Route",
"unique": 1
},
{
"fieldname": "section_break_5",
"fieldtype": "Section Break"
},
{
"description": "Description for listing page, in plain text, only a couple of lines. (max 200 characters)",
"fieldname": "blog_intro",
"fieldtype": "Small Text",
"label": "Blog Intro"
},
{
"default": "Markdown",
"fieldname": "content_type",
"fieldtype": "Select",
"label": "Content Type",
"options": "Markdown\nRich Text\nHTML",
"reqd": 1
},
{
"depends_on": "eval:doc.content_type === 'Rich Text'",
"fieldname": "content",
"fieldtype": "Text Editor",
"ignore_xss_filter": 1,
"in_global_search": 1,
"label": "Content"
},
{
"depends_on": "eval:doc.content_type === 'Markdown'",
"fieldname": "content_md",
"fieldtype": "Markdown Editor",
"ignore_xss_filter": 1,
"label": "Content (Markdown)"
},
{
"depends_on": "eval:doc.content_type === 'HTML'",
"fieldname": "content_html",
"fieldtype": "HTML Editor",
"ignore_xss_filter": 1,
"label": "Content (HTML)"
},
{
"default": "0",
"fieldname": "email_sent",
"fieldtype": "Check",
"hidden": 1,
"label": "Email Sent"
},
{
"default": "0",
"fieldname": "disable_comments",
"fieldtype": "Check",
"label": "Disable Comments"
},
{
"fieldname": "meta_description",
"fieldtype": "Small Text",
"label": "Meta Description"
},
{
"fieldname": "column_break_18",
"fieldtype": "Column Break"
},
{
"fieldname": "meta_image",
"fieldtype": "Attach Image",
"label": "Meta Image",
"mandatory_depends_on": "eval:doc.featured"
},
{
"fieldname": "section_break_20",
"fieldtype": "Section Break"
},
{
"description": "This is an example Google SERP Preview.",
"fieldname": "google_preview",
"fieldtype": "HTML",
"label": "Google Snippet Preview",
"read_only": 1
},
{
"fieldname": "meta_tags",
"fieldtype": "Section Break",
"label": "Meta Tags"
},
{
"description": "in minutes",
"fieldname": "read_time",
"fieldtype": "Int",
"hidden": 1,
"label": "Read Time",
"read_only": 1
},
{
"default": "0",
"fieldname": "featured",
"fieldtype": "Check",
"label": "Featured"
},
{
"default": "0",
"fieldname": "hide_cta",
"fieldtype": "Check",
"hidden": 1,
"label": "Hide CTA"
},
{
"fieldname": "meta_title",
"fieldtype": "Data",
"label": "Meta Title",
"length": 60
},
{
"default": "1",
"description": "Enable email notification for any comment or likes received on your Blog Post.",
"fieldname": "enable_email_notification",
"fieldtype": "Check",
"label": "Enable Email Notification"
},
{
"default": "0",
"fieldname": "disable_likes",
"fieldtype": "Check",
"label": "Disable Likes"
}
],
"has_web_view": 1,
"icon": "fa fa-quote-left",
"idx": 1,
"index_web_pages_for_search": 1,
"is_published_field": "published",
"links": [],
"make_attachments_public": 1,
"modified": "2024-03-23 16:01:29.148911",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Post",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Blogger",
"share": 1,
"write": 1
}
],
"route": "blog",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "title",
"track_changes": 1
}

View file

@ -1,396 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
from math import ceil
import frappe
from frappe import _
from frappe.utils import (
cint,
get_fullname,
global_date_format,
markdown,
sanitize_html,
strip_html_tags,
today,
)
from frappe.website.utils import (
clear_cache,
find_first_image,
get_comment_list,
get_html_content_based_on_type,
)
from frappe.website.website_generator import WebsiteGenerator
class BlogPost(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.types import DF
blog_category: DF.Link
blog_intro: DF.SmallText | None
blogger: DF.Link
content: DF.TextEditor | None
content_html: DF.HTMLEditor | None
content_md: DF.MarkdownEditor | None
content_type: DF.Literal["Markdown", "Rich Text", "HTML"]
disable_comments: DF.Check
disable_likes: DF.Check
email_sent: DF.Check
enable_email_notification: DF.Check
featured: DF.Check
hide_cta: DF.Check
meta_description: DF.SmallText | None
meta_image: DF.AttachImage | None
meta_title: DF.Data | None
published: DF.Check
published_on: DF.Date | None
read_time: DF.Int
route: DF.Data | None
title: DF.Data
# end: auto-generated types
@frappe.whitelist()
def make_route(self):
if not self.route:
return (
frappe.db.get_value("Blog Category", self.blog_category, "route")
+ "/"
+ self.scrub(self.title)
)
def validate(self):
super().validate()
if not self.blog_intro:
content = get_html_content_based_on_type(self, "content", self.content_type)
self.blog_intro = strip_html_tags(content)[:200]
if self.blog_intro:
self.blog_intro = self.blog_intro[:200]
if not self.meta_title:
self.meta_title = self.title[:60]
else:
self.meta_title = self.meta_title[:60]
if not self.meta_description:
self.meta_description = self.blog_intro[:140]
else:
self.meta_description = self.meta_description[:140]
if self.published and not self.published_on:
self.published_on = today()
if self.featured:
if not self.meta_image:
frappe.throw(_("A featured post must have a cover image"))
self.reset_featured_for_other_blogs()
self.set_read_time()
if self.is_website_published():
from frappe.core.doctype.file.utils import extract_images_from_doc
# Extract images first before the standard image extraction to ensure they are public.
extract_images_from_doc(self, "content", is_private=False)
extract_images_from_doc(self, "content_md", is_private=False)
def reset_featured_for_other_blogs(self):
all_posts = frappe.get_all("Blog Post", {"featured": 1})
for post in all_posts:
frappe.db.set_value("Blog Post", post.name, "featured", 0)
def on_update(self):
super().on_update()
clear_cache("writers")
def on_trash(self):
super().on_trash()
def get_context(self, context):
# this is for double precaution. usually it wont reach this code if not published
if not cint(self.published):
raise Exception("This blog has not been published yet!")
context.no_breadcrumbs = True
# temp fields
context.full_name = get_fullname(self.owner)
context.updated = global_date_format(self.published_on)
context.social_links = self.fetch_social_links_info()
context.cta = self.fetch_cta()
context.enable_cta = not self.hide_cta and frappe.db.get_single_value(
"Blog Settings", "show_cta_in_blog", cache=True
)
if self.blogger:
context.blogger_info = frappe.get_doc("Blogger", self.blogger).as_dict()
context.author = self.blogger
context.content = get_html_content_based_on_type(self, "content", self.content_type)
# if meta description is not present, then blog intro or first 140 characters of the blog will be set as description
context.description = (
self.meta_description or self.blog_intro or strip_html_tags(context.content[:140])
)
context.metatags = {
"name": self.meta_title,
"description": context.description,
}
# if meta image is not present, then first image inside the blog will be set as the meta image
image = find_first_image(context.content)
context.metatags["image"] = self.meta_image or image or None
self.load_comments(context)
self.load_likes(context)
context.category = frappe.db.get_value(
"Blog Category", context.doc.blog_category, ["title", "route"], as_dict=1
)
context.parents = [
{"name": _("Home"), "route": "/"},
{"name": "Blog", "route": "/blog"},
{"label": context.category.title, "route": context.category.route},
]
context.guest_allowed = frappe.db.get_single_value("Blog Settings", "allow_guest_to_comment")
def fetch_cta(self):
if frappe.db.get_single_value("Blog Settings", "show_cta_in_blog", cache=True):
blog_settings = frappe.get_cached_doc("Blog Settings")
return {
"show_cta_in_blog": 1,
"title": blog_settings.title,
"subtitle": blog_settings.subtitle,
"cta_label": blog_settings.cta_label,
"cta_url": blog_settings.cta_url,
}
return {}
def fetch_social_links_info(self):
if not frappe.db.get_single_value("Blog Settings", "enable_social_sharing", cache=True):
return []
url = frappe.local.site + "/" + self.route
return [
{
"icon": "twitter",
"link": "https://twitter.com/intent/tweet?text=" + self.title + "&url=" + url,
},
{
"icon": "facebook",
"link": "https://www.facebook.com/sharer.php?u=" + url,
},
{
"icon": "linkedin",
"link": "https://www.linkedin.com/sharing/share-offsite/?url=" + url,
},
{
"icon": "envelope",
"link": "mailto:?subject=" + self.title + "&body=" + url,
},
]
def load_comments(self, context):
context.comment_list = get_comment_list(self.doctype, self.name)
if not context.comment_list:
context.comment_count = 0
else:
context.comment_count = len(context.comment_list)
def load_likes(self, context):
user = frappe.session.user
filters = {
"comment_type": "Like",
"reference_doctype": self.doctype,
"reference_name": self.name,
}
context.like_count = frappe.db.count("Comment", filters)
filters["comment_email"] = user
if user == "Guest":
filters["ip_address"] = frappe.local.request_ip
context.like = frappe.db.count("Comment", filters)
def set_read_time(self):
content = self.content or self.content_html or ""
if self.content_type == "Markdown":
content = markdown(self.content_md)
total_words = len(strip_html_tags(content).split())
self.read_time = ceil(total_words / 250)
def get_list_context(context=None):
list_context = frappe._dict(
get_list=get_blog_list,
no_breadcrumbs=True,
hide_filters=True,
# show_search = True,
title=_("Blog"),
)
blog_settings = frappe.get_doc("Blog Settings").as_dict(no_default_fields=True)
list_context.update(blog_settings)
category_name = frappe.utils.escape_html(
frappe.local.form_dict.blog_category or frappe.local.form_dict.category
)
if category_name:
category = frappe.get_doc("Blog Category", category_name)
list_context.blog_introduction = category.description or _("Posts filed under {0}").format(
category.title
)
list_context.blog_title = category.title
list_context.preview_image = category.preview_image
elif frappe.local.form_dict.blogger:
blogger = frappe.db.get_value("Blogger", {"name": frappe.local.form_dict.blogger}, "full_name")
list_context.sub_title = _("Posts by {0}").format(blogger)
list_context.title = blogger
elif frappe.local.form_dict.txt:
list_context.sub_title = _('Filtered by "{0}"').format(sanitize_html(frappe.local.form_dict.txt))
if list_context.sub_title:
list_context.parents = [{"name": _("Home"), "route": "/"}, {"name": "Blog", "route": "/blog"}]
else:
list_context.parents = [{"name": _("Home"), "route": "/"}]
if blog_settings.browse_by_category:
list_context.blog_categories = get_blog_categories()
list_context.metatags = {
"name": list_context.blog_title,
"title": list_context.blog_title,
"description": list_context.blog_introduction,
"image": list_context.preview_image,
}
return list_context
def get_blog_categories():
from pypika import Order
from pypika.terms import ExistsCriterion
post, category = frappe.qb.DocType("Blog Post"), frappe.qb.DocType("Blog Category")
return (
frappe.qb.from_(category)
.select(category.name, category.route, category.title)
.where(
(category.published == 1)
& ExistsCriterion(
frappe.qb.from_(post)
.select("name")
.where((post.published == 1) & (post.blog_category == category.name))
)
)
.orderby(category.title, order=Order.asc)
.run(as_dict=1)
)
def clear_blog_cache():
for blog in frappe.db.get_list("Blog Post", fields=["route"], pluck="route", filters={"published": True}):
clear_cache(blog)
clear_cache("writers")
def get_blog_list(doctype, txt=None, filters=None, limit_start=0, limit_page_length=20, order_by=None):
conditions = []
if filters and filters.get("blog_category"):
category = filters.get("blog_category")
else:
category = frappe.utils.escape_html(
frappe.local.form_dict.blog_category or frappe.local.form_dict.category
)
if filters and filters.get("blogger"):
conditions.append("t1.blogger={}".format(frappe.db.escape(filters.get("blogger"))))
if category:
conditions.append("t1.blog_category={}".format(frappe.db.escape(category)))
if txt:
conditions.append(
"(t1.content like {0} or t1.title like {0})".format(frappe.db.escape("%" + txt + "%"))
)
if conditions:
frappe.local.no_cache = 1
query = """\
select
t1.title, t1.name, t1.blog_category, t1.route, t1.published_on, t1.read_time,
t1.published_on as creation,
t1.read_time as read_time,
t1.featured as featured,
t1.meta_image as cover_image,
t1.content as content,
t1.content_type as content_type,
t1.content_html as content_html,
t1.content_md as content_md,
ifnull(t1.blog_intro, t1.content) as intro,
t2.full_name, t2.avatar, t1.blogger,
(select count(name) from `tabComment`
where
comment_type='Comment'
and reference_doctype='Blog Post'
and reference_name=t1.name) as comments
from `tabBlog Post` t1, `tabBlogger` t2
where t1.published = 1
and t1.blogger = t2.name
{condition}
order by featured desc, published_on desc, name asc
limit {page_len} OFFSET {start}""".format(
start=limit_start,
page_len=limit_page_length,
condition=(" and " + " and ".join(conditions)) if conditions else "",
)
posts = frappe.db.sql(query, as_dict=1)
for post in posts:
post.content = get_html_content_based_on_type(post, "content", post.content_type)
if not post.cover_image:
post.cover_image = find_first_image(post.content)
post.published = global_date_format(post.creation)
post.content = strip_html_tags(post.content)
if not post.comments:
post.comment_text = _("No comments yet")
elif post.comments == 1:
post.comment_text = _("1 comment")
else:
post.comment_text = _("{0} comments").format(str(post.comments))
post.avatar = post.avatar or ""
post.category = frappe.db.get_value(
"Blog Category", post.blog_category, ["name", "route", "title"], as_dict=True
)
if (
post.avatar
and ("http:" not in post.avatar and "https:" not in post.avatar)
and not post.avatar.startswith("/")
):
post.avatar = "/" + post.avatar
return posts

View file

@ -1,10 +0,0 @@
frappe.listview_settings["Blog Post"] = {
add_fields: ["title", "published", "blogger", "blog_category"],
get_indicator: function (doc) {
if (doc.published) {
return [__("Published"), "green", "published,=,1"];
} else {
return [__("Not Published"), "gray", "published,=,0"];
}
},
};

View file

@ -1,91 +0,0 @@
{% extends "templates/web.html" %}
{% block meta_block %}
{% include "templates/includes/meta_block.html" %}
{% endblock %}
{% block page_content %}
<div class="blog-container">
<article class="blog-content" itemscope itemtype="http://schema.org/BlogPosting">
<!-- begin blog content -->
<div class="blog-header">
<div>
<a class="mr-2" href="/blog">{{ _('Blog') }}</a>
<span class="text-muted">/</span>
<a class="ml-2" href="/{{ category.route }}">{{ category.title }}</a>
</div>
<h1 itemprop="headline" class="blog-title">{{ title }}</h1>
<p class="blog-intro">
{{ blog_intro }}
</p>
<div class="text-muted">
<time datetime="{{ published_on }}">{{ frappe.format_date(published_on) }}</time>
{%- if read_time -%}
&nbsp;&middot;
<span>{{ read_time }} {{ _('min read') }} </span>
{%- endif -%}
</div>
</div>
<hr class="my-5">
<div itemprop="articleBody" class="from-markdown">
{{ content }}
</div>
<!-- end blog content -->
</article>
{%- if enable_cta -%}
{{ web_block(
"Section With Small CTA",
values=cta,
add_container=0,
add_top_padding=0,
add_bottom_padding=0,
css_class="my-5"
) }}
{%- endif -%}
<div class="blog-footer">
<div class="blog-feedback">
{% if not disable_likes %}
{% include 'templates/includes/likes/likes.html' %}
{% endif %}
</div>
{% if social_links %}
<div>
{% for link in social_links %}
<a href="{{ link.link }}" class="text-muted ml-2 fa fa-{{ link.icon }}" target="_blank"></a>
{% endfor %}
</div>
{% endif %}
<div>
{{ _('Published on') }} <time datetime="{{ published_on }}">{{ frappe.format_date(published_on) }}</time>
</div>
</div>
{% if blogger_info %}
<hr class="mt-2 mb-5">
{% include "templates/includes/blog/blogger.html" %}
{% endif %}
{% if not disable_comments %}
<div class="blog-comments">
{% include 'templates/includes/comments/comments.html' %}
</div>
{% endif %}
</div>
<script>
frappe.ready(() => {
frappe.set_search_path("/blog");
// scroll to comment or like section if url contain hash
if (window.location.hash) {
var hash = window.location.hash;
if ($(hash).length) {
$('html, body').animate({
scrollTop: $(hash).offset().top - 100
}, 900, 'swing');
}
}
})
</script>
{% endblock %}

View file

@ -1,91 +0,0 @@
{% extends "templates/web.html" %}
{% block title %}{{ blog_title or _("Blog") }}{% endblock %}
{% block hero %}{% endblock %}
{% block page_content %}
<div class="row py-8">
<div class="col-md-8">
<div class="hero">
<div class="hero-content">
<h1>{{ blog_title or _('Blog') }}</h1>
<p>{{ blog_introduction or '' }}</p>
</div>
</div>
{%- if browse_by_category -%}
<div style="max-width: 20rem">
<label for="category-select" class="sr-only">{{ _("Browse by category") }}</label>
<select id="category-select" class="custom-select" onchange="window.location.pathname = this.value">
<option value="" {{ not frappe.form_dict.category and "selected" or "" }} disabled>
{{ _("Browse by category") }}
</option>
{%- if frappe.form_dict.category -%}
<option value="blog">{{ _("Show all blogs") }}</option>
{%- endif -%}
{%- for category in blog_categories -%}
<option value="{{ category.route }}" {{ frappe.form_dict.category == category.name and "selected" or "" }}>
{{ _(category.title) }}
</option>
{%- endfor -%}
</select>
</div>
{%- endif -%}
</div>
</div>
<div class="blog-list-content">
<div data-doctype="{{ doctype }}" data-txt="{{ (txt or '') | e }}">
{% if not result -%}
<div class="text-muted" style="min-height: 300px;">
{{ no_result_message or _("Nothing to show") }}
</div>
{% else %}
<div id="blog-list" class="blog-list result row">
{% for item in result %}
{{ item }}
{% endfor %}
</div>
{% endif %}
<button class="btn btn-light btn-more btn {% if not show_more -%} hidden {%- endif %}">{{ _("Load More") }}</button>
</div>
</div>
{% endblock %}
{% block script %}
<script>
frappe.ready(() => {
let result_wrapper = $(".blog-list.result");
let next_start = {{ next_start or 0 }};
$(".blog-list-content .btn-more").on("click", function() {
let $btn = $(this);
let args = $.extend(frappe.utils.get_query_params(), {
doctype: "Blog Post",
category: {{ frappe.form_dict.category|tojson or "''"}},
limit_start: next_start,
pathname: location.pathname,
});
$btn.prop("disabled", true);
frappe.call('frappe.www.list.get', args)
.then(r => {
var data = r.message;
next_start = data.next_start;
$.each(data.result, function(i, d) {
$(d).appendTo(result_wrapper);
});
toggle_more(data.show_more);
})
.always(() => {
$btn.prop("disabled", false);
});
});
function toggle_more(show) {
if (!show) {
$(".btn-more").addClass("hide");
}
}
});
</script>
{% endblock %}

View file

@ -1,43 +0,0 @@
{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
{%- set post = doc -%}
<div class="blog-card col-sm-12 {{ 'col-md-8' if post.featured else 'col-md-4' }}">
<div class="card h-100">
<div class="card-img-top">
{% if post.cover_image %}
<img src="{{ post.cover_image }}" alt="{{post.title}} - Cover Image">
{% else %}
<div class="default-cover">
<span>{{ post.title }}</span>
</div>
{% endif %}
</div>
<div class="card-body">
<div>
<div class="text-muted small text-uppercase">
{%- if post.featured -%}
<span class="text-body">{{ _('Featured') }} &middot; </span>
{%- endif -%}
<span>{{ post.category.title }}</span>
</div>
{%- if post.featured -%}
<h5 class="mt-1"><span class="text-dark">{{ post.title }}</span></h5>
{%- else -%}
<h5 class="mt-1"><span class="text-dark">{{ post.title }}</span></h5>
{%- endif -%}
<p class="post-description text-muted">{{ post.intro }}</p>
</div>
<div class="blog-card-footer">
{{ avatar(full_name=post.full_name, image=post.avatar, size='avatar-medium') }}
<div class="text-muted">
<a href="/blog?blogger={{ post.blogger }}">{{ post.full_name }}</a>
<div class="small">
{{ frappe.format_date(post.published_on) }}
{% if post.read_time %} &middot; {{ post.read_time }} {{ _('min read') }} {% endif %}
</div>
</div>
</div>
</div>
<a class="stretched-link" href="/{{ post.route }}"></a>
</div>
</div>

View file

@ -1,191 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import re
from bs4 import BeautifulSoup
import frappe
from frappe.custom.doctype.customize_form.customize_form import reset_customization
from frappe.tests import IntegrationTestCase
from frappe.utils import random_string, set_request
from frappe.website.doctype.blog_post.blog_post import get_blog_list
from frappe.website.serve import get_response
from frappe.website.utils import clear_website_cache
from frappe.website.website_generator import WebsiteGenerator
EXTRA_TEST_RECORD_DEPENDENCIES = ["Blog Post"]
class TestBlogPost(IntegrationTestCase):
def setUp(self):
reset_customization("Blog Post")
def tearDown(self):
if hasattr(frappe.local, "request"):
delattr(frappe.local, "request")
def test_generator_view(self):
pages = frappe.get_all(
"Blog Post", fields=["name", "route"], filters={"published": 1, "route": ("!=", "")}, limit=1
)
set_request(path=pages[0].route)
response = get_response()
self.assertTrue(response.status_code, 200)
html = response.get_data().decode()
self.assertTrue(
'<article class="blog-content" itemscope itemtype="http://schema.org/BlogPosting">' in html
)
def test_generator_not_found(self):
pages = frappe.get_all("Blog Post", fields=["name", "route"], filters={"published": 0}, limit=1)
route = f"test-route-{frappe.generate_hash(length=5)}"
frappe.db.set_value("Blog Post", pages[0].name, "route", route)
set_request(path=route)
response = get_response()
self.assertTrue(response.status_code, 404)
def test_category_link(self):
# Make a temporary Blog Post (and a Blog Category)
blog = make_test_blog("Test Category Link")
# Visit the blog post page
set_request(path=blog.route)
blog_page_response = get_response()
blog_page_html = frappe.safe_decode(blog_page_response.get_data())
# On blog post page find link to the category page
soup = BeautifulSoup(blog_page_html, "html.parser")
category_page_link = next(iter(soup.find_all("a", href=re.compile(blog.blog_category))))
category_page_url = category_page_link["href"]
# Visit the category page (by following the link found in above stage)
set_request(path=category_page_url)
category_page_response = get_response()
category_page_html = frappe.safe_decode(category_page_response.get_data())
# Category page should contain the blog post title
self.assertIn(blog.title, category_page_html)
# Cleanup
frappe.delete_doc("Blog Post", blog.name)
frappe.delete_doc("Blog Category", blog.blog_category)
def test_blog_pagination(self):
# Create some Blog Posts for a Blog Category
category_title, blogs, BLOG_COUNT = "List Category", [], 4
for _ in range(BLOG_COUNT):
blog = make_test_blog(category_title)
blogs.append(blog)
filters = frappe._dict({"blog_category": scrub(category_title)})
# Assert that get_blog_list returns results as expected
self.assertEqual(len(get_blog_list(None, None, filters, 0, 3)), 3)
self.assertEqual(len(get_blog_list(None, None, filters, 0, BLOG_COUNT)), BLOG_COUNT)
self.assertEqual(len(get_blog_list(None, None, filters, 0, 2)), 2)
self.assertEqual(len(get_blog_list(None, None, filters, 2, BLOG_COUNT)), 2)
# Cleanup Blog Post and linked Blog Category
for blog in blogs:
frappe.delete_doc(blog.doctype, blog.name)
frappe.delete_doc("Blog Category", blogs[0].blog_category)
def test_caching(self):
# to enable caching
frappe.flags.force_website_cache = True
print(frappe.session.user)
clear_website_cache()
# first response no-cache
pages = frappe.get_all(
"Blog Post",
fields=["name", "route"],
filters={"published": 1, "title": "_Test Blog Post"},
limit=1,
)
route = pages[0].route
set_request(path=route)
# response = get_response()
response = get_response()
# TODO: enable this assert
# self.assertIn(('X-From-Cache', 'False'), list(response.headers))
set_request(path=route)
response = get_response()
self.assertIn(("X-From-Cache", "True"), list(response.headers))
frappe.flags.force_website_cache = True
def test_spam_comments(self):
# Make a temporary Blog Post (and a Blog Category)
blog = make_test_blog("Test Spam Comment")
# Create a spam comment
frappe.get_doc(
doctype="Comment",
comment_type="Comment",
reference_doctype="Blog Post",
reference_name=blog.name,
comment_email='<a href="https://example.com/spam/">spam</a>',
comment_by='<a href="https://example.com/spam/">spam</a>',
published=1,
content='More spam content. <a href="https://example.com/spam/">spam</a> with link.',
).insert()
# Visit the blog post page
set_request(path=blog.route)
blog_page_response = get_response()
blog_page_html = frappe.safe_decode(blog_page_response.get_data())
self.assertNotIn('<a href="https://example.com/spam/">spam</a>', blog_page_html)
self.assertIn("More spam content. spam with link.", blog_page_html)
# Cleanup
frappe.delete_doc("Blog Post", blog.name)
frappe.delete_doc("Blog Category", blog.blog_category)
def test_like_dislike(self):
test_blog = make_test_blog()
frappe.db.delete("Comment", {"comment_type": "Like", "reference_doctype": "Blog Post"})
from frappe.templates.includes.likes.likes import like
liked = like("Blog Post", test_blog.name, True)
self.assertEqual(liked, True)
disliked = like("Blog Post", test_blog.name, False)
self.assertEqual(disliked, False)
frappe.db.delete("Comment", {"comment_type": "Like", "reference_doctype": "Blog Post"})
test_blog.delete()
def scrub(text):
return WebsiteGenerator.scrub(None, text)
def make_test_blog(category_title="Test Blog Category"):
category_name = scrub(category_title)
if not frappe.db.exists("Blog Category", category_name):
frappe.get_doc(doctype="Blog Category", title=category_title).insert()
if not frappe.db.exists("Blogger", "test-blogger"):
frappe.get_doc(doctype="Blogger", short_name="test-blogger", full_name="Test Blogger").insert()
return frappe.get_doc(
doctype="Blog Post",
blog_category=category_name,
blogger="test-blogger",
title=random_string(20),
route=random_string(20),
content=random_string(20),
published=1,
).insert()

View file

@ -1,38 +0,0 @@
[
{
"blog_category": "-test-blog-category",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger",
"content": "Test Blog Content",
"doctype": "Blog Post",
"title": "_Test Blog Post",
"published": 1
},
{
"blog_category": "-test-blog-category-1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger",
"content": "Test Blog Content",
"doctype": "Blog Post",
"title": "_Test Blog Post 1",
"published": 1
},
{
"blog_category": "-test-blog-category-1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger 1",
"content": "Test Blog Content",
"doctype": "Blog Post",
"title": "_Test Blog Post 2",
"published": 0
},
{
"blog_category": "-test-blog-category-1",
"blog_intro": "Test Blog Intro",
"blogger": "_Test Blogger 2",
"content": "Test Blog Content",
"doctype": "Blog Post",
"title": "_Test Blog Post 3",
"published": 0
}
]

View file

@ -1 +0,0 @@
Blog titles and introduction texts.

View file

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

View file

@ -1,161 +0,0 @@
{
"actions": [],
"creation": "2013-03-11 17:48:16",
"description": "Settings to control blog categories and interactions like comments and likes",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"blog_title",
"blog_introduction",
"preview_image",
"column_break",
"enable_social_sharing",
"allow_guest_to_comment",
"browse_by_category",
"show_cta_in_blog",
"cta_section",
"title",
"subtitle",
"column_break_11",
"cta_label",
"cta_url",
"section_break_12",
"like_limit",
"column_break_14",
"comment_limit"
],
"fields": [
{
"fieldname": "blog_title",
"fieldtype": "Data",
"label": "Blog Title"
},
{
"fieldname": "blog_introduction",
"fieldtype": "Small Text",
"label": "Blog Introduction"
},
{
"default": "0",
"fieldname": "enable_social_sharing",
"fieldtype": "Check",
"label": "Enable Social Sharing"
},
{
"collapsible": 1,
"fieldname": "column_break",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "show_cta_in_blog",
"fieldtype": "Check",
"label": "Show \"Call to Action\" in Blog"
},
{
"depends_on": "eval:doc.show_cta_in_blog",
"fieldname": "cta_section",
"fieldtype": "Section Break",
"label": "Call to Action"
},
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"mandatory_depends_on": "eval:doc.show_cta_in_blog"
},
{
"fieldname": "subtitle",
"fieldtype": "Data",
"label": "Subtitle",
"mandatory_depends_on": "eval:doc.show_cta_in_blog"
},
{
"fieldname": "cta_label",
"fieldtype": "Data",
"label": "CTA Label",
"mandatory_depends_on": "eval:doc.show_cta_in_blog"
},
{
"fieldname": "cta_url",
"fieldtype": "Data",
"label": "CTA URL",
"mandatory_depends_on": "eval:doc.show_cta_in_blog"
},
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_12",
"fieldtype": "Section Break",
"label": "Rate Limits"
},
{
"default": "5",
"description": "Comment limit per hour",
"fieldname": "comment_limit",
"fieldtype": "Int",
"label": "Comment limit"
},
{
"fieldname": "column_break_14",
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "allow_guest_to_comment",
"fieldtype": "Check",
"label": "Allow Guest to comment"
},
{
"default": "0",
"fieldname": "browse_by_category",
"fieldtype": "Check",
"label": "Browse by category"
},
{
"default": "5",
"description": "Like limit per hour",
"fieldname": "like_limit",
"fieldtype": "Int",
"label": "Like limit"
},
{
"fieldname": "preview_image",
"fieldtype": "Attach Image",
"label": "Preview Image"
}
],
"icon": "fa fa-cog",
"idx": 1,
"issingle": 1,
"links": [],
"modified": "2024-03-23 16:01:29.318488",
"modified_by": "Administrator",
"module": "Website",
"name": "Blog Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"print": 1,
"read": 1,
"role": "Blogger",
"share": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View file

@ -1,46 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
# License: MIT. See LICENSE
import frappe
from frappe.model.document import Document
class BlogSettings(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
allow_guest_to_comment: DF.Check
blog_introduction: DF.SmallText | None
blog_title: DF.Data | None
browse_by_category: DF.Check
comment_limit: DF.Int
cta_label: DF.Data | None
cta_url: DF.Data | None
enable_social_sharing: DF.Check
like_limit: DF.Int
preview_image: DF.AttachImage | None
show_cta_in_blog: DF.Check
subtitle: DF.Data | None
title: DF.Data | None
# end: auto-generated types
def on_update(self):
from frappe.website.utils import clear_cache
clear_cache("blog")
clear_cache("writers")
def get_like_limit():
return frappe.get_single_value("Blog Settings", "like_limit") or 5
def get_comment_limit():
return frappe.get_single_value("Blog Settings", "comment_limit") or 5

View file

@ -1,8 +0,0 @@
# Copyright (c) 2020, Frappe Technologies and Contributors
# License: MIT. See LICENSE
# import frappe
from frappe.tests import IntegrationTestCase
class TestBlogSettings(IntegrationTestCase):
pass

View file

@ -1 +0,0 @@
User of blog writer in "Blog" section.

View file

@ -1,6 +0,0 @@
// Copyright (c) 2016, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Blogger", {
refresh: function (frm) {},
});

View file

@ -1,102 +0,0 @@
{
"actions": [],
"allow_import": 1,
"autoname": "field:short_name",
"creation": "2013-03-25 16:00:51",
"description": "User ID of a Blogger",
"doctype": "DocType",
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"disabled",
"short_name",
"full_name",
"user",
"bio",
"avatar"
],
"fields": [
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"description": "Will be used in url (usually first name).",
"fieldname": "short_name",
"fieldtype": "Data",
"label": "Short Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "full_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Full Name",
"reqd": 1
},
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User"
},
{
"fieldname": "bio",
"fieldtype": "Small Text",
"label": "Bio"
},
{
"fieldname": "avatar",
"fieldtype": "Attach Image",
"label": "Avatar"
}
],
"icon": "fa fa-user",
"idx": 1,
"image_field": "avatar",
"links": [
{
"link_doctype": "Blog Post",
"link_fieldname": "blogger"
}
],
"make_attachments_public": 1,
"max_attachments": 1,
"modified": "2024-03-23 16:01:29.432477",
"modified_by": "Administrator",
"module": "Website",
"name": "Blogger",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"import": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"print": 1,
"read": 1,
"role": "Blogger",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "full_name",
"track_changes": 1
}

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