diff --git a/frontend/src/components/dialog/sysadmin-dialog/sysadmin-move-group-dialog.js b/frontend/src/components/dialog/sysadmin-dialog/sysadmin-move-group-dialog.js new file mode 100644 index 0000000000..8d04007af8 --- /dev/null +++ b/frontend/src/components/dialog/sysadmin-dialog/sysadmin-move-group-dialog.js @@ -0,0 +1,220 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button, Modal, ModalBody, ModalFooter, Popover } from 'reactstrap'; +import toaster from '../../../components/toast'; +import { gettext, orgID } from '../../../utils/constants'; +import { orgAdminAPI } from '../../../utils/org-admin-api'; +import { systemAdminAPI } from '../../../utils/system-admin-api'; +import { Utils } from '../../../utils/utils'; +import SeahubModalHeader from '@/components/common/seahub-modal-header'; +import SearchInput from '../../search-input'; +import ClickOutside from '../../click-outside'; +import classnames from 'classnames'; + +import '../../../css/department-select.css'; + +export default class MoveDepartmentDialog extends React.Component { + + static propTypes = { + toggle: PropTypes.func.isRequired, + nodeId: PropTypes.number.isRequired, + onDepartmentChanged: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + this.state = { + selectedDepartment: null, + searchedDepartments: [], + searchValue: '', + highlightIndex: -1, + isPopoverOpen: false, + errMsgs: '', + }; + } + + onValueChanged = (newSearchValue) => { + this.setState({ + searchValue: newSearchValue + }); + const searchValue = newSearchValue.trim(); + if (searchValue.length === 0) { + this.setState({ + searchedDepartments: [], + highlightIndex: -1, + }); + } else { + if (orgID) { + orgAdminAPI.orgAdminSearchGroup(orgID, searchValue).then((res) => { + const filteredGroupList = res.data.group_list + .filter(item => item.creator_email === 'system admin') + .map(item => ({ + id: item.id, + name: item.group_name, + creator_email: item.creator_email, + creator_name: item.creator_name, + ctime: item.ctime, + creator_contact_email: item.creator_contact_email, + })); + this.setState({ + searchedDepartments: filteredGroupList, + highlightIndex: filteredGroupList.length > 0 ? 0 : -1, + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } else { + systemAdminAPI.sysAdminSearchGroups(searchValue).then((res) => { + const filteredGroupList = res.data.group_list + .filter(item => item.owner === 'system admin') + .map(item => ({ + id: item.id, + name: item.name, + creator_email: item.owner, + creator_name: item.owner_name, + ctime: item.ctime, + creator_contact_email: item.creator_contact_email, + })); + this.setState({ + searchedDepartments: filteredGroupList, + highlightIndex: filteredGroupList.length > 0 ? 0 : -1, + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + } + }; + + onDepartmentClick = (department) => { + this.setState({ + selectedDepartment: department, + isPopoverOpen: false, + searchValue: '', + searchedDepartments: [], + }); + }; + + onClickOutside = (e) => { + if (e.target.id !== 'department-select' && this.state.isPopoverOpen) { + this.setState({ + isPopoverOpen: false, + searchedDepartments: [], + searchValue: '', + highlightIndex: -1, + }); + } + }; + + onTogglePopover = () => { + this.setState({ isPopoverOpen: !this.state.isPopoverOpen }); + if (!this.state.isPopoverOpen) { + this.onValueChanged(this.state.searchValue); + } + }; + + handleSubmit = () => { + if (!this.state.selectedDepartment) return; + + const { nodeId } = this.props; + const targetDepartmentId = this.state.selectedDepartment.id; + const req = orgID ? + orgAdminAPI.orgAdminMoveDepartment(orgID, nodeId, targetDepartmentId) : + systemAdminAPI.sysAdminMoveDepartment(nodeId, targetDepartmentId); + + req.then((res) => { + this.props.toggle(); + this.props.onDepartmentChanged(this.state.selectedDepartment); + toaster.success(gettext('Department moved successfully')); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }; + + render() { + const { searchValue, searchedDepartments, selectedDepartment, errMsgs } = this.state; + + return ( + + {gettext('Move to department')} + +
+ +
+
+ {selectedDepartment ? ( +
+ {selectedDepartment.name} +
+ ) : ( +
+ {gettext('Select target department')} +
+ )} +
+ +
+
+ +
+
+ {searchedDepartments.length > 0 ? ( + searchedDepartments.map((department, index) => { + return ( +
this.onDepartmentClick(department)} + > + {department.name} +
+ ); + }) + ) : ( +
+ {searchValue ? gettext('Department not found') : gettext('Enter characters to start searching')} +
+ )} +
+
+
+
+
+
+ {errMsgs.length > 0 && ( + + )} +
+ + + + +
+ ); + } +} diff --git a/frontend/src/css/department-select.css b/frontend/src/css/department-select.css new file mode 100644 index 0000000000..51892a61af --- /dev/null +++ b/frontend/src/css/department-select.css @@ -0,0 +1,88 @@ +.department-select-component { + position: relative; + width: 100%; +} + +.department-select-wrapper { + position: relative; + width: 100%; +} + +.department-select-input { + cursor: pointer; + min-height: 36px; + padding: 0.25rem 0.5rem; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 3px; + display: flex; + align-items: center; +} + +.department-select-input.focus { + color: #495057; + background-color: #fff; + border-color: #80bdff; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); +} + +.department-select-placeholder { + color: #6c757d; +} + +.selected-department { + display: flex; + align-items: center; + color: #212529; +} + +.department-select-popover { + width: 100% !important; + max-width: none !important; + padding: 0; + margin-top: 2px; +} + +.department-select-container { + background-color: #fff; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.department-search-container { + padding: 8px; + border-bottom: 1px solid #e9ecef; +} + +.department-list-container { + padding: 4px 0; + max-height: 200px; + overflow-y: auto; +} + +.department-item { + padding: 6px 12px; + cursor: pointer; + display: flex; + align-items: center; +} + +.department-item:hover, +.department-item-highlight { + background-color: #f8f9fa; +} + +.department-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #212529; +} + +.no-search-result { + padding: 8px 12px; + color: #6c757d; + text-align: center; +} diff --git a/frontend/src/pages/org-admin/departments/department.js b/frontend/src/pages/org-admin/departments/department.js index 356a8600c6..7ffc27f508 100644 --- a/frontend/src/pages/org-admin/departments/department.js +++ b/frontend/src/pages/org-admin/departments/department.js @@ -132,6 +132,7 @@ class Department extends React.Component { toggleAddMembers={this.props.toggleAddMembers} toggleRename={this.props.toggleRename} toggleDelete={this.props.toggleDelete} + toggleMoveDepartment={this.props.toggleMoveDepartment} /> diff --git a/frontend/src/pages/org-admin/departments/departments-node-dropdown-menu.js b/frontend/src/pages/org-admin/departments/departments-node-dropdown-menu.js index 877fcc4eba..0a5e95839b 100644 --- a/frontend/src/pages/org-admin/departments/departments-node-dropdown-menu.js +++ b/frontend/src/pages/org-admin/departments/departments-node-dropdown-menu.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { DropdownItem, DropdownMenu } from 'reactstrap'; import { gettext } from '../../../utils/constants'; -function DepartmentNodeMenu({ node, toggleDelete, toggleRename, toggleAddMembers, toggleAddDepartment, toggleAddLibrary, toggleSetQuotaDialog }) { +function DepartmentNodeMenu({ node, toggleDelete, toggleRename, toggleAddMembers, toggleAddDepartment, toggleAddLibrary, toggleSetQuotaDialog, toggleMoveDepartment }) { return ( toggleDelete(node)}> {gettext('Delete')} + toggleMoveDepartment(node)}> + {gettext('Move department')} + toggleSetQuotaDialog(node)}> {gettext('Set quota')} @@ -42,6 +45,7 @@ DepartmentNodeMenu.propTypes = { toggleAddDepartment: PropTypes.func.isRequired, toggleAddLibrary: PropTypes.func.isRequired, toggleSetQuotaDialog: PropTypes.func.isRequired, + toggleMoveDepartment: PropTypes.func.isRequired }; export default DepartmentNodeMenu; diff --git a/frontend/src/pages/org-admin/departments/departments-tree-panel.js b/frontend/src/pages/org-admin/departments/departments-tree-panel.js index d660e0e9a3..21aa41ce22 100644 --- a/frontend/src/pages/org-admin/departments/departments-tree-panel.js +++ b/frontend/src/pages/org-admin/departments/departments-tree-panel.js @@ -13,7 +13,8 @@ const DepartmentsTreePanelPropTypes = { toggleAddLibrary: PropTypes.func, toggleAddMembers: PropTypes.func, toggleRename: PropTypes.func, - toggleDelete: PropTypes.func + toggleDelete: PropTypes.func, + toggleMoveDepartment: PropTypes.func, }; class DepartmentsTreePanel extends Component { @@ -35,6 +36,7 @@ class DepartmentsTreePanel extends Component { toggleAddMembers={this.props.toggleAddMembers} toggleRename={this.props.toggleRename} toggleDelete={this.props.toggleDelete} + toggleMoveDepartment={this.props.toggleMoveDepartment} /> ); })} diff --git a/frontend/src/pages/org-admin/departments/departments.js b/frontend/src/pages/org-admin/departments/departments.js index 54011b3bc3..6d48b34d69 100644 --- a/frontend/src/pages/org-admin/departments/departments.js +++ b/frontend/src/pages/org-admin/departments/departments.js @@ -7,6 +7,7 @@ import toaster from '../../../components/toast'; import SetGroupQuotaDialog from '../../../components/dialog/org-set-group-quota-dialog'; import AddDepartmentDialog from '../../../components/dialog/sysadmin-dialog/add-department-v2-dialog'; import AddDepartMemberDialog from '../../../components/dialog/sysadmin-dialog/sysadmin-add-depart-member-v2-dialog'; +import MoveDepartmentDialog from '../../../components/dialog/sysadmin-dialog/sysadmin-move-group-dialog'; import RenameDepartmentDialog from '../../../components/dialog/sysadmin-dialog/rename-department-v2-dialog'; import DeleteDepartmentConfirmDialog from '../../../components/dialog/sysadmin-dialog/delete-department-v2-confirm-dialog'; import AddRepoDialog from '../../../components/dialog/org-add-repo-dialog'; @@ -28,6 +29,7 @@ class Departments extends React.Component { operateNode: null, isAddDepartmentDialogShow: false, isAddMembersDialogShow: false, + isMoveDeparmentDialogShow: false, isRenameDepartmentDialogShow: false, isDeleteDepartmentDialogShow: false, isShowAddRepoDialog: false, @@ -143,6 +145,10 @@ class Departments extends React.Component { this.setState({ operateNode: node, isAddMembersDialogShow: !this.state.isAddMembersDialogShow }); }; + toggleMoveDepartment = (node) => { + this.setState({ operateNode: node, isMoveDeparmentDialogShow: !this.state.isMoveDeparmentDialogShow }); + }; + toggleRename = (node) => { this.setState({ operateNode: node, isRenameDepartmentDialogShow: !this.state.isRenameDepartmentDialogShow }); }; @@ -180,6 +186,62 @@ class Departments extends React.Component { node.name = department.name; }; + onDepartmentChanged = (targetDepartment) => { + const { operateNode, rootNodes } = this.state; + + // Remove from original parent but keep the node reference + let nodeToMove = operateNode; + if (operateNode.parentNode) { + operateNode.parentNode.deleteChildById(operateNode.id); + } else { + const index = rootNodes.indexOf(operateNode); + if (index !== -1) { + rootNodes.splice(index, 1); + } + } + + if (targetDepartment) { + // Find existing target node in tree + let existingTargetNode = null; + const findTargetNode = (nodes) => { + for (let n of nodes) { + if (n.id === targetDepartment.id) { + existingTargetNode = n; + return; + } + if (n.children.length > 0) { + findTargetNode(n.children); + } + } + }; + findTargetNode(rootNodes); + + if (existingTargetNode) { + // Update node's parent and add to target's children + nodeToMove.parentNode = existingTargetNode; + const isAlreadyChild = existingTargetNode.children.some(child => child.id === nodeToMove.id); + if (!isAlreadyChild) { + existingTargetNode.addChildren([nodeToMove]); + } + } + } else { + // If targetDepartment is null, it becomes a root node + nodeToMove.parentNode = null; + const isAlreadyRoot = rootNodes.some(node => node.id === nodeToMove.id); + if (!isAlreadyRoot) { + this.setState({ + rootNodes: [...rootNodes, nodeToMove] + }); + } + } + + // Update checked department if needed + const { checkedDepartmentId } = this.state; + if (checkedDepartmentId === nodeToMove.id) { + this.onChangeDepartment(nodeToMove.id); + } + }; + onDelete = () => { const { operateNode, checkedDepartmentId } = this.state; orgAdminAPI.orgAdminDeleteDepartGroup(orgID, operateNode.id).then((res) => { @@ -315,7 +377,7 @@ class Departments extends React.Component { render() { const { rootNodes, operateNode, checkedDepartmentId, isAddDepartmentDialogShow, isAddMembersDialogShow, membersList, isMembersListLoading, isTopDepartmentLoading, isRenameDepartmentDialogShow, - isDeleteDepartmentDialogShow, sortBy, sortOrder } = this.state; + isDeleteDepartmentDialogShow, sortBy, sortOrder, isMoveDeparmentDialogShow } = this.state; return ( @@ -339,6 +401,7 @@ class Departments extends React.Component { toggleAddMembers={this.toggleAddMembers} toggleRename={this.toggleRename} toggleDelete={this.toggleDelete} + toggleMoveDepartment={this.toggleMoveDepartment} /> @@ -381,6 +445,14 @@ class Departments extends React.Component { onMemberChanged={this.onMemberChanged} /> } + {isMoveDeparmentDialogShow && + + } {isRenameDepartmentDialogShow && ); }); @@ -156,6 +157,7 @@ class DepartmentsV2TreeNode extends Component { toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleRename={this.props.toggleRename} toggleDelete={this.props.toggleDelete} + toggleMoveDepartment={this.props.toggleMoveDepartment} /> } diff --git a/frontend/src/pages/sys-admin/departments/department.js b/frontend/src/pages/sys-admin/departments/department.js index dd10e6c5e4..03a3253db5 100644 --- a/frontend/src/pages/sys-admin/departments/department.js +++ b/frontend/src/pages/sys-admin/departments/department.js @@ -153,6 +153,7 @@ class Department extends React.Component { toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleAddLibrary={this.props.toggleAddLibrary} toggleAddMembers={this.props.toggleAddMembers} + toggleMoveDepartment={this.props.toggleMoveDepartment} toggleRename={this.props.toggleRename} toggleDelete={this.props.toggleDelete} /> diff --git a/frontend/src/pages/sys-admin/departments/departments-node-dropdown-menu.js b/frontend/src/pages/sys-admin/departments/departments-node-dropdown-menu.js index 877fcc4eba..0a5e95839b 100644 --- a/frontend/src/pages/sys-admin/departments/departments-node-dropdown-menu.js +++ b/frontend/src/pages/sys-admin/departments/departments-node-dropdown-menu.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { DropdownItem, DropdownMenu } from 'reactstrap'; import { gettext } from '../../../utils/constants'; -function DepartmentNodeMenu({ node, toggleDelete, toggleRename, toggleAddMembers, toggleAddDepartment, toggleAddLibrary, toggleSetQuotaDialog }) { +function DepartmentNodeMenu({ node, toggleDelete, toggleRename, toggleAddMembers, toggleAddDepartment, toggleAddLibrary, toggleSetQuotaDialog, toggleMoveDepartment }) { return ( toggleDelete(node)}> {gettext('Delete')} + toggleMoveDepartment(node)}> + {gettext('Move department')} + toggleSetQuotaDialog(node)}> {gettext('Set quota')} @@ -42,6 +45,7 @@ DepartmentNodeMenu.propTypes = { toggleAddDepartment: PropTypes.func.isRequired, toggleAddLibrary: PropTypes.func.isRequired, toggleSetQuotaDialog: PropTypes.func.isRequired, + toggleMoveDepartment: PropTypes.func.isRequired }; export default DepartmentNodeMenu; diff --git a/frontend/src/pages/sys-admin/departments/departments-tree-panel.js b/frontend/src/pages/sys-admin/departments/departments-tree-panel.js index ef6bc6af83..2c2eb7d8db 100644 --- a/frontend/src/pages/sys-admin/departments/departments-tree-panel.js +++ b/frontend/src/pages/sys-admin/departments/departments-tree-panel.js @@ -13,7 +13,8 @@ const DepartmentsTreePanelPropTypes = { toggleAddLibrary: PropTypes.func, toggleAddMembers: PropTypes.func, toggleRename: PropTypes.func, - toggleDelete: PropTypes.func + toggleDelete: PropTypes.func, + toggleMoveDepartment: PropTypes.func, }; class DepartmentsTreePanel extends Component { @@ -33,6 +34,7 @@ class DepartmentsTreePanel extends Component { toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleAddLibrary={this.props.toggleAddLibrary} toggleAddMembers={this.props.toggleAddMembers} + toggleMoveDepartment={this.props.toggleMoveDepartment} toggleRename={this.props.toggleRename} toggleDelete={this.props.toggleDelete} /> diff --git a/frontend/src/pages/sys-admin/departments/departments.js b/frontend/src/pages/sys-admin/departments/departments.js index 187b576d35..6c52272080 100644 --- a/frontend/src/pages/sys-admin/departments/departments.js +++ b/frontend/src/pages/sys-admin/departments/departments.js @@ -10,6 +10,7 @@ import AddDepartmentV2Dialog from '../../../components/dialog/sysadmin-dialog/ad import AddDepartMemberV2Dialog from '../../../components/dialog/sysadmin-dialog/sysadmin-add-depart-member-v2-dialog'; import RenameDepartmentV2Dialog from '../../../components/dialog/sysadmin-dialog/rename-department-v2-dialog'; import DeleteDepartmentV2ConfirmDialog from '../../../components/dialog/sysadmin-dialog/delete-department-v2-confirm-dialog'; +import MoveDepartmentDialog from '../../../components/dialog/sysadmin-dialog/sysadmin-move-group-dialog'; import AddRepoDialog from '../../../components/dialog/sysadmin-dialog/sysadmin-add-repo-dialog'; import DepartmentNode from './department-node'; import DepartmentsTreePanel from './departments-tree-panel'; @@ -32,6 +33,7 @@ class Departments extends React.Component { isRenameDepartmentDialogShow: false, isDeleteDepartmentDialogShow: false, isShowAddRepoDialog: false, + isMoveDeparmentDialogShow: false, membersList: [], isTopDepartmentLoading: false, isMembersListLoading: false, @@ -188,6 +190,10 @@ class Departments extends React.Component { this.setState({ operateNode: node, isDeleteDepartmentDialogShow: !this.state.isDeleteDepartmentDialogShow }); }; + toggleMoveDepartment = (node) => { + this.setState({ operateNode: node, isMoveDeparmentDialogShow: !this.state.isMoveDeparmentDialogShow }); + }; + addDepartment = (parentNode, department) => { parentNode.addChildren([new DepartmentNode({ id: department.id, @@ -217,6 +223,57 @@ class Departments extends React.Component { node.name = department.name; }; + onDepartmentChanged = (targetDepartment) => { + const { operateNode, rootNodes } = this.state; + + let nodeToMove = operateNode; + if (operateNode.parentNode) { + operateNode.parentNode.deleteChildById(operateNode.id); + } else { + const index = rootNodes.indexOf(operateNode); + if (index !== -1) { + rootNodes.splice(index, 1); + } + } + + if (targetDepartment) { + let existingTargetNode = null; + const findTargetNode = (nodes) => { + for (let n of nodes) { + if (n.id === targetDepartment.id) { + existingTargetNode = n; + return; + } + if (n.children.length > 0) { + findTargetNode(n.children); + } + } + }; + findTargetNode(rootNodes); + + if (existingTargetNode) { + nodeToMove.parentNode = existingTargetNode; + const isAlreadyChild = existingTargetNode.children.some(child => child.id === nodeToMove.id); + if (!isAlreadyChild) { + existingTargetNode.addChildren([nodeToMove]); + } + } + } else { + nodeToMove.parentNode = null; + const isAlreadyRoot = rootNodes.some(node => node.id === nodeToMove.id); + if (!isAlreadyRoot) { + this.setState({ + rootNodes: [...rootNodes, nodeToMove] + }); + } + } + + const { checkedDepartmentId } = this.state; + if (checkedDepartmentId === nodeToMove.id) { + this.onChangeDepartment(nodeToMove.id); + } + }; + onDelete = () => { const { operateNode, checkedDepartmentId } = this.state; systemAdminAPI.sysAdminDeleteDepartment(operateNode.id).then(() => { @@ -348,7 +405,7 @@ class Departments extends React.Component { render() { const { rootNodes, operateNode, checkedDepartmentId, isAddDepartmentDialogShow, isAddMembersDialogShow, membersList, isMembersListLoading, isTopDepartmentLoading, isRenameDepartmentDialogShow, - isDeleteDepartmentDialogShow, sortBy, sortOrder } = this.state; + isDeleteDepartmentDialogShow, sortBy, sortOrder, isMoveDeparmentDialogShow } = this.state; return ( @@ -372,6 +429,7 @@ class Departments extends React.Component { toggleAddMembers={this.toggleAddMembers} toggleRename={this.toggleRename} toggleDelete={this.toggleDelete} + toggleMoveDepartment={this.toggleMoveDepartment} /> } @@ -418,6 +477,13 @@ class Departments extends React.Component { onMemberChanged={this.onMemberChanged} /> } + {isMoveDeparmentDialogShow && + + } {isRenameDepartmentDialogShow && ); }); @@ -154,6 +156,7 @@ class DepartmentsTreeNode extends Component { toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleAddLibrary={this.props.toggleAddLibrary} toggleAddMembers={this.props.toggleAddMembers} + toggleMoveDepartment={this.props.toggleMoveDepartment} toggleRename={this.props.toggleRename} toggleDelete={this.props.toggleDelete} /> diff --git a/frontend/src/utils/org-admin-api.js b/frontend/src/utils/org-admin-api.js index 05673c1a5c..91f82ac781 100644 --- a/frontend/src/utils/org-admin-api.js +++ b/frontend/src/utils/org-admin-api.js @@ -425,6 +425,13 @@ class OrgAdminAPI { return this.req.put(url, form); } + orgAdminMoveDepartment(orgID, groupID, targetDepartmentId) { + const url = this.server + '/api/v2.1/org/' + orgID + '/admin/groups/' + groupID + '/move-department/'; + const form = new FormData(); + form.append('target_group_id', targetDepartmentId); + return this.req.put(url, form); + } + orgAdminDeleteDepartGroup(orgID, groupID) { const url = this.server + '/api/v2.1/org/' + orgID + '/admin/address-book/groups/' + groupID + '/'; return this.req.delete(url); diff --git a/frontend/src/utils/system-admin-api.js b/frontend/src/utils/system-admin-api.js index e013dd5b85..3a86de9ba3 100644 --- a/frontend/src/utils/system-admin-api.js +++ b/frontend/src/utils/system-admin-api.js @@ -522,6 +522,13 @@ class SystemAdminAPI { return this.req.put(url, formData); } + sysAdminMoveDepartment(groupID, targetGroupID) { + const url = this.server + '/api/v2.1/admin/groups/' + groupID + '/'; + let formData = new FormData(); + formData.append('target_group_id', targetGroupID); + return this.req.put(url, formData); + } + sysAdminDeleteDepartment(groupID) { const url = this.server + '/api/v2.1/admin/address-book/groups/' + groupID + '/'; return this.req.delete(url); diff --git a/seahub/api2/endpoints/admin/groups.py b/seahub/api2/endpoints/admin/groups.py index 06271fbc38..72d08bec9b 100644 --- a/seahub/api2/endpoints/admin/groups.py +++ b/seahub/api2/endpoints/admin/groups.py @@ -188,6 +188,7 @@ class AdminGroup(APIView): 1. transfer a group. 2. set group quota. 3. rename group. + 4. move a group to another group. Permission checking: 1. Admin user; @@ -286,6 +287,56 @@ class AdminGroup(APIView): error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + # move a group to another group + target_group_id = request.data.get('target_group_id') + if target_group_id: + try: + target_group_id = int(target_group_id) + except ValueError: + error_msg = 'target_group_id invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if group.creator_name != 'system admin': + error_msg = 'Group %s is not a department' % group_id + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + target_group = ccnet_api.get_group(target_group_id) + if not target_group: + error_msg = 'Target group %d not found.' % target_group_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if target_group.creator_name != 'system admin': + error_msg = 'Group %s is not a department' % target_group_id + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if group.parent_group_id == target_group_id or group_id == target_group_id: + return Response({'success': True}) + + is_org = ccnet_api.is_org_group(group_id) + if is_org: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + try: + ccnet_db = CcnetDB() + sub_groups = ccnet_db.get_all_sub_groups(group_id) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if target_group_id in sub_groups: + error_msg = 'Cannot move to its own sub department.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + ccnet_db.move_department(group_id, target_group_id) + return Response({'success': True}) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + group_info = get_group_info(group_id) return Response(group_info) @@ -368,18 +419,15 @@ class AdminDepartments(APIView): throttle_classes = (UserRateThrottle,) def get(self, request): + if not request.user.admin_permissions.can_manage_group(): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + try: all_groups = ccnet_api.list_all_departments() except Exception as e: logger.error(e) error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - - try: - avatar_size = int(request.GET.get('avatar_size', GROUP_AVATAR_DEFAULT_SIZE)) - except ValueError: - avatar_size = GROUP_AVATAR_DEFAULT_SIZE - result = [] for group in all_groups: diff --git a/seahub/organizations/api/admin/groups.py b/seahub/organizations/api/admin/groups.py index 89ec38cc91..f3fe226d12 100644 --- a/seahub/organizations/api/admin/groups.py +++ b/seahub/organizations/api/admin/groups.py @@ -226,10 +226,6 @@ class OrgAdminDepartments(APIView): error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - try: - avatar_size = int(request.GET.get('avatar_size', GROUP_AVATAR_DEFAULT_SIZE)) - except ValueError: - avatar_size = GROUP_AVATAR_DEFAULT_SIZE result = [] for group in departments: created_at = timestamp_to_isoformat_timestr(group.timestamp) @@ -289,3 +285,74 @@ class OrgAdminGroupToDeptView(APIView): 'creator_contact_email': email2contact_email(group.creator_name), } return Response(group_info) + + +class OrgAdminMoveDepartment(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsProVersion, IsOrgAdminUser) + throttle_classes = (UserRateThrottle,) + + def put(self, request, org_id, group_id): + org_id = int(org_id) + if not ccnet_api.get_org_by_id(org_id): + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + group_id = int(group_id) + group = ccnet_api.get_group(group_id) + if not group: + error_msg = 'Group %s not found.' % group_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if group.creator_name != 'system admin': + error_msg = 'Group %s is not a department' % group_id + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if get_org_id_by_group(group_id) != org_id: + error_msg = 'Group %s not found.' % group_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + target_group_id = request.data.get('target_group_id') + try: + target_group_id = int(target_group_id) + except: + error_msg = 'target_group_id %s invalid' % target_group_id + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + target_group = ccnet_api.get_group(target_group_id) + if not target_group: + error_msg = 'Group %s not found.' % target_group_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if target_group.creator_name != 'system admin': + error_msg = 'Group %s is not a department' % target_group_id + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if get_org_id_by_group(target_group_id) != org_id: + error_msg = 'Group %s not found.' % target_group_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + if group.parent_group_id == target_group_id or group_id == target_group_id: + return Response({'success': True}) + + try: + ccnet_db = CcnetDB() + sub_groups = ccnet_db.get_all_sub_groups(group_id) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if target_group_id in sub_groups: + error_msg = 'Cannot move to its own sub department.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + ccnet_db.move_department(group_id, target_group_id) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) diff --git a/seahub/organizations/api_urls.py b/seahub/organizations/api_urls.py index 99d2b9e70a..8191a7da46 100644 --- a/seahub/organizations/api_urls.py +++ b/seahub/organizations/api_urls.py @@ -14,7 +14,7 @@ from .api.admin.users import OrgAdminUser, OrgAdminUsers, OrgAdminSearchUser, \ OrgAdminImportUsers, OrgAdminInviteUser from .api.admin.user_set_password import OrgAdminUserSetPassword from .api.admin.groups import OrgAdminGroups, OrgAdminGroup, OrgAdminSearchGroup, \ - OrgAdminDepartments, OrgAdminGroupToDeptView + OrgAdminDepartments, OrgAdminGroupToDeptView, OrgAdminMoveDepartment from .api.admin.repos import OrgAdminRepos, OrgAdminRepo from .api.admin.trash_libraries import OrgAdminTrashLibraries, OrgAdminTrashLibrary from .api.admin.info import OrgAdminInfo @@ -77,6 +77,7 @@ urlpatterns = [ path('/admin/groups//', OrgAdminGroup.as_view(), name='api-admin-group'), path('/admin/groups//libraries/', AdminGroupLibraries.as_view(), name='api-admin-group-libraries'), path('/admin/groups//group-to-department/', OrgAdminGroupToDeptView.as_view(), name='api-admin-group-to-department'), + path('/admin/groups//move-department/', OrgAdminMoveDepartment.as_view(), name='api-admin-move-department'), re_path(r'^(?P\d+)/admin/groups/(?P\d+)/libraries/(?P[-0-9a-f]{36})/$', AdminGroupLibrary.as_view(), name='api-admin-group-library'), path('/admin/groups//group-owned-libraries/', AdminGroupOwnedLibraries.as_view(), name='api-admin-group-owned-libraries'), diff --git a/seahub/utils/ccnet_db.py b/seahub/utils/ccnet_db.py index e8ba199b46..1f3aca0e9d 100644 --- a/seahub/utils/ccnet_db.py +++ b/seahub/utils/ccnet_db.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import os -from django.db import connection +from django.db import connection, transaction def get_ccnet_db_name(): @@ -256,3 +256,62 @@ class CcnetDB: staffs = cursor.fetchall() return [s[0] for s in staffs] + + + def get_all_sub_groups(self, group_id): + sql = f""" + SELECT group_id + FROM `{self.db_name}`.`GroupStructure` + WHERE path LIKE %s + """ + with connection.cursor() as cursor: + cursor.execute(sql, [f'%{group_id}%']) + sub_groups = cursor.fetchall() + return [s[0] for s in sub_groups] + + def move_department(self, department_id, target_department_id): + get_current_path_sql = f""" + SELECT path + FROM `{self.db_name}`.`GroupStructure` + WHERE group_id = %s + """ + + update_group_sql = f""" + UPDATE `{self.db_name}`.`Group` + SET parent_group_id = %s + WHERE group_id = %s + """ + + update_structure_sql = f""" + UPDATE `{self.db_name}`.`GroupStructure` + SET path = CONCAT(%s, SUBSTRING(path, LENGTH(%s) + 1)) + WHERE path LIKE %s + """ + + with transaction.atomic(): + with connection.cursor() as cursor: + # Get target department's path + cursor.execute(get_current_path_sql, [target_department_id]) + target_path_result = cursor.fetchone() + target_path = target_path_result[0] if target_path_result else str(target_department_id) + + # Get current department's path + cursor.execute(get_current_path_sql, [department_id]) + current_path_result = cursor.fetchone() + current_path = current_path_result[0] if current_path_result else str(department_id) + + # Update parent in Group table + cursor.execute(update_group_sql, [target_department_id, department_id]) + # Create new path prefix + new_path_prefix = f"{target_path}, {department_id}" if target_path else str(department_id) + old_path_prefix = current_path + + # Update paths in GroupStructure table + cursor.execute( + update_structure_sql, + [ + new_path_prefix, # New path prefix + old_path_prefix, # Old path prefix to remove + f"{old_path_prefix}%" # Pattern to match all children + ] + )