From 7747e1c5e8f3e13e977c8a270ae3e99175f76373 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Tue, 17 Mar 2026 14:19:23 +0100 Subject: [PATCH 1/4] Extract generate_user_id/generate_login_name as standalone functions Move user ID and login name generation logic from BaseRegistrationForm methods into standalone functions in plone.app.users.utils, enabling reuse from plone.api and plone.restapi without form view dependency. Part of PLIP 4292. Co-Authored-By: Claude Opus 4.6 --- news/4292.feature | 3 + src/plone/app/users/browser/register.py | 154 ++-------------------- src/plone/app/users/utils.py | 162 ++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 145 deletions(-) create mode 100644 news/4292.feature diff --git a/news/4292.feature b/news/4292.feature new file mode 100644 index 00000000..b3165d6c --- /dev/null +++ b/news/4292.feature @@ -0,0 +1,3 @@ +Extract ``generate_user_id`` and ``generate_login_name`` as standalone functions +in ``plone.app.users.utils``, enabling reuse from ``plone.api`` and ``plone.restapi`` +without form view dependency. diff --git a/src/plone/app/users/browser/register.py b/src/plone/app/users/browser/register.py index 43c64ede..21abf008 100644 --- a/src/plone/app/users/browser/register.py +++ b/src/plone/app/users/browser/register.py @@ -1,18 +1,17 @@ from AccessControl import getSecurityManager from plone.app.users.browser.account import AccountPanelSchemaAdapter from plone.app.users.browser.account import getSchema -from plone.app.users.browser.interfaces import ILoginNameGenerator -from plone.app.users.browser.interfaces import IUserIdGenerator from plone.app.users.schema import IAddUserSchema from plone.app.users.schema import ICombinedRegisterSchema from plone.app.users.schema import IRegisterSchema +from plone.app.users.utils import generate_login_name as _generate_login_name +from plone.app.users.utils import generate_user_id as _generate_user_id from plone.app.users.utils import notifyWidgetActionExecutionError -from plone.app.users.utils import uuid_userid_generator +from plone.app.users.utils import RENAME_AFTER_CREATION_ATTEMPTS # noqa: F401 from plone.autoform.form import AutoExtensibleForm from plone.base import PloneMessageFactory as _ from plone.base.interfaces import ISecuritySchema from plone.base.interfaces import IUserGroupsSettingsSchema -from plone.i18n.normalizer.interfaces import IIDNormalizer from plone.protect import CheckAuthenticator from plone.registry.interfaces import IRegistry from Products.CMFCore.interfaces import ISiteRoot @@ -30,16 +29,11 @@ from zope.component import getAdapter from zope.component import getMultiAdapter from zope.component import getUtility -from zope.component import queryUtility from zope.schema import getFieldNames import logging -# Number of retries for creating a user id like bob-jones-42: -RENAME_AFTER_CREATION_ATTEMPTS = 100 - - def getRegisterSchema(): schema = getSchema( ICombinedRegisterSchema, @@ -127,148 +121,18 @@ def updateActions(self): def generate_user_id(self, data): """Generate a user id from data. - We try a few options for coming up with a good user id: - - 1. We query a utility, so integrators can register a hook to - generate a user id using their own logic. - - 2. If use_uuid_as_userid is set in the registry, we - generate a uuid. - - 3. If a username is given and we do not use email as login, - then we simply return that username as the user id. - - 4. We create a user id based on the full name, if that is - passed. This may result in an id like bob-jones-2. - - When the email address is used as login name, we originally - used the email address as user id as well. This has a few - possible downsides, which are the main reasons for the new, - pluggable approach: - - - It does not work for some valid email addresses. - - - Exposing the email address in this way may not be wanted. - - - When the user later changes his email address, the user id - will still be his old address. It works, but may be - confusing. - - Another possibility would be to simply generate a uuid, but that - is ugly. We could certainly try that though: the big plus here - would be that you then cannot create a new user with the same user - id as a previously existing user if this ever gets removed. If - you would get the same id, this new user would get the same global - and local roles, if those have not been cleaned up. - - When a user id is chosen, the 'user_id' key of the data gets - set and the user id is returned. + Delegates to the standalone function in plone.app.users.utils. + See :func:`plone.app.users.utils.generate_user_id` for details. """ - generator = queryUtility(IUserIdGenerator) - if generator: - userid = generator(data) - if userid: - data["user_id"] = userid - return userid - - settings = self._get_security_settings() - if settings.use_uuid_as_userid: - userid = uuid_userid_generator() - data["user_id"] = userid - return userid - - # We may have a username already. - userid = data.get("username") - if userid: - # If we are not using email as login, then this user name is fine. - if not settings.use_email_as_login: - data["user_id"] = userid - return userid - - # First get a default value that we can return if we cannot - # find anything better. - pas = getToolByName(self.context, "acl_users") - email = pas.applyTransform(data.get("email")) - default = data.get("username") or email or "" - data["user_id"] = default - fullname = data.get("fullname") - if not fullname: - return default - userid = getUtility(IIDNormalizer).normalize(fullname) - # First check that this is a valid member id, regardless of - # whether a member with this id already exists or not. We - # access an underscore attribute of the registration tool, so - # we take a precaution in case this is ever removed as an - # implementation detail. - registration = getToolByName(self.context, "portal_registration") - if hasattr(registration, "_ALLOWED_MEMBER_ID_PATTERN"): - if not registration._ALLOWED_MEMBER_ID_PATTERN.match(userid): - # If 'bob-jones' is not good then 'bob-jones-1' will not - # be good either. - return default - if registration.isMemberIdAllowed(userid): - data["user_id"] = userid - return userid - # Try bob-jones-1, bob-jones-2, etc. - idx = 1 - while idx <= RENAME_AFTER_CREATION_ATTEMPTS: - new_id = "%s-%d" % (userid, idx) - if registration.isMemberIdAllowed(new_id): - data["user_id"] = new_id - return new_id - idx += 1 - - # We cannot come up with a nice id, so we simply return the default. - return default + return _generate_user_id(self.context, data) def generate_login_name(self, data): """Generate a login name from data. - Usually the login name and user id are the same, but this is - not necessarily true. When using the email address as login - name, we may have a different user id, generated by calling - the generate_user_id method. - - We try a few options for coming up with a good login name: - - 1. We query a utility, so integrators can register a hook to - generate a login name using their own logic. - - 2. If a username is given and we do not use email as login, - then we simply return that username as the login name. - - 3. When using email as login, we use the email address. - - In all cases, we call PAS.applyTransform on the login name, if - that is defined. This is a recent addition to PAS, currently - under development. - - When a login name is chosen, the 'login_name' key of the data gets - set and the login name is returned. + Delegates to the standalone function in plone.app.users.utils. + See :func:`plone.app.users.utils.generate_login_name` for details. """ - pas = getToolByName(self.context, "acl_users") - generator = queryUtility(ILoginNameGenerator) - if generator: - login_name = generator(data) - if login_name: - login_name = pas.applyTransform(login_name) - data["login_name"] = login_name - return login_name - - # We may have a username already. - login_name = data.get("username") - login_name = pas.applyTransform(login_name) - data["login_name"] = login_name - settings = self._get_security_settings() - # If we are not using email as login, then this user name is fine. - if not settings.use_email_as_login: - return login_name - - # We use email as login. - login_name = data.get("email") - login_name = pas.applyTransform(login_name) - data["login_name"] = login_name - return login_name + return _generate_login_name(self.context, data) # Actions validators def validate_registration(self, action, data): diff --git a/src/plone/app/users/utils.py b/src/plone/app/users/utils.py index 9a6c0b4d..09e1fc6a 100644 --- a/src/plone/app/users/utils.py +++ b/src/plone/app/users/utils.py @@ -1,12 +1,22 @@ +from plone.app.users.browser.interfaces import ILoginNameGenerator +from plone.app.users.browser.interfaces import IUserIdGenerator +from plone.base.interfaces import ISecuritySchema +from plone.i18n.normalizer.interfaces import IIDNormalizer +from plone.registry.interfaces import IRegistry from plone.uuid.interfaces import IUUIDGenerator +from Products.CMFCore.utils import getToolByName from z3c.form.action import ActionErrorOccurred from z3c.form.interfaces import WidgetActionExecutionError from zope.component import getUtility +from zope.component import queryUtility from zope.interface import Invalid import zope.event +RENAME_AFTER_CREATION_ATTEMPTS = 100 + + def uuid_userid_generator(data=None): # Generate a unique user id. This can be used as # IUserIdGenerator if wanted. @@ -14,6 +24,158 @@ def uuid_userid_generator(data=None): return generator() +def generate_user_id(context, data): + """Generate a user id from data. + + We try a few options for coming up with a good user id: + + 1. We query a utility, so integrators can register a hook to + generate a user id using their own logic. + + 2. If use_uuid_as_userid is set in the registry, we + generate a uuid. + + 3. If a username is given and we do not use email as login, + then we simply return that username as the user id. + + 4. We create a user id based on the full name, if that is + passed. This may result in an id like bob-jones-2. + + When the email address is used as login name, we originally + used the email address as user id as well. This has a few + possible downsides, which are the main reasons for the new, + pluggable approach: + + - It does not work for some valid email addresses. + + - Exposing the email address in this way may not be wanted. + + - When the user later changes his email address, the user id + will still be his old address. It works, but may be + confusing. + + Another possibility would be to simply generate a uuid, but that + is ugly. We could certainly try that though: the big plus here + would be that you then cannot create a new user with the same user + id as a previously existing user if this ever gets removed. If + you would get the same id, this new user would get the same global + and local roles, if those have not been cleaned up. + + When a user id is chosen, the 'user_id' key of the data gets + set and the user id is returned. + + :param context: An acquisition-wrapped Plone context object. + :param data: A dict with optional keys 'username', 'email', 'fullname'. + Sets data['user_id'] as a side effect. + :returns: The generated user_id string. + """ + generator = queryUtility(IUserIdGenerator) + if generator: + userid = generator(data) + if userid: + data["user_id"] = userid + return userid + + registry = getUtility(IRegistry) + settings = registry.forInterface(ISecuritySchema, prefix="plone") + + if settings.use_uuid_as_userid: + userid = uuid_userid_generator() + data["user_id"] = userid + return userid + + # We may have a username already. + userid = data.get("username") + if userid: + # If we are not using email as login, then this is fine as + # user id. + if not settings.use_email_as_login: + data["user_id"] = userid + return userid + + # First get a default value that we can return if we cannot + # find anything better. + pas = getToolByName(context, "acl_users") + email = pas.applyTransform(data.get("email")) + default = data.get("username") or email or "" + data["user_id"] = default + fullname = data.get("fullname") + if not fullname: + return default + userid = getUtility(IIDNormalizer).normalize(fullname) + registration = getToolByName(context, "portal_registration") + if hasattr(registration, "_ALLOWED_MEMBER_ID_PATTERN"): + if not registration._ALLOWED_MEMBER_ID_PATTERN.match(userid): + return default + if registration.isMemberIdAllowed(userid): + data["user_id"] = userid + return userid + # Try bob-jones-1, bob-jones-2, etc. + idx = 1 + while idx <= RENAME_AFTER_CREATION_ATTEMPTS: + new_id = "%s-%d" % (userid, idx) + if registration.isMemberIdAllowed(new_id): + data["user_id"] = new_id + return new_id + idx += 1 + + return default + + +def generate_login_name(context, data): + """Generate a login name from data. + + Usually the login name and user id are the same, but this is + not necessarily true. When using the email address as login + name, we may have a different user id, generated by calling + the generate_user_id function. + + We try a few options for coming up with a good login name: + + 1. We query a utility, so integrators can register a hook to + generate a login name using their own logic. + + 2. If a username is given and we do not use email as login, + then we simply return that username as the login name. + + 3. When using email as login, we use the email address. + + In all cases, we call PAS.applyTransform on the login name, if + that is defined. This is a recent addition to PAS, currently + under development. + + When a login name is chosen, the 'login_name' key of the data gets + set and the login name is returned. + + :param context: An acquisition-wrapped Plone context object. + :param data: A dict with optional keys 'username', 'email'. + Sets data['login_name'] as a side effect. + :returns: The generated login_name string. + """ + pas = getToolByName(context, "acl_users") + generator = queryUtility(ILoginNameGenerator) + if generator: + login_name = generator(data) + if login_name: + login_name = pas.applyTransform(login_name) + data["login_name"] = login_name + return login_name + + login_name = data.get("username") + login_name = pas.applyTransform(login_name) + data["login_name"] = login_name + + registry = getUtility(IRegistry) + settings = registry.forInterface(ISecuritySchema, prefix="plone") + if not settings.use_email_as_login: + return login_name + + login_name = data.get("email") + login_name = pas.applyTransform(login_name) + data["login_name"] = login_name + return login_name + + def notifyWidgetActionExecutionError(action, widget, err_str): zope.event.notify( ActionErrorOccurred( From 1e27277d16a3d3dbcbe63ebf87e2b37e5e490488 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Tue, 17 Mar 2026 16:11:06 +0100 Subject: [PATCH 2/4] Fix isort formatting in utils.py Co-Authored-By: Claude Opus 4.6 --- src/plone/app/users/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/plone/app/users/utils.py b/src/plone/app/users/utils.py index 09e1fc6a..d7257e50 100644 --- a/src/plone/app/users/utils.py +++ b/src/plone/app/users/utils.py @@ -13,7 +13,6 @@ import zope.event - RENAME_AFTER_CREATION_ATTEMPTS = 100 From 79b9a2f7fa29f412453cda0fbdee0307d0cc3e15 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 20 Mar 2026 00:43:33 +0100 Subject: [PATCH 3/4] Update news/4292.feature Co-authored-by: David Glick --- news/4292.feature | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/news/4292.feature b/news/4292.feature index b3165d6c..3be248f5 100644 --- a/news/4292.feature +++ b/news/4292.feature @@ -1,3 +1 @@ -Extract ``generate_user_id`` and ``generate_login_name`` as standalone functions -in ``plone.app.users.utils``, enabling reuse from ``plone.api`` and ``plone.restapi`` -without form view dependency. +Extract ``generate_user_id`` and ``generate_login_name`` as standalone functions in ``plone.app.users.utils``, enabling reuse from ``plone.api`` and ``plone.restapi`` without form view dependency. @jensens From 76fb2d71ec29f8ab94252fb336e1402f88f8980f Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Fri, 20 Mar 2026 00:51:55 +0100 Subject: [PATCH 4/4] Use zope.deferredimport.deprecated for RENAME_AFTER_CREATION_ATTEMPTS Mark the backwards-compat re-export with a deprecation warning so it can be removed in Plone 7. Consumers should import from plone.app.users.utils instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/plone/app/users/browser/register.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/plone/app/users/browser/register.py b/src/plone/app/users/browser/register.py index 21abf008..806c2b46 100644 --- a/src/plone/app/users/browser/register.py +++ b/src/plone/app/users/browser/register.py @@ -7,7 +7,6 @@ from plone.app.users.utils import generate_login_name as _generate_login_name from plone.app.users.utils import generate_user_id as _generate_user_id from plone.app.users.utils import notifyWidgetActionExecutionError -from plone.app.users.utils import RENAME_AFTER_CREATION_ATTEMPTS # noqa: F401 from plone.autoform.form import AutoExtensibleForm from plone.base import PloneMessageFactory as _ from plone.base.interfaces import ISecuritySchema @@ -29,10 +28,16 @@ from zope.component import getAdapter from zope.component import getMultiAdapter from zope.component import getUtility +from zope.deferredimport import deprecated from zope.schema import getFieldNames import logging +deprecated( + "Import from plone.app.users.utils instead.", + RENAME_AFTER_CREATION_ATTEMPTS="plone.app.users.utils:RENAME_AFTER_CREATION_ATTEMPTS", +) + def getRegisterSchema(): schema = getSchema(