Merge branch 'develop' into add-action-confirm-on-workflow
This commit is contained in:
commit
1b962c8569
129 changed files with 4582 additions and 2550 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ context("Sidebar", () => {
|
|||
.window()
|
||||
.its("frappe")
|
||||
.then((frappe) => {
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_blog_post");
|
||||
return frappe.call("frappe.tests.ui_test_helpers.create_doctype_for_attachment");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ context("Sidebar", () => {
|
|||
}).then((todo) => {
|
||||
verify_attachment_visibility(`todo/${todo.message.name}`, true);
|
||||
});
|
||||
verify_attachment_visibility("blog-post/test-blog-attachment-post", false);
|
||||
verify_attachment_visibility("test-blog-category/_Test Blog Category 2", false);
|
||||
});
|
||||
|
||||
it("Verify attachment accessibility UX", () => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ context("Table MultiSelect", () => {
|
|||
it("select value from multiselect dropdown", () => {
|
||||
cy.new_form("Assignment Rule");
|
||||
cy.fill_field("__newname", name);
|
||||
cy.fill_field("document_type", "Blog Post");
|
||||
cy.fill_field("document_type", "Web Page");
|
||||
cy.get(".section-head").contains("Assignment Rules").scrollIntoView();
|
||||
cy.fill_field("assign_condition", 'status=="Open"', "Code");
|
||||
cy.get('input[data-fieldname="users"]').focus().as("input");
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
126
frappe/core/api/user_invitation.py
Normal file
126
frappe/core/api/user_invitation.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.core.doctype.user_invitation.user_invitation import UserInvitation
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def invite_by_email(
|
||||
emails: str, roles: list[str], redirect_to_path: str, app_name: str = "frappe"
|
||||
) -> dict[str, list[str]]:
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
# validate emails
|
||||
frappe.utils.validate_email_address(emails, throw=True)
|
||||
email_list = frappe.utils.split_emails(emails)
|
||||
if not email_list:
|
||||
frappe.throw(title=_("Invalid input"), msg=_("No email addresses to invite"))
|
||||
|
||||
# get relevant data from the database
|
||||
accepted_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"email": ["in", email_list], "status": "Accepted", "app_name": app_name},
|
||||
pluck="email",
|
||||
)
|
||||
pending_invite_emails = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"email": ["in", email_list], "status": "Pending", "app_name": app_name},
|
||||
pluck="email",
|
||||
)
|
||||
|
||||
# create invitation documents
|
||||
to_invite = list(set(email_list) - set(accepted_invite_emails) - set(pending_invite_emails))
|
||||
for email in to_invite:
|
||||
frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=email,
|
||||
roles=[dict(role=role) for role in roles],
|
||||
app_name=app_name,
|
||||
redirect_to_path=redirect_to_path,
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
return {
|
||||
"accepted_invite_emails": accepted_invite_emails,
|
||||
"pending_invite_emails": pending_invite_emails,
|
||||
"invited_emails": to_invite,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True, methods=["GET"])
|
||||
def accept_invitation(key: str) -> None:
|
||||
_accept_invitation(key, False)
|
||||
|
||||
|
||||
# `app_name` is required for security
|
||||
@frappe.whitelist(methods=["PATCH", "POST"])
|
||||
def cancel_invitation(name: str, app_name: str):
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
if not frappe.db.exists("User Invitation", name):
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
|
||||
|
||||
invitation = frappe.get_doc("User Invitation", name)
|
||||
if invitation.app_name != app_name:
|
||||
# message is not specific enough for security
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation not found"))
|
||||
|
||||
if invitation.status == "Cancelled":
|
||||
return {"cancelled_now": False}
|
||||
|
||||
if invitation.status != "Pending":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation cannot be cancelled"))
|
||||
|
||||
invitation.flags.ignore_permissions = True
|
||||
return {"cancelled_now": invitation.cancel_invite()}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["GET"])
|
||||
def get_pending_invitations(app_name: str):
|
||||
UserInvitation.validate_role(app_name)
|
||||
|
||||
pending_invitations = frappe.db.get_all(
|
||||
"User Invitation", fields=["name", "email"], filters={"status": "Pending", "app_name": app_name}
|
||||
)
|
||||
res = []
|
||||
for pending_invitation in pending_invitations:
|
||||
roles = frappe.db.get_all("User Role", fields=["role"], filters={"parent": pending_invitation.name})
|
||||
res.append(
|
||||
{
|
||||
"name": pending_invitation.name,
|
||||
"email": pending_invitation.email,
|
||||
"roles": [r.role for r in roles],
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
def _accept_invitation(key: str, in_test: bool) -> None:
|
||||
# get invitation
|
||||
hashed_key = frappe.utils.sha256_hash(key)
|
||||
invitation_name = frappe.db.get_value("User Invitation", filters={"key": hashed_key})
|
||||
if not invitation_name:
|
||||
frappe.throw(title=_("Error"), msg=_("Invalid key"))
|
||||
invitation = frappe.get_doc("User Invitation", invitation_name)
|
||||
|
||||
# accept invitation
|
||||
invitation.accept(ignore_permissions=True)
|
||||
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
should_update_password = not user.last_password_reset_date and not bool(
|
||||
frappe.get_system_settings("disable_user_pass_login")
|
||||
)
|
||||
|
||||
# set redirect_to
|
||||
redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path())
|
||||
if should_update_password:
|
||||
redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}"
|
||||
|
||||
# GET requests do not cause an implicit commit
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
if not in_test and not should_update_password:
|
||||
frappe.local.login_manager.login_as(invitation.email)
|
||||
|
||||
# set response
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = redirect_to
|
||||
|
|
@ -5,11 +5,16 @@ import json
|
|||
import frappe
|
||||
from frappe.templates.includes.comments.comments import add_comment
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.tests.test_helpers import setup_for_tests
|
||||
from frappe.tests.test_model_utils import set_user
|
||||
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["Web Page"]
|
||||
|
||||
|
||||
class TestComment(IntegrationTestCase):
|
||||
def setUp(self):
|
||||
setup_for_tests()
|
||||
|
||||
def test_comment_creation(self):
|
||||
test_doc = frappe.get_doc(doctype="ToDo", description="test")
|
||||
test_doc.insert()
|
||||
|
|
@ -42,16 +47,16 @@ class TestComment(IntegrationTestCase):
|
|||
|
||||
# test via blog
|
||||
def test_public_comment(self):
|
||||
test_blog = make_test_blog()
|
||||
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
|
||||
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
add_comment_args = {
|
||||
"comment": "Good comment with 10 chars",
|
||||
"comment_email": "test@test.com",
|
||||
"comment_by": "Good Tester",
|
||||
"reference_doctype": test_blog.doctype,
|
||||
"reference_name": test_blog.name,
|
||||
"route": test_blog.route,
|
||||
"route": f"blog/{test_blog.doctype}/{test_blog.name}",
|
||||
}
|
||||
add_comment(**add_comment_args)
|
||||
|
||||
|
|
@ -64,7 +69,7 @@ class TestComment(IntegrationTestCase):
|
|||
1,
|
||||
)
|
||||
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
|
||||
add_comment_args.update(comment="pleez vizits my site http://mysite.com", comment_by="bad commentor")
|
||||
add_comment(**add_comment_args)
|
||||
|
|
@ -81,7 +86,7 @@ class TestComment(IntegrationTestCase):
|
|||
)
|
||||
|
||||
# test for filtering html and css injection elements
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
|
||||
frappe.db.delete("Comment", {"reference_doctype": "Test Blog Post"})
|
||||
|
||||
add_comment_args.update(comment="<script>alert(1)</script>Comment", comment_by="hacker")
|
||||
add_comment(**add_comment_args)
|
||||
|
|
@ -96,26 +101,10 @@ class TestComment(IntegrationTestCase):
|
|||
|
||||
test_blog.delete()
|
||||
|
||||
@IntegrationTestCase.change_settings("Blog Settings", {"allow_guest_to_comment": 0})
|
||||
def test_guest_cannot_comment(self):
|
||||
test_blog = make_test_blog()
|
||||
with set_user("Guest"):
|
||||
self.assertEqual(
|
||||
add_comment(
|
||||
comment="Good comment with 10 chars",
|
||||
comment_email="mail@example.org",
|
||||
comment_by="Good Tester",
|
||||
reference_doctype="Blog Post",
|
||||
reference_name=test_blog.name,
|
||||
route=test_blog.route,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def test_user_not_logged_in(self):
|
||||
some_system_user = frappe.db.get_value("User", {"name": ("not in", frappe.STANDARD_USERS)})
|
||||
|
||||
test_blog = make_test_blog()
|
||||
test_blog = frappe.get_doc("Web Page", "test-web-page-1")
|
||||
with set_user("Guest"):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
|
|
@ -123,7 +112,7 @@ class TestComment(IntegrationTestCase):
|
|||
comment="Good comment with 10 chars",
|
||||
comment_email=some_system_user,
|
||||
comment_by="Good Tester",
|
||||
reference_doctype="Blog Post",
|
||||
reference_doctype="Web Page",
|
||||
reference_name=test_blog.name,
|
||||
route=test_blog.route,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
"email_append_to",
|
||||
"sender_field",
|
||||
"sender_name_field",
|
||||
"recipient_account_field",
|
||||
"subject_field",
|
||||
"fields_tab",
|
||||
"fields_section",
|
||||
|
|
@ -707,6 +708,12 @@
|
|||
"fieldtype": "Int",
|
||||
"label": "Rows Threshold for Grid Search",
|
||||
"non_negative": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "email_append_to",
|
||||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
|
|
@ -785,7 +792,7 @@
|
|||
"link_fieldname": "document_type"
|
||||
}
|
||||
],
|
||||
"modified": "2025-06-24 07:46:34.380662",
|
||||
"modified": "2025-07-19 12:23:16.296416",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "DocType",
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ class DocType(Document):
|
|||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
read_only: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
restrict_to_domain: DF.Link | None
|
||||
route: DF.Data | None
|
||||
row_format: DF.Literal["Dynamic", "Compressed"]
|
||||
|
|
|
|||
|
|
@ -129,6 +129,21 @@ select name from \`tabPerson\`
|
|||
where tenant_id = 2
|
||||
order by creation desc
|
||||
</code></pre>
|
||||
|
||||
<hr>
|
||||
|
||||
<h4>Workflow Task</h4>
|
||||
<p>Execute when a particular <a href="/app/workflow-action-master">Workflow Action Master</a> is executed.</p>
|
||||
<p>Gets the document which the action is being applied on in the <code>doc</code> variable.</p>
|
||||
<code><pre>
|
||||
# create a customer with the same name as the given document
|
||||
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = doc.first_name + " " + doc.last_name # we get this from the workflow action
|
||||
customer.customer_type = "Company"
|
||||
|
||||
c.save()
|
||||
</code></pre>
|
||||
`);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Script Type",
|
||||
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI",
|
||||
"options": "DocType Event\nScheduler Event\nPermission Query\nAPI\nWorkflow Task",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
"link_fieldname": "server_script"
|
||||
}
|
||||
],
|
||||
"modified": "2024-05-08 03:21:54.169380",
|
||||
"modified": "2025-07-03 16:12:29.676150",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Server Script",
|
||||
|
|
@ -171,6 +171,7 @@
|
|||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ class ServerScript(Document):
|
|||
rate_limit_seconds: DF.Int
|
||||
reference_doctype: DF.Link | None
|
||||
script: DF.Code
|
||||
script_type: DF.Literal["DocType Event", "Scheduler Event", "Permission Query", "API"]
|
||||
script_type: DF.Literal[
|
||||
"DocType Event", "Scheduler Event", "Permission Query", "API", "Workflow Task"
|
||||
]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
|
|
@ -216,6 +218,19 @@ class ServerScript(Document):
|
|||
if locals["conditions"]:
|
||||
return locals["conditions"]
|
||||
|
||||
def execute_workflow_task(self, doc: Document):
|
||||
"""
|
||||
Specific to Workflow Tasks via Workflow Action Master
|
||||
"""
|
||||
if self.script_type != "Workflow Task":
|
||||
raise frappe.DoesNotExistError
|
||||
|
||||
safe_exec(
|
||||
self.script,
|
||||
_locals={"doc": doc},
|
||||
script_filename=self.name,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@http_cache(max_age=10 * 60, stale_while_revalidate=6 * 60 * 60)
|
||||
|
|
|
|||
|
|
@ -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 |
106
frappe/core/doctype/user_invitation/internal_doc/index.md
Normal file
106
frappe/core/doctype/user_invitation/internal_doc/index.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# User Invitation
|
||||
|
||||
## Index
|
||||
|
||||
- [Motivation](#motivation)
|
||||
- [How to use it?](#how-to-use-it)
|
||||
- [Whitelisted functions](#whitelisted-functions)
|
||||
- [`invite_by_email`](#invite_by_email)
|
||||
- [`accept_invitation`](#accept_invitation)
|
||||
- [`get_pending_invitations`](#get_pending_invitations)
|
||||
- [`cancel_invitation`](#cancel_invitation)
|
||||
- [Normal flow](#normal-flow)
|
||||
- [Important points](#important-points)
|
||||
|
||||
## Motivation
|
||||
|
||||
- Until now, there was no way to invite and create a new user based on a sent invitation that can be accepted or rejected by the invitee.
|
||||
- Due to this, custom Framework applications have to implement a user invitation flow. But most of the rules around this flow are generic enough to let Framework store all of the common logic associated with a typical user invitation flow.
|
||||
- This will help ensure consistency and prevent code duplication for custom Framework applications that need this type of feature.
|
||||
|
||||
## How to use it?
|
||||
|
||||
Define user invitation hooks in your app's `hooks.py` file. An example is shown below.
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
## Normal flow
|
||||
|
||||
1. Invitations are created from the desk or by using the [`invite_by_email`](#invite_by_email) whitelisted function. An email is sent to the invited email with a link to accept the invitation.
|
||||
2. The app administrator or anyone able to use the desk can cancel invitations. Once an invitation is cancelled, an email is sent to the creator of the invitation.
|
||||
3. Once the invitation is accepted, a new user is created (if required) with the roles specified in the invitation and is redirected to the specified path.
|
||||
4. If the invitee doesn't accept the invitation within three days, the invitation is marked as expired by a background job that executes every day. Currently, there is no way to customize the expiration time.
|
||||
|
||||
## Important points:
|
||||
|
||||
- There can't be multiple pending invitations for the same app.
|
||||
- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`.
|
||||
- To manually mark an invitation as expired, you can use the `expire` method on the invitation document.
|
||||
- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
254
frappe/core/doctype/user_invitation/test_user_invitation.py
Normal file
254
frappe/core/doctype/user_invitation/test_user_invitation.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
import re
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe.core.api.user_invitation import (
|
||||
_accept_invitation,
|
||||
cancel_invitation,
|
||||
get_pending_invitations,
|
||||
invite_by_email,
|
||||
)
|
||||
from frappe.core.doctype.user_invitation.user_invitation import mark_expired_invitations
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
emails = [
|
||||
"test_user_invite1@example.com",
|
||||
"test_user_invite2@example.com",
|
||||
"test_user_invite3@example.com",
|
||||
"test_user_invite4@example.com",
|
||||
"test_user_invite5@example.com",
|
||||
]
|
||||
|
||||
|
||||
class IntegrationTestUserInvitation(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for UserInvitation.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
user = frappe.new_doc("User")
|
||||
user.first_name = "Test"
|
||||
user.last_name = "123"
|
||||
user.email = emails[0]
|
||||
user.append_roles("System Manager")
|
||||
user.insert()
|
||||
frappe.set_user(emails[0])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
super().tearDownClass()
|
||||
IntegrationTestUserInvitation.delete_all_invitations()
|
||||
IntegrationTestUserInvitation.delete_all_user_roles()
|
||||
frappe.db.delete("Email Queue")
|
||||
for user_email in emails:
|
||||
if frappe.db.exists("User", user_email):
|
||||
frappe.delete_doc("User", user_email)
|
||||
frappe.set_user("Administrator")
|
||||
# some of the code under test commit internally
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
@classmethod
|
||||
def delete_all_user_roles(cls):
|
||||
frappe.db.sql("DELETE FROM `tabUser Role`")
|
||||
|
||||
@classmethod
|
||||
def delete_all_invitations(cls):
|
||||
frappe.db.sql("DELETE FROM `tabUser Invitation`")
|
||||
|
||||
@classmethod
|
||||
def delete_invitation(cls, name: str):
|
||||
frappe.db.sql(f'DELETE FROM `tabUser Invitation` WHERE name = "{name}"')
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
IntegrationTestUserInvitation.delete_all_invitations()
|
||||
IntegrationTestUserInvitation.delete_all_user_roles()
|
||||
frappe.db.delete("Email Queue")
|
||||
|
||||
def test_insert_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
self.assertEqual(len(self.get_email_names()), 0)
|
||||
invitation.insert()
|
||||
self.assertEqual(invitation.invited_by, frappe.session.user)
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
self.assertIsInstance(invitation.email_sent_at, str)
|
||||
self.assertIsInstance(invitation.key, str)
|
||||
self.assertIsInstance(invitation.roles, list)
|
||||
sent_emails = self.get_email_messages()
|
||||
self.assertEqual(len(sent_emails), 1)
|
||||
self.assertIn("invited", sent_emails[0].message.lower())
|
||||
|
||||
def test_update_invitation_status_to_expired(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names()), 1)
|
||||
invitation.expire()
|
||||
emails = self.get_email_messages(False)
|
||||
self.assertEqual(len(emails), 2)
|
||||
self.assertIn("expired", emails[0].message.lower())
|
||||
|
||||
def test_cancel_pending_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
invitation.cancel_invite()
|
||||
sent_emails = self.get_email_messages(False)
|
||||
self.assertEqual(len(sent_emails), 2)
|
||||
self.assertIn("cancelled", sent_emails[0].message.lower())
|
||||
|
||||
def test_cancel_accepted_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
invitation.status = "Accepted"
|
||||
invitation.save()
|
||||
invitation.cancel_invite()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
|
||||
def test_cancel_expired_invitation(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
invitation.expire()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
invitation.cancel_invite()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
|
||||
def test_mark_expired_invitations(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
# the status of invitations older than 3 days should be set to expired
|
||||
invitation.db_set("creation", frappe.utils.add_days(frappe.utils.now(), -4))
|
||||
mark_expired_invitations()
|
||||
invitation.reload()
|
||||
self.assertEqual(invitation.status, "Expired")
|
||||
|
||||
def test_invite_by_email_api(self):
|
||||
accepted_invite_email = emails[1]
|
||||
invitation = frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=accepted_invite_email,
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
invitation.status = "Accepted"
|
||||
invitation.save()
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
pending_invite_email = emails[2]
|
||||
frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=pending_invite_email,
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
email_to_invite = emails[3]
|
||||
res = invite_by_email(
|
||||
emails=", ".join([accepted_invite_email, pending_invite_email, email_to_invite]),
|
||||
roles=["System Manager"],
|
||||
redirect_to_path="/xyz",
|
||||
)
|
||||
self.assertSequenceEqual(res["accepted_invite_emails"], [accepted_invite_email])
|
||||
self.assertSequenceEqual(res["pending_invite_emails"], [pending_invite_email])
|
||||
self.assertSequenceEqual(res["invited_emails"], [email_to_invite])
|
||||
self.assertEqual(len(self.get_email_names(False)), 3)
|
||||
|
||||
def test_accept_invitation_api_pass_redirect(self):
|
||||
invitation = frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=emails[1],
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0)
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
key = invitation._after_insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
_accept_invitation(key, True)
|
||||
res = frappe.local.response
|
||||
self.assertEqual(res.type, "redirect")
|
||||
pattern = f"^{re.escape(frappe.utils.get_url(''))}/update-password\\?key=.+&redirect_to=/abc$"
|
||||
self.assertRegex(res.location, pattern)
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
IntegrationTestUserInvitation.delete_invitation(invitation.name)
|
||||
frappe.delete_doc("User", user.name)
|
||||
|
||||
def test_accept_invitation_api_direct_redirect(self):
|
||||
invitation = frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=emails[1],
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
).insert()
|
||||
self.assertEqual(len(frappe.get_all("User", filters={"email": invitation.email}, pluck="name")), 0)
|
||||
original_disable_user_pass_login = frappe.get_system_settings("disable_user_pass_login")
|
||||
frappe.db.set_single_value("System Settings", "disable_user_pass_login", 1)
|
||||
self.assertEqual(len(self.get_email_names(False)), 1)
|
||||
key = invitation._after_insert()
|
||||
self.assertEqual(len(self.get_email_names(False)), 2)
|
||||
_accept_invitation(key, True)
|
||||
frappe.db.set_single_value(
|
||||
"System Settings", "disable_user_pass_login", original_disable_user_pass_login
|
||||
)
|
||||
res = frappe.local.response
|
||||
self.assertEqual(res.type, "redirect")
|
||||
pattern = f"^{re.escape(frappe.utils.get_url(''))}/abc$"
|
||||
self.assertRegex(res.location, pattern)
|
||||
user = frappe.get_doc("User", invitation.email)
|
||||
IntegrationTestUserInvitation.delete_invitation(invitation.name)
|
||||
frappe.delete_doc("User", user.name)
|
||||
|
||||
def test_get_pending_invitations_api(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
invitation.reload()
|
||||
pending_invitations = get_pending_invitations("frappe")
|
||||
self.assertEqual(len(pending_invitations), 1)
|
||||
pending_invitation = pending_invitations[0]
|
||||
self.assertEqual(pending_invitation["name"], invitation.name)
|
||||
self.assertEqual(pending_invitation["email"], invitation.email)
|
||||
roles = pending_invitation["roles"]
|
||||
self.assertIsInstance(roles, list)
|
||||
self.assertSequenceEqual(roles, [r.role for r in invitation.roles])
|
||||
|
||||
def test_cancel_invitation_api(self):
|
||||
invitation = self.get_dummy_invitation()
|
||||
invitation.insert()
|
||||
invitation.reload()
|
||||
self.assertEqual(invitation.status, "Pending")
|
||||
self.assertEqual(len(self.get_email_names()), 1)
|
||||
res = cancel_invitation(invitation.name, "frappe")
|
||||
self.assertTrue(res["cancelled_now"])
|
||||
invitation.reload()
|
||||
self.assertEqual(invitation.status, "Cancelled")
|
||||
self.assertEqual(len(self.get_email_names()), 2)
|
||||
res = cancel_invitation(invitation.name, "frappe")
|
||||
self.assertFalse(res["cancelled_now"])
|
||||
self.assertEqual(len(self.get_email_names()), 2)
|
||||
|
||||
def get_dummy_invitation(self):
|
||||
return frappe.get_doc(
|
||||
doctype="User Invitation",
|
||||
email=emails[1],
|
||||
roles=[dict(role="System Manager")],
|
||||
redirect_to_path="/abc",
|
||||
app_name="frappe",
|
||||
)
|
||||
|
||||
def get_email_names(self, sent_only=True):
|
||||
filters = {"status": "Sent"} if sent_only else None
|
||||
return frappe.db.get_all("Email Queue", filters=filters, fields=["name"])
|
||||
|
||||
def get_email_messages(self, sent_only=True):
|
||||
filters = {"status": "Sent"} if sent_only else None
|
||||
return frappe.db.get_all("Email Queue", filters=filters, fields=["message"])
|
||||
23
frappe/core/doctype/user_invitation/user_invitation.js
Normal file
23
frappe/core/doctype/user_invitation/user_invitation.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) 2025, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("User Invitation", {
|
||||
refresh(frm) {
|
||||
frappe.xcall("frappe.apps.get_apps").then((r) => {
|
||||
const apps = r?.map((r) => r.name) ?? [];
|
||||
const default_app = "frappe";
|
||||
frm.set_df_property("app_name", "options", [default_app, ...apps]);
|
||||
if (!frm.doc.app_name) {
|
||||
frm.set_value("app_name", default_app);
|
||||
}
|
||||
});
|
||||
if (frm.doc.__islocal || frm.doc.status !== "Pending") {
|
||||
return;
|
||||
}
|
||||
frm.add_custom_button(__("Cancel"), () => {
|
||||
frappe.confirm(__("Are you sure you want to cancel the invitation?"), () =>
|
||||
frm.call("cancel_invite")
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
143
frappe/core/doctype/user_invitation/user_invitation.json
Normal file
143
frappe/core/doctype/user_invitation/user_invitation.json
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-07-07 14:19:31.014655",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"email",
|
||||
"app_name",
|
||||
"redirect_to_path",
|
||||
"roles",
|
||||
"status",
|
||||
"invited_by",
|
||||
"key",
|
||||
"user",
|
||||
"email_sent_at",
|
||||
"accepted_at"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Email",
|
||||
"reqd": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "invited_by",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Invited By",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Pending",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "Pending\nAccepted\nExpired\nCancelled",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "email_sent_at",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Email Sent At",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "accepted_at",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Accepted At",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "User",
|
||||
"options": "User",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "app_name",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "App Name",
|
||||
"reqd": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "redirect_to_path",
|
||||
"fieldtype": "Data",
|
||||
"label": "Redirect To Path",
|
||||
"read_only_depends_on": "eval:doc.status!==\"Pending\"",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "key",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Key",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "roles",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Roles",
|
||||
"options": "User Role",
|
||||
"read_only_depends_on": "eval:Boolean(doc.creation)",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-26 11:52:46.984800",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "User Invitation",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [
|
||||
{
|
||||
"color": "Green",
|
||||
"title": "Accepted"
|
||||
},
|
||||
{
|
||||
"color": "Orange",
|
||||
"title": "Pending"
|
||||
},
|
||||
{
|
||||
"color": "Yellow",
|
||||
"title": "Expired"
|
||||
},
|
||||
{
|
||||
"color": "Red",
|
||||
"title": "Cancelled"
|
||||
}
|
||||
]
|
||||
}
|
||||
241
frappe/core/doctype/user_invitation/user_invitation.py
Normal file
241
frappe/core/doctype/user_invitation/user_invitation.py
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# Copyright (c) 2025, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.permissions import get_roles
|
||||
|
||||
|
||||
class UserInvitation(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.core.doctype.user_role.user_role import UserRole
|
||||
from frappe.types import DF
|
||||
|
||||
accepted_at: DF.Datetime | None
|
||||
app_name: DF.Literal[None]
|
||||
email: DF.Data
|
||||
email_sent_at: DF.Datetime | None
|
||||
invited_by: DF.Link | None
|
||||
key: DF.Data | None
|
||||
redirect_to_path: DF.Data
|
||||
roles: DF.TableMultiSelect[UserRole]
|
||||
status: DF.Literal["Pending", "Accepted", "Expired", "Cancelled"]
|
||||
user: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
def before_insert(self):
|
||||
self._validate_invite()
|
||||
self.invited_by = frappe.session.user
|
||||
self.status = "Pending"
|
||||
|
||||
def after_insert(self):
|
||||
self._after_insert()
|
||||
|
||||
def accept(self, ignore_permissions: bool = False):
|
||||
accepted_now = self._accept()
|
||||
if not accepted_now:
|
||||
return
|
||||
user, user_inserted = self._upsert_user(ignore_permissions)
|
||||
self.save(ignore_permissions)
|
||||
user.save(ignore_permissions)
|
||||
self._run_after_accept_hooks(user, user_inserted)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_invite(self):
|
||||
if self.status != "Pending":
|
||||
return False
|
||||
self.status = "Cancelled"
|
||||
self.save()
|
||||
email_title = self._get_email_title()
|
||||
frappe.sendmail(
|
||||
recipients=self.email,
|
||||
subject=_("Invitation to join {0} cancelled").format(email_title),
|
||||
template="user_invitation_cancelled",
|
||||
args={"title": email_title},
|
||||
now=True,
|
||||
)
|
||||
return True
|
||||
|
||||
@frappe.whitelist()
|
||||
def expire(self):
|
||||
if self.status != "Pending":
|
||||
return
|
||||
self.status = "Expired"
|
||||
self.save()
|
||||
email_title = self._get_email_title()
|
||||
invited_by_user = frappe.get_doc("User", self.invited_by)
|
||||
frappe.sendmail(
|
||||
recipients=invited_by_user.email,
|
||||
subject=_("Invitation to join {0} expired").format(email_title),
|
||||
template="user_invitation_expired",
|
||||
args={"title": email_title},
|
||||
now=False,
|
||||
)
|
||||
|
||||
def _validate_invite(self):
|
||||
self._validate_app_name()
|
||||
self._validate_roles()
|
||||
self._validate_email()
|
||||
if frappe.db.get_value(
|
||||
"User Invitation", filters={"email": self.email, "status": "Accepted", "app_name": self.app_name}
|
||||
):
|
||||
frappe.throw(title=_("Error"), msg=_("invitation already accepted"))
|
||||
if frappe.db.get_value(
|
||||
"User Invitation", filters={"email": self.email, "status": "Pending", "app_name": self.app_name}
|
||||
):
|
||||
frappe.throw(title=_("Error"), msg=_("invitation already exists"))
|
||||
|
||||
def _after_insert(self):
|
||||
key = frappe.generate_hash()
|
||||
self.db_set("key", frappe.utils.sha256_hash(key))
|
||||
invite_link = frappe.utils.get_url(
|
||||
f"/api/method/frappe.core.api.user_invitation.accept_invitation?key={key}"
|
||||
)
|
||||
email_title = self._get_email_title()
|
||||
frappe.sendmail(
|
||||
recipients=self.email,
|
||||
subject=_("You've been invited to join {0}").format(email_title),
|
||||
template="user_invitation",
|
||||
args={"title": email_title, "invite_link": invite_link},
|
||||
now=True,
|
||||
)
|
||||
self.db_set("email_sent_at", frappe.utils.now())
|
||||
return key
|
||||
|
||||
def _accept(self):
|
||||
if self.status == "Accepted":
|
||||
return False
|
||||
if self.status == "Expired":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation is expired"))
|
||||
if self.status == "Cancelled":
|
||||
frappe.throw(title=_("Error"), msg=_("Invitation is cancelled"))
|
||||
self.status = "Accepted"
|
||||
self.accepted_at = frappe.utils.now()
|
||||
self.user = self.email
|
||||
return True
|
||||
|
||||
def _upsert_user(self, ignore_permissions: bool = False):
|
||||
user: Document | None = None
|
||||
user_inserted = False
|
||||
if frappe.db.exists("User", self.user):
|
||||
user = frappe.get_doc("User", self.user)
|
||||
else:
|
||||
user = frappe.new_doc("User")
|
||||
user.user_type = "System User"
|
||||
user.email = self.email
|
||||
user.first_name = self.email.split("@")[0].title()
|
||||
user.send_welcome_email = False
|
||||
user.insert(ignore_permissions)
|
||||
user_inserted = True
|
||||
user.append_roles(*[r.role for r in self.roles])
|
||||
return user, user_inserted
|
||||
|
||||
def _run_after_accept_hooks(self, user: Document, user_inserted: bool):
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
|
||||
if not isinstance(user_invitation_hook, dict):
|
||||
return
|
||||
for dot_path in user_invitation_hook.get("after_accept") or []:
|
||||
frappe.call(dot_path, invitation=self, user=user, user_inserted=user_inserted)
|
||||
|
||||
def _get_email_title(self):
|
||||
return frappe.get_hooks("app_title", app_name=self.app_name)[0]
|
||||
|
||||
def _validate_app_name(self):
|
||||
UserInvitation.validate_app_name(self.app_name)
|
||||
|
||||
def _validate_roles(self):
|
||||
if self.app_name == "frappe":
|
||||
return
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=self.app_name)
|
||||
allowed_roles: list[str] = []
|
||||
if isinstance(user_invitation_hook, dict):
|
||||
allowed_roles = user_invitation_hook.get("allowed_roles") or []
|
||||
for r in self.roles:
|
||||
if r.role in allowed_roles:
|
||||
continue
|
||||
frappe.throw(
|
||||
title=_("Invalid role"),
|
||||
msg=_("{0} is not an allowed role for {1}").format(r.role, self.app_name),
|
||||
)
|
||||
|
||||
def _validate_email(self):
|
||||
frappe.utils.validate_email_address(self.email, throw=True)
|
||||
|
||||
def get_redirect_to_path(self):
|
||||
start_index = 1 if self.redirect_to_path.startswith("/") else 0
|
||||
return self.redirect_to_path[start_index:]
|
||||
|
||||
@staticmethod
|
||||
def validate_app_name(app_name: str):
|
||||
if app_name not in frappe.get_installed_apps():
|
||||
frappe.throw(title=_("Invalid app"), msg=_("application is not installed"))
|
||||
|
||||
@staticmethod
|
||||
def validate_role(app_name: str) -> None:
|
||||
UserInvitation.validate_app_name(app_name)
|
||||
user_invitation_hook = frappe.get_hooks("user_invitation", app_name=app_name)
|
||||
only_for: list[str] = []
|
||||
if isinstance(user_invitation_hook, dict):
|
||||
only_for = user_invitation_hook.get("only_for") or []
|
||||
if "System Manager" not in only_for:
|
||||
only_for.append("System Manager")
|
||||
frappe.only_for(only_for)
|
||||
|
||||
|
||||
def mark_expired_invitations() -> None:
|
||||
days = 3
|
||||
invitations_to_expire = frappe.db.get_all(
|
||||
"User Invitation",
|
||||
filters={"status": "Pending", "creation": ["<", frappe.utils.add_days(frappe.utils.now(), -days)]},
|
||||
)
|
||||
for invitation in invitations_to_expire:
|
||||
invitation = frappe.get_doc("User Invitation", invitation.name)
|
||||
invitation.expire()
|
||||
# to avoid losing work in case the job times out without finishing
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
|
||||
def get_allowed_apps(user: Document | None) -> list[str]:
|
||||
user_roles = set(get_user_roles(user))
|
||||
allowed_apps: list[str] = []
|
||||
for app in frappe.get_installed_apps():
|
||||
user_invitation_hooks = frappe.get_hooks("user_invitation", app_name=app)
|
||||
if not isinstance(user_invitation_hooks, dict):
|
||||
continue
|
||||
only_for = user_invitation_hooks.get("only_for") or []
|
||||
if set(only_for) & user_roles:
|
||||
allowed_apps.append(app)
|
||||
return allowed_apps
|
||||
|
||||
|
||||
def get_permission_query_conditions(user: Document | None) -> str | None:
|
||||
user = get_user(user)
|
||||
user_roles = get_user_roles(user)
|
||||
if "System Manager" in user_roles:
|
||||
return
|
||||
allowed_apps = get_allowed_apps(user)
|
||||
if not allowed_apps:
|
||||
return "false"
|
||||
allowed_apps_str = ", ".join([f'"{app}"' for app in allowed_apps])
|
||||
return f"`tabUser Invitation`.app_name IN ({allowed_apps_str})"
|
||||
|
||||
|
||||
def has_permission(
|
||||
doc: UserInvitation, user: Document | None = None, permission_type: str | None = None
|
||||
) -> bool:
|
||||
return permission_type != "delete" and doc.app_name in get_allowed_apps(user)
|
||||
|
||||
|
||||
def get_user_roles(user: Document | None) -> list[str]:
|
||||
return get_roles(get_user(user))
|
||||
|
||||
|
||||
def get_user(user: Document | None) -> Document:
|
||||
return user or frappe.session.user
|
||||
|
|
@ -8,7 +8,7 @@ from frappe.core.doctype.user_permission.user_permission import (
|
|||
)
|
||||
from frappe.permissions import add_permission, has_user_permission
|
||||
from frappe.tests import IntegrationTestCase
|
||||
from frappe.website.doctype.blog_post.test_blog_post import make_test_blog
|
||||
from frappe.tests.test_helpers import setup_for_tests
|
||||
|
||||
|
||||
class TestUserPermission(IntegrationTestCase):
|
||||
|
|
@ -23,6 +23,7 @@ class TestUserPermission(IntegrationTestCase):
|
|||
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`")
|
||||
frappe.delete_doc_if_exists("DocType", "Doc A")
|
||||
frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabDoc A`")
|
||||
setup_for_tests()
|
||||
|
||||
def test_default_user_permission_validation(self):
|
||||
user = create_user("test_default_permission@example.com")
|
||||
|
|
@ -39,27 +40,27 @@ class TestUserPermission(IntegrationTestCase):
|
|||
add_user_permissions(param)
|
||||
# create a duplicate entry with default
|
||||
perm_user = create_user("test_default_corectness2@example.com")
|
||||
test_blog = make_test_blog()
|
||||
param = get_params(perm_user, "Blog Post", test_blog.name, is_default=1, hide_descendants=1)
|
||||
test_blog = frappe.get_doc("Test Blog Post", "_Test Blog Post 1")
|
||||
param = get_params(perm_user, "Test Blog Post", test_blog.name, is_default=1, hide_descendants=1)
|
||||
add_user_permissions(param)
|
||||
frappe.db.delete("User Permission", filters={"for_value": test_blog.name})
|
||||
frappe.delete_doc("Blog Post", test_blog.name)
|
||||
frappe.delete_doc("Test Blog Post", test_blog.name)
|
||||
|
||||
def test_default_user_permission(self):
|
||||
frappe.set_user("Administrator")
|
||||
user = create_user("test_user_perm1@example.com", "Website Manager")
|
||||
for category in ["general", "public"]:
|
||||
if not frappe.db.exists("Blog Category", category):
|
||||
frappe.get_doc({"doctype": "Blog Category", "title": category}).insert()
|
||||
if not frappe.db.exists("Test Blog Category", category):
|
||||
frappe.get_doc({"doctype": "Test Blog Category", "title": category}).insert()
|
||||
|
||||
param = get_params(user, "Blog Category", "general", is_default=1)
|
||||
param = get_params(user, "Test Blog Category", "general", is_default=1)
|
||||
add_user_permissions(param)
|
||||
|
||||
param = get_params(user, "Blog Category", "public")
|
||||
param = get_params(user, "Test Blog Category", "public")
|
||||
add_user_permissions(param)
|
||||
|
||||
frappe.set_user("test_user_perm1@example.com")
|
||||
doc = frappe.new_doc("Blog Post")
|
||||
doc = frappe.new_doc("Test Blog Post")
|
||||
|
||||
self.assertEqual(doc.blog_category, "general")
|
||||
frappe.set_user("Administrator")
|
||||
|
|
|
|||
35
frappe/core/doctype/user_role/user_role.json
Normal file
35
frappe/core/doctype/user_role/user_role.json
Normal 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": []
|
||||
}
|
||||
23
frappe/core/doctype/user_role/user_role.py
Normal file
23
frappe/core/doctype/user_role/user_role.py
Normal 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
|
||||
|
|
@ -49,6 +49,7 @@
|
|||
"email_append_to",
|
||||
"sender_field",
|
||||
"sender_name_field",
|
||||
"recipient_account_field",
|
||||
"subject_field",
|
||||
"section_break_8",
|
||||
"sort_field",
|
||||
|
|
@ -415,6 +416,12 @@
|
|||
"fieldname": "protect_attached_files",
|
||||
"fieldtype": "Check",
|
||||
"label": "Protect Attached Files"
|
||||
},
|
||||
{
|
||||
"depends_on": "email_append_to",
|
||||
"fieldname": "recipient_account_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Recipient Account Field"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
|
|
@ -423,7 +430,7 @@
|
|||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-27 18:22:32.618603",
|
||||
"modified": "2025-07-19 12:23:41.564203",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Custom",
|
||||
"name": "Customize Form",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class CustomizeForm(Document):
|
|||
protect_attached_files: DF.Check
|
||||
queue_in_background: DF.Check
|
||||
quick_entry: DF.Check
|
||||
recipient_account_field: DF.Data | None
|
||||
search_fields: DF.Data | None
|
||||
sender_field: DF.Data | None
|
||||
sender_name_field: DF.Data | None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
"""Use blog post test to test user permissions logic"""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from frappe.model.db_query import DatabaseQuery
|
|||
from frappe.permissions import add_permission, reset_perms
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["User"]
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = ["User", "Web Page"]
|
||||
|
||||
|
||||
class TestToDo(IntegrationTestCase):
|
||||
|
|
@ -93,8 +93,8 @@ class TestToDo(IntegrationTestCase):
|
|||
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
test_user.add_roles("Blogger")
|
||||
add_permission("ToDo", "Blogger")
|
||||
test_user.add_roles("Website Manager")
|
||||
add_permission("ToDo", "Website Manager")
|
||||
|
||||
frappe.set_user("test4@example.com")
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ class TestToDo(IntegrationTestCase):
|
|||
self.assertFalse(todo1.has_permission("write"))
|
||||
|
||||
frappe.set_user("Administrator")
|
||||
test_user.remove_roles("Blogger")
|
||||
test_user.remove_roles("Website Manager")
|
||||
reset_perms("ToDo")
|
||||
clear_permissions_cache("ToDo")
|
||||
frappe.db.rollback()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
@import "website_avatar";
|
||||
@import "web_form";
|
||||
@import "page_builder";
|
||||
@import "blog";
|
||||
@import "markdown";
|
||||
@import "sidebar";
|
||||
@import "portal";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
470
frappe/search/sqlite_search.md
Normal file
470
frappe/search/sqlite_search.md
Normal 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.
|
||||
1442
frappe/search/sqlite_search.py
Normal file
1442
frappe/search/sqlite_search.py
Normal file
File diff suppressed because it is too large
Load diff
20
frappe/templates/emails/user_invitation.html
Normal file
20
frappe/templates/emails/user_invitation.html
Normal 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>
|
||||
9
frappe/templates/emails/user_invitation_cancelled.html
Normal file
9
frappe/templates/emails/user_invitation_cancelled.html
Normal 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>
|
||||
9
frappe/templates/emails/user_invitation_expired.html
Normal file
9
frappe/templates/emails/user_invitation_expired.html
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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()})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
271
frappe/tests/test_helpers.py
Normal file
271
frappe/tests/test_helpers.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
505
frappe/tests/test_sqlite_search.py
Normal file
505
frappe/tests/test_sqlite_search.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Blog category.
|
||||
|
|
@ -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) {},
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{% extends "templates/pages/blog.html" %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
window.category = "{{ docname }}";
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<div>
|
||||
<a href={{ route }}>{{ title }}</a>
|
||||
</div>
|
||||
<!-- this is a sample default list template -->
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
Blog post for "Blogs" section of website.
|
||||
|
|
@ -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>
|
||||
`);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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 -%}
|
||||
·
|
||||
<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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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') }} · </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 %} · {{ post.read_time }} {{ _('min read') }} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a class="stretched-link" href="/{{ post.route }}"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
Blog titles and introduction texts.
|
||||
|
|
@ -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) {},
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1 +0,0 @@
|
|||
User of blog writer in "Blog" section.
|
||||
|
|
@ -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) {},
|
||||
});
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue