1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-26 15:26:19 +00:00

update department structure func (#8043)

* update department structure

* optimize

* optimize var

* Update groups.py

* code-optimize

* Update api_urls.py

* Update department-select.css

---------

Co-authored-by: 孙永强 <11704063+s-yongqiang@user.noreply.gitee.com>
Co-authored-by: r350178982 <32759763+r350178982@users.noreply.github.com>
This commit is contained in:
awu0403
2025-07-21 17:35:45 +08:00
committed by GitHub
parent 7115bc4a87
commit 40d98e2fb2
18 changed files with 673 additions and 19 deletions

View File

@@ -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 (
<Modal isOpen={true} toggle={this.props.toggle}>
<SeahubModalHeader toggle={this.props.toggle}>{gettext('Move to department')}</SeahubModalHeader>
<ModalBody>
<div className="department-select-component">
<ClickOutside onClickOutside={this.onClickOutside}>
<div className="department-select-wrapper">
<div
className={classnames('department-select-input', { 'focus': this.state.isPopoverOpen })}
id="department-select"
onClick={this.onTogglePopover}
>
{selectedDepartment ? (
<div className="selected-department">
{selectedDepartment.name}
</div>
) : (
<div className="department-select-placeholder">
{gettext('Select target department')}
</div>
)}
</div>
<Popover
placement="bottom-start"
isOpen={this.state.isPopoverOpen}
target={'department-select'}
hideArrow={true}
fade={false}
className="department-select-popover"
>
<div className="department-select-container">
<div className="department-search-container">
<SearchInput
autoFocus={true}
placeholder={gettext('Search departments')}
value={searchValue}
onChange={this.onValueChanged}
/>
</div>
<div className="department-list-container">
{searchedDepartments.length > 0 ? (
searchedDepartments.map((department, index) => {
return (
<div
key={department.id}
className={classnames('department-item', {
'department-item-highlight': index === this.state.highlightIndex
})}
onClick={() => this.onDepartmentClick(department)}
>
<span className="department-name">{department.name}</span>
</div>
);
})
) : (
<div className="no-search-result">
{searchValue ? gettext('Department not found') : gettext('Enter characters to start searching')}
</div>
)}
</div>
</div>
</Popover>
</div>
</ClickOutside>
</div>
{errMsgs.length > 0 && (
<ul className="list-unstyled">
{errMsgs.map((item, index) => {
return <li key={index} className="error mt-2">{item}</li>;
})}
</ul>
)}
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={this.props.toggle}>{gettext('Cancel')}</Button>
<Button color="primary" onClick={this.handleSubmit} disabled={!selectedDepartment}>{gettext('Submit')}</Button>
</ModalFooter>
</Modal>
);
}
}

View File

@@ -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;
}

View File

@@ -132,6 +132,7 @@ class Department extends React.Component {
toggleAddMembers={this.props.toggleAddMembers} toggleAddMembers={this.props.toggleAddMembers}
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
toggleMoveDepartment={this.props.toggleMoveDepartment}
/> />
</Dropdown> </Dropdown>
</div> </div>

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { DropdownItem, DropdownMenu } from 'reactstrap'; import { DropdownItem, DropdownMenu } from 'reactstrap';
import { gettext } from '../../../utils/constants'; 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 ( return (
<DropdownMenu <DropdownMenu
modifiers={[{ name: 'preventOverflow', options: { boundary: document.body } }]} modifiers={[{ name: 'preventOverflow', options: { boundary: document.body } }]}
@@ -24,6 +24,9 @@ function DepartmentNodeMenu({ node, toggleDelete, toggleRename, toggleAddMembers
<DropdownItem key={`${node.id}-delete`} onClick={() => toggleDelete(node)}> <DropdownItem key={`${node.id}-delete`} onClick={() => toggleDelete(node)}>
{gettext('Delete')} {gettext('Delete')}
</DropdownItem> </DropdownItem>
<DropdownItem key={`${node.id}-move`} onClick={() => toggleMoveDepartment(node)}>
{gettext('Move department')}
</DropdownItem>
<DropdownItem key={`${node.id}-set-quota`} onClick={() => toggleSetQuotaDialog(node)}> <DropdownItem key={`${node.id}-set-quota`} onClick={() => toggleSetQuotaDialog(node)}>
{gettext('Set quota')} {gettext('Set quota')}
</DropdownItem> </DropdownItem>
@@ -42,6 +45,7 @@ DepartmentNodeMenu.propTypes = {
toggleAddDepartment: PropTypes.func.isRequired, toggleAddDepartment: PropTypes.func.isRequired,
toggleAddLibrary: PropTypes.func.isRequired, toggleAddLibrary: PropTypes.func.isRequired,
toggleSetQuotaDialog: PropTypes.func.isRequired, toggleSetQuotaDialog: PropTypes.func.isRequired,
toggleMoveDepartment: PropTypes.func.isRequired
}; };
export default DepartmentNodeMenu; export default DepartmentNodeMenu;

View File

@@ -13,7 +13,8 @@ const DepartmentsTreePanelPropTypes = {
toggleAddLibrary: PropTypes.func, toggleAddLibrary: PropTypes.func,
toggleAddMembers: PropTypes.func, toggleAddMembers: PropTypes.func,
toggleRename: PropTypes.func, toggleRename: PropTypes.func,
toggleDelete: PropTypes.func toggleDelete: PropTypes.func,
toggleMoveDepartment: PropTypes.func,
}; };
class DepartmentsTreePanel extends Component { class DepartmentsTreePanel extends Component {
@@ -35,6 +36,7 @@ class DepartmentsTreePanel extends Component {
toggleAddMembers={this.props.toggleAddMembers} toggleAddMembers={this.props.toggleAddMembers}
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
toggleMoveDepartment={this.props.toggleMoveDepartment}
/> />
); );
})} })}

View File

@@ -7,6 +7,7 @@ import toaster from '../../../components/toast';
import SetGroupQuotaDialog from '../../../components/dialog/org-set-group-quota-dialog'; import SetGroupQuotaDialog from '../../../components/dialog/org-set-group-quota-dialog';
import AddDepartmentDialog from '../../../components/dialog/sysadmin-dialog/add-department-v2-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 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 RenameDepartmentDialog from '../../../components/dialog/sysadmin-dialog/rename-department-v2-dialog';
import DeleteDepartmentConfirmDialog from '../../../components/dialog/sysadmin-dialog/delete-department-v2-confirm-dialog'; import DeleteDepartmentConfirmDialog from '../../../components/dialog/sysadmin-dialog/delete-department-v2-confirm-dialog';
import AddRepoDialog from '../../../components/dialog/org-add-repo-dialog'; import AddRepoDialog from '../../../components/dialog/org-add-repo-dialog';
@@ -28,6 +29,7 @@ class Departments extends React.Component {
operateNode: null, operateNode: null,
isAddDepartmentDialogShow: false, isAddDepartmentDialogShow: false,
isAddMembersDialogShow: false, isAddMembersDialogShow: false,
isMoveDeparmentDialogShow: false,
isRenameDepartmentDialogShow: false, isRenameDepartmentDialogShow: false,
isDeleteDepartmentDialogShow: false, isDeleteDepartmentDialogShow: false,
isShowAddRepoDialog: false, isShowAddRepoDialog: false,
@@ -143,6 +145,10 @@ class Departments extends React.Component {
this.setState({ operateNode: node, isAddMembersDialogShow: !this.state.isAddMembersDialogShow }); this.setState({ operateNode: node, isAddMembersDialogShow: !this.state.isAddMembersDialogShow });
}; };
toggleMoveDepartment = (node) => {
this.setState({ operateNode: node, isMoveDeparmentDialogShow: !this.state.isMoveDeparmentDialogShow });
};
toggleRename = (node) => { toggleRename = (node) => {
this.setState({ operateNode: node, isRenameDepartmentDialogShow: !this.state.isRenameDepartmentDialogShow }); this.setState({ operateNode: node, isRenameDepartmentDialogShow: !this.state.isRenameDepartmentDialogShow });
}; };
@@ -180,6 +186,62 @@ class Departments extends React.Component {
node.name = department.name; 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 = () => { onDelete = () => {
const { operateNode, checkedDepartmentId } = this.state; const { operateNode, checkedDepartmentId } = this.state;
orgAdminAPI.orgAdminDeleteDepartGroup(orgID, operateNode.id).then((res) => { orgAdminAPI.orgAdminDeleteDepartGroup(orgID, operateNode.id).then((res) => {
@@ -315,7 +377,7 @@ class Departments extends React.Component {
render() { render() {
const { rootNodes, operateNode, checkedDepartmentId, isAddDepartmentDialogShow, isAddMembersDialogShow, const { rootNodes, operateNode, checkedDepartmentId, isAddDepartmentDialogShow, isAddMembersDialogShow,
membersList, isMembersListLoading, isTopDepartmentLoading, isRenameDepartmentDialogShow, membersList, isMembersListLoading, isTopDepartmentLoading, isRenameDepartmentDialogShow,
isDeleteDepartmentDialogShow, sortBy, sortOrder } = this.state; isDeleteDepartmentDialogShow, sortBy, sortOrder, isMoveDeparmentDialogShow } = this.state;
return ( return (
<Fragment> <Fragment>
<MainPanelTopbar /> <MainPanelTopbar />
@@ -339,6 +401,7 @@ class Departments extends React.Component {
toggleAddMembers={this.toggleAddMembers} toggleAddMembers={this.toggleAddMembers}
toggleRename={this.toggleRename} toggleRename={this.toggleRename}
toggleDelete={this.toggleDelete} toggleDelete={this.toggleDelete}
toggleMoveDepartment={this.toggleMoveDepartment}
/> />
<Department <Department
rootNodes={rootNodes} rootNodes={rootNodes}
@@ -358,6 +421,7 @@ class Departments extends React.Component {
toggleSetQuotaDialog={this.toggleSetQuotaDialog} toggleSetQuotaDialog={this.toggleSetQuotaDialog}
toggleAddLibrary={this.toggleAddLibrary} toggleAddLibrary={this.toggleAddLibrary}
toggleAddMembers={this.toggleAddMembers} toggleAddMembers={this.toggleAddMembers}
toggleMoveDepartment={this.toggleMoveDepartment}
toggleRename={this.toggleRename} toggleRename={this.toggleRename}
toggleDelete={this.toggleDelete} toggleDelete={this.toggleDelete}
/> />
@@ -381,6 +445,14 @@ class Departments extends React.Component {
onMemberChanged={this.onMemberChanged} onMemberChanged={this.onMemberChanged}
/> />
} }
{isMoveDeparmentDialogShow &&
<MoveDepartmentDialog
orgID={orgID}
toggle={this.toggleMoveDepartment}
nodeId={operateNode.id}
onDepartmentChanged={this.onDepartmentChanged}
/>
}
{isRenameDepartmentDialogShow && {isRenameDepartmentDialogShow &&
<RenameDepartmentDialog <RenameDepartmentDialog
orgID={orgID} orgID={orgID}

View File

@@ -87,6 +87,7 @@ class DepartmentsV2TreeNode extends Component {
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
toggleAddLibrary={this.props.toggleAddLibrary} toggleAddLibrary={this.props.toggleAddLibrary}
toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleSetQuotaDialog={this.props.toggleSetQuotaDialog}
toggleMoveDepartment={this.props.toggleMoveDepartment}
/> />
); );
}); });
@@ -156,6 +157,7 @@ class DepartmentsV2TreeNode extends Component {
toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleSetQuotaDialog={this.props.toggleSetQuotaDialog}
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
toggleMoveDepartment={this.props.toggleMoveDepartment}
/> />
</Dropdown> </Dropdown>
} }

View File

@@ -153,6 +153,7 @@ class Department extends React.Component {
toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleSetQuotaDialog={this.props.toggleSetQuotaDialog}
toggleAddLibrary={this.props.toggleAddLibrary} toggleAddLibrary={this.props.toggleAddLibrary}
toggleAddMembers={this.props.toggleAddMembers} toggleAddMembers={this.props.toggleAddMembers}
toggleMoveDepartment={this.props.toggleMoveDepartment}
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
/> />

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { DropdownItem, DropdownMenu } from 'reactstrap'; import { DropdownItem, DropdownMenu } from 'reactstrap';
import { gettext } from '../../../utils/constants'; 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 ( return (
<DropdownMenu <DropdownMenu
modifiers={[{ name: 'preventOverflow', options: { boundary: document.body } }]} modifiers={[{ name: 'preventOverflow', options: { boundary: document.body } }]}
@@ -24,6 +24,9 @@ function DepartmentNodeMenu({ node, toggleDelete, toggleRename, toggleAddMembers
<DropdownItem key={`${node.id}-delete`} onClick={() => toggleDelete(node)}> <DropdownItem key={`${node.id}-delete`} onClick={() => toggleDelete(node)}>
{gettext('Delete')} {gettext('Delete')}
</DropdownItem> </DropdownItem>
<DropdownItem key={`${node.id}-move`} onClick={() => toggleMoveDepartment(node)}>
{gettext('Move department')}
</DropdownItem>
<DropdownItem key={`${node.id}-set-quota`} onClick={() => toggleSetQuotaDialog(node)}> <DropdownItem key={`${node.id}-set-quota`} onClick={() => toggleSetQuotaDialog(node)}>
{gettext('Set quota')} {gettext('Set quota')}
</DropdownItem> </DropdownItem>
@@ -42,6 +45,7 @@ DepartmentNodeMenu.propTypes = {
toggleAddDepartment: PropTypes.func.isRequired, toggleAddDepartment: PropTypes.func.isRequired,
toggleAddLibrary: PropTypes.func.isRequired, toggleAddLibrary: PropTypes.func.isRequired,
toggleSetQuotaDialog: PropTypes.func.isRequired, toggleSetQuotaDialog: PropTypes.func.isRequired,
toggleMoveDepartment: PropTypes.func.isRequired
}; };
export default DepartmentNodeMenu; export default DepartmentNodeMenu;

View File

@@ -13,7 +13,8 @@ const DepartmentsTreePanelPropTypes = {
toggleAddLibrary: PropTypes.func, toggleAddLibrary: PropTypes.func,
toggleAddMembers: PropTypes.func, toggleAddMembers: PropTypes.func,
toggleRename: PropTypes.func, toggleRename: PropTypes.func,
toggleDelete: PropTypes.func toggleDelete: PropTypes.func,
toggleMoveDepartment: PropTypes.func,
}; };
class DepartmentsTreePanel extends Component { class DepartmentsTreePanel extends Component {
@@ -33,6 +34,7 @@ class DepartmentsTreePanel extends Component {
toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleSetQuotaDialog={this.props.toggleSetQuotaDialog}
toggleAddLibrary={this.props.toggleAddLibrary} toggleAddLibrary={this.props.toggleAddLibrary}
toggleAddMembers={this.props.toggleAddMembers} toggleAddMembers={this.props.toggleAddMembers}
toggleMoveDepartment={this.props.toggleMoveDepartment}
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
/> />

View File

@@ -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 AddDepartMemberV2Dialog from '../../../components/dialog/sysadmin-dialog/sysadmin-add-depart-member-v2-dialog';
import RenameDepartmentV2Dialog from '../../../components/dialog/sysadmin-dialog/rename-department-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 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 AddRepoDialog from '../../../components/dialog/sysadmin-dialog/sysadmin-add-repo-dialog';
import DepartmentNode from './department-node'; import DepartmentNode from './department-node';
import DepartmentsTreePanel from './departments-tree-panel'; import DepartmentsTreePanel from './departments-tree-panel';
@@ -32,6 +33,7 @@ class Departments extends React.Component {
isRenameDepartmentDialogShow: false, isRenameDepartmentDialogShow: false,
isDeleteDepartmentDialogShow: false, isDeleteDepartmentDialogShow: false,
isShowAddRepoDialog: false, isShowAddRepoDialog: false,
isMoveDeparmentDialogShow: false,
membersList: [], membersList: [],
isTopDepartmentLoading: false, isTopDepartmentLoading: false,
isMembersListLoading: false, isMembersListLoading: false,
@@ -188,6 +190,10 @@ class Departments extends React.Component {
this.setState({ operateNode: node, isDeleteDepartmentDialogShow: !this.state.isDeleteDepartmentDialogShow }); this.setState({ operateNode: node, isDeleteDepartmentDialogShow: !this.state.isDeleteDepartmentDialogShow });
}; };
toggleMoveDepartment = (node) => {
this.setState({ operateNode: node, isMoveDeparmentDialogShow: !this.state.isMoveDeparmentDialogShow });
};
addDepartment = (parentNode, department) => { addDepartment = (parentNode, department) => {
parentNode.addChildren([new DepartmentNode({ parentNode.addChildren([new DepartmentNode({
id: department.id, id: department.id,
@@ -217,6 +223,57 @@ class Departments extends React.Component {
node.name = department.name; 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 = () => { onDelete = () => {
const { operateNode, checkedDepartmentId } = this.state; const { operateNode, checkedDepartmentId } = this.state;
systemAdminAPI.sysAdminDeleteDepartment(operateNode.id).then(() => { systemAdminAPI.sysAdminDeleteDepartment(operateNode.id).then(() => {
@@ -348,7 +405,7 @@ class Departments extends React.Component {
render() { render() {
const { rootNodes, operateNode, checkedDepartmentId, isAddDepartmentDialogShow, isAddMembersDialogShow, const { rootNodes, operateNode, checkedDepartmentId, isAddDepartmentDialogShow, isAddMembersDialogShow,
membersList, isMembersListLoading, isTopDepartmentLoading, isRenameDepartmentDialogShow, membersList, isMembersListLoading, isTopDepartmentLoading, isRenameDepartmentDialogShow,
isDeleteDepartmentDialogShow, sortBy, sortOrder } = this.state; isDeleteDepartmentDialogShow, sortBy, sortOrder, isMoveDeparmentDialogShow } = this.state;
return ( return (
<Fragment> <Fragment>
<MainPanelTopbar {...this.props} /> <MainPanelTopbar {...this.props} />
@@ -372,6 +429,7 @@ class Departments extends React.Component {
toggleAddMembers={this.toggleAddMembers} toggleAddMembers={this.toggleAddMembers}
toggleRename={this.toggleRename} toggleRename={this.toggleRename}
toggleDelete={this.toggleDelete} toggleDelete={this.toggleDelete}
toggleMoveDepartment={this.toggleMoveDepartment}
/> />
<Department <Department
rootNodes={rootNodes} rootNodes={rootNodes}
@@ -398,6 +456,7 @@ class Departments extends React.Component {
resetPerPage={this.resetPerPage} resetPerPage={this.resetPerPage}
currentPageInfo={this.state.currentPageInfo} currentPageInfo={this.state.currentPageInfo}
perPage={this.state.perPage} perPage={this.state.perPage}
toggleMoveDepartment={this.toggleMoveDepartment}
/> />
</> </>
} }
@@ -418,6 +477,13 @@ class Departments extends React.Component {
onMemberChanged={this.onMemberChanged} onMemberChanged={this.onMemberChanged}
/> />
} }
{isMoveDeparmentDialogShow &&
<MoveDepartmentDialog
toggle={this.toggleMoveDepartment}
nodeId={operateNode.id}
onDepartmentChanged={this.onDepartmentChanged}
/>
}
{isRenameDepartmentDialogShow && {isRenameDepartmentDialogShow &&
<RenameDepartmentV2Dialog <RenameDepartmentV2Dialog
node={operateNode} node={operateNode}

View File

@@ -15,7 +15,8 @@ const departmentsTreeNodePropTypes = {
toggleAddLibrary: PropTypes.func, toggleAddLibrary: PropTypes.func,
toggleAddMembers: PropTypes.func, toggleAddMembers: PropTypes.func,
toggleRename: PropTypes.func, toggleRename: PropTypes.func,
toggleDelete: PropTypes.func toggleDelete: PropTypes.func,
toggleMoveDepartment: PropTypes.func,
}; };
class DepartmentsTreeNode extends Component { class DepartmentsTreeNode extends Component {
@@ -87,6 +88,7 @@ class DepartmentsTreeNode extends Component {
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
toggleAddLibrary={this.props.toggleAddLibrary} toggleAddLibrary={this.props.toggleAddLibrary}
toggleMoveDepartment={this.props.toggleMoveDepartment}
/> />
); );
}); });
@@ -154,6 +156,7 @@ class DepartmentsTreeNode extends Component {
toggleSetQuotaDialog={this.props.toggleSetQuotaDialog} toggleSetQuotaDialog={this.props.toggleSetQuotaDialog}
toggleAddLibrary={this.props.toggleAddLibrary} toggleAddLibrary={this.props.toggleAddLibrary}
toggleAddMembers={this.props.toggleAddMembers} toggleAddMembers={this.props.toggleAddMembers}
toggleMoveDepartment={this.props.toggleMoveDepartment}
toggleRename={this.props.toggleRename} toggleRename={this.props.toggleRename}
toggleDelete={this.props.toggleDelete} toggleDelete={this.props.toggleDelete}
/> />

View File

@@ -425,6 +425,13 @@ class OrgAdminAPI {
return this.req.put(url, form); 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) { orgAdminDeleteDepartGroup(orgID, groupID) {
const url = this.server + '/api/v2.1/org/' + orgID + '/admin/address-book/groups/' + groupID + '/'; const url = this.server + '/api/v2.1/org/' + orgID + '/admin/address-book/groups/' + groupID + '/';
return this.req.delete(url); return this.req.delete(url);

View File

@@ -522,6 +522,13 @@ class SystemAdminAPI {
return this.req.put(url, formData); 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) { sysAdminDeleteDepartment(groupID) {
const url = this.server + '/api/v2.1/admin/address-book/groups/' + groupID + '/'; const url = this.server + '/api/v2.1/admin/address-book/groups/' + groupID + '/';
return this.req.delete(url); return this.req.delete(url);

View File

@@ -188,6 +188,7 @@ class AdminGroup(APIView):
1. transfer a group. 1. transfer a group.
2. set group quota. 2. set group quota.
3. rename group. 3. rename group.
4. move a group to another group.
Permission checking: Permission checking:
1. Admin user; 1. Admin user;
@@ -286,6 +287,56 @@ class AdminGroup(APIView):
error_msg = 'Internal Server Error' error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) 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) group_info = get_group_info(group_id)
return Response(group_info) return Response(group_info)
@@ -368,18 +419,15 @@ class AdminDepartments(APIView):
throttle_classes = (UserRateThrottle,) throttle_classes = (UserRateThrottle,)
def get(self, request): def get(self, request):
if not request.user.admin_permissions.can_manage_group():
return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.')
try: try:
all_groups = ccnet_api.list_all_departments() all_groups = ccnet_api.list_all_departments()
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
error_msg = 'Internal Server Error' error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) 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 = [] result = []
for group in all_groups: for group in all_groups:

View File

@@ -226,10 +226,6 @@ class OrgAdminDepartments(APIView):
error_msg = 'Internal Server Error' error_msg = 'Internal Server Error'
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) 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 = [] result = []
for group in departments: for group in departments:
created_at = timestamp_to_isoformat_timestr(group.timestamp) created_at = timestamp_to_isoformat_timestr(group.timestamp)
@@ -289,3 +285,74 @@ class OrgAdminGroupToDeptView(APIView):
'creator_contact_email': email2contact_email(group.creator_name), 'creator_contact_email': email2contact_email(group.creator_name),
} }
return Response(group_info) 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})

View File

@@ -14,7 +14,7 @@ from .api.admin.users import OrgAdminUser, OrgAdminUsers, OrgAdminSearchUser, \
OrgAdminImportUsers, OrgAdminInviteUser OrgAdminImportUsers, OrgAdminInviteUser
from .api.admin.user_set_password import OrgAdminUserSetPassword from .api.admin.user_set_password import OrgAdminUserSetPassword
from .api.admin.groups import OrgAdminGroups, OrgAdminGroup, OrgAdminSearchGroup, \ from .api.admin.groups import OrgAdminGroups, OrgAdminGroup, OrgAdminSearchGroup, \
OrgAdminDepartments, OrgAdminGroupToDeptView OrgAdminDepartments, OrgAdminGroupToDeptView, OrgAdminMoveDepartment
from .api.admin.repos import OrgAdminRepos, OrgAdminRepo from .api.admin.repos import OrgAdminRepos, OrgAdminRepo
from .api.admin.trash_libraries import OrgAdminTrashLibraries, OrgAdminTrashLibrary from .api.admin.trash_libraries import OrgAdminTrashLibraries, OrgAdminTrashLibrary
from .api.admin.info import OrgAdminInfo from .api.admin.info import OrgAdminInfo
@@ -77,6 +77,7 @@ urlpatterns = [
path('<int:org_id>/admin/groups/<int:group_id>/', OrgAdminGroup.as_view(), name='api-admin-group'), path('<int:org_id>/admin/groups/<int:group_id>/', OrgAdminGroup.as_view(), name='api-admin-group'),
path('<int:org_id>/admin/groups/<int:group_id>/libraries/', AdminGroupLibraries.as_view(), name='api-admin-group-libraries'), path('<int:org_id>/admin/groups/<int:group_id>/libraries/', AdminGroupLibraries.as_view(), name='api-admin-group-libraries'),
path('<int:org_id>/admin/groups/<int:group_id>/group-to-department/', OrgAdminGroupToDeptView.as_view(), name='api-admin-group-to-department'), path('<int:org_id>/admin/groups/<int:group_id>/group-to-department/', OrgAdminGroupToDeptView.as_view(), name='api-admin-group-to-department'),
path('<int:org_id>/admin/groups/<int:group_id>/move-department/', OrgAdminMoveDepartment.as_view(), name='api-admin-move-department'),
re_path(r'^(?P<org_id>\d+)/admin/groups/(?P<group_id>\d+)/libraries/(?P<repo_id>[-0-9a-f]{36})/$', AdminGroupLibrary.as_view(), name='api-admin-group-library'), re_path(r'^(?P<org_id>\d+)/admin/groups/(?P<group_id>\d+)/libraries/(?P<repo_id>[-0-9a-f]{36})/$', AdminGroupLibrary.as_view(), name='api-admin-group-library'),
path('<int:org_id>/admin/groups/<int:group_id>/group-owned-libraries/', AdminGroupOwnedLibraries.as_view(), name='api-admin-group-owned-libraries'), path('<int:org_id>/admin/groups/<int:group_id>/group-owned-libraries/', AdminGroupOwnedLibraries.as_view(), name='api-admin-group-owned-libraries'),

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os import os
from django.db import connection from django.db import connection, transaction
def get_ccnet_db_name(): def get_ccnet_db_name():
@@ -256,3 +256,62 @@ class CcnetDB:
staffs = cursor.fetchall() staffs = cursor.fetchall()
return [s[0] for s in staffs] 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
]
)