diff --git a/docassemble/ALDashboard/aldashboard.py b/docassemble/ALDashboard/aldashboard.py index 788d37f..d5123f6 100644 --- a/docassemble/ALDashboard/aldashboard.py +++ b/docassemble/ALDashboard/aldashboard.py @@ -581,36 +581,51 @@ def search_users_by_email( wordstart: str, limit: int = 20, exclude_privileged: bool = False, + search_names: bool = False, ) -> List[Tuple[int, str]]: - """ - Search for users whose email starts with the given text. Used by the input type: ajax field on large servers, - so we never load the full user table - just return a handful of matches as the admin types. + """Find a bounded list of users for AJAX selectors. + + By default, match email prefixes. With ``search_names``, match text anywhere + in the email or full display name, as in the small-server user picker. """ wordstart = wordstart.strip() if not wordstart: return [] + match = UserModel.email.istartswith(wordstart, autoescape=True) + if search_names: + full_name = ( + func.coalesce(UserModel.first_name, "") + + " " + + func.coalesce(UserModel.last_name, "") + ) + match = or_( + UserModel.email.icontains(wordstart, autoescape=True), + full_name.icontains(wordstart, autoescape=True), + UserModel.nickname.icontains(wordstart, autoescape=True), + ) statement = select( - UserModel.id, UserModel.email, UserModel.first_name, UserModel.last_name - ).where(UserModel.email.istartswith(wordstart)) + UserModel.id, + UserModel.email, + UserModel.first_name, + UserModel.last_name, + UserModel.nickname, + ).where(match) if exclude_privileged: statement = statement.where( ~UserModel.roles.any(Role.name.in_(["admin", "developer", "cron"])) ) - statement = statement.limit(limit) + statement = statement.order_by(UserModel.email, UserModel.id).limit(limit) with _get_db_session() as session: users = session.execute(statement).all() - results = [] - for user in users: - user_id, email, first_name, last_name = user - label = email - if first_name: - label += " " + first_name - if last_name: - label += " " + last_name - results.append((user_id, label)) - return results + results = [] + for user_id, email, first_name, last_name, nickname in users: + label = " ".join( + part for part in (email, first_name or nickname, last_name) if part + ) + results.append((user_id, label or f"User ID {user_id}")) + return results def speedy_get_users() -> List[Dict[int, str]]: diff --git a/docassemble/ALDashboard/data/questions/manage_users.yml b/docassemble/ALDashboard/data/questions/manage_users.yml index 739acf0..b09c96d 100644 --- a/docassemble/ALDashboard/data/questions/manage_users.yml +++ b/docassemble/ALDashboard/data/questions/manage_users.yml @@ -110,13 +110,13 @@ subquestion: | % endif % endif fields: - - User: chosen_user + - User: chosen_user_selection required: False - datatype: integer + datatype: text input type: combobox code: | [ - {user[0]: f"{user[2]} {user[3]} ({user[1]})".strip()} + {str(user[0]): f"{user[2]} {user[3]} ({user[1]})".strip()} for user in get_users_and_name( limit_to_non_admin_or_developers=not user_has_privilege(["admin", "developer"]) ) @@ -128,11 +128,15 @@ fields: manage_user_count <= 200 js show if: | val("user_task") != "recent_activity" - - User: chosen_user + - User: chosen_user_selection required: False - datatype: integer + datatype: text input type: ajax action: manage_user_search_ajax + trigger at: 2 + hint: Start typing a name or email address + under text: | + Enter at least 2 characters of a name or email address, then select a result. show if: code: | manage_user_count > 200 @@ -182,11 +186,17 @@ fields: code: | not "access_privileges" in user_info().permissions validation code: | - if user_task != "recent_activity" and not chosen_user: - validation_error("Select a user", field="chosen_user") + if user_task != "recent_activity" and not chosen_user_selection: + validation_error("Select a user", field="chosen_user_selection") if user_task == "reset_password" and new_user_password != new_user_password_2: validation_error("The passwords do not match", field="new_user_password_2") --- +# Keep the AJAX value as text: docassemble 1.9 cannot render integer defaults. +code: | + chosen_user = int(chosen_user_selection) +depends on: + - chosen_user_selection +--- event: manage_user_search_ajax code: | set_save_status("ignore") @@ -204,6 +214,7 @@ code: | [str(user_id), label] for user_id, label in search_users_by_email( original, + search_names=True, exclude_privileged=not user_has_privilege(["admin", "developer"]), ) ] diff --git a/docassemble/ALDashboard/test/test_user_account_manager.py b/docassemble/ALDashboard/test/test_user_account_manager.py index 98af99c..c7099fd 100644 --- a/docassemble/ALDashboard/test/test_user_account_manager.py +++ b/docassemble/ALDashboard/test/test_user_account_manager.py @@ -167,7 +167,9 @@ def test_manage_user_picker_adapts_to_server_size(): ) user_fields = [ - field for field in picker["fields"] if field.get("User") == "chosen_user" + field + for field in picker["fields"] + if field.get("User") == "chosen_user_selection" ] assert len(user_fields) == 2 assert user_fields[0]["input type"] == "combobox" @@ -330,3 +332,153 @@ def test_reset_email_confirmation_labels_an_account_without_email(): ) assert "no email address" in confirmation["subquestion"] + + +def test_user_picker_keeps_ajax_values_as_text_and_converts_for_management(): + documents = list( + YAML(typ="safe").load_all( + (PACKAGE_ROOT / "data/questions/manage_users.yml").read_text() + ) + ) + picker = next( + d + for d in documents + if isinstance(d, dict) and d.get("id") == "select user and management task" + ) + fields = [f for f in picker["fields"] if f.get("User") == "chosen_user_selection"] + assert all(f["datatype"] == "text" for f in fields) + assert fields[1]["trigger at"] == 2 + assert "name or email" in fields[1]["hint"] + assert "at least 2 characters" in fields[1]["under text"] + choices = eval( + fields[0]["code"], + { + "get_users_and_name": lambda **kwargs: [ + (42, "alice@example.com", "Alice", "Example") + ], + "user_has_privilege": lambda roles: True, + }, + ) + assert choices == [{"42": "Alice Example (alice@example.com)"}] + conversion = next( + d["code"] + for d in documents + if isinstance(d, dict) + and "chosen_user = int(chosen_user_selection)" in d.get("code", "") + ) + namespace = {"chosen_user_selection": "42"} + exec(conversion, namespace) + assert namespace["chosen_user"] == 42 + + errors = [] + + class ValidationError(Exception): + pass + + def validation_error(message, field): + errors.append((message, field)) + raise ValidationError + + namespace.update( + user_task="view_user_info", + chosen_user_selection="", + validation_error=validation_error, + ) + import pytest + + with pytest.raises(ValidationError): + exec(picker["validation code"], namespace) + assert errors == [("Select a user", "chosen_user_selection")] + namespace["user_task"] = "recent_activity" + exec(picker["validation code"], namespace) + + +def test_ajax_user_search_matches_names_emails_and_preserves_role_filter(): + from typing import List, Tuple + from sqlalchemy import ( + Column, + ForeignKey, + Integer, + String, + Table, + create_engine, + func, + or_, + select, + ) + from sqlalchemy.orm import Session, declarative_base, relationship + + Base = declarative_base() + user_roles = Table( + "user_roles", + Base.metadata, + Column("user_id", ForeignKey("users.id")), + Column("role_id", ForeignKey("roles.id")), + ) + + class Role(Base): + __tablename__ = "roles" + id = Column(Integer, primary_key=True) + name = Column(String) + + class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True) + email = Column(String) + first_name = Column(String) + last_name = Column(String) + nickname = Column(String) + roles = relationship(Role, secondary=user_roles) + + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + session.add_all( + [ + User( + id=1, + email="asmith@example.com", + first_name="Alice", + last_name="Smith", + ), + User(id=2, email=None, nickname="Alice Helper"), + User(id=3, email="alice@example.com", roles=[Role(name="admin")]), + User( + id=4, email="alice-dev@example.com", roles=[Role(name="developer")] + ), + User(id=5, email="alice-cron@example.com", roles=[Role(name="cron")]), + User(id=6, email="percent%name@example.com"), + ] + ) + session.commit() + + @contextmanager + def database_session(): + yield session + + namespace = dict( + List=List, + Tuple=Tuple, + UserModel=User, + Role=Role, + func=func, + or_=or_, + select=select, + _get_db_session=database_session, + ) + exec( + _function_sources({"search_users_by_email"})["search_users_by_email"], + namespace, + ) + search = namespace["search_users_by_email"] + assert search(" ALICE SM ", search_names=True) == [ + (1, "asmith@example.com Alice Smith") + ] + assert { + u[0] for u in search("alice", search_names=True, exclude_privileged=True) + } == {1, 2} + assert {u[0] for u in search("ALICE")} == {3, 4, 5} + assert len(search("alice", search_names=True, limit=2)) == 2 + assert search("%", search_names=True) == [(6, "percent%name@example.com")] + assert search(" ", search_names=True) == [] + assert search("no match", search_names=True) == []