Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
60bb42d
fix(rest): restore groups field in users overview with bulk computation
aschumann-virtualcable Jul 20, 2026
17007f2
Merge remote-tracking branch 'origin/master' into fix/rest-users-over…
aschumann-virtualcable Jul 20, 2026
14cf720
perf(rest): cache authenticator user/group overviews for the dashboard
janpenitent Jul 21, 2026
6d71081
chore(admin): rebuild static bundle with dashboard drill-down fixes
janpenitent Jul 21, 2026
dcfe87d
fix(admin): rebuild bundle to prevent white flash on navigation
janpenitent Jul 21, 2026
01b431a
perf(rest): cache pool user-service and restrained-pool overviews
janpenitent Jul 21, 2026
83f7ab8
Merge branch 'master' into master-fix-admin-white-flash
janpenitent Jul 21, 2026
1c403d2
chore(admin): rebuild static bundle after master merge
janpenitent Jul 21, 2026
62d58c8
Merge remote-tracking branch 'origin/master' into integration/dashboa…
aschumann-virtualcable Jul 22, 2026
cc38e8b
fix(rest): use sumarize arg in _build_items
aschumann-virtualcable Jul 22, 2026
ca96486
perf(rest): drop the service pools overview cache, invalidate the res…
aschumann-virtualcable Jul 22, 2026
81bbdf3
revert: restore files the master merge had rolled back
aschumann-virtualcable Jul 22, 2026
c657ae1
Merge remote-tracking branch 'origin/master' into integration/dashboa…
aschumann-virtualcable Jul 22, 2026
42107e8
Merge remote-tracking branch 'origin/master' into integration/dashboa…
aschumann-virtualcable Jul 22, 2026
f76fbd3
revert(rest): drop the admin overview caches
aschumann-virtualcable Jul 22, 2026
ebca5d3
Update translations and HTML script references
aschumann-virtualcable Jul 22, 2026
8046801
Merge remote-tracking branch 'origin/master' into integration/dashboa…
aschumann-virtualcable Jul 24, 2026
6ba2a23
Update script references in admin index.html with new version stamps
aschumann-virtualcable Jul 24, 2026
34c88b1
Merge remote-tracking branch 'origin/master' into integration/dashboa…
aschumann-virtualcable Jul 27, 2026
af8798d
chore(admin): rebuild bundle from integration-dashboard-etag
aschumann-virtualcable Jul 27, 2026
1ab48e7
fix(admin): update script integrity and timestamp in index.html
aschumann-virtualcable Jul 27, 2026
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: 44 additions & 3 deletions src/uds/REST/methods/users_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,41 @@ def get_service_pools_for_groups(
yield servicepool


def bulk_groups_of_users(users: list['User'], authenticator: 'Authenticator') -> dict[str, list[str]]:
"""
Bulk equivalent of User.get_groups() for a list of users: direct non-meta groups
(resolved through the parent user, as get_groups does) plus the authenticator
metagroups each user belongs to.

Note: users must come from a queryset with prefetch_related('groups'), so the
whole computation costs a fixed number of queries (~6, regardless of the number
of users) instead of O(N) per-user ones.
"""
direct: dict[str, list[str]] = {u.uuid: [g.uuid for g in u.groups.all() if not g.is_meta] for u in users}
parents: dict[str, list[str]] = {
u.uuid: [g.uuid for g in u.groups.all() if not g.is_meta]
for u in User.objects.filter(uuid__in={u.parent for u in users if u.parent}).prefetch_related('groups')
}
metagroups = [
(m.uuid, m.meta_if_any, {g.uuid for g in m.groups.all()})
for m in authenticator.groups.filter(is_meta=True).prefetch_related('groups')
]

result: dict[str, list[str]] = {}
for u in users:
# If the parent is missing, get_groups falls back to the user's own groups
groups = parents.get(u.parent, direct[u.uuid]) if u.parent else direct[u.uuid]
direct_set = set(groups)
result[u.uuid] = groups + [
meta_uuid
for meta_uuid, meta_if_any, members in metagroups
if not members # Empty metagroup: everyone belongs (0 == 0 on get_groups)
or (meta_if_any and not members.isdisjoint(direct_set))
or (not meta_if_any and members <= direct_set)
]
return result


@dataclasses.dataclass
class UserItem(types.rest.BaseRestItem):
id: str
Expand Down Expand Up @@ -149,7 +184,6 @@ def as_user_item(user: "User") -> UserItem:
last_access=user.last_access,
mfa_data=user.mfa_data,
parent=user.parent,
groups=[i.uuid for i in user.get_groups()],
role=user.get_role().as_str(),
)

Expand All @@ -171,8 +205,15 @@ def get_item_position(self, parent: "Model", item_uuid: str) -> int:
def get_items(self, parent: "Model") -> types.rest.ItemsResult[UserItem]:
parent = ensure.is_instance(parent, Authenticator)

# Extract authenticator
return [self.as_user_item(i) for i in self.odata_filter(parent.users.all())]
users = self.odata_filter(parent.users.prefetch_related('groups'))
# groups filled in bulk; per-user get_groups() would cost O(N) extra queries
groups_of = bulk_groups_of_users(users, parent)
items: list[UserItem] = []
for u in users:
item = self.as_user_item(u)
item.groups = groups_of[u.uuid]
items.append(item)
return items

@typing.override
def get_item(self, parent: "Model", item: str) -> UserItem:
Expand Down
126 changes: 63 additions & 63 deletions src/uds/static/admin/main.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/uds/static/admin/polyfills.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/uds/static/admin/styles.css

Large diffs are not rendered by default.

38 changes: 23 additions & 15 deletions src/uds/static/admin/translations-fakejs.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// "Fake" javascript file for translations
// Typescript
gettext("Item changed by another administrator");
gettext("Error saving element");
gettext("Error handling your request");
gettext("Search");
Expand Down Expand Up @@ -123,6 +124,28 @@ gettext("Edit server");
gettext("Delete server");
gettext("In Maintenance");
gettext("Active");
gettext("User");
gettext("Name");
gettext("Authenticator");
gettext("State");
gettext("Last access");
gettext("Role");
gettext("Group");
gettext("Authenticator");
gettext("Comments");
gettext("State");
gettext("Name");
gettext("State");
gettext("User services");
gettext("In preparation");
gettext("Usage");
gettext("Pool group");
gettext("Name");
gettext("Service pool");
gettext("Unique ID");
gettext("State");
gettext("IP");
gettext("In use");
gettext("Last 7 days");
gettext("Last 30 days");
gettext("Last 90 days");
Expand Down Expand Up @@ -308,12 +331,6 @@ gettext("In Maintenance");
gettext("Active");
gettext("Error");
gettext("Please, select a valid server");
gettext("Maintenance");
gettext("Exit maintenance mode");
gettext("Enter maintenance mode");
gettext("Exit maintenance mode?");
gettext("Enter maintenance mode?");
gettext("Maintenance mode for");
gettext("Error");
gettext("This tunnel already has all the tunnel servers available");
gettext("Remove member from tunnel");
Expand Down Expand Up @@ -482,15 +499,6 @@ gettext("Sessions");
gettext("Pools used");
gettext("Hours");
gettext("Avg hours/session");
gettext("total users");
gettext("total groups");
gettext("with services");
gettext("View authenticators");
gettext("total pools");
gettext("View service pools");
gettext("total services");
gettext("assigned");
gettext("View service pools");
gettext("restrained services");
gettext("View service pools");
gettext("Summary");
Expand Down
4 changes: 2 additions & 2 deletions src/uds/templates/uds/admin/index.html

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions tests/REST/methods/services_pools/test_services_pools.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,18 @@ def test_service_pools(self) -> None:
db_pool = models.ServicePool.objects.get(uuid=service_pool["id"])
self.assertTrue(rest.assertions.assert_servicepool_is(db_pool, service_pool))

def test_overview_reflects_edit_immediately(self) -> None:
url = "servicespools/overview"

pool = models.ServicePool.objects.all()[0]
self.assertEqual(self.client.rest_get(url).status_code, 200)

pool.comments = "edited after a first listing"
pool.save()

edited = next(p for p in self.client.rest_get(url).json() if p["id"] == pool.uuid)
self.assertEqual(edited["comments"], pool.comments)

# ------------------------------------------------------------------
# CRUD smoke extension (Phase 1 — Safety net)
# ------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions tests/REST/methods/users_groups/test_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ def test_users(self) -> None:
# Locate the user in the auth
self.assertTrue(rest.assertions.assert_user_is(self.auth.users.get(name=user["name"]), user))

def test_users_overview_groups(self) -> None:
url = f'authenticators/{self.auth.uuid}/users'

response = self.client.rest_get(f'{url}/overview')
self.assertEqual(response.status_code, 200)
for user in response.json():
db_user = self.auth.users.get(name=user['name'])
# Bulk-computed overview groups must match per-user get_groups() semantics
self.assertEqual(
set(user['groups']),
{g.uuid for g in db_user.get_groups()},
f'Groups of user {user["name"]} do not match',
)

def test_users_tableinfo(self) -> None:
url = f"authenticators/{self.auth.uuid}/users/tableinfo"

Expand Down
Loading