{{ title }}
-- {{ blog_intro }} -
--
diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 72d078a720..9b27516c2b 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -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", () => { diff --git a/cypress/integration/sidebar.js b/cypress/integration/sidebar.js index b1a487bc8d..5d170ca9ca 100644 --- a/cypress/integration/sidebar.js +++ b/cypress/integration/sidebar.js @@ -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", () => { diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index 8261b5b384..0850cae7b5 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -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"); diff --git a/frappe/auth.py b/frappe/auth.py index 9e4e66dda0..f667138ea1 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -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: diff --git a/frappe/core/api/user_invitation.py b/frappe/core/api/user_invitation.py new file mode 100644 index 0000000000..09811a9ace --- /dev/null +++ b/frappe/core/api/user_invitation.py @@ -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 diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index b24641e3b6..0c80c05eb7 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -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="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, ) diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index dfcb88b2ff..6a035986b3 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -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", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 1b94db76a9..845dbf5219 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -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"] diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index 1a1d19d11d..86b99c1c12 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -129,6 +129,21 @@ select name from \`tabPerson\` where tenant_id = 2 order by creation desc + +
Execute when a particular Workflow Action Master is executed.
+Gets the document which the action is being applied on in the doc variable.
+# 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()
+
`);
},
});
diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json
index 6cd35f6352..c8a78063d2 100644
--- a/frappe/core/doctype/server_script/server_script.json
+++ b/frappe/core/doctype/server_script/server_script.json
@@ -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": [],
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index fb639790f1..1b56c39e75 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -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)
diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json
index dc9d2bc6a5..c4a90e266f 100644
--- a/frappe/core/doctype/user/user.json
+++ b/frappe/core/doctype/user/user.json
@@ -845,11 +845,6 @@
"link_doctype": "Contact",
"link_fieldname": "user"
},
- {
- "group": "Profile",
- "link_doctype": "Blogger",
- "link_fieldname": "user"
- },
{
"group": "Logs",
"link_doctype": "Access Log",
diff --git a/frappe/templates/includes/likes/__init__.py b/frappe/core/doctype/user_invitation/__init__.py
similarity index 100%
rename from frappe/templates/includes/likes/__init__.py
rename to frappe/core/doctype/user_invitation/__init__.py
diff --git a/frappe/core/doctype/user_invitation/internal_doc/cancel_invitation_api_example.png b/frappe/core/doctype/user_invitation/internal_doc/cancel_invitation_api_example.png
new file mode 100644
index 0000000000..b1cdc73a69
Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/cancel_invitation_api_example.png differ
diff --git a/frappe/core/doctype/user_invitation/internal_doc/get_pending_invitations_api_example.png b/frappe/core/doctype/user_invitation/internal_doc/get_pending_invitations_api_example.png
new file mode 100644
index 0000000000..f5994fa6df
Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/get_pending_invitations_api_example.png differ
diff --git a/frappe/core/doctype/user_invitation/internal_doc/index.md b/frappe/core/doctype/user_invitation/internal_doc/index.md
new file mode 100644
index 0000000000..2e3fa127bf
--- /dev/null
+++ b/frappe/core/doctype/user_invitation/internal_doc/index.md
@@ -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.
diff --git a/frappe/core/doctype/user_invitation/internal_doc/invite_by_email_api_example.png b/frappe/core/doctype/user_invitation/internal_doc/invite_by_email_api_example.png
new file mode 100644
index 0000000000..47856f1c5e
Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/invite_by_email_api_example.png differ
diff --git a/frappe/core/doctype/user_invitation/internal_doc/role_doc_role_permissions_manager.png b/frappe/core/doctype/user_invitation/internal_doc/role_doc_role_permissions_manager.png
new file mode 100644
index 0000000000..ba8eb32b81
Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/role_doc_role_permissions_manager.png differ
diff --git a/frappe/core/doctype/user_invitation/internal_doc/user_invitation_doc_role_permissions_manager.png b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_doc_role_permissions_manager.png
new file mode 100644
index 0000000000..d14794c081
Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_doc_role_permissions_manager.png differ
diff --git a/frappe/core/doctype/user_invitation/internal_doc/user_invitation_hooks_example.png b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_hooks_example.png
new file mode 100644
index 0000000000..8bc0fcd839
Binary files /dev/null and b/frappe/core/doctype/user_invitation/internal_doc/user_invitation_hooks_example.png differ
diff --git a/frappe/core/doctype/user_invitation/test_user_invitation.py b/frappe/core/doctype/user_invitation/test_user_invitation.py
new file mode 100644
index 0000000000..a8342c87c4
--- /dev/null
+++ b/frappe/core/doctype/user_invitation/test_user_invitation.py
@@ -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"])
diff --git a/frappe/core/doctype/user_invitation/user_invitation.js b/frappe/core/doctype/user_invitation/user_invitation.js
new file mode 100644
index 0000000000..d60e28c031
--- /dev/null
+++ b/frappe/core/doctype/user_invitation/user_invitation.js
@@ -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")
+ );
+ });
+ },
+});
diff --git a/frappe/core/doctype/user_invitation/user_invitation.json b/frappe/core/doctype/user_invitation/user_invitation.json
new file mode 100644
index 0000000000..dffe32e82a
--- /dev/null
+++ b/frappe/core/doctype/user_invitation/user_invitation.json
@@ -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"
+ }
+ ]
+}
diff --git a/frappe/core/doctype/user_invitation/user_invitation.py b/frappe/core/doctype/user_invitation/user_invitation.py
new file mode 100644
index 0000000000..cccb8749a3
--- /dev/null
+++ b/frappe/core/doctype/user_invitation/user_invitation.py
@@ -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
diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py
index 47534ee5d8..afcc41279f 100644
--- a/frappe/core/doctype/user_permission/test_user_permission.py
+++ b/frappe/core/doctype/user_permission/test_user_permission.py
@@ -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")
diff --git a/frappe/website/doctype/blog_category/__init__.py b/frappe/core/doctype/user_role/__init__.py
similarity index 100%
rename from frappe/website/doctype/blog_category/__init__.py
rename to frappe/core/doctype/user_role/__init__.py
diff --git a/frappe/core/doctype/user_role/user_role.json b/frappe/core/doctype/user_role/user_role.json
new file mode 100644
index 0000000000..00fb33ea9a
--- /dev/null
+++ b/frappe/core/doctype/user_role/user_role.json
@@ -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": []
+}
diff --git a/frappe/core/doctype/user_role/user_role.py b/frappe/core/doctype/user_role/user_role.py
new file mode 100644
index 0000000000..6270a86312
--- /dev/null
+++ b/frappe/core/doctype/user_role/user_role.py
@@ -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
diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json
index 32e25ae29e..03ac4d1761 100644
--- a/frappe/custom/doctype/customize_form/customize_form.json
+++ b/frappe/custom/doctype/customize_form/customize_form.json
@@ -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",
diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py
index 3bbd874732..584882a754 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -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
diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py
index 01cd92d705..76679a05b7 100644
--- a/frappe/desk/doctype/event/test_event.py
+++ b/frappe/desk/doctype/event/test_event.py
@@ -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
diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py
index 6fa713a279..60673aa34e 100644
--- a/frappe/desk/doctype/todo/test_todo.py
+++ b/frappe/desk/doctype/todo/test_todo.py
@@ -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()
diff --git a/frappe/email/receive.py b/frappe/email/receive.py
index 5907528f38..a0409c7909 100644
--- a/frappe/email/receive.py
+++ b/frappe/email/receive.py
@@ -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:
diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py
index 8964c48965..dc5faccdd2 100644
--- a/frappe/email/test_smtp.py
+++ b/frappe/email/test_smtp.py
@@ -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
diff --git a/frappe/hooks.py b/frappe/hooks.py
index de7e59b34b..8d16696f41 100644
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -54,7 +54,6 @@ web_include_icons = [
email_css = ["email.bundle.css"]
website_route_rules = [
- {"from_route": "/blog/Learn how to integrate with our REST API.
+
+ Bearer tokensSee our code examples for details.+
| Method | POST |
, 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.
diff --git a/frappe/search/sqlite_search.py b/frappe/search/sqlite_search.py
new file mode 100644
index 0000000000..1cc97dcc16
--- /dev/null
+++ b/frappe/search/sqlite_search.py
@@ -0,0 +1,1442 @@
+# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See license.txt
+
+import datetime
+import inspect
+import os
+import re
+import sqlite3
+import time
+from abc import ABC, abstractmethod
+from collections import defaultdict
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any
+
+from bs4 import BeautifulSoup
+
+import frappe
+from frappe.model.document import Document
+from frappe.utils import update_progress_bar
+
+
+class WarningType(Enum):
+ """Warning types for search indexing."""
+
+ INVALID_DOCUMENT = "invalid_document"
+ MISSING_TEXT_FIELDS = "missing_text_fields"
+ MISSING_CONTENT_FIELD = "missing_content_field"
+ MISSING_TITLE_FIELD = "missing_title_field"
+ MISSING_DOCTYPE = "missing_doctype"
+ MISSING_NAME = "missing_name"
+ OTHER = "other"
+
+
+@dataclass
+class IndexWarning:
+ """Structured warning for search indexing."""
+
+ type: WarningType
+ message: str
+ doctype: str | None = None
+ docname: str | None = None
+ field: str | None = None
+ missing_fields: list | None = None
+
+ def __str__(self):
+ return self.message
+
+
+class SQLiteSearchIndexMissingError(Exception):
+ pass
+
+
+# Search Configuration Constants
+MAX_SEARCH_RESULTS = 100
+SNIPPET_LENGTH = 64
+MIN_WORD_LENGTH = 4
+MAX_EDIT_DISTANCE = 3
+MIN_SIMILARITY_THRESHOLD = 0.6
+MAX_SPELLING_SUGGESTIONS = 3
+SIMILARITY_TRIGRAM_WEIGHT = 0.7
+SIMILARITY_SEQUENCE_WEIGHT = 0.3
+FREQUENCY_BOOST_FACTOR = 1000
+MAX_FREQUENCY_BOOST = 1.2
+RECENCY_DECAY_RATE = 0.005 # Linear decay per day beyond 90 days
+MIN_RECENCY_BOOST = 0.5
+TITLE_EXACT_MATCH_BOOST = 5.0
+TITLE_PARTIAL_MATCH_BOOST = 2.0
+DISCUSSION_BOOST = 1.2
+COMMENT_BOOST = 1.0
+
+# Time-based recency categories for aggressive boosting
+RECENT_HOURS_BOOST = 1.8 # Documents from last 24 hours
+RECENT_WEEK_BOOST = 1.5 # Documents from last 7 days
+RECENT_MONTH_BOOST = 1.2 # Documents from last 30 days
+RECENT_QUARTER_BOOST = 1.1 # Documents from last 90 days
+
+
+class SQLiteSearch(ABC):
+ """
+ Abstract base class for SQLite FTS5-based full-text search for Frappe.
+
+ Provides full-text search with advanced features:
+ - Spelling correction using trigram similarity
+ - Time-based recency boost with categorical scoring
+ - Custom scoring with title matching and document type boosts
+ - Ranking tracking (original BM25 vs modified scores)
+ - Filtering by user-defined criteria
+ - Permission-aware search results via query-level filtering
+ """
+
+ @staticmethod
+ def scoring_function(func):
+ """
+ Decorator to mark methods as scoring functions that should be automatically
+ included in the scoring pipeline.
+
+ Usage:
+ @SQLiteSearch.scoring_function
+ def custom_boost(self, row, query, query_words):
+ return 1.5
+ """
+ func._is_scoring_function = True
+ return func
+
+ def __init__(self, db_name=None):
+ # Use class-level INDEX_NAME if db_name not provided
+ if db_name is None:
+ db_name = getattr(self, "INDEX_NAME", "search.db")
+
+ self.db_name = db_name
+ self.db_path = self._get_db_path()
+
+ # Validate required class attributes early
+ if not hasattr(self, "INDEX_SCHEMA"):
+ raise ValueError("INDEX_SCHEMA must be defined as a class-level variable")
+ if not hasattr(self, "INDEXABLE_DOCTYPES"):
+ raise ValueError("INDEXABLE_DOCTYPES must be defined as a class-level variable")
+
+ self.doc_configs = self._build_doc_configs()
+ self.warnings: list[IndexWarning] = [] # Collect warnings during indexing
+ self.schema = self._get_schema()
+ self._validate_config()
+
+ # Helper Methods for New API
+
+ def _parse_doctype_fields(self, doctype, config):
+ """Parse field definitions for a doctype to extract field names and mappings."""
+ if "fields" not in config:
+ raise ValueError(f"Missing 'fields' in configuration for doctype '{doctype}'")
+
+ parsed_fields = []
+ field_mappings = {}
+
+ for field_def in config["fields"]:
+ if isinstance(field_def, str):
+ parsed_fields.append(field_def)
+ elif isinstance(field_def, dict):
+ for schema_field, doctype_field in field_def.items():
+ parsed_fields.append(doctype_field)
+ field_mappings[schema_field] = doctype_field
+ else:
+ raise ValueError(f"Invalid field definition: {field_def}")
+
+ return parsed_fields, field_mappings
+
+ def _build_doc_configs(self):
+ """Build document configurations from class-level INDEXABLE_DOCTYPES."""
+ doc_configs = {}
+ for doctype, config in self.INDEXABLE_DOCTYPES.items():
+ parsed_fields, field_mappings = self._parse_doctype_fields(doctype, config)
+
+ # Determine content field
+ content_field = field_mappings.get("content")
+ if not content_field:
+ if "content" in parsed_fields:
+ content_field = "content"
+ else:
+ raise ValueError(
+ f"Content field must be present in fields list or explicitly mapped for '{doctype}'"
+ )
+
+ # Determine title field
+ title_field = field_mappings.get("title")
+ if not title_field and "title" in parsed_fields:
+ title_field = "title"
+
+ doc_configs[doctype] = {
+ "fields": parsed_fields,
+ "field_mappings": field_mappings,
+ "content_field": content_field,
+ "title_field": title_field,
+ "modified_field": field_mappings.get("modified", "modified"),
+ "filters": config.get("filters", {}),
+ }
+
+ return doc_configs
+
+ def _get_schema(self):
+ """Get the search index schema with automatic defaults."""
+ if not hasattr(self, "INDEX_SCHEMA"):
+ raise ValueError("INDEX_SCHEMA must be defined as a class-level variable")
+
+ schema = self.INDEX_SCHEMA.copy()
+
+ # Default text fields to title and content
+ schema.setdefault("text_fields", ["title", "content"])
+
+ # Default tokenizer
+ schema.setdefault("tokenizer", "unicode61 remove_diacritics 2")
+
+ # Automatically add required metadata fields
+ metadata_fields = schema.setdefault("metadata_fields", [])
+ required_fields = ["doctype", "name"]
+
+ for field in required_fields:
+ if field not in metadata_fields:
+ metadata_fields.append(field)
+
+ # Add 'modified' to metadata if it's used in the schema or any doctype config
+ is_modified_in_schema = "modified" in self.INDEX_SCHEMA.get("metadata_fields", [])
+ is_modified_in_doctypes = any(
+ "modified" in config.get("field_mappings", {}) or "modified" in config.get("fields", [])
+ for config in self.doc_configs.values()
+ )
+
+ if (is_modified_in_schema or is_modified_in_doctypes) and "modified" not in metadata_fields:
+ metadata_fields.append("modified")
+
+ schema["metadata_fields"] = metadata_fields
+
+ return schema
+
+ # Abstract Method - Must be implemented by subclasses
+
+ @abstractmethod
+ def get_search_filters(self):
+ """
+ Return filters to apply to search results.
+
+ Returns:
+ dict: Permission filters in format:
+ {
+ "field_name": value, # Single value: field = value
+ "field_name": [val1, val2] # List: field IN (val1, val2)
+ }
+ """
+ pass
+
+ # Public API Methods
+
+ def search(self, query, title_only=False, filters=None):
+ """
+ Main search method with advanced filtering support.
+
+ Args:
+ query (str): Search query text
+ title_only (bool): Whether to search only in titles
+ filters (dict): Optional filters by field names
+
+ Returns:
+ dict: Search results with summary statistics
+ """
+ if not self.is_search_enabled():
+ return self._empty_search_result(title_only, filters)
+
+ self.raise_if_not_indexed()
+
+ if not query:
+ return self._empty_search_result(title_only, filters)
+
+ start_time = time.time()
+
+ # Prepare filters if provided
+ filters = filters or {}
+
+ # Get permission filters from subclass
+ permission_filters = self.get_search_filters()
+
+ # Combine user filters with permission filters
+ all_filters = {**filters, **permission_filters}
+
+ # Prepare FTS5 query with spelling correction
+ expanded_query, corrections = self._expand_query_with_corrections(query)
+ fts_query = self._prepare_fts_query(expanded_query)
+
+ try:
+ raw_results = self._execute_search_query(fts_query, title_only, all_filters)
+ total_matches = len(raw_results)
+ except sqlite3.Error as e:
+ frappe.log_error(f"Search query failed: {e}")
+ raw_results = []
+ total_matches = 0
+
+ # Process results
+ processed_results = self._process_search_results(raw_results, query)
+
+ duration = time.time() - start_time
+
+ return {
+ "results": processed_results,
+ "summary": {
+ "duration": round(duration, 3),
+ "total_matches": total_matches,
+ "returned_matches": total_matches,
+ "corrected_words": corrections,
+ "corrected_query": expanded_query if corrections else None,
+ "title_only": title_only,
+ "filtered_matches": len(processed_results),
+ "applied_filters": filters,
+ },
+ }
+
+ def build_index(self):
+ """Build the complete search index from scratch using atomic replacement."""
+ if not self.is_search_enabled():
+ return
+
+ # Use temporary database path for atomic replacement
+ temp_db_path = self._get_db_path(is_temp=True)
+ original_db_path = self.db_path
+
+ # Remove temp file if it exists
+ if os.path.exists(temp_db_path):
+ os.unlink(temp_db_path)
+
+ # Temporarily switch to temp database for building
+ self.db_path = temp_db_path
+
+ try:
+ self._update_progress("Setting up search tables", 0, 100, absolute=True)
+
+ # Setup tables in temp database
+ self._ensure_fts_table()
+
+ self._update_progress("Fetching records", 20, 100, absolute=True)
+
+ records = self.get_documents()
+ documents = []
+
+ self._update_progress("Preparing documents", 30, 100, absolute=True)
+
+ total_records = len(records)
+ for i, doc in enumerate(records):
+ document = self.prepare_document(doc)
+ if document:
+ documents.append(document)
+
+ # Update progress during document preparation
+ if i % 100 == 0:
+ progress = 30 + int((i / total_records) * 20) # 30-50% range
+ self._update_progress("Preparing documents", progress, 100, absolute=True)
+
+ self._update_progress("Indexing documents", 50, 100, absolute=True)
+
+ self._index_documents(documents)
+
+ self._update_progress("Building spell correction vocabulary", 80, 100, absolute=True)
+
+ # Build vocabulary for spelling correction
+ self._build_vocabulary(documents)
+
+ # Atomic replacement: move temp database to final location
+ if os.path.exists(original_db_path):
+ os.unlink(original_db_path)
+ os.rename(temp_db_path, original_db_path)
+
+ self._update_progress("Search index build complete", 100, 100, absolute=True)
+
+ # Print warning summary
+ self._print_warning_summary()
+
+ except Exception:
+ # Clean up temp file on error
+ if os.path.exists(temp_db_path):
+ os.unlink(temp_db_path)
+ raise
+ finally:
+ # Restore original database path
+ self.db_path = original_db_path
+
+ # Status and Validation Methods
+
+ def index_exists(self):
+ """Check if FTS index exists."""
+ if not os.path.exists(self.db_path):
+ return False
+
+ try:
+ result = self.sql(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='search_fts'", read_only=True
+ )
+ return bool(result)
+ except sqlite3.Error:
+ return False
+
+ def drop_index(self):
+ """Drop the search index by removing the database file."""
+ if os.path.exists(self.db_path):
+ try:
+ os.unlink(self.db_path)
+ except OSError as e:
+ frappe.log_error(f"Failed to remove search index file {self.db_path}: {e}")
+ raise
+
+ def is_search_enabled(self):
+ """Override this to enable/disable search"""
+ return True
+
+ def raise_if_not_indexed(self):
+ """Raise exception if search index doesn't exist."""
+ if not self.index_exists():
+ raise SQLiteSearchIndexMissingError("Search index does not exist. Please build the index first.")
+
+ def get_documents(self):
+ """Get all records to be indexed."""
+ records = []
+ for doctype, config in self.doc_configs.items():
+ docs = frappe.qb.get_query(
+ doctype, fields=config["fields"], filters=config.get("filters", {})
+ ).run(as_dict=True)
+
+ for doc in docs:
+ doc.doctype = doctype
+ if config["modified_field"] != "modified":
+ doc.modified = getattr(doc, config["modified_field"], None) or doc.modified
+ records.append(doc)
+
+ return records
+
+ # Private Implementation Methods
+
+ def _execute_search_query(self, fts_query, title_only, filters):
+ """Execute the FTS search query with optional filters."""
+ # Build filter conditions
+ filter_conditions = []
+ filter_params = []
+
+ if filters:
+ # Build filter conditions dynamically
+ for field, values in filters.items():
+ if not values and isinstance(values, list):
+ # If filter is an empty list, it should not match any documents.
+ filter_conditions.append("1=0")
+ continue
+
+ if not values: # Skip empty filters
+ continue
+
+ if isinstance(values, list):
+ if len(values) == 1:
+ filter_conditions.append(f"{field} = ?")
+ filter_params.append(values[0])
+ else:
+ placeholders = ",".join(["?" for _ in values])
+ filter_conditions.append(f"{field} IN ({placeholders})")
+ filter_params.extend(values)
+ else:
+ filter_conditions.append(f"{field} = ?")
+ filter_params.append(values)
+
+ # Combine filter conditions with AND
+ filter_clause = ""
+ if filter_conditions:
+ filter_clause = "AND " + " AND ".join(filter_conditions)
+
+ # Get schema to build dynamic SELECT fields
+ text_fields = self.schema["text_fields"]
+ metadata_fields = self.schema["metadata_fields"]
+
+ # Build SELECT clause with all fields
+ select_fields = []
+
+ # Add title highlighting
+ title_field = "title" if "title" in text_fields else text_fields[0] if text_fields else "doc_id"
+ title_column_index = self._get_text_field_column_index(title_field)
+ if title_column_index is not None:
+ select_fields.append(f"highlight(search_fts, {title_column_index}, '', '') as title")
+ else:
+ select_fields.append(f"{title_field} as title")
+
+ # Add content snippet or highlighting
+ if not title_only and "content" in text_fields:
+ content_index = self._get_text_field_column_index("content")
+ select_fields.append(
+ f"snippet(search_fts, {content_index}, '', '', '...', ?) as content"
+ )
+ elif "content" in text_fields:
+ select_fields.append("content")
+
+ # Add all other fields
+ for field in metadata_fields:
+ if field != "doc_id": # Already handled above
+ select_fields.append(field)
+
+ # Add scoring fields
+ select_fields.extend(["bm25(search_fts) as bm25_score", f"{title_field} as original_title"])
+
+ select_clause = ",\n ".join(select_fields)
+
+ if title_only:
+ sql = f"""
+ SELECT
+ doc_id,
+ {select_clause}
+ FROM search_fts
+ WHERE search_fts MATCH ?
+ AND {title_field} MATCH ?
+ {filter_clause}
+ ORDER BY bm25_score
+ LIMIT ?
+ """
+ return self.sql(sql, (fts_query, fts_query, *filter_params, MAX_SEARCH_RESULTS), read_only=True)
+ else:
+ params = []
+ if "content" in text_fields:
+ params.append(SNIPPET_LENGTH)
+ params.extend([fts_query, *filter_params, MAX_SEARCH_RESULTS])
+
+ sql = f"""
+ SELECT
+ doc_id,
+ {select_clause}
+ FROM search_fts
+ WHERE search_fts MATCH ?
+ {filter_clause}
+ ORDER BY bm25_score
+ LIMIT ?
+ """
+ return self.sql(sql, params, read_only=True)
+
+ def _process_search_results(self, raw_results, query):
+ """Process search results with scoring."""
+ processed_results = []
+ query_words = query.split()
+
+ # Get schema configuration
+ text_fields = self.schema["text_fields"]
+ metadata_fields = self.schema["metadata_fields"]
+
+ # 1-based ranking
+ for original_rank, row in enumerate(raw_results, 1):
+ # Apply advanced heuristics scoring
+ score = self._calculate_advanced_score(row, query, query_words)
+
+ # Build result dynamically based on schema
+ result = {
+ "id": row["doc_id"],
+ "score": score,
+ "original_rank": original_rank,
+ "bm25_score": row["bm25_score"],
+ }
+
+ # Add text fields
+ for field in text_fields:
+ result[field] = row[field] if field in row.keys() else ""
+
+ # Add metadata fields
+ for field in metadata_fields:
+ if field == "owner":
+ # Map owner to author for backward compatibility
+ result["author"] = row["owner"] if "owner" in row.keys() else ""
+ else:
+ result[field] = row[field] if field in row.keys() else None
+
+ processed_results.append(result)
+
+ # Sort by custom score (descending - higher is better)
+ processed_results.sort(key=lambda x: x["score"], reverse=True)
+
+ # Add modified ranking after custom scoring
+ for i, result in enumerate(processed_results):
+ result["modified_rank"] = i + 1
+
+ return processed_results
+
+ def get_scoring_pipeline(self):
+ """
+ Return the scoring pipeline, a list of methods to calculate the final score.
+ Each method in the list should accept either (row, query) or (row, query, query_words)
+ and return a float. The final score is the product of all values returned by the pipeline methods.
+ Subclasses can override this to customize the scoring logic.
+ """
+ pipeline = [
+ self._get_base_score,
+ self._get_title_boost,
+ ]
+
+ # Only add recency boost if modified is available in the schema
+ if "modified" in self.schema["metadata_fields"]:
+ pipeline.append(self._get_recency_boost)
+
+ # Automatically discover and add decorated scoring functions
+ for attr_name in dir(self):
+ attr = getattr(self, attr_name)
+ if callable(attr) and hasattr(attr, "_is_scoring_function"):
+ pipeline.append(attr)
+
+ return pipeline
+
+ def _calculate_advanced_score(self, row, query, query_words):
+ """
+ Calculate the final score by executing the scoring pipeline.
+ The final score is the product of all scores returned by the pipeline methods.
+ """
+ pipeline = self.get_scoring_pipeline()
+ final_score = 1.0
+
+ for scoring_method in pipeline:
+ # Check method signature to determine how to call it
+ sig = inspect.signature(scoring_method)
+ params = list(sig.parameters.keys())
+
+ # Skip 'self' parameter
+ if params and params[0] == "self":
+ params = params[1:]
+
+ # Call method based on its signature
+ if len(params) >= 3 or "query_words" in params:
+ # Method accepts query_words parameter
+ final_score *= scoring_method(row, query, query_words)
+ else:
+ # Method only accepts row and query
+ final_score *= scoring_method(row, query)
+
+ return final_score
+
+ def _get_base_score(self, row, query):
+ """Calculate the base score from BM25."""
+ bm25_score = abs(row["bm25_score"]) if row["bm25_score"] is not None else 0
+ return 1.0 / (1.0 + bm25_score) if bm25_score > 0 else 0.5
+
+ def _get_title_boost(self, row, query, query_words):
+ """Calculate the title matching boost based on percentage of words matched."""
+ original_title = (row["original_title"] or "").lower()
+ query_lower = query.lower()
+
+ # Check for exact phrase match first (highest boost)
+ if query_lower in original_title:
+ return TITLE_EXACT_MATCH_BOOST
+
+ # Calculate percentage of query words that match in title
+ if not query_words:
+ return 1.0
+
+ matched_words = 0
+ for word in query_words:
+ if word.lower() in original_title:
+ matched_words += 1
+
+ if matched_words == 0:
+ return 1.0
+
+ # Calculate match percentage
+ match_percentage = matched_words / len(query_words)
+
+ # Scale the boost between TITLE_PARTIAL_MATCH_BOOST (2.0) and TITLE_EXACT_MATCH_BOOST (5.0)
+ # based on the percentage of words matched
+ min_boost = TITLE_PARTIAL_MATCH_BOOST # 2.0
+ max_boost = TITLE_EXACT_MATCH_BOOST # 5.0
+
+ # Linear interpolation: boost = min_boost + (max_boost - min_boost) * match_percentage
+ boost = min_boost + (max_boost - min_boost) * match_percentage
+
+ return boost
+
+ def _get_recency_boost(self, row, query):
+ """Calculate the time-based recency boost."""
+ # Return neutral boost if modified is not available
+ if "modified" not in row or row["modified"] is None:
+ return 1.0
+
+ current_time = time.time()
+ doc_timestamp = row["modified"]
+ hours_old = (current_time - doc_timestamp) / 3600
+ days_old = hours_old / 24
+
+ if hours_old <= 24:
+ return RECENT_HOURS_BOOST
+ if days_old <= 7:
+ return RECENT_WEEK_BOOST
+ if days_old <= 30:
+ return RECENT_MONTH_BOOST
+ if days_old <= 90:
+ return RECENT_QUARTER_BOOST
+
+ # Older documents get linear decay
+ days_beyond_90 = days_old - 90
+ return max(MIN_RECENCY_BOOST, RECENT_QUARTER_BOOST - (days_beyond_90 * RECENCY_DECAY_RATE))
+
+ def _get_text_field_column_index(self, field_name):
+ """Get the 1-based column index of a text field in the FTS table."""
+ try:
+ # FTS table columns are doc_id, then text_fields...
+ # So index is 1 (for doc_id) + index in text_fields list
+ return 1 + self.schema["text_fields"].index(field_name)
+ except ValueError:
+ return None
+
+ # Spelling Correction Methods
+
+ def _expand_query_with_corrections(self, query):
+ """Expand query with spelling corrections."""
+ words = query.strip().split()
+ expanded_terms = []
+ corrections = {}
+
+ for word in words:
+ similar_words = self._find_similar_words(word)
+ if similar_words and similar_words[0] != word:
+ # Replace the misspelled word with the corrected word
+ corrected_word = similar_words[0]
+ expanded_terms.append(corrected_word)
+ corrections[word] = corrected_word
+ else:
+ expanded_terms.append(word)
+
+ expanded_query = " ".join(expanded_terms)
+ return expanded_query, corrections if corrections else None
+
+ def _find_similar_words(
+ self, word, max_suggestions=MAX_SPELLING_SUGGESTIONS, min_similarity=MIN_SIMILARITY_THRESHOLD
+ ):
+ """Find similar words using indexed trigram similarity - much faster!"""
+ import difflib
+
+ word = word.lower()
+ if len(word) < MIN_WORD_LENGTH:
+ return []
+
+ word_trigrams = self._generate_trigrams(word)
+ word_length = len(word)
+
+ try:
+ # Find candidate words that share trigrams (MUCH faster than checking all words)
+ placeholders = ",".join("?" * len(word_trigrams))
+ candidates = self.sql(
+ f"""
+ SELECT t.word, v.frequency, v.length, COUNT(*) as shared_trigrams
+ FROM search_trigrams t
+ JOIN search_vocabulary v ON t.word = v.word
+ WHERE t.trigram IN ({placeholders})
+ AND ABS(v.length - ?) <= ? -- Length filter for efficiency
+ GROUP BY t.word, v.frequency, v.length
+ HAVING shared_trigrams >= 1 -- Must share at least 1 trigram
+ ORDER BY shared_trigrams DESC, v.frequency DESC
+ """,
+ (*word_trigrams, word_length, MAX_EDIT_DISTANCE),
+ read_only=True,
+ )
+ except sqlite3.Error:
+ return []
+
+ similarities = []
+ word_trigram_set = set(word_trigrams)
+
+ for candidate_word, freq, candidate_length, _ in candidates:
+ # Quick length-based filter
+ if abs(candidate_length - word_length) > MAX_EDIT_DISTANCE:
+ continue
+
+ candidate_trigrams = set(self._generate_trigrams(candidate_word))
+
+ # Jaccard similarity for trigrams
+ intersection = len(word_trigram_set & candidate_trigrams)
+ union = len(word_trigram_set | candidate_trigrams)
+ trigram_similarity = intersection / union if union > 0 else 0
+
+ # Skip if trigram similarity is too low
+ if trigram_similarity < 0.3:
+ continue
+
+ # Sequence similarity for additional accuracy (only for promising candidates)
+ seq_similarity = difflib.SequenceMatcher(None, word, candidate_word).ratio()
+
+ # Combined similarity with frequency boost
+ combined_similarity = (
+ trigram_similarity * SIMILARITY_TRIGRAM_WEIGHT + seq_similarity * SIMILARITY_SEQUENCE_WEIGHT
+ )
+ frequency_boost = min(
+ MAX_FREQUENCY_BOOST, 1.0 + (freq / FREQUENCY_BOOST_FACTOR)
+ ) # Slight boost for common words
+ final_score = combined_similarity * frequency_boost
+
+ if final_score >= min_similarity:
+ similarities.append((candidate_word, final_score))
+
+ # Sort by similarity and return top suggestions
+ similarities.sort(key=lambda x: x[1], reverse=True)
+ return [word for word, score in similarities[:max_suggestions]]
+
+ def _build_vocabulary(self, documents):
+ """Build vocabulary and trigram index from documents for spelling correction."""
+ import re
+
+ word_freq = defaultdict(int)
+ word_regex = re.compile(r"\w+") # Compile regex once for efficiency
+
+ # Extract words from all documents in batches
+ for i, doc in enumerate(documents):
+ # Show progress for large document sets
+ if i % 1000 == 0:
+ progress = 80 + int((i / len(documents)) * 15) # 80-95% range
+ self._update_progress(
+ f"Processing vocabulary ({i}/{len(documents)})", progress, 100, absolute=True
+ )
+
+ # Process title and content together for efficiency
+ combined_text = " ".join(
+ [(doc.get("title", "") or "").lower(), (doc.get("content", "") or "").lower()]
+ )
+
+ # Extract all words at once with compiled regex
+ words = word_regex.findall(combined_text)
+
+ for word in words:
+ if len(word) > MIN_WORD_LENGTH - 1 and word.isalpha(): # Filter out short words and non-alpha
+ word_freq[word] += 1
+
+ # Clear existing data in a single transaction
+ conn = self._get_connection()
+ try:
+ cursor = conn.cursor()
+ cursor.execute("DELETE FROM search_vocabulary")
+ cursor.execute("DELETE FROM search_trigrams")
+ conn.commit()
+ finally:
+ conn.close()
+
+ if not word_freq:
+ return
+
+ # Prepare batch data for vocabulary
+ vocab_data = []
+ trigram_data = []
+ trigram_set = set() # Use set to avoid duplicate trigrams
+
+ for word, freq in word_freq.items():
+ vocab_data.append((word, freq, len(word)))
+
+ # Generate trigrams for this word
+ trigrams = self._generate_trigrams(word)
+ for trigram in trigrams:
+ trigram_key = (trigram, word)
+ if trigram_key not in trigram_set:
+ trigram_set.add(trigram_key)
+ trigram_data.append(trigram_key)
+
+ # Use batch inserts with a single transaction
+ conn = self._get_connection()
+ try:
+ cursor = conn.cursor()
+
+ # Batch insert vocabulary
+ cursor.executemany(
+ "INSERT INTO search_vocabulary (word, frequency, length) VALUES (?, ?, ?)", vocab_data
+ )
+
+ # Batch insert trigrams (duplicates already removed)
+ cursor.executemany("INSERT INTO search_trigrams (trigram, word) VALUES (?, ?)", trigram_data)
+
+ conn.commit()
+ finally:
+ conn.close()
+
+ # Database and Infrastructure Methods
+
+ def _get_connection(self, read_only=False):
+ """Get SQLite connection with FTS5 support and performance optimizations."""
+ try:
+ conn = sqlite3.connect(self.db_path)
+ conn.row_factory = sqlite3.Row
+
+ # Apply performance optimizations
+ cursor = conn.cursor()
+ self._set_pragmas(cursor, read_only)
+
+ # Test the connection
+ cursor.execute("SELECT 1")
+ return conn
+ except sqlite3.Error as e:
+ frappe.log_error(f"Failed to connect to search database: {e}")
+ raise SQLiteSearchIndexMissingError(f"Search database connection failed: {e}") from e
+
+ def _set_pragmas(self, cursor, is_read=False):
+ """Set SQLite performance pragmas."""
+ cursor.execute("PRAGMA journal_mode = WAL;") # Write-Ahead Logging for concurrency
+ cursor.execute("PRAGMA synchronous = NORMAL;") # Better performance vs FULL
+ cursor.execute("PRAGMA cache_size = -8192;") # 8MB cache
+ cursor.execute("PRAGMA temp_store = MEMORY;") # Memory temp storage
+ if is_read:
+ cursor.execute("PRAGMA query_only = 1;") # Read-only optimization
+
+ def _ensure_fts_table(self):
+ """Create FTS table and related tables if they don't exist."""
+ # Get schema from subclass
+ text_fields = self.schema["text_fields"]
+ metadata_fields = self.schema["metadata_fields"]
+ tokenizer = self.schema["tokenizer"]
+
+ # Use a single transaction for all table creation operations
+ conn = self._get_connection()
+ try:
+ cursor = conn.cursor()
+
+ # Create the FTS table with dynamic columns
+ cursor.execute(f"""
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
+ doc_id UNINDEXED,
+ {", ".join([f"{field}" for field in text_fields])},
+ {", ".join([f"{field} UNINDEXED" for field in metadata_fields])},
+ tokenize="{tokenizer}"
+ )
+ """)
+
+ # Create the vocabulary and trigram tables
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS search_vocabulary (
+ word TEXT PRIMARY KEY,
+ frequency INTEGER DEFAULT 1,
+ length INTEGER
+ )
+ """)
+
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS search_trigrams (
+ trigram TEXT,
+ word TEXT,
+ PRIMARY KEY (trigram, word)
+ )
+ """)
+
+ # Index for fast trigram lookups
+ cursor.execute("""
+ CREATE INDEX IF NOT EXISTS idx_trigram_lookup ON search_trigrams(trigram)
+ """)
+
+ conn.commit()
+ finally:
+ conn.close()
+
+ def _index_documents(self, documents):
+ """Bulk index documents into SQLite FTS."""
+ if not documents:
+ return
+
+ # Get schema configuration to build dynamic insert SQL
+ text_fields = self.schema["text_fields"]
+ metadata_fields = self.schema["metadata_fields"]
+
+ # Always add doc_id as first field (required for FTS)
+ all_fields = ["doc_id", *text_fields, *metadata_fields]
+ placeholders = ",".join(["?" for _ in all_fields])
+ field_names = ",".join(all_fields)
+
+ insert_sql = f"""
+ INSERT INTO search_fts ({field_names})
+ VALUES ({placeholders})
+ """
+
+ # Process documents in chunks to prevent memory issues with large datasets
+ chunk_size = 1000
+ conn = self._get_connection()
+ try:
+ cursor = conn.cursor()
+
+ for i in range(0, len(documents), chunk_size):
+ chunk = documents[i : i + chunk_size]
+ values_to_insert = []
+
+ for doc in chunk:
+ # Validate document has required fields
+ if not doc.get("doctype") or not doc.get("name"):
+ self._warn_invalid_document(doc, "missing doctype/name")
+ continue
+
+ # Validate text fields are present
+ missing_text_fields = []
+ for field in text_fields:
+ if field not in doc or doc[field] is None:
+ missing_text_fields.append(field)
+
+ if missing_text_fields:
+ self._warn_missing_text_fields(
+ doc.get("doctype", ""), doc.get("name", ""), missing_text_fields
+ )
+ continue
+
+ # Build values tuple dynamically based on schema
+ values = []
+ for field in all_fields:
+ # Build doc_id automatically from doctype:name
+ if field == "doc_id":
+ doc_id = doc.get("id") or f"{doc.get('doctype', '')}:{doc.get('name', '')}"
+ values.append(doc_id)
+ else:
+ values.append(doc.get(field, ""))
+
+ values_to_insert.append(tuple(values))
+
+ # Insert the chunk
+ if values_to_insert:
+ cursor.executemany(insert_sql, values_to_insert)
+
+ conn.commit()
+ finally:
+ conn.close()
+
+ def index_doc(self, doctype, docname):
+ """Index a single document."""
+ doc = frappe.get_doc(doctype, docname)
+ self.raise_if_not_indexed()
+ document = self.prepare_document(doc)
+ if document:
+ self._index_documents([document])
+
+ def remove_doc(self, doctype, docname):
+ """Remove a single document from the index."""
+ self.raise_if_not_indexed()
+ doc_id = f"{doctype}:{docname}"
+ self.sql("DELETE FROM search_fts WHERE doc_id = ?", (doc_id,), commit=True)
+
+ # Utility Methods
+
+ def _update_progress(self, message, progress, total=100, absolute=True):
+ """Update progress bar only if not running in a web request context or tests."""
+ if not hasattr(frappe.local, "request") and not frappe.flags.in_test:
+ update_progress_bar(message, progress, total, absolute=absolute)
+
+ def _validate_config(self):
+ """Validate document configuration at startup."""
+ metadata_fields = self.schema["metadata_fields"]
+
+ for doctype, config in self.doc_configs.items():
+ # Validate that all specified fields are present in the 'fields' list
+ fields_to_check = ["content_field", "title_field"]
+ if "modified" in metadata_fields:
+ fields_to_check.append("modified_field")
+
+ for field_key in fields_to_check:
+ field_value = config.get(field_key)
+ if field_value and field_value not in config["fields"]:
+ raise ValueError(
+ f"{field_key.replace('_', ' ').title()} '{field_value}' not found in 'fields' list for Doctype '{doctype}'"
+ )
+
+ def _empty_search_result(self, title_only=False, filters=None):
+ """Return empty search result structure."""
+ return {
+ "results": [],
+ "summary": {
+ "total_matches": 0,
+ "filtered_matches": 0,
+ "duration": 0,
+ "returned_matches": 0,
+ "corrected_words": None,
+ "corrected_query": None,
+ "title_only": title_only,
+ "applied_filters": filters or {},
+ },
+ }
+
+ def _get_db_path(self, is_temp=False):
+ """Get the path for the SQLite FTS database."""
+ site_path = frappe.get_site_path()
+ db_path = os.path.join(site_path, self.db_name)
+ if is_temp:
+ return db_path.replace(".db", ".temp.db")
+ return db_path
+
+ def _prepare_fts_query(self, query):
+ """Prepare query for FTS5 with proper escaping and operators."""
+ query = query.strip()
+ if not query:
+ return ""
+
+ # Simple query - split into terms and add wildcards for partial matching
+ terms = query.split()
+ fts_terms = []
+
+ for term in terms:
+ # Escape special FTS5 characters
+ term = term.replace('"', '""')
+ # Add wildcard for prefix matching
+ if len(term) > MIN_WORD_LENGTH - 1:
+ fts_terms.append(f'"{term}"*')
+ else:
+ fts_terms.append(f'"{term}"')
+
+ return " ".join(fts_terms)
+
+ def sql(self, query, params=None, read_only=False, commit=False):
+ """Execute a SQL query on the search database."""
+ conn = self._get_connection(read_only=read_only)
+ try:
+ cursor = conn.cursor()
+ cursor.execute(query, params or [])
+
+ if read_only:
+ return cursor.fetchall()
+
+ if commit:
+ conn.commit()
+
+ # For write operations, we might not need to return anything,
+ # but returning the cursor could be useful for getting rowcount, etc.
+ return cursor
+ finally:
+ conn.close()
+
+ def prepare_document(self, doc):
+ """Prepare a document for indexing by validating and transforming it."""
+ is_valid, config = self._validate_document_for_indexing(doc)
+ if not is_valid:
+ return None
+
+ document = {
+ "id": f"{doc.doctype}:{doc.name}",
+ "doctype": doc.doctype,
+ "name": doc.name,
+ }
+
+ self._add_text_fields_to_document(document, doc, config)
+ self._add_metadata_fields_to_document(document, doc, config)
+
+ return document
+
+ def _validate_document_for_indexing(self, doc):
+ """Run all validation checks for a document before indexing."""
+ if not hasattr(doc, "doctype") or not doc.doctype:
+ self._warn_missing_doctype(doc)
+ return False, None
+
+ if not hasattr(doc, "name") or not doc.name:
+ self._warn_missing_name(doc.doctype)
+ return False, None
+
+ config = self.doc_configs.get(doc.doctype)
+ if not config:
+ return False, None
+
+ text_fields = self.schema["text_fields"]
+
+ # Validate title field
+ if "title" in text_fields:
+ title_field = config.get("title_field")
+ if title_field and (not hasattr(doc, title_field) or getattr(doc, title_field, None) is None):
+ self._warn_missing_title_field(doc.doctype, doc.name, title_field)
+ return False, None
+
+ # Validate content field
+ if "content" in text_fields:
+ content_field = config["content_field"]
+ if not hasattr(doc, content_field) or getattr(doc, content_field, None) is None:
+ self._warn_missing_content_field(doc.doctype, doc.name, content_field)
+ return False, None
+
+ return True, config
+
+ def _add_text_fields_to_document(self, document, doc, config):
+ """Populate text fields in the document for indexing."""
+ text_fields = self.schema["text_fields"]
+ title_field = config.get("title_field")
+ content_field = config["content_field"]
+
+ for field in text_fields:
+ if field == "title":
+ if title_field:
+ raw_title = getattr(doc, title_field, "") or ""
+ document["title"] = self._process_content(raw_title)
+ else:
+ document["title"] = "" # No title field configured
+ elif field == "content":
+ raw_content = getattr(doc, content_field, "") or ""
+ document["content"] = self._process_content(raw_content)
+ else:
+ # Handle other custom text fields
+ raw_text = getattr(doc, field, "")
+ document[field] = self._process_content(raw_text)
+
+ def _add_metadata_fields_to_document(self, document, doc, config):
+ """Populate metadata fields in the document for indexing."""
+ metadata_fields = self.schema["metadata_fields"]
+
+ for field in metadata_fields:
+ if field in document: # Skip already populated fields (id, doctype, name)
+ continue
+
+ if field == "modified":
+ modified_field = config["modified_field"]
+ modified_value = getattr(doc, modified_field, None)
+ if modified_value:
+ if not isinstance(modified_value, datetime.datetime):
+ modified_value = frappe.utils.get_datetime(modified_value)
+ document["modified"] = modified_value.timestamp()
+ continue
+
+ # Handle other metadata fields with potential mapping
+ field_mappings = config.get("field_mappings", {})
+ actual_field = field_mappings.get(field, field)
+ value = getattr(doc, actual_field, None)
+
+ # Convert Mock objects to strings to avoid database errors
+ if value is not None and hasattr(value, "_mock_name"):
+ value = str(value)
+
+ document[field] = value
+
+ def _process_content(self, content):
+ """Process content to remove HTML tags, links, and images for better indexing quality."""
+ if not content:
+ return ""
+
+ # Convert to string in case it's a Mock object or other type
+ content = str(content)
+
+ soup = BeautifulSoup(content, "html.parser")
+
+ # Extract text content from links before removing HTML tags
+ for link in soup.find_all("a"):
+ link_text = link.get_text().strip()
+ if link_text:
+ link.replace_with(link_text)
+ else:
+ link.replace_with("[link]")
+
+ text = soup.get_text(separator=" ").strip() # remove tags
+ text = re.sub(r"https?://[^\s]+", "[link]", text) # replace standalone links
+ text = re.sub(r"\s+", " ", text).strip() # normalize whitespace
+ return text
+
+ def _generate_trigrams(self, word):
+ """Generate trigrams for a word for fuzzy matching."""
+ word = f" {word.lower()} " # Add padding
+ return [word[i : i + 3] for i in range(len(word) - 2)]
+
+ def _print_warning_summary(self):
+ """Print a summary of warnings collected during indexing."""
+ if not self.warnings:
+ return
+
+ print("\n" + "=" * 60)
+ print("SEARCH INDEX BUILD WARNINGS")
+ print("=" * 60)
+
+ # Group warnings by type
+ warning_groups: dict[WarningType, list[IndexWarning]] = {}
+ for warning in self.warnings:
+ warning_groups.setdefault(warning.type, []).append(warning)
+
+ # Define display names for warning types
+ type_display_names = {
+ WarningType.INVALID_DOCUMENT: "Invalid Documents",
+ WarningType.MISSING_TEXT_FIELDS: "Missing Text Fields",
+ WarningType.MISSING_CONTENT_FIELD: "Missing Content Field",
+ WarningType.MISSING_TITLE_FIELD: "Missing Title Field",
+ WarningType.MISSING_DOCTYPE: "Missing Document Type",
+ WarningType.MISSING_NAME: "Missing Document Name",
+ WarningType.OTHER: "Other Issues",
+ }
+
+ # Print grouped warnings
+ for warning_type, warnings in warning_groups.items():
+ display_name = type_display_names.get(warning_type, warning_type.value.title())
+ print(f"\n{display_name} ({len(warnings)} warnings):")
+ print("-" * 50)
+
+ for warning in warnings[:5]: # Show first 5 warnings of each type
+ print(f" • {warning.message}")
+
+ if len(warnings) > 5:
+ print(f" ... and {len(warnings) - 5} more")
+
+ print(f"\nTotal warnings: {len(self.warnings)}")
+ print("=" * 60)
+
+ # Warning helper methods (utility functions)
+
+ def _add_warning(self, warning_type: WarningType, message: str, **kwargs):
+ """Add a structured warning to the warnings list."""
+ warning = IndexWarning(type=warning_type, message=message, **kwargs)
+ self.warnings.append(warning)
+
+ def _warn_invalid_document(self, doc: dict, reason: str):
+ """Add warning for invalid document."""
+ self._add_warning(
+ WarningType.INVALID_DOCUMENT,
+ f"Skipping document with {reason}: {doc}",
+ doctype=doc.get("doctype"),
+ docname=doc.get("name"),
+ )
+
+ def _warn_missing_text_fields(self, doctype: str, docname: str, missing_fields: list):
+ """Add warning for missing text fields."""
+ self._add_warning(
+ WarningType.MISSING_TEXT_FIELDS,
+ f"Document {doctype}:{docname} missing text fields: {missing_fields}",
+ doctype=doctype,
+ docname=docname,
+ missing_fields=missing_fields,
+ )
+
+ def _warn_missing_content_field(self, doctype: str, docname: str, field: str):
+ """Add warning for missing content field."""
+ self._add_warning(
+ WarningType.MISSING_CONTENT_FIELD,
+ f"Document {doctype}:{docname} missing content field '{field}'",
+ doctype=doctype,
+ docname=docname,
+ field=field,
+ )
+
+ def _warn_missing_title_field(self, doctype: str, docname: str, field: str):
+ """Add warning for missing title field."""
+ self._add_warning(
+ WarningType.MISSING_TITLE_FIELD,
+ f"Document {doctype}:{docname} missing title field '{field}'",
+ doctype=doctype,
+ docname=docname,
+ field=field,
+ )
+
+ def _warn_missing_doctype(self, doc: Any):
+ """Add warning for missing doctype."""
+ self._add_warning(
+ WarningType.MISSING_DOCTYPE,
+ f"Document missing doctype: {doc}",
+ docname=getattr(doc, "name", None),
+ )
+
+ def _warn_missing_name(self, doctype: str):
+ """Add warning for missing name."""
+ self._add_warning(WarningType.MISSING_NAME, f"Document missing name: {doctype}", doctype=doctype)
+
+ def get_warning_statistics(self) -> dict[str, Any]:
+ """Get warning statistics for programmatic use."""
+ if not self.warnings:
+ return {"total": 0, "by_type": {}}
+
+ stats = {"total": len(self.warnings), "by_type": {}}
+
+ for warning in self.warnings:
+ warning_type = warning.type.value
+ if warning_type not in stats["by_type"]:
+ stats["by_type"][warning_type] = {"count": 0, "examples": []}
+
+ stats["by_type"][warning_type]["count"] += 1
+
+ # Keep a few examples
+ if len(stats["by_type"][warning_type]["examples"]) < 3:
+ stats["by_type"][warning_type]["examples"].append(
+ {
+ "message": warning.message,
+ "doctype": warning.doctype,
+ "docname": warning.docname,
+ "field": warning.field,
+ "missing_fields": warning.missing_fields,
+ }
+ )
+
+ return stats
+
+
+# Module-level Functions for background tasks
+
+
+def build_index_if_not_exists():
+ """Build index if it doesn't exist."""
+ search_classes = get_search_classes()
+
+ for SearchClass in search_classes:
+ build_index(SearchClass, force=False)
+
+
+def build_index(
+ SearchClass: type[SQLiteSearch] | None = None, search_class_path: str | None = None, force: bool = False
+):
+ """Build search index for SearchClass"""
+ if not SearchClass and not search_class_path:
+ raise ValueError("Either SearchClass or search_class_path must be provided")
+
+ if search_class_path:
+ SearchClass = frappe.get_attr(search_class_path)
+
+ search = SearchClass()
+ if not search.is_search_enabled():
+ return
+ if not search.index_exists() or force:
+ print(f"{SearchClass.__name__}: Index does not exist, building...")
+ search.build_index()
+
+
+def build_index_in_background():
+ """Enqueue index building in background."""
+ search_classes = get_search_classes()
+ for SearchClass in search_classes:
+ search = SearchClass()
+ if not search.is_search_enabled():
+ return
+ search_class_path = f"{SearchClass.__module__}.{SearchClass.__name__}"
+ print(f"Enqueuing {search_class_path}.build_index")
+ frappe.enqueue(
+ "frappe.search.sqlite_search.build_index",
+ queue="long",
+ job_id=search_class_path,
+ deduplicate=True,
+ # build_index args
+ search_class_path=search_class_path,
+ force=True,
+ )
+
+
+def update_doc_index(doc: Document, method=None):
+ search_classes = get_search_classes()
+
+ for SearchClass in search_classes:
+ search = SearchClass()
+
+ if not (search.is_search_enabled() and search.index_exists()):
+ return
+
+ for doctype, config in search.doc_configs.items():
+ if doc.doctype == doctype:
+ fields = config.get("fields", [])
+ if not fields:
+ continue
+
+ any_field_changed = any(doc.has_value_changed(field) for field in fields)
+ if any_field_changed:
+ print(f"Enqueuing {search.__class__.__name__}.index_doc for {doc.doctype}:{doc.name}")
+ search.index_doc(doctype, doc.name)
+
+
+def delete_doc_index(doc: Document, method=None):
+ search_classes = get_search_classes()
+
+ for SearchClass in search_classes:
+ search = SearchClass()
+
+ if not (search.is_search_enabled() and search.index_exists()):
+ return
+
+ for doctype, config in search.doc_configs.items():
+ if doc.doctype == doctype:
+ fields = config.get("fields", [])
+ if not fields:
+ continue
+
+ print(f"Enqueuing {search.__class__.__name__}.remove_doc for {doc.doctype}:{doc.name}")
+ search.remove_doc(doctype, doc.name)
+
+
+def get_search_classes() -> list[type[SQLiteSearch]]:
+ module_paths = frappe.get_hooks("sqlite_search")
+ search_classes = [frappe.get_attr(path) for path in module_paths]
+
+ for search_class in search_classes:
+ # validate if search classes extend from SQLiteSearch
+ if not issubclass(search_class, SQLiteSearch):
+ raise TypeError(f"Search class {search_class.__name__} must extend SQLiteSearch")
+
+ return search_classes
diff --git a/frappe/templates/emails/user_invitation.html b/frappe/templates/emails/user_invitation.html
new file mode 100644
index 0000000000..d7928930fd
--- /dev/null
+++ b/frappe/templates/emails/user_invitation.html
@@ -0,0 +1,20 @@
+
+ {{ _("Hello,") }}
+
+
+ {{ _("You've been invited to join {0}.").format(title) }}
+
+
+ {{ _("Click below to get started:") }}
+
+
+ {{ _("Accept Invitation") }}
+
+
+ {{ _("If you have any questions, reach out to your system administrator.") }}
+
diff --git a/frappe/templates/emails/user_invitation_cancelled.html b/frappe/templates/emails/user_invitation_cancelled.html
new file mode 100644
index 0000000000..3b102c742c
--- /dev/null
+++ b/frappe/templates/emails/user_invitation_cancelled.html
@@ -0,0 +1,9 @@
+
+ {{ _("Hello,") }}
+
+
+ {{ _("Your invitation to join {0} has been cancelled by the site administrator.").format(title) }}
+
+
+ {{ _("If this was a mistake or you need access again, please reach out to your team.") }}
+
diff --git a/frappe/templates/emails/user_invitation_expired.html b/frappe/templates/emails/user_invitation_expired.html
new file mode 100644
index 0000000000..64e5200c97
--- /dev/null
+++ b/frappe/templates/emails/user_invitation_expired.html
@@ -0,0 +1,9 @@
+
+ {{ _("Hello,") }}
+
+
+ {{ _("Your invitation to join {0} has expired.").format(title) }}
+
+
+ {{ _("You can ask your team to resend the invitation if you'd still like to join.") }}
+
diff --git a/frappe/templates/includes/blog/blogger.html b/frappe/templates/includes/blog/blogger.html
deleted file mode 100644
index bc36501ddd..0000000000
--- a/frappe/templates/includes/blog/blogger.html
+++ /dev/null
@@ -1,14 +0,0 @@
-{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
-
-
- {{ avatar(full_name=blogger_info.full_name, image=blogger_info.avatar, size='avatar-large') }}
-
-
-
- {{ blogger_info.full_name }}
-
- {% if blogger_info.bio %}
- {{ blogger_info.bio }}
- {% endif %}
-
-
diff --git a/frappe/templates/includes/blog/hero.html b/frappe/templates/includes/blog/hero.html
deleted file mode 100644
index b8a39e5c9c..0000000000
--- a/frappe/templates/includes/blog/hero.html
+++ /dev/null
@@ -1,12 +0,0 @@
-{% if blog_title and not (form_dict.txt or form_dict.by) %}
-
-
-
- {{ blog_title }}
-
- {% if blog_introduction -%}
- {{ blog_introduction }}
- {%- endif %}
-
-
-{% endif %}
diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py
index f9ba40d7cb..33d5fddbf3 100644
--- a/frappe/templates/includes/comments/comments.py
+++ b/frappe/templates/includes/comments/comments.py
@@ -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 + "".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()})
diff --git a/frappe/templates/includes/likes/likes.html b/frappe/templates/includes/likes/likes.html
deleted file mode 100644
index 4a59ee75ed..0000000000
--- a/frappe/templates/includes/likes/likes.html
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
-
diff --git a/frappe/templates/includes/likes/likes.py b/frappe/templates/includes/likes/likes.py
deleted file mode 100644
index 884835c056..0000000000
--- a/frappe/templates/includes/likes/likes.py
+++ /dev/null
@@ -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"{content} {ref_doc_title}
"
- message = message + "".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
diff --git a/frappe/tests/test_api_v2.py b/frappe/tests/test_api_v2.py
index 54a802aa62..de1831729c 100644
--- a/frappe/tests/test_api_v2.py
+++ b/frappe/tests/test_api_v2.py
@@ -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}
diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py
index aaa6d79c6d..26306dab41 100644
--- a/frappe/tests/test_db_query.py
+++ b/frappe/tests/test_db_query.py
@@ -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):
diff --git a/frappe/tests/test_defaults.py b/frappe/tests/test_defaults.py
index a46195f2b5..11e51bf145 100644
--- a/frappe/tests/test_defaults.py
+++ b/frappe/tests/test_defaults.py
@@ -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")
diff --git a/frappe/tests/test_form_load.py b/frappe/tests/test_form_load.py
index 194ef28612..6cf69380a3 100644
--- a/frappe/tests/test_form_load.py
+++ b/frappe/tests/test_form_load.py
@@ -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]
diff --git a/frappe/tests/test_helpers.py b/frappe/tests/test_helpers.py
new file mode 100644
index 0000000000..e70593fe96
--- /dev/null
+++ b/frappe/tests/test_helpers.py
@@ -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()
diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py
index df802f8d07..dda684f143 100644
--- a/frappe/tests/test_permissions.py
+++ b/frappe/tests/test_permissions.py
@@ -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)
diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py
index c8e7f93a9c..c5a65f9cb5 100644
--- a/frappe/tests/test_query.py
+++ b/frappe/tests/test_query.py
@@ -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,
diff --git a/frappe/tests/test_sitemap.py b/frappe/tests/test_sitemap.py
index 8b5ae0e172..c71b5415b9 100644
--- a/frappe/tests/test_sitemap.py
+++ b/frappe/tests/test_sitemap.py
@@ -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" in xml)
self.assertTrue("/contact" in xml)
- self.assertTrue(blogs[0].route in xml)
diff --git a/frappe/tests/test_sqlite_search.py b/frappe/tests/test_sqlite_search.py
new file mode 100644
index 0000000000..be88901ebc
--- /dev/null
+++ b/frappe/tests/test_sqlite_search.py
@@ -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": "This is bold text with links and
line breaks.
",
+ }
+ )
+ 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("", result["content"])
+ self.assertNotIn("", result["content"])
+ self.assertIn("bold", result["content"])
+ self.assertNotIn(
+ "", 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)
diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py
index a06fa09eef..871edde18b 100644
--- a/frappe/tests/ui_test_helpers.py
+++ b/frappe/tests/ui_test_helpers.py
@@ -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
diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py
index 7cb5decb3b..c1362bb33b 100644
--- a/frappe/utils/__init__.py
+++ b/frappe/utils/__init__.py
@@ -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)
diff --git a/frappe/utils/password.py b/frappe/utils/password.py
index db3e0ff09d..0393cd69dd 100644
--- a/frappe/utils/password.py
+++ b/frappe/utils/password.py
@@ -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,
)
diff --git a/frappe/website/doctype/blog_category/README.md b/frappe/website/doctype/blog_category/README.md
deleted file mode 100644
index af14b5dc14..0000000000
--- a/frappe/website/doctype/blog_category/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Blog category.
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_category/blog_category.js b/frappe/website/doctype/blog_category/blog_category.js
deleted file mode 100644
index 193531c73c..0000000000
--- a/frappe/website/doctype/blog_category/blog_category.js
+++ /dev/null
@@ -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) {},
-});
diff --git a/frappe/website/doctype/blog_category/blog_category.json b/frappe/website/doctype/blog_category/blog_category.json
deleted file mode 100644
index db0aa29546..0000000000
--- a/frappe/website/doctype/blog_category/blog_category.json
+++ /dev/null
@@ -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
-}
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_category/blog_category.py b/frappe/website/doctype/blog_category/blog_category.py
deleted file mode 100644
index fac00370f6..0000000000
--- a/frappe/website/doctype/blog_category/blog_category.py
+++ /dev/null
@@ -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
diff --git a/frappe/website/doctype/blog_category/templates/blog_category.html b/frappe/website/doctype/blog_category/templates/blog_category.html
deleted file mode 100644
index 99817d7808..0000000000
--- a/frappe/website/doctype/blog_category/templates/blog_category.html
+++ /dev/null
@@ -1,9 +0,0 @@
-{% extends "templates/pages/blog.html" %}
-
-{% block title %}{{ title }}{% endblock %}
-
-{% block script %}
-
-{% endblock %}
diff --git a/frappe/website/doctype/blog_category/templates/blog_category_row.html b/frappe/website/doctype/blog_category/templates/blog_category_row.html
deleted file mode 100644
index 2b999819cb..0000000000
--- a/frappe/website/doctype/blog_category/templates/blog_category_row.html
+++ /dev/null
@@ -1,4 +0,0 @@
-
- {{ title }}
-
-
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_category/test_blog_category.py b/frappe/website/doctype/blog_category/test_blog_category.py
deleted file mode 100644
index 3d216c9324..0000000000
--- a/frappe/website/doctype/blog_category/test_blog_category.py
+++ /dev/null
@@ -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
diff --git a/frappe/website/doctype/blog_category/test_records.json b/frappe/website/doctype/blog_category/test_records.json
deleted file mode 100644
index 4bd4ac35b7..0000000000
--- a/frappe/website/doctype/blog_category/test_records.json
+++ /dev/null
@@ -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"
- }
-]
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_post/README.md b/frappe/website/doctype/blog_post/README.md
deleted file mode 100644
index 63d3c0f31e..0000000000
--- a/frappe/website/doctype/blog_post/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Blog post for "Blogs" section of website.
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_post/blog_post.js b/frappe/website/doctype/blog_post/blog_post.js
deleted file mode 100644
index 5f7268d074..0000000000
--- a/frappe/website/doctype/blog_post/blog_post.js
+++ /dev/null
@@ -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(`
-
-
-
- ${frappe.boot.sitename}
- › ${route_array.join(" › ")}
-
-
- ${seo_title}
-
-
- ${date} ${seo_description}
-
-
- `);
-}
diff --git a/frappe/website/doctype/blog_post/blog_post.json b/frappe/website/doctype/blog_post/blog_post.json
deleted file mode 100644
index 9de699e522..0000000000
--- a/frappe/website/doctype/blog_post/blog_post.json
+++ /dev/null
@@ -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
-}
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_post/blog_post.py b/frappe/website/doctype/blog_post/blog_post.py
deleted file mode 100644
index cc3b66ee8e..0000000000
--- a/frappe/website/doctype/blog_post/blog_post.py
+++ /dev/null
@@ -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
diff --git a/frappe/website/doctype/blog_post/blog_post_list.js b/frappe/website/doctype/blog_post/blog_post_list.js
deleted file mode 100644
index 0d617654ca..0000000000
--- a/frappe/website/doctype/blog_post/blog_post_list.js
+++ /dev/null
@@ -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"];
- }
- },
-};
diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html
deleted file mode 100644
index b5e2f8d4f8..0000000000
--- a/frappe/website/doctype/blog_post/templates/blog_post.html
+++ /dev/null
@@ -1,91 +0,0 @@
-{% extends "templates/web.html" %}
-
-{% block meta_block %}
- {% include "templates/includes/meta_block.html" %}
-{% endblock %}
-
-{% block page_content %}
-
-
-
-
-
- {{ title }}
-
- {{ blog_intro }}
-
-
-
- {%- if read_time -%}
- ·
- {{ read_time }} {{ _('min read') }}
- {%- endif -%}
-
-
-
-
- {{ content }}
-
-
-
- {%- 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 -%}
-
-
- {% if blogger_info %}
-
- {% include "templates/includes/blog/blogger.html" %}
- {% endif %}
-
- {% if not disable_comments %}
-
- {% include 'templates/includes/comments/comments.html' %}
-
- {% endif %}
-
-
-
-{% endblock %}
diff --git a/frappe/website/doctype/blog_post/templates/blog_post_list.html b/frappe/website/doctype/blog_post/templates/blog_post_list.html
deleted file mode 100644
index 0dc8f95ea8..0000000000
--- a/frappe/website/doctype/blog_post/templates/blog_post_list.html
+++ /dev/null
@@ -1,91 +0,0 @@
-{% extends "templates/web.html" %}
-{% block title %}{{ blog_title or _("Blog") }}{% endblock %}
-{% block hero %}{% endblock %}
-
-{% block page_content %}
-
-
-
-
-
- {{ blog_title or _('Blog') }}
- {{ blog_introduction or '' }}
-
-
-
- {%- if browse_by_category -%}
-
-
-
-
- {%- endif -%}
-
-
-
-
-
- {% if not result -%}
-
- {{ no_result_message or _("Nothing to show") }}
-
- {% else %}
-
- {% for item in result %}
- {{ item }}
- {% endfor %}
-
- {% endif %}
-
-
-
-{% endblock %}
-
-{% block script %}
-
-{% endblock %}
diff --git a/frappe/website/doctype/blog_post/templates/blog_post_row.html b/frappe/website/doctype/blog_post/templates/blog_post_row.html
deleted file mode 100644
index 91beeb12e9..0000000000
--- a/frappe/website/doctype/blog_post/templates/blog_post_row.html
+++ /dev/null
@@ -1,43 +0,0 @@
-{% from "frappe/templates/includes/avatar_macro.html" import avatar %}
-
-{%- set post = doc -%}
-
-
-
- {% if post.cover_image %}
-
- {% else %}
-
- {{ post.title }}
-
- {% endif %}
-
-
-
-
- {%- if post.featured -%}
- {{ _('Featured') }} ·
- {%- endif -%}
- {{ post.category.title }}
-
- {%- if post.featured -%}
- {{ post.title }}
- {%- else -%}
- {{ post.title }}
- {%- endif -%}
- {{ post.intro }}
-
-
-
-
-
-
diff --git a/frappe/website/doctype/blog_post/test_blog_post.py b/frappe/website/doctype/blog_post/test_blog_post.py
deleted file mode 100644
index 4d66938159..0000000000
--- a/frappe/website/doctype/blog_post/test_blog_post.py
+++ /dev/null
@@ -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(
- '' 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='spam',
- comment_by='spam',
- published=1,
- content='More spam content. spam 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('spam', 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()
diff --git a/frappe/website/doctype/blog_post/test_records.json b/frappe/website/doctype/blog_post/test_records.json
deleted file mode 100644
index 4b29eadfa4..0000000000
--- a/frappe/website/doctype/blog_post/test_records.json
+++ /dev/null
@@ -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
- }
-]
diff --git a/frappe/website/doctype/blog_settings/README.md b/frappe/website/doctype/blog_settings/README.md
deleted file mode 100644
index 0a76d4e261..0000000000
--- a/frappe/website/doctype/blog_settings/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Blog titles and introduction texts.
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_settings/blog_settings.js b/frappe/website/doctype/blog_settings/blog_settings.js
deleted file mode 100644
index 7be9b2c28b..0000000000
--- a/frappe/website/doctype/blog_settings/blog_settings.js
+++ /dev/null
@@ -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) {},
-});
diff --git a/frappe/website/doctype/blog_settings/blog_settings.json b/frappe/website/doctype/blog_settings/blog_settings.json
deleted file mode 100644
index be0dd749ed..0000000000
--- a/frappe/website/doctype/blog_settings/blog_settings.json
+++ /dev/null
@@ -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
-}
\ No newline at end of file
diff --git a/frappe/website/doctype/blog_settings/blog_settings.py b/frappe/website/doctype/blog_settings/blog_settings.py
deleted file mode 100644
index 2864eebc2c..0000000000
--- a/frappe/website/doctype/blog_settings/blog_settings.py
+++ /dev/null
@@ -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
diff --git a/frappe/website/doctype/blog_settings/test_blog_settings.py b/frappe/website/doctype/blog_settings/test_blog_settings.py
deleted file mode 100644
index 653f68c1bb..0000000000
--- a/frappe/website/doctype/blog_settings/test_blog_settings.py
+++ /dev/null
@@ -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
diff --git a/frappe/website/doctype/blogger/README.md b/frappe/website/doctype/blogger/README.md
deleted file mode 100644
index 994e686ad2..0000000000
--- a/frappe/website/doctype/blogger/README.md
+++ /dev/null
@@ -1 +0,0 @@
-User of blog writer in "Blog" section.
\ No newline at end of file
diff --git a/frappe/website/doctype/blogger/__init__.py b/frappe/website/doctype/blogger/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/website/doctype/blogger/blogger.js b/frappe/website/doctype/blogger/blogger.js
deleted file mode 100644
index 29afdb6298..0000000000
--- a/frappe/website/doctype/blogger/blogger.js
+++ /dev/null
@@ -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) {},
-});
diff --git a/frappe/website/doctype/blogger/blogger.json b/frappe/website/doctype/blogger/blogger.json
deleted file mode 100644
index cbc1dfe467..0000000000
--- a/frappe/website/doctype/blogger/blogger.json
+++ /dev/null
@@ -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
-}
\ No newline at end of file
diff --git a/frappe/website/doctype/blogger/blogger.py b/frappe/website/doctype/blogger/blogger.py
deleted file mode 100644
index 2bd7b75fe8..0000000000
--- a/frappe/website/doctype/blogger/blogger.py
+++ /dev/null
@@ -1,52 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: MIT. See LICENSE
-
-# License: MIT. See LICENSE
-
-import frappe
-from frappe import _
-from frappe.model.document import Document
-
-
-class Blogger(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
-
- avatar: DF.AttachImage | None
- bio: DF.SmallText | None
- disabled: DF.Check
- full_name: DF.Data
- short_name: DF.Data
- user: DF.Link | None
- # end: auto-generated types
-
- def validate(self):
- if self.user and not frappe.db.exists("User", self.user):
- # for data import
- frappe.get_doc(
- {"doctype": "User", "email": self.user, "first_name": self.user.split("@", 1)[0]}
- ).insert()
-
- def on_update(self):
- "if user is set, then update all older blogs"
-
- from frappe.website.doctype.blog_post.blog_post import clear_blog_cache
-
- clear_blog_cache()
-
- if self.user:
- for blog in frappe.db.sql_list(
- """select name from `tabBlog Post` where owner=%s
- and ifnull(blogger,'')=''""",
- self.user,
- ):
- b = frappe.get_doc("Blog Post", blog)
- b.blogger = self.name
- b.save()
-
- frappe.permissions.add_user_permission("Blogger", self.name, self.user)
diff --git a/frappe/website/doctype/blogger/test_blogger.py b/frappe/website/doctype/blogger/test_blogger.py
deleted file mode 100644
index c694c81314..0000000000
--- a/frappe/website/doctype/blogger/test_blogger.py
+++ /dev/null
@@ -1,4 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: MIT. See LICENSE
-
-import frappe
diff --git a/frappe/website/doctype/blogger/test_records.json b/frappe/website/doctype/blogger/test_records.json
deleted file mode 100644
index 4ccb7fed32..0000000000
--- a/frappe/website/doctype/blogger/test_records.json
+++ /dev/null
@@ -1,17 +0,0 @@
-[
- {
- "doctype": "Blogger",
- "full_name": "_Test Blogger",
- "short_name": "_Test Blogger"
- },
- {
- "doctype": "Blogger",
- "full_name": "_Test Blogger 1",
- "short_name": "_Test Blogger 1"
- },
- {
- "doctype": "Blogger",
- "full_name": "_Test Blogger 2",
- "short_name": "_Test Blogger 2"
- }
-]
\ No newline at end of file
diff --git a/frappe/website/doctype/website_route_meta/test_website_route_meta.py b/frappe/website/doctype/website_route_meta/test_website_route_meta.py
index 9008532075..18fabbff26 100644
--- a/frappe/website/doctype/website_route_meta/test_website_route_meta.py
+++ b/frappe/website/doctype/website_route_meta/test_website_route_meta.py
@@ -5,21 +5,21 @@ from frappe.tests import IntegrationTestCase
from frappe.utils import set_request
from frappe.website.serve import get_response
-EXTRA_TEST_RECORD_DEPENDENCIES = ["Blog Post"]
+EXTRA_TEST_RECORD_DEPENDENCIES = ["Web Page"]
class TestWebsiteRouteMeta(IntegrationTestCase):
def test_meta_tag_generation(self):
blogs = frappe.get_all(
- "Blog Post", fields=["name", "route"], filters={"published": 1, "route": ("!=", "")}, limit=1
+ "Web Page", fields=["name", "route"], filters={"published": 1, "route": ("!=", "")}, limit=1
)
blog = blogs[0]
# create meta tags for this route
doc = frappe.new_doc("Website Route Meta")
- doc.append("meta_tags", {"key": "type", "value": "blog_post"})
- doc.append("meta_tags", {"key": "og:title", "value": "My Blog"})
+ doc.append("meta_tags", {"key": "type", "value": "web_page"})
+ doc.append("meta_tags", {"key": "og:title", "value": "My Web Page"})
doc.name = blog.route
doc.insert()
@@ -31,8 +31,8 @@ class TestWebsiteRouteMeta(IntegrationTestCase):
html = self.normalize_html(response.get_data().decode())
- self.assertIn(self.normalize_html(""""""), html)
- self.assertIn(self.normalize_html(""""""), html)
+ self.assertIn(self.normalize_html(""""""), html)
+ self.assertIn(self.normalize_html(""""""), html)
def tearDown(self):
frappe.db.rollback()
diff --git a/frappe/website/web_template/hero/hero.html b/frappe/website/web_template/hero/hero.html
index 454a3a8f98..20c693656a 100644
--- a/frappe/website/web_template/hero/hero.html
+++ b/frappe/website/web_template/hero/hero.html
@@ -1,6 +1,8 @@
+ {%- if title -%}
{{ _(title) }}
+ {%- endif -%}
{%- if subtitle -%}
{{ _(subtitle) }}
diff --git a/frappe/website/web_template/hero_with_right_image/hero_with_right_image.html b/frappe/website/web_template/hero_with_right_image/hero_with_right_image.html
index 69a9fff702..d761d542af 100644
--- a/frappe/website/web_template/hero_with_right_image/hero_with_right_image.html
+++ b/frappe/website/web_template/hero_with_right_image/hero_with_right_image.html
@@ -1,9 +1,11 @@
+ {%- if title -%}
{{ _(title) }}
+ {%- endif -%}
{%- if subtitle -%}
{{ _(subtitle) }}
diff --git a/frappe/website/web_template/section_with_collapsible_content/section_with_collapsible_content.html b/frappe/website/web_template/section_with_collapsible_content/section_with_collapsible_content.html
index a2edf5b549..85b9ed89ac 100644
--- a/frappe/website/web_template/section_with_collapsible_content/section_with_collapsible_content.html
+++ b/frappe/website/web_template/section_with_collapsible_content/section_with_collapsible_content.html
@@ -1,5 +1,7 @@
+ {%- if title -%}
{{ _(title) }}
+ {%- endif -%}
{%- if subtitle -%}
{{ _(subtitle) }}
{%- endif -%}
diff --git a/frappe/website/web_template/section_with_cta/section_with_cta.html b/frappe/website/web_template/section_with_cta/section_with_cta.html
index f59dbc5e66..08a153095a 100644
--- a/frappe/website/web_template/section_with_cta/section_with_cta.html
+++ b/frappe/website/web_template/section_with_cta/section_with_cta.html
@@ -1,6 +1,8 @@
+ {%- if title -%}
{{ _(title) }}
+ {%- endif -%}
{%- if subtitle -%}
{{ _(subtitle) }}
{%- endif -%}
diff --git a/frappe/website/web_template/section_with_embed/section_with_embed.html b/frappe/website/web_template/section_with_embed/section_with_embed.html
index daf253afb2..f2e89ca493 100644
--- a/frappe/website/web_template/section_with_embed/section_with_embed.html
+++ b/frappe/website/web_template/section_with_embed/section_with_embed.html
@@ -1,5 +1,7 @@