diff --git a/frontend/config/webpack.entry.js b/frontend/config/webpack.entry.js index 4f4b1eb78a6..9b88e763b90 100644 --- a/frontend/config/webpack.entry.js +++ b/frontend/config/webpack.entry.js @@ -38,6 +38,7 @@ const entryFiles = { sysAdmin: '/pages/sys-admin', search: '/pages/search', uploadLink: '/pages/upload-link', + subscription: '/subscription.js', }; const getEntries = (isEnvDevelopment) => { diff --git a/frontend/src/assets/icons/currency.svg b/frontend/src/assets/icons/currency.svg new file mode 100644 index 00000000000..23bc368565e --- /dev/null +++ b/frontend/src/assets/icons/currency.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/common/account.js b/frontend/src/components/common/account.js index 1f991c1b567..379e3d52250 100644 --- a/frontend/src/components/common/account.js +++ b/frontend/src/components/common/account.js @@ -6,6 +6,10 @@ import { seafileAPI } from '../../utils/seafile-api'; import { siteRoot, gettext, appAvatarURL, enableSSOToThirdpartWebsite } from '../../utils/constants'; import toaster from '../toast'; +const { + isOrgContext, +} = window.app.pageOptions; + const propTypes = { isAdminPanel: PropTypes.bool, }; @@ -22,6 +26,7 @@ class Account extends Component { isStaff: false, isOrgStaff: false, usageRate: '', + enableSubscription: false, }; this.isFirstMounted = true; } @@ -81,6 +86,7 @@ class Account extends Component { isInstAdmin: resp.data.is_inst_admin, isOrgStaff: resp.data.is_org_staff === 1 ? true : false, showInfo: !this.state.showInfo, + enableSubscription: resp.data.enable_subscription, }); }).catch(error => { let errMessage = Utils.getErrorMsg(error); @@ -163,6 +169,7 @@ class Account extends Component { {gettext('Settings')} + {(this.state.enableSubscription && !isOrgContext) && {'付费管理'}} {this.renderMenu()} {enableSSOToThirdpartWebsite && {gettext('Customer Portal')}} {gettext('Log out')} diff --git a/frontend/src/components/subscription.js b/frontend/src/components/subscription.js new file mode 100644 index 00000000000..e1c00945bb0 --- /dev/null +++ b/frontend/src/components/subscription.js @@ -0,0 +1,523 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import toaster from './toast'; +import { Modal, ModalHeader, ModalBody, ModalFooter, InputGroup, InputGroupAddon, InputGroupText, Input, Button } from 'reactstrap'; +import { gettext, serviceURL } from '../utils/constants'; +import { Utils } from '../utils/utils'; +import { subscriptionAPI } from '../utils/subscription-api'; +import Loading from './loading'; + +import '../css/layout.css'; +import '../css/subscription.css'; + +const { + isOrgContext, +} = window.app.pageOptions; + + +const PlansPropTypes = { + plans: PropTypes.array.isRequired, + onPay: PropTypes.func.isRequired, + paymentType: PropTypes.string.isRequired, + handleContentScroll: PropTypes.func.isRequired, +}; + +class Plans extends Component { + constructor(props) { + super(props); + this.state = { + currentPlan: props.plans[0], + assetQuotaUnitCount: 1, + count: 1, + }; + } + + togglePlan = (plan) => { + this.setState({currentPlan: plan}, () => { + }); + }; + + onPay = () => { + let { paymentType } = this.props; + let { currentPlan, assetQuotaUnitCount, count } = this.state; + let totalAmount, assetQuota, newUserCount; + + // parse + if (paymentType === 'paid') { + newUserCount = currentPlan.count; + totalAmount = currentPlan.total_amount; + } else if (paymentType === 'extend_time') { + newUserCount = currentPlan.count; + assetQuota = currentPlan.asset_quota; + totalAmount = currentPlan.total_amount; + } else if (paymentType === 'add_user') { + newUserCount = count; + totalAmount = count * currentPlan.price_per_user; + } else if (paymentType === 'buy_quota') { + assetQuota = (assetQuotaUnitCount) * currentPlan.asset_quota_unit; + totalAmount = assetQuotaUnitCount * currentPlan.price_per_asset_quota_unit; + } else { + toaster.danger(gettext('Internal Server Error.')); + return; + } + + this.props.onPay(currentPlan.plan_id, newUserCount, assetQuota, totalAmount); + }; + + onCountInputChange = (e) => { + let { currentPlan } = this.state; + if (!currentPlan.can_custom_count) { + return; + } + let count = e.target.value.replace(/^(0+)|[^\d]+/g, ''); + if (count < 1) { + count = 1; + } else if (count > 9999) { + count = 9999; + } + this.setState({count: count}); + }; + + onAssetQuotaUnitCountInputChange = (e) => { + let { currentPlan } = this.state; + if (!currentPlan.can_custom_asset_quota) { + return; + } + let count = e.target.value.replace(/^(0+)|[^\d]+/g, ''); + if (count < 1) { + count = 1; + } else if (count > 9999) { + count = 9999; + } + this.setState({assetQuotaUnitCount: count}); + }; + + renderPaidOrExtendTime = () => { + let { plans, paymentType } = this.props; + let { currentPlan } = this.state; + let boughtQuota = 0; + if (paymentType === 'extend_time') { + boughtQuota = currentPlan.asset_quota - 100; + } + let totalAmount = currentPlan.total_amount; + let originalTotalAmount = totalAmount; + return ( +
+ {'选择方案'} +
+ {plans.map((item, index) => { + let selectedCss = item.plan_id === currentPlan.plan_id ? 'plan-selected' : ''; + let countDescription = '¥' + item.price_per_user; + if (isOrgContext) { + countDescription += '/每用户'; + } + return ( +
+ {item.name} + {countDescription} +
+ ); + })} +
+ + {paymentType === 'extend_time' && boughtQuota > 0 && + + {'增加空间'} +
+
+ {currentPlan.asset_quota_unit + 'GB x ' + (boughtQuota / currentPlan.asset_quota_unit)} + {/* 续费时候需要减去附赠的100GB */} + {'¥' + (boughtQuota / currentPlan.asset_quota_unit) * currentPlan.price_per_asset_quota_unit} +
+
+
+ } + + {'方案汇总'} +
+
+
+ {'所选方案'} + {currentPlan.name} +
+ {isOrgContext && +
+ {'成员人数'} + {currentPlan.count + '人'} +
+ } +
+ {'可用空间'} + {'100GB(附赠)' + (boughtQuota > 0 ? '+' + boughtQuota + 'GB(扩充)' : '')} +
+
+ {'到期时间'} + {currentPlan.new_term_end} +
+
+ {'实际支付金额'} + + {originalTotalAmount !== totalAmount && + {'¥' + originalTotalAmount} + } + {'¥' + totalAmount + ' '} + +
+
+
+ +
+ ); + }; + + renderAddUser = () => { + let { currentPlan, count } = this.state; + let operationIntro = '新增用户'; + let originalTotalAmount = count * currentPlan.price_per_user; + let totalAmount = originalTotalAmount; + return ( +
+
+

{currentPlan.name}

+ + {'¥ '}{currentPlan.price}{' ' + currentPlan.description} + + + + {operationIntro} + + + + + {'总价 ¥ ' + totalAmount} + {originalTotalAmount !== totalAmount && + {' ¥' + originalTotalAmount} + } + + {'有效期至 ' + currentPlan.new_term_end} + {'注:当有效期剩余天数少于计划中的时候,增加用户的价格按天来计算'} + +
+ ); + }; + + renderBuyQuota = () => { + let { currentPlan, assetQuotaUnitCount } = this.state; + let operationIntro = '新增空间'; + let originalTotalAmount = assetQuotaUnitCount * currentPlan.price_per_asset_quota_unit; + let totalAmount = originalTotalAmount; + return ( +
+
+

{currentPlan.name}

+ + {'¥ '}{currentPlan.asset_quota_price}{' ' + currentPlan.asset_quota_description} + + + + {operationIntro} + + + + {' x ' + currentPlan.asset_quota_unit + 'GB'} + + + + {'总价 ¥ ' + totalAmount} + {originalTotalAmount !== totalAmount && + {' ¥' + originalTotalAmount} + } + + {'有效期至 ' + currentPlan.new_term_end} + {'注:当有效期剩余天数少于计划中的时候,增加空间的价格按天来计算'} + +
+ ); + }; + + render() { + let { paymentType } = this.props; + if (paymentType === 'paid' || paymentType === 'extend_time') { + return this.renderPaidOrExtendTime(); + } else if (paymentType === 'add_user') { + return this.renderAddUser(); + } else if (paymentType === 'buy_quota') { + return this.renderBuyQuota(); + } else { + toaster.danger(gettext('Internal Server Error.')); + return; + } + } +} + +Plans.propTypes = PlansPropTypes; + +const PlansDialogPropTypes = { + isOrgContext: PropTypes.bool.isRequired, + paymentType: PropTypes.string.isRequired, + paymentTypeTrans: PropTypes.string.isRequired, + toggleDialog: PropTypes.func.isRequired, +}; + +class PlansDialog extends Component { + + constructor(props) { + super(props); + this.state = { + isLoading: true, + isWaiting: false, + planList: [], + paymentSourceList: [], + }; + } + + getPlans = () => { + subscriptionAPI.getSubscriptionPlans(this.props.paymentType).then((res) => { + this.setState({ + planList: res.data.plan_list, + paymentSourceList: res.data.payment_source_list, + isLoading: false, + }); + }).catch(error => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + isLoading: false, + errorMsg: errorMsg, + }); + }); + }; + + onPay = (planID, count, asset_quota, totalAmount) => { + this.setState({ isWaiting: true }); + let payUrl = serviceURL + '/subscription/pay/?payment_source=' + this.state.paymentSourceList[0] + + '&payment_type=' + this.props.paymentType + '&plan_id=' + planID + + '&total_amount=' + totalAmount; + if (count) { + payUrl += '&count=' + count; + } + if (asset_quota) { + payUrl += '&asset_quota=' + asset_quota; + } + window.open(payUrl); + }; + + onReload = () => { + window.location.reload(); + }; + + componentDidMount() { + this.getPlans(); + } + + render() { + const { isLoading, isWaiting, planList } = this.state; + const { toggleDialog, paymentTypeTrans, paymentType } = this.props; + const modalStyle = (paymentType === 'paid' || paymentType === 'extend_time') ? + {width: '560px', maxWidth: '560px'} : {width: '560px'}; + + if (isLoading) { + return ( + + {paymentTypeTrans} + + + + + ); + } + if (isWaiting) { + return ( + + {paymentTypeTrans} + +
{'是否完成付款?'}
+
+ + + +
+ ); + } + return ( + + {paymentTypeTrans} + +
+ +
+
+
+ ); + } +} + +PlansDialog.propTypes = PlansDialogPropTypes; + +const propTypes = { + isOrgContext: PropTypes.bool.isRequired, + handleContentScroll: PropTypes.func, +}; + +class Subscription extends Component { + + constructor(props) { + super(props); + this.paymentTypeTransMap = { + paid: '立即购买', + extend_time: '立即续费', + add_user: '增加用户', + buy_quota: '增加空间', + }; + this.state = { + isLoading: true, + errorMsg: '', + isDialogOpen: false, + planName: this.props.isOrgContext ? '团队版' : '个人版', + userLimit: 20, + assetQuota: 1, + termEnd: '长期', + subscription: null, + paymentTypeList: [], + currentPaymentType: '', + errorMsgCode: '' + }; + } + + getSubscription = () => { + subscriptionAPI.getSubscription().then((res) => { + const subscription = res.data.subscription; + const paymentTypeList = res.data.payment_type_list; + if (!subscription) { + this.setState({ + isLoading: false, + paymentTypeList: paymentTypeList, + }); + } else { + let isActive = subscription.is_active; + let plan = subscription.plan; + this.setState({ + isLoading: false, + subscription, + planName: plan.name, + userLimit: subscription.user_limit, + assetQuota: isActive ? subscription.asset_quota : plan.asset_quota, + termEnd: isActive ? subscription.term_end : '已过期', + paymentTypeList: paymentTypeList, + }); + } + }).catch(error => { + let errorMsg = Utils.getErrorMsg(error); + this.setState({ + isLoading: false, + errorMsg: errorMsg, + }); + }); + }; + + toggleDialog = () => { + this.setState({ isDialogOpen: !this.state.isDialogOpen }); + }; + + togglePaymentType = (paymentType) => { + this.setState({ currentPaymentType: paymentType }); + this.toggleDialog(); + }; + + componentDidMount() { + this.getSubscription(); + } + + render() { + const { isLoading, errorMsg, planName, userLimit, assetQuota, termEnd, + isDialogOpen, paymentTypeList, currentPaymentType } = this.state; + if (isLoading) { + return ; + } + if (errorMsg) { + return

{errorMsg}

; + } + return ( + +
+
+

{'当前版本'}

+

{planName}

+
+ {this.props.isOrgContext && +
+

{'用户数限制'}

+

{userLimit}

+
+ } +
+

{'空间'}

+

{assetQuota ? assetQuota + 'GB' : '1GB'}

+
+
+

{'订阅有效期'}

+

{termEnd}

+
+
+

{'云服务付费方案'}

+

+ {'查看详情'} +

+
+ {paymentTypeList.map((item, index) => { + let name = this.paymentTypeTransMap[item]; + return ( + + ); + })} + {!this.state.subscription && +
+

{'销售咨询'}

+ +

{'微信扫码联系销售'}

+
+ } +
+ {isDialogOpen && + + } +
+ ); + } +} + +Subscription.propTypes = propTypes; + +export default Subscription; diff --git a/frontend/src/css/subscription.css b/frontend/src/css/subscription.css new file mode 100644 index 00000000000..7b02a0d57a2 --- /dev/null +++ b/frontend/src/css/subscription.css @@ -0,0 +1,230 @@ +.content { + padding: 0rem 1rem 8rem; + overflow: auto; + } + + .content .dropdown .btn-outline-primary { + color: #f19645; + background-color: transparent; + background-image: none; + border-color: #f19645; + } + + .content .dropdown .btn-outline-primary:hover { + color: #fff; + background-color: #f19645; + border-color: #f19645; + } + + .content .dropdown .btn-outline-primary:focus { + box-shadow: 0 0 0 2px rgba(241, 150, 69, 0.5); + } + + .content .dropdown .btn-outline-primary:active { + color: #fff; + background-color: #f19645; + border-color: #f19645; + } + + .content .dropdown .btn-outline-primary:not(:disabled):not(.disabled):active:focus, + .content .dropdown .btn-outline-primary:not(:disabled):not(.disabled).active:focus, + .content .dropdown .show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 2px rgba(241, 150, 69, 0.5); + } + + .subscription-info { + margin: 1em 0 3em; + } + + .subscription-info-heading { + font-size: 1rem; + font-weight: normal; + padding-bottom: 0.2em; + border-bottom: 1px solid #ddd; + margin-bottom: 0.7em; + } + + .price-version-container-header { + max-width: 90%; + border-radius: 10px; + padding: 0 1rem 2rem; + box-shadow: 0 3px 8px 3px rgba(116, 129, 141, 0.15); + overflow: hidden; + margin: 0.5rem 1rem; + } + + .price-version-container-header .price-version-container-top { + height: 10px; + margin: 0 -1rem; + } + + .price-version-container-header .price-version-plan-name { + font-size: 1.5rem; + padding: 1rem 0; + border-bottom: 1px solid #ED7109; + } + + .price-version-container-header .price-table-btn-details { + color: #fff; + font-size: 1rem; + } + + .price-version-container-header .font-500 { + font-weight: 500; + } + + .price-version-container-header .price-version-plan-price { + font-size: 2rem; + } + + .price-version-container-header .price-version-plan-whole-price { + font-size: 24px; + } + + .price-version-container-header .price-version-plan-valid-day, + .price-version-container-header .price-version-plan-user-number { + font-size: 18px; + } + + .subscription-container { + width: 480px; + margin: 0.5rem auto; + } + + .price-version-container-header .user-quota-plan-name { + margin-bottom: 24px; + text-align: center; + border-bottom: 1px solid rgba(253, 150, 68, 0.4); + } + + .subscription-container .items-dl { + margin-top: 10px; + } + + .plan-description-item { + border: 1px solid #bdbdbd; + border-radius: 5px; + margin: 0 0 10px; + display: flex; + justify-content: space-between; + background-color: #f9f9f9; + line-height: 3; + padding: 0 20px; + cursor: pointer; + } + + .plan-description-item.plan-selected { + background-color: #fff5eb; + border-color: #ED7109; + border-width: 2px; + } + + .plan-description-item .plan-name { + font-size: 20px; + font-weight: 500; + } + + .plan-description-item .plan-description { + font-size: 20px; + font-weight: 500; + color: #ED7109; + } + + .order-item { + margin: 0px; + display: flex; + justify-content: space-between; + border-left: 1px solid #bdbdbd; + border-right: 1px solid #bdbdbd; + background-color: #f9f9f9; + padding: 5px 20px; + line-height: 2; + } + + .order-item.order-item-top { + border-top: 1px solid #bdbdbd; + border-radius: 5px 5px 0 0; + } + + .order-item.order-item-middle { + border-bottom: 1px solid #bdbdbd; + } + + .order-item.order-item-bottom { + border-bottom: 1px solid #bdbdbd; + border-radius: 0 0 5px 5px; + } + + .order-item .order-into { + color: #666666; + } + + .order-item .order-value { + font-weight: 500; + color: #000; + } + + .order-item .order-value .input-group-append, + .order-item .order-value .input-group-prepend { + height: 38px; + } + + .order-item .order-price { + font-size: 26px; + font-weight: 500; + color: #ED7109; + } + + .order-item .order-price span:first-child { + font-size: 16px; + } + + .subscription-submit { + background-color: #ED7109; + border-radius: 5px; + width: 100%; + margin-bottom: 10px; + color: white; + height: 44px; + font-size: 16px; + } + + .subscription-notice { + font-size: 14px; + color: #9a9a9a; + } + + .subscription-container .subscription-subtitle { + font-size: 18px; + font-weight: 500; + } + + .subscription-container .subscription-list.order-item-top.order-item-bottom { + border-radius: 5px; + } + + .subscription-container .subscription-list span { + height: 36px; + line-height: 36px; + } + + .subscription-container .text-orange { + color: #ED7109 !important; + } + + .user-numbers.input-group .input-group-prepend { + width: 50%; + } + + .space-quota .input-group .input-group-append, + .space-quota .input-group .input-group-prepend { + width: 33.33%; + } + + .subscription-add-space .input-group .input-group-append .input-group-text, + .price-version-container-header .input-group .input-group-prepend .input-group-text { + width: 100%; + text-align: center; + font-weight: 500; + display: block; + } diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js index 7ac17290e7d..e38f133fe31 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -36,6 +36,7 @@ import OrgLogsFileAudit from './org-logs-file-audit'; import OrgLogsFileUpdate from './org-logs-file-update'; import OrgLogsPermAudit from './org-logs-perm-audit'; import OrgSAMLConfig from './org-saml-config'; +import OrgSubscription from './org-subscription'; import '../../css/layout.css'; import '../../css/toolbar.css'; @@ -96,6 +97,7 @@ class Org extends React.Component { + diff --git a/frontend/src/pages/org-admin/org-subscription.js b/frontend/src/pages/org-admin/org-subscription.js new file mode 100644 index 00000000000..6006f585520 --- /dev/null +++ b/frontend/src/pages/org-admin/org-subscription.js @@ -0,0 +1,32 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import MainPanelTopbar from './main-panel-topbar'; +import Subscription from '../../components/subscription'; + +const propTypes = { + onCloseSidePanel: PropTypes.func, +}; + +class OrgSubscription extends Component { + render() { + return ( + + +
+
+
+

{'付费管理'}

+
+
+ +
+
+
+
+ ); + } +} + +OrgSubscription.propTypes = propTypes; + +export default OrgSubscription; diff --git a/frontend/src/pages/org-admin/side-panel.js b/frontend/src/pages/org-admin/side-panel.js index 0946548e6b5..b19273caffe 100644 --- a/frontend/src/pages/org-admin/side-panel.js +++ b/frontend/src/pages/org-admin/side-panel.js @@ -2,7 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from '@gatsbyjs/reach-router'; import Logo from '../../components/logo'; -import { gettext, siteRoot, enableMultiADFS } from '../../utils/constants'; +import Icon from '../../components/icon'; +import { gettext, siteRoot, enableSubscription, enableMultiADFS } from '../../utils/constants'; const propTypes = { isSidePanelClosed: PropTypes.bool.isRequired, @@ -80,6 +81,14 @@ class SidePanel extends React.Component { {gettext('Departments')} + {enableSubscription && +
  • + this.tabItemClick('subscription')} > + + {'付费管理'} + +
  • + }
  • this.tabItemClick('publinkadmin')} > diff --git a/frontend/src/subscription.js b/frontend/src/subscription.js new file mode 100644 index 00000000000..8c5177163d9 --- /dev/null +++ b/frontend/src/subscription.js @@ -0,0 +1,72 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { siteRoot, mediaUrl, logoPath, logoWidth, logoHeight, siteTitle } from './utils/constants'; +import SideNav from './components/user-settings/side-nav'; +import Account from './components/common/account'; +import Notification from './components/common/notification'; +import Subscription from './components/subscription'; + +import './css/toolbar.css'; +import './css/search.css'; +import './css/user-settings.css'; + + +class UserSubscription extends React.Component { + + constructor(props) { + super(props); + this.sideNavItems = [ + { show: true, href: '#current-plan', text: '当前版本' }, + { show: true, href: '#asset-quota', text: '空间' }, + { show: true, href: '#current-subscription-period', text: '订阅有效期' }, + { show: true, href: '#product-price', text: '云服务付费方案' }, + ]; + this.state = { + curItemID: this.sideNavItems[0].href.substr(1), + }; + } + + handleContentScroll = (e) => { + // Mobile does not display the sideNav, so when scrolling don't update curItemID + const scrollTop = e.target.scrollTop; + const scrolled = this.sideNavItems.filter((item, index) => { + return item.show && document.getElementById(item.href.substr(1)).offsetTop - 45 < scrollTop; + }); + if (scrolled.length) { + this.setState({ + curItemID: scrolled[scrolled.length - 1].href.substr(1) + }); + } + }; + + render() { + let logoUrl = logoPath.startsWith('http') ? logoPath : mediaUrl + logoPath; + return ( +
    +
    + + logo + +
    + + +
    +
    +
    +
    + +
    +
    +

    {'付费管理'}

    + +
    +
    +
    + ); + } +} + +ReactDOM.render( + , + document.getElementById('wrapper') +); diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index 3a461c64fa9..0bb26943f1f 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -140,6 +140,7 @@ export const orgEnableAdminCustomLogo = window.org ? window.org.pageOptions.orgE export const orgEnableAdminCustomName = window.org ? window.org.pageOptions.orgEnableAdminCustomName === 'True' : false; export const orgEnableAdminInviteUser = window.org ? window.org.pageOptions.orgEnableAdminInviteUser === 'True' : false; export const enableMultiADFS = window.org ? window.org.pageOptions.enableMultiADFS === 'True' : false; +export const enableSubscription = window.org ? window.org.pageOptions.enableSubscription : false; // sys admin export const constanceEnabled = window.sysadmin ? window.sysadmin.pageOptions.constance_enabled : ''; diff --git a/frontend/src/utils/subscription-api.js b/frontend/src/utils/subscription-api.js new file mode 100644 index 00000000000..9a45cd7a651 --- /dev/null +++ b/frontend/src/utils/subscription-api.js @@ -0,0 +1,61 @@ +import axios from 'axios'; +import cookie from 'react-cookies'; +import { siteRoot } from './constants'; + +class SubscriptionAPI { + + init({ server, username, password, token }) { + this.server = server; + this.username = username; + this.password = password; + this.token = token; //none + if (this.token && this.server) { + this.req = axios.create({ + baseURL: this.server, + headers: { 'Authorization': 'Token ' + this.token }, + }); + } + return this; + } + + initForSeahubUsage({ siteRoot, xcsrfHeaders }) { + if (siteRoot && siteRoot.charAt(siteRoot.length - 1) === '/') { + var server = siteRoot.substring(0, siteRoot.length - 1); + this.server = server; + } else { + this.server = siteRoot; + } + + this.req = axios.create({ + headers: { + 'X-CSRFToken': xcsrfHeaders, + } + }); + return this; + } + + getSubscription() { + const url = this.server + '/api/v2.1/subscription/'; + return this.req.get(url); + } + + getSubscriptionPlans(paymentType) { + const url = this.server + '/api/v2.1/subscription/plans/'; + let params = { + payment_type: paymentType, + }; + return this.req.get(url, { params: params }); + } + + getSubscriptionLogs() { + const url = this.server + '/api/v2.1/subscription/logs/'; + return this.req.get(url); + } + +} + +let subscriptionAPI = new SubscriptionAPI(); +let xcsrfHeaders = cookie.load('sfcsrftoken'); +subscriptionAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); + +export { subscriptionAPI }; diff --git a/media/css/seahub_react.css b/media/css/seahub_react.css index 648d24e7d48..cc819859d4d 100644 --- a/media/css/seahub_react.css +++ b/media/css/seahub_react.css @@ -668,6 +668,7 @@ a, a:hover { color: #ec8000; } color: #fff; } +.side-nav-con .seafile-multicolor-icon, .side-nav-con [class^="sf2-icon-"], .side-nav-con [class^="sf3-font-"], .side-nav-con [class^="fas"] { diff --git a/media/img/qr-sale.png b/media/img/qr-sale.png new file mode 100644 index 00000000000..e986c08b1c6 Binary files /dev/null and b/media/img/qr-sale.png differ diff --git a/seahub/api2/endpoints/admin/organizations.py b/seahub/api2/endpoints/admin/organizations.py index 9b2d6429ea7..3a9d88fdccf 100644 --- a/seahub/api2/endpoints/admin/organizations.py +++ b/seahub/api2/endpoints/admin/organizations.py @@ -77,6 +77,9 @@ def get_org_detailed_info(org): users = ccnet_api.get_org_emailusers(org.url_prefix, -1, -1) org_info['users_count'] = len(users) + active_users_count = len([m for m in users if m.is_active]) + org_info['active_users_count'] = active_users_count + repos = seafile_api.get_org_repo_list(org_id, -1, -1) org_info['repos_count'] = len(repos) @@ -460,3 +463,30 @@ def get(self, request): result.append(org_info) return Response({'organization_list': result}) + + +class AdminOrganizationsBaseInfo(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, IsProVersion) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + ''' + Get base info of organizations in bulk by ids + ''' + if not MULTI_TENANCY: + error_msg = 'Feature is not enabled.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + org_ids = request.GET.getlist('org_ids',[]) + orgs = [] + for org_id in org_ids: + try: + org = ccnet_api.get_org_by_id(int(org_id)) + if not org: + continue + except: + continue + base_info = {'org_id': org.org_id, 'org_name': org.org_name} + orgs.append(base_info) + return Response({'organization_list': orgs}) diff --git a/seahub/api2/endpoints/admin/users.py b/seahub/api2/endpoints/admin/users.py index f85ceacf6d2..d4b8f861888 100644 --- a/seahub/api2/endpoints/admin/users.py +++ b/seahub/api2/endpoints/admin/users.py @@ -23,7 +23,7 @@ from seahub.api2.authentication import TokenAuthentication from seahub.api2.throttling import UserRateThrottle -from seahub.api2.utils import api_error, to_python_boolean +from seahub.api2.utils import api_error, to_python_boolean, get_user_common_info from seahub.api2.models import TokenV2 from seahub.utils.ccnet_db import get_ccnet_db_name import seahub.settings as settings @@ -2092,3 +2092,28 @@ def put(self, request): logger.error(e) return Response({'success': True}) + + +class AdminUserList(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, ) + throttle_classes = (UserRateThrottle, ) + + def post(self, request): + """return user_list by user_id_list + """ + # argument check + user_id_list = request.data.get('user_id_list') + if not isinstance(user_id_list, list): + error_msg = 'user_id_list invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # main + user_list = list() + for user_id in user_id_list: + if not isinstance(user_id, str): + continue + user_info = get_user_common_info(user_id) + user_list.append(user_info) + + return Response({'user_list': user_list}) diff --git a/seahub/api2/endpoints/subscription.py b/seahub/api2/endpoints/subscription.py new file mode 100644 index 00000000000..252d2c9e185 --- /dev/null +++ b/seahub/api2/endpoints/subscription.py @@ -0,0 +1,132 @@ +import logging +import requests + +from rest_framework.views import APIView +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated +from rest_framework import status +from rest_framework.response import Response +from django.utils.translation import gettext as _ + +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.throttling import UserRateThrottle +from seahub.api2.utils import api_error +from seahub.subscription.utils import subscription_check, get_customer_id, \ + get_subscription_api_headers, subscription_permission_check, \ + handler_subscription_api_response +from seahub.subscription.settings import SUBSCRIPTION_SERVER_URL + +logger = logging.getLogger(__name__) + + +class SubscriptionView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """Get subscription + """ + # check + if not subscription_check(): + error_msg = _('Feature is not enabled.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + } + url = SUBSCRIPTION_SERVER_URL.rstrip('/') + '/api/seafile/subscription/' + response = requests.get(url, params=data, headers=headers) + response = handler_subscription_api_response(response) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(response.json(), status=response.status_code) + + +class SubscriptionPlansView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """Get plans + """ + # check + if not subscription_check(): + error_msg = _('Feature is not enabled.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + payment_type = request.GET.get('payment_type') + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + 'payment_type': payment_type, + } + url = SUBSCRIPTION_SERVER_URL.rstrip( + '/') + '/api/seafile/subscription/plans/' + response = requests.get(url, params=data, headers=headers) + response = handler_subscription_api_response(response) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(response.json(), status=response.status_code) + + +class SubscriptionLogsView(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """Get subscription logs by paid + """ + # check + if not subscription_check(): + error_msg = _('Feature is not enabled.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + } + url = SUBSCRIPTION_SERVER_URL.rstrip( + '/') + '/api/seafile/subscription/logs/' + response = requests.get(url, params=data, headers=headers) + response = handler_subscription_api_response(response) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response(response.json(), status=response.status_code) diff --git a/seahub/api2/views.py b/seahub/api2/views.py index b8b2aa79596..3fe552d7aa7 100644 --- a/seahub/api2/views.py +++ b/seahub/api2/views.py @@ -105,7 +105,7 @@ STORAGE_CLASS_MAPPING_POLICY, \ ENABLE_RESET_ENCRYPTED_REPO_PASSWORD, SHARE_LINK_EXPIRE_DAYS_MAX, \ SHARE_LINK_EXPIRE_DAYS_MIN, SHARE_LINK_EXPIRE_DAYS_DEFAULT - +from seahub.subscription.utils import subscription_check try: from seahub.settings import CLOUD_MODE @@ -331,6 +331,7 @@ def _get_account_info(self, request): info['contact_email'] = p.contact_email if p else "" info['institution'] = p.institution if p and p.institution else "" info['is_staff'] = request.user.is_staff + info['enable_subscription'] = subscription_check() if getattr(settings, 'MULTI_INSTITUTION', False): from seahub.institutions.models import InstitutionAdmin diff --git a/seahub/organizations/templates/organizations/org_admin_react.html b/seahub/organizations/templates/organizations/org_admin_react.html index 53fad10895c..03117e518a0 100644 --- a/seahub/organizations/templates/organizations/org_admin_react.html +++ b/seahub/organizations/templates/organizations/org_admin_react.html @@ -18,6 +18,8 @@ orgEnableAdminCustomName: '{{ org_enable_admin_custom_name }}', orgEnableAdminInviteUser: '{{ org_enable_admin_invite_user }}', enableMultiADFS: '{{ enable_multi_adfs }}', + isOrgContext: true, + enableSubscription: {% if enable_subscription %} true {% else %} false {% endif %}, } } diff --git a/seahub/organizations/urls.py b/seahub/organizations/urls.py index 88b1ff52917..7cd2096eaf0 100644 --- a/seahub/organizations/urls.py +++ b/seahub/organizations/urls.py @@ -43,4 +43,6 @@ path('associate//', org_associate, name='org_associate'), path('samlconfig/', react_fake_view, name='saml_config'), + + re_path(r'^subscription/$', react_fake_view, name='org_subscription'), ] diff --git a/seahub/organizations/views.py b/seahub/organizations/views.py index d7dccc96724..a2fb49d4ed2 100644 --- a/seahub/organizations/views.py +++ b/seahub/organizations/views.py @@ -35,6 +35,7 @@ ORG_ENABLE_ADMIN_CUSTOM_LOGO, ORG_ENABLE_ADMIN_CUSTOM_NAME, \ ORG_ENABLE_ADMIN_INVITE_USER from seahub.organizations.utils import get_or_create_invitation_link +from seahub.subscription.utils import subscription_check # Get an instance of a logger logger = logging.getLogger(__name__) @@ -260,6 +261,7 @@ def react_fake_view(request, **kwargs): 'group_id': group_id, 'invitation_link': invitation_link, 'enable_multi_adfs': ENABLE_MULTI_ADFS, + 'enable_subscription': subscription_check(), }) @login_required diff --git a/seahub/settings.py b/seahub/settings.py index a42118e845d..815d4a568d2 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -277,6 +277,7 @@ 'seahub.krb5_auth', 'seahub.django_cas_ng', 'seahub.seadoc', + 'seahub.subscription', ] diff --git a/seahub/subscription/__init__.py b/seahub/subscription/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seahub/subscription/settings.py b/seahub/subscription/settings.py new file mode 100644 index 00000000000..eacc941975f --- /dev/null +++ b/seahub/subscription/settings.py @@ -0,0 +1,10 @@ +from django.conf import settings + + +ENABLE_SUBSCRIPTION = getattr(settings, 'ENABLE_SUBSCRIPTION', False) +SUBSCRIPTION_SERVER_AUTH_KEY = getattr( + settings, 'SUBSCRIPTION_SERVER_AUTH_KEY', '') +SUBSCRIPTION_SERVER_URL = getattr( + settings, 'SUBSCRIPTION_SERVER_URL', '') + +SUBSCRIPTION_ORG_PREFIX = getattr(settings, 'SUBSCRIPTION_ORG_PREFIX', 'org_') diff --git a/seahub/subscription/urls.py b/seahub/subscription/urls.py new file mode 100644 index 00000000000..e6bf185ecd7 --- /dev/null +++ b/seahub/subscription/urls.py @@ -0,0 +1,7 @@ +from django.urls import re_path +from .views import subscription_view, subscription_pay_view + +urlpatterns = [ + re_path(r'^$', subscription_view, name="subscription"), + re_path(r'pay/$', subscription_pay_view, name="subscription-pay"), +] diff --git a/seahub/subscription/utils.py b/seahub/subscription/utils.py new file mode 100644 index 00000000000..f0aa0ecdc6d --- /dev/null +++ b/seahub/subscription/utils.py @@ -0,0 +1,90 @@ +import logging +import requests +from django.core.cache import cache + +from seahub.utils import normalize_cache_key +from seahub.utils import is_pro_version, is_org_context +from .settings import ENABLE_SUBSCRIPTION, SUBSCRIPTION_SERVER_AUTH_KEY, \ + SUBSCRIPTION_SERVER_URL, SUBSCRIPTION_ORG_PREFIX + +logger = logging.getLogger(__name__) + +SUBSCRIPTION_TOKEN_CACHE_KEY = 'SUBSCRIPTION_TOKEN' + + +def subscription_check(): + + if not is_pro_version() or not ENABLE_SUBSCRIPTION: + return False + + if not SUBSCRIPTION_SERVER_AUTH_KEY \ + or not SUBSCRIPTION_SERVER_URL: + logger.error('subscription relevant settings invalid.') + logger.error( + 'please check SUBSCRIPTION_SERVER_AUTH_KEY') + logger.error('SUBSCRIPTION_SERVER_URL: %s' % SUBSCRIPTION_SERVER_URL) + return False + + return True + + +def get_subscription_jwt_token(): + cache_key = normalize_cache_key(SUBSCRIPTION_TOKEN_CACHE_KEY) + jwt_token = cache.get(cache_key, None) + + if not jwt_token: + data = { + 'auth_key': SUBSCRIPTION_SERVER_AUTH_KEY, + } + url = SUBSCRIPTION_SERVER_URL.rstrip('/') + '/api/jwt-auth/' + response = requests.post(url, json=data) + if response.status_code >= 400: + raise ConnectionError(response.status_code, response.text) + + response_dic = response.json() + jwt_token = response_dic.get('token') + cache.set(cache_key, jwt_token, 3000) + + return jwt_token + + +def clear_subscription_jwt_token(): + cache_key = normalize_cache_key(SUBSCRIPTION_TOKEN_CACHE_KEY) + cache.set(cache_key, None) + + +def get_subscription_api_headers(): + jwt_token = get_subscription_jwt_token() + headers = { + 'Authorization': 'JWT ' + jwt_token, + 'Content-Type': 'application/json', + } + + return headers + + +def handler_subscription_api_response(response): + if response.status_code == 403: + clear_subscription_jwt_token() + response.status_code = 500 + + return response + + +def subscription_permission_check(request): + if is_org_context(request): + is_org_staff = request.user.org.is_staff + if not is_org_staff: + return False + + return True + + +def get_customer_id(request): + if is_org_context(request): + org_id = request.user.org.org_id + customer_id = SUBSCRIPTION_ORG_PREFIX + str(org_id) + else: + customer_id = request.user.username + + return customer_id diff --git a/seahub/subscription/views.py b/seahub/subscription/views.py new file mode 100644 index 00000000000..08b6bb8763c --- /dev/null +++ b/seahub/subscription/views.py @@ -0,0 +1,91 @@ +# Copyright (c) 2012-2020 Seafile Ltd. +# encoding: utf-8 +import requests +import logging +from django.shortcuts import render +from django.utils.translation import gettext as _ +from django.http import HttpResponseRedirect + +from seahub.utils import render_error, is_org_context +from seahub.auth.decorators import login_required +from .utils import subscription_check, subscription_permission_check, \ + get_subscription_api_headers, get_customer_id, handler_subscription_api_response +from .settings import SUBSCRIPTION_SERVER_URL + +logger = logging.getLogger(__name__) + + +@login_required +def subscription_view(request): + """ + subscription + """ + if not subscription_check(): + return render_error(request, _('Feature is not enabled.')) + + if is_org_context(request): + return render_error(request, _('Permission denied.')) + + return_dict = {} + template = 'subscription/subscription_react.html' + return render(request, template, return_dict) + + +@login_required +def subscription_pay_view(request): + """ + subscription + """ + if not subscription_check(): + return render_error(request, _('Feature is not enabled.')) + + if not subscription_permission_check(request): + error_msg = _('Permission denied.') + return render_error(request, error_msg) + + plan_id = request.GET.get('plan_id') + payment_source = request.GET.get('payment_source') + payment_type = request.GET.get('payment_type') + count = request.GET.get('count') + asset_quota = request.GET.get('asset_quota') + total_amount = request.GET.get('total_amount') + + # main + try: + customer_id = get_customer_id(request) + headers = get_subscription_api_headers() + + data = { + 'customer_id': customer_id, + 'plan_id': plan_id, + 'payment_source': payment_source, + 'payment_type': payment_type, + 'total_amount': total_amount, + } + if count: + data['count'] = count + if asset_quota: + data['asset_quota'] = asset_quota + + url = SUBSCRIPTION_SERVER_URL.rstrip('/') + '/api/seafile/subscription/pay/' + response = requests.post(url, json=data, headers=headers) + response = handler_subscription_api_response(response) + response_dic = response.json() + if response.status_code >= 400: + error_msg = response_dic.get('error_msg') + if 'non_field_errors' in response_dic and response_dic['non_field_errors']: + error_msg = response_dic['non_field_errors'][0] + return render_error(request, error_msg) + + use_redirect_url = response_dic.get('use_redirect_url') + redirect_url = response_dic.get('redirect_url') + + if use_redirect_url and redirect_url: + return HttpResponseRedirect(redirect_url) + + if not use_redirect_url: + return render(request, 'subscription/pay_result.html', {'info': '支付成功'}) + except Exception as e: + logger.error(e) + error_msg = _('Internal Server Error') + return render_error(request, error_msg) diff --git a/seahub/templates/base_for_react.html b/seahub/templates/base_for_react.html index c79d6b088da..952c0fc9065 100644 --- a/seahub/templates/base_for_react.html +++ b/seahub/templates/base_for_react.html @@ -147,6 +147,7 @@ enableSeafileAI: {% if enable_seafile_ai %} true {% else %} false {% endif %}, canSetExProps: {% if can_set_ex_props %} true {% else %} false {% endif %}, enableSeaTableIntegration: {% if enable_seatable_integration %} true {% else %} false {% endif %}, + isOrgContext: {% if org is not None %} true {% else %} false {% endif %}, } }; diff --git a/seahub/templates/subscription/pay_result.html b/seahub/templates/subscription/pay_result.html new file mode 100644 index 00000000000..6a6a725035e --- /dev/null +++ b/seahub/templates/subscription/pay_result.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block main_panel %} +
    +

    {{ info }}

    +
    +{% endblock %} + diff --git a/seahub/templates/subscription/subscription_react.html b/seahub/templates/subscription/subscription_react.html new file mode 100644 index 00000000000..4be94901c56 --- /dev/null +++ b/seahub/templates/subscription/subscription_react.html @@ -0,0 +1,19 @@ +{% extends 'base_for_react.html' %} +{% load seahub_tags avatar_tags i18n %} +{% load render_bundle from webpack_loader %} + +{% block sub_title %}付费管理 - {% endblock %} + +{% block extra_style %} +{% render_bundle 'subscription' 'css' %} +{% endblock %} + +{% block extra_script %} + +{% render_bundle 'subscription' 'js' %} +{% endblock %} diff --git a/seahub/urls.py b/seahub/urls.py index 3d5bf276ecf..2e46b98b26d 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -141,7 +141,7 @@ from seahub.api2.endpoints.admin.device_errors import AdminDeviceErrors from seahub.api2.endpoints.admin.users import AdminUsers, AdminUser, AdminUserResetPassword, AdminAdminUsers, \ AdminUserGroups, AdminUserShareLinks, AdminUserUploadLinks, AdminUserBeSharedRepos, \ - AdminLDAPUsers, AdminSearchUser, AdminUpdateUserCcnetEmail + AdminLDAPUsers, AdminSearchUser, AdminUpdateUserCcnetEmail, AdminUserList from seahub.api2.endpoints.admin.device_trusted_ip import AdminDeviceTrustedIP from seahub.api2.endpoints.admin.libraries import AdminLibraries, AdminLibrary, \ AdminSearchLibrary @@ -163,7 +163,7 @@ AdminImportUsers from seahub.api2.endpoints.admin.operation_logs import AdminOperationLogs from seahub.api2.endpoints.admin.organizations import AdminOrganizations, \ - AdminOrganization, AdminSearchOrganization + AdminOrganization, AdminSearchOrganization, AdminOrganizationsBaseInfo from seahub.api2.endpoints.admin.institutions import AdminInstitutions, AdminInstitution from seahub.api2.endpoints.admin.institution_users import AdminInstitutionUsers, AdminInstitutionUser from seahub.api2.endpoints.admin.org_users import AdminOrgUsers, AdminOrgUser @@ -203,6 +203,7 @@ from seahub.ai.apis import LibrarySdocIndexes, Search, LibrarySdocIndex, TaskStatus, \ LibraryIndexState, QuestionAnsweringSearchInLibrary, FileDownloadToken +from seahub.api2.endpoints.subscription import SubscriptionView, SubscriptionPlansView, SubscriptionLogsView urlpatterns = [ path('accounts/', include('seahub.base.registration_urls')), @@ -581,6 +582,7 @@ re_path(r'^api/v2.1/admin/ldap-users/$', AdminLDAPUsers.as_view(), name='api-v2.1-admin-ldap-users'), re_path(r'^api/v2.1/admin/search-user/$', AdminSearchUser.as_view(), name='api-v2.1-admin-search-user'), re_path(r'^api/v2.1/admin/update-user-ccnet-email/$', AdminUpdateUserCcnetEmail.as_view(), name='api-v2.1-admin-update-user-ccnet-email'), + re_path(r'^api/v2.1/admin/user-list/$', AdminUserList.as_view(), name='api-v2.1-admin-user-list'), # [^...] Matches any single character not in brackets # + Matches between one and unlimited times, as many times as possible @@ -672,6 +674,7 @@ ## admin::organizations re_path(r'^api/v2.1/admin/organizations/$', AdminOrganizations.as_view(), name='api-v2.1-admin-organizations'), + re_path(r'^api/v2.1/admin/organizations-basic-info/$', AdminOrganizationsBaseInfo.as_view(), name='api-v2.1-admin-organizations-basic-info'), re_path(r'^api/v2.1/admin/search-organization/$', AdminSearchOrganization.as_view(), name='api-v2.1-admin-Search-organization'), re_path(r'^api/v2.1/admin/organizations/(?P\d+)/$', AdminOrganization.as_view(), name='api-v2.1-admin-organization'), re_path(r'^api/v2.1/admin/organizations/(?P\d+)/users/$', AdminOrgUsers.as_view(), name='api-v2.1-admin-org-users'), @@ -990,3 +993,11 @@ re_path(r'^client-sso/(?P[^/]+)/$', client_sso, name="client_sso"), re_path(r'^client-sso/(?P[^/]+)/complete/$', client_sso_complete, name="client_sso_complete"), ] + +if getattr(settings, 'ENABLE_SUBSCRIPTION', False): + urlpatterns += [ + re_path(r'^subscription/', include('seahub.subscription.urls')), + re_path(r'^api/v2.1/subscription/$', SubscriptionView.as_view(), name='api-v2.1-subscription'), + re_path(r'^api/v2.1/subscription/plans/$', SubscriptionPlansView.as_view(), name='api-v2.1-subscription-plans'), + re_path(r'^api/v2.1/subscription/logs/$', SubscriptionLogsView.as_view(), name='api-v2.1-subscription-logs'), + ]