Simplified user info update APIs, DB and frontend to remove separate update functions for each property. Automatically choose preferred username for users as they register, based on display name.
This commit is contained in:
@@ -808,7 +808,7 @@ async function submitDialog() {
|
||||
|
||||
// Close dialog immediately, then perform async operation
|
||||
closeDialog()
|
||||
apiJson(`/auth/api/admin/users/${user.uuid}/display-name`, { method: 'PATCH', body: { display_name: name } })
|
||||
apiJson(`/auth/api/admin/users/${user.uuid}/info`, { method: 'PATCH', body: { display_name: name } })
|
||||
.then(() => {
|
||||
authStore.showMessage(`User renamed to "${name}".`, 'success', 2500)
|
||||
onUserNameSaved()
|
||||
|
||||
@@ -189,7 +189,7 @@ defineExpose({ focusFirstElement })
|
||||
:loading="loading"
|
||||
:org-display-name="userDetail.org.display_name"
|
||||
:role-name="userDetail.role"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/display-name`"
|
||||
:update-endpoint="`/auth/api/admin/users/${selectedUser.uuid}/info`"
|
||||
@saved="$emit('onUserNameSaved')"
|
||||
@edit="handleEditName"
|
||||
>
|
||||
|
||||
@@ -817,7 +817,7 @@ th {
|
||||
display: grid;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
padding: 1.1rem 1.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
:loading="authStore.isLoading"
|
||||
:org-display-name="authStore.userInfo.ctx.org.display_name"
|
||||
:role-name="authStore.userInfo.ctx.role.display_name"
|
||||
update-endpoint="/auth/api/user/display-name"
|
||||
update-endpoint="/auth/api/user/info"
|
||||
@saved="authStore.loadUserInfo()"
|
||||
@edit="openEditDialog"
|
||||
@keydown="handleUserInfoKeydown"
|
||||
@@ -397,17 +397,13 @@ const saveProfile = async () => {
|
||||
try {
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
const tasks = []
|
||||
if (name !== user.display_name)
|
||||
tasks.push(apiJson('/auth/api/user/display-name', { method: 'PATCH', body: { display_name: name } }))
|
||||
if (emailVal !== (user.email || null))
|
||||
tasks.push(apiJson('/auth/api/user/email', { method: 'PATCH', body: { email: emailVal } }))
|
||||
if (usernameVal !== (user.preferred_username || null))
|
||||
tasks.push(apiJson('/auth/api/user/preferred-username', { method: 'PATCH', body: { preferred_username: usernameVal } }))
|
||||
if (telephoneVal !== (user.telephone || null))
|
||||
tasks.push(apiJson('/auth/api/user/telephone', { method: 'PATCH', body: { telephone: telephoneVal } }))
|
||||
if (tasks.length) {
|
||||
await Promise.all(tasks)
|
||||
const body = {}
|
||||
if (name !== user.display_name) body.display_name = name
|
||||
if (emailVal !== (user.email || null)) body.email = emailVal
|
||||
if (usernameVal !== (user.preferred_username || null)) body.preferred_username = usernameVal
|
||||
if (telephoneVal !== (user.telephone || null)) body.telephone = telephoneVal
|
||||
if (Object.keys(body).length) {
|
||||
await apiJson('/auth/api/user/info', { method: 'PATCH', body })
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Profile updated!', 'success', 3000)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ const userLoaded = computed(() => !!props.name)
|
||||
.info-fields-block { grid-area: fields; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
|
||||
.contact-item { display: block; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-link { color: var(--color-text); text-decoration: none; display: block; transition: transform 0.1s ease; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.contact-link:hover { transform: scale(1.05); }
|
||||
.contact-link:hover { transform: scale(1.01); }
|
||||
.info-line { grid-area: info; line-height: 1.4; font-size: 0.9em; }
|
||||
.info-date { color: var(--color-text) !important; }
|
||||
.info-label { color: var(--color-text) !important; }
|
||||
|
||||
@@ -62,11 +62,9 @@ from paskia.db.operations import (
|
||||
update_role_name,
|
||||
update_session,
|
||||
update_user_display_name,
|
||||
update_user_email,
|
||||
update_user_preferred_username,
|
||||
update_user_info,
|
||||
update_user_role,
|
||||
update_user_telephone,
|
||||
update_user_theme,
|
||||
is_username_taken,
|
||||
)
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
@@ -149,11 +147,9 @@ __all__ = [
|
||||
"update_role_name",
|
||||
"update_session",
|
||||
"update_user_display_name",
|
||||
"update_user_email",
|
||||
"update_user_preferred_username",
|
||||
"update_user_info",
|
||||
"update_user_role",
|
||||
"update_user_telephone",
|
||||
"update_user_theme",
|
||||
"is_username_taken",
|
||||
# OIDC
|
||||
"create_oid_client",
|
||||
"update_oid_client",
|
||||
|
||||
+76
-54
@@ -31,6 +31,7 @@ from paskia.db.structs import (
|
||||
User,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.nameutil import slugify_name
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,6 +45,17 @@ _db._store = _store
|
||||
_initialized = False
|
||||
|
||||
|
||||
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
|
||||
"""Check if a preferred_username is already taken by another user."""
|
||||
if not username:
|
||||
return False
|
||||
for user in _db.users.values():
|
||||
if user.preferred_username == username:
|
||||
if exclude_uuid is None or user.uuid != exclude_uuid:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Write operations (validate, modify, commit or raise ValueError)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -261,93 +273,103 @@ def update_user_display_name(
|
||||
The acting user should be logged via ctx.
|
||||
For self-service (user updating own name), pass user's ctx.
|
||||
For admin operations, pass admin's ctx.
|
||||
|
||||
If the user's preferred_username is currently None, this will auto-fill it
|
||||
with a slugified version of the display name (if unique and non-empty).
|
||||
"""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
display_name = (display_name or "").strip()
|
||||
if not display_name:
|
||||
raise ValueError("Display name cannot be empty")
|
||||
user = _db.users[uuid]
|
||||
with _db.transaction("update_user_display_name", ctx):
|
||||
_db.users[uuid].display_name = display_name
|
||||
user.display_name = display_name
|
||||
# Auto-fill preferred_username if not already set
|
||||
if user.preferred_username is None:
|
||||
slug = slugify_name(display_name)
|
||||
if slug and not is_username_taken(slug, exclude_uuid=uuid):
|
||||
user.preferred_username = slug
|
||||
|
||||
|
||||
def update_user_theme(
|
||||
def update_user_info(
|
||||
uuid: UUID,
|
||||
theme: str,
|
||||
*,
|
||||
display_name: str | object = _UNSET,
|
||||
theme: str | object = _UNSET,
|
||||
email: str | None | object = _UNSET,
|
||||
preferred_username: str | None | object = _UNSET,
|
||||
telephone: str | None | object = _UNSET,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user theme preference ('' for auto, 'light', 'dark')."""
|
||||
"""Update user profile information.
|
||||
|
||||
Pass only the fields you want to update. Use None to clear optional fields.
|
||||
This does NOT auto-fill preferred_username - use update_user_display_name
|
||||
for the registration flow where auto-fill is desired.
|
||||
|
||||
Args:
|
||||
uuid: User UUID
|
||||
display_name: User display name (cannot be empty)
|
||||
theme: Theme preference ('' for auto, 'light', 'dark')
|
||||
email: Email address (None to clear)
|
||||
preferred_username: Username for OIDC claims (None to clear, must be unique)
|
||||
telephone: Phone number (None to clear)
|
||||
ctx: Session context for audit logging
|
||||
"""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if theme not in ("", "light", "dark"):
|
||||
raise ValueError(f"Invalid theme: {theme}")
|
||||
with _db.transaction("update_user_theme", ctx):
|
||||
_db.users[uuid].theme = theme
|
||||
|
||||
user = _db.users[uuid]
|
||||
|
||||
def update_user_email(
|
||||
uuid: UUID,
|
||||
email: str | None,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user email for OIDC email claim. Can be None to clear."""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if email is not None:
|
||||
# Validate all fields before transaction
|
||||
if display_name is not _UNSET:
|
||||
display_name = (display_name or "").strip()
|
||||
if not display_name:
|
||||
raise ValueError("Display name cannot be empty")
|
||||
|
||||
if theme is not _UNSET:
|
||||
if theme not in ("", "light", "dark"):
|
||||
raise ValueError(f"Invalid theme: {theme}")
|
||||
|
||||
if email is not _UNSET and email is not None:
|
||||
email = (email or "").strip()
|
||||
if not email:
|
||||
email = None
|
||||
elif "@" not in email or len(email) > 254:
|
||||
raise ValueError("Invalid email format")
|
||||
with _db.transaction("update_user_email", ctx):
|
||||
_db.users[uuid].email = email
|
||||
|
||||
|
||||
def update_user_preferred_username(
|
||||
uuid: UUID,
|
||||
preferred_username: str | None,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user preferred_username for OIDC preferred_username claim. Can be None to clear."""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if preferred_username is not None:
|
||||
if preferred_username is not _UNSET and preferred_username is not None:
|
||||
preferred_username = (preferred_username or "").strip()
|
||||
if not preferred_username:
|
||||
preferred_username = None
|
||||
elif len(preferred_username) > 128:
|
||||
raise ValueError("Preferred username cannot be empty (use None to clear)")
|
||||
if len(preferred_username) > 128:
|
||||
raise ValueError("preferred_username too long")
|
||||
with _db.transaction("update_user_preferred_username", ctx):
|
||||
_db.users[uuid].preferred_username = preferred_username
|
||||
if is_username_taken(preferred_username, exclude_uuid=uuid):
|
||||
raise ValueError("Username already taken")
|
||||
|
||||
|
||||
def update_user_telephone(
|
||||
uuid: UUID,
|
||||
telephone: str | None,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update user telephone number. Can be None to clear."""
|
||||
if isinstance(uuid, str):
|
||||
uuid = UUID(uuid)
|
||||
if uuid not in _db.users:
|
||||
raise ValueError(f"User {uuid} not found")
|
||||
if telephone is not None:
|
||||
if telephone is not _UNSET and telephone is not None:
|
||||
telephone = (telephone or "").strip()
|
||||
if not telephone:
|
||||
telephone = None
|
||||
elif len(telephone) > 32:
|
||||
raise ValueError("telephone too long")
|
||||
with _db.transaction("update_user_telephone", ctx):
|
||||
_db.users[uuid].telephone = telephone
|
||||
|
||||
with _db.transaction("update_user_info", ctx):
|
||||
if display_name is not _UNSET:
|
||||
user.display_name = display_name
|
||||
if theme is not _UNSET:
|
||||
user.theme = theme
|
||||
if email is not _UNSET:
|
||||
user.email = email
|
||||
if preferred_username is not _UNSET:
|
||||
user.preferred_username = preferred_username
|
||||
if telephone is not _UNSET:
|
||||
user.telephone = telephone
|
||||
|
||||
|
||||
def update_user_role(
|
||||
|
||||
+23
-83
@@ -607,13 +607,17 @@ async def admin_get_user_detail(
|
||||
)
|
||||
|
||||
|
||||
@app.patch("/users/{user_uuid}/display-name")
|
||||
async def admin_update_user_display_name(
|
||||
@app.patch("/users/{user_uuid}/info")
|
||||
async def admin_update_user_info(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update user profile info (display_name, email, preferred_username, telephone).
|
||||
|
||||
Pass only the fields you want to update. Use null to clear optional fields.
|
||||
"""
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
@@ -628,90 +632,26 @@ async def admin_update_user_display_name(
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
if len(new_name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
db.update_user_display_name(user_uuid, new_name, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
kwargs = {}
|
||||
if "display_name" in payload:
|
||||
name = (payload["display_name"] or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="display_name cannot be empty")
|
||||
if len(name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
kwargs["display_name"] = name
|
||||
if "email" in payload:
|
||||
kwargs["email"] = payload["email"]
|
||||
if "preferred_username" in payload:
|
||||
kwargs["preferred_username"] = payload["preferred_username"]
|
||||
if "telephone" in payload:
|
||||
kwargs["telephone"] = payload["telephone"]
|
||||
|
||||
@app.patch("/users/{user_uuid}/email")
|
||||
async def admin_update_user_email(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
email = payload.get("email")
|
||||
db.update_user_email(user_uuid, email, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
if not kwargs:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
|
||||
@app.patch("/users/{user_uuid}/preferred-username")
|
||||
async def admin_update_user_preferred_username(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
preferred_username = payload.get("preferred_username")
|
||||
db.update_user_preferred_username(user_uuid, preferred_username, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/users/{user_uuid}/telephone")
|
||||
async def admin_update_user_telephone(
|
||||
user_uuid: UUID,
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
try:
|
||||
user = db.data().users[user_uuid]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
ctx = await authz.verify(
|
||||
auth,
|
||||
["auth:admin", "auth:org:admin"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
)
|
||||
if not can_manage_org(ctx, user.org.uuid):
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
telephone = payload.get("telephone")
|
||||
db.update_user_telephone(user_uuid, telephone, ctx=ctx)
|
||||
db.update_user_info(user_uuid, **kwargs, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
+45
-61
@@ -39,6 +39,7 @@ async def user_update_display_name(
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update display name only. Used by registration flow (auto-fills preferred_username)."""
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
@@ -58,6 +59,49 @@ async def user_update_display_name(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/info")
|
||||
async def user_update_info(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Update user profile info (display_name, email, preferred_username, telephone).
|
||||
|
||||
Pass only the fields you want to update. Use null to clear optional fields.
|
||||
Does NOT auto-fill preferred_username (unlike /display-name endpoint).
|
||||
"""
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
|
||||
kwargs = {}
|
||||
if "display_name" in payload:
|
||||
name = (payload["display_name"] or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="display_name cannot be empty")
|
||||
if len(name) > 64:
|
||||
raise HTTPException(status_code=400, detail="display_name too long")
|
||||
kwargs["display_name"] = name
|
||||
if "email" in payload:
|
||||
kwargs["email"] = payload["email"]
|
||||
if "preferred_username" in payload:
|
||||
kwargs["preferred_username"] = payload["preferred_username"]
|
||||
if "telephone" in payload:
|
||||
kwargs["telephone"] = payload["telephone"]
|
||||
|
||||
if not kwargs:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
db.update_user_info(ctx.user.uuid, **kwargs, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/theme")
|
||||
async def user_update_theme(
|
||||
request: Request,
|
||||
@@ -76,67 +120,7 @@ async def user_update_theme(
|
||||
theme = payload.get("theme", "")
|
||||
if theme not in ("", "light", "dark"):
|
||||
raise HTTPException(status_code=400, detail="Invalid theme")
|
||||
db.update_user_theme(ctx.user.uuid, theme, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/email")
|
||||
async def user_update_email(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
email = payload.get("email")
|
||||
db.update_user_email(ctx.user.uuid, email, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/preferred-username")
|
||||
async def user_update_preferred_username(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
preferred_username = payload.get("preferred_username")
|
||||
db.update_user_preferred_username(ctx.user.uuid, preferred_username, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.patch("/telephone")
|
||||
async def user_update_telephone(
|
||||
request: Request,
|
||||
payload: dict = Body(...),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
ctx = db.data().session_ctx(auth, request.headers.get("host"))
|
||||
if not ctx:
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
telephone = payload.get("telephone")
|
||||
db.update_user_telephone(ctx.user.uuid, telephone, ctx=ctx)
|
||||
db.update_user_info(ctx.user.uuid, theme=theme, ctx=ctx)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Name and username utilities."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def slugify_name(name: str) -> str:
|
||||
"""Convert display name to slug-compatible username.
|
||||
|
||||
Uses dots as separators, preserves existing dots and dashes.
|
||||
Strips trailing parenthesized content and other unwanted punctuation.
|
||||
|
||||
Examples:
|
||||
'John Doe' → 'john.doe'
|
||||
'María José García' → 'maria.jose.garcia'
|
||||
'Jean-Pierre' → 'jean-pierre'
|
||||
'John.Doe' → 'john.doe'
|
||||
'John Doe (Admin)' → 'john.doe'
|
||||
' Multiple Spaces ' → 'multiple.spaces'
|
||||
|
||||
Returns empty string for empty/whitespace-only input.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
# Strip trailing parenthesized content (e.g., " (Admin)")
|
||||
name = re.sub(r"\s*\([^)]*\)\s*$", "", name)
|
||||
# Normalize unicode → ASCII equivalent (é → e, ñ → n)
|
||||
name = unicodedata.normalize("NFKD", name)
|
||||
name = name.encode("ascii", "ignore").decode("ascii")
|
||||
# Lowercase
|
||||
name = name.lower()
|
||||
# Replace non-alphanumeric (except . and -) with dots
|
||||
name = re.sub(r"[^a-z0-9.-]+", ".", name)
|
||||
# Collapse multiple dots
|
||||
name = re.sub(r"\.+", ".", name)
|
||||
# Strip leading/trailing dots
|
||||
return name.strip(".")
|
||||
Reference in New Issue
Block a user