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 + +
+ +

Workflow Task

+

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. + +![user invitation hooks example](./user_invitation_hooks_example.png) + +- `only_for` + + Roles that are allowed to invite users to your app. + +- `allowed_roles` + + Roles that are allowed to be invited to your app. + +- `after_accept` + + Dot path of the function to execute after the user accepts the invitation. + + ```python + from frappe.model.document import Document + + def after_accept( + invitation: Document, + user: Document, + user_inserted: bool + ) -> None: + # your business logic here + ``` + +> `after_accept` is optional and should be used only if required. + +At this point, you can start using the whitelisted functions under the `apis` section (`frappe/core/api/user_invitation.py`). For more information, read [whitelisted functions](#whitelisted-functions). + +By default, only `System Manager`s can create a new invitation, view the list of invitations, or view more details associated with a single invitation **using the desk**. To enable users with specific roles to perform the mentioned actions, you might want to provide `create`, `read`, and `write` access to the relevant roles. + +Example - If a user having the `Agent Manager` role should be able to use all of the user invitation features using the desk, these should be enabled: + +- User Invitation doctype + ![user invitation doctype's role permissions manager entry](./user_invitation_doc_role_permissions_manager.png) + +- Role doctype + ![role doctype's role permissions manager entry](./role_doc_role_permissions_manager.png) + +## Whitelisted functions + +There are a few whitelisted functions that can be used to manage invitations. All of the whitelisted functions are in `frappe/core/api/user_invitation.py`. + +### `invite_by_email` + +Invite new emails to your application. + +![invite by email api example](./invite_by_email_api_example.png) + +> The invited email will receive an email with a link to accept the invitation. + +### `accept_invitation` + +Enables invitees to accept the sent invitations. + +> This function should not be used directly. The only reason this function is whitelisted is because the sent invitations contain a link that the invitees use to accept the invitations. + +### `get_pending_invitations` + +Get all of the pending invitations associated with an installed Framework application. + +![get pending invitations api example](./get_pending_invitations_api_example.png) + +### `cancel_invitation` + +Cancels a specific pending invitation associated with an installed Framework application. + +![cancel invitation api example](./cancel_invitation_api_example.png) + +## Normal flow + +1. Invitations are created from the desk or by using the [`invite_by_email`](#invite_by_email) whitelisted function. An email is sent to the invited email with a link to accept the invitation. +2. The app administrator or anyone able to use the desk can cancel invitations. Once an invitation is cancelled, an email is sent to the creator of the invitation. +3. Once the invitation is accepted, a new user is created (if required) with the roles specified in the invitation and is redirected to the specified path. +4. If the invitee doesn't accept the invitation within three days, the invitation is marked as expired by a background job that executes every day. Currently, there is no way to customize the expiration time. + +## Important points: + +- There can't be multiple pending invitations for the same app. +- Once an invitation document is created from Desk, all of the fields are immutable except the `Redirect To Path` field which is mutable only when the invitation status is `Pending`. +- To manually mark an invitation as expired, you can use the `expire` method on the invitation document. +- To manually cancel an invitation, you can use the `cancel_invite` method on the invitation document. 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/", "to_route": "Blog Post"}, {"from_route": "/kb/", "to_route": "Help Article"}, {"from_route": "/profile", "to_route": "me"}, {"from_route": "/app/", "to_route": "app"}, @@ -111,6 +110,7 @@ permission_query_conditions = { "Workflow Action": "frappe.workflow.doctype.workflow_action.workflow_action.get_permission_query_conditions", "Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.get_permission_query_condition", "File": "frappe.core.doctype.file.file.get_permission_query_conditions", + "User Invitation": "frappe.core.doctype.user_invitation.user_invitation.get_permission_query_conditions", } has_permission = { @@ -128,6 +128,7 @@ has_permission = { "File": "frappe.core.doctype.file.file.has_permission", "Prepared Report": "frappe.core.doctype.prepared_report.prepared_report.has_permission", "Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.has_permission", + "User Invitation": "frappe.core.doctype.user_invitation.user_invitation.has_permission", } has_website_permission = {"Address": "frappe.contacts.doctype.address.address.has_website_permission"} @@ -153,6 +154,7 @@ doc_events = { "frappe.automation.doctype.assignment_rule.assignment_rule.update_due_date", "frappe.core.doctype.user_type.user_type.apply_permissions_for_non_standard_user_type", "frappe.core.doctype.permission_log.permission_log.make_perm_log", + "frappe.search.sqlite_search.update_doc_index", ], "after_rename": "frappe.desk.notifications.clear_doctype_notifications", "on_cancel": [ @@ -163,6 +165,7 @@ doc_events = { "on_trash": [ "frappe.desk.notifications.clear_doctype_notifications", "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", + "frappe.search.sqlite_search.delete_doc_index", ], "on_update_after_submit": [ "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", @@ -205,6 +208,7 @@ scheduler_events = { "frappe.deferred_insert.save_to_db", "frappe.automation.doctype.reminder.reminder.send_reminders", "frappe.model.utils.link_count.update_link_count", + "frappe.search.sqlite_search.build_index_if_not_exists", ], # 10 minutes "0/10 * * * *": [ @@ -248,6 +252,7 @@ scheduler_events = { "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record", "frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry", "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", + "frappe.core.doctype.user_invitation.user_invitation.mark_expired_invitations", ], "weekly_long": [ "frappe.desk.form.document_follow.send_weekly_updates", @@ -276,7 +281,10 @@ setup_wizard_exception = [ ] before_migrate = ["frappe.core.doctype.patch_log.patch_log.before_migrate"] -after_migrate = ["frappe.website.doctype.website_theme.website_theme.after_migrate"] +after_migrate = [ + "frappe.website.doctype.website_theme.website_theme.after_migrate", + "frappe.search.sqlite_search.build_index_in_background", +] otp_methods = ["OTP App", "Email", "SMS"] @@ -352,7 +360,6 @@ global_search_doctypes = { {"doctype": "ToDo"}, {"doctype": "Note"}, {"doctype": "Event"}, - {"doctype": "Blog Post"}, {"doctype": "Dashboard"}, {"doctype": "Country"}, {"doctype": "Currency"}, @@ -566,3 +573,7 @@ persistent_cache_keys = [ "rate-limit-counter-*", "rl:*", ] + +user_invitation = { + "only_for": ["System Manager"], +} diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py index 31ba3d0963..85bf3b4af9 100644 --- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py @@ -613,7 +613,7 @@ class Test_OpenLDAP(LDAP_TestCase, TestCase): "ldap_group": "Administrators", "erpnext_role": "System Manager", }, - {"doctype": "LDAP Group Mapping", "ldap_group": "Users", "erpnext_role": "Blogger"}, + {"doctype": "LDAP Group Mapping", "ldap_group": "Users", "erpnext_role": "Website Manager"}, {"doctype": "LDAP Group Mapping", "ldap_group": "Group3", "erpnext_role": "Accounts User"}, ] LDAP_USERNAME_FIELD = "uid" @@ -637,7 +637,7 @@ class Test_ActiveDirectory(LDAP_TestCase, TestCase): "ldap_group": "Domain Administrators", "erpnext_role": "System Manager", }, - {"doctype": "LDAP Group Mapping", "ldap_group": "Domain Users", "erpnext_role": "Blogger"}, + {"doctype": "LDAP Group Mapping", "ldap_group": "Domain Users", "erpnext_role": "Website Manager"}, { "doctype": "LDAP Group Mapping", "ldap_group": "Enterprise Administrators", diff --git a/frappe/integrations/doctype/webhook/webhook.json b/frappe/integrations/doctype/webhook/webhook.json index 5b8d277753..be71730a57 100644 --- a/frappe/integrations/doctype/webhook/webhook.json +++ b/frappe/integrations/doctype/webhook/webhook.json @@ -56,7 +56,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Doc Event", - "options": "after_insert\non_update\non_submit\non_cancel\non_trash\non_update_after_submit\non_change", + "options": "after_insert\non_update\non_submit\non_cancel\non_trash\non_update_after_submit\non_change\nworkflow_transition", "set_only_once": 1 }, { @@ -189,7 +189,7 @@ "link_fieldname": "webhook" } ], - "modified": "2024-10-28 12:21:52.172428", + "modified": "2025-07-18 18:22:38.276809", "modified_by": "Administrator", "module": "Integrations", "name": "Webhook", diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index e1741f3b56..b20ed27ad8 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -49,6 +49,7 @@ class Webhook(Document): "on_trash", "on_update_after_submit", "on_change", + "workflow_transition", ] webhook_doctype: DF.Link webhook_headers: DF.Table[WebhookHeader] @@ -68,6 +69,9 @@ class Webhook(Document): def on_update(self): frappe.client_cache.delete_value("webhooks") + def execute_for_doc(self, doc: Document): + enqueue_webhook(doc, self) + def validate_docevent(self): if self.webhook_doctype: is_submittable = frappe.get_value("DocType", self.webhook_doctype, "is_submittable") @@ -144,7 +148,8 @@ def get_context(doc): def enqueue_webhook(doc, webhook) -> None: request_url = headers = data = r = None try: - webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name")) + if not isinstance(webhook, Document): + webhook: Webhook = frappe.get_doc("Webhook", webhook.get("name")) request_url = webhook.request_url if webhook.is_dynamic_url: request_url = frappe.render_template(webhook.request_url, get_context(doc)) @@ -180,6 +185,9 @@ def enqueue_webhook(doc, webhook) -> None: sleep(3 * i + 1) if i != 2: continue + else: + if webhook.webhook_docevent == "workflow_transition": + raise e def log_request( diff --git a/frappe/locale/fa.po b/frappe/locale/fa.po index 616b454ac2..b4a22e3b5d 100644 --- a/frappe/locale/fa.po +++ b/frappe/locale/fa.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-07-20 09:35+0000\n" -"PO-Revision-Date: 2025-07-22 21:46\n" +"PO-Revision-Date: 2025-07-26 22:36\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Persian\n" "MIME-Version: 1.0\n" @@ -22765,7 +22765,7 @@ msgstr "فیلدها را انتخاب کنید" #: frappe/public/js/frappe/data_import/data_exporter.js:147 msgid "Select Fields To Insert" -msgstr "فیلدهایی را برای درج انتخاب کنید" +msgstr "انتخاب فیلدها برای درج" #: frappe/public/js/frappe/data_import/data_exporter.js:148 msgid "Select Fields To Update" @@ -27753,25 +27753,25 @@ msgstr "از % برای هر مقدار غیر خالی استفاده کنید. #. Label of the ascii_encode_password (Check) field in DocType 'Email Account' #: frappe/email/doctype/email_account/email_account.json msgid "Use ASCII encoding for password" -msgstr "از رمزگذاری ASCII برای گذرواژه استفاده کنید" +msgstr "استفاده از رمزگذاری ASCII برای گذرواژه" #. Label of the use_first_day_of_period (Check) field in DocType 'Auto Email #. Report' #: frappe/email/doctype/auto_email_report/auto_email_report.json msgid "Use First Day of Period" -msgstr "از اولین روز دوره استفاده کنید" +msgstr "استفاده از اولین روز دوره" #. Label of the use_html (Check) field in DocType 'Email Template' #: frappe/email/doctype/email_template/email_template.json msgid "Use HTML" -msgstr "از HTML استفاده کنید" +msgstr "استفاده از HTML" #. Label of the use_imap (Check) field in DocType 'Email Account' #. Label of the use_imap (Check) field in DocType 'Email Domain' #: frappe/email/doctype/email_account/email_account.json #: frappe/email/doctype/email_domain/email_domain.json msgid "Use IMAP" -msgstr "از IMAP استفاده کنید" +msgstr "استفاده از IMAP" #. Label of the use_number_format_from_currency (Check) field in DocType #. 'System Settings' @@ -27796,21 +27796,21 @@ msgstr "استفاده از نمودار گزارش" #: frappe/email/doctype/email_account/email_account.json #: frappe/email/doctype/email_domain/email_domain.json msgid "Use SSL" -msgstr "از SSL استفاده کنید" +msgstr "استفاده از SSL" #. Label of the use_starttls (Check) field in DocType 'Email Account' #. Label of the use_starttls (Check) field in DocType 'Email Domain' #: frappe/email/doctype/email_account/email_account.json #: frappe/email/doctype/email_domain/email_domain.json msgid "Use STARTTLS" -msgstr "از STARTTLS استفاده کنید" +msgstr "استفاده از STARTTLS" #. Label of the use_tls (Check) field in DocType 'Email Account' #. Label of the use_tls (Check) field in DocType 'Email Domain' #: frappe/email/doctype/email_account/email_account.json #: frappe/email/doctype/email_domain/email_domain.json msgid "Use TLS" -msgstr "از TLS استفاده کنید" +msgstr "استفاده از TLS" #: frappe/utils/password_strength.py:44 msgid "Use a few words, avoid common phrases." @@ -29168,7 +29168,7 @@ msgstr "عمل گردش کار" #. Description of a DocType #: frappe/workflow/doctype/workflow_action_master/workflow_action_master.json msgid "Workflow Action Master" -msgstr "استاد اکشن گردش کار" +msgstr "مدیر اکشن گردش کار" #. Label of the workflow_action_name (Data) field in DocType 'Workflow Action #. Master' @@ -29939,7 +29939,7 @@ msgstr "" #: frappe/core/doctype/prepared_report/prepared_report.js:57 msgid "Your CSV file is being generated and will appear in the Attachments section once ready. Additionally, you will get notified when the file is available for download." -msgstr "" +msgstr "فایل CSV شما در حال تولید است و به محض آماده شدن در بخش پیوست‌ها نمایش داده خواهد شد. علاوه بر این، هنگامی که فایل برای دانلود در دسترس قرار گرفت، به شما اطلاع داده خواهد شد." #: frappe/desk/page/setup_wizard/setup_wizard.js:397 msgid "Your Country" @@ -30130,7 +30130,7 @@ msgstr "ایجاد كردن" #. Option for the 'Indicator Color' (Select) field in DocType 'Workspace' #: frappe/desk/doctype/workspace/workspace.json msgid "cyan" -msgstr "فیروزه ای" +msgstr "فیروزه‌ای" #: frappe/public/js/frappe/form/controls/duration.js:218 #: frappe/public/js/frappe/utils/utils.js:1119 @@ -30318,7 +30318,7 @@ msgstr "آیکون" #. Inspector' #: frappe/core/doctype/permission_inspector/permission_inspector.json msgid "import" -msgstr "درون‌ریزی" +msgstr "درون‌بُرد" #. Description of the 'Read Time' (Int) field in DocType 'Blog Post' #: frappe/website/doctype/blog_post/blog_post.json @@ -30693,7 +30693,7 @@ msgstr "از طریق قانون واگذاری" #: frappe/automation/doctype/auto_repeat/auto_repeat.py:242 msgid "via Auto Repeat" -msgstr "" +msgstr "از طریق تکرار خودکار" #: frappe/core/doctype/data_import/importer.py:271 #: frappe/core/doctype/data_import/importer.py:292 @@ -30985,7 +30985,7 @@ msgstr "{0} این را ایجاد کرد" #: frappe/public/js/frappe/form/footer/version_timeline_content_builder.js:250 msgctxt "Form timeline" msgid "{0} created this document {1}" -msgstr "" +msgstr "{0} این سند را ایجاد کرد {1}" #: frappe/public/js/frappe/utils/pretty_date.js:33 msgid "{0} d" diff --git a/frappe/locale/id.po b/frappe/locale/id.po index 13e133e83b..69f71893f1 100644 --- a/frappe/locale/id.po +++ b/frappe/locale/id.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-07-20 09:35+0000\n" -"PO-Revision-Date: 2025-07-22 21:46\n" +"PO-Revision-Date: 2025-07-25 22:40\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Indonesian\n" "MIME-Version: 1.0\n" @@ -14087,7 +14087,7 @@ msgstr "" #: frappe/public/js/frappe/ui/filters/filter.js:652 msgid "Last 6 Months" -msgstr "" +msgstr "6 Bulan Terakhir" #: frappe/public/js/frappe/ui/filters/filter.js:624 msgid "Last 7 Days" @@ -16521,7 +16521,7 @@ msgstr "" #: frappe/public/js/frappe/ui/filters/filter.js:704 msgid "Next 6 Months" -msgstr "" +msgstr "6 Bulan ke Depan" #: frappe/public/js/frappe/ui/filters/filter.js:680 msgid "Next 7 Days" @@ -16550,11 +16550,11 @@ msgstr "" #: frappe/public/js/frappe/ui/filters/filter.js:696 msgid "Next Month" -msgstr "" +msgstr "Bulan Depan" #: frappe/public/js/frappe/ui/filters/filter.js:700 msgid "Next Quarter" -msgstr "" +msgstr "Kuartal Depan" #. Label of the next_schedule_date (Date) field in DocType 'Auto Repeat' #: frappe/automation/doctype/auto_repeat/auto_repeat.json @@ -16584,11 +16584,11 @@ msgstr "" #: frappe/public/js/frappe/ui/filters/filter.js:692 msgid "Next Week" -msgstr "" +msgstr "Minggu Depan" #: frappe/public/js/frappe/ui/filters/filter.js:708 msgid "Next Year" -msgstr "" +msgstr "Tahun Depan" #: frappe/public/js/frappe/form/workflow.js:45 msgid "Next actions" @@ -26071,19 +26071,19 @@ msgstr "Papan Kanban ini akan menjadi pribadi" #: frappe/public/js/frappe/ui/filters/filter.js:666 msgid "This Month" -msgstr "" +msgstr "Bulan Ini" #: frappe/public/js/frappe/ui/filters/filter.js:670 msgid "This Quarter" -msgstr "" +msgstr "Kuartal Ini" #: frappe/public/js/frappe/ui/filters/filter.js:662 msgid "This Week" -msgstr "" +msgstr "Minggu Ini" #: frappe/public/js/frappe/ui/filters/filter.js:674 msgid "This Year" -msgstr "" +msgstr "Tahun Ini" #: frappe/custom/doctype/customize_form/customize_form.js:220 msgid "This action is irreversible. Do you wish to continue?" @@ -26789,7 +26789,7 @@ msgstr "Token hilang" #: frappe/public/js/frappe/ui/filters/filter.js:739 msgid "Tomorrow" -msgstr "" +msgstr "Besok" #: frappe/desk/doctype/bulk_update/bulk_update.py:68 #: frappe/model/workflow.py:254 @@ -29474,7 +29474,7 @@ msgstr "Ya" #: frappe/public/js/frappe/ui/filters/filter.js:727 msgid "Yesterday" -msgstr "" +msgstr "Kemarin" #: frappe/public/js/frappe/utils/user.js:33 msgctxt "Name of the current user. For example: You edited this 5 hours ago." @@ -31412,7 +31412,7 @@ msgstr "{0} minggu yang lalu" #: frappe/public/js/frappe/utils/pretty_date.js:39 msgid "{0} y" -msgstr "{0} t" +msgstr "" #: frappe/public/js/frappe/utils/pretty_date.js:72 msgid "{0} years ago" diff --git a/frappe/locale/sr.po b/frappe/locale/sr.po index 2d0d68650b..ac1562971f 100644 --- a/frappe/locale/sr.po +++ b/frappe/locale/sr.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-07-20 09:35+0000\n" -"PO-Revision-Date: 2025-07-21 21:50\n" +"PO-Revision-Date: 2025-07-23 21:53\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Serbian (Cyrillic)\n" "MIME-Version: 1.0\n" @@ -11910,7 +11910,7 @@ msgstr "Сакривена поља" #: frappe/public/js/frappe/views/reports/query_report.js:1641 msgid "Hidden columns include: {0}" -msgstr "" +msgstr "Сакривене колоне укључују: {0}" #. Option for the 'Page Number' (Select) field in DocType 'Print Format' #: frappe/printing/doctype/print_format/print_format.json @@ -12863,7 +12863,7 @@ msgstr "Укључи филтере" #: frappe/public/js/frappe/views/reports/query_report.js:1639 msgid "Include hidden columns" -msgstr "" +msgstr "Укључи сакривене колоне" #: frappe/public/js/frappe/views/reports/query_report.js:1611 msgid "Include indentation" @@ -27320,7 +27320,7 @@ msgstr "Преводи" #. Name of a role #: frappe/core/doctype/translation/translation.json msgid "Translator" -msgstr "" +msgstr "Преводилац" #. Option for the 'Email Status' (Select) field in DocType 'Communication' #: frappe/core/doctype/communication/communication.json diff --git a/frappe/locale/sr_CS.po b/frappe/locale/sr_CS.po index 90f6b66b1f..230c038287 100644 --- a/frappe/locale/sr_CS.po +++ b/frappe/locale/sr_CS.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: developers@frappe.io\n" "POT-Creation-Date: 2025-07-20 09:35+0000\n" -"PO-Revision-Date: 2025-07-21 21:50\n" +"PO-Revision-Date: 2025-07-23 21:53\n" "Last-Translator: developers@frappe.io\n" "Language-Team: Serbian (Latin)\n" "MIME-Version: 1.0\n" @@ -11911,7 +11911,7 @@ msgstr "Sakrivena polja" #: frappe/public/js/frappe/views/reports/query_report.js:1641 msgid "Hidden columns include: {0}" -msgstr "" +msgstr "Sakrivene kolone uključuju: {0}" #. Option for the 'Page Number' (Select) field in DocType 'Print Format' #: frappe/printing/doctype/print_format/print_format.json @@ -12864,7 +12864,7 @@ msgstr "Uključi filtere" #: frappe/public/js/frappe/views/reports/query_report.js:1639 msgid "Include hidden columns" -msgstr "" +msgstr "Uključi sakrivene kolone" #: frappe/public/js/frappe/views/reports/query_report.js:1611 msgid "Include indentation" @@ -27321,7 +27321,7 @@ msgstr "Prevodi" #. Name of a role #: frappe/core/doctype/translation/translation.json msgid "Translator" -msgstr "" +msgstr "Prevodilac" #. Option for the 'Email Status' (Select) field in DocType 'Communication' #: frappe/core/doctype/communication/communication.json diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 69eafe6c6b..f1198f22cc 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -14,6 +14,9 @@ if TYPE_CHECKING: from frappe.workflow.doctype.workflow.workflow import Workflow +DEFAULT_WORKFLOW_TASKS = ["Webhook", "Server Script"] + + class WorkflowStateError(frappe.ValidationError): pass @@ -126,6 +129,59 @@ def apply_workflow(doc, action): if next_state.update_field: doc.set(next_state.update_field, next_state.update_value) + if transition.transition_tasks: + workflow_transitions = frappe.db.get_all( + "Workflow Transition Task", + {"parent": transition.transition_tasks, "enabled": True}, + ["task", "link", "asynchronous"], + order_by="idx", + ) + + """app-specific actions defined by the user + Example: + def create_customer(doc): + + + this goes in the hooks.py + workflow_methods = [{"name": "Create a customer", "method": + "frappe.dotted.path.create_customer"}] + """ + + tasks = {i["name"]: i["method"] for i in frappe.get_hooks("workflow_methods")} + + sync_tasks = [] + async_tasks = [] + for workflow_transition in workflow_transitions: + # edge-case with user-defined server scripts + if workflow_transition.task in DEFAULT_WORKFLOW_TASKS: + match workflow_transition.task: + case "Webhook": + webhook = frappe.get_doc("Webhook", workflow_transition.link) + task_method = webhook.execute_for_doc + + case "Server Script": + server_script = frappe.get_doc("Server Script", workflow_transition.link) + task_method = server_script.execute_workflow_task + + else: # normal app-defined tasks + try: + task_method = frappe.get_attr(tasks[workflow_transition.task]) + except KeyError: + frappe.throw(_('There is no task called "{}"').format(workflow_transition.task)) + + if workflow_transition.asynchronous: + async_tasks.append(task_method) + else: + sync_tasks.append(task_method) + + # will execute in the same transaction as the rest of the transition + for sync_task in sync_tasks: + sync_task(doc) + + # will spawn separate background jobs. Use for asynchronous, optional tasks. + for async_task in async_tasks: + frappe.enqueue(async_task, doc=doc, enqueue_after_commit=True) + new_docstatus = DocStatus(next_state.doc_status or 0) if doc.docstatus.is_draft() and new_docstatus.is_draft(): doc.save() diff --git a/frappe/patches/v16_0/add_module_deprecation_warning.py b/frappe/patches/v16_0/add_module_deprecation_warning.py index 3bd105a141..fa0ee5fda1 100644 --- a/frappe/patches/v16_0/add_module_deprecation_warning.py +++ b/frappe/patches/v16_0/add_module_deprecation_warning.py @@ -6,6 +6,7 @@ def execute(): "Social Module/ Energy Points System": ("eps", "system"), "Offsite Backup Integrations (Google Drive, S3, Dropbox)": ("offsite_backups", "intergration"), "Newsletter": ("newsletter", "functionality"), + "Blogs": ("blogs", "functionality"), } for module, (app, system_type) in module_app_map.items(): click.secho( diff --git a/frappe/public/js/billing.bundle.js b/frappe/public/js/billing.bundle.js index bf0b95cfa6..6c366e6055 100644 --- a/frappe/public/js/billing.bundle.js +++ b/frappe/public/js/billing.bundle.js @@ -4,7 +4,7 @@ let isFCUser = false; $(document).ready(function () { if ( frappe.boot.is_fc_site && - frappe.boot.setup_complete === 1 && + !!frappe.boot.setup_complete && !frappe.is_mobile() && frappe.user.has_role("System Manager") ) { diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 651e282989..7dff1236f5 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -1743,7 +1743,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { row.is_total_row = true; return row; }, {}); - + if (!totalRow?.currency && rows[0]?.currency) { + totalRow.currency = rows[0].currency; + } rows.push(totalRow); } return rows; diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 5d32026171..4f9aada570 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -61,11 +61,15 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { } setup_events() { + const me = this; if (this.list_view_settings?.disable_auto_refresh) { return; } frappe.realtime.doctype_subscribe(this.doctype); frappe.realtime.on("list_update", (data) => this.on_update(data)); + this.page.actions_btn_group.on("show.bs.dropdown", () => { + me.toggle_workflow_actions(); + }); } setup_page() { diff --git a/frappe/public/js/workflow_builder/components/Properties.vue b/frappe/public/js/workflow_builder/components/Properties.vue index 8f14d18ba5..d63badbf3e 100644 --- a/frappe/public/js/workflow_builder/components/Properties.vue +++ b/frappe/public/js/workflow_builder/components/Properties.vue @@ -18,7 +18,7 @@ let properties = computed(() => { if (store.workflow.selected && "action" in store.workflow.selected.data) { title.value = __("Transition Properties"); return store.transitionfields.filter((df) => - ["action", "allowed", "allow_self_approval", "action_confirm", "condition"].includes(df.fieldname) + ["action", "allowed", "allow_self_approval", "enable_action_confirmation", "condition", "transition_tasks"].includes(df.fieldname) ); } else if (store.workflow.selected && "state" in store.workflow.selected.data) { title.value = __("State Properties"); diff --git a/frappe/public/scss/website/blog.scss b/frappe/public/scss/website/blog.scss deleted file mode 100644 index 15cabf9192..0000000000 --- a/frappe/public/scss/website/blog.scss +++ /dev/null @@ -1,138 +0,0 @@ -:root { - --comment-timeline-bottom: 60px; - --comment-timeline-top: 8px; -} - -.blog-list { - display: flex; - flex-wrap: wrap; - margin-right: -15px; - margin-left: -15px; - - &.result { - border-bottom: none; - } -} - -.blog-list-content { - margin-bottom: 3rem; -} - -.blog-card { - margin-bottom: 2rem; - position: relative; - width: 100%; - - .card { - border: 1px solid var(--border-color); - } - - .card-body { - display: flex; - flex-direction: column; - justify-content: space-between; - } - - .card-img-top { - width: 100%; - overflow: hidden; - height: 12rem; - - img { - min-height: 12rem; - min-width: 100%; - object-fit: cover; - } - - .default-cover { - height: 100%; - width: 100%; - padding: 1rem; - display: flex; - align-items: center; - justify-content: center; - background: $gray-200; - - font-size: 1.2rem; - font-weight: 500; - color: $gray-600; - } - } - - .blog-card-footer { - display: flex; - align-items: top; - margin-top: 0.5rem; - - .avatar { - margin-top: 0.4rem; - margin-right: 0.5rem; - } - } -} - -.blog-container { - font-size: 1rem; - max-width: 800px; - margin: 0px auto; - - .blog-title { - margin-top: 1rem; - - @include media-breakpoint-up(xl) { - line-height: 1; - font-size: $font-size-4xl; - } - } - - .blog-footer { - display: flex; - justify-content: space-between; - color: $text-muted; - margin-top: 3rem; - } - - .blog-intro { - font-size: 1.125rem; - font-weight: 400; - } - - .blog-content { - margin-bottom: 1rem; - - .blog-header { - margin-bottom: 3rem; - margin-top: 5rem; - } - .from-markdown a { - text-decoration: underline; - } - } - - .blog-comments { - margin-top: 1rem; - margin-bottom: 5rem; - } - - .feedback-item svg { - vertical-align: sub; - } - - .blog-feedback { - display: inline-flex; - .like-icon { - cursor: pointer; - - use { - stroke: var(--gray-800); - --icon-stroke: transparent; - } - } - .like-icon.liked { - use { - stroke: var(--gray-800); - --icon-stroke: var(--red-500); - } - } - } -} diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss index f1dc450859..fc02a7b891 100644 --- a/frappe/public/scss/website/index.scss +++ b/frappe/public/scss/website/index.scss @@ -21,7 +21,6 @@ @import "website_avatar"; @import "web_form"; @import "page_builder"; -@import "blog"; @import "markdown"; @import "sidebar"; @import "portal"; diff --git a/frappe/search/__init__.py b/frappe/search/__init__.py index 81df8f4a80..959e0884a8 100644 --- a/frappe/search/__init__.py +++ b/frappe/search/__init__.py @@ -3,6 +3,7 @@ import frappe from frappe.search.full_text_search import FullTextSearch +from frappe.search.sqlite_search import SQLiteSearch from frappe.search.website_search import WebsiteSearch from frappe.utils import cint diff --git a/frappe/search/sqlite_search.md b/frappe/search/sqlite_search.md new file mode 100644 index 0000000000..2ff3227caa --- /dev/null +++ b/frappe/search/sqlite_search.md @@ -0,0 +1,470 @@ +# SQLite Search Framework + +SQLite Search is a full-text search framework for Frappe applications that provides advanced search capabilities using SQLite's FTS5 (Full-Text Search) engine. It offers features like spelling correction, time-based recency scoring, custom ranking, permission-aware filtering, and extensible scoring pipelines. + +## Table of Contents + +- [Quick Start](#quick-start) +- [How It Works](#how-it-works) +- [Configuration](#configuration) +- [Features & Customization](#features--customization) +- [API Reference](#api-reference) + +## Quick Start + +### 1. Create a Search Class + +Create a search implementation by extending `SQLiteSearch`: + +```python +# my_app/search.py +from frappe.search.sqlite_search import SQLiteSearch + +class MyAppSearch(SQLiteSearch): + # Database file name + INDEX_NAME = "my_app_search.db" + + # Define the search schema + INDEX_SCHEMA = { + "metadata_fields": ["project", "owner", "status"], + "tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'", + } + + # Define which doctypes to index and their field mappings + INDEXABLE_DOCTYPES = { + "Task": { + "fields": ["name", {"title": "subject"}, {"content": "description"}, "modified", "project", "owner", "status"], + }, + "Issue": { + "fields": ["name", "title", "description", {"modified": "last_updated"}, "project", "owner"], + "filters": {"status": ("!=", "Closed")}, # Only index non-closed issues + }, + } + + def get_search_filters(self): + """Return permission filters for current user""" + # Get projects accessible to current user + accessible_projects = frappe.get_all( + "Project", + filters={"owner": frappe.session.user}, + pluck="name" + ) + + if not accessible_projects: + return {"project": []} # No access + + return {"project": accessible_projects} +``` + +### 2. Register the Search Class + +Add your search class to hooks.py: + +```python +# my_app/hooks.py +sqlite_search = ['my_app.search.MyAppSearch'] +``` + +### 3. Create API Endpoint + +Create a whitelisted method to expose search functionality: + +```python +# my_app/api.py +import frappe +from my_app.search import MyAppSearch + +@frappe.whitelist() +def search(query, filters=None): + search = MyAppSearch() + result = search.search(query, filters=filters) + + return result +``` + +### 4. Build the Index + +Build the search index programmatically or via console: + +```python +from my_app.search import MyAppSearch +search = MyAppSearch() +search.build_index() +``` + +## How It Works + +### 1. Indexing Process + +#### Full Index Building + +When you call `build_index()`, the framework performs a complete index rebuild: + +1. **Database Preparation**: Creates a temporary SQLite database with FTS5 tables configured according to your schema +2. **Document Collection**: Queries all specified doctypes using the configured field mappings and filters +3. **Document Processing**: For each document: + - Extracts and maps fields according to `INDEXABLE_DOCTYPES` configuration + - Cleans HTML content using BeautifulSoup to extract plain text + - Applies custom document preparation logic if `prepare_document()` is overridden + - Validates required fields (title, content) are present +4. **Batch Insertion**: Inserts processed documents into the FTS5 index in batches for performance +5. **Vocabulary Building**: Constructs a spelling correction dictionary from all indexed text +6. **Atomic Replacement**: Replaces the existing index database with the new one atomically + +#### Individual Document Indexing + +For real-time updates using `index_doc()` or `remove_doc()`: + +1. **Single Document Processing**: Retrieves and processes one document using the same field mapping logic +2. **Incremental Update**: Updates the existing FTS5 index by inserting, updating, or deleting the specific document +3. **Vocabulary Update**: Updates the spelling dictionary with new terms from the document + +### 2. Search Process + +When a user performs a search using `search()`, the framework executes these steps: + +1. **Permission Filtering**: Calls `get_search_filters()` to determine what documents the current user can access +2. **Query Preprocessing**: + - Validates the search query is not empty + - Combines user-provided filters with permission filters +3. **Spelling Correction**: + - Analyzes query terms against the vocabulary dictionary + - Uses trigram similarity to suggest corrections for misspelled words + - Expands the original query with corrected terms +4. **FTS5 Query Execution**: + - Constructs an FTS5-compatible query string + - Executes the full-text search against the SQLite database + - Applies metadata filters (status, owner, project, etc.) + - Retrieves raw results with BM25 scores +5. **Results Processing**: + - **Custom Scoring**: Applies the scoring pipeline to calculate final relevance scores + - Base BM25 score processing + - Title matching boosts (exact and partial matches) + - Recency boosting based on document age + - Custom scoring functions (doctype-specific, priority-based, etc.) + - **Ranking**: Sorts results by final scores and assigns rank positions + - **Content Formatting**: Generates content snippets and highlights matching terms + +## Configuration + +### INDEX_SCHEMA + +Defines the structure of your search index: + +```python +INDEX_SCHEMA = { + # Text fields that will be searchable (defaults to ["title", "content"]) + "text_fields": ["title", "content"], + + # Metadata fields stored alongside text content for filtering + "metadata_fields": ["project", "owner", "status", "priority"], + + # FTS5 tokenizer configuration + "tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_@.'" +} +``` + +### INDEXABLE_DOCTYPES + +Specifies which doctypes to index and how to map their fields: + +```python +INDEXABLE_DOCTYPES = { + "Task": { + # Field mapping + "fields": [ + "name", + {"title": "subject"}, # Maps subject field to title + {"content": "description"}, # Maps description field to content + {"modified": "creation"}, # Use creation instead of modified for recency boost + "project", + "owner" + ], + + # Optional filters to limit which records are indexed + "filters": { + "status": ("!=", "Cancelled"), + "docstatus": ("!=", 2) + } + } +} +``` + +### Field Mapping Rules + +- **String fields**: Direct mapping `"field_name"` +- **Aliased fields**: Dictionary mapping `{"schema_field": "doctype_field"}` +- **Required fields**: `title` and `content` fields must be present or explicitly mapped (e.g., `{"title": "subject"}`) +- **Auto-added fields**: `doctype` and `name` are automatically included +- **Modified field**: Added automatically if used in any doctype configuration. Used for recency boosting - if you want to use a different timestamp field (like `creation` or `last_updated`), map it to `modified` using `{"modified": "creation"}` + +## Features & Customization + +### Permission Filtering + +Implement `get_search_filters()` to control access: + +```python +def get_search_filters(self): + """Return filters based on user permissions""" + user = frappe.session.user + + if user == "Administrator": + return {} # No restrictions + + # Example: User can only see their own and public documents + return { + "owner": user, + "status": ["Active", "Published"] + } +``` + +### Custom Scoring + +Create custom scoring functions to influence search relevance: + +```python +class MyAppSearch(SQLiteSearch): + ... + + @SQLiteSearch.scoring_function + def _get_priority_boost(self, row, query, query_words): + """Boost high-priority items""" + priority = row.get("priority", "Medium") + + if priority == "High": + return 1.5 + if priority == "Medium": + return 1.1 + return 1.0 +``` + +### Recency Boosting + +The framework automatically provides time-based recency boosting using the `modified` field: + +```python +# The modified field is used for calculating document age +# Recent documents get higher scores: +# - Last 24 hours: 1.8x boost +# - Last 7 days: 1.5x boost +# - Last 30 days: 1.2x boost +# - Last 90 days: 1.1x boost +# - Older documents: gradually decreasing boost + +# If your doctype uses a different timestamp field, map it to modified: +INDEXABLE_DOCTYPES = { + "GP Discussion": { + "fields": ["name", "title", "content", {"modified": "last_post_at"}, "project"], + }, + "Article": { + "fields": ["name", "title", "content", {"modified": "published_date"}, "category"], + } +} +``` + +### Document Preparation + +Override `prepare_document()` for custom document processing: + +```python +def prepare_document(self, doc): + """Custom document preparation""" + document = super().prepare_document(doc) + if not document: + return None + + # Add computed fields + if doc.doctype == "Task": + # Combine multiple fields into content + content_parts = [ + doc.description or "", + doc.notes or "", + "\n".join([comment.content for comment in doc.get("comments", [])]) + ] + document["content"] = "\n".join(filter(None, content_parts)) + + # set fields that might be stored in another table + document["category"] = get_category_for_task(doc) + + return document +``` + +### Spelling Correction + +The framework includes built-in spelling correction using trigram similarity: + +```python +# Spelling correction happens automatically +search_result = search.search("projetc managment") # Will find "project management" + +# Access correction information +print(search_result["summary"]["corrected_words"]) +# Output: {"projetc": "project", "managment": "management"} +``` + +### Content Processing + +HTML content is automatically cleaned and processed using BeautifulSoup: + +```python +# Complex HTML content like this: +html_content = """ +
+

API Documentation

+

Learn how to integrate with our REST API.

+ API workflow diagram +
    +
  • Authentication: Use Bearer tokens
  • +
  • Rate limiting: 1000 requests/hour
  • +
+
See our code examples for details.
+
MethodPOST
+ + +
+""" + +# Is automatically converted to clean, searchable plain text: +""" +API Documentation + +Learn how to integrate with our REST API. + +Authentication: Use Bearer tokens +Rate limiting: 1000 requests/hour + +See our code examples for details. + +Method POST +""" + +# The cleaning process: +# 1. Removes all HTML tags (
,

, , , 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 @@ -

- \ 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 %} - {{post.title}} - 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 @@
+ {%- if title -%}

{{ _(title) }}

+ {%- endif -%} {%- if subtitle -%}

{{ _(subtitle) }}

{%- endif -%} diff --git a/frappe/website/web_template/section_with_image/section_with_image.html b/frappe/website/web_template/section_with_image/section_with_image.html index 61c4010808..5644aa2dda 100644 --- a/frappe/website/web_template/section_with_image/section_with_image.html +++ b/frappe/website/web_template/section_with_image/section_with_image.html @@ -1,5 +1,7 @@
+ {%- if title -%}

{{ _(title) }}

+ {%- endif -%} {%- if subtitle -%}

{{ _(subtitle) }}

{%- endif -%} diff --git a/frappe/website/web_template/section_with_image_grid/section_with_image_grid.html b/frappe/website/web_template/section_with_image_grid/section_with_image_grid.html index 89887333a2..600eb75ded 100644 --- a/frappe/website/web_template/section_with_image_grid/section_with_image_grid.html +++ b/frappe/website/web_template/section_with_image_grid/section_with_image_grid.html @@ -1,5 +1,7 @@
-

{{ title }}

+ {%- if title -%} +

{{ _(title) }}

+ {%- endif -%}

{{ subtitle }}

diff --git a/frappe/website/web_template/section_with_small_cta/section_with_small_cta.html b/frappe/website/web_template/section_with_small_cta/section_with_small_cta.html index 4465fcc153..c385329c7b 100644 --- a/frappe/website/web_template/section_with_small_cta/section_with_small_cta.html +++ b/frappe/website/web_template/section_with_small_cta/section_with_small_cta.html @@ -1,7 +1,9 @@
-

{{ title or '' }}

+ {%- if title -%} +

{{ _(title) }}

+ {%- endif -%} {%- if subtitle -%}

{{ subtitle }}

{%- endif -%} diff --git a/frappe/website/workspace/website/website.json b/frappe/website/workspace/website/website.json index bcbbcb74ad..d3eb13496c 100644 --- a/frappe/website/workspace/website/website.json +++ b/frappe/website/workspace/website/website.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"id\":\"yq1JKyNTFg\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Web Page\",\"col\":3}},{\"id\":\"5GuZo0uP_K\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Web Form\",\"col\":3}},{\"id\":\"292vrD2W3o\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Blog Post\",\"col\":3}},{\"id\":\"xAkA6ItB7O\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"k8RquSngSk\",\"type\":\"card\",\"data\":{\"card_name\":\"Blog\",\"col\":4}},{\"id\":\"qEHBG-BEBI\",\"type\":\"card\",\"data\":{\"card_name\":\"Web Site\",\"col\":4}},{\"id\":\"oUox7d-8lQ\",\"type\":\"card\",\"data\":{\"card_name\":\"Knowledge Base\",\"col\":4}},{\"id\":\"96xAe0QVaV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"ZvrzvEoYtc\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"FVMFcgsiyK\",\"type\":\"card\",\"data\":{\"card_name\":\"Tracking\",\"col\":4}}]", + "content": "[{\"id\":\"yq1JKyNTFg\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Web Page\",\"col\":3}},{\"id\":\"5GuZo0uP_K\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Web Form\",\"col\":3}},{\"id\":\"xAkA6ItB7O\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"qEHBG-BEBI\",\"type\":\"card\",\"data\":{\"card_name\":\"Web Site\",\"col\":4}},{\"id\":\"oUox7d-8lQ\",\"type\":\"card\",\"data\":{\"card_name\":\"Knowledge Base\",\"col\":4}},{\"id\":\"96xAe0QVaV\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"ZvrzvEoYtc\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"FVMFcgsiyK\",\"type\":\"card\",\"data\":{\"card_name\":\"Tracking\",\"col\":4}}]", "creation": "2020-03-02 14:13:51.089373", "custom_blocks": [], "docstatus": 0, @@ -67,48 +67,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "icon": "", - "is_query_report": 0, - "label": "Blog", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Blog Post", - "link_count": 0, - "link_to": "Blog Post", - "link_type": "DocType", - "onboard": 1, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Blogger", - "link_count": 0, - "link_to": "Blogger", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Blog Category", - "link_count": 0, - "link_to": "Blog Category", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "hidden": 0, "icon": "project", @@ -279,14 +237,6 @@ "roles": [], "sequence_id": 14.0, "shortcuts": [ - { - "color": "Green", - "format": "{} Published", - "label": "Blog Post", - "link_to": "Blog Post", - "stats_filter": "{\"published\":\"1\"}", - "type": "DocType" - }, { "color": "Green", "format": "{} Published", diff --git a/frappe/workflow/doctype/workflow/test_workflow.py b/frappe/workflow/doctype/workflow/test_workflow.py index 0811fa3077..f7b43dd64b 100644 --- a/frappe/workflow/doctype/workflow/test_workflow.py +++ b/frappe/workflow/doctype/workflow/test_workflow.py @@ -2,13 +2,14 @@ # License: MIT. See LICENSE from unittest.mock import patch +import responses + import frappe from frappe.model.workflow import ( WorkflowTransitionError, apply_workflow, get_common_transition_actions, ) -from frappe.query_builder import DocType from frappe.tests import IntegrationTestCase from frappe.tests.utils import make_test_records from frappe.utils import random_string @@ -19,16 +20,19 @@ class TestWorkflow(IntegrationTestCase): def setUpClass(cls): super().setUpClass() make_test_records("User") + cls.enterClassContext(cls.enable_safe_exec()) def setUp(self): self.patcher = patch("frappe.attach_print", return_value={}) self.patcher.start() frappe.db.delete("Workflow Action") self.workflow = create_todo_workflow() + create_domain_workflow() def tearDown(self): frappe.set_user("Administrator") self.patcher.stop() + frappe.delete_doc("Workflow", "Test ToDo") def test_default_condition(self): @@ -126,6 +130,50 @@ class TestWorkflow(IntegrationTestCase): "invalid python code" in str(se.exception).lower(), msg="Python code validation not working" ) + # app-defined workflow task tests start here + def test_sync_tasks(self, doc=None): + """test workflow with workflow tasks (server scripts, webhooks and app-defined methods)""" + + # for webhooks + self.responses = responses.RequestsMock() + self.responses.start() + + self.responses.add( + responses.POST, + "https://workflowtasks.org/post", + status=200, + json={}, + ) + + domain = frappe.new_doc("Domain") + domain.domain = random_string(length=10) + domain.save() + + with self.patch_hooks( + { + "workflow_methods": [ + { + "name": "Create Note", + "method": "frappe.workflow.doctype.workflow.test_workflow.create_new_note", + } + ] + } + ): + apply_workflow(domain, "Approve") + + # refer create_new_task() + self.assertTrue( + frappe.db.exists("Note", {"title": "workflow - " + domain.name, "content": "workflow test"}) + ) + self.assertTrue(frappe.db.exists("Domain", {"name": "workflow - " + domain.name})) + self.assertTrue(frappe.db.exists("Webhook Request Log", {"url": "https://workflowtasks.org/post"})) + + # for webhooks + self.responses.stop() + self.responses.reset() + + return domain + def create_todo_workflow(): from frappe.tests.ui_test_helpers import UI_TEST_USER @@ -181,5 +229,107 @@ def create_todo_workflow(): return workflow +def create_domain_workflow(): + from frappe.tests.ui_test_helpers import UI_TEST_USER + + if frappe.db.exists("Workflow", "Test Domain"): + frappe.delete_doc("Workflow", "Test Domain") + + TEST_ROLE = "Test Approver" + + if not frappe.db.exists("Role", TEST_ROLE): + frappe.get_doc(doctype="Role", role_name=TEST_ROLE).insert(ignore_if_duplicate=True) + if frappe.db.exists("User", UI_TEST_USER): + frappe.get_doc("User", UI_TEST_USER).add_roles(TEST_ROLE) + + server_script = create_new_server_script() + webhook = create_new_webhook() + + pending_to_approved_transition = frappe.new_doc("Workflow Transition Tasks") + pending_to_approved_transition.name = random_string(length=10) + pending_to_approved_transition.append("tasks", {"task": "Create Note"}) + pending_to_approved_transition.append("tasks", {"task": "Server Script", "link": server_script.name}) + pending_to_approved_transition.append("tasks", {"task": "Webhook", "link": webhook.name}) + + pending_to_approved_transition.save() + + workflow = frappe.new_doc("Workflow") + workflow.workflow_name = "Test Domain" + workflow.document_type = "Domain" + workflow.workflow_state_field = "workflow_state" + workflow.is_active = 1 + workflow.send_email_alert = 1 + workflow.append("states", dict(state="Pending", allow_edit="All")) + workflow.append( + "states", + dict(state="Approved", allow_edit=TEST_ROLE, update_field="status", update_value="Closed"), + ) + workflow.append("states", dict(state="Rejected", allow_edit=TEST_ROLE)) + workflow.append( + "transitions", + dict( + state="Pending", + action="Approve", + next_state="Approved", + allowed=TEST_ROLE, + allow_self_approval=1, + transition_tasks=pending_to_approved_transition.name, + ), + ) + workflow.append( + "transitions", + dict( + state="Pending", + action="Reject", + next_state="Rejected", + allowed=TEST_ROLE, + allow_self_approval=1, + ), + ) + workflow.append( + "transitions", + dict(state="Rejected", action="Review", next_state="Pending", allowed="All", allow_self_approval=1), + ) + workflow.insert(ignore_permissions=True) + + return workflow + + def create_new_todo(): return frappe.get_doc(doctype="ToDo", description="workflow " + random_string(10)).insert() + + +def create_new_note(doc): + note = frappe.new_doc("Note") + note.title = "workflow - " + doc.name + note.content = "workflow test" + + note.save() + + +def create_new_server_script(): + server_script = frappe.new_doc("Server Script") + server_script.name = random_string(length=10) + server_script.script_type = "Workflow Task" + server_script.script = """ +# create a domain with the same name as the given document +domain = frappe.new_doc("Domain") +domain.domain = "workflow - " + doc.name + +domain.save() + """ + server_script.save() + + return server_script + + +def create_new_webhook(): + webhook = frappe.new_doc("Webhook") + webhook.__newname = random_string(10) + webhook.webhook_docevent = "workflow_transition" + webhook.webhook_doctype = "Domain" + webhook.request_method = "POST" + webhook.request_url = "https://workflowtasks.org/post" + webhook.save() + + return webhook diff --git a/frappe/workflow/doctype/workflow/workflow.py b/frappe/workflow/doctype/workflow/workflow.py index d66f8ccafe..04efba4177 100644 --- a/frappe/workflow/doctype/workflow/workflow.py +++ b/frappe/workflow/doctype/workflow/workflow.py @@ -4,6 +4,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.model.workflow import DEFAULT_WORKFLOW_TASKS from frappe.utils import cint @@ -131,3 +132,8 @@ def get_workflow_state_count(doctype, workflow_state_field, states): group_by=workflow_state_field, ) return [r for r in result if r[workflow_state_field]] + + +@frappe.whitelist(methods=["GET"]) +def get_workflow_methods(): + return [i["name"] for i in frappe.get_hooks("workflow_methods")] + DEFAULT_WORKFLOW_TASKS diff --git a/frappe/workflow/doctype/workflow_transition/workflow_transition.json b/frappe/workflow/doctype/workflow_transition/workflow_transition.json index d78732d5e2..b90a0332ff 100644 --- a/frappe/workflow/doctype/workflow_transition/workflow_transition.json +++ b/frappe/workflow/doctype/workflow_transition/workflow_transition.json @@ -13,6 +13,7 @@ "allow_self_approval", "send_email_to_creator", "enable_action_confirmation", + "transition_tasks", "conditions", "condition", "column_break_7", @@ -107,17 +108,24 @@ "fieldname": "enable_action_confirmation", "fieldtype": "Check", "label": "Enable Action Confirmation" - } + }, + { + "fieldname": "transition_tasks", + "fieldtype": "Link", + "label": "Transition Tasks", + "options": "Workflow Transition Tasks" + } ], "idx": 1, "istable": 1, "links": [], - "modified": "2025-03-24 02:47:44.188152", + "modified": "2025-07-04 15:56:34.345888", "modified_by": "Administrator", "module": "Workflow", "name": "Workflow Transition", "owner": "Administrator", "permissions": [], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] diff --git a/frappe/workflow/doctype/workflow_transition/workflow_transition.py b/frappe/workflow/doctype/workflow_transition/workflow_transition.py index 6d467e01e3..7eceafaeaf 100644 --- a/frappe/workflow/doctype/workflow_transition/workflow_transition.py +++ b/frappe/workflow/doctype/workflow_transition/workflow_transition.py @@ -24,6 +24,7 @@ class WorkflowTransition(Document): parenttype: DF.Data send_email_to_creator: DF.Check state: DF.Link + transition_tasks: DF.Link | None workflow_builder_id: DF.Data | None # end: auto-generated types diff --git a/frappe/website/doctype/blog_post/__init__.py b/frappe/workflow/doctype/workflow_transition_task/__init__.py similarity index 100% rename from frappe/website/doctype/blog_post/__init__.py rename to frappe/workflow/doctype/workflow_transition_task/__init__.py diff --git a/frappe/workflow/doctype/workflow_transition_task/test_workflow_transition_task.py b/frappe/workflow/doctype/workflow_transition_task/test_workflow_transition_task.py new file mode 100644 index 0000000000..262f1cd876 --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_task/test_workflow_transition_task.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestWorkflowTransitionTask(IntegrationTestCase): + """ + Integration tests for WorkflowTransitionTask. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.js b/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.js new file mode 100644 index 0000000000..e9bfca6cd7 --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Workflow Transition Task", { +// refresh(frm) { + +// }, +// }); diff --git a/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.json b/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.json new file mode 100644 index 0000000000..c06823a7c2 --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.json @@ -0,0 +1,63 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-07-04 16:41:15.904217", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "task", + "asynchronous", + "enabled", + "link" + ], + "fields": [ + { + "fieldname": "task", + "fieldtype": "Select", + "in_list_view": 1, + "in_preview": 1, + "label": "Task", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "in_preview": 1, + "label": "Enabled" + }, + { + "depends_on": "eval: [\"Server Script\", \"Webhook\"].includes(doc.task)", + "fetch_if_empty": 1, + "fieldname": "link", + "fieldtype": "Dynamic Link", + "label": "Link", + "mandatory_depends_on": "eval: [\"Server Script\", \"Webhook\"].includes(doc.task)", + "options": "task" + }, + { + "default": "0", + "description": "Spawns actions in a background job", + "fieldname": "asynchronous", + "fieldtype": "Check", + "in_list_view": 1, + "in_preview": 1, + "label": "Asynchronous" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-07-14 12:13:18.108225", + "modified_by": "Administrator", + "module": "Workflow", + "name": "Workflow Transition Task", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.py b/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.py new file mode 100644 index 0000000000..39e9cbeccc --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_task/workflow_transition_task.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class WorkflowTransitionTask(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 + + asynchronous: DF.Check + enabled: DF.Check + link: DF.DynamicLink | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + task: DF.Literal[None] + # end: auto-generated types + + pass diff --git a/frappe/website/doctype/blog_settings/__init__.py b/frappe/workflow/doctype/workflow_transition_tasks/__init__.py similarity index 100% rename from frappe/website/doctype/blog_settings/__init__.py rename to frappe/workflow/doctype/workflow_transition_tasks/__init__.py diff --git a/frappe/workflow/doctype/workflow_transition_tasks/test_workflow_transition_tasks.py b/frappe/workflow/doctype/workflow_transition_tasks/test_workflow_transition_tasks.py new file mode 100644 index 0000000000..960322caa3 --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_tasks/test_workflow_transition_tasks.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestWorkflowTransitionTasks(IntegrationTestCase): + """ + Integration tests for WorkflowTransitionTasks. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.js b/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.js new file mode 100644 index 0000000000..e2a89f9b8d --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.js @@ -0,0 +1,19 @@ +// Copyright (c) 2025, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Workflow Transition Tasks", { + refresh: function (frm) { + frappe + .call({ + method: "frappe.workflow.doctype.workflow.workflow.get_workflow_methods", + type: "GET", + }) + .then((options) => { + frm.get_field("tasks").grid.update_docfield_property( + "task", + "options", + options.message + ); + }); + }, +}); diff --git a/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.json b/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.json new file mode 100644 index 0000000000..2f8c48ac58 --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "prompt", + "creation": "2025-07-04 15:41:50.296193", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "tasks" + ], + "fields": [ + { + "fieldname": "tasks", + "fieldtype": "Table", + "label": "Tasks", + "options": "Workflow Transition Task" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-07-08 12:23:01.908648", + "modified_by": "Administrator", + "module": "Workflow", + "name": "Workflow Transition Tasks", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.py b/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.py new file mode 100644 index 0000000000..37b180a618 --- /dev/null +++ b/frappe/workflow/doctype/workflow_transition_tasks/workflow_transition_tasks.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 WorkflowTransitionTasks(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 + from frappe.workflow.doctype.workflow_transition_task.workflow_transition_task import ( + WorkflowTransitionTask, + ) + + tasks: DF.Table[WorkflowTransitionTask] + # end: auto-generated types + + pass diff --git a/frappe/www/rss.py b/frappe/www/rss.py deleted file mode 100644 index e275893204..0000000000 --- a/frappe/www/rss.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE - -from urllib.parse import quote, urljoin - -import frappe -from frappe.utils import cstr, escape_html, get_request_site_address, now - -no_cache = 1 -base_template_path = "www/rss.xml" - - -def get_context(context): - """generate rss feed""" - - host = get_request_site_address() - - blog_list = frappe.get_all( - "Blog Post", - fields=["name", "published_on", "modified", "title", "blog_intro", "route"], - filters={"published": 1}, - order_by="published_on desc", - limit=20, - ) - - for blog in blog_list: - blog.link = urljoin(host, blog.route) - blog.blog_intro = escape_html(blog.blog_intro or "") - blog.title = escape_html(blog.title or "") - - if blog_list: - modified = max(blog["modified"] for blog in blog_list) - else: - modified = now() - - blog_settings = frappe.get_doc("Blog Settings", "Blog Settings") - - context = { - "title": blog_settings.blog_title or "Blog", - "description": blog_settings.blog_introduction or "", - "modified": modified, - "items": blog_list, - "link": host + "/blog", - } - - # print context - return context diff --git a/frappe/www/rss.xml b/frappe/www/rss.xml deleted file mode 100644 index 8752896370..0000000000 --- a/frappe/www/rss.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - {{ title }} - {{ description }} - {{ link }} - {{ modified }} - {{ modified }} - 1800 - {% for i in items %} - {{ i.title }} - {{ i.blog_intro }} - {{ i.link }} - {{ i.name }} - {{ i.published_on }} - {% endfor %} - -