seitime-frappe/frappe/core/api/user_invitation.py
Elton Lobo 6d1008933f
feat: add user invitation doctype & related public methods (#33308)
* feat: add user invitation doctype & related public methods

* style(user-invitation): execute formatters & add semgrep comments

* refactor(user-invitation): use `is` to compare `None` values

* fix(user-invitation): skip fetching `after_accept` for default app

* fix(user-invitation): translate email templates

* fix(user-invitaton): return pending invites from invite by email api

* refactor(user-invitation): improve code quality

* fix(user-invitation): translate all error messages

* refactor(user-invitation): improve security & readability

Improvements:
- move invite expiration check to `daily_maintenance`
- explicitly import all of the used packages
- specify methods for all security-critical endpoints
- improve error messages and give them suitable titles
- remove unnecessary utility functions
- make invitation key management secure
- translate all of the subjects of the sent emails
- use the `app_title` hook to create email titles
- commit the work done after each iteration of the background invitation
  expiry checker
- restructure code to improve readability
- use `user.reset_password` to generate the target link
- use clear long names to name identifiers
- add document states with relevant colors (User Invitation doctype)
- differ `sendmail` emails whenever possible
- send an email to the invitation creator instead of the invitee after
  the invite has expired
- remove `User Invitation Manager` role

* fix(user-invitation): use valid emails to test doctype & related code

* feat(user-invitation): support adding multiple roles

* refactor(user-invitation): mark relevant fields `set only once`

* feat(user-invitation): add `Cancelled` status

* test(user-invitation): correct broken tests

* test(user-invitation): form valid f-strings & run code formatter

* feat(user-invitation): make doctype usable from desk

* fix(user-invitation): remove delete permission from invitation doctype

* feat(user-invitation): pass user inserted info to `after_accept` hook

* refactor(user-invitation): improve custom action methods & errors

Improvements:
- trigger actions only when the invitation is in the `Pending` state
- use lowercase letters to start error messages
- handle cases where `user_invitation_hook` is not defined

* refactor(user-invitation): remove site name from email templates

* docs(user-invitation): add internal documentation

* feat(user-invitation): add 'get pending' & cancel invites apis

* fix(user-invitation): make invitation app specific

* refactor(user-invitation): avoid mixing function programming

* fix(user-invitation): make apis usable for app specific valid users

* fix(user-invitation): allow app specific invites

* feat(user-invitation): make list view & permission checks app specific

* refactor(user-invitation): convert class methods to static when possible

* feat(user-invitation): add `app_only_for` method to the doc

* fix(user-invitation): f-string syntax error in `get_permission_query_conditions`

* docs(user-invitation): add examples & improve the internal doc

* refactor: rename method name

static_ is unnecessary
only_for doesn't make sense in this context when arguments are not roles

* fix: Support POST request too

We dont follow REST semantics 100%, anything that modifies something
should ideally be doable with POST too.

* chore: cap

* fix: Avoid ignore_permissions as user arg

---------

Co-authored-by: Ankush Menat <ankush@frappe.io>
2025-07-28 16:25:53 +05:30

126 lines
4 KiB
Python

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