diff --git a/frontend/src/components/common/account.js b/frontend/src/components/common/account.js index cfad00b5cf..5e9431561c 100644 --- a/frontend/src/components/common/account.js +++ b/frontend/src/components/common/account.js @@ -122,7 +122,7 @@ class Account extends Component { }; } else if (isOrgStaff) { data = { - url: `${siteRoot}org/useradmin/`, + url: `${siteRoot}org/info/`, text: gettext('Organization Admin') }; } else if (isInstAdmin) { diff --git a/frontend/src/components/dialog/org-import-users-dialog.js b/frontend/src/components/dialog/org-import-users-dialog.js new file mode 100644 index 0000000000..acadd65037 --- /dev/null +++ b/frontend/src/components/dialog/org-import-users-dialog.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import { gettext, siteRoot } from '../../utils/constants'; + +const propTypes = { + toggle: PropTypes.func.isRequired, + importUsersInBatch: PropTypes.func.isRequired, +}; + +class ImportOrgUsersDialog extends React.Component { + constructor(props) { + super(props); + this.fileInputRef = React.createRef(); + this.state = { + errorMsg: '' + }; + } + + toggle = () => { + this.props.toggle(); + } + + openFileInput = () => { + this.fileInputRef.current.click(); + } + + uploadFile = (e) => { + // no file selected + if (!this.fileInputRef.current.files.length) { + return; + } + // check file extension + let fileName = this.fileInputRef.current.files[0].name; + if(fileName.substr(fileName.lastIndexOf('.') + 1) != 'xlsx') { + this.setState({ + errorMsg: gettext('Please choose a .xlsx file.') + }); + return; + } + const file = this.fileInputRef.current.files[0]; + this.props.importUsersInBatch(file); + this.toggle(); + } + + render() { + let { errorMsg } = this.state; + return ( + + {gettext('Import users from a .xlsx file')} + +

{gettext('Download an example file')}

+ + + {errorMsg && {errorMsg}} +
+ + + +
+ ); + } +} + +ImportOrgUsersDialog.propTypes = propTypes; + +export default ImportOrgUsersDialog; diff --git a/frontend/src/pages/org-admin/devices/desktop-devices.js b/frontend/src/pages/org-admin/devices/desktop-devices.js new file mode 100644 index 0000000000..47ce6a20d3 --- /dev/null +++ b/frontend/src/pages/org-admin/devices/desktop-devices.js @@ -0,0 +1,29 @@ +import React, { Component, Fragment } from 'react'; +import DevicesNav from './devices-nav'; +import DevicesByPlatform from './devices-by-platform'; +import MainPanelTopbar from '../main-panel-topbar'; + +class OrgDesktopDevices extends Component { + + constructor(props) { + super(props); + } + + render() { + return ( + + +
+
+ + +
+
+
+ ); + } +} + +export default OrgDesktopDevices; diff --git a/frontend/src/pages/org-admin/devices/devices-by-platform.js b/frontend/src/pages/org-admin/devices/devices-by-platform.js new file mode 100644 index 0000000000..691a2cb50d --- /dev/null +++ b/frontend/src/pages/org-admin/devices/devices-by-platform.js @@ -0,0 +1,216 @@ +import React, { Component, Fragment } from 'react'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { orgID, gettext } from '../../../utils/constants'; +import toaster from '../../../components/toast'; +import { Utils } from '../../../utils/utils'; +import EmptyTip from '../../../components/empty-tip'; +import moment from 'moment'; +import Loading from '../../../components/loading'; +import Paginator from '../../../components/paginator'; +import SysAdminUnlinkDevice from '../../../components/dialog/sysadmin-dialog/sysadmin-unlink-device-dialog'; + +class Content extends Component { + + constructor(props) { + super(props); + } + + getPreviousPageDevicesList = () => { + this.props.getDevicesListByPage(this.props.pageInfo.current_page - 1); + } + + getNextPageDevicesList = () => { + this.props.getDevicesListByPage(this.props.pageInfo.current_page + 1); + } + + render() { + const { loading, errorMsg, items, pageInfo, curPerPage } = this.props; + if (loading) { + return ; + } else if (errorMsg) { + return

{errorMsg}

; + } else { + const emptyTip = ( + +

{gettext('No connected devices')}

+
+ ); + const table = ( + + + + + + + + + + + + + + {items.map((item, index) => { + return (); + })} + +
{gettext('User')}{gettext('Platform')}{' / '}{gettext('Version')}{gettext('Device Name')}{gettext('IP')}{gettext('Last Access')}{/*Operations*/}
+ +
+ ); + + return items.length ? table : emptyTip; + } + } +} + +class Item extends Component { + + constructor(props) { + super(props); + this.state = { + unlinked: false, + isOpIconShown: false, + isUnlinkDeviceDialogOpen: false + }; + } + + handleMouseOver = () => { + this.setState({isOpIconShown: true}); + } + + handleMouseOut = () => { + this.setState({isOpIconShown: false}); + } + + handleUnlink = (e) => { + e.preventDefault(); + if (this.props.item.is_desktop_client) { + this.toggleUnlinkDeviceDialog(); + } else { + this.unlinkDevice(true); + } + } + + toggleUnlinkDeviceDialog = () => { + this.setState({isUnlinkDeviceDialogOpen: !this.state.isUnlinkDeviceDialogOpen}); + } + + unlinkDevice = (deleteFiles) => { + const { platform, device_id, user } = this.props.item; + seafileAPI.orgAdminUnlinkDevice(orgID, platform, device_id, user, deleteFiles).then((res) => { + this.setState({unlinked: true}); + let message = gettext('Successfully unlinked the device.'); + toaster.success(message); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + render() { + const item = this.props.item; + const { unlinked, isUnlinkDeviceDialogOpen, isOpIconShown } = this.state; + + if (unlinked) { + return null; + } + + return ( + + + {item.user_name} + {item.platform}{' / '}{item.client_version} + {item.device_name} + {item.last_login_ip} + + {moment(item.last_accessed).fromNow()} + + + + + + {isUnlinkDeviceDialogOpen && + + } + + ); + } +} + +class DevicesByPlatform extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + devicesData: {}, + pageInfo: {}, + perPage: 25 + }; + } + + componentDidMount () { + let urlParams = (new URL(window.location)).searchParams; + const { currentPage = 1, perPage } = this.state; + this.setState({ + perPage: parseInt(urlParams.get('per_page') || perPage), + currentPage: parseInt(urlParams.get('page') || currentPage) + }, () => { + this.getDevicesListByPage(this.state.currentPage); + }); + } + + getDevicesListByPage = (page) => { + let platform = this.props.devicesPlatform; + let per_page = this.state.perPage; + seafileAPI.orgAdminListDevices(orgID, platform, page, per_page).then((res) => { + this.setState({ + devicesData: res.data.devices, + pageInfo: res.data.page_info, + loading: false + }); + }).catch((error) => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403 + }); + }); + } + + resetPerPage = (perPage) => { + this.setState({ + perPage: perPage + }, () => { + this.getDevicesListByPage(1); + }); + } + + render() { + return ( +
+ +
+ ); + } +} + +export default DevicesByPlatform; diff --git a/frontend/src/pages/org-admin/devices/devices-errors.js b/frontend/src/pages/org-admin/devices/devices-errors.js new file mode 100644 index 0000000000..cecf5f459f --- /dev/null +++ b/frontend/src/pages/org-admin/devices/devices-errors.js @@ -0,0 +1,204 @@ +import React, { Component, Fragment } from 'react'; +import { Button } from 'reactstrap'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { siteRoot, gettext, orgID } from '../../../utils/constants'; +import toaster from '../../../components/toast'; +import { Utils } from '../../../utils/utils'; +import EmptyTip from '../../../components/empty-tip'; +import moment from 'moment'; +import Loading from '../../../components/loading'; +import { Link } from '@reach/router'; +import DevicesNav from './devices-nav'; +import MainPanelTopbar from '../main-panel-topbar'; +import UserLink from '../user-link'; +import Paginator from '../../../components/paginator'; + +class Content extends Component { + + constructor(props) { + super(props); + } + + getPreviousPageDeviceErrorsList = () => { + this.props.getDeviceErrorsListByPage(this.props.pageInfo.current_page - 1); + } + + getNextPageDeviceErrorsList = () => { + this.props.getDeviceErrorsListByPage(this.props.pageInfo.current_page + 1); + } + + render() { + const { loading, errorMsg, items, pageInfo, curPerPage } = this.props; + if (loading) { + return ; + } else if (errorMsg) { + return

{errorMsg}

; + } else { + const emptyTip = ( + +

{gettext('No sync errors')}

+
+ ); + const table = ( + + + + + + + + + + + + + + {items.map((item, index) => { + return (); + })} + +
{gettext('User')}{gettext('Device')}{' / '}{gettext('Version')}{gettext('IP')}{gettext('Library')}{gettext('Error')}{gettext('Time')}
+ +
+ ); + return items.length ? table : emptyTip; + } + } +} + +class Item extends Component { + + constructor(props) { + super(props); + this.state = { + isOpIconShown: false, + }; + } + + handleMouseOver = () => { + this.setState({isOpIconShown: true}); + } + + handleMouseOut = () => { + this.setState({isOpIconShown: false}); + } + + render() { + let item = this.props.item; + return ( + + + {item.device_name}{' / '}{item.client_version} + {item.device_ip} + {item.repo_name} + {item.error_msg} + + {moment(item.error_time).fromNow()} + + + ); + } +} + +class OrgDevicesErrors extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + devicesErrors: [], + isCleanBtnShown: false, + pageInfo: {}, + perPage: 25 + }; + } + + componentDidMount () { + let urlParams = (new URL(window.location)).searchParams; + const { currentPage = 1, perPage } = this.state; + this.setState({ + perPage: parseInt(urlParams.get('per_page') || perPage), + currentPage: parseInt(urlParams.get('page') || currentPage) + }, () => { + this.getDeviceErrorsListByPage(this.state.currentPage); + }); + } + + getDeviceErrorsListByPage = (page) => { + let per_page = this.state.perPage; + seafileAPI.orgAdminListDevicesErrors(orgID, page, per_page).then((res) => { + this.setState({ + loading: false, + devicesErrors: res.data.device_errors, + pageInfo: res.data.page_info, + isCleanBtnShown: res.data.length > 0 + }); + }).catch((error) => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403 + }); + }); + } + + clean = () => { + seafileAPI.sysAdminClearDeviceErrors().then((res) => { + this.setState({ + devicesErrors: [], + isCleanBtnShown: false + }); + let message = gettext('Successfully cleaned all errors.'); + toaster.success(message); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + resetPerPage = (perPage) => { + this.setState({ + perPage: perPage + }, () => { + this.getDeviceErrorsListByPage(1); + }); + } + render() { + return ( + + {this.state.isCleanBtnShown ? ( + + + + ) : ( + + )} +
+
+ +
+ +
+
+
+
+ ); + } +} + +export default OrgDevicesErrors; diff --git a/frontend/src/pages/org-admin/devices/devices-nav.js b/frontend/src/pages/org-admin/devices/devices-nav.js new file mode 100644 index 0000000000..263690a592 --- /dev/null +++ b/frontend/src/pages/org-admin/devices/devices-nav.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@reach/router'; +import { siteRoot, gettext } from '../../../utils/constants'; + +const propTypes = { + currentItem: PropTypes.string.isRequired +}; + +class Nav extends React.Component { + + constructor(props) { + super(props); + this.navItems = [ + {name: 'desktop', urlPart:'desktop-devices', text: gettext('Desktop')}, + {name: 'mobile', urlPart:'mobile-devices', text: gettext('Mobile')}, + {name: 'errors', urlPart:'devices-errors', text: gettext('Errors')} + ]; + } + + render() { + const { currentItem } = this.props; + return ( +
+
    + {this.navItems.map((item, index) => { + return ( +
  • + {item.text} +
  • + ); + })} +
+
+ ); + } +} + +Nav.propTypes = propTypes; + +export default Nav; diff --git a/frontend/src/pages/org-admin/devices/mobile-devices.js b/frontend/src/pages/org-admin/devices/mobile-devices.js new file mode 100644 index 0000000000..41a9f4d2c6 --- /dev/null +++ b/frontend/src/pages/org-admin/devices/mobile-devices.js @@ -0,0 +1,29 @@ +import React, { Component, Fragment } from 'react'; +import DevicesNav from './devices-nav'; +import DevicesByPlatform from './devices-by-platform'; +import MainPanelTopbar from '../main-panel-topbar'; + +class MobileDevices extends Component { + + constructor(props) { + super(props); + } + + render() { + return ( + + +
+
+ + +
+
+
+ ); + } +} + +export default MobileDevices; diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js index af3d949776..c039c5f699 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -3,6 +3,15 @@ import ReactDOM from 'react-dom'; import { Router } from '@reach/router'; import { siteRoot } from '../../utils/constants'; import SidePanel from './side-panel'; + +import OrgStatisticFile from './statistic/statistic-file'; +import OrgStatisticStorage from './statistic/statistic-storage'; +import OrgStatisticTraffic from './statistic/statistic-traffic'; +import OrgStatisticUsers from './statistic/statistic-users'; +import OrgStatisticReport from './statistic/statistic-reports'; +import OrgDesktopDevices from './devices/desktop-devices.js'; +import OrgMobileDevices from './devices/mobile-devices.js'; +import OrgDevicesErrors from './devices/devices-errors.js'; import OrgUsers from './org-users-users'; import OrgUsersSearchUsers from './org-users-search-users'; import OrgAdmins from './org-users-admins'; @@ -44,6 +53,12 @@ class Org extends React.Component { if (location.href.indexOf(`${siteRoot}org/useradmin`) != -1) { currentTab = 'users'; } + if (location.href.indexOf(`${siteRoot}org/statistics-admin/`) != -1) { + currentTab = 'statistics-admin'; + } + if (location.href.indexOf(`${siteRoot}org/deviceadmin/`) != -1) { + currentTab = 'deviceadmin'; + } if (location.href.indexOf(`${siteRoot}org/groupadmin`) != -1) { currentTab = 'groupadmin'; } @@ -68,7 +83,15 @@ class Org extends React.Component {
- + + + + + + + + + diff --git a/frontend/src/pages/org-admin/org-users-users.js b/frontend/src/pages/org-admin/org-users-users.js index 4677fb0765..0ba5c2fe97 100644 --- a/frontend/src/pages/org-admin/org-users-users.js +++ b/frontend/src/pages/org-admin/org-users-users.js @@ -4,6 +4,7 @@ import Nav from './org-users-nav'; import OrgUsersList from './org-users-list'; import MainPanelTopbar from './main-panel-topbar'; import ModalPortal from '../../components/modal-portal'; +import ImportOrgUsersDialog from '../../components/dialog/org-import-users-dialog'; import AddOrgUserDialog from '../../components/dialog/org-add-user-dialog'; import InviteUserDialog from '../../components/dialog/org-admin-invite-user-dialog'; import toaster from '../../components/toast'; @@ -72,6 +73,7 @@ class OrgUsers extends Component { sortBy: '', sortOrder: 'asc', isShowAddOrgUserDialog: false, + isImportOrgUsersDialogOpen: false, isInviteUserDialogOpen: false }; } @@ -110,6 +112,10 @@ class OrgUsers extends Component { }); } + toggleImportOrgUsersDialog = () => { + this.setState({isImportOrgUsersDialogOpen: !this.state.isImportOrgUsersDialogOpen}); + } + toggleAddOrgUser = () => { this.setState({isShowAddOrgUserDialog: !this.state.isShowAddOrgUserDialog}); } @@ -135,6 +141,30 @@ class OrgUsers extends Component { }); } + importOrgUsers = (file) => { + toaster.notify(gettext('It may take some time, please wait.')); + seafileAPI.orgAdminImportUsersViaFile(orgID, file).then((res) => { + if (res.data.success.length) { + const users = res.data.success.map(item => { + if (item.institution == undefined) { + item.institution = ''; + } + return new OrgUserInfo(item); + }); + this.setState({ + orgUsers: users.concat(this.state.orgUsers) + }); + } + res.data.failed.map(item => { + const msg = `${item.email}: ${item.error_msg}`; + toaster.danger(msg); + }); + }).catch((error) => { + let errMsg = Utils.getErrorMsg(error); + toaster.danger(errMsg); + }); + } + addOrgUser = (email, name, password) => { seafileAPI.orgAdminAddOrgUser(orgID, email, name, password).then(res => { let userInfo = new OrgUserInfo(res.data); @@ -198,12 +228,18 @@ class OrgUsers extends Component { let topbarChildren; topbarChildren = ( - + + {invitationLink && } + {this.state.isImportOrgUsersDialogOpen && + + + + } {this.state.isShowAddOrgUserDialog && diff --git a/frontend/src/pages/org-admin/side-panel.js b/frontend/src/pages/org-admin/side-panel.js index 3e0943b7cf..8215371a3d 100644 --- a/frontend/src/pages/org-admin/side-panel.js +++ b/frontend/src/pages/org-admin/side-panel.js @@ -33,11 +33,23 @@ class SidePanel extends React.Component {

{gettext('Admin')}

  • - this.tabItemClick('orgmanage')} > + this.tabItemClick('info')} > {gettext('Info')}
  • +
  • + this.tabItemClick('statistics-admin')} > + + {gettext('Statistic')} + +
  • +
  • + this.tabItemClick('deviceadmin')} > + + {gettext('Devices')} + +
  • this.tabItemClick('repoadmin')} > diff --git a/frontend/src/pages/org-admin/statistic/picker.js b/frontend/src/pages/org-admin/statistic/picker.js new file mode 100644 index 0000000000..d1836b4913 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/picker.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import Calendar from '@seafile/seafile-calendar'; +import DatePicker from '@seafile/seafile-calendar/lib/Picker'; +import { translateCalendar } from '../../../utils/date-format-utils'; + +import '@seafile/seafile-calendar/assets/index.css'; + +const propsTypes = { + disabledDate: PropTypes.func.isRequired, + value: PropTypes.object, + onChange: PropTypes.func.isRequired, +}; + +const FORMAT = 'YYYY-MM-DD'; + +class Picker extends React.Component { + + constructor(props) { + super(props); + this.defaultCalendarValue = null; + } + + componentDidMount() { + let lang = window.app.config.lang; + this.defaultCalendarValue = moment().locale(lang).clone(); + } + + render() { + const props = this.props; + const calendar = (); + return ( + + { + ({value}) => { + return ( + + + + ); + } + } + + ); + } +} + +Picker.propsTypes = propsTypes; + +export default Picker; diff --git a/frontend/src/pages/org-admin/statistic/statistic-chart.js b/frontend/src/pages/org-admin/statistic/statistic-chart.js new file mode 100644 index 0000000000..463e31da82 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-chart.js @@ -0,0 +1,123 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; +import PropTypes from 'prop-types'; +import { Utils } from '../../../utils/utils'; + +const propTypes = { + labels: PropTypes.array.isRequired, + filesData: PropTypes.array.isRequired, + suggestedMaxNumbers: PropTypes.number.isRequired, + isLegendStatus: PropTypes.bool.isRequired, + chartTitle: PropTypes.string.isRequired, + isTitleCallback: PropTypes.bool, + isTicksCallback: PropTypes.bool, +}; + +class StatisticChart extends React.Component { + + constructor(props) { + super(props); + this.state = { + data: {}, + opations: {} + }; + } + + componentDidMount() { + let { labels, filesData, isTitleCallback, isTicksCallback, suggestedMaxNumbers, isLegendStatus, chartTitle } = this.props; + let _this = this; + let data = { + labels: labels, + datasets: filesData + }; + let options = { + title: { + display: true, + fontSize: 14, + text: chartTitle, + }, + elements: { + line: { + fill: false, + tension: 0, // disable bezier curves, i.e, draw straight lines + borderWidth: 2 + } + }, + legend: { + display: isLegendStatus, + labels: { + usePointStyle: true + } + }, + tooltips: { + callbacks: { + label: function(tooltipItem, data) { + if (isTitleCallback) { + return _this.titleCallback(tooltipItem, data); + } + return data.datasets[tooltipItem.datasetIndex].label + ': ' + tooltipItem.yLabel; + } + } + }, + layout: { + padding: { + right: 100, + } + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + suggestedMax: suggestedMaxNumbers, + callback: function(value, index, values) { + if (isTicksCallback) { + return _this.ticksCallback(value, index, values); + } + return value; + } + } + }], + xAxes: [{ + ticks: { + maxTicksLimit: 20 + } + }] + } + }; + this.setState({ + data: data, + options: options + }); + } + + componentWillReceiveProps(nextProps) { + let data = { + labels: nextProps.labels, + datasets: nextProps.filesData + }; + this.setState({data: data}); + } + + titleCallback = (tooltipItem, data) => { + return data.datasets[tooltipItem.datasetIndex].label + ': ' + Utils.bytesToSize(tooltipItem.yLabel); + } + + ticksCallback = (value, index, values) => { + return Utils.bytesToSize(value); + } + + render() { + + let { data, options } = this.state; + return ( + + ); + } +} + +StatisticChart.propTypes = propTypes; + +export default StatisticChart; diff --git a/frontend/src/pages/org-admin/statistic/statistic-common-tool.js b/frontend/src/pages/org-admin/statistic/statistic-common-tool.js new file mode 100644 index 0000000000..033acae1c8 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-common-tool.js @@ -0,0 +1,138 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'reactstrap'; +import moment from 'moment'; +import { gettext } from '../../../utils/constants'; +import Picker from './picker'; + +const propTypes = { + getActiviesFiles: PropTypes.func.isRequired, + children: PropTypes.object, +}; + +class StatisticCommonTool extends React.Component { + + constructor(props) { + super(props); + this.state = { + statisticType: 'oneWeek', + startValue: null, + endValue: null, + }; + } + + componentDidMount() { + let today = moment().format('YYYY-MM-DD 00:00:00'); + let endTime = today; + let startTime = moment().subtract(6,'d').format('YYYY-MM-DD 00:00:00'); + let group_by = 'day'; + this.props.getActiviesFiles(startTime, endTime, group_by); + } + + changeActive = (statisticTypeName) => { + let { statisticType } = this.state; + if (statisticType === statisticTypeName) { + return; + } + let today = moment().format('YYYY-MM-DD 00:00:00'); + let endTime = today; + let startTime; + switch(statisticTypeName) { + case 'oneWeek' : + startTime = moment().subtract(6,'d').format('YYYY-MM-DD 00:00:00'); + break; + case 'oneMonth' : + startTime = moment().subtract(29,'d').format('YYYY-MM-DD 00:00:00'); + break; + case 'oneYear' : + startTime = moment().subtract(364,'d').format('YYYY-MM-DD 00:00:00'); + break; + } + this.setState({ + statisticType: statisticTypeName, + }); + let group_by = 'day'; + this.props.getActiviesFiles(startTime, endTime, group_by); + } + + disabledStartDate = (startValue) => { + if (!startValue) { + return false; + } + let today = moment().format(); + + const endValue = this.state.endValue; + if (!endValue) { + let startTime = moment(startValue).format(); + return today < startTime; + } + return endValue.isBefore(startValue) || moment(startValue).format() > today; + } + + disabledEndDate = (endValue) => { + if (!endValue) { + return false; + } + let today = moment().format(); + const startValue = this.state.startValue; + if (!startValue) { + let endTime = moment(endValue).format(); + return today < endTime; + } + return endValue.isBefore(startValue) || moment(endValue).format() > today; + } + + onChange = (field, value) => { + this.setState({ + [field]: value, + }); + } + + onSubmit = () => { + let { startValue, endValue } = this.state; + if(!startValue || !endValue) { + return; + } + this.setState({ + statisticType: 'itemButton', + }); + let startTime = moment(startValue).format('YYYY-MM-DD 00:00:00'); + let endTime = moment(endValue).format('YYYY-MM-DD 00:00:00'); + let group_by = 'day'; + this.props.getActiviesFiles(startTime, endTime, group_by); + } + + render() { + let { statisticType, endValue, startValue } = this.state; + return( + + {this.props.children} +
    +
    +
    {gettext('7 Days')}
    +
    {gettext('30 Days')}
    +
    {gettext('1 Year')}
    +
    +
    + + - + + +
    +
    +
    + ); + } +} + +StatisticCommonTool.propTypes = propTypes; + +export default StatisticCommonTool; diff --git a/frontend/src/pages/org-admin/statistic/statistic-file.js b/frontend/src/pages/org-admin/statistic/statistic-file.js new file mode 100644 index 0000000000..75708915a1 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-file.js @@ -0,0 +1,104 @@ +import React, { Fragment } from 'react'; +import moment from 'moment'; +import MainPanelTopbar from '../main-panel-topbar'; +import StatisticNav from './statistic-nav'; +import StatisticCommonTool from './statistic-common-tool'; +import { seafileAPI } from '../../../utils/seafile-api'; +import StatisticChart from './statistic-chart'; +import Loading from '../../../components/loading'; +import { gettext, orgID } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; + +import '../../../css/system-stat.css'; + +class OrgStatisticFile extends React.Component { + + constructor(props) { + super(props); + this.state = { + filesData: [], + labels: [], + isLoading: true + }; + } + + getActiviesFiles = (startTime, endTime, groupBy) => { + let { filesData } = this.state; + + seafileAPI.orgAdminStatisticFiles(orgID, startTime, endTime, groupBy).then((res) => { + let labels = [], + added = [], + deleted = [], + visited = [], + modified = []; + let data = res.data; + if (Array.isArray(data)) { + data.forEach(item => { + labels.push(moment(item.datetime).format('YYYY-MM-DD')); + added.push(item.added); + deleted.push(item.deleted); + modified.push(item.modified); + visited.push(item.visited); + }); + let addedData = { + label: gettext('Added'), + data: added, + borderColor: '#57cd6b', + backgroundColor: '#57cd6b'}; + let visitedData = { + label: gettext('Visited'), + data: visited, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + let modifiedData = { + label: gettext('Modified'), + data: modified, + borderColor: '#72c3fc', + backgroundColor: '#72c3fc'}; + let deletedData = { + label: gettext('Deleted'), + data: deleted, + borderColor: '#f75356', + backgroundColor: '#f75356'}; + filesData = [visitedData, addedData, modifiedData, deletedData]; + } + this.setState({ + filesData: filesData, + labels: labels, + isLoading: false + }); + }).catch(err => { + let errMessage = Utils.getErrorMsg(err); + toaster.danger(errMessage); + }); + } + + render() { + let { labels, filesData, isLoading } = this.state; + + return( + + +
    + +
    + + {isLoading && } + {!isLoading && labels.length > 0 && + + } +
    +
    +
    + ); + } +} + +export default OrgStatisticFile; diff --git a/frontend/src/pages/org-admin/statistic/statistic-nav.js b/frontend/src/pages/org-admin/statistic/statistic-nav.js new file mode 100644 index 0000000000..eb5f20c488 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-nav.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@reach/router'; +import { siteRoot, gettext } from '../../../utils/constants'; + +const propTypes = { + currentItem: PropTypes.string.isRequired +}; + +class Nav extends React.Component { + + constructor(props) { + super(props); + this.navItems = [ + {name: 'fileStatistic', urlPart: 'statistics-admin/file', text: gettext('File')}, + {name: 'storageStatistic', urlPart: 'statistics-admin/total-storage', text: gettext('Storage')}, + {name: 'usersStatistic', urlPart: 'statistics-admin/active-users', text: gettext('Users')}, + {name: 'trafficStatistic', urlPart: 'statistics-admin/traffic', text: gettext('Traffic')}, + {name: 'reportsStatistic', urlPart: 'statistics-admin/reports', text: gettext('Reports')}, + ]; + } + + render() { + const { currentItem } = this.props; + return ( +
    +
      + {this.navItems.map((item, index) => { + return ( +
    • + {item.text} +
    • + ); + })} +
    +
    + ); + } +} + +Nav.propTypes = propTypes; + +export default Nav; diff --git a/frontend/src/pages/org-admin/statistic/statistic-reports.js b/frontend/src/pages/org-admin/statistic/statistic-reports.js new file mode 100644 index 0000000000..11f2c51897 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-reports.js @@ -0,0 +1,88 @@ +import React, { Fragment } from 'react'; +import MainPanelTopbar from '../main-panel-topbar'; +import moment from 'moment'; +import StatisticNav from './statistic-nav'; +import { Button, Input } from 'reactstrap'; +import { siteRoot, gettext, orgID } from '../../../utils/constants'; + +class OrgStatisticReports extends React.Component { + + constructor(props) { + super(props); + this.state = { + month: moment().format('YYYYMM'), + errorMessage: '' + }; + } + + handleChange = (e) => { + let month = e.target.value; + this.setState({ + month: month + }); + } + + onGenerateReports = (type) => { + let url = siteRoot + 'api/v2.1/org/' + orgID + '/admin/statistics/'; + let { month } = this.state; + if (!month) { + let errorMessage = gettext('It is required.'); + this.setState({ + errorMessage: errorMessage + }); + return; + } + if (type === 'month') { + let pattern = /^([012]\d{3})(0[1-9]|1[012])$/; + if (!pattern.test(month)) { + let errorMessage = gettext('Invalid month, should be yyyymm.'); + this.setState({ + errorMessage: errorMessage + }); + return; + } + } + switch(type) { + case 'month': + url += 'user-traffic/excel/?month=' + month; + break; + case 'storage': + url += 'user-storage/excel/?'; + break; + } + this.setState({ + errorMessage: '' + }); + window.location.href = url; + } + + render() { + + let { errorMessage } = this.state; + return( + + +
    + +
    +
    +
    {gettext('Monthly User Traffic')}
    +
    + {gettext('Month:')} + + +
    + {errorMessage &&
    {errorMessage}
    } +
    +
    +
    {gettext('User Storage')}
    + +
    +
    +
    +
    + ); + } +} + +export default OrgStatisticReports; diff --git a/frontend/src/pages/org-admin/statistic/statistic-storage.js b/frontend/src/pages/org-admin/statistic/statistic-storage.js new file mode 100644 index 0000000000..63a499e151 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-storage.js @@ -0,0 +1,81 @@ +import React, { Fragment } from 'react'; +import moment from 'moment'; +import MainPanelTopbar from '../main-panel-topbar'; +import StatisticNav from './statistic-nav'; +import StatisticCommonTool from './statistic-common-tool'; +import { seafileAPI } from '../../../utils/seafile-api'; +import StatisticChart from './statistic-chart'; +import Loading from '../../../components/loading'; +import { gettext, orgID } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; + +class OrgStatisticStorage extends React.Component { + + constructor(props) { + super(props); + this.state = { + filesData: [], + labels: [], + isLoading: true + }; + } + + getActiviesFiles = (startTime, endTime, groupBy) => { + let { filesData } = this.state; + seafileAPI.orgAdminStatisticStorages(orgID, startTime, endTime, groupBy).then((res) => { + let labels = [], + totalStorage = []; + let data = res.data; + if (Array.isArray(data)) { + data.forEach(item => { + labels.push(moment(item.datetime).format('YYYY-MM-DD')); + totalStorage.push(item.total_storage); + }); + let total_storage = { + label: gettext('Total Storage'), + data: totalStorage, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + filesData = [total_storage]; + } + this.setState({ + filesData: filesData, + labels: labels, + isLoading: false + }); + }).catch(err => { + let errMessage = Utils.getErrorMsg(err); + toaster.danger(errMessage); + }); + } + + render() { + let { labels, filesData, isLoading } = this.state; + return( + + +
    + +
    + + {isLoading && } + {!isLoading && labels.length > 0 && + + } +
    +
    +
    + ); + } +} + +export default OrgStatisticStorage; diff --git a/frontend/src/pages/org-admin/statistic/statistic-traffic-users.js b/frontend/src/pages/org-admin/statistic/statistic-traffic-users.js new file mode 100644 index 0000000000..6282c64549 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-traffic-users.js @@ -0,0 +1,158 @@ +import React, { Fragment } from 'react'; +import { Input } from 'reactstrap'; +import TrafficTable from './traffic-table'; +import TrafficTableBody from './traffic-table-body'; +import { seafileAPI } from '../../../utils/seafile-api'; +import Paginator from '../../../components/paginator'; +import moment from 'moment'; +import Loading from '../../../components/loading'; +import { gettext, orgID } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; + +class UsersTraffic extends React.Component { + + constructor(props) { + super(props); + this.state = { + userTrafficList: [], + hasNextPage: false, + perPage: 25, + currentPage: 1, + month: moment().format('YYYYMM'), + isLoading: false, + errorMessage: '', + sortBy: 'link_file_download', + sortOrder: 'desc' + }; + this.initPage = 1; + this.initMonth = moment().format('YYYYMM'); + } + + componentDidMount() { + let urlParams = (new URL(window.location)).searchParams; + const { currentPage, perPage } = this.state; + this.setState({ + perPage: parseInt(urlParams.get('per_page') || perPage), + currentPage: parseInt(urlParams.get('page') || currentPage) + }, () => { + this.getTrafficList(this.initMonth, this.state.currentPage); + }); + } + + getPreviousPage = () => { + this.getTrafficList(this.state.month, this.state.currentPage - 1); + } + + getNextPage = () => { + this.getTrafficList(this.state.month, this.state.currentPage + 1); + } + + handleChange = (e) => { + let month = e.target.value; + this.setState({ + month: month + }); + } + + handleKeyPress = (e) => { + let { month } = this.state; + if (e.key === 'Enter') { + let pattern = /^([012]\d{3})(0[1-9]|1[012])$/; + if (!pattern.test(month)) { + let errorMessage = gettext('Invalid month, should be yyyymm.'); + this.setState({ + errorMessage: errorMessage + }); + return; + } + this.getTrafficList(month, this.initPage); + e.target.blur(); + e.preventDefault(); + } + } + + getTrafficList = (month, page) => { + const { perPage, sortBy, sortOrder } = this.state; + const orderBy = sortOrder == 'asc' ? sortBy : `${sortBy}_${sortOrder}`; + this.setState({ + isLoading: true, + errorMessage: '' + }); + seafileAPI.orgAdminListUserTraffic(orgID, month, page, perPage, orderBy).then(res => { + let userTrafficList = res.data.user_monthly_traffic_list.slice(0); + this.setState({ + month: month, + currentPage: page, + userTrafficList: userTrafficList, + hasNextPage: res.data.has_next_page, + isLoading: false + }); + }).catch(err => { + let errMessage = Utils.getErrorMsg(err); + toaster.danger(errMessage); + }); + } + + sortItems = (sortBy) => { + this.setState({ + sortBy: sortBy, + sortOrder: this.state.sortOrder == 'asc' ? 'desc' : 'asc' + }, () => { + const { month, currentPage } = this.state; + this.getTrafficList(month, currentPage); + }); + } + + resetPerPage = (newPerPage) => { + this.setState({ + perPage: newPerPage, + }, () => this.getTrafficList(this.initMonth, this.initPage)); + } + + render() { + const { + isLoading, errorMessage, userTrafficList, + currentPage, hasNextPage, perPage, + sortBy, sortOrder + } = this.state; + return ( + +
    + {gettext('Month:')} + + {errorMessage &&
    {errorMessage}
    } +
    + {isLoading && } + {!isLoading && + + {userTrafficList.length > 0 && userTrafficList.map((item, index) => { + return( + + ); + })} + + } + +
    + ); + } +} + +export default UsersTraffic; diff --git a/frontend/src/pages/org-admin/statistic/statistic-traffic.js b/frontend/src/pages/org-admin/statistic/statistic-traffic.js new file mode 100644 index 0000000000..31a89b0844 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-traffic.js @@ -0,0 +1,213 @@ +import React, { Fragment } from 'react'; +import moment from 'moment'; +import { gettext, orgID } from '../../../utils/constants'; +import { seafileAPI } from '../../../utils/seafile-api'; +import MainPanelTopbar from '../main-panel-topbar'; +import StatisticNav from './statistic-nav'; +import StatisticCommonTool from './statistic-common-tool'; +import Loading from '../../../components/loading'; +import UsersTraffic from './statistic-traffic-users'; +import StatisticChart from './statistic-chart'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; + +class OrgStatisticTraffic extends React.Component { + + constructor(props) { + super(props); + this.state = { + filesData: [], + linkData: [], + syncData: [], + webData: [], + labels: [], + isLoading: true, + tabActive: 'system' + }; + } + + changeTabActive = activeName => { + this.setState({tabActive: activeName}); + } + + getActiviesFiles = (startTime, endTime, groupBy) => { + seafileAPI.orgAdminStatisticSystemTraffic(orgID, startTime, endTime, groupBy).then((res) => { + let labels = []; + let total_upload = [], + total_download = [], + link_upload = [], + link_download = [], + sync_upload = [], + sync_download = [], + web_upload = [], + web_download = []; + let data = res.data; + if (Array.isArray(data)) { + data.forEach(item => { + labels.push(moment(item.datetime).format('YYYY-MM-DD')); + link_upload.push(item['link-file-upload']); + link_download.push(item['link-file-download']); + sync_upload.push(item['sync-file-upload']); + sync_download.push(item['sync-file-download']); + web_upload.push(item['web-file-upload']); + web_download.push(item['web-file-download']); + total_upload.push(item['link-file-upload'] + item['sync-file-upload'] + item['web-file-upload']); + total_download.push(item['link-file-download'] + item['sync-file-download'] + item['web-file-download']); + }); + let linkUpload = { + label: gettext('Upload'), + data: link_upload, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + let linkDownload = { + label: gettext('Download'), + data: link_download, + borderColor: '#57cd6b', + backgroundColor: '#57cd6b'}; + let syncUpload = { + label: gettext('Upload'), + data: sync_upload, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + let syncDownload = { + label: gettext('Download'), + data: sync_download, + borderColor: '#57cd6b', + backgroundColor: '#57cd6b'}; + let webUpload = { + label: gettext('Upload'), + data: web_upload, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + let webDownload = { + label: gettext('Download'), + data: web_download, + borderColor: '#57cd6b', + backgroundColor: '#57cd6b'}; + let totalUpload = { + label: gettext('Upload'), + data: total_upload, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + let totalDownload = { + label: gettext('Download'), + data: total_download, + borderColor: '#57cd6b', + backgroundColor: '#57cd6b'}; + let linkData = [linkUpload, linkDownload]; + let syncData = [syncUpload, syncDownload]; + let webData = [webUpload, webDownload]; + let filesData = [totalUpload, totalDownload]; + this.setState({ + linkData: linkData, + syncData: syncData, + webData: webData, + filesData: filesData, + labels: labels, + isLoading: false + }); + } + }).catch(err => { + let errMessage = Utils.getErrorMsg(err); + toaster.danger(errMessage); + }); + } + + renderCommonTool = () => { + let { tabActive } = this.state; + if (tabActive === 'system') { + return ( + +
    +
    {gettext('System')}
    +
    {gettext('Users')}
    +
    +
    + ); + } + return ( +
    +
    {gettext('System')}
    +
    {gettext('Users')}
    +
    + ); + } + + render() { + let { labels, filesData, linkData, syncData, webData, isLoading, tabActive } = this.state; + + return ( + + +
    + +
    + {this.renderCommonTool()} + {isLoading && } + {!isLoading && tabActive === 'system' && +
    +
    + {labels.length > 0 && + + } +
    +
    + {labels.length > 0 && + + } +
    +
    + {labels.length > 0 && + + } +
    +
    + {labels.length > 0 && + + } +
    +
    + } + {!isLoading && tabActive === 'user' && + + } +
    +
    +
    + ); + } +} + +export default OrgStatisticTraffic; diff --git a/frontend/src/pages/org-admin/statistic/statistic-users.js b/frontend/src/pages/org-admin/statistic/statistic-users.js new file mode 100644 index 0000000000..e9421bd992 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/statistic-users.js @@ -0,0 +1,79 @@ +import React, { Fragment } from 'react'; +import moment from 'moment'; +import { gettext, orgID } from '../../../utils/constants'; +import MainPanelTopbar from '../main-panel-topbar'; +import StatisticNav from './statistic-nav'; +import StatisticCommonTool from './statistic-common-tool'; +import { seafileAPI } from '../../../utils/seafile-api'; +import StatisticChart from './statistic-chart'; +import Loading from '../../../components/loading'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; + +class OrgStatisticUsers extends React.Component { + + constructor(props) { + super(props); + this.state = { + filesData: [], + labels: [], + isLoading: true, + }; + } + + getActiviesFiles = (startTime, endTime, groupBy) => { + let { filesData } = this.state; + seafileAPI.orgAdminStatisticActiveUsers(orgID, startTime, endTime, groupBy).then((res) => { + let labels = [], + count = []; + let data = res.data; + if (Array.isArray(data)) { + data.forEach(item => { + labels.push(moment(item.datetime).format('YYYY-MM-DD')); + count.push(item.count); + }); + let userCount = { + label: gettext('Active Users'), + data: count, + borderColor: '#fd913a', + backgroundColor: '#fd913a'}; + filesData = [userCount]; + } + this.setState({ + filesData: filesData, + labels: labels, + isLoading: false + }); + }).catch(err => { + let errMessage = Utils.getErrorMsg(err); + toaster.danger(errMessage); + }); + } + + render() { + let { labels, filesData, isLoading } = this.state; + return ( + + +
    + +
    + + {isLoading && } + {!isLoading && labels.length > 0 && + + } +
    +
    +
    + ); + } +} + +export default OrgStatisticUsers; diff --git a/frontend/src/pages/org-admin/statistic/traffic-table-body.js b/frontend/src/pages/org-admin/statistic/traffic-table-body.js new file mode 100644 index 0000000000..67dd62155d --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/traffic-table-body.js @@ -0,0 +1,54 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Utils } from '../../../utils/utils'; +import { siteRoot } from '../../../utils/constants'; + +const propTypes = { + type: PropTypes.string.isRequired, + userTrafficItem: PropTypes.object.isRequired, +}; + +class TrafficTableBody extends React.Component { + + trafficName = () => { + let { userTrafficItem, type } = this.props; + switch(type) { + case 'user': + if (userTrafficItem.name) { + return ( + {userTrafficItem.name} + ); + } + return({'--'}); + case 'org': + return({userTrafficItem.org_name}); + } + } + + render() { + let { userTrafficItem } = this.props; + + let syncUploadSize = Utils.bytesToSize(userTrafficItem.sync_file_upload); + let syncDownloadSize = Utils.bytesToSize(userTrafficItem.sync_file_download); + let webUploadSize = Utils.bytesToSize(userTrafficItem.web_file_upload); + let webDownloadSize = Utils.bytesToSize(userTrafficItem.web_file_download); + let linkUploadSize = Utils.bytesToSize(userTrafficItem.link_file_upload); + let linkDownloadSize = Utils.bytesToSize(userTrafficItem.link_file_download); + + return( + + {this.trafficName()} + {syncUploadSize} + {syncDownloadSize} + {webUploadSize} + {webDownloadSize} + {linkUploadSize} + {linkDownloadSize} + + ); + } +} + +TrafficTableBody.propTypes = propTypes; + +export default TrafficTableBody; diff --git a/frontend/src/pages/org-admin/statistic/traffic-table.js b/frontend/src/pages/org-admin/statistic/traffic-table.js new file mode 100644 index 0000000000..34bcb944a1 --- /dev/null +++ b/frontend/src/pages/org-admin/statistic/traffic-table.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { gettext } from '../../../utils/constants'; + +const propTypes = { + type: PropTypes.string.isRequired, + sortBy: PropTypes.string.isRequired, + sortOrder: PropTypes.string.isRequired, + sortItems: PropTypes.func.isRequired, + children: PropTypes.oneOfType([PropTypes.bool, PropTypes.array]), +}; + +class TrafficTable extends React.Component { + + constructor(props) { + super(props); + } + + render() { + const { type, sortBy, sortOrder } = this.props; + const sortIcon = sortOrder == 'asc' ? : ; + + return ( + + + + + + + + + + + + + + {this.props.children} + +
    {type == 'user' ? gettext('User') : gettext('Organization')}
    {gettext('Sync Upload')} {sortBy === 'sync_file_upload' && sortIcon}
    {gettext('Sync Download')} {sortBy === 'sync_file_download' && sortIcon}
    {gettext('Web Upload')} {sortBy === 'web_file_upload' && sortIcon}
    {gettext('Web Download')} {sortBy === 'web_file_download' && sortIcon}
    {gettext('Share link upload')} {sortBy === 'link_file_upload' && sortIcon}
    {gettext('Share link download')} {sortBy === 'link_file_download' && sortIcon}
    + ); + } +} + +TrafficTable.propTypes = propTypes; + +export default TrafficTable; diff --git a/frontend/src/pages/org-admin/user-link.js b/frontend/src/pages/org-admin/user-link.js new file mode 100644 index 0000000000..f4bb4be3bc --- /dev/null +++ b/frontend/src/pages/org-admin/user-link.js @@ -0,0 +1,20 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@reach/router'; +import { siteRoot, orgID } from '../../utils/constants'; + +const propTypes = { + email: PropTypes.string.isRequired, + name: PropTypes.string.isRequired +}; + +class UserLink extends Component { + + render() { + return {this.props.name}; + } +} + +UserLink.propTypes = propTypes; + +export default UserLink; diff --git a/seahub/organizations/api/admin/devices.py b/seahub/organizations/api/admin/devices.py new file mode 100644 index 0000000000..05db9dcace --- /dev/null +++ b/seahub/organizations/api/admin/devices.py @@ -0,0 +1,220 @@ +# Copyright (c) 2012-2016 Seafile Ltd. +import logging + +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status + +from seaserv import seafile_api, ccnet_api +from pysearpc import SearpcError + +from seahub.utils.devices import do_unlink_device +from seahub.utils.timeutils import datetime_to_isoformat_timestr, \ + timestamp_to_isoformat_timestr + +from seahub.api2.permissions import IsProVersion, IsOrgAdminUser +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.utils import api_error +from seahub.api2.models import TokenV2, DESKTOP_PLATFORMS, MOBILE_PLATFORMS +from seahub.base.templatetags.seahub_tags import email2nickname + +logger = logging.getLogger(__name__) + + +class OrgAdminDevices(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + current_page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '50')) + except ValueError: + current_page = 1 + per_page = 50 + + start = (current_page - 1) * per_page + end = current_page * per_page + 1 + + platform = request.GET.get('platform', None) + org_users = ccnet_api.get_org_users_by_url_prefix(org.url_prefix, -1, -1) + org_user_emails = [user.email for user in org_users] + + devices = TokenV2.objects.filter(wiped_at=None) + + if platform == 'desktop': + devices = devices.filter(platform__in=DESKTOP_PLATFORMS) \ + .filter(user__in=org_user_emails) \ + .order_by('-last_accessed')[start: end] + + elif platform == 'mobile': + devices = devices.filter(platform__in=MOBILE_PLATFORMS) \ + .filter(user__in=org_user_emails) \ + .order_by('-last_accessed')[start: end] + else: + devices = devices.order_by('-last_accessed') \ + .filter(user__in=org_user_emails)[start: end] + + if len(devices) == end - start: + devices = devices[:per_page] + has_next_page = True + else: + has_next_page = False + + return_results = [] + for device in devices: + result = {} + result['client_version'] = device.client_version + result['device_id'] = device.device_id + result['device_name'] = device.device_name + result['last_accessed'] = datetime_to_isoformat_timestr(device.last_accessed) + result['last_login_ip'] = device.last_login_ip + result['user'] = device.user + result['user_name'] = email2nickname(device.user) + result['platform'] = device.platform + + result['is_desktop_client'] = False + if result['platform'] in DESKTOP_PLATFORMS: + result['is_desktop_client'] = True + + return_results.append(result) + + page_info = { + 'has_next_page': has_next_page, + 'current_page': current_page + } + return Response({"page_info": page_info, "devices": return_results}) + + def delete(self, request, org_id): + + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + platform = request.data.get('platform', '') + device_id = request.data.get('device_id', '') + remote_wipe = request.data.get('wipe_device', '') + user = request.data.get('user', '') + + if not platform: + error_msg = 'platform invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not device_id: + error_msg = 'device_id invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not user: + error_msg = 'user invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + remote_wipe = True if remote_wipe == 'true' else False + + try: + do_unlink_device(user, platform, device_id, remote_wipe=remote_wipe) + except SearpcError 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}) + + +class OrgAdminDevicesErrors(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle, ) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + current_page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '100')) + except ValueError: + current_page = 1 + per_page = 100 + + start = (current_page - 1) * per_page + limit = per_page + 1 + + return_results = [] + try: + device_errors = seafile_api.list_org_repo_sync_errors(org_id, start, limit) + except SearpcError as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if len(device_errors) > per_page: + device_errors = device_errors[:per_page] + has_next_page = True + else: + has_next_page = False + + for error in device_errors: + result = {} + result['email'] = error.email if error.email else '' + result['name'] = email2nickname(error.email) + result['device_ip'] = error.peer_ip if error.peer_ip else '' + result['repo_name'] = error.repo_name if error.repo_name else '' + result['repo_id'] = error.repo_id if error.repo_id else '' + result['error_msg'] = error.error_con if error.error_con else '' + + tokens = TokenV2.objects.filter(device_id=error.peer_id) + if tokens: + result['device_name'] = tokens[0].device_name + result['client_version'] = tokens[0].client_version + else: + result['device_name'] = '' + result['client_version'] = '' + + if error.error_time: + result['error_time'] = timestamp_to_isoformat_timestr(error.error_time) + else: + result['error_time'] = '' + + return_results.append(result) + + page_info = { + 'has_next_page': has_next_page, + 'current_page': current_page + } + + return Response({"page_info": page_info, "device_errors": return_results}) + + def delete(self, request, org_id): + + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + if not org: + error_msg = 'Organization %s not found.' % org_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + seafile_api.clear_repo_sync_errors() + except SearpcError 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/admin/statistics.py b/seahub/organizations/api/admin/statistics.py new file mode 100644 index 0000000000..e93e5d412c --- /dev/null +++ b/seahub/organizations/api/admin/statistics.py @@ -0,0 +1,411 @@ +# Copyright (c) 2012-2016 Seafile Ltd. +import datetime +import pytz +import logging + +from rest_framework.authentication import SessionAuthentication +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status +from django.utils import timezone +from django.utils.translation import ugettext as _ +from django.http import HttpResponse + +from seaserv import ccnet_api + +from seahub.utils import get_org_file_ops_stats_by_day, \ + get_org_total_storage_stats_by_day, get_org_user_activity_stats_by_day, \ + get_org_traffic_by_day, is_pro_version, EVENTS_ENABLED, \ + seafevents_api +from seahub.utils.timeutils import datetime_to_isoformat_timestr +from seahub.utils.ms_excel import write_xls +from seahub.utils.file_size import byte_to_mb +from seahub.views.sysadmin import _populate_user_quota_usage +from seahub.base.templatetags.seahub_tags import email2nickname, \ + email2contact_email + +from seahub.api2.permissions import IsProVersion, IsOrgAdminUser +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.utils import api_error + +logger = logging.getLogger(__name__) + + +def get_time_offset(): + timezone_name = timezone.get_current_timezone_name() + offset = pytz.timezone(timezone_name).localize(datetime.datetime.now()).strftime('%z') + return offset[:3] + ':' + offset[3:] + + +def get_init_data(start_time, end_time, init_data=0): + res = {} + start_time = start_time.replace(hour=0).replace(minute=0).replace(second=0) + end_time = end_time.replace(hour=0).replace(minute=0).replace(second=0) + time_delta = end_time - start_time + date_length = time_delta.days + 1 + for offset in range(date_length): + offset = offset * 24 + dt = start_time + datetime.timedelta(hours=offset) + if isinstance(init_data, dict): + res[dt] = init_data.copy() + else: + res[dt] = init_data + return res + + +def check_parameter(func): + + def _decorated(view, request, org_id, *args, **kwargs): + + if not is_pro_version() or not EVENTS_ENABLED: + return api_error(status.HTTP_404_NOT_FOUND, 'Events not enabled.') + + start_time = request.GET.get("start", "") + end_time = request.GET.get("end", "") + + if not start_time: + error_msg = "Start time can not be empty" + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not end_time: + error_msg = "End time can not be empty" + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + start_time = datetime.datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") + except Exception: + error_msg = "Start time %s invalid" % start_time + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + end_time = datetime.datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") + except Exception: + error_msg = "End time %s invalid" % end_time + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + return func(view, request, org_id, start_time, end_time, *args, **kwargs) + + return _decorated + + +class OrgFileOperationsView(APIView): + + """ + Get file operations statistics. + Permission checking: + 1. only org admin can perform this action. + """ + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + @check_parameter + def get(self, request, org_id, start_time, end_time): + """ + Get records of the specified time range. + param: + start: the start time of the query. + end: the end time of the query. + return: + the list of file operations record. + """ + data = get_org_file_ops_stats_by_day(org_id, start_time, end_time, get_time_offset()) + ops_added_dict = get_init_data(start_time, end_time) + ops_visited_dict = get_init_data(start_time, end_time) + ops_deleted_dict = get_init_data(start_time, end_time) + ops_modified_dict = get_init_data(start_time, end_time) + + # [{'number': 2, + # 'op_type': 'Added', + # 'timestamp': datetime.datetime(2022, 10, 27, 0, 0)}] + for item in data: + if item.get('op_type') == 'Added': + ops_added_dict[item.get('timestamp')] = item.get('number') + if item.get('op_type') == 'Visited': + ops_visited_dict[item.get('timestamp')] = item.get('number') + if item.get('op_type') == 'Deleted': + ops_deleted_dict[item.get('timestamp')] = item.get('number') + if item.get('op_type') == 'Modified': + ops_modified_dict[item.get('timestamp')] = item.get('number') + + res_data = [] + for k, v in list(ops_added_dict.items()): + res_data.append({'datetime': datetime_to_isoformat_timestr(k), + 'added': v, + 'visited': ops_visited_dict[k], + 'deleted': ops_deleted_dict[k], + 'modified': ops_modified_dict[k]}) + + return Response(sorted(res_data, key=lambda x: x['datetime'])) + + +class OrgTotalStorageView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + @check_parameter + def get(self, request, org_id, start_time, end_time): + + data = get_org_total_storage_stats_by_day(org_id, start_time, end_time, get_time_offset()) + + # [{'number': Decimal('2558796'), + # 'timestamp': datetime.datetime(2022, 11, 1, 0, 0)}, + # {'number': Decimal('2558796'), + # 'timestamp': datetime.datetime(2022, 11, 2, 0, 0)}] + init_data = get_init_data(start_time, end_time) + for e in data: + init_data[e.get("timestamp")] = e.get("number") + + res_data = [] + for k, v in list(init_data.items()): + res_data.append({'datetime': datetime_to_isoformat_timestr(k), 'total_storage': v}) + + return Response(sorted(res_data, key=lambda x: x['datetime'])) + + +class OrgActiveUsersView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + @check_parameter + def get(self, request, org_id, start_time, end_time): + + data = get_org_user_activity_stats_by_day(org_id, start_time, end_time) + + # [{'number': 1, 'timestamp': datetime.datetime(2022, 10, 27, 0, 0)}, + # {'number': 2, 'timestamp': datetime.datetime(2022, 10, 31, 0, 0)}, + # {'number': 2, 'timestamp': datetime.datetime(2022, 11, 1, 0, 0)}, + # {'number': 1, 'timestamp': datetime.datetime(2022, 11, 2, 0, 0)}] + init_data = get_init_data(start_time, end_time) + for e in data: + init_data[e.get("timestamp")] = e.get("number") + + res_data = [] + for k, v in list(init_data.items()): + res_data.append({'datetime': datetime_to_isoformat_timestr(k), 'count': v}) + + return Response(sorted(res_data, key=lambda x: x['datetime'])) + + +class OrgSystemTrafficView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + @check_parameter + def get(self, request, org_id, start_time, end_time): + + op_type_list = ['web-file-upload', 'web-file-download', + 'sync-file-download', 'sync-file-upload', + 'link-file-upload', 'link-file-download'] + init_count = [0] * 6 + init_data = get_init_data(start_time, end_time, + dict(list(zip(op_type_list, init_count)))) + + data = get_org_traffic_by_day(org_id, start_time, end_time, get_time_offset()) + # [(datetime.datetime(2022, 11, 1, 0, 0), 'web-file-upload', 2558798), + # (datetime.datetime(2022, 11, 2, 0, 0), 'web-file-upload', 48659279), + # (datetime.datetime(2022, 11, 3, 0, 0), 'link-file-download', 48658882), + # (datetime.datetime(2022, 11, 3, 0, 0), 'web-file-upload', 24329441)] + for e in data: + dt, op_type, count = e + init_data[dt].update({op_type: count}) + + res_data = [] + for k, v in list(init_data.items()): + res = {'datetime': datetime_to_isoformat_timestr(k)} + res.update(v) + res_data.append(res) + + return Response(sorted(res_data, key=lambda x: x['datetime'])) + + +class OrgUserTrafficView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + + month = request.GET.get("month", "") + if not month: + error_msg = "month invalid." + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + month_obj = datetime.datetime.strptime(month, "%Y%m") + except Exception as e: + logger.error(e) + error_msg = "month %s invalid" % month + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '25')) + except ValueError: + page = 1 + per_page = 25 + start = (page - 1) * per_page + + order_by = request.GET.get('order_by', '') + filters = [ + 'sync_file_upload', 'sync_file_download', + 'web_file_upload', 'web_file_download', + 'link_file_upload', 'link_file_download', + ] + if order_by not in filters and \ + order_by not in map(lambda x: x + '_desc', filters): + order_by = 'link_file_download_desc' + + # get one more item than per_page, to judge has_next_page + try: + traffics = seafevents_api.get_all_users_traffic_by_month(month_obj, + start, + start + per_page + 1, + order_by, + 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) + + if len(traffics) == per_page + 1: + has_next_page = True + traffics = traffics[:per_page] + else: + has_next_page = False + + user_monthly_traffic_list = [] + for traffic in traffics: + info = {} + info['email'] = traffic['user'] + info['name'] = email2nickname(traffic['user']) + info['sync_file_upload'] = traffic['sync_file_upload'] + info['sync_file_download'] = traffic['sync_file_download'] + info['web_file_upload'] = traffic['web_file_upload'] + info['web_file_download'] = traffic['web_file_download'] + info['link_file_upload'] = traffic['link_file_upload'] + info['link_file_download'] = traffic['link_file_download'] + user_monthly_traffic_list.append(info) + + return Response({ + 'user_monthly_traffic_list': user_monthly_traffic_list, + 'has_next_page': has_next_page + }) + + +class OrgUserTrafficExcelView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + + month = request.GET.get("month", "") + if not month: + error_msg = "month invalid." + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + month_obj = datetime.datetime.strptime(month, "%Y%m") + except Exception: + error_msg = "Month %s invalid" % month + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + res_data = seafevents_api.get_all_users_traffic_by_month(month_obj, + -1, -1, + org_id=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) + + data_list = [] + head = [_("Time"), _("User"), _("Web Download") + ('(MB)'), + _("Sync Download") + ('(MB)'), _("Link Download") + ('(MB)'), + _("Web Upload") + ('(MB)'), _("Sync Upload") + ('(MB)'), + _("Link Upload") + ('(MB)')] + + for data in res_data: + web_download = byte_to_mb(data['web_file_download']) + sync_download = byte_to_mb(data['sync_file_download']) + link_download = byte_to_mb(data['link_file_download']) + web_upload = byte_to_mb(data['web_file_upload']) + sync_upload = byte_to_mb(data['sync_file_upload']) + link_upload = byte_to_mb(data['link_file_upload']) + + row = [month, data['user'], web_download, sync_download, + link_download, web_upload, sync_upload, link_upload] + + data_list.append(row) + + excel_name = "User Traffic %s" % month + + try: + wb = write_xls(excel_name, head, data_list) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = 'attachment; filename="%s.xlsx"' % excel_name + wb.save(response) + + return response + + +class OrgUserStorageExcelView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def get(self, request, org_id): + + org_id = int(org_id) + org = ccnet_api.get_org_by_id(org_id) + all_users = ccnet_api.get_org_users_by_url_prefix(org.url_prefix, -1, -1) + + head = [_("Email"), _("Name"), _("Contact Email"), + _("Space Usage") + "(MB)", _("Space Quota") + "(MB)"] + + data_list = [] + for user in all_users: + + user_email = user.email + user_name = email2nickname(user_email) + user_contact_email = email2contact_email(user_email) + + _populate_user_quota_usage(user) + space_usage_MB = byte_to_mb(user.space_usage) + space_quota_MB = byte_to_mb(user.space_quota) + + row = [user_email, user_name, user_contact_email, + space_usage_MB, space_quota_MB] + + data_list.append(row) + + excel_name = 'User Storage' + try: + wb = write_xls('users', head, data_list) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + response = HttpResponse(content_type='application/ms-excel') + response['Content-Disposition'] = 'attachment; filename="%s.xlsx"' % excel_name + wb.save(response) + + return response diff --git a/seahub/organizations/api/admin/users.py b/seahub/organizations/api/admin/users.py index 9afaa660e3..5eb3649e55 100644 --- a/seahub/organizations/api/admin/users.py +++ b/seahub/organizations/api/admin/users.py @@ -1,5 +1,7 @@ # Copyright (c) 2012-2016 Seafile Ltd. import logging +from io import BytesIO +from openpyxl import load_workbook from rest_framework import status from rest_framework.views import APIView @@ -19,8 +21,10 @@ from seahub.base.models import UserLastLogin from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email from seahub.profile.models import Profile from seahub.settings import SEND_EMAIL_ON_ADDING_SYSTEM_MEMBER -from seahub.utils import is_valid_email, IS_EMAIL_CONFIGURED +from seahub.utils import is_valid_email, IS_EMAIL_CONFIGURED, \ + get_file_type_and_ext from seahub.utils.file_size import get_file_size_unit +from seahub.utils.error_msg import file_type_error_msg from seahub.utils.timeutils import timestamp_to_isoformat_timestr, datetime_to_isoformat_timestr from seahub.utils.licenseparse import user_number_over_limit from seahub.views.sysadmin import send_user_add_mail @@ -44,8 +48,8 @@ class OrgAdminUsers(APIView): throttle_classes = (UserRateThrottle,) permission_classes = (IsProVersion, IsOrgAdminUser) - def get_info_of_users_order_by_quota_usage(self, org, all_users, direction, - page, per_page): + def get_info_of_users_order_by_quota_usage(self, org, all_users, + direction, page, per_page): # get user's quota usage info user_usage_dict = {} @@ -145,8 +149,11 @@ class OrgAdminUsers(APIView): return api_error(status.HTTP_400_BAD_REQUEST, error_msg) try: - data = self.get_info_of_users_order_by_quota_usage(org, all_users, direction, - current_page, per_page) + data = self.get_info_of_users_order_by_quota_usage(org, + all_users, + direction, + current_page, + per_page) except Exception as e: logger.error(e) error_msg = 'Internal Server Error' @@ -201,7 +208,6 @@ class OrgAdminUsers(APIView): 'page_next': page_next }) - def post(self, request, org_id): """Added an organization user, check member quota before adding. """ @@ -282,7 +288,7 @@ class OrgAdminUsers(APIView): user_info['email'] = user.email user_info['contact_email'] = email2contact_email(user.email) user_info['last_login'] = None - user_info['self_usage'] = 0 # get_org_user_self_usage(org.org_id, user.email) + user_info['self_usage'] = 0 # get_org_user_self_usage(org.org_id, user.email) try: user_info['quota'] = get_org_user_quota(org_id, user.email) except SearpcError as e: @@ -291,6 +297,7 @@ class OrgAdminUsers(APIView): user_info['quota_usage'] = user_info['self_usage'] user_info['quota_total'] = user_info['quota'] + user_info['create_time'] = user_info['ctime'] return Response(user_info) @@ -589,3 +596,161 @@ class OrgAdminSearchUser(APIView): user_list.append(user_info) return Response({'user_list': user_list}) + + +class OrgAdminImportUsers(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdminUser) + + def post(self, request, org_id): + """ Import users from xlsx file + + Permission checking: + 1. admin user. + """ + + # resource check + 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) + + # check plan + url_prefix = request.user.org.url_prefix + org_members = len(ccnet_api.get_org_users_by_url_prefix(url_prefix, -1, -1)) + + if ORG_MEMBER_QUOTA_ENABLED: + from seahub.organizations.models import OrgMemberQuota + org_members_quota = OrgMemberQuota.objects.get_quota(request.user.org.org_id) + if org_members_quota is not None and org_members >= org_members_quota: + err_msg = 'Failed. You can only invite %d members.' % org_members_quota + return api_error(status.HTTP_403_FORBIDDEN, err_msg) + + xlsx_file = request.FILES.get('file', None) + if not xlsx_file: + error_msg = 'file can not be found.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + file_type, ext = get_file_type_and_ext(xlsx_file.name) + if ext != 'xlsx': + error_msg = file_type_error_msg(ext, 'xlsx') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + content = xlsx_file.read() + + try: + fs = BytesIO(content) + wb = load_workbook(filename=fs, read_only=True) + except Exception as e: + logger.error(e) + + # example file is like: + # Email Password Name(Optional) Space Quota(MB, Optional) + # a@a.com a a 1024 + # b@b.com b b 2048 + + rows = wb.worksheets[0].rows + records = [] + + # skip first row(head field). + next(rows) + for row in rows: + if not all(col.value is None for col in row): + records.append([col.value for col in row]) + + if user_number_over_limit(new_users=len(records)): + error_msg = 'The number of users exceeds the limit.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + result = {} + result['failed'] = [] + result['success'] = [] + for record in records: + if record[0]: + email = record[0].strip() + if not is_valid_email(email): + result['failed'].append({ + 'email': email, + 'error_msg': 'email %s invalid.' % email + }) + continue + else: + result['failed'].append({ + 'email': '', + 'error_msg': 'email invalid.' + }) + continue + + if not record[1] or not record[1].strip(): + result['failed'].append({ + 'email': email, + 'error_msg': 'password invalid.' + }) + continue + else: + password = record[1].strip() + + try: + User.objects.get(email=email) + result['failed'].append({ + 'email': email, + 'error_msg': 'user %s exists.' % email + }) + continue + except User.DoesNotExist: + pass + + User.objects.create_user(email, password, is_staff=False, is_active=True) + set_org_user(org_id, email) + + if IS_EMAIL_CONFIGURED: + if SEND_EMAIL_ON_ADDING_SYSTEM_MEMBER: + try: + send_user_add_mail(request, email, password) + except Exception as e: + logger.error(str(e)) + + # update the user's optional info + # update nikename + if record[2]: + try: + nickname = record[2].strip() + if len(nickname) <= 64 and '/' not in nickname: + Profile.objects.add_or_update(email, nickname, '') + except Exception as e: + logger.error(e) + + # update quota + if record[3]: + try: + space_quota_mb = int(record[3]) + if space_quota_mb >= 0: + space_quota = int(space_quota_mb) * get_file_size_unit('MB') + seafile_api.set_org_user_quota(org_id, email, space_quota) + except Exception as e: + logger.error(e) + + user = User.objects.get(email=email) + + info = {} + info['email'] = email + info['name'] = email2nickname(email) + info['contact_email'] = email2contact_email(email) + + info['is_staff'] = user.is_staff + info['is_active'] = user.is_active + + info['quota_usage'] = 0 + try: + info['quota_total'] = get_org_user_quota(org_id, email) + except SearpcError as e: + logger.error(e) + info['quota_total'] = -1 + + info['create_time'] = timestamp_to_isoformat_timestr(user.ctime) + info['last_login'] = None + + result['success'].append(info) + + return Response(result) diff --git a/seahub/organizations/api_urls.py b/seahub/organizations/api_urls.py index d22e169ed6..72036f9235 100644 --- a/seahub/organizations/api_urls.py +++ b/seahub/organizations/api_urls.py @@ -10,7 +10,8 @@ from .api.group_owned_libraries import ( AdminGroupOwnedLibraries, AdminGroupOwnedLibrary ) from .api.group_members import AdminGroupMembers, AdminGroupMember -from .api.admin.users import OrgAdminUser, OrgAdminUsers, OrgAdminSearchUser +from .api.admin.users import OrgAdminUser, OrgAdminUsers, OrgAdminSearchUser, \ + OrgAdminImportUsers from .api.admin.user_set_password import OrgAdminUserSetPassword from .api.admin.groups import OrgAdminGroups, OrgAdminGroup, OrgAdminSearchGroup from .api.admin.repos import OrgAdminRepos, OrgAdminRepo @@ -19,7 +20,38 @@ from .api.admin.links import OrgAdminLinks, OrgAdminLink from .api.admin.logs import OrgAdminLogsFileAccess, OrgAdminLogsFileUpdate, OrgAdminLogsPermAudit from .api.admin.user_repos import OrgAdminUserRepos, OrgAdminUserBesharedRepos +from .api.admin.devices import OrgAdminDevices, OrgAdminDevicesErrors + +from .api.admin.statistics import OrgFileOperationsView, OrgTotalStorageView, \ + OrgActiveUsersView, OrgSystemTrafficView, OrgUserTrafficView, \ + OrgUserTrafficExcelView, OrgUserStorageExcelView + urlpatterns = [ + url(r'^(?P\d+)/admin/statistics/file-operations/$', + OrgFileOperationsView.as_view(), + name='api-v2.1-org-admin-statistics-file-operations'), + url(r'^(?P\d+)/admin/statistics/total-storage/$', + OrgTotalStorageView.as_view(), + name='api-v2.1-org-admin-statistics-total-storage'), + url(r'^(?P\d+)/admin/statistics/active-users/$', + OrgActiveUsersView.as_view(), + name='api-v2.1-org-admin-statistics-active-users'), + url(r'^(?P\d+)/admin/statistics/system-traffic/$', + OrgSystemTrafficView.as_view(), + name='api-v2.1-org-admin-statistics-system-traffic'), + url(r'^(?P\d+)/admin/statistics/user-traffic/$', + OrgUserTrafficView.as_view(), + name='api-v2.1-org-admin-statistics-user-traffic'), + url(r'^(?P\d+)/admin/statistics/user-traffic/excel/$', + OrgUserTrafficExcelView.as_view(), + name='api-v2.1-org-admin-statistics-user-traffic-excel'), + url(r'^(?P\d+)/admin/statistics/user-storage/excel/$', + OrgUserStorageExcelView.as_view(), + name='api-v2.1-org-admin-statistics-user-storage-excel'), + + url(r'^(?P\d+)/admin/devices/$', OrgAdminDevices.as_view(), name='api-v2.1-org-admin-devices'), + url(r'^(?P\d+)/admin/devices-errors/$', OrgAdminDevicesErrors.as_view(), name='api-v2.1-org-admin-devices-errors'), + url(r'^(?P\d+)/admin/address-book/groups/$', AdminAddressBookGroups.as_view(), name='api-admin-address-book-groups'), url(r'^(?P\d+)/admin/address-book/groups/(?P\d+)/$', AdminAddressBookGroup.as_view(), name='api-admin-address-book-group'), @@ -35,6 +67,7 @@ urlpatterns = [ url(r'^(?P\d+)/admin/groups/(?P\d+)/members/$', AdminGroupMembers.as_view(), name='api-admin-group-members'), url(r'^(?P\d+)/admin/groups/(?P\d+)/members/(?P[^/]+)/$', AdminGroupMember.as_view(), name='api-admin-group-member'), url(r'^(?P\d+)/admin/users/$', OrgAdminUsers.as_view(), name='api-v2.1-org-admin-users'), + url(r'^(?P\d+)/admin/import-users/$', OrgAdminImportUsers.as_view(), name='api-v2.1-org-admin-import-users'), url(r'^(?P\d+)/admin/search-user/$', OrgAdminSearchUser.as_view(), name='api-v2.1-org-admin-search-user'), url(r'^(?P\d+)/admin/users/(?P[^/]+)/$', OrgAdminUser.as_view(), name='api-v2.1-org-admin-user'), url(r'^(?P\d+)/admin/users/(?P[^/]+)/set-password/', OrgAdminUserSetPassword.as_view(), name='api-v2.1-org-admin-user-reset-password'), diff --git a/seahub/organizations/urls.py b/seahub/organizations/urls.py index c9281475b4..2defabd852 100644 --- a/seahub/organizations/urls.py +++ b/seahub/organizations/urls.py @@ -7,6 +7,15 @@ urlpatterns = [ url(r'^add/$', org_add, name='org_add'), url(r'^register/$', org_register, name='org_register'), + url(r'^statistics-admin/file/$', react_fake_view, name='org_statistics_admin_file'), + url(r'^statistics-admin/total-storage/$', react_fake_view, name='org_statistics_admin_total_storage'), + url(r'^statistics-admin/active-users/$', react_fake_view, name='org_statistics_admin_active_users'), + url(r'^statistics-admin/traffic/$', react_fake_view, name='org_statistics_admin_traffic'), + + url(r'^deviceadmin/desktop-devices/$', react_fake_view, name='org_device_admin'), + url(r'^deviceadmin/mobile-devices/$', react_fake_view, name='org_device_admin_mobile_devices'), + url(r'^deviceadmin/devices-errors/$', react_fake_view, name='org_device_admin_devices_errors'), + url(r'^useradmin/$', react_fake_view, name='org_user_admin'), url(r'^useradmin/search-users/$', react_fake_view, name='org_user_admin_search_users'), url(r'^useradmin/admins/$', react_fake_view, name='org_useradmin_admins'), @@ -25,7 +34,7 @@ urlpatterns = [ url(r'^logadmin/file-update/$', react_fake_view, name='org_log_file_update'), url(r'^logadmin/perm-audit/$', react_fake_view, name='org_log_perm_audit'), - url(r'^orgmanage/$', react_fake_view, name='org_manage'), + url(r'^info/$', react_fake_view, name='org_info'), url(r'^departmentadmin/$', react_fake_view, name='org_department_admin'), url(r'^departmentadmin/groups/(?P\d+)/', react_fake_view, name='org_department_admin'), url(r'^associate/(?P.+)/$', org_associate, name='org_associate'), diff --git a/seahub/utils/__init__.py b/seahub/utils/__init__.py index 1fe301a9bd..6f967e9e9c 100644 --- a/seahub/utils/__init__.py +++ b/seahub/utils/__init__.py @@ -703,6 +703,12 @@ if EVENTS_CONFIG_FILE: res = seafevents.get_user_activity_stats_by_day(session, start, end, offset) return res + def get_org_user_activity_stats_by_day(org_id, start, end): + """ + """ + res = seafevents_api.get_org_user_activity_stats_by_day(org_id, start, end) + return res + def get_org_user_events(org_id, username, start, count): return _get_events(username, start, count, org_id=org_id) @@ -773,6 +779,12 @@ if EVENTS_CONFIG_FILE: res = seafevents.get_file_ops_stats_by_day(session, start, end, offset) return res + def get_org_file_ops_stats_by_day(org_id, start, end, offset): + """ return file audit record of sepcifiy time group by day. + """ + res = seafevents_api.get_org_file_ops_stats_by_day(org_id, start, end, offset) + return res + def get_total_storage_stats_by_day(start, end, offset): """ """ @@ -780,6 +792,12 @@ if EVENTS_CONFIG_FILE: res = seafevents.get_total_storage_stats_by_day(session, start, end, offset) return res + def get_org_total_storage_stats_by_day(org_id, start, end, offset): + """ + """ + res = seafevents_api.get_org_storage_stats_by_day(org_id, start, end, offset) + return res + def get_system_traffic_by_day(start, end, offset, op_type='all'): with _get_seafevents_session() as session: res = seafevents.get_system_traffic_by_day(session, start, end, offset, op_type) @@ -848,6 +866,8 @@ else: pass def get_user_activity_stats_by_day(): pass + def get_org_user_activity_stats_by_day(): + pass def get_log_events_by_time(): pass def get_org_user_events(): @@ -864,10 +884,16 @@ else: pass def get_file_ops_stats_by_day(): pass + def get_org_file_ops_stats_by_day(): + pass def get_total_storage_stats_by_day(): pass + def get_org_total_storage_stats_by_day(): + pass def get_system_traffic_by_day(): pass + def get_org_system_traffic_by_day(): + pass def get_org_traffic_by_day(): pass def get_file_update_events(): diff --git a/seahub/views/sysadmin.py b/seahub/views/sysadmin.py index b90759db45..834b569da9 100644 --- a/seahub/views/sysadmin.py +++ b/seahub/views/sysadmin.py @@ -50,7 +50,7 @@ from seahub.utils import IS_EMAIL_CONFIGURED, string2list, is_valid_username, \ is_pro_version, send_html_email, \ get_server_id, delete_virus_file, get_virus_file_by_vid, \ get_virus_files, FILE_AUDIT_ENABLED, get_max_upload_file_size, \ - get_site_name, seafevents_api + get_site_name, seafevents_api, is_org_context from seahub.utils.ip import get_remote_ip from seahub.utils.file_size import get_file_size_unit from seahub.utils.ldap import get_ldap_info @@ -779,7 +779,6 @@ def batch_user_make_admin(request): return HttpResponse(json.dumps({'success': True,}), content_type=content_type) @login_required -@sys_staff_required def batch_add_user_example(request): """ get example file. """ @@ -787,20 +786,32 @@ def batch_add_user_example(request): if not next_page: next_page = SITE_ROOT data_list = [] - head = [_('Email'), - _('Password'), - _('Name') + '(' + _('Optional') + ')', - _('Role') + '(' + _('Optional') + ')', - _('Space Quota') + '(MB, ' + _('Optional') + ')', - 'Login ID'] - for i in range(5): - username = "test" + str(i) + "@example.com" - password = "123456" - name = "test" + str(i) - role = "default" - quota = "1000" - login_id = "login id " + str(i) - data_list.append([username, password, name, role, quota, login_id]) + if not is_org_context(request): + head = [_('Email'), + _('Password'), + _('Name') + '(' + _('Optional') + ')', + _('Role') + '(' + _('Optional') + ')', + _('Space Quota') + '(MB, ' + _('Optional') + ')', + 'Login ID'] + for i in range(5): + username = "test" + str(i) + "@example.com" + password = "123456" + name = "test" + str(i) + role = "default" + quota = "1000" + login_id = "login id " + str(i) + data_list.append([username, password, name, role, quota, login_id]) + else: + head = [_('Email'), + _('Password'), + _('Name') + '(' + _('Optional') + ')', + _('Space Quota') + '(MB, ' + _('Optional') + ')'] + for i in range(5): + username = "test" + str(i) + "@example.com" + password = "123456" + name = "test" + str(i) + quota = "1000" + data_list.append([username, password, name, quota]) wb = write_xls('sample', head, data_list) if not wb: