mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Replace legacy admin group forms with Vue GroupForm component
Replace FormGeneric-based group create/edit views with a new GroupForm Vue component that uses the API directly. Add search/pagination support to roles and users API endpoints for the multiselect dropdowns. Remove legacy controller methods (create_group, manage_users_and_roles_for_group) that loaded all users/roles upfront causing slow page loads. Also add auto_create_role option to group creation API, fix selenium test selector for the new component's submit button, and enable the admin_user_display test for Playwright.
This commit is contained in:
@@ -13342,6 +13342,12 @@ export interface components {
|
||||
* @description Payload schema for creating a group.
|
||||
*/
|
||||
GroupCreatePayload: {
|
||||
/**
|
||||
* auto-create role
|
||||
* @description If true, create a new role with the same name as the group and associate it.
|
||||
* @default false
|
||||
*/
|
||||
auto_create_role: boolean;
|
||||
/** name of the group */
|
||||
name: string;
|
||||
/**
|
||||
@@ -40889,7 +40895,14 @@ export interface operations {
|
||||
};
|
||||
index_api_roles_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description Search by role name or user email (for private roles). */
|
||||
search?: string | null;
|
||||
/** @description The maximum number of roles to return. */
|
||||
limit?: number | null;
|
||||
/** @description Number of roles to skip. */
|
||||
offset?: number | null;
|
||||
};
|
||||
header?: {
|
||||
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
|
||||
"run-as"?: string | null;
|
||||
@@ -43324,6 +43337,10 @@ export interface operations {
|
||||
f_name?: string | null;
|
||||
/** @description Filter on username OR email */
|
||||
f_any?: string | null;
|
||||
/** @description Maximum number of users to return. */
|
||||
limit?: number | null;
|
||||
/** @description Number of users to skip. */
|
||||
offset?: number | null;
|
||||
};
|
||||
header?: {
|
||||
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import "vue-multiselect/dist/vue-multiselect.min.css";
|
||||
|
||||
import { faSave } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton, BFormCheckbox } from "bootstrap-vue";
|
||||
import { ref } from "vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import { useRouter } from "vue-router/composables";
|
||||
|
||||
import { GalaxyApi } from "@/api";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
import FormInput from "@/components/Form/Elements/FormInput.vue";
|
||||
import FormCard from "@/components/Form/FormCard.vue";
|
||||
import FormElementLabel from "@/components/Form/FormElementLabel.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface RoleOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
groupId?: string;
|
||||
}>();
|
||||
|
||||
const isEditMode = !!props.groupId;
|
||||
|
||||
const errorMessage = ref("");
|
||||
const loading = ref(false);
|
||||
const groupName = ref("");
|
||||
const selectedUsers = ref<UserOption[]>([]);
|
||||
const selectedRoles = ref<RoleOption[]>([]);
|
||||
const userOptions = ref<UserOption[]>([]);
|
||||
const roleOptions = ref<RoleOption[]>([]);
|
||||
const userSearch = ref("");
|
||||
const roleSearch = ref("");
|
||||
const autoCreateRole = ref(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function onUserSearch(searchValue: string) {
|
||||
userSearch.value = searchValue;
|
||||
if (searchValue.length < 3) {
|
||||
userOptions.value = [...selectedUsers.value];
|
||||
return;
|
||||
}
|
||||
const { data, error } = await GalaxyApi().GET("/api/users", {
|
||||
params: { query: { f_email: searchValue, limit: 50 } },
|
||||
});
|
||||
if (error) {
|
||||
errorMessage.value = errorMessageAsString(error);
|
||||
return;
|
||||
}
|
||||
const selectedIds = new Set(selectedUsers.value.map((u) => u.id));
|
||||
const filtered = data.filter((u) => u.email && !selectedIds.has(u.id)).map((u) => ({ id: u.id, email: u.email! }));
|
||||
userOptions.value = [...selectedUsers.value, ...filtered];
|
||||
}
|
||||
|
||||
async function onRoleSearch(searchValue: string) {
|
||||
roleSearch.value = searchValue;
|
||||
if (searchValue.length < 3) {
|
||||
roleOptions.value = [...selectedRoles.value];
|
||||
return;
|
||||
}
|
||||
const { data, error } = await GalaxyApi().GET("/api/roles", {
|
||||
params: { query: { search: searchValue, limit: 50 } },
|
||||
});
|
||||
if (error) {
|
||||
errorMessage.value = errorMessageAsString(error);
|
||||
return;
|
||||
}
|
||||
const selectedIds = new Set(selectedRoles.value.map((r) => r.id));
|
||||
const filtered = data.filter((r) => !selectedIds.has(r.id)).map((r) => ({ id: r.id, name: r.name }));
|
||||
roleOptions.value = [...selectedRoles.value, ...filtered];
|
||||
}
|
||||
|
||||
async function loadGroupData() {
|
||||
if (!props.groupId) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data: group, error: groupError } = await GalaxyApi().GET("/api/groups/{group_id}", {
|
||||
params: { path: { group_id: props.groupId } },
|
||||
});
|
||||
if (groupError) {
|
||||
errorMessage.value = errorMessageAsString(groupError);
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
groupName.value = group.name;
|
||||
|
||||
const { data: users, error: usersError } = await GalaxyApi().GET("/api/groups/{group_id}/users", {
|
||||
params: { path: { group_id: props.groupId } },
|
||||
});
|
||||
if (usersError) {
|
||||
errorMessage.value = errorMessageAsString(usersError);
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
selectedUsers.value = users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
}));
|
||||
userOptions.value = [...selectedUsers.value];
|
||||
|
||||
const { data: roles, error: rolesError } = await GalaxyApi().GET("/api/groups/{group_id}/roles", {
|
||||
params: { path: { group_id: props.groupId } },
|
||||
});
|
||||
if (rolesError) {
|
||||
errorMessage.value = errorMessageAsString(rolesError);
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
selectedRoles.value = roles.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
}));
|
||||
roleOptions.value = [...selectedRoles.value];
|
||||
} catch (e) {
|
||||
errorMessage.value = errorMessageAsString(e);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const userIds = selectedUsers.value.map((u) => u.id);
|
||||
const roleIds = selectedRoles.value.map((r) => r.id);
|
||||
|
||||
if (isEditMode) {
|
||||
const { error } = await GalaxyApi().PUT("/api/groups/{group_id}", {
|
||||
params: { path: { group_id: props.groupId! } },
|
||||
body: {
|
||||
user_ids: userIds,
|
||||
role_ids: roleIds,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
errorMessage.value = errorMessageAsString(error);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!groupName.value) {
|
||||
errorMessage.value = "Please enter a group name.";
|
||||
return;
|
||||
}
|
||||
const { error } = await GalaxyApi().POST("/api/groups", {
|
||||
body: {
|
||||
name: groupName.value,
|
||||
user_ids: userIds,
|
||||
role_ids: roleIds,
|
||||
auto_create_role: autoCreateRole.value,
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
errorMessage.value = errorMessageAsString(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push("/admin/groups");
|
||||
}
|
||||
|
||||
loadGroupData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingSpan v-if="loading" />
|
||||
<div v-else>
|
||||
<BAlert v-if="errorMessage" variant="danger" show>{{ errorMessage }}</BAlert>
|
||||
<FormCard :title="isEditMode ? `Group '${groupName}'` : 'Create a new Group'" icon="fa-users">
|
||||
<template v-slot:body>
|
||||
<FormElementLabel title="Name" :required="!isEditMode" :condition="!!groupName">
|
||||
<FormInput v-if="!isEditMode" id="admin-group-name-input" v-model="groupName" />
|
||||
<span v-else>{{ groupName }}</span>
|
||||
</FormElementLabel>
|
||||
|
||||
<FormElementLabel title="Users">
|
||||
<Multiselect
|
||||
id="admin-group-users-select"
|
||||
v-model="selectedUsers"
|
||||
:options="userOptions"
|
||||
:clear-on-select="true"
|
||||
:multiple="true"
|
||||
:internal-search="false"
|
||||
:max-height="300"
|
||||
label="email"
|
||||
track-by="id"
|
||||
placeholder="Search users by email..."
|
||||
@search-change="onUserSearch">
|
||||
<template slot="noResult">
|
||||
<div v-if="userSearch.length < 3">Enter at least 3 characters to search</div>
|
||||
<div v-else>No users found</div>
|
||||
</template>
|
||||
<template slot="noOptions">
|
||||
<div>Enter at least 3 characters to search</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</FormElementLabel>
|
||||
|
||||
<FormElementLabel title="Roles">
|
||||
<Multiselect
|
||||
id="admin-group-roles-select"
|
||||
v-model="selectedRoles"
|
||||
:options="roleOptions"
|
||||
:clear-on-select="true"
|
||||
:multiple="true"
|
||||
:internal-search="false"
|
||||
:max-height="300"
|
||||
label="name"
|
||||
track-by="id"
|
||||
placeholder="Search roles by name..."
|
||||
@search-change="onRoleSearch">
|
||||
<template slot="noResult">
|
||||
<div v-if="roleSearch.length < 3">Enter at least 3 characters to search</div>
|
||||
<div v-else>No roles found</div>
|
||||
</template>
|
||||
<template slot="noOptions">
|
||||
<div>Enter at least 3 characters to search</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</FormElementLabel>
|
||||
|
||||
<FormElementLabel v-if="!isEditMode" title="Auto-create role">
|
||||
<BFormCheckbox v-model="autoCreateRole">
|
||||
Create a new role with the same name as this group
|
||||
</BFormCheckbox>
|
||||
</FormElementLabel>
|
||||
</template>
|
||||
</FormCard>
|
||||
<BButton id="admin-group-submit" class="my-2" variant="primary" @click="onSubmit">
|
||||
<FontAwesomeIcon :icon="faSave" class="mr-1" />
|
||||
<span v-localize>{{ isEditMode ? "Save" : "Create" }}</span>
|
||||
</BButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ import DataTypes from "@/components/admin/DataTypes.vue";
|
||||
import ToolboxDependencies from "@/components/admin/Dependencies/Landing.vue";
|
||||
import DisplayApplications from "@/components/admin/DisplayApplications.vue";
|
||||
import ErrorStack from "@/components/admin/ErrorStack.vue";
|
||||
import GroupForm from "@/components/admin/GroupForm.vue";
|
||||
import JobsList from "@/components/admin/JobsList.vue";
|
||||
import BroadcastForm from "@/components/admin/Notifications/BroadcastForm.vue";
|
||||
import NotificationForm from "@/components/admin/Notifications/NotificationForm.vue";
|
||||
@@ -206,10 +207,9 @@ export default [
|
||||
},
|
||||
{
|
||||
path: "form/manage_users_and_roles_for_group",
|
||||
component: FormGeneric,
|
||||
component: GroupForm,
|
||||
props: (route) => ({
|
||||
url: `/admin/manage_users_and_roles_for_group?id=${route.query.id}`,
|
||||
redirect: "/admin/groups",
|
||||
groupId: route.query.id,
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -226,11 +226,7 @@ export default [
|
||||
},
|
||||
{
|
||||
path: "form/create_group",
|
||||
component: FormGeneric,
|
||||
props: {
|
||||
url: "/admin/create_group",
|
||||
redirect: "/admin/groups",
|
||||
},
|
||||
component: GroupForm,
|
||||
},
|
||||
{
|
||||
path: "form/create_quota",
|
||||
|
||||
@@ -1247,7 +1247,7 @@ admin:
|
||||
registration_form: 'form#registration'
|
||||
groups_grid: '#groups-grid'
|
||||
roles_grid: '#roles-grid'
|
||||
groups_create_view: '#submit:not([aria-disabled])'
|
||||
groups_create_view: '#admin-group-submit'
|
||||
|
||||
libraries:
|
||||
|
||||
|
||||
@@ -52,8 +52,19 @@ class GroupsManager:
|
||||
group = model.Group(name=name)
|
||||
sa_session.add(group)
|
||||
|
||||
role_ids = list(payload.role_ids)
|
||||
if payload.auto_create_role:
|
||||
existing_role = sa_session.scalars(select(model.Role).where(model.Role.name == name).limit(1)).first()
|
||||
if existing_role:
|
||||
raise Conflict(f"A role with name '{name}' already exists")
|
||||
role = model.Role(name=name, description=f"Role for group {name}")
|
||||
sa_session.add(role)
|
||||
sa_session.flush()
|
||||
gra = model.GroupRoleAssociation(group, role)
|
||||
sa_session.add(gra)
|
||||
|
||||
trans.app.security_agent.set_group_user_and_role_associations(
|
||||
group, user_ids=payload.user_ids, role_ids=payload.role_ids
|
||||
group, user_ids=payload.user_ids, role_ids=role_ids
|
||||
)
|
||||
sa_session.commit()
|
||||
|
||||
|
||||
@@ -66,8 +66,21 @@ class RoleManager(base.ModelManager[model.Role]):
|
||||
|
||||
return role
|
||||
|
||||
def list_displayable_roles(self, trans: ProvidesUserContext) -> list[Role]:
|
||||
return get_displayable_roles(trans.sa_session, trans.user, trans.user_is_admin, trans.app.security_agent)
|
||||
def list_displayable_roles(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
search: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[Role]:
|
||||
return get_displayable_roles(
|
||||
trans.sa_session,
|
||||
trans.user,
|
||||
trans.user_is_admin,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
def create_role(self, trans: ProvidesUserContext, role_definition_model: RoleDefinitionModel) -> model.Role:
|
||||
name = role_definition_model.name
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from sqlalchemy import (
|
||||
and_,
|
||||
false,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
|
||||
@@ -44,13 +46,44 @@ def get_roles_by_ids(session: galaxy_scoped_session, role_ids):
|
||||
return session.scalars(stmt).all()
|
||||
|
||||
|
||||
def get_displayable_roles(session, trans_user, user_is_admin, security_agent):
|
||||
roles = []
|
||||
def get_displayable_roles(
|
||||
session,
|
||||
trans_user,
|
||||
user_is_admin,
|
||||
search: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
):
|
||||
stmt = select(Role).where(Role.deleted == false())
|
||||
for role in session.scalars(stmt):
|
||||
if user_is_admin or security_agent.ok_to_display(trans_user, role):
|
||||
roles.append(role)
|
||||
return roles
|
||||
if not user_is_admin:
|
||||
if trans_user:
|
||||
# Non-admin users see: all non-private/non-sharing roles,
|
||||
# plus their own private role and their own sharing roles.
|
||||
user_role_ids = select(UserRoleAssociation.role_id).where(UserRoleAssociation.user_id == trans_user.id)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
~Role.type.in_((Role.types.PRIVATE, Role.types.SHARING)),
|
||||
and_(Role.type.in_((Role.types.PRIVATE, Role.types.SHARING)), Role.id.in_(user_role_ids)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Anonymous: exclude private and sharing roles entirely
|
||||
stmt = stmt.where(~Role.type.in_((Role.types.PRIVATE, Role.types.SHARING)))
|
||||
if search:
|
||||
# LEFT JOIN to User via UserRoleAssociation for private roles only,
|
||||
# so coalesce(User.email, Role.name) gives the displayed name.
|
||||
stmt = stmt.outerjoin(
|
||||
UserRoleAssociation,
|
||||
and_(UserRoleAssociation.role_id == Role.id, Role.type == Role.types.PRIVATE),
|
||||
).outerjoin(User, UserRoleAssociation.user_id == User.id)
|
||||
displayed_name = func.coalesce(User.email, Role.name)
|
||||
stmt = stmt.where(displayed_name.ilike(f"%{search}%"))
|
||||
stmt = stmt.order_by(Role.id)
|
||||
if offset:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return session.scalars(stmt).all()
|
||||
|
||||
|
||||
def get_private_role_user_emails_dict(session, role_ids: set[int] | None = None) -> dict[int, str]:
|
||||
|
||||
@@ -57,6 +57,8 @@ def get_users_for_index(
|
||||
is_admin: bool = False,
|
||||
expose_user_email: bool = False,
|
||||
expose_user_name: bool = False,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> Sequence[User]:
|
||||
stmt = select(User)
|
||||
if f_email and (is_admin or expose_user_email):
|
||||
@@ -77,6 +79,11 @@ def get_users_for_index(
|
||||
stmt = stmt.where(User.deleted == true())
|
||||
else:
|
||||
stmt = stmt.where(User.deleted == false())
|
||||
stmt = stmt.order_by(User.email)
|
||||
if offset:
|
||||
stmt = stmt.offset(offset)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return session.scalars(stmt).all()
|
||||
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@ class GroupCreatePayload(Model):
|
||||
[],
|
||||
title="role IDs",
|
||||
)
|
||||
auto_create_role: bool = Field(
|
||||
False,
|
||||
title="auto-create role",
|
||||
description="If true, create a new role with the same name as the group and associate it.",
|
||||
)
|
||||
|
||||
|
||||
@partial_model()
|
||||
|
||||
@@ -3,8 +3,12 @@ API operations on Role objects.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Body
|
||||
from fastapi import (
|
||||
Body,
|
||||
Query,
|
||||
)
|
||||
|
||||
from galaxy.managers.context import ProvidesUserContext
|
||||
from galaxy.schema.schema import (
|
||||
@@ -22,6 +26,24 @@ from galaxy.webapps.galaxy.services.roles import RolesService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SearchRolesQueryParam: Optional[str] = Query(
|
||||
default=None,
|
||||
title="Search filter",
|
||||
description="Search by role name or user email (for private roles).",
|
||||
)
|
||||
LimitRolesQueryParam: Optional[int] = Query(
|
||||
default=None,
|
||||
ge=1,
|
||||
title="Limit",
|
||||
description="The maximum number of roles to return.",
|
||||
)
|
||||
OffsetRolesQueryParam: Optional[int] = Query(
|
||||
default=0,
|
||||
ge=0,
|
||||
title="Offset",
|
||||
description="Number of roles to skip.",
|
||||
)
|
||||
|
||||
|
||||
# Empty paths (e.g. /api/roles) only work if a prefix is defined right here.
|
||||
# https://github.com/tiangolo/fastapi/pull/415/files
|
||||
@@ -33,8 +55,14 @@ class FastAPIRoles:
|
||||
service: RolesService = depends(RolesService)
|
||||
|
||||
@router.get("/api/roles")
|
||||
def index(self, trans: ProvidesUserContext = DependsOnTrans) -> RoleListResponse:
|
||||
return self.service.get_index(trans=trans)
|
||||
def index(
|
||||
self,
|
||||
trans: ProvidesUserContext = DependsOnTrans,
|
||||
search: Optional[str] = SearchRolesQueryParam,
|
||||
limit: Optional[int] = LimitRolesQueryParam,
|
||||
offset: Optional[int] = OffsetRolesQueryParam,
|
||||
) -> RoleListResponse:
|
||||
return self.service.get_index(trans=trans, search=search, limit=limit, offset=offset)
|
||||
|
||||
@router.get("/api/roles/{id}")
|
||||
def show(self, id: RoleIDPathParam, trans: ProvidesUserContext = DependsOnTrans) -> RoleModelResponse:
|
||||
|
||||
@@ -640,8 +640,14 @@ class FastAPIUsers:
|
||||
f_email: Optional[str] = FilterEmailQueryParam,
|
||||
f_name: Optional[str] = FilterNameQueryParam,
|
||||
f_any: Optional[str] = FilterAnyQueryParam,
|
||||
limit: Optional[int] = Query(
|
||||
default=None, ge=1, title="Limit", description="Maximum number of users to return."
|
||||
),
|
||||
offset: Optional[int] = Query(default=0, ge=0, title="Offset", description="Number of users to skip."),
|
||||
) -> list[MaybeLimitedUserModel]:
|
||||
return self.service.get_index(trans=trans, deleted=deleted, f_email=f_email, f_name=f_name, f_any=f_any)
|
||||
return self.service.get_index(
|
||||
trans=trans, deleted=deleted, f_email=f_email, f_name=f_name, f_any=f_any, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/users/{user_id}",
|
||||
|
||||
@@ -17,7 +17,6 @@ from galaxy.exceptions import (
|
||||
RequestParameterInvalidException,
|
||||
)
|
||||
from galaxy.managers.quotas import QuotaManager
|
||||
from galaxy.model.db.role import get_private_role_user_emails_dict
|
||||
from galaxy.model.index_filter_util import (
|
||||
raw_text_column_filter,
|
||||
text_column_filter,
|
||||
@@ -763,153 +762,6 @@ class AdminGalaxy(controller.BaseUIController):
|
||||
trans.sa_session.commit()
|
||||
return {"message": f"Group '{old_name}' has been renamed to '{new_name}'."}
|
||||
|
||||
@web.legacy_expose_api
|
||||
@web.require_admin
|
||||
def manage_users_and_roles_for_group(self, trans, payload=None, **kwd):
|
||||
group_id = kwd.get("id")
|
||||
if not group_id:
|
||||
return self.message_exception(trans, f"Invalid group id ({str(group_id)}) received")
|
||||
group = get_group(trans, group_id)
|
||||
if trans.request.method == "GET":
|
||||
in_users = []
|
||||
all_users = []
|
||||
in_roles = []
|
||||
all_roles = []
|
||||
for user in (
|
||||
trans.sa_session.query(trans.app.model.User)
|
||||
.filter(trans.app.model.User.table.c.deleted == false())
|
||||
.order_by(trans.app.model.User.table.c.email)
|
||||
):
|
||||
if user in [x.user for x in group.users]:
|
||||
in_users.append(trans.security.encode_id(user.id))
|
||||
all_users.append((user.email, trans.security.encode_id(user.id)))
|
||||
|
||||
private_role_emails = get_private_role_user_emails_dict(trans.sa_session)
|
||||
|
||||
for role in (
|
||||
trans.sa_session.query(trans.app.model.Role)
|
||||
.filter(trans.app.model.Role.deleted == false())
|
||||
.order_by(trans.app.model.Role.name)
|
||||
):
|
||||
if role in [x.role for x in group.roles]:
|
||||
in_roles.append(trans.security.encode_id(role.id))
|
||||
displayed_name = private_role_emails.get(role.id, role.name)
|
||||
all_roles.append((displayed_name, trans.security.encode_id(role.id)))
|
||||
return {
|
||||
"title": f"Group '{group.name}'",
|
||||
"message": f"Group '{group.name}' is currently associated with {len(in_users)} user(s) and {len(in_roles)} role(s).",
|
||||
"status": "info",
|
||||
"inputs": [
|
||||
build_select_input("in_roles", "Roles", all_roles, in_roles),
|
||||
build_select_input("in_users", "Users", all_users, in_users),
|
||||
],
|
||||
}
|
||||
else:
|
||||
user_ids = [trans.security.decode_id(id) for id in util.listify(payload.get("in_users"))]
|
||||
role_ids = [trans.security.decode_id(id) for id in util.listify(payload.get("in_roles"))]
|
||||
try:
|
||||
trans.app.security_agent.set_group_user_and_role_associations(
|
||||
group, user_ids=user_ids, role_ids=role_ids
|
||||
)
|
||||
return {
|
||||
"message": f"Group '{group.name}' has been updated with {len(user_ids)} associated users and {len(role_ids)} associated roles."
|
||||
}
|
||||
except RequestParameterInvalidException:
|
||||
return self.message_exception(trans, "One or more invalid user/role id has been provided.")
|
||||
|
||||
@web.legacy_expose_api
|
||||
@web.require_admin
|
||||
def create_group(self, trans, payload=None, **kwd):
|
||||
if trans.request.method == "GET":
|
||||
all_users = []
|
||||
all_roles = []
|
||||
for user in (
|
||||
trans.sa_session.query(trans.app.model.User)
|
||||
.filter(trans.app.model.User.table.c.deleted == false())
|
||||
.order_by(trans.app.model.User.table.c.email)
|
||||
):
|
||||
all_users.append((user.email, trans.security.encode_id(user.id)))
|
||||
|
||||
private_role_emails = get_private_role_user_emails_dict(trans.sa_session)
|
||||
|
||||
for role in (
|
||||
trans.sa_session.query(trans.app.model.Role)
|
||||
.filter(trans.app.model.Role.deleted == false())
|
||||
.order_by(trans.app.model.Role.name)
|
||||
):
|
||||
displayed_name = private_role_emails.get(role.id, role.name)
|
||||
all_roles.append((displayed_name, trans.security.encode_id(role.id)))
|
||||
return {
|
||||
"title": "Create Group",
|
||||
"title_id": "create-group",
|
||||
"inputs": [
|
||||
{"name": "name", "label": "Name"},
|
||||
build_select_input("in_roles", "Roles", all_roles, []),
|
||||
build_select_input("in_users", "Users", all_users, []),
|
||||
{
|
||||
"name": "auto_create",
|
||||
"label": "Create a new role of the same name for this group:",
|
||||
"type": "boolean",
|
||||
"optional": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
else:
|
||||
name = util.restore_text(payload.get("name", ""))
|
||||
auto_create_checked = payload.get("auto_create")
|
||||
in_users = [
|
||||
trans.sa_session.query(trans.app.model.User).get(trans.security.decode_id(x))
|
||||
for x in util.listify(payload.get("in_users"))
|
||||
]
|
||||
in_roles = [
|
||||
trans.sa_session.query(trans.app.model.Role).get(trans.security.decode_id(x))
|
||||
for x in util.listify(payload.get("in_roles"))
|
||||
]
|
||||
if not name:
|
||||
return self.message_exception(trans, "Enter a valid name.")
|
||||
elif trans.sa_session.query(trans.app.model.Group).filter(trans.app.model.Group.name == name).first():
|
||||
return self.message_exception(
|
||||
trans,
|
||||
"Group names must be unique and a group with that name already exists, so choose another name.",
|
||||
)
|
||||
elif None in in_users or None in in_roles:
|
||||
return self.message_exception(trans, "One or more invalid user/role id has been provided.")
|
||||
else:
|
||||
# Create the role
|
||||
group = trans.app.model.Group(name=name)
|
||||
trans.sa_session.add(group)
|
||||
# Create the UserRoleAssociations
|
||||
for user in in_users:
|
||||
uga = trans.app.model.UserGroupAssociation(user, group)
|
||||
trans.sa_session.add(uga)
|
||||
# Create the GroupRoleAssociations
|
||||
for role in in_roles:
|
||||
gra = trans.app.model.GroupRoleAssociation(group, role)
|
||||
trans.sa_session.add(gra)
|
||||
if auto_create_checked:
|
||||
# Check if role with same name already exists
|
||||
if trans.sa_session.query(trans.app.model.Role).filter(trans.app.model.Role.name == name).first():
|
||||
return self.message_exception(
|
||||
trans,
|
||||
"A role with that name already exists, so choose another name or disable role creation.",
|
||||
)
|
||||
# Create the role
|
||||
role = trans.app.model.Role(name=name, description=f"Role for group {name}")
|
||||
trans.sa_session.add(role)
|
||||
# Associate the group with the role
|
||||
gra = trans.model.GroupRoleAssociation(group, role)
|
||||
trans.sa_session.add(gra)
|
||||
num_in_roles = len(in_roles) + 1
|
||||
else:
|
||||
num_in_roles = len(in_roles)
|
||||
trans.sa_session.commit()
|
||||
message = f"Group '{group.name}' has been created with {len(in_users)} associated users and {num_in_roles} associated roles."
|
||||
if auto_create_checked:
|
||||
message += (
|
||||
"One of the roles associated with this group is the newly created role with the same name."
|
||||
)
|
||||
return {"message": message}
|
||||
|
||||
@web.expose
|
||||
@web.require_admin
|
||||
def create_new_user(self, trans, **kwd):
|
||||
|
||||
@@ -38,14 +38,17 @@ class RolesService(ServiceBase):
|
||||
super().__init__(security)
|
||||
self.role_manager = role_manager
|
||||
|
||||
def get_index(self, trans: ProvidesUserContext) -> RoleListResponse:
|
||||
roles = self.role_manager.list_displayable_roles(trans)
|
||||
def get_index(
|
||||
self,
|
||||
trans: ProvidesUserContext,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = 0,
|
||||
) -> RoleListResponse:
|
||||
roles = self.role_manager.list_displayable_roles(trans, search=search, limit=limit, offset=offset or 0)
|
||||
role_ids = {r.id for r in roles}
|
||||
private_role_emails = get_private_role_user_emails_dict(trans.sa_session, role_ids=role_ids)
|
||||
data = []
|
||||
for role in roles:
|
||||
displayed_name = private_role_emails.get(role.id, role.name)
|
||||
data.append(role_to_model(role, displayed_name))
|
||||
data = [role_to_model(role, private_role_emails.get(role.id, role.name)) for role in roles]
|
||||
return RoleListResponse(root=data)
|
||||
|
||||
def show(self, trans: ProvidesUserContext, id: DecodedDatabaseIdField) -> RoleModelResponse:
|
||||
|
||||
@@ -204,6 +204,8 @@ class UsersService(ServiceBase):
|
||||
f_email: Optional[str],
|
||||
f_name: Optional[str],
|
||||
f_any: Optional[str],
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = 0,
|
||||
) -> list[MaybeLimitedUserModel]:
|
||||
# never give any info to non-authenticated users
|
||||
if not trans.user and not trans.user_is_bootstrap_admin:
|
||||
@@ -234,6 +236,8 @@ class UsersService(ServiceBase):
|
||||
trans.user_is_admin,
|
||||
trans.app.config.expose_user_email,
|
||||
trans.app.config.expose_user_name,
|
||||
limit=limit,
|
||||
offset=offset or 0,
|
||||
)
|
||||
rval: list[MaybeLimitedUserModel] = []
|
||||
for user in users:
|
||||
|
||||
@@ -31,6 +31,31 @@ class TestGroupsApi(ApiTestCase):
|
||||
response = self._post("groups", payload, admin=True, json=True)
|
||||
self._assert_status_code_is(response, 400)
|
||||
|
||||
def test_create_with_auto_create_role(self):
|
||||
name = f"auto-role-group-{self.dataset_populator.get_random_name()}"
|
||||
payload = self._build_valid_group_payload(name)
|
||||
payload["auto_create_role"] = True
|
||||
response = self._post("groups", payload, admin=True, json=True)
|
||||
self._assert_status_code_is(response, 200)
|
||||
group = response.json()[0]
|
||||
self._assert_valid_group(group)
|
||||
# Verify role with same name was created and associated with the group
|
||||
roles = self._get(f"groups/{group['id']}/roles", admin=True).json()
|
||||
role_names = [r["name"] for r in roles]
|
||||
assert name in role_names
|
||||
|
||||
def test_create_with_auto_create_role_conflict(self):
|
||||
"""Auto-create role should fail if a role with the same name already exists."""
|
||||
name = f"auto-role-conflict-{self.dataset_populator.get_random_name()}"
|
||||
# First create a role with this name
|
||||
role_payload = {"name": name, "description": "A test role.", "user_ids": [self.dataset_populator.user_id()]}
|
||||
self._post("roles", role_payload, admin=True, json=True)
|
||||
# Now try to create a group with auto_create_role - should conflict
|
||||
payload = self._build_valid_group_payload(name)
|
||||
payload["auto_create_role"] = True
|
||||
response = self._post("groups", payload, admin=True, json=True)
|
||||
self._assert_status_code_is(response, 409)
|
||||
|
||||
def test_create_duplicated_name_raises_409(self):
|
||||
payload = self._build_valid_group_payload()
|
||||
response = self._post("groups", payload, admin=True, json=True)
|
||||
|
||||
@@ -130,6 +130,36 @@ class TestRolesApi(ApiTestCase):
|
||||
response = self._get(f"roles/{different_user_role_id}")
|
||||
assert_status_code_is(response, 404)
|
||||
|
||||
@requires_admin
|
||||
def test_list_with_pagination(self):
|
||||
self._create_role()
|
||||
self._create_role()
|
||||
# Test limit
|
||||
response = self._get("roles", data={"limit": 1}, admin=True)
|
||||
assert_status_code_is(response, 200)
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
# Test limit + offset returns different result
|
||||
response_offset = self._get("roles", data={"limit": 1, "offset": 1}, admin=True)
|
||||
assert_status_code_is(response_offset, 200)
|
||||
data_offset = response_offset.json()
|
||||
assert len(data_offset) == 1
|
||||
assert data[0]["id"] != data_offset[0]["id"]
|
||||
|
||||
@requires_admin
|
||||
def test_list_with_search(self):
|
||||
unique = self.dataset_populator.get_random_name()
|
||||
role = self._create_role(name=f"searchable-{unique}")
|
||||
response = self._get("roles", data={"search": unique}, admin=True)
|
||||
assert_status_code_is(response, 200)
|
||||
data = response.json()
|
||||
assert any(r["id"] == role["id"] for r in data)
|
||||
# Search for non-existing returns empty
|
||||
response = self._get("roles", data={"search": "nonexistent-xyz-99999"}, admin=True)
|
||||
assert_status_code_is(response, 200)
|
||||
data = response.json()
|
||||
assert len(data) == 0
|
||||
|
||||
@requires_admin
|
||||
def test_create_only_admin(self):
|
||||
response = self._post("roles", json=True)
|
||||
|
||||
@@ -47,6 +47,23 @@ class TestUsersApi(ApiTestCase):
|
||||
all_deleted_users = all_deleted_users_response_2.json()
|
||||
assert len([u for u in all_deleted_users if u["email"] == TEST_USER_EMAIL_INDEX_DELETED]) == 1
|
||||
|
||||
@requires_admin
|
||||
def test_index_with_pagination(self):
|
||||
dataset_populator = DatasetPopulator(self.galaxy_interactor)
|
||||
self._setup_user(f"pagination_test_{dataset_populator.get_random_name()}@bx.psu.edu")
|
||||
self._setup_user(f"pagination_test_{dataset_populator.get_random_name()}@bx.psu.edu")
|
||||
response = self._get("users", data={"limit": 1}, admin=True)
|
||||
self._assert_status_code_is(response, 200)
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
# With offset
|
||||
response_offset = self._get("users", data={"limit": 1, "offset": 1}, admin=True)
|
||||
self._assert_status_code_is(response_offset, 200)
|
||||
data_offset = response_offset.json()
|
||||
assert len(data_offset) == 1
|
||||
# Different results
|
||||
assert data[0]["id"] != data_offset[0]["id"]
|
||||
|
||||
def test_index_anon(self):
|
||||
with self._different_user(anon=True):
|
||||
all_users_response = self._get("users")
|
||||
|
||||
@@ -182,7 +182,6 @@ class TestAdminApp(SeleniumTestCase):
|
||||
assert title_element.text == "Local Data"
|
||||
self.screenshot("admin_local_data")
|
||||
|
||||
@selenium_only("Not yet migrated to support Playwright backend")
|
||||
@selenium_test
|
||||
@requires_admin
|
||||
def test_admin_user_display(self):
|
||||
|
||||
@@ -151,8 +151,8 @@ def test_get_displayable_roles(session, make_role, make_user_and_role):
|
||||
admin_role1 = make_role(type="admin", name="admin-role-1", description="Description of admin-role1")
|
||||
make_role(type="admin", description="Description of admin-role1", deleted=True)
|
||||
|
||||
user_is_admin, security_agent = True, None
|
||||
roles = get_displayable_roles(session, user1, user_is_admin, security_agent)
|
||||
user_is_admin = True
|
||||
roles = get_displayable_roles(session, user1, user_is_admin)
|
||||
assert len(roles) == 3
|
||||
assert roles[0].id == private_role1.id
|
||||
assert roles[1].id == private_role2.id
|
||||
|
||||
Reference in New Issue
Block a user