Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions docassemble/ALDashboard/aldashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down
25 changes: 18 additions & 7 deletions docassemble/ALDashboard/data/questions/manage_users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@
% 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"])
)
Expand All @@ -128,11 +128,15 @@
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
Expand Down Expand Up @@ -182,11 +186,17 @@
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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small thing I noticed on my local server is if I type something that isn’t in the user list and click Continue I get an error page. This should show “Select a user” there instead

Suggested change
if user_task != "recent_activity" and not chosen_user_selection:
if user_task != "recent_activity" and not str(chosen_user_selection).isdigit():

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")
Expand All @@ -204,6 +214,7 @@
[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"]),
)
]
Expand All @@ -230,7 +241,7 @@
---
code: |
def valid_password(pword):
if not re.match(

Check warning on line 244 in docassemble/ALDashboard/data/questions/manage_users.yml

View workflow job for this annotation

GitHub Actions / build

WG123

code block defines function `valid_password`; move reusable helper functions to a Python module instead, or use inline code for small snippets that are only used once
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d!@#$%^&*()_+\-=\[\]{};:\'\"\\|,.<>\/?]{6,}$",
pword,
):
Expand Down Expand Up @@ -436,9 +447,9 @@
<td>${ format_date(interview.modtime, "MMM d YYYY") }</td>
<td>${ f"Step {interview.num_keys}" if not interview.progress else f"{interview.progress}%" }</td>
<td>
<a target="_blank" rel="noopener" href="${ interview_url(i=interview.filename, session=interview.key) }"><i class="fa-solid fa-folder-open"></i>&nbsp;Join live interview</a><br>

Check warning on line 450 in docassemble/ALDashboard/data/questions/manage_users.yml

View workflow job for this annotation

GitHub Actions / build

WA519

link in subquestion opens a new tab or window without warning text: <a target="_blank" rel="noopener" href="${ interview_url(i=interview.filename, session=interview.key) }"><i class="fa-s…
<a target="_blank" rel="noopener" href="${ interview_url_action('view_single_session', i=user_info().package + ':list_sessions.yml', new_session=1, session_id=interview.key) }"><i class="fa-solid fa-magnifying-glass"></i>&nbsp;Details and files</a><br>

Check warning on line 451 in docassemble/ALDashboard/data/questions/manage_users.yml

View workflow job for this annotation

GitHub Actions / build

WA519

link in subquestion opens a new tab or window without warning text: <a target="_blank" rel="noopener" href="${ interview_url_action('view_single_session', i=user_info().package + ':list_s…
<a target="_blank" rel="noopener" href="${ interview_url_action('view_session_variables', i=user_info().package + ':list_sessions.yml', new_session=1, session_id=interview.key) }"><i class="fa-solid fa-eye"></i>&nbsp;View answers</a>

Check warning on line 452 in docassemble/ALDashboard/data/questions/manage_users.yml

View workflow job for this annotation

GitHub Actions / build

WA519

link in subquestion opens a new tab or window without warning text: <a target="_blank" rel="noopener" href="${ interview_url_action('view_session_variables', i=user_info().package + ':lis…
</td>
</tr>
% endfor
Expand Down
154 changes: 153 additions & 1 deletion docassemble/ALDashboard/test/test_user_account_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) == []
Loading