1
0
mirror of https://github.com/haiwen/seahub.git synced 2025-09-09 02:42:47 +00:00

Add department transfer function to the administrator interface (#6014)

* Add the function of transferring departments in the administrator interface

* fix admin get all departments

* update

* Update departments.py

* update

* update

* add department repo transfer

* Update groups.py

* update

---------

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
2024-04-25 12:08:13 +08:00
committed by GitHub
parent 7a04bfa928
commit db6cee68e9
11 changed files with 362 additions and 51 deletions

View File

@@ -1,19 +1,34 @@
import React from 'react';
import React, {Fragment} from 'react';
import PropTypes from 'prop-types';
import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap';
import {
Button,
Modal,
ModalHeader,
ModalBody,
ModalFooter,
Nav,
NavItem,
NavLink,
TabContent,
TabPane
} from 'reactstrap';
import makeAnimated from 'react-select/animated';
import { seafileAPI } from '../../utils/seafile-api';
import { gettext, isPro } from '../../utils/constants';
import {gettext, isPro, orgID} from '../../utils/constants';
import { Utils } from '../../utils/utils';
import toaster from '../toast';
import UserSelect from '../user-select';
import { SeahubSelect } from '../common/select';
import '../../css/transfer-dialog.css';
const propTypes = {
itemName: PropTypes.string.isRequired,
toggleDialog: PropTypes.func.isRequired,
submit: PropTypes.func.isRequired,
canTransferToDept: PropTypes.bool
canTransferToDept: PropTypes.bool,
isOrgAdmin: PropTypes.bool,
isSysAdmin: PropTypes.bool,
};
class TransferDialog extends React.Component {
@@ -23,23 +38,54 @@ class TransferDialog extends React.Component {
selectedOption: null,
errorMsg: [],
transferToUser: true,
transferToGroup: false,
activeTab: 'transUser'
};
this.options = [];
}
handleSelectChange = (option) => {
this.setState({selectedOption: option});
this.setState({ selectedOption: option });
};
submit = () => {
let user = this.state.selectedOption;
this.props.submit(user);
};
componentDidMount() {
if (isPro) {
if (this.props.isOrgAdmin) {
seafileAPI.orgAdminListDepartments(orgID).then((res) => {
for (let i = 0; i < res.data.length; i++) {
let obj = {};
obj.value = res.data[i].name;
obj.email = res.data[i].email;
obj.label = res.data[i].name;
this.options.push(obj);
}
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}
else if (this.props.isSysAdmin) {
seafileAPI.sysAdminListDepartments().then((res) => {
for (let i = 0; i < res.data.length; i++) {
let obj = {};
obj.value = res.data[i].name;
obj.email = res.data[i].email;
obj.label = res.data[i].name;
this.options.push(obj);
}
}).catch(error => {
let errMessage = Utils.getErrorMsg(error);
toaster.danger(errMessage);
});
}
else{
seafileAPI.listDepartments().then((res) => {
for (let i = 0 ; i < res.data.length; i++) {
for (let i = 0; i < res.data.length; i++) {
let obj = {};
obj.value = res.data[i].name;
obj.email = res.data[i].email;
@@ -56,49 +102,85 @@ class TransferDialog extends React.Component {
onClick = () => {
this.setState({
transferToUser: !this.state.transferToUser,
});
};
render() {
toggle = (tab) => {
if (this.state.activeTab !== tab) {
this.setState({ activeTab: tab });
}
};
renderTransContent = () => {
let activeTab = this.state.activeTab;
let canTransferToDept = true;
if (this.props.canTransferToDept != undefined) {
canTransferToDept = this.props.canTransferToDept;
}
return (
<Fragment>
<div className="transfer-dialog-side">
<Nav pills>
<NavItem role="tab" aria-selected={activeTab === 'transUser'} aria-controls="transfer-user-panel">
<NavLink className={activeTab === 'transUser' ? 'active' : ''} onClick={(this.toggle.bind(this, 'transUser'))} tabIndex="0" onKeyDown={this.onTabKeyDown}>
{gettext('Transfer to user')}
</NavLink>
</NavItem>
{isPro &&
<NavItem role="tab" aria-selected={activeTab === 'transDepart'} aria-controls="transfer-depart-panel">
<NavLink className={activeTab === 'transDepart' ? 'active' : ''} onClick={this.toggle.bind(this, 'transDepart')} tabIndex="0" onKeyDown={this.onTabKeyDown}>
{gettext('Transfer to department')}
</NavLink>
</NavItem>}
</Nav>
</div>
<div className="transfer-dialog-main">
<TabContent activeTab={this.state.activeTab}>
<Fragment>
<TabPane tabId="transUser" role="tabpanel" id="transfer-user-panel">
<UserSelect
ref="userSelect"
isMulti={false}
className="reviewer-select"
placeholder={gettext('Select a user')}
onSelectChange={this.handleSelectChange}
/>
</TabPane>
{isPro && canTransferToDept &&
<TabPane tabId="transDepart" role="tabpanel" id="transfer-depart-panel">
<SeahubSelect
isClearable
maxMenuHeight={200}
hideSelectedOptions={true}
components={makeAnimated()}
placeholder={gettext('Select a department')}
options={this.options}
onChange={this.handleSelectChange}
value={this.state.selectedOption}
/>
</TabPane>}
</Fragment>
</TabContent>
</div>
</Fragment>
);
};
render() {
const { itemName: repoName } = this.props;
let title = gettext('Transfer Library {library_name}');
title = title.replace('{library_name}', '<span class="op-target text-truncate mx-1">' + Utils.HTMLescape(repoName) + '</span>');
return (
<Modal isOpen={true} toggle={this.props.toggleDialog}>
<Modal isOpen={true} style={{maxWidth: '720px'}} toggle={this.props.toggleDialog} className="transfer-dialog">
<ModalHeader toggle={this.props.toggleDialog}>
<span dangerouslySetInnerHTML={{__html: title}} className="d-flex mw-100"></span>
<span dangerouslySetInnerHTML={{ __html: title }} className="d-flex mw-100"></span>
</ModalHeader>
<ModalBody>
{this.state.transferToUser ?
<UserSelect
ref="userSelect"
isMulti={false}
className="reviewer-select"
placeholder={gettext('Select a user')}
onSelectChange={this.handleSelectChange}
/> :
<SeahubSelect
isClearable
maxMenuHeight={200}
hideSelectedOptions={true}
components={makeAnimated()}
placeholder={gettext('Select a department')}
options={this.options}
onChange={this.handleSelectChange}
value={this.state.selectedOption}
/>
}
{isPro && canTransferToDept &&
<span role="button" tabIndex="0" className="action-link" onClick={this.onClick} onKeyDown={Utils.onKeyDown}>{this.state.transferToUser ?
gettext('Transfer to department'): gettext('Transfer to user')}
</span>
}
<ModalBody className="transfer-dialog-content" role="tablist">
{this.renderTransContent()}
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={this.props.toggleDialog}>{gettext('Cancel')}</Button>

View File

@@ -0,0 +1,52 @@
.transfer-dialog .transfer-dialog-content {
padding: 0;
min-height: 22.5rem;
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.transfer-dialog .transfer-dialog-content {
flex-direction: row;
}
}
.transfer-dialog-content .transfer-dialog-side {
flex-basis: 29%;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.transfer-dialog .nav .nav-item .nav-link {
padding: 0.3125rem 0.25rem;
}
@media (min-width: 768px) {
.transfer-dialog-content .transfer-dialog-side {
padding: 12px 8px;
border: 0;
border-right: 1px solid #eee;
}
.transfer-dialog-side .nav {
flex-direction: column;
}
.transfer-dialog-side .nav-pills .nav-item .nav-link {
width: 100%;
padding: 0.3125rem 0.5rem;
margin: 0;
}
}
.transfer-dialog-content .transfer-dialog-main {
display: flex;
flex-basis: 78%;
padding: 1rem;
}
.transfer-dialog-content .transfer-dialog-main .tab-content {
flex: 1;
}
.transfer-dialog-content .transfer-dialog-main .tab-pane {
height: 100%;
}

View File

@@ -200,7 +200,8 @@ class RepoItem extends React.Component {
highlight: false,
showMenu: false,
isItemMenuShow: false,
isTransferDialogShow: false
isTransferDialogShow: false,
orgAdmin: true
};
}
@@ -291,7 +292,7 @@ class RepoItem extends React.Component {
render() {
let { repo } = this.props;
let isOperationMenuShow = this.state.showMenu && !repo.isDepartmentRepo;
let isOperationMenuShow = this.state.showMenu;
return (
<Fragment>
<tr className={this.state.highlight ? 'tr-highlight' : ''} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
@@ -326,6 +327,7 @@ class RepoItem extends React.Component {
itemName={repo.repoName}
submit={this.onTransferRepo}
toggleDialog={this.toggleTransfer}
isOrgAdmin={true}
/>
</ModalPortal>
)}

View File

@@ -280,6 +280,12 @@ class Item extends Component {
getOperations = () => {
const { repo } = this.props;
let operations = ['Delete', 'Transfer'];
const index = repo.owner_email.indexOf('@seafile_group');
let isGroupOwnedRepo = index != -1;
if (isGroupOwnedRepo) {
operations = ['Transfer'];
return operations;
}
if (!repo.encrypted) {
operations.push('Share');
}
@@ -319,7 +325,7 @@ class Item extends Component {
}
</td>
<td>
{(!isGroupOwnedRepo && isOpIconShown) &&
{(isOpIconShown) &&
<OpMenu
operations={this.getOperations()}
translateOperations={this.translateOperations}
@@ -359,8 +365,8 @@ class Item extends Component {
<TransferDialog
itemName={repo.name}
submit={this.onTransferRepo}
canTransferToDept={false}
toggleDialog={this.toggleTransferDialog}
isSysAdmin={true}
/>
</ModalPortal>
}

View File

@@ -11,8 +11,11 @@ from django.utils.translation import gettext as _
from seaserv import seafile_api, ccnet_api
from pysearpc import SearpcError
from seahub.avatar.settings import GROUP_AVATAR_DEFAULT_SIZE
from seahub.avatar.templatetags.group_avatar_tags import get_default_group_avatar_url, api_grp_avatar_url
from seahub.base.accounts import User
from seahub.base.templatetags.seahub_tags import email2nickname
from seahub.settings import CLOUD_MODE, MULTI_TENANCY
from seahub.utils import is_valid_username, is_pro_version
from seahub.utils.timeutils import timestamp_to_isoformat_timestr
from seahub.group.utils import is_group_member, is_group_admin, \
@@ -351,3 +354,47 @@ class AdminSearchGroup(APIView):
result.append(group_info)
return Response({"group_list": result})
class AdminDepartments(APIView):
"""
List all departments
"""
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsAdminUser,)
throttle_classes = (UserRateThrottle,)
def get(self, request):
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:
try:
avatar_url, is_default, date_uploaded = api_grp_avatar_url(group.id, avatar_size)
except:
avatar_url = get_default_group_avatar_url()
created_at = timestamp_to_isoformat_timestr(group.timestamp)
department_info = {
"id": group.id,
"email": '%s@seafile_group' % str(group.id),
"parent_group_id": group.parent_group_id,
"name": group.group_name,
"owner": group.creator_name,
"created_at": created_at,
"avatar_url": request.build_absolute_uri(avatar_url),
}
result.append(department_info)
return Response(result)

View File

@@ -8,6 +8,7 @@ from rest_framework.views import APIView
from rest_framework import status
from django.template.defaultfilters import filesizeformat
from django.utils.translation import gettext as _
import seaserv
from seaserv import ccnet_api, seafile_api
from seahub.api2.authentication import TokenAuthentication
@@ -26,6 +27,7 @@ from seahub.utils import is_valid_dirent_name, is_valid_email
from seahub.utils.timeutils import timestamp_to_isoformat_timestr
from seahub.api2.endpoints.group_owned_libraries import get_group_id_by_repo_owner
from seahub.constants import PERMISSION_READ_WRITE
try:
from seahub.settings import MULTI_TENANCY
@@ -342,7 +344,7 @@ class AdminLibrary(APIView):
new_owner = request.data.get('owner', None)
if new_owner:
if not is_valid_email(new_owner):
if not is_valid_email(new_owner) and '@seafile_group' not in new_owner:
error_msg = 'owner invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
@@ -419,7 +421,14 @@ class AdminLibrary(APIView):
pub_repos = seafile_api.list_inner_pub_repos_by_owner(repo_owner)
# transfer repo
seafile_api.set_repo_owner(repo_id, new_owner)
if '@seafile_group' in new_owner:
group_id = int(new_owner.split('@')[0])
if seaserv.is_org_group(group_id):
error_msg = 'Can not transfer library to an organization department %s' % new_owner
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
seafile_api.transfer_repo_to_group(repo_id, group_id, PERMISSION_READ_WRITE)
else:
seafile_api.set_repo_owner(repo_id, new_owner)
# reshare repo to user
for shared_user in shared_users:

View File

@@ -13,7 +13,10 @@ from seahub.api2.throttling import UserRateThrottle
from seahub.api2.authentication import TokenAuthentication
from seahub.api2.utils import api_error
from seahub.api2.endpoints.admin.groups import AdminGroup as SysAdminGroup
from seahub.avatar.settings import GROUP_AVATAR_DEFAULT_SIZE
from seahub.avatar.templatetags.group_avatar_tags import api_grp_avatar_url, get_default_group_avatar_url
from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email
from seahub.utils.ccnet_db import CcnetDB
from seahub.utils.timeutils import timestamp_to_isoformat_timestr
from pysearpc import SearpcError
@@ -202,3 +205,45 @@ class OrgAdminSearchGroup(APIView):
groups_list.append(group)
return Response({'group_list': groups_list})
class OrgAdminDepartments(APIView):
"""
List all departments of the current organization
"""
authentication_classes = (TokenAuthentication, SessionAuthentication)
permission_classes = (IsProVersion, IsOrgAdminUser)
throttle_classes = (UserRateThrottle,)
def get(self, request, org_id):
try:
db_api = CcnetDB()
departments = db_api.list_org_departments(int(org_id))
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 departments:
try:
avatar_url, is_default, date_uploaded = api_grp_avatar_url(group.id, avatar_size)
except:
avatar_url = get_default_group_avatar_url()
created_at = timestamp_to_isoformat_timestr(group.timestamp)
department_info = {
"id": group.id,
"email": '%s@seafile_group' % str(group.id),
"parent_group_id": group.parent_group_id,
"name": group.group_name,
"owner": group.creator_name,
"created_at": created_at,
"avatar_url": request.build_absolute_uri(avatar_url),
}
result.append(department_info)
return Response(result)

View File

@@ -19,11 +19,13 @@ from seahub.group.utils import group_id_to_name
from seahub.utils.timeutils import timestamp_to_isoformat_timestr
from seahub.utils import is_valid_email
from seahub.signals import repo_deleted
from seahub.constants import PERMISSION_READ_WRITE
from seahub.organizations.views import is_org_repo, org_user_exists
from pysearpc import SearpcError
logger = logging.getLogger(__name__)
@@ -128,12 +130,11 @@ class OrgAdminRepo(APIView):
"""Transfer an organization library
"""
new_owner = request.data.get('email', None)
if not new_owner:
error_msg = 'Email invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
if not is_valid_email(new_owner):
if not is_valid_email(new_owner) and not '@seafile_group' in new_owner:
error_msg = 'Email invalid.'
return api_error(status.HTTP_400_BAD_REQUEST, error_msg)
@@ -143,9 +144,10 @@ class OrgAdminRepo(APIView):
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
# permission checking
if not org_user_exists(org_id, new_owner):
error_msg = 'User %s not in org %s.' % (new_owner, org_id)
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
if '@seafile_group' not in new_owner:
if not org_user_exists(org_id, new_owner):
error_msg = 'User %s not in org %s.' % (new_owner, org_id)
return api_error(status.HTTP_404_NOT_FOUND, error_msg)
repo = seafile_api.get_repo(repo_id)
if not repo:
@@ -166,8 +168,17 @@ class OrgAdminRepo(APIView):
# get all pub repos
pub_repos = seafile_api.list_org_inner_pub_repos_by_owner(org_id, repo_owner)
seafile_api.set_org_repo_owner(org_id, repo_id, new_owner)
# transfer repo
if '@seafile_group' in new_owner:
group_id = int(new_owner.split('@')[0])
if seaserv.is_org_group(group_id):
group_org_id = ccnet_api.get_org_id_by_group(group_id)
if org_id != group_org_id:
error_msg = 'Permission denied.'
return api_error(status.HTTP_403_FORBIDDEN, error_msg)
seafile_api.org_transfer_repo_to_group(repo_id, org_id, group_id, PERMISSION_READ_WRITE)
else:
seafile_api.set_org_repo_owner(org_id, repo_id, new_owner)
# reshare repo to user
for shared_user in shared_users:

View File

@@ -13,7 +13,7 @@ from .api.group_members import AdminGroupMembers, AdminGroupMember
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
from .api.admin.groups import OrgAdminGroups, OrgAdminGroup, OrgAdminSearchGroup, OrgAdminDepartments
from .api.admin.repos import OrgAdminRepos, OrgAdminRepo
from .api.admin.info import OrgAdminInfo
from .api.admin.links import OrgAdminLinks, OrgAdminLink
@@ -95,5 +95,6 @@ urlpatterns = [
path('admin/logs/file-access/', OrgAdminLogsFileAccess.as_view(), name='api-v2.1-org-admin-logs-file-access'),
path('admin/logs/file-update/', OrgAdminLogsFileUpdate.as_view(), name='api-v2.1-org-admin-logs-file-update'),
path('admin/logs/repo-permission/', OrgAdminLogsPermAudit.as_view(), name='api-v2.1-org-admin-logs-repo-permission'),
path('<int:org_id>/admin/departments/', OrgAdminDepartments.as_view(), name='api-v2.1-org-admin-departments'),
]

View File

@@ -150,7 +150,7 @@ from seahub.api2.endpoints.admin.system_library import AdminSystemLibrary, \
AdminSystemLibraryUploadLink
from seahub.api2.endpoints.admin.default_library import AdminDefaultLibrary
from seahub.api2.endpoints.admin.trash_libraries import AdminTrashLibraries, AdminTrashLibrary
from seahub.api2.endpoints.admin.groups import AdminGroups, AdminGroup, AdminSearchGroup
from seahub.api2.endpoints.admin.groups import AdminGroups, AdminGroup, AdminSearchGroup, AdminDepartments
from seahub.api2.endpoints.admin.group_libraries import AdminGroupLibraries, AdminGroupLibrary
from seahub.api2.endpoints.admin.group_members import AdminGroupMembers, AdminGroupMember
from seahub.api2.endpoints.admin.shares import AdminShares
@@ -635,6 +635,9 @@ urlpatterns = [
re_path(r'^api/v2.1/admin/groups/(?P<group_id>\d+)/group-owned-libraries/$', AdminGroupOwnedLibraries.as_view(), name='api-v2.1-admin-group-owned-libraries'),
re_path(r'^api/v2.1/admin/groups/(?P<group_id>\d+)/group-owned-libraries/(?P<repo_id>[-0-9a-f]{36})/$', AdminGroupOwnedLibrary.as_view(), name='api-v2.1-admin-owned-group-library'),
## admin::departments
re_path(r'api/v2.1/admin/departments/$', AdminDepartments.as_view(), name='api-v2.1-admin-departments'),
## admin::shares
re_path(r'^api/v2.1/admin/shares/$', AdminShares.as_view(), name='api-v2.1-admin-shares'),

View File

@@ -22,3 +22,56 @@ def get_ccnet_db_name():
error_msg = 'Failed to init ccnet db, only mysql db supported.'
return None, error_msg
return db_name, None
import os
import configparser
from django.db import connection
class CcnetGroup(object):
def __init__(self, **kwargs):
self.id = kwargs.get('group_id')
self.group_name = kwargs.get('group_name')
self.creator_name = kwargs.get('creator_name')
self.timestamp = kwargs.get('timestamp')
self.parent_group_id = kwargs.get('parent_group_id')
class CcnetDB:
def __init__(self):
self.db_name = get_ccnet_db_name()[0]
def list_org_departments(self, org_id):
sql = f"""
SELECT
g.group_id, group_name, creator_name, timestamp, type, parent_group_id
FROM
`{self.db_name}`.`OrgGroup` o
LEFT JOIN
`{self.db_name}`.`Group` g
ON o.group_id=g.group_id
WHERE
org_id={org_id} AND parent_group_id<>0;
"""
groups = []
with connection.cursor() as cursor:
cursor.execute(sql)
for item in cursor.fetchall():
group_id = item[0]
group_name = item[1]
creator_name = item[2]
timestamp=item[3]
parent_group_id = item[5]
params = {
'group_id':group_id,
'group_name': group_name,
'creator_name': creator_name,
'timestamp': timestamp,
'parent_group_id': parent_group_id
}
group_obj = CcnetGroup(**params)
groups.append(group_obj)
return groups