diff --git a/src/uproot/_static/monitor.js b/src/uproot/_static/monitor.js
index fa4135a..0acec4c 100644
--- a/src/uproot/_static/monitor.js
+++ b/src/uproot/_static/monitor.js
@@ -651,3 +651,35 @@ window.actuallyRedirect = function() {
uproot.alert("The action has completed.");
});
};
+
+window.actuallyGroup = function() {
+ const action = uproot.selectedValue("group_action");
+
+ if (!action) {
+ return uproot.error(_("No action selected."));
+ }
+
+ const groupSize = parseInt(I("group-size")?.value ?? "2", 10);
+ const shuffle = !!I("group-shuffle")?.checked;
+ const reload = !!I("group-reload")?.checked;
+
+ if (action === "by_size" && (isNaN(groupSize) || groupSize < 1)) {
+ return uproot.error(_("Group size must be at least 1."));
+ }
+
+ window.bootstrap?.Modal.getOrCreateInstance(I("group-modal")).hide();
+ window.invokeFromMonitor("group_players", { action, group_size: groupSize, shuffle, reload })
+ .then((result) => {
+ loadExtraData();
+ if (result.groups_created) {
+ uproot.alert(_("Created #n# group(s).").replace("#n#", result.groups_created));
+ } else if (result.players_reset !== undefined) {
+ uproot.alert(_("Reset group assignment for #n# player(s).").replace("#n#", result.players_reset));
+ } else {
+ uproot.alert(_("The action has completed."));
+ }
+ })
+ .catch((err) => {
+ uproot.error(err.message || _("An error occurred."));
+ });
+};
diff --git a/src/uproot/admin.py b/src/uproot/admin.py
index abf816d..4850178 100644
--- a/src/uproot/admin.py
+++ b/src/uproot/admin.py
@@ -52,6 +52,7 @@
adminmessage,
advance_by_one,
fields_from_all,
+ group_players,
info_online,
insert_fields,
mark_dropout,
@@ -113,6 +114,7 @@
"adminmessage",
"advance_by_one",
"fields_from_all",
+ "group_players",
"info_online",
"insert_fields",
"mark_dropout",
diff --git a/src/uproot/default/admin/Session.html b/src/uproot/default/admin/Session.html
index f99360a..6cb2026 100644
--- a/src/uproot/default/admin/Session.html
+++ b/src/uproot/default/admin/Session.html
@@ -226,6 +226,11 @@
{% translate %}Player monitor
{% translate %}Redirect to URL{% endtranslate %}
+
+
+ {% translate %}Group players{% endtranslate %}
+
+
@@ -326,6 +331,48 @@ {% translate %}Player monitor
{% endcall %}
+
+{% call modal(_, "group", title=_("Group players"), action_text=_("Apply grouping"), action_handler="actuallyGroup()") %}
+{{ player_count(_) }}
+
+
+
+
+
+
+{% endcall %}
+
diff --git a/src/uproot/server2.py b/src/uproot/server2.py
index e1f8aae..b4527ad 100644
--- a/src/uproot/server2.py
+++ b/src/uproot/server2.py
@@ -1073,6 +1073,7 @@ async def dummy(
fields_from_all=a.fields_from_all,
flip_active=a.flip_active,
flip_testing=a.flip_testing,
+ group_players=a.group_players,
insert_fields=a.insert_fields,
mark_dropout=a.mark_dropout,
praise=a.praise,
diff --git a/src/uproot/services/player_service.py b/src/uproot/services/player_service.py
index 88b408e..2627f28 100644
--- a/src/uproot/services/player_service.py
+++ b/src/uproot/services/player_service.py
@@ -224,3 +224,103 @@ async def adminmessage(sname: t.Sessionname, unames: list[str], msg: str) -> Non
event="_uproot_AdminMessaged",
),
)
+
+
+async def group_players(
+ sname: t.Sessionname,
+ unames: list[str],
+ action: str,
+ group_size: int = 1,
+ shuffle: bool = False,
+ reload: bool = False,
+) -> dict[str, Any]:
+ """
+ Manage player group assignments.
+
+ Actions:
+ - "same_group": Put all selected players in the same group
+ - "reset": Remove group assignments from selected players
+ - "by_size": Create groups of specified size
+
+ Args:
+ sname: Session name
+ unames: List of player usernames
+ action: One of "same_group", "reset", "by_size"
+ group_size: Size of groups when action is "by_size"
+ shuffle: Whether to shuffle players before grouping (for "by_size")
+ reload: Whether to reload player pages after grouping
+
+ Returns:
+ Result dict with info about created/modified groups
+ """
+ import random
+
+ import uproot.core as c
+
+ session_exists(sname, False)
+
+ sid = t.SessionIdentifier(sname)
+ pids = [t.PlayerIdentifier(sname, uname) for uname in unames]
+
+ # Shuffle players if requested
+ if shuffle:
+ random.shuffle(pids)
+
+ result: dict[str, Any] = {"action": action, "players": unames}
+
+ with sid() as session:
+ if action == "same_group":
+ # Put all selected players in the same group
+ gid = c.create_group(session, pids, overwrite=True)
+ result["groups_created"] = 1
+ result["group_name"] = gid.gname
+
+ elif action == "reset":
+ # Remove group assignments from selected players
+ reset_count = 0
+ for pid in pids:
+ with pid() as player:
+ if player._uproot_group is not None:
+ player._uproot_group = None
+ player.member_id = None
+ reset_count += 1
+ result["players_reset"] = reset_count
+
+ elif action == "by_size":
+ # Create groups of specified size
+ if group_size < 1:
+ raise ValueError("Group size must be at least 1")
+ if len(pids) % group_size != 0:
+ raise ValueError(
+ f"Number of selected players ({len(pids)}) "
+ f"must be divisible by group size ({group_size})"
+ )
+
+ groups_created = []
+ for i in range(0, len(pids), group_size):
+ group_pids = pids[i : i + group_size]
+ gid = c.create_group(session, group_pids, overwrite=True)
+ groups_created.append(gid.gname)
+
+ result["groups_created"] = len(groups_created)
+ result["group_names"] = groups_created
+
+ else:
+ raise ValueError(f"Unknown action: {action}")
+
+ # Optionally reload player pages
+ if reload:
+ for uname in unames:
+ ptuple = sname, uname
+ q.enqueue(
+ ptuple,
+ dict(
+ source="admin",
+ kind="action",
+ payload=dict(
+ action="reload",
+ ),
+ ),
+ )
+
+ return result