diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml index a3aec92a36..302bc2d7bc 100644 --- a/.github/workflows/npm-test.yml +++ b/.github/workflows/npm-test.yml @@ -33,11 +33,8 @@ jobs: - name: Add "Node ${{ steps.resolved-node-version.outputs.NODE_VERSION }}" to summary run: echo "${{ matrix.node-version }} → **${{ steps.resolved-node-version.outputs.NODE_VERSION }}**" >> "$GITHUB_STEP_SUMMARY" - # Set up Chrome, for the unit tests - - uses: browser-actions/setup-chrome@latest - - run: chrome --version - - # Cache node_modules, keyed on os, node version, package-lock, and patches + # Cache: Use cache for node_modules + # Keyed on os, node version, package-lock, and patches - uses: actions/cache@v4 name: Check for cached node_modules id: cache-nodemodules @@ -47,37 +44,37 @@ jobs: path: node_modules key: ${{ runner.os }}-build-${{ env.cache-name }}-node-v${{ steps.resolved-node-version.outputs.NODE_VERSION }}-${{ hashFiles('**/package-lock.json', 'patches/**/*.patch') }} - # Cache hit: node_modules is copied from a previous run. Run copy-fonts - - if: steps.cache-nodemodules.outputs.cache-hit == 'true' - name: Run copy-fonts (if using cached node_modules) + # Cache hit: If the cache key matches, + # /node_modules/ will have been copied from a previous run. + # (Run the post-install step, `npm run copy-fonts`) + - name: Run copy-fonts (if using cached node_modules) + if: steps.cache-nodemodules.outputs.cache-hit == 'true' run: npm run copy-fonts - # Cache miss: Run npm install, which does copy-fonts as post-install step - - if: steps.cache-nodemodules.outputs.cache-hit != 'true' - name: Install JavaScript dependencies (npm install) + # Cache miss: If node_modules has not been cached, + # `npm install` + # (This includes `npm run copy-fonts` as post-install step) + - name: Install JavaScript dependencies (npm install) + if: steps.cache-nodemodules.outputs.cache-hit != 'true' run: npm install - # Build the app! + # Check that the full build succeeds - name: Build Prod run: SKIP_TS_CHECK=true npm run build - # Run TypeScript Checks and ESLint - - name: Check TypeScript # Separated for visibility + # Check for TypeScript errors + - name: Check TypeScript run: npm run check-types + + # Check for ESLint messages (errors only) - name: Check ESLint, errors only run: npm run lint -- --quiet - # Unit Tests - - name: Build Tests - run: npx webpack --config webpack/test.config.js + # Run the Unit test suite (formbuilder and helpers) + - name: Run unit tests and xlform tests + run: npx jest --config ./jsapp/jest/unit.config.ts --ci - - name: Run Tests, with mocha-chrome - run: npx mocha-chrome test/tests.html --chrome-launcher.connectionPollInterval=5000 - # This step takes less than 1 minute if it succeeds, but will hang for - # 6 hours if it fails with 'No inspectable targets' - # Timeout early to make it easier to manually re-run jobs. - # Tracking issue: https://github.com/kobotoolbox/kpi/issues/4337 - timeout-minutes: 1 + # Run the Jest test suite (React components) + - name: Run component tests with Jest + run: npx jest --config ./jsapp/jest/jest.config.ts --ci - - name: Run components tests with Jest - run: npm run jest diff --git a/hub/admin/extend_user.py b/hub/admin/extend_user.py index e18e80493f..b962f2a1ad 100644 --- a/hub/admin/extend_user.py +++ b/hub/admin/extend_user.py @@ -25,6 +25,7 @@ from kobo.apps.trash_bin.models.account import AccountTrash from kobo.apps.trash_bin.utils import move_to_trash from kpi.models.asset import AssetDeploymentStatus + from .filters import UserAdvancedSearchFilter from .mixins import AdvancedSearchMixin @@ -167,9 +168,7 @@ class ExtendedUserAdmin(AdvancedSearchMixin, UserAdmin): actions = ['remove', 'delete'] class Media: - css = { - 'all': ('admin/css/inline_as_fieldset.css',) - } + css = {'all': ('admin/css/inline_as_fieldset.css',)} @admin.action(description='Remove selected users (delete everything but their username)') def remove(self, request, queryset, **kwargs): @@ -293,9 +292,13 @@ def _filter_queryset_for_organization_user(self, queryset): """ Displays only users whose organization has a single member. """ - return queryset.annotate( - user_count=Count('organizations_organization__organization_users') - ).filter(user_count__lte=1).order_by('username') + return ( + queryset.annotate( + user_count=Count('organizations_organization__organization_users') + ) + .filter(user_count__lte=1) + .order_by('username') + ) def _remove_or_delete( self, diff --git a/jsapp/jest/coffeeTransformer.js b/jsapp/jest/coffeeTransformer.js new file mode 100644 index 0000000000..8e2b6b4b96 --- /dev/null +++ b/jsapp/jest/coffeeTransformer.js @@ -0,0 +1,44 @@ +const coffeescript = require('coffeescript'); +const createCacheKeyFunction = require('@jest/create-cache-key-function').default; +/** + * @typedef {import('@jest/transform').SyncTransformer} SyncTransformer + * @typedef {import('@jest/transform').TransformedSource} TransformedSource + */ + +/** + * Transform CoffeeScript files for Jest + * See: https://jestjs.io/docs/code-transformation + * + * @implements { SyncTransformer } + */ +module.exports = { + /** + * Process coffee files + * + * @param {string} sourceText + * @param {string} filename + * @returns {TransformedSource} + */ + process(sourceText, filename) { + const {js, sourceMap, v3SourceMap } = coffeescript.compile( + sourceText, + // ☕ CoffeeScript 1.12.7 compiler options + { + // 📜 For source maps + filename, + sourceMap: true, + + // đŸ“Ļ Same default as coffee-loader + bare: true, + } + ); + return { + code: js, + map: JSON.parse(v3SourceMap), + }; + }, + + getCacheKey: createCacheKeyFunction( + [__filename, require.resolve('coffeescript')], + ), +}; diff --git a/jsapp/jest/setupUnitTest.ts b/jsapp/jest/setupUnitTest.ts new file mode 100644 index 0000000000..ccb49175ce --- /dev/null +++ b/jsapp/jest/setupUnitTest.ts @@ -0,0 +1,17 @@ +import chai from 'chai'; +import $ from 'jquery'; + +// Polyfill global fetch (for Node 20 and older) +import 'whatwg-fetch'; + +// Add global t() mock (see /static/js/global_t.js) +global.t = (str: string) => str; + +// @ts-expect-error: ℹī¸ Add chai global for BDD-style tests +global.chai = chai; + +// @ts-expect-error: ℹī¸ Use chai's version of `expect` +global.expect = chai.expect; + +// @ts-expect-error: ℹī¸ Add jQuery globals for xlform code +global.jQuery = global.$ = $; diff --git a/jsapp/jest/unit.config.ts b/jsapp/jest/unit.config.ts new file mode 100644 index 0000000000..1718ea094a --- /dev/null +++ b/jsapp/jest/unit.config.ts @@ -0,0 +1,58 @@ +import type {Config} from 'jest'; +import {defaults} from 'jest-config'; + +// Config to run ☕ unit tests using the Jest runner +// +// To run the unit tests: 🏃 +// +// npx jest --config ./jsapp/jest/unit.config.ts +// + +const config: Config = { + // Naming convention (*.tests.*) + testMatch: ['**/?(*.)+(tests).(js|jsx|ts|tsx|es6|coffee)'], + + // Where to find tests. = 'kpi/jsapp/jest' + roots: [ + '/../js/', // unit tests 🛠ī¸ 'jsapp/js/**/*.tests.{ts,es6}' + '/../../test/', // xlform/coffee ☕ 'test/**/*.tests.coffee' + ], + + // Where to resolve module imports + moduleNameMapper: { + // ℹī¸ same aliases as in webpack.common.js (module.resolve.alias) + '^jsapp/(.+)$': '/../$1', // 📁 'jsapp/*' + '^js/(.*)$': '/../js/$1', // 📁 'js/*' + '^test/(.*)$': '/../../test/$1', // 📁 'test/*' + '^utils$': '/../js/utils', // 📄 'utils' + // 🎨 mock all CSS modules imported (styles.root = 'root') + '\\.(css|scss)$': 'identity-obj-proxy', + }, + + // Extensions to try in order (for import statements with no extension) + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'es6', 'coffee'], + + // Transformers (SWC for JS/TS, CoffeeScript for .coffee) + transform: { + '^.+\\.(js|jsx|ts|tsx|es6)$': '@swc/jest', + '^.+\\.coffee$': '/coffeeTransformer.js', + }, + + // Exclude these files, even if they contain tests + testPathIgnorePatterns: [ + 'test/xlform/integration.tests.coffee$', // 📄 skipped in `ee98aebe631b` + ...defaults.testPathIgnorePatterns, // đŸ“Ļ exclude '/node_modules/' + ], + + // Set up test environment + testEnvironment: 'jsdom', + + // Make Chai and jQuery globals available in the test environment + setupFilesAfterEnv: ['/setupUnitTest.ts'], + + // Appearance options (for console output) + verbose: true, + displayName: {name: 'UNIT', color: 'black'}, +}; + +export default config; diff --git a/jsapp/js/account/accountSidebar.module.scss b/jsapp/js/account/accountSidebar.module.scss index b068805e9c..7faebff017 100644 --- a/jsapp/js/account/accountSidebar.module.scss +++ b/jsapp/js/account/accountSidebar.module.scss @@ -1,8 +1,7 @@ @use 'scss/colors'; .accountSidebar { - margin-top: 10px; - padding: 14px 20px 20px 0; + padding: 24px 20px 20px 0; overflow-y: auto; overflow-x: hidden; flex: 1; @@ -33,7 +32,7 @@ color: inherit; padding: 0 0 0 18px !important; border-left: 3px solid transparent; - margin-bottom: 18px; + margin-bottom: 16px; cursor: pointer; .newLinkLabelText { @@ -59,7 +58,7 @@ } .subhead { - padding: 0 0 0 18px !important; + padding: 0 0 0 20px !important; margin-bottom: 16px; font-size: 12px; font-weight: 600; diff --git a/jsapp/js/account/accountSidebar.tsx b/jsapp/js/account/accountSidebar.tsx index a8ef94ca45..21f11b3003 100644 --- a/jsapp/js/account/accountSidebar.tsx +++ b/jsapp/js/account/accountSidebar.tsx @@ -1,4 +1,4 @@ -import React, {useContext, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {NavLink} from 'react-router-dom'; import {observer} from 'mobx-react-lite'; import styles from './accountSidebar.module.scss'; @@ -7,9 +7,16 @@ import Icon from 'js/components/common/icon'; import type {IconName} from 'jsapp/fonts/k-icons'; import Badge from '../components/common/badge'; import subscriptionStore from 'js/account/subscriptionStore'; +import envStore from 'js/envStore'; import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; -import {useOrganizationQuery} from './stripe.api'; +import { + useOrganizationQuery, + OrganizationUserRole, +} from 'js/account/organization/organizationQuery'; +import {useFeatureFlag, FeatureFlag} from 'js/featureFlags'; +import {getSimpleMMOLabel} from './organization/organization.utils'; +import LoadingSpinner from 'js/components/common/loadingSpinner'; interface AccountNavLinkProps { iconName: IconName; @@ -36,61 +43,167 @@ function AccountNavLink(props: AccountNavLinkProps) { ); } -function AccountSidebar() { - const [showPlans, setShowPlans] = useState(false); +// TODO: When we no longer hide the MMO sidebar behind a feature flag, +// the check for org ownership can be removed as it will be logically entailed +// by the org being single-user. +function renderSingleUserOrgSidebar( + isStripeEnabled: boolean, + showAddOnsLink: boolean, + isOwner: boolean +) { + return ( + + ); +} + +function renderMmoSidebar( + userRole: OrganizationUserRole, + isStripeEnabled: boolean, + showAddOnsLink: boolean, + mmoLabel: string +) { + const showBillingRoutes = + userRole === OrganizationUserRole.owner && isStripeEnabled; + const hasAdminPrivileges = [ + OrganizationUserRole.admin, + OrganizationUserRole.owner, + ].includes(userRole); + return ( + + ); +} + +function AccountSidebar() { + const [isStripeEnabled, setIsStripeEnabled] = useState(false); + const enableMMORoutes = useFeatureFlag(FeatureFlag.mmosEnabled); const orgQuery = useOrganizationQuery(); useWhenStripeIsEnabled(() => { if (!subscriptionStore.isInitialised) { subscriptionStore.fetchSubscriptionInfo(); } - setShowPlans(true); + setIsStripeEnabled(true); }, [subscriptionStore.isInitialised]); const showAddOnsLink = useMemo(() => { return !subscriptionStore.planResponse.length; }, [subscriptionStore.isInitialised]); - return ( - + const mmoLabel = getSimpleMMOLabel( + envStore.data, + subscriptionStore.activeSubscriptions[0] + ); + + if (!orgQuery.data) { + return ; + } + + if (orgQuery.data.is_mmo && enableMMORoutes) { + return renderMmoSidebar( + orgQuery.data?.request_user_role, + isStripeEnabled, + showAddOnsLink, + mmoLabel + ); + } + + return renderSingleUserOrgSidebar( + isStripeEnabled, + showAddOnsLink, + orgQuery.data.is_owner ); } diff --git a/jsapp/js/account/add-ons/addOnList.component.tsx b/jsapp/js/account/add-ons/addOnList.component.tsx deleted file mode 100644 index 52401086db..0000000000 --- a/jsapp/js/account/add-ons/addOnList.component.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import useWhen from 'js/hooks/useWhen.hook'; -import subscriptionStore from 'js/account/subscriptionStore'; -import { - Price, - Organization, - Product, - SubscriptionChangeType, - SubscriptionInfo, -} from 'js/account/stripe.types'; -import { - getSubscriptionChangeDetails, - isAddonProduct, - isChangeScheduled, - isRecurringAddonProduct, - processCheckoutResponse, -} from 'js/account/stripe.utils'; -import {formatDate} from 'js/utils'; -import {postCustomerPortal} from 'js/account/stripe.api'; -import styles from './addOnList.module.scss'; -import BillingButton from 'js/account/plans/billingButton.component'; -import Badge, {BadgeColor} from 'js/components/common/badge'; - -/** - * A table of add-on products along with buttons to purchase/manage them. - * @TODO Until one-time add-ons are complete, this only displays recurring add-ons. - */ -const AddOnList = (props: { - products: Product[]; - organization: Organization | null; - isBusy: boolean; - setIsBusy: (value: boolean) => void; - onClickBuy: (price: Price) => void; -}) => { - const [subscribedAddOns, setSubscribedAddOns] = useState( - [] - ); - const [subscribedPlans, setSubscribedPlans] = useState( - [] - ); - const [activeSubscriptions, setActiveSubscriptions] = useState< - SubscriptionInfo[] - >([]); - const [addOnProducts, setAddOnProducts] = useState([]); - - /** - * Extract the add-on products and prices from the list of all products - */ - useEffect(() => { - if (!props.products) { - return; - } - const addonProducts = props.products - .filter(isAddonProduct) - // TODO: remove the next line when one-time add-ons are ready - .filter(isRecurringAddonProduct) - .map((product) => { - return { - ...product, - prices: product.prices.filter((price) => price.active), - }; - }); - setAddOnProducts(addonProducts); - }, [props.products]); - - const currentAddon = useMemo(() => { - if (subscriptionStore.addOnsResponse.length) { - return subscriptionStore.addOnsResponse[0]; - } else { - return null; - } - }, [subscriptionStore.isInitialised]); - - const subscriptionUpdate = useMemo(() => { - return getSubscriptionChangeDetails(currentAddon, props.products); - }, [currentAddon, props.products]); - - useWhen( - () => subscriptionStore.isInitialised, - () => { - setSubscribedAddOns(subscriptionStore.addOnsResponse); - setSubscribedPlans(subscriptionStore.planResponse); - setActiveSubscriptions(subscriptionStore.activeSubscriptions); - }, - [] - ); - - const isSubscribedAddOnPrice = useCallback( - (price: Price) => - isChangeScheduled(price, activeSubscriptions) || - subscribedAddOns.some( - (subscription) => subscription.items[0].price.id === price.id - ), - [subscribedAddOns] - ); - - const handleCheckoutError = () => { - props.setIsBusy(false); - }; - - const onClickManage = (price?: Price) => { - if (!props.organization || props.isBusy) { - return; - } - props.setIsBusy(true); - postCustomerPortal(props.organization.id, price?.id) - .then(processCheckoutResponse) - .catch(handleCheckoutError); - }; - - const renderUpdateBadge = (price: Price) => { - if (!(subscriptionUpdate && isSubscribedAddOnPrice(price))) { - return null; - } - - let color: BadgeColor; - let label; - switch (subscriptionUpdate.type) { - case SubscriptionChangeType.CANCELLATION: - color = 'light-red'; - label = t('Ends on ##cancel_date##').replace( - '##cancel_date##', - formatDate(subscriptionUpdate.date) - ); - break; - case SubscriptionChangeType.RENEWAL: - color = 'light-blue'; - label = t('Renews on ##renewal_date##').replace( - '##renewal_date##', - formatDate(subscriptionUpdate.date) - ); - break; - case SubscriptionChangeType.PRODUCT_CHANGE: - if (currentAddon?.items[0].price.product.id === price.product) { - color = 'light-amber'; - label = t('Ends on ##end_date##').replace( - '##end_date##', - formatDate(subscriptionUpdate.date) - ); - } else { - color = 'light-teal'; - label = t('Starts on ##start_date##').replace( - '##start_date##', - formatDate(subscriptionUpdate.date) - ); - } - break; - default: - return null; - } - return ; - }; - - if (!addOnProducts.length || subscribedPlans.length || !props.organization) { - return null; - } - - return ( - - - - {addOnProducts.map((product) => - product.prices.map((price) => ( - - - - - - )) - )} - -
- -

- {t( - `Add-ons can be added to your Community plan to increase your usage limits. If you are approaching or - have reached the usage limits included with your plan, increase your limits with add-ons to continue - data collection.` - )} -

-
-
-
- {product.name} - {renderUpdateBadge(price)} -
-
{price.human_readable_price}
-
-
- {isSubscribedAddOnPrice(price) && ( - - )} - {!isSubscribedAddOnPrice(price) && ( - props.onClickBuy(price)} - isFullWidth - /> - )} -
- ); -}; - -export default AddOnList; diff --git a/jsapp/js/account/add-ons/addOnList.module.scss b/jsapp/js/account/add-ons/addOnList.module.scss deleted file mode 100644 index c8dc8c9864..0000000000 --- a/jsapp/js/account/add-ons/addOnList.module.scss +++ /dev/null @@ -1,63 +0,0 @@ -@use 'scss/colors'; -@use 'scss/breakpoints'; -@use 'js/account/plans/plan.module'; - -.header { - font-size: 18px; - font-weight: 700; - color: colors.$kobo-storm; - text-transform: uppercase; -} - -.caption { - text-align: start; -} - -.table { - table-layout: fixed; - border-collapse: collapse; - border-spacing: 0; - width: 100%; - max-width: plan.$plans-page-max-width; - - tr { - border-top: 1px solid colors.$kobo-light-storm; - - &:last-child { - border-bottom: 1px solid colors.$kobo-light-storm; - } - } - - td { - padding-block: 1em; - font-weight: 600; - font-size: 16px; - } -} - -.productAndPrice { - line-height: 2em; -} - -.price { - color: colors.$kobo-gray-700; -} - -.productName { - margin-right: 12px; -} - -.buy { - width: 140px; - padding-left: 20px; -} - -@include breakpoints.breakpoint(mediumAndUp) { - .productAndPrice { - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - gap: 0 20px; - } -} diff --git a/jsapp/js/account/addOns/addOnList.component.tsx b/jsapp/js/account/addOns/addOnList.component.tsx new file mode 100644 index 0000000000..270fbfc6c1 --- /dev/null +++ b/jsapp/js/account/addOns/addOnList.component.tsx @@ -0,0 +1,243 @@ +import React, {useContext, useEffect, useState} from 'react'; +import useWhen from 'js/hooks/useWhen.hook'; +import subscriptionStore from 'js/account/subscriptionStore'; +import type { + Price, + Product, + SubscriptionInfo, + OneTimeAddOn, +} from 'js/account/stripe.types'; +import {isAddonProduct} from 'js/account/stripe.utils'; +import styles from './addOnList.module.scss'; +import {OneTimeAddOnRow} from 'jsapp/js/account/addOns/oneTimeAddOnRow.component'; +import type {BadgeColor} from 'jsapp/js/components/common/badge'; +import Badge from 'jsapp/js/components/common/badge'; +import {formatDate} from 'js/utils'; +import {OneTimeAddOnsContext} from 'jsapp/js/account/useOneTimeAddonList.hook'; +import {FeatureFlag, useFeatureFlag} from 'jsapp/js/featureFlags'; +import type {Organization} from 'js/account/organization/organizationQuery'; + +/** + * A table of add-on products along with dropdowns to purchase them. + */ +const AddOnList = (props: { + products: Product[]; + organization: Organization | null; + isBusy: boolean; + setIsBusy: (value: boolean) => void; + onClickBuy: (price: Price) => void; +}) => { + const [subscribedAddOns, setSubscribedAddOns] = useState( + [] + ); + const [subscribedPlans, setSubscribedPlans] = useState( + [] + ); + const [activeSubscriptions, setActiveSubscriptions] = useState< + SubscriptionInfo[] + >([]); + const [addOnProducts, setAddOnProducts] = useState([]); + const oneTimeAddOnsContext = useContext(OneTimeAddOnsContext); + const areOneTimeAddonsEnabled = useFeatureFlag(FeatureFlag.oneTimeAddonsEnabled); + const oneTimeAddOnSubscriptions = oneTimeAddOnsContext.oneTimeAddOns; + const oneTimeAddOnProducts = addOnProducts.filter( + (product) => product.metadata.product_type === 'addon_onetime' + ); + const recurringAddOnProducts = addOnProducts.filter( + (product) => product.metadata.product_type === 'addon' + ); + const showRecurringAddons = !subscribedPlans.length && !!recurringAddOnProducts.length; + const showOneTimeAddons = areOneTimeAddonsEnabled && !!oneTimeAddOnProducts.length; + + /** + * Extract the add-on products and prices from the list of all products + */ + useEffect(() => { + if (!props.products) { + return; + } + const addonProducts = props.products + .filter((product) => isAddonProduct(product)) + .map((product) => { + return { + ...product, + prices: product.prices.filter((price) => price.active), + }; + }); + setAddOnProducts(addonProducts); + }, [props.products]); + + useWhen( + () => subscriptionStore.isInitialised, + () => { + setSubscribedAddOns(subscriptionStore.addOnsResponse); + setSubscribedPlans(subscriptionStore.planResponse); + setActiveSubscriptions(subscriptionStore.activeSubscriptions); + }, + [] + ); + + if (!addOnProducts.length || !props.organization) { + return null; + } + + function ActivePreviousAddons( + addOns: SubscriptionInfo[], + oneTimeAddOns: OneTimeAddOn[], + activeStatus: string, + available: boolean, + label: string, + badgeLabel: string, + color: BadgeColor + ) { + return ( + + + + {addOns.map((product) => { + if (product.status === activeStatus) { + return ( + + + + + ); + } + return null; + })} + {oneTimeAddOns.map((oneTimeAddOn: OneTimeAddOn) => { + if (oneTimeAddOn.is_available === available) { + return ( + + + + + ); + } + return null; + })} + +
+ +
+ + {product.items[0].price.product.name} + + +

+ {t('Added on ##date##').replace( + '##date##', + formatDate(product.created) + )} +

+
+ {product.items[0].price.human_readable_price + .replace('USD/month', '') + .replace('USD/year', '')} +
+ + {t('##name## x ##quantity##') + .replace( + '##name##', + oneTimeAddOnProducts.find( + (product) => product.id === oneTimeAddOn.product + )?.name || label + ) + .replace( + '##quantity##', + oneTimeAddOn.quantity.toString() + )} + + +

+ {t('Added on ##date##').replace( + '##date##', + formatDate(oneTimeAddOn.created) + )} +

+
+ {'$##price##'.replace( + '##price##', + ( + (oneTimeAddOn.quantity * + (oneTimeAddOnProducts.find( + (product) => product.id === oneTimeAddOn.product + )?.prices[0].unit_amount || 0)) / + 100 + ).toFixed(2) + )} +
+ ); + } + return ( + <> + + + + {showRecurringAddons && ( + product.id).join('-')} + products={recurringAddOnProducts} + isBusy={props.isBusy} + setIsBusy={props.setIsBusy} + subscribedAddOns={subscribedAddOns} + activeSubscriptions={activeSubscriptions} + organization={props.organization} + /> + )} + {showOneTimeAddons && ( + product.id).join('-')} + products={oneTimeAddOnProducts} + isBusy={props.isBusy} + setIsBusy={props.setIsBusy} + subscribedAddOns={subscribedAddOns} + activeSubscriptions={activeSubscriptions} + organization={props.organization} + /> + )} + +
+ +

+ {t( + `Add-ons can be added to your Community plan to increase your usage limits. If you are approaching or + have reached the usage limits included with your plan, increase your limits with add-ons to continue + data collection.` + )} +

+
+ {subscribedAddOns.some((product) => product.status === 'active') || + oneTimeAddOnSubscriptions.some( + (oneTimeAddOns) => oneTimeAddOns.is_available + ) + ? ActivePreviousAddons( + subscribedAddOns, + oneTimeAddOnSubscriptions, + 'active', + true, + t('your active add-ons'), + t('Active'), + 'light-teal' + ) + : null} + + {subscribedAddOns.some((product) => product.status !== 'active') || + oneTimeAddOnSubscriptions.some( + (oneTimeAddOns) => !oneTimeAddOns.is_available + ) + ? ActivePreviousAddons( + subscribedAddOns, + oneTimeAddOnSubscriptions, + 'inactive', + false, + t('previous add-ons'), + t('Inactive'), + 'light-storm' + ) + : null} + + ); +}; + +export default AddOnList; diff --git a/jsapp/js/account/addOns/addOnList.module.scss b/jsapp/js/account/addOns/addOnList.module.scss new file mode 100644 index 0000000000..78a8d43303 --- /dev/null +++ b/jsapp/js/account/addOns/addOnList.module.scss @@ -0,0 +1,176 @@ +@use 'scss/colors'; +@use 'scss/breakpoints'; +@use 'js/account/plans/plan.module'; + +.header { + font-size: 18px; + font-weight: 700; + color: colors.$kobo-storm; + text-transform: uppercase; +} + +.caption { + text-align: start; +} + +.table { + table-layout: fixed; + border-collapse: collapse; + border-spacing: 0; + width: 100%; + max-width: plan.$plans-page-max-width; + + tr { + border-top: 1px solid colors.$kobo-light-storm; + + &:last-child { + border-bottom: 1px solid colors.$kobo-light-storm; + } + } + + td { + padding-block: 1em; + font-weight: 600; + font-size: 16px; + } +} + +.productAndPrice { + line-height: 2em; +} + +.price { + color: colors.$kobo-gray-700; + display: flex; + width: 100%; + justify-content: center; +} + +.oneTimePrice { + width: 100%; + text-align: center; + font-weight: 600; + font-size: 16px; + color: colors.$kobo-gray-700; + padding-right: 15px; +} + +.productName { + display: flex; + margin-right: 12px; + font-size: 16px; + font-weight: 600; +} + +.description { + display: none; +} + +.oneTime { + display: flex; + width: 100%; + justify-content: right; +} + +.purchasedAddOns { + padding-top: 2%; + padding-bottom: 2%; +} + +.activePrice { + text-align: right; + font-size: 16px; + font-weight: 600; +} + +.addonDescription { + display: flex; + font-size: 14px; + font-weight: 400; +} + +.row { + display: table; + width: 100%; + align-items: center; +} + +.buy { + width: 200px; +} + +.mobileView { + display: flex; + vertical-align: center; + flex: 1; + align-items: center; +} + +.fullScreen { + display: none; +} + +@media screen and (min-width: breakpoints.$b600) { + .table td:nth-child(2) { + margin-inline-end: 10em; + } + + .description { + display: flex; + font-size: 14px; + font-weight: 400; + } + + .productName { + display: table-cell; + width: 40%; + } + + .fullScreen, + .price { + display: table-cell; + width: 30%; + } + + .mobileView { + display: none; + } + + .oneTimePrice, + .buy { + display: table-cell; + vertical-align: middle; + justify-content: center; + } + + .oneTimePrice { + width: 50%; + } + + .oneTime { + justify-content: center; + + :last-child { + margin-left: 8px; + } + } + + .productName { + margin-right: 12px; + } + + .buy { + width: 140px; + padding-left: 20px; + } +} + +@include breakpoints.breakpoint(mediumAndUp) { + .productAndPrice { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + gap: 0 20px; + } +} diff --git a/jsapp/js/account/add-ons/addOns.component.tsx b/jsapp/js/account/addOns/addOns.component.tsx similarity index 100% rename from jsapp/js/account/add-ons/addOns.component.tsx rename to jsapp/js/account/addOns/addOns.component.tsx diff --git a/jsapp/js/account/add-ons/addOns.module.scss b/jsapp/js/account/addOns/addOns.module.scss similarity index 100% rename from jsapp/js/account/add-ons/addOns.module.scss rename to jsapp/js/account/addOns/addOns.module.scss diff --git a/jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx b/jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx new file mode 100644 index 0000000000..05c19d9735 --- /dev/null +++ b/jsapp/js/account/addOns/oneTimeAddOnRow.component.tsx @@ -0,0 +1,205 @@ +import styles from 'js/account/addOns/addOnList.module.scss'; +import React, {useMemo, useState} from 'react'; +import type { + Product, + SubscriptionInfo, +} from 'js/account/stripe.types'; +import KoboSelect3 from 'js/components/special/koboAccessibleSelect'; +import BillingButton from 'js/account/plans/billingButton.component'; +import {postCheckout, postCustomerPortal} from 'js/account/stripe.api'; +import {useDisplayPrice} from 'js/account/plans/useDisplayPrice.hook'; +import {isChangeScheduled} from 'js/account/stripe.utils'; +import type {Organization} from 'js/account/organization/organizationQuery'; + +interface OneTimeAddOnRowProps { + products: Product[]; + isBusy: boolean; + setIsBusy: (value: boolean) => void; + activeSubscriptions: SubscriptionInfo[]; + subscribedAddOns: SubscriptionInfo[]; + organization: Organization; +} + +const MAX_ONE_TIME_ADDON_PURCHASE_QUANTITY = 10; + +const quantityOptions = Array.from( + {length: MAX_ONE_TIME_ADDON_PURCHASE_QUANTITY}, + (_, zeroBasedIndex) => { + const index = (zeroBasedIndex + 1).toString(); + return {value: index, label: index}; + } +); + +export const OneTimeAddOnRow = ({ + products, + isBusy, + setIsBusy, + activeSubscriptions, + subscribedAddOns, + organization, +}: OneTimeAddOnRowProps) => { + const [selectedProduct, setSelectedProduct] = useState(products[0]); + const [quantity, setQuantity] = useState('1'); + const [selectedPrice, setSelectedPrice] = useState( + selectedProduct.prices[0] + ); + const displayPrice = useDisplayPrice(selectedPrice, parseInt(quantity)); + const priceOptions = useMemo( + () => + selectedProduct.prices.map((price) => { + return {value: price.id, label: price.recurring?.interval || 'me'}; + }), + [selectedProduct] + ); + + let displayName; + let description; + + if ( + selectedProduct.metadata.asr_seconds_limit || + selectedProduct.metadata.mt_characters_limit + ) { + displayName = t('NLP Package'); + description = t( + 'Increase your transcription minutes and translations characters.' + ); + } else if (selectedProduct.metadata.storage_bytes_limit) { + displayName = t('File Storage'); + description = t( + 'Get up to 50GB of media storage on a KoboToolbox public server.' + ); + } + + const isSubscribedAddOnPrice = useMemo( + () => + isChangeScheduled(selectedPrice, activeSubscriptions) || + subscribedAddOns.some( + (subscription) => subscription.items[0].price.id === selectedPrice.id + ), + [subscribedAddOns, selectedPrice] + ); + + const onChangeProduct = (productId: string) => { + const product = products.find((product) => product.id === productId); + if (product) { + setSelectedProduct(product); + setSelectedPrice(product.prices[0]); + } + }; + + const onChangePrice = (inputPrice: string | null) => { + if (inputPrice) { + const priceObject = selectedProduct.prices.find( + (price) => inputPrice === price.id + ); + if (priceObject) { + setSelectedPrice(priceObject); + } + } + }; + + const onChangeQuantity = (quantity: string | null) => { + if (quantity) { + setQuantity(quantity); + } + }; + + // TODO: Merge functionality of onClickBuy and onClickManage so we can unduplicate + // the billing button in priceTableCells + const onClickBuy = () => { + if (isBusy || !selectedPrice) { + return; + } + setIsBusy(true); + if (selectedPrice) { + postCheckout(selectedPrice.id, organization.id, parseInt(quantity)) + .then((response) => window.location.assign(response.url)) + .catch(() => setIsBusy(false)); + } + }; + + const onClickManage = () => { + if (isBusy || !selectedPrice) { + return; + } + setIsBusy(true); + postCustomerPortal(organization.id) + .then((response) => window.location.assign(response.url)) + .catch(() => setIsBusy(false)); + }; + + const priceTableCells = ( + <> +
+ {selectedPrice.recurring?.interval === 'year' + ? selectedPrice.human_readable_price + : displayPrice} +
+
+ {isSubscribedAddOnPrice && ( + + )} + {!isSubscribedAddOnPrice && ( + + )} +
+ + ); + + return ( + + + {displayName} + {description &&

{description}

} +
+ {priceTableCells} +
+ + +
+ { + return {value: product.id, label: product.name}; + })} + onChange={(productId) => onChangeProduct(productId as string)} + value={selectedProduct.id} + /> + {displayName === 'File Storage' ? ( + + ) : ( + + )} +
+ + + {priceTableCells} + + + ); +}; diff --git a/jsapp/js/account/addOns/updateBadge.component.tsx b/jsapp/js/account/addOns/updateBadge.component.tsx new file mode 100644 index 0000000000..5d69466201 --- /dev/null +++ b/jsapp/js/account/addOns/updateBadge.component.tsx @@ -0,0 +1,62 @@ +import { + BaseProduct, + Price, + SubscriptionChangeType, + SubscriptionInfo, +} from 'js/account/stripe.types'; +import Badge, {BadgeColor} from 'js/components/common/badge'; +import {formatDate} from 'js/utils'; +import React from 'react'; + +interface UpdateBadgeProps { + price: Price; + subscriptionUpdate: { + type: SubscriptionChangeType; + nextProduct: BaseProduct | null; + date: string; + }; + currentAddon: SubscriptionInfo | null; +} + +export const UpdateBadge = ({ + price, + subscriptionUpdate, + currentAddon, +}: UpdateBadgeProps) => { + let color: BadgeColor; + let label; + switch (subscriptionUpdate.type) { + case SubscriptionChangeType.CANCELLATION: + color = 'light-red'; + label = t('Ends on ##cancel_date##').replace( + '##cancel_date##', + formatDate(subscriptionUpdate.date) + ); + break; + case SubscriptionChangeType.RENEWAL: + color = 'light-blue'; + label = t('Renews on ##renewal_date##').replace( + '##renewal_date##', + formatDate(subscriptionUpdate.date) + ); + break; + case SubscriptionChangeType.PRODUCT_CHANGE: + if (currentAddon?.items[0].price.product.id === price.product) { + color = 'light-amber'; + label = t('Ends on ##end_date##').replace( + '##end_date##', + formatDate(subscriptionUpdate.date) + ); + } else { + color = 'light-teal'; + label = t('Starts on ##start_date##').replace( + '##start_date##', + formatDate(subscriptionUpdate.date) + ); + } + break; + default: + return null; + } + return ; +}; diff --git a/jsapp/js/account/billingContextProvider.component.tsx b/jsapp/js/account/billingContextProvider.component.tsx index 4a2a590542..ff2c79926c 100644 --- a/jsapp/js/account/billingContextProvider.component.tsx +++ b/jsapp/js/account/billingContextProvider.component.tsx @@ -1,8 +1,9 @@ import React, {ReactNode} from 'react'; +import { OneTimeAddOnsContext, useOneTimeAddOns } from './useOneTimeAddonList.hook'; import {UsageContext, useUsage} from 'js/account/usage/useUsage.hook'; import {ProductsContext, useProducts} from 'js/account/useProducts.hook'; import sessionStore from 'js/stores/session'; -import {useOrganizationQuery} from 'js/account/stripe.api'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; export const BillingContextProvider = (props: {children: ReactNode}) => { const orgQuery = useOrganizationQuery(); @@ -10,12 +11,16 @@ export const BillingContextProvider = (props: {children: ReactNode}) => { if (!sessionStore.isLoggedIn) { return <>{props.children}; } + const usage = useUsage(orgQuery.data?.id || null); const products = useProducts(); + const oneTimeAddOns = useOneTimeAddOns(); return ( - {props.children} + + {props.children} + ); diff --git a/jsapp/js/account/organization/MembersRoute.tsx b/jsapp/js/account/organization/MembersRoute.tsx new file mode 100644 index 0000000000..dc7708e35f --- /dev/null +++ b/jsapp/js/account/organization/MembersRoute.tsx @@ -0,0 +1,102 @@ +// Libraries +import React from 'react'; + +// Partial components +import PaginatedQueryUniversalTable from 'js/universalTable/paginatedQueryUniversalTable.component'; +import LoadingSpinner from 'js/components/common/loadingSpinner'; +import Avatar from 'js/components/common/avatar'; +import Badge from 'jsapp/js/components/common/badge'; + +// Stores, hooks and utilities +import {formatTime} from 'js/utils'; +import {useOrganizationQuery} from './organizationQuery'; +import useOrganizationMembersQuery from './membersQuery'; + +// Constants and types +import type {OrganizationMember} from './membersQuery'; + +// Styles +import styles from './membersRoute.module.scss'; + +export default function MembersRoute() { + const orgQuery = useOrganizationQuery(); + + if (!orgQuery.data?.id) { + return ( + + ); + } + + return ( +
+
+

{t('Members')}

+
+ + + queryHook={useOrganizationMembersQuery} + columns={[ + { + key: 'user__extra_details__name', + label: t('Name'), + cellFormatter: (member: OrganizationMember) => ( + + ), + size: 360, + }, + { + key: 'invite', + label: t('Status'), + size: 120, + cellFormatter: (member: OrganizationMember) => { + if (member.invite?.status) { + return member.invite.status; + } else { + return ; + } + return null; + }, + }, + { + key: 'date_joined', + label: t('Date added'), + size: 140, + cellFormatter: (member: OrganizationMember) => formatTime(member.date_joined), + }, + { + key: 'role', + label: t('Role'), + size: 120, + }, + { + key: 'user__has_mfa_enabled', + label: t('2FA'), + size: 90, + cellFormatter: (member: OrganizationMember) => { + if (member.user__has_mfa_enabled) { + return ; + } + return ; + }, + }, + { + // We use `url` here, but the cell would contain interactive UI + // element + key: 'url', + label: '', + size: 64, + // TODO: this will be added soon + cellFormatter: () => (' '), + }, + ]} + /> +
+ ); +} diff --git a/jsapp/js/account/organization/OrganizationSettingsRoute.tsx b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx new file mode 100644 index 0000000000..f56accea7c --- /dev/null +++ b/jsapp/js/account/organization/OrganizationSettingsRoute.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export default function OrganizationSettingsRoute() { + return ( +
Organization settings view to be implemented
+ ); +} diff --git a/jsapp/js/account/organization/membersQuery.ts b/jsapp/js/account/organization/membersQuery.ts new file mode 100644 index 0000000000..1bec128e80 --- /dev/null +++ b/jsapp/js/account/organization/membersQuery.ts @@ -0,0 +1,85 @@ +import {keepPreviousData, useQuery} from '@tanstack/react-query'; +import {endpoints} from 'js/api.endpoints'; +import type {PaginatedResponse} from 'js/dataInterface'; +import {fetchGet} from 'js/api'; +import {QueryKeys} from 'js/query/queryKeys'; +import {useOrganizationQuery, type OrganizationUserRole} from './organizationQuery'; + +export interface OrganizationMember { + /** + * The url to the member within the organization + * `/api/v2/organizations//members//` + */ + url: string; + /** `/api/v2/users//` */ + user: string; + user__username: string; + /** can be an empty string in some edge cases */ + user__email: string | ''; + /** can be an empty string in some edge cases */ + user__extra_details__name: string | ''; + role: OrganizationUserRole; + user__has_mfa_enabled: boolean; + user__is_active: boolean; + /** yyyy-mm-dd HH:MM:SS */ + date_joined: string; + invite?: { + /** '/api/v2/organizations//invites//' */ + url: string; + /** yyyy-mm-dd HH:MM:SS */ + date_created: string; + /** yyyy-mm-dd HH:MM:SS */ + date_modified: string; + status: 'sent' | 'accepted' | 'expired' | 'declined'; + }; +} + +/** + * Fetches paginated list of members for given organization. + * This is mainly needed for `useOrganizationMembersQuery`, so you most probably + * would use it through that hook rather than directly. + */ +async function getOrganizationMembers( + limit: number, + offset: number, + orgId: string +) { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }); + + const apiUrl = endpoints.ORGANIZATION_MEMBERS_URL.replace(':organization_id', orgId); + + return fetchGet>( + apiUrl + '?' + params, + { + errorMessageDisplay: t('There was an error getting the list.'), + } + ); +} + +/** + * A hook that gives you paginated list of organization members. Uses + * `useOrganizationQuery` to get the id. + */ +export default function useOrganizationMembersQuery( + itemLimit: number, + pageOffset: number +) { + const orgQuery = useOrganizationQuery(); + const orgId = orgQuery.data?.id; + + return useQuery({ + queryKey: [QueryKeys.organizationMembers, itemLimit, pageOffset, orgId], + // `orgId!` because it's ensured to be there in `enabled` property :ok: + queryFn: () => getOrganizationMembers(itemLimit, pageOffset, orgId!), + placeholderData: keepPreviousData, + enabled: !!orgId, + // We might want to improve this in future, for now let's not retry + retry: false, + // The `refetchOnWindowFocus` option is `true` by default, I'm setting it + // here so we don't forget about it. + refetchOnWindowFocus: true, + }); +} diff --git a/jsapp/js/account/organization/membersRoute.module.scss b/jsapp/js/account/organization/membersRoute.module.scss new file mode 100644 index 0000000000..d100d36428 --- /dev/null +++ b/jsapp/js/account/organization/membersRoute.module.scss @@ -0,0 +1,26 @@ +@use 'scss/colors'; +@use 'scss/breakpoints'; + +.membersRouteRoot { + padding: 20px; + overflow-y: auto; + height: 100%; +} + +.header { + margin-bottom: 20px; +} + +h2.headerText { + color: colors.$kobo-storm; + text-transform: uppercase; + font-size: 18px; + font-weight: 700; + margin: 0; +} + +@include breakpoints.breakpoint(mediumAndUp) { + .membersRouteRoot { + padding: 50px; + } +} diff --git a/jsapp/js/account/organizations/organizations.utils.tsx b/jsapp/js/account/organization/organization.utils.tsx similarity index 87% rename from jsapp/js/account/organizations/organizations.utils.tsx rename to jsapp/js/account/organization/organization.utils.tsx index f30e9dcc78..3dbeeb4d62 100644 --- a/jsapp/js/account/organizations/organizations.utils.tsx +++ b/jsapp/js/account/organization/organization.utils.tsx @@ -1,7 +1,7 @@ import type {SubscriptionInfo} from 'jsapp/js/account/stripe.types'; import type {EnvStoreData} from 'jsapp/js/envStore'; -/** Only use this directly for complex cases/strings (for example, possessive case). +/** Only use this directly for complex cases/strings (for example, possessive case). * Otherwise, use getSimpleMMOLabel. * @param {EnvStoreData} envStoreData * @param {SubscriptionInfo} subscription @@ -11,13 +11,9 @@ export function shouldUseTeamLabel( envStoreData: EnvStoreData, subscription: SubscriptionInfo | null ) { - if (subscription) { - return ( - subscription.items[0].price.product.metadata?.plan_type !== 'enterprise' - ); - } - - return envStoreData.use_team_label; + return subscription + ? subscription.items[0].price.product.metadata?.use_team_label === 'true' + : envStoreData.use_team_label; } /** diff --git a/jsapp/js/account/organization/organizationQuery.ts b/jsapp/js/account/organization/organizationQuery.ts new file mode 100644 index 0000000000..70f499b09a --- /dev/null +++ b/jsapp/js/account/organization/organizationQuery.ts @@ -0,0 +1,85 @@ +import type {FailResponse} from 'js/dataInterface'; +import {fetchGetUrl} from 'jsapp/js/api'; +import type {UndefinedInitialDataOptions} from '@tanstack/react-query'; +import {useQuery} from '@tanstack/react-query'; +import {QueryKeys} from 'js/query/queryKeys'; +import {FeatureFlag, useFeatureFlag} from 'js/featureFlags'; +import sessionStore from 'js/stores/session'; +import {useEffect} from 'react'; + +export interface Organization { + id: string; + name: string; + is_active: boolean; + created: string; + modified: string; + slug: string; + is_owner: boolean; + is_mmo: boolean; + request_user_role: OrganizationUserRole; +} + +export enum OrganizationUserRole { + member = 'member', + admin = 'admin', + owner = 'owner', +} + +/** + * Organization object is used globally. + * For convenience, errors are handled once at the top, see `RequireOrg`. + * No need to handle errors at every usage. + */ +export const useOrganizationQuery = (options?: Omit, 'queryFn' | 'queryKey'>) => { + const isMmosEnabled = useFeatureFlag(FeatureFlag.mmosEnabled); + + const currentAccount = sessionStore.currentAccount; + + const organizationUrl = + 'organization' in currentAccount ? currentAccount.organization?.url : null; + + // Using a separated function to fetch the organization data to prevent + // feature flag dependencies from being added to the hook + const fetchOrganization = async (): Promise => { + // organizationUrl is a full url with protocol and domain name, so we're using fetchGetUrl + // We're asserting the organizationUrl is not null here because the query is disabled if it is + const organization = await fetchGetUrl(organizationUrl!); + + if (isMmosEnabled) { + return organization; + } + + // While the project is in development we will force a false return for the is_mmo + // to make sure we don't have any implementations appearing for users + return { + ...organization, + is_mmo: false, + }; + }; + + // Setting the 'enabled' property so the query won't run until we have the session data + // loaded. Account data is needed to fetch the organization data. + const isQueryEnabled = + !sessionStore.isPending && + sessionStore.isInitialLoadComplete && + !!organizationUrl; + + const query = useQuery({ + ...options, + queryFn: fetchOrganization, + queryKey: [QueryKeys.organization], + enabled: isQueryEnabled && options?.enabled !== false, + }); + + // `organizationUrl` must exist, unless it's changed (e.g. user added/removed from organization). + // In such case, refetch organizationUrl to fetch the new `organizationUrl`. + // DEBT: don't throw toast within fetchGetUrl. + // DEBT: don't retry the failing url 3-4 times before switching to the new url. + useEffect(() => { + if (query.error?.status === 404) { + sessionStore.refreshAccount(); + } + }, [query.error?.status]); + + return query; +}; diff --git a/jsapp/js/account/plans/billingButton.module.scss b/jsapp/js/account/plans/billingButton.module.scss index 5780225f62..9665729f25 100644 --- a/jsapp/js/account/plans/billingButton.module.scss +++ b/jsapp/js/account/plans/billingButton.module.scss @@ -2,4 +2,5 @@ display: flex; justify-content: center; font-weight: 700; + flex-shrink: 0; } diff --git a/jsapp/js/account/plans/plan.component.tsx b/jsapp/js/account/plans/plan.component.tsx index bfab02795e..7031d599de 100644 --- a/jsapp/js/account/plans/plan.component.tsx +++ b/jsapp/js/account/plans/plan.component.tsx @@ -9,7 +9,7 @@ import React, { } from 'react'; import {useNavigate, useSearchParams} from 'react-router-dom'; import styles from './plan.module.scss'; -import {postCheckout, postCustomerPortal, useOrganizationQuery} from '../stripe.api'; +import {postCheckout, postCustomerPortal} from '../stripe.api'; import Button from 'js/components/common/button'; import classnames from 'classnames'; import LoadingSpinner from 'js/components/common/loadingSpinner'; @@ -18,7 +18,7 @@ import {ACTIVE_STRIPE_STATUSES} from 'js/constants'; import type {FreeTierThresholds} from 'js/envStore'; import envStore from 'js/envStore'; import useWhen from 'js/hooks/useWhen.hook'; -import AddOnList from 'js/account/add-ons/addOnList.component'; +import AddOnList from 'jsapp/js/account/addOns/addOnList.component'; import subscriptionStore from 'js/account/subscriptionStore'; import {when} from 'mobx'; import { @@ -28,7 +28,6 @@ import { } from 'js/account/stripe.utils'; import type { Price, - Organization, Product, SubscriptionInfo, SinglePricedProduct, @@ -39,6 +38,7 @@ import {PlanContainer} from 'js/account/plans/planContainer.component'; import {ProductsContext} from '../useProducts.hook'; import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; import {useRefreshApiFetcher} from 'js/hooks/useRefreshApiFetcher.hook'; +import {useOrganizationQuery, type Organization} from 'js/account/organization/organizationQuery'; export interface PlanState { subscribedProduct: null | SubscriptionInfo[]; diff --git a/jsapp/js/account/plans/planButton.component.tsx b/jsapp/js/account/plans/planButton.component.tsx index ba708953c5..9eefbeae66 100644 --- a/jsapp/js/account/plans/planButton.component.tsx +++ b/jsapp/js/account/plans/planButton.component.tsx @@ -1,8 +1,8 @@ import BillingButton from 'js/account/plans/billingButton.component'; -import React, {useContext} from 'react'; import type {Price, SinglePricedProduct} from 'js/account/stripe.types'; -import {postCustomerPortal, useOrganizationQuery} from 'js/account/stripe.api'; +import {postCustomerPortal} from 'js/account/stripe.api'; import {processCheckoutResponse} from 'js/account/stripe.utils'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; interface PlanButtonProps { buySubscription: (price: Price, quantity?: number) => void; diff --git a/jsapp/js/account/plans/planContainer.component.tsx b/jsapp/js/account/plans/planContainer.component.tsx index a9656fbe12..3a36c301b8 100644 --- a/jsapp/js/account/plans/planContainer.component.tsx +++ b/jsapp/js/account/plans/planContainer.component.tsx @@ -239,8 +239,8 @@ export const PlanContainer = ({ const asrMinutes = useMemo(() => { return ( (adjustedQuantity * - (parseInt(product.metadata?.nlp_seconds_limit || '0') || - parseInt(product.price.metadata?.nlp_seconds_limit || '0'))) / + (parseInt(product.metadata?.asr_seconds_limit || '0') || + parseInt(product.price.metadata?.asr_seconds_limit || '0'))) / 60 ); }, [adjustedQuantity, product]); @@ -248,8 +248,8 @@ export const PlanContainer = ({ const mtCharacters = useMemo(() => { return ( adjustedQuantity * - (parseInt(product.metadata?.nlp_character_limit || '0') || - parseInt(product.price.metadata?.nlp_character_limit || '0')) + (parseInt(product.metadata?.mt_characters_limit || '0') || + parseInt(product.price.metadata?.mt_characters_limit || '0')) ); }, [adjustedQuantity, product]); diff --git a/jsapp/js/account/plans/useDisplayPrice.hook.tsx b/jsapp/js/account/plans/useDisplayPrice.hook.tsx index 04b1b42089..2edc56ecaa 100644 --- a/jsapp/js/account/plans/useDisplayPrice.hook.tsx +++ b/jsapp/js/account/plans/useDisplayPrice.hook.tsx @@ -18,6 +18,12 @@ export const useDisplayPrice = ( submissionQuantity, price.transform_quantity ); + if (!price?.recurring?.interval) { + return t('$##price##').replace( + '##price##', + totalPrice.toFixed(2) + ); + } return t('$##price## USD/month').replace( '##price##', totalPrice.toFixed(2) diff --git a/jsapp/js/account/routes.constants.ts b/jsapp/js/account/routes.constants.ts index a8ff1262bd..4a4e49b6ea 100644 --- a/jsapp/js/account/routes.constants.ts +++ b/jsapp/js/account/routes.constants.ts @@ -11,7 +11,7 @@ export const PlansRoute = React.lazy( () => import(/* webpackPrefetch: true */ './plans/plan.component') ); export const AddOnsRoute = React.lazy( - () => import(/* webpackPrefetch: true */ './add-ons/addOns.component') + () => import(/* webpackPrefetch: true */ './addOns/addOns.component') ); export const AccountSettings = React.lazy( () => import(/* webpackPrefetch: true */ './accountSettingsRoute') @@ -19,6 +19,12 @@ export const AccountSettings = React.lazy( export const DataStorage = React.lazy( () => import(/* webpackPrefetch: true */ './usage/usageTopTabs') ); +export const MembersRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './organization/MembersRoute') +); +export const OrganizationSettingsRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './organization/OrganizationSettingsRoute') +); export const ACCOUNT_ROUTES: {readonly [key: string]: string} = { ACCOUNT_SETTINGS: ROUTES.ACCOUNT_ROOT + '/settings', USAGE: ROUTES.ACCOUNT_ROOT + '/usage', diff --git a/jsapp/js/account/routes.tsx b/jsapp/js/account/routes.tsx index ba5f3a313e..b6646f9360 100644 --- a/jsapp/js/account/routes.tsx +++ b/jsapp/js/account/routes.tsx @@ -1,8 +1,8 @@ import React from 'react'; import {Navigate, Route} from 'react-router-dom'; import RequireAuth from 'js/router/requireAuth'; -import {ValidateOrgPermissions} from 'js/router/validateOrgPermissions.component'; -import {OrganizationUserRole} from './stripe.types'; +import {RequireOrgPermissions} from 'js/router/RequireOrgPermissions.component'; +import {OrganizationUserRole} from 'js/account/organization/organizationQuery'; import { ACCOUNT_ROUTES, AccountSettings, @@ -11,6 +11,8 @@ import { DataStorage, PlansRoute, SecurityRoute, + MembersRoute, + OrganizationSettingsRoute, } from 'js/account/routes.constants'; import {useFeatureFlag, FeatureFlag} from 'js/featureFlags'; @@ -36,12 +38,12 @@ export default function routes() { index element={ - - + } /> @@ -50,12 +52,12 @@ export default function routes() { index element={ - - + } /> @@ -64,7 +66,7 @@ export default function routes() { index element={ - - + } /> @@ -80,7 +82,7 @@ export default function routes() { path={ACCOUNT_ROUTES.USAGE_PROJECT_BREAKDOWN} element={ - - + } /> @@ -116,12 +118,12 @@ export default function routes() { path={ACCOUNT_ROUTES.ORGANIZATION_MEMBERS} element={ - -
Organization members view to be implemented
-
+ +
} /> @@ -129,7 +131,7 @@ export default function routes() { path={ACCOUNT_ROUTES.ORGANIZATION_SETTINGS} element={ - -
Organization settings view to be implemented
-
+ +
} /> diff --git a/jsapp/js/account/security/email/emailSection.component.tsx b/jsapp/js/account/security/email/emailSection.component.tsx index e350affae5..4877dcc8a8 100644 --- a/jsapp/js/account/security/email/emailSection.component.tsx +++ b/jsapp/js/account/security/email/emailSection.component.tsx @@ -10,6 +10,7 @@ import { deleteUnverifiedUserEmails, } from './emailSection.api'; import type {EmailResponse} from './emailSection.api'; +import {useOrganizationQuery} from '../../organization/organizationQuery'; // Partial components import Button from 'jsapp/js/components/common/button'; @@ -33,6 +34,8 @@ interface EmailState { export default function EmailSection() { const [session] = useState(() => sessionStore); + const orgQuery = useOrganizationQuery(); + let initialEmail = ''; if ('email' in session.currentAccount) { initialEmail = session.currentAccount.email; @@ -116,6 +119,10 @@ export default function EmailSection() { const unverifiedEmail = email.emails.find( (userEmail) => !userEmail.verified && !userEmail.primary ); + const isReady = session.isInitialLoadComplete && 'email' in currentAccount; + const userCanChangeEmail = orgQuery.data?.is_mmo + ? orgQuery.data.request_user_role !== 'member' + : true; return (
@@ -123,83 +130,87 @@ export default function EmailSection() {

{t('Email address')}

-
- {!session.isPending && - session.isInitialLoadComplete && - 'email' in currentAccount && ( - - )} - - {unverifiedEmail?.email && - !session.isPending && - session.isInitialLoadComplete && - 'email' in currentAccount && ( - <> -
- -

- - {t('Check your email ##UNVERIFIED_EMAIL##. ').replace( - '##UNVERIFIED_EMAIL##', - unverifiedEmail.email - )} - - - {t( - 'A verification link has been sent to confirm your ownership. Once confirmed, this address will replace ##UNVERIFIED_EMAIL##' - ).replace('##UNVERIFIED_EMAIL##', currentAccount.email)} -

-
- -
-
- - {email.refreshedEmail && ( - - )} - - )} + + + {t( + 'A verification link has been sent to confirm your ownership. Once confirmed, this address will replace ##UNVERIFIED_EMAIL##' + ).replace('##UNVERIFIED_EMAIL##', currentAccount.email)} +

+
+ +
+
+ + {email.refreshedEmail && ( + + )} + + )} - -
{ - e.preventDefault(); - handleSubmit(); - }} - > -
); } diff --git a/jsapp/js/account/security/email/emailSection.module.scss b/jsapp/js/account/security/email/emailSection.module.scss index 7675c0999f..3350c95c9d 100644 --- a/jsapp/js/account/security/email/emailSection.module.scss +++ b/jsapp/js/account/security/email/emailSection.module.scss @@ -34,3 +34,13 @@ display: flex; gap: 10px; } + +.emailText { + font-weight: 600; +} + +.emailUpdateDisabled { + flex: 5; + // To compensate for the `options` class not displaying when there is no email + margin-right: calc(30% + 8px); +} diff --git a/jsapp/js/account/stripe.api.ts b/jsapp/js/account/stripe.api.ts index ad2784c30f..158574649d 100644 --- a/jsapp/js/account/stripe.api.ts +++ b/jsapp/js/account/stripe.api.ts @@ -4,26 +4,22 @@ import {endpoints} from 'js/api.endpoints'; import {ACTIVE_STRIPE_STATUSES} from 'js/constants'; import type {PaginatedResponse} from 'js/dataInterface'; import envStore from 'js/envStore'; -import {fetchGet, fetchGetUrl, fetchPost} from 'jsapp/js/api'; +import {fetchGet, fetchPost} from 'jsapp/js/api'; import type { AccountLimit, ChangePlan, Checkout, - Organization, + OneTimeAddOn, PriceMetadata, Product, } from 'js/account/stripe.types'; import {Limits} from 'js/account/stripe.types'; import {getAdjustedQuantityForPrice} from 'js/account/stripe.utils'; -import {useQuery} from '@tanstack/react-query'; -import {QueryKeys} from 'js/query/queryKeys'; -import {FeatureFlag, useFeatureFlag} from '../featureFlags'; -import sessionStore from 'js/stores/session'; const DEFAULT_LIMITS: AccountLimit = Object.freeze({ submission_limit: Limits.unlimited, - nlp_seconds_limit: Limits.unlimited, - nlp_character_limit: Limits.unlimited, + asr_seconds_limit: Limits.unlimited, + mt_characters_limit: Limits.unlimited, storage_bytes_limit: Limits.unlimited, }); @@ -33,6 +29,12 @@ export async function getProducts() { }); } +export async function getOneTimeAddOns() { + return fetchGet>(endpoints.ADD_ONS_URL, { + errorMessageDisplay: t('There was an error getting one-time add-ons.'), + }); +} + export async function changeSubscription( price_id: string, subscription_id: string, @@ -50,47 +52,6 @@ export async function changeSubscription( }); } -export const useOrganizationQuery = () => { - const isMmosEnabled = useFeatureFlag(FeatureFlag.mmosEnabled); - - const currentAccount = sessionStore.currentAccount; - - const organizationUrl = - 'organization' in currentAccount ? currentAccount.organization?.url : null; - - // Using a separated function to fetch the organization data to prevent - // feature flag dependencies from being added to the hook - const fetchOrganization = async (): Promise => { - // organizationUrl is a full url with protocol and domain name, so we're using fetchGetUrl - // We're asserting the organizationUrl is not null here because the query is disabled if it is - const organization = await fetchGetUrl(organizationUrl!); - - if (isMmosEnabled) { - return organization; - } - - // While the project is in development we will force a false return for the is_mmo - // to make sure we don't have any implementations appearing for users - return { - ...organization, - is_mmo: false, - }; - }; - - // Setting the 'enabled' property so the query won't run until we have the session data - // loaded. Account data is needed to fetch the organization data. - const isQueryEnabled = - !sessionStore.isPending && - sessionStore.isInitialLoadComplete && - !!organizationUrl; - - return useQuery({ - queryFn: fetchOrganization, - queryKey: [QueryKeys.organization], - enabled: isQueryEnabled, - }); -}; - /** * Start a checkout session for the given price and organization. Response contains the checkout URL. */ @@ -114,7 +75,7 @@ export async function postCheckout( */ export async function postCustomerPortal( organizationId: string, - priceId: string = '', + priceId = '', quantity = 1 ) { return fetchPost( @@ -201,10 +162,10 @@ const getFreeTierLimits = async (limits: AccountLimit) => { newLimits['submission_limit'] = thresholds.data; } if (thresholds.translation_chars) { - newLimits['nlp_character_limit'] = thresholds.translation_chars; + newLimits['mt_characters_limit'] = thresholds.translation_chars; } if (thresholds.transcription_minutes) { - newLimits['nlp_seconds_limit'] = thresholds.transcription_minutes * 60; + newLimits['asr_seconds_limit'] = thresholds.transcription_minutes * 60; } return newLimits; }; @@ -232,6 +193,41 @@ const getRecurringAddOnLimits = (limits: AccountLimit) => { return newLimits; }; +/** + * Add one-time addon limits to already calculated account limits + */ +const addRemainingOneTimeAddOnLimits = ( + limits: AccountLimit, + oneTimeAddOns: OneTimeAddOn[] +) => { + // This yields a separate object, so we need to make a copy + limits = {...limits}; + oneTimeAddOns + .filter((addon) => addon.is_available) + .forEach((addon) => { + if ( + addon.limits_remaining.submission_limit && + limits.submission_limit !== Limits.unlimited + ) { + limits.submission_limit += addon.limits_remaining.submission_limit; + } + if ( + addon.limits_remaining.asr_seconds_limit && + limits.asr_seconds_limit !== Limits.unlimited + ) { + limits.asr_seconds_limit += addon.limits_remaining.asr_seconds_limit; + } + if ( + addon.limits_remaining.mt_characters_limit && + limits.mt_characters_limit !== Limits.unlimited + ) { + limits.mt_characters_limit += + addon.limits_remaining.mt_characters_limit; + } + }); + return limits; +}; + /** * Get all metadata keys for the logged-in user's plan, or from the free tier if they have no plan. */ @@ -280,24 +276,33 @@ const getStripeMetadataAndFreeTierStatus = async (products: Product[]) => { * - the `FREE_TIER_THRESHOLDS` override * - the user's subscription limits */ -export async function getAccountLimits(products: Product[]) { +export async function getAccountLimits( + products: Product[], + oneTimeAddOns: OneTimeAddOn[] +) { const {metadata, hasFreeTier} = await getStripeMetadataAndFreeTierStatus( products ); // initialize to unlimited - let limits: AccountLimit = {...DEFAULT_LIMITS}; + let recurringLimits: AccountLimit = {...DEFAULT_LIMITS}; // apply any limits from the metadata - limits = {...limits, ...getLimitsForMetadata(metadata)}; + recurringLimits = {...recurringLimits, ...getLimitsForMetadata(metadata)}; if (hasFreeTier) { // if the user is on the free tier, overwrite their limits with whatever free tier limits exist - limits = await getFreeTierLimits(limits); + recurringLimits = await getFreeTierLimits(recurringLimits); - // if the user has active recurring add-ons, use those as the final say on their limits - limits = getRecurringAddOnLimits(limits); + // if the user has active recurring add-ons, use those as their limits + recurringLimits = getRecurringAddOnLimits(recurringLimits); } - return limits; + // create separate object with one-time addon limits added to the limits calculated so far + const remainingLimits = addRemainingOneTimeAddOnLimits( + recurringLimits, + oneTimeAddOns + ); + + return {recurringLimits, remainingLimits}; } diff --git a/jsapp/js/account/stripe.types.ts b/jsapp/js/account/stripe.types.ts index b77752c2ff..fb3381cdcd 100644 --- a/jsapp/js/account/stripe.types.ts +++ b/jsapp/js/account/stripe.types.ts @@ -139,24 +139,6 @@ export interface TransformQuantity { round: 'up' | 'down'; } -export interface Organization { - id: string; - name: string; - is_active: boolean; - created: string; - modified: string; - slug: string; - is_owner: boolean; - is_mmo: boolean; - request_user_role: OrganizationUserRole; -} - -export enum OrganizationUserRole { - member = 'member', - admin = 'admin', - owner = 'owner', -} - export enum PlanNames { 'FREE' = 'Community', // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values @@ -180,11 +162,16 @@ export type LimitAmount = number | 'unlimited'; export interface AccountLimit { submission_limit: LimitAmount; - nlp_seconds_limit: LimitAmount; - nlp_character_limit: LimitAmount; + asr_seconds_limit: LimitAmount; + mt_characters_limit: LimitAmount; storage_bytes_limit: LimitAmount; } +export interface AccountLimitDetail { + recurringLimits: AccountLimit; + remainingLimits: AccountLimit; +} + export interface Checkout { url: string; } @@ -217,3 +204,28 @@ export type ChangePlan = | { status: ChangePlanStatus.error; }; + +export interface OneTimeAddOn { + id: string; + created: string; + is_available: boolean; + usage_limits: Partial; + total_usage_limits: Partial; + limits_remaining: Partial; + organization: string; + product: string; + quantity: number; +} + +export interface OneTimeUsageLimits { + submission_limit: number; + asr_seconds_limit: number; + mt_characters_limit: number; +} + +export enum USAGE_TYPE { + 'SUBMISSIONS', + 'TRANSCRIPTION', + 'TRANSLATION', + 'STORAGE', +} diff --git a/jsapp/js/account/stripe.utils.ts b/jsapp/js/account/stripe.utils.ts index cd2350bced..f91296bbe3 100644 --- a/jsapp/js/account/stripe.utils.ts +++ b/jsapp/js/account/stripe.utils.ts @@ -1,8 +1,12 @@ import {when} from 'mobx'; +import prettyBytes from 'pretty-bytes'; +import {useCallback} from 'react'; import {ACTIVE_STRIPE_STATUSES} from 'js/constants'; import envStore from 'js/envStore'; import { + Limits, + USAGE_TYPE, Price, BaseProduct, ChangePlan, @@ -11,6 +15,7 @@ import { SubscriptionChangeType, SubscriptionInfo, TransformQuantity, + LimitAmount, } from 'js/account/stripe.types'; import subscriptionStore from 'js/account/subscriptionStore'; import {convertUnixTimestampToUtc, notify} from 'js/utils'; @@ -43,12 +48,16 @@ export async function hasActiveSubscription() { ); } -export function isAddonProduct(product: Product) { - return product.metadata.product_type === 'addon'; +export function isOneTimeAddonProduct(product: Product) { + return product.metadata?.product_type === 'addon_onetime'; } export function isRecurringAddonProduct(product: Product) { - return product.prices.some((price) => price?.recurring); + return product.metadata?.product_type === 'addon'; +} + +export function isAddonProduct(product: Product) { + return isOneTimeAddonProduct(product) || isRecurringAddonProduct(product); } export function processCheckoutResponse(data: Checkout) { @@ -215,3 +224,40 @@ export const isDowngrade = ( getAdjustedQuantityForPrice(newQuantity, price.transform_quantity); return currentTotalPrice > newTotalPrice; }; + +/** + * Render a limit amount, usage amount, or total balance as readable text + * @param {USAGE_TYPE} type - The limit/usage amount + * @param {number|'unlimited'} amount - The limit/usage amount + * @param {number|'unlimited'|null} [available=null] - If we're showing a balance, + * `amount` takes the usage amount and this takes the limit amount + */ +export const useLimitDisplay = () => { + const limitDisplay = useCallback( + ( + type: USAGE_TYPE, + amount: LimitAmount, + available: LimitAmount | null = null + ) => { + if (amount === Limits.unlimited || available === Limits.unlimited) { + return t('Unlimited'); + } + const total = available ? available - amount : amount; + switch (type) { + case USAGE_TYPE.STORAGE: + return prettyBytes(total); + case USAGE_TYPE.TRANSCRIPTION: + return t('##minutes## mins').replace( + '##minutes##', + typeof total === 'number' + ? Math.floor(total).toLocaleString() + : total + ); + default: + return total.toLocaleString(); + } + }, + [] + ); + return {limitDisplay}; +}; diff --git a/jsapp/js/account/subscriptionStore.ts b/jsapp/js/account/subscriptionStore.ts index be7469003f..ac9de4411c 100644 --- a/jsapp/js/account/subscriptionStore.ts +++ b/jsapp/js/account/subscriptionStore.ts @@ -1,9 +1,8 @@ import {makeAutoObservable} from 'mobx'; -import {handleApiFail} from 'js/api'; +import {handleApiFail, fetchGet} from 'js/api'; import {ACTIVE_STRIPE_STATUSES, ROOT_URL} from 'js/constants'; -import {fetchGet} from 'jsapp/js/api'; import type {PaginatedResponse} from 'js/dataInterface'; -import {Product, SubscriptionInfo} from 'js/account/stripe.types'; +import type {Product, SubscriptionInfo} from 'js/account/stripe.types'; const PRODUCTS_URL = '/api/v2/stripe/products/'; @@ -53,16 +52,16 @@ class SubscriptionStore { ); this.canceledPlans = response.results.filter( (sub) => - sub.items[0]?.price.product.metadata?.product_type == 'plan' && + sub.items[0]?.price.product.metadata?.product_type === 'plan' && sub.status === 'canceled' ); // get any active plan subscriptions for the user this.planResponse = this.activeSubscriptions.filter( - (sub) => sub.items[0]?.price.product.metadata?.product_type == 'plan' + (sub) => sub.items[0]?.price.product.metadata?.product_type === 'plan' ); // get any active recurring add-on subscriptions for the user this.addOnsResponse = this.activeSubscriptions.filter( - (sub) => sub.items[0]?.price.product.metadata?.product_type == 'addon' + (sub) => sub.items[0]?.price.product.metadata?.product_type === 'addon' ); this.isPending = false; diff --git a/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.component.tsx b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.component.tsx new file mode 100644 index 0000000000..c19dd7981e --- /dev/null +++ b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.component.tsx @@ -0,0 +1,69 @@ +import React, {useContext, useMemo} from 'react'; +import styles from './oneTimeAddOnList.module.scss'; +import {OneTimeAddOn, USAGE_TYPE} from 'jsapp/js/account/stripe.types'; +import {useLimitDisplay} from 'jsapp/js/account/stripe.utils'; +import {ProductsContext} from 'jsapp/js/account/useProducts.hook'; + +interface OneTimeAddOnList { + type: USAGE_TYPE; + oneTimeAddOns: OneTimeAddOn[]; +} + +function OneTimeAddOnList(props: OneTimeAddOnList) { + const [productsContext] = useContext(ProductsContext); + const {limitDisplay} = useLimitDisplay(); + + const formattedAddOns = useMemo(() => { + return props.oneTimeAddOns.map((addon) => { + let productName = + productsContext.products.find((product) => product.id === addon.product) + ?.name ?? 'One-Time Addon'; + + let remainingLimit = 0; + switch (props.type) { + case USAGE_TYPE.SUBMISSIONS: + remainingLimit = addon.limits_remaining.submission_limit ?? 0; + break; + case USAGE_TYPE.TRANSCRIPTION: + remainingLimit = addon.limits_remaining.asr_seconds_limit ?? 0; + remainingLimit = remainingLimit / 60; + break; + case USAGE_TYPE.TRANSLATION: + remainingLimit = addon.limits_remaining.mt_characters_limit ?? 0; + break; + default: + break; + } + return { + productName, + remainingLimit, + quantity: addon.quantity, + }; + }); + }, [props.oneTimeAddOns, props.type, productsContext.isLoaded]); + + return ( +
+ {formattedAddOns.map((addon, i) => ( +
+ +
+ {t('##REMAINING## remaining').replace( + '##REMAINING##', + limitDisplay(props.type, addon.remainingLimit) + )} +
+
+ ))} +
+ ); +} + +export default OneTimeAddOnList; diff --git a/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.module.scss b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.module.scss new file mode 100644 index 0000000000..529feb5938 --- /dev/null +++ b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnList/oneTimeAddOnList.module.scss @@ -0,0 +1,21 @@ +@use 'scss/colors'; + + +.oneTimeAddOnListContainer { + .oneTimeAddOnListEntry + .oneTimeAddOnListEntry { + margin-top: 8px; + } +} + +.oneTimeAddOnListEntry { + display: flex; + justify-content: space-between; + padding: 16px; + border: 1px solid colors.$kobo-gray-300; + border-radius: 6px; + + label { + margin-right: 30px; + font-weight: 600; + } +} diff --git a/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component.tsx b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component.tsx new file mode 100644 index 0000000000..c1e32895e6 --- /dev/null +++ b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component.tsx @@ -0,0 +1,105 @@ +import React, {useState} from 'react'; +import KoboModal from 'js/components/modals/koboModal'; +import KoboModalHeader from 'js/components/modals/koboModalHeader'; +import styles from './oneTimeAddOnUsageModal.module.scss'; +import {OneTimeAddOn, RecurringInterval, USAGE_TYPE} from '../../stripe.types'; +import {useLimitDisplay} from '../../stripe.utils'; +import OneTimeAddOnList from './oneTimeAddOnList/oneTimeAddOnList.component'; + +interface OneTimeAddOnUsageModalProps { + type: USAGE_TYPE; + recurringLimit: number; + remainingLimit: number; + period: RecurringInterval; + oneTimeAddOns: OneTimeAddOn[]; + usage: number; +} + +function OneTimeAddOnUsageModal(props: OneTimeAddOnUsageModalProps) { + const [showModal, setShowModal] = useState(false); + const toggleModal = () => { + setShowModal(!showModal); + }; + const {limitDisplay} = useLimitDisplay(); + + const typeTitles: {[key in USAGE_TYPE]: string} = { + [USAGE_TYPE.STORAGE]: 'Storage GB', + [USAGE_TYPE.SUBMISSIONS]: 'Submissions', + [USAGE_TYPE.TRANSCRIPTION]: 'Transcription minutes', + [USAGE_TYPE.TRANSLATION]: 'Translation characters', + }; + + const periodAdjectiveDisplay: {[key in RecurringInterval]: string} = { + year: 'yearly', + month: 'monthly', + }; + + return ( + <> +
  • + + {t('View add-on details')} + +
  • + + + {t('Add-on details')} + +
    +
    +
    + {t('##TYPE##').replace('##TYPE##', typeTitles[props.type])} +
    +
      +
    • + + {limitDisplay(props.type, props.recurringLimit)} +
    • +
    • + + + {limitDisplay( + props.type, + props.recurringLimit, + props.remainingLimit + )} + +
    • +
    • + + {limitDisplay(props.type, props.usage)} +
    • +
    • + + + + {limitDisplay(props.type, props.remainingLimit)} + + +
    • +
    +
    +

    {t('Purchased add-ons')}

    + +
    +
    + + ); +} + +export default OneTimeAddOnUsageModal; diff --git a/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.module.scss b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.module.scss new file mode 100644 index 0000000000..6e2e23c862 --- /dev/null +++ b/jsapp/js/account/usage/oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.module.scss @@ -0,0 +1,57 @@ + +@use 'scss/colors'; + +.addonModalTrigger { + text-decoration: underline; + cursor: pointer; +} + +.addonUsageDetails { + ul + .addonTypeHeader { + margin-top: 20px; + } + + margin-bottom: 32px; +} + +.addonTypeHeader { + font-weight: 600; + font-size: 12px; + color: colors.$kobo-gray-600; + text-transform: uppercase; +} + +.addonModalContent { + padding: 0 24px 24px 24px; +} + +.usageBreakdown { + margin-top: 8px; + + & > li { + display: flex; + justify-content: space-between; + } + + li + li { + margin-top: 8x; + } + + li:last-of-type { + margin-top: 14px; + } +} + +.totalAvailable { + padding: 8px 0; + border: colors.$kobo-gray-200 1px; + border-style: solid none; +} + +.listHeaderText { + font-size: 18px; + font-weight: 700; + color: colors.$kobo-storm; + text-transform: uppercase; + margin: 0 0 16px 0; +} diff --git a/jsapp/js/account/usage/usage.api.ts b/jsapp/js/account/usage/usage.api.ts index 2bbab18bf6..ca49a3b624 100644 --- a/jsapp/js/account/usage/usage.api.ts +++ b/jsapp/js/account/usage/usage.api.ts @@ -55,17 +55,15 @@ export interface UsageResponse { } const USAGE_URL = '/api/v2/service_usage/'; -const ORGANIZATION_USAGE_URL = - '/api/v2/organizations/##ORGANIZATION_ID##/service_usage/'; +const ORGANIZATION_USAGE_URL = '/api/v2/organizations/:organization_id/service_usage/'; const ASSET_USAGE_URL = '/api/v2/asset_usage/'; -const ORGANIZATION_ASSET_USAGE_URL = - '/api/v2/organizations/##ORGANIZATION_ID##/asset_usage/'; +const ORGANIZATION_ASSET_USAGE_URL = '/api/v2/organizations/:organization_id/asset_usage/'; export async function getUsage(organization_id: string | null = null) { if (organization_id) { return fetchGet( - ORGANIZATION_USAGE_URL.replace('##ORGANIZATION_ID##', organization_id), + ORGANIZATION_USAGE_URL.replace(':organization_id', organization_id), { includeHeaders: true, errorMessageDisplay: t('There was an error fetching usage data.'), @@ -95,10 +93,7 @@ export async function getAssetUsageForOrganization( return await getAssetUsage(ASSET_USAGE_URL); } - const apiUrl = ORGANIZATION_ASSET_USAGE_URL.replace( - '##ORGANIZATION_ID##', - organizationId - ); + const apiUrl = ORGANIZATION_ASSET_USAGE_URL.replace(':organization_id', organizationId); const params = new URLSearchParams({ page: pageNumber.toString(), diff --git a/jsapp/js/account/usage/usage.component.tsx b/jsapp/js/account/usage/usage.component.tsx index 58edca16d1..b4cb480976 100644 --- a/jsapp/js/account/usage/usage.component.tsx +++ b/jsapp/js/account/usage/usage.component.tsx @@ -1,20 +1,19 @@ import {when} from 'mobx'; import React, {useContext, useEffect, useMemo, useState} from 'react'; import {useLocation} from 'react-router-dom'; -import type {AccountLimit, LimitAmount} from 'js/account/stripe.types'; -import {Limits} from 'js/account/stripe.types'; +import type {AccountLimitDetail, LimitAmount, OneTimeAddOn} from 'js/account/stripe.types'; +import {Limits, USAGE_TYPE} from 'js/account/stripe.types'; import {getAccountLimits} from 'js/account/stripe.api'; import subscriptionStore from 'js/account/subscriptionStore'; import LoadingSpinner from 'js/components/common/loadingSpinner'; -import UsageContainer, { - USAGE_CONTAINER_TYPE, -} from 'js/account/usage/usageContainer'; +import UsageContainer from 'js/account/usage/usageContainer'; import envStore from 'js/envStore'; import {convertSecondsToMinutes, formatDate} from 'js/utils'; import styles from './usage.module.scss'; import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; import {ProductsContext} from '../useProducts.hook'; import {UsageContext} from 'js/account/usage/useUsage.hook'; +import {OneTimeAddOnsContext} from '../useOneTimeAddonList.hook'; import moment from 'moment'; import {YourPlan} from 'js/account/usage/yourPlan.component'; import cx from 'classnames'; @@ -22,10 +21,14 @@ import LimitNotifications from 'js/components/usageLimits/limitNotifications.com import {useRefreshApiFetcher} from 'js/hooks/useRefreshApiFetcher.hook'; interface LimitState { - storageByteLimit: LimitAmount; - nlpCharacterLimit: LimitAmount; - nlpMinuteLimit: LimitAmount; - submissionLimit: LimitAmount; + storageByteRemainingLimit: LimitAmount; + storageByteRecurringLimit: LimitAmount; + nlpCharacterRemainingLimit: LimitAmount; + nlpCharacterRecurringLimit: LimitAmount; + nlpMinuteRemainingLimit: LimitAmount; + nlpMinuteRecurringLimit: LimitAmount; + submissionsRemainingLimit: LimitAmount; + submissionsRecurringLimit: LimitAmount; isLoaded: boolean; stripeEnabled: boolean; } @@ -33,13 +36,18 @@ interface LimitState { export default function Usage() { const [products] = useContext(ProductsContext); const [usage, loadUsage, usageStatus] = useContext(UsageContext); + const oneTimeAddOnsContext = useContext(OneTimeAddOnsContext); useRefreshApiFetcher(loadUsage, usageStatus); const [limits, setLimits] = useState({ - storageByteLimit: Limits.unlimited, - nlpCharacterLimit: Limits.unlimited, - nlpMinuteLimit: Limits.unlimited, - submissionLimit: Limits.unlimited, + storageByteRemainingLimit: Limits.unlimited, + storageByteRecurringLimit: Limits.unlimited, + nlpCharacterRemainingLimit: Limits.unlimited, + nlpCharacterRecurringLimit: Limits.unlimited, + nlpMinuteRemainingLimit: Limits.unlimited, + nlpMinuteRecurringLimit: Limits.unlimited, + submissionsRemainingLimit: Limits.unlimited, + submissionsRecurringLimit: Limits.unlimited, isLoaded: false, stripeEnabled: false, }); @@ -51,8 +59,15 @@ export default function Usage() { !usageStatus.pending && !usageStatus.error && (products.isLoaded || !limits.stripeEnabled) && + limits.isLoaded && + oneTimeAddOnsContext.isLoaded, + [ + usageStatus, + products.isLoaded, limits.isLoaded, - [usageStatus, products.isLoaded, limits.isLoaded, limits.stripeEnabled] + limits.stripeEnabled, + oneTimeAddOnsContext.isLoaded, + ] ); const dateRange = useMemo(() => { @@ -84,9 +99,12 @@ export default function Usage() { useEffect(() => { const getLimits = async () => { await when(() => envStore.isReady); - let limits: AccountLimit; + let limits: AccountLimitDetail; if (envStore.data.stripe_public_key) { - limits = await getAccountLimits(products.products); + limits = await getAccountLimits( + products.products, + oneTimeAddOnsContext.oneTimeAddOns + ); } else { setLimits((prevState) => { return { @@ -100,13 +118,22 @@ export default function Usage() { setLimits((prevState) => { return { ...prevState, - storageByteLimit: limits.storage_bytes_limit, - nlpCharacterLimit: limits.nlp_character_limit, - nlpMinuteLimit: - typeof limits.nlp_seconds_limit === 'number' - ? convertSecondsToMinutes(limits.nlp_seconds_limit) - : limits.nlp_seconds_limit, - submissionLimit: limits.submission_limit, + storageByteRemainingLimit: limits.remainingLimits.storage_bytes_limit, + storageByteRecurringLimit: limits.recurringLimits.storage_bytes_limit, + nlpCharacterRemainingLimit: + limits.remainingLimits.mt_characters_limit, + nlpCharacterRecurringLimit: + limits.recurringLimits.mt_characters_limit, + nlpMinuteRemainingLimit: + typeof limits.remainingLimits.asr_seconds_limit === 'number' + ? convertSecondsToMinutes(limits.remainingLimits.asr_seconds_limit) + : limits.remainingLimits.asr_seconds_limit, + nlpMinuteRecurringLimit: + typeof limits.recurringLimits.asr_seconds_limit === 'number' + ? convertSecondsToMinutes(limits.recurringLimits.asr_seconds_limit) + : limits.recurringLimits.asr_seconds_limit, + submissionsRemainingLimit: limits.remainingLimits.submission_limit, + submissionsRecurringLimit: limits.recurringLimits.submission_limit, isLoaded: true, stripeEnabled: true, }; @@ -114,7 +141,57 @@ export default function Usage() { }; getLimits(); - }, [products.isLoaded]); + }, [products.isLoaded, oneTimeAddOnsContext.isLoaded]); + + function filterAddOns(type: USAGE_TYPE) { + const availableAddons = oneTimeAddOnsContext.oneTimeAddOns.filter( + (addon) => addon.is_available + ); + + // Find the relevant addons, but first check and make sure add-on + // limits aren't superceded by an "unlimited" usage limit. + switch (type) { + case USAGE_TYPE.SUBMISSIONS: + return limits.submissionsRecurringLimit !== Limits.unlimited + ? availableAddons.filter( + (addon) => addon.total_usage_limits.submission_limit + ) + : []; + case USAGE_TYPE.TRANSCRIPTION: + return limits.nlpMinuteRecurringLimit !== Limits.unlimited + ? availableAddons.filter( + (addon) => addon.total_usage_limits.asr_seconds_limit + ) + : []; + case USAGE_TYPE.TRANSLATION: + return limits.nlpCharacterRecurringLimit !== Limits.unlimited + ? availableAddons.filter( + (addon) => addon.total_usage_limits.mt_characters_limit + ) + : []; + default: + return []; + } + } + + // Find out if any usage type has one-time addons so we can + // adjust the formatting of the usage containers to accommodate + // a detail link. + const hasAddOnsLayout = useMemo(() => { + let result = false; + for (const type of [ + USAGE_TYPE.STORAGE, + USAGE_TYPE.SUBMISSIONS, + USAGE_TYPE.TRANSCRIPTION, + USAGE_TYPE.TRANSLATION, + ]) { + const relevantAddons = filterAddOns(type); + if (relevantAddons.length > 0) { + result = true; + } + } + return result; + }, [oneTimeAddOnsContext.isLoaded, limits.isLoaded]); // if stripe is enabled, load fresh subscription info whenever we navigate to this route useWhenStripeIsEnabled(() => { @@ -149,8 +226,12 @@ export default function Usage() {
    @@ -160,10 +241,13 @@ export default function Usage() {
    @@ -177,9 +261,12 @@ export default function Usage() {
    @@ -191,8 +278,12 @@ export default function Usage() {
    diff --git a/jsapp/js/account/usage/usageContainer.tsx b/jsapp/js/account/usage/usageContainer.tsx index 7de56ccdce..40bca8edc5 100644 --- a/jsapp/js/account/usage/usageContainer.tsx +++ b/jsapp/js/account/usage/usageContainer.tsx @@ -1,127 +1,127 @@ -import prettyBytes from 'pretty-bytes'; -import React, {useCallback, useMemo, useState} from 'react'; -import type {LimitAmount, RecurringInterval} from 'js/account/stripe.types'; +import React, {useMemo, useState} from 'react'; +import type { + LimitAmount, + OneTimeAddOn, + RecurringInterval, +} from 'js/account/stripe.types'; import Icon from 'js/components/common/icon'; import styles from 'js/account/usage/usageContainer.module.scss'; import {USAGE_WARNING_RATIO} from 'js/constants'; -import {Limits} from 'js/account/stripe.types'; +import {Limits, USAGE_TYPE} from 'js/account/stripe.types'; +import {useLimitDisplay} from '../stripe.utils'; import cx from 'classnames'; import subscriptionStore from 'js/account/subscriptionStore'; import Badge from 'js/components/common/badge'; import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; - -export enum USAGE_CONTAINER_TYPE { - 'TRANSCRIPTION', - 'STORAGE', -} +import OneTimeAddOnUsageModal from './oneTimeAddOnUsageModal/oneTimeAddOnUsageModal.component'; interface UsageContainerProps { usage: number; - limit: LimitAmount; + remainingLimit: LimitAmount; + recurringLimit: LimitAmount; + oneTimeAddOns: OneTimeAddOn[]; + hasAddOnsLayout: boolean; period: RecurringInterval; label?: string; - type?: USAGE_CONTAINER_TYPE; + type: USAGE_TYPE; } const UsageContainer = ({ usage, - limit, + remainingLimit, + recurringLimit, + oneTimeAddOns, + hasAddOnsLayout, period, + type, label = undefined, - type = undefined, }: UsageContainerProps) => { const [isStripeEnabled, setIsStripeEnabled] = useState(false); const [subscriptions] = useState(() => subscriptionStore); - const hasStorageAddOn = useMemo( + const hasRecurringAddOn = useMemo( () => subscriptions.addOnsResponse.length > 0, [subscriptions.addOnsResponse] ); + + const displayOneTimeAddons = useMemo( + () => oneTimeAddOns.length > 0, + [oneTimeAddOns] + ); + + const {limitDisplay} = useLimitDisplay(); + useWhenStripeIsEnabled(() => setIsStripeEnabled(true), []); let limitRatio = 0; - if (limit !== Limits.unlimited && limit) { - limitRatio = usage / limit; + if (remainingLimit !== Limits.unlimited && remainingLimit) { + limitRatio = usage / remainingLimit; } const isOverLimit = limitRatio >= 1; const isNearingLimit = !isOverLimit && limitRatio > USAGE_WARNING_RATIO; - /** - * Render a limit amount, usage amount, or total balance as readable text - * @param {number|'unlimited'} amount - The limit/usage amount - * @param {number|'unlimited'} [available] - If we're showing a balance, - * `amount` takes the usage amount and this takes the limit amount - */ - const limitDisplay = useCallback( - (amount: LimitAmount, available?: LimitAmount) => { - if (amount === Limits.unlimited || available === Limits.unlimited) { - return t('Unlimited'); - } - const total = available ? available - amount : amount; - switch (type) { - case USAGE_CONTAINER_TYPE.STORAGE: - return prettyBytes(total); - case USAGE_CONTAINER_TYPE.TRANSCRIPTION: - return t('##minutes## mins').replace( - '##minutes##', - total.toLocaleString() - ); - default: - return total.toLocaleString(); - } - }, - [limit, type, usage] - ); - return ( - <> -
      - {isStripeEnabled && ( -
    • - - {limitDisplay(limit)} -
    • - )} +
        + {isStripeEnabled && (
      • + + + {limitDisplay(type, remainingLimit)} + +
      • + )} +
      • + + {limitDisplay(type, usage)} +
      • + {isStripeEnabled && ( +
      • - {limitDisplay(usage)} +
        + {isNearingLimit && } + {isOverLimit && } + {limitDisplay(type, usage, remainingLimit)} +
        +
      • + )} + {hasRecurringAddOn && type === USAGE_TYPE.STORAGE && ( +
      • + + {subscriptions.addOnsResponse[0].items?.[0].price.product.name} + + } + />
      • - {isStripeEnabled && ( -
      • - -
        - {isNearingLimit && } - {isOverLimit && } - {limitDisplay(usage, limit)} -
        -
      • - )} - {hasStorageAddOn && type === USAGE_CONTAINER_TYPE.STORAGE && ( -
      • - - { - subscriptions.addOnsResponse[0].items?.[0].price.product - .name - } - - } - /> -
      • - )} -
      - + )} + {displayOneTimeAddons && ( + // We have already checked for "unlimited" limit amounts when filtering the addons, + // so we can now cast limits as numbers + + )} +
    ); }; diff --git a/jsapp/js/account/usage/usageProjectBreakdown.tsx b/jsapp/js/account/usage/usageProjectBreakdown.tsx index a9a993eddc..6fe5387041 100644 --- a/jsapp/js/account/usage/usageProjectBreakdown.tsx +++ b/jsapp/js/account/usage/usageProjectBreakdown.tsx @@ -16,7 +16,7 @@ import {convertSecondsToMinutes} from 'jsapp/js/utils'; import {UsageContext} from './useUsage.hook'; import Button from 'js/components/common/button'; import Icon from 'js/components/common/icon'; -import {useOrganizationQuery} from 'js/account/stripe.api'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; type ButtonType = 'back' | 'forward'; diff --git a/jsapp/js/account/usage/useUsage.hook.ts b/jsapp/js/account/usage/useUsage.hook.ts index 17a016c4a6..1166605524 100644 --- a/jsapp/js/account/usage/useUsage.hook.ts +++ b/jsapp/js/account/usage/useUsage.hook.ts @@ -1,5 +1,5 @@ -import {createContext, useCallback} from 'react'; -import type {Organization, RecurringInterval} from 'js/account/stripe.types'; +import {createContext} from 'react'; +import type {RecurringInterval} from 'js/account/stripe.types'; import {getSubscriptionInterval} from 'js/account/stripe.api'; import {convertSecondsToMinutes, formatRelativeTime} from 'js/utils'; import {getUsage} from 'js/account/usage/usage.api'; diff --git a/jsapp/js/account/useOneTimeAddonList.hook.ts b/jsapp/js/account/useOneTimeAddonList.hook.ts new file mode 100644 index 0000000000..bb98330f82 --- /dev/null +++ b/jsapp/js/account/useOneTimeAddonList.hook.ts @@ -0,0 +1,37 @@ +import {createContext, useState} from 'react'; +import type { + OneTimeAddOn, +} from 'js/account/stripe.types'; +import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook'; +import {getOneTimeAddOns} from 'js/account/stripe.api'; + +export interface OneTimeAddOnState { + oneTimeAddOns: OneTimeAddOn[]; + isLoaded: boolean; +} + +const INITIAL_ADDONS_STATE: OneTimeAddOnState = Object.freeze({ + oneTimeAddOns: [], + isLoaded: false, +}); + +export function useOneTimeAddOns() { + const [addons, setAddons] = useState(INITIAL_ADDONS_STATE); + + // get list of addons + useWhenStripeIsEnabled(() => { + getOneTimeAddOns().then((oneTimeAddOns) => { + setAddons(() => { + return { + oneTimeAddOns: oneTimeAddOns.results, + isLoaded: true, + }; + }); + }); + }, []); + + return addons; +} + +export const OneTimeAddOnsContext = + createContext(INITIAL_ADDONS_STATE); diff --git a/jsapp/js/api.endpoints.ts b/jsapp/js/api.endpoints.ts index 5a4c6e899d..a6096989f1 100644 --- a/jsapp/js/api.endpoints.ts +++ b/jsapp/js/api.endpoints.ts @@ -1,9 +1,11 @@ export const endpoints = { ASSET_URL: '/api/v2/assets/:uid/', + ORG_ASSETS_URL: '/api/v2/organizations/:organization_id/assets/', ME_URL: '/me/', PRODUCTS_URL: '/api/v2/stripe/products/', SUBSCRIPTION_URL: '/api/v2/stripe/subscriptions/', - ORGANIZATION_URL: '/api/v2/organizations/', + ADD_ONS_URL: '/api/v2/stripe/addons/', + ORGANIZATION_MEMBERS_URL: '/api/v2/organizations/:organization_id/members/', /** Expected parameters: price_id and organization_id **/ CHECKOUT_URL: '/api/v2/stripe/checkout-link', /** Expected parameter: organization_id **/ @@ -12,4 +14,4 @@ export const endpoints = { CHANGE_PLAN_URL: '/api/v2/stripe/change-plan', ACCESS_LOGS_URL: '/api/v2/access-logs/me', LOGOUT_ALL: '/logout-all/', -}; +} as const; diff --git a/jsapp/js/app.jsx b/jsapp/js/app.jsx index a061b5a61a..8c93f5016e 100644 --- a/jsapp/js/app.jsx +++ b/jsapp/js/app.jsx @@ -30,9 +30,10 @@ import {isAnyProcessingRouteActive} from 'js/components/processing/routes.utils' import pageState from 'js/pageState.store'; // Query-related -import { QueryClientProvider } from '@tanstack/react-query' -import { ReactQueryDevtools } from '@tanstack/react-query-devtools' -import { queryClient } from './query/queryClient.ts' +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { queryClient } from './query/queryClient.ts'; +import { RequireOrg } from './router/RequireOrg'; class App extends React.Component { constructor(props) { @@ -107,42 +108,44 @@ class App extends React.Component { - - - - {this.shouldDisplayMainLayoutElements() && -
    - } - - - {this.state.pageState.modal && ( - - )} - - {this.shouldDisplayMainLayoutElements() && ( - <> - - - - )} - - + + + + {this.shouldDisplayMainLayoutElements() && +
    + } + + + {this.state.pageState.modal && ( + + )} + {this.shouldDisplayMainLayoutElements() && ( <> - {this.isFormSingle() && } - + + )} - - - + + {this.shouldDisplayMainLayoutElements() && ( + <> + {this.isFormSingle() && } + + + )} + + + + + diff --git a/jsapp/js/components/common/avatar.module.scss b/jsapp/js/components/common/avatar.module.scss index d755cdce2e..d12aaa8640 100644 --- a/jsapp/js/components/common/avatar.module.scss +++ b/jsapp/js/components/common/avatar.module.scss @@ -1,38 +1,36 @@ @use 'scss/colors'; @use 'scss/mixins'; -$avatar-size-s: 24px; +$avatar-size-s: 20px; $avatar-size-m: 32px; -$avatar-size-l: 48px; @mixin avatar-size($size) { // This is the gap between the initials and the (optional) username - gap: $size * 0.25; + gap: $size * 0.375; // We want the gap to be 12px for "m" size .initials { width: $size; height: $size; border-radius: $size; line-height: $size; - font-size: $size * 0.6; + font-size: $size * 0.6875; // We want the initial to have 22px for "m" size + font-weight: 500; } -} -.avatar { - @include mixins.centerRowFlex; -} - -.avatar-size-s { - @include avatar-size($avatar-size-s); + .text { + line-height: $size * 0.6875; // We want it to be 22px for "m" size + } } -.avatar-size-m { - @include avatar-size($avatar-size-m); +.avatar { + display: inline-flex; + flex-direction: row; + align-content: center; + align-items: center; } -.avatar-size-l { - @include avatar-size($avatar-size-l); -} +.avatar-size-s {@include avatar-size($avatar-size-s);} +.avatar-size-m {@include avatar-size($avatar-size-m);} .initials { text-align: center; @@ -43,3 +41,42 @@ $avatar-size-l: 48px; // actual background color is provided by JS, this is just safeguard background-color: colors.$kobo-storm; } + +.text { + flex: 1; + display: inline-flex; + align-content: center; + align-items: center; + flex-direction: row; + flex-wrap: wrap; + gap: 0 8px; + font-size: 14px; +} + +// If `fullName` is being displayed, we want the username to be less prominent +// and to have "@" prefix +.text.hasFullName .username { + font-size: 12px; + line-height: 17px; + font-weight: 400; + color: colors.$kobo-gray-700; + + &::before { + content: '@'; + } +} + +.fullName, +// If `fullName` is missing, we want the username to be more visible +.text:not(.hasFullName) .username { + font-weight: 600; + color: colors.$kobo-gray-800; +} + +.email { + font-weight: 400; + color: colors.$kobo-gray-700; + // Force email to always wrap to new line: + flex-basis: 100%; + width: 0; +} diff --git a/jsapp/js/components/common/avatar.stories.tsx b/jsapp/js/components/common/avatar.stories.tsx index 8896581e4e..3a8d9728c5 100644 --- a/jsapp/js/components/common/avatar.stories.tsx +++ b/jsapp/js/components/common/avatar.stories.tsx @@ -1,33 +1,63 @@ import React from 'react'; -import type {ComponentStory, ComponentMeta} from '@storybook/react'; +import type {ComponentStory, ComponentMeta, StoryObj} from '@storybook/react'; import Avatar from './avatar'; import type {AvatarSize} from './avatar'; -const avatarSizes: AvatarSize[] = ['s', 'm', 'l']; +const avatarSizes: AvatarSize[] = ['s', 'm']; export default { title: 'common/Avatar', component: Avatar, argTypes: { - username: {type: 'string'}, size: { options: avatarSizes, control: {type: 'select'}, }, + username: {type: 'string'}, isUsernameVisible: {type: 'boolean'}, + hasFullName: { + type: 'boolean', + description: 'Allows testing `fullName` being empty string or not existing', + }, + fullName: {type: 'string', if: {arg: 'hasFullName', truthy: true}}, + hasEmail: { + type: 'boolean', + description: 'Allows testing `email` being empty string or not existing', + }, + email: {type: 'string', if: {arg: 'hasEmail', truthy: true}}, }, } as ComponentMeta; -const Template: ComponentStory = (args) => ; +const Template: ComponentStory = (args) => ( + +); -export const Primary = Template.bind({}); -Primary.args = { - username: 'Leszek', +export const Simple = Template.bind({}); +Simple.args = { size: avatarSizes[0], + username: 'leszek', isUsernameVisible: true, }; +export const Full: StoryObj = { + render: () => ( + + ), +}; + // We want to test how the avatar colors look like with some ~random usernames. const bulkUsernames = [ // NATO phonetic alphabet (https://en.wikipedia.org/wiki/NATO_phonetic_alphabet) @@ -49,11 +79,11 @@ const bulkUsernames = [ 'Sunita', 'Andrea', 'Christine', 'Irina', 'Laura', 'Linda', 'Marina', 'Carmen', 'Ghulam', 'Vladimir', 'Barbara', 'Angela', 'George', 'Roberto', 'Peng', ]; -export const BulkTest = () => ( +export const BulkColorsTest = () => (
    {bulkUsernames.map((username) => (
    - +
    ))}
    diff --git a/jsapp/js/components/common/avatar.tsx b/jsapp/js/components/common/avatar.tsx index 827ef16fbd..8d0f18db9e 100644 --- a/jsapp/js/components/common/avatar.tsx +++ b/jsapp/js/components/common/avatar.tsx @@ -2,7 +2,7 @@ import React from 'react'; import cx from 'classnames'; import styles from './avatar.module.scss'; -export type AvatarSize = 'l' | 'm' | 's'; +export type AvatarSize = 's' | 'm'; /** * A simple function that generates hsl color from given string. Saturation and @@ -19,18 +19,32 @@ function stringToHSL(string: string, saturation: number, lightness: number) { interface AvatarProps { /** - * First letter of the username would be used as avatar. Whole username would - * be used to generate the color of the avatar. + * It is not recommended to display full name or email with `s` size. */ - username: string; + size: AvatarSize; /** - * Username is not being displayed by default. + * First letter of the username would be used as avatar. Whole username would + * be used to generate the color of the avatar. If `isUsernameVisible` is + * being used, username will be displayed next to the avatar. */ + username: string; + /** Username is required, but will not be displayed by default. */ isUsernameVisible?: boolean; - size: AvatarSize; + fullName?: string; + email?: string; } +/** + * Displays an avatar (a letter in a circle) and optionally also username, full + * name and email. + */ export default function Avatar(props: AvatarProps) { + const isAnyTextBeingDisplayed = ( + props.isUsernameVisible || + props.fullName !== undefined || + props.email !== undefined + ); + return (
    - {props.isUsernameVisible && - + {isAnyTextBeingDisplayed && +
    + {props.fullName !== undefined && + {props.fullName} + } + + {/* Sometimes will be prefixed with "@" symbol */} + {props.isUsernameVisible && + {props.username} + } + + {props.email !== undefined && +
    {props.email}
    + } +
    }
    ); diff --git a/jsapp/js/components/common/badge.module.scss b/jsapp/js/components/common/badge.module.scss index 89b48f53de..7c2943213b 100644 --- a/jsapp/js/components/common/badge.module.scss +++ b/jsapp/js/components/common/badge.module.scss @@ -50,6 +50,11 @@ $badge-font-l: 14px; background-color: colors.$kobo-light-teal; } +.color-light-green { + color: colors.$kobo-dark-green; + background-color: colors.$kobo-light-green; +} + @mixin badge-size($size, $font) { // NOTE: icon size is already handled by badge.tsx file rendering proper component height: $size; @@ -57,7 +62,11 @@ $badge-font-l: 14px; line-height: $size; font-size: $font; border-radius: $size * 0.5; - padding: 0 $size * 0.4; + padding: 0 $size * 0.2; + + &.hasLabel { + padding: 0 $size * 0.4; + } .icon + .label { margin-left: $size * 0.15; diff --git a/jsapp/js/components/common/badge.stories.tsx b/jsapp/js/components/common/badge.stories.tsx index a21386faa9..17b75dccd3 100644 --- a/jsapp/js/components/common/badge.stories.tsx +++ b/jsapp/js/components/common/badge.stories.tsx @@ -9,7 +9,9 @@ const badgeColors: BadgeColor[] = [ 'light-storm', 'light-amber', 'light-blue', + 'light-red', 'light-teal', + 'light-green', ]; const badgeSizes: BadgeSize[] = ['s', 'm', 'l']; diff --git a/jsapp/js/components/common/badge.tsx b/jsapp/js/components/common/badge.tsx index 57bd258aca..5706ffb49d 100644 --- a/jsapp/js/components/common/badge.tsx +++ b/jsapp/js/components/common/badge.tsx @@ -1,6 +1,5 @@ import React from 'react'; import cx from 'classnames'; -import {ButtonToIconMap} from 'js/components/common/button'; import styles from './badge.module.scss'; import type {IconName} from 'jsapp/fonts/k-icons'; import Icon from './icon'; @@ -11,7 +10,8 @@ export type BadgeColor = | 'light-amber' | 'light-blue' | 'light-red' - | 'light-teal'; + | 'light-teal' + | 'light-green'; export type BadgeSize = 'l' | 'm' | 's'; export const BadgeToIconMap: Map = new Map(); @@ -23,7 +23,8 @@ interface BadgeProps { color: BadgeColor; size: BadgeSize; icon?: IconName; - label: React.ReactNode; + /** Optional to allow icon-only badges */ + label?: React.ReactNode; /** * Use it to ensure that the badge will always be display in whole. Without * this (the default behaviour) the badge will take as much space as it gets, @@ -39,16 +40,21 @@ export default function Badge(props: BadgeProps) { styles.root, styles[`color-${props.color}`], styles[`size-${props.size}`], - ], {[styles.disableShortening]: props.disableShortening})} + ], { + [styles.disableShortening]: props.disableShortening, + [styles.hasLabel]: props.label !== undefined, + })} > {props.icon && ( )} - {props.label} + {props.label && ( + {props.label} + )}
    ); } diff --git a/jsapp/js/components/common/inlineMessage.scss b/jsapp/js/components/common/inlineMessage.scss index 91a6e95f36..3331b59ecc 100644 --- a/jsapp/js/components/common/inlineMessage.scss +++ b/jsapp/js/components/common/inlineMessage.scss @@ -13,11 +13,11 @@ line-height: 22px; a { - text-decoration: underline; - color: inherit; + text-decoration: none; + color: colors.$kobo-dark-blue; &:hover { - text-decoration: none; + text-decoration: underline; } } diff --git a/jsapp/js/components/header/accountMenu.tsx b/jsapp/js/components/header/accountMenu.tsx index 6963459bc1..66c94c6dc9 100644 --- a/jsapp/js/components/header/accountMenu.tsx +++ b/jsapp/js/components/header/accountMenu.tsx @@ -88,12 +88,12 @@ export default function AccountMenu() { - - - - - {accountName} - {accountEmail} + {/* diff --git a/jsapp/js/components/header/organizationBadge.component.tsx b/jsapp/js/components/header/organizationBadge.component.tsx index b7fd7e6512..534de9c5c3 100644 --- a/jsapp/js/components/header/organizationBadge.component.tsx +++ b/jsapp/js/components/header/organizationBadge.component.tsx @@ -1,4 +1,5 @@ -import {useOrganizationQuery} from 'jsapp/js/account/stripe.api'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; + import styles from './organizationBadge.module.scss'; export default function OrganizationBadge() { diff --git a/jsapp/js/components/modals/koboModal.scss b/jsapp/js/components/modals/koboModal.scss index a314f6c703..e50111c307 100644 --- a/jsapp/js/components/modals/koboModal.scss +++ b/jsapp/js/components/modals/koboModal.scss @@ -137,7 +137,6 @@ $kobo-modal-header-icon-margin: 10px; &.kobo-modal__footer--end { justify-content: flex-end; } - } // If the KoboModalContent component is used together with the KoboModalFooter diff --git a/jsapp/js/components/special/koboAccessibleSelect.module.scss b/jsapp/js/components/special/koboAccessibleSelect.module.scss index 2fab6f334e..406f5cd487 100644 --- a/jsapp/js/components/special/koboAccessibleSelect.module.scss +++ b/jsapp/js/components/special/koboAccessibleSelect.module.scss @@ -16,6 +16,18 @@ $input-color: colors.$kobo-gray-800; font-size: 12px; } +.m { + width: 60%; +} + +.s { + width: 8em; +} + +.fit { + width: min-content; +} + // Dropdown trigger. // A focusable input that receives keyboard shortcuts and mouse clicks .trigger { @@ -93,7 +105,9 @@ $k-select-menu-padding: 6px; // Menu containing a list of options. .menu { display: none; - &[data-expanded='true'] {display: block;} + &[data-expanded='true'] { + display: block; + } user-select: none; position: absolute; diff --git a/jsapp/js/components/special/koboAccessibleSelect.tsx b/jsapp/js/components/special/koboAccessibleSelect.tsx index cab407999b..dedaeb4d97 100644 --- a/jsapp/js/components/special/koboAccessibleSelect.tsx +++ b/jsapp/js/components/special/koboAccessibleSelect.tsx @@ -40,17 +40,21 @@ export default function KoboSelect3(props: KoboSelect3Props) { return NOTHING_SELECTED; } }; - const findPropOption = () => ( - props.options.find((o) => o.value === props.value) || NOTHING_SELECTED - ); - const indexOfPropOption = () => ( - props.options.findIndex((o) => o.value === props.value) - ); + const findPropOption = () => + props.options.find((o) => o.value === props.value) || NOTHING_SELECTED; + const indexOfPropOption = () => + props.options.findIndex((o) => o.value === props.value); const homeIndex = () => (props.isClearable ? -1 : 0); // first or deselection - const endIndex = () => (props.options.length - 1); - const openMenu = () => {setExpanded(true);}; - const closeMenu = () => {setExpanded(false);}; - const toggleMenu = () => {setExpanded((b) => !b);}; + const endIndex = () => props.options.length - 1; + const openMenu = () => { + setExpanded(true); + }; + const closeMenu = () => { + setExpanded(false); + }; + const toggleMenu = () => { + setExpanded((b) => !b); + }; const resetRefs = () => { // Reset refs to prop value; we do this in more than one place indexRef.current = indexOfPropOption(); @@ -77,11 +81,10 @@ export default function KoboSelect3(props: KoboSelect3Props) { const combining = /[\u0300-\u036F]/g; return str.normalize('NFKD').replace(combining, ''); }; - const matchesBeginningOf = (needle: string, haystack: string) => ( - closestAscii(haystack).toLowerCase().startsWith( - closestAscii(needle).toLowerCase() - ) - ); + const matchesBeginningOf = (needle: string, haystack: string) => + closestAscii(haystack) + .toLowerCase() + .startsWith(closestAscii(needle).toLowerCase()); const jumpToNextPrefixMatch = (prefix: string) => { const start = (indexRef.current + 1) % props.options.length; for (let i = 0; i < props.options.length; i++) { @@ -97,39 +100,55 @@ export default function KoboSelect3(props: KoboSelect3Props) { // If there's a valid selection, indexRef and optionRef are up-to-date. // Otherwise, indexRef is -1 and optionRef is NOTHING_SELECTED. - if (!expanded) {resetRefs();} + if (!expanded) { + resetRefs(); + } // Do what we need to do if the options list changes useEffect(() => { resetRefs(); - if (expanded) {scrollOptionIntoView();} + if (expanded) { + scrollOptionIntoView(); + } }, [props.options]); // Ensure selected option is visible as the menu opens useEffect(() => { - if (expanded) {scrollOptionIntoView();} + if (expanded) { + scrollOptionIntoView(); + } }, [expanded]); // Refs and helpers for letter cycling / prefix matching. const cycle = useRef(true); const buffer = useRef(''); const lastLetterTime = useRef(0); // millis - const beginMatchMode = () => {cycle.current = false;}; - const cancelMatchMode = () => {cycle.current = true; buffer.current = '';}; + const beginMatchMode = () => { + cycle.current = false; + }; + const cancelMatchMode = () => { + cycle.current = true; + buffer.current = ''; + }; const emulateBrowserSelectLetterMatching = (eventKey: string) => { // Some non-letter keys cancel MATCH mode regardless of timing if (/(Tab|Enter|Esc|Home|End|Arrow|Page)/.test(eventKey)) { - cancelMatchMode(); return; + cancelMatchMode(); + return; } // The rest of this function deals with single-letter keystroke events. - if (eventKey.length > 1) {return;} + if (eventKey.length > 1) { + return; + } // Check what time it is. // If it's been more than a second since the last letter, we revert to // CYCLE mode. const now = Date.now(); // current time in milliseconds - if (now - lastLetterTime.current > 1000) {cancelMatchMode();} + if (now - lastLetterTime.current > 1000) { + cancelMatchMode(); + } lastLetterTime.current = now; // remember time of this letter keystroke // Begin MATCH mode if encountering a new letter while in cycle mode @@ -139,14 +158,19 @@ export default function KoboSelect3(props: KoboSelect3Props) { // ÃĨngstrÃļm (a) // atom // disco (d) - if (cycle.current && buffer.current.length > 0 && + if ( + cycle.current && + buffer.current.length > 0 && // Compare key (e.g. 'a') with first letter of (cycled) buffer, ('aaa') - !matchesBeginningOf(eventKey, buffer.current)) { + !matchesBeginningOf(eventKey, buffer.current) + ) { beginMatchMode(); } // Space ' ' doesn't cycle or start a buffer, but it may appear in a match - if (eventKey === ' ' && buffer.current.length === 0) {return;} + if (eventKey === ' ' && buffer.current.length === 0) { + return; + } // Append the current letter to the letter buffer. buffer.current += eventKey; @@ -245,7 +269,9 @@ export default function KoboSelect3(props: KoboSelect3Props) { forceUpdate(); }; - const triggerBlurHandler = () => {closeMenu();}; + const triggerBlurHandler = () => { + closeMenu(); + }; const triggerMouseDownHandler = (e: React.MouseEvent) => { if (e.button === 0) { toggleMenu(); @@ -269,10 +295,12 @@ export default function KoboSelect3(props: KoboSelect3Props) { } forceUpdate(); }; - const preventDefault = (e: React.UIEvent) => {e.preventDefault();}; + const preventDefault = (e: React.UIEvent) => { + e.preventDefault(); + }; return ( -
    +
    ))}
    - + {/* Like other input fields */} - {props.error &&

    - {props.error} -

    } + {props.error &&

    {props.error}

    }
    ); } @@ -371,9 +402,9 @@ interface KoboSelect3Props { // isPending?: boolean; // TODO // design system - // size?: 'l' | 'm' | 's'; // oops, all 'l'. + // size?: 'l' | 'm' | 's' | 'fit'; // 'fit' uses min-content // type?: 'blue' | 'gray' | 'outline'; // oops, all 'outline' - size?: 'l'; + size?: 'l' | 'm' | 's' | 'fit'; type?: 'outline'; noMaxMenuHeight?: boolean; // Override 4.5 item height limit diff --git a/jsapp/js/components/usageLimits/useExceedingLimits.hook.ts b/jsapp/js/components/usageLimits/useExceedingLimits.hook.ts index 2aa831db66..a258e40426 100644 --- a/jsapp/js/components/usageLimits/useExceedingLimits.hook.ts +++ b/jsapp/js/components/usageLimits/useExceedingLimits.hook.ts @@ -8,6 +8,7 @@ import {when} from 'mobx'; import subscriptionStore from 'js/account/subscriptionStore'; import {UsageContext} from 'js/account/usage/useUsage.hook'; import {ProductsContext} from 'jsapp/js/account/useProducts.hook'; +import {OneTimeAddOnsContext} from 'jsapp/js/account/useOneTimeAddonList.hook'; interface SubscribedState { subscribedProduct: null | SubscriptionInfo; @@ -25,6 +26,7 @@ export const useExceedingLimits = () => { const [state, dispatch] = useReducer(subscriptionReducer, initialState); const [usage, _, usageStatus] = useContext(UsageContext); const [productsContext] = useContext(ProductsContext); + const oneTimeAddOnsContext = useContext(OneTimeAddOnsContext); const [exceedList, setExceedList] = useState([]); const [warningList, setWarningList] = useState([]); @@ -45,16 +47,24 @@ export const useExceedingLimits = () => { // Get products and get default limits for community plan useWhenStripeIsEnabled(() => { - getAccountLimits(productsContext.products).then((limits) => { - setSubscribedSubmissionLimit(limits.submission_limit); - setSubscribedStorageLimit(limits.storage_bytes_limit); - setTranscriptionMinutes( - convertSecondsToMinutes(Number(limits.nlp_seconds_limit)) - ); - setTranslationChars(Number(limits.nlp_character_limit)); - setAreLimitsLoaded(true); - }); - }, [productsContext.products]); + if (productsContext.isLoaded && oneTimeAddOnsContext.isLoaded) { + getAccountLimits( + productsContext.products, + oneTimeAddOnsContext.oneTimeAddOns + ).then((limits) => { + setSubscribedSubmissionLimit(limits.remainingLimits.submission_limit); + setSubscribedStorageLimit(limits.remainingLimits.storage_bytes_limit); + setTranscriptionMinutes(limits.remainingLimits.asr_seconds_limit); + setTranslationChars(limits.remainingLimits.mt_characters_limit); + setAreLimitsLoaded(true); + }); + } + }, [ + productsContext.isLoaded, + productsContext.products, + oneTimeAddOnsContext.isLoaded, + oneTimeAddOnsContext.oneTimeAddOns, + ]); // Get subscription data useWhenStripeIsEnabled( diff --git a/jsapp/js/constants.ts b/jsapp/js/constants.ts index 7a817b0e42..64d48bdfe7 100644 --- a/jsapp/js/constants.ts +++ b/jsapp/js/constants.ts @@ -28,8 +28,10 @@ export const ROOT_URL = (() => { ); let rootPath = ''; if (rootPathEl === null) { - console.error('no kpi-root-path meta tag set. defaulting to ""'); - rootPath = ''; + // @ts-expect-error: ℹī¸ global 'expect' indicates we're in a unit test + if (!globalThis.expect) { + console.error('no kpi-root-path meta tag set. defaulting to ""'); + } } else { // Strip trailing slashes rootPath = rootPathEl.content.replace(/\/*$/, ''); diff --git a/jsapp/js/envStore.ts b/jsapp/js/envStore.ts index 6e74ab6a46..23621c92a2 100644 --- a/jsapp/js/envStore.ts +++ b/jsapp/js/envStore.ts @@ -44,6 +44,7 @@ export interface EnvironmentResponse { */ terms_of_service__sitewidemessage__exists: boolean; open_rosa_server: string; + project_history_log_lifespan: number; } /* diff --git a/jsapp/js/featureFlags.ts b/jsapp/js/featureFlags.ts index 30ec3015b6..9e5c7884db 100644 --- a/jsapp/js/featureFlags.ts +++ b/jsapp/js/featureFlags.ts @@ -4,7 +4,8 @@ */ export enum FeatureFlag { activityLogsEnabled = 'activityLogsEnabled', - mmosEnabled = 'mmosEnabled' + mmosEnabled = 'mmosEnabled', + oneTimeAddonsEnabled = 'oneTimeAddonsEnabled' } /** diff --git a/jsapp/js/projects/myOrgProjectsRoute.tsx b/jsapp/js/projects/myOrgProjectsRoute.tsx new file mode 100644 index 0000000000..6ae77d46a2 --- /dev/null +++ b/jsapp/js/projects/myOrgProjectsRoute.tsx @@ -0,0 +1,52 @@ +// Libraries +import React, {useState, useEffect} from 'react'; + +// Partial components +import UniversalProjectsRoute from './universalProjectsRoute'; +import LoadingSpinner from 'js/components/common/loadingSpinner'; + +// Stores, hooks and utilities +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; + +// Constants and types +import { + ORG_VIEW, + HOME_ORDERABLE_FIELDS, + HOME_DEFAULT_VISIBLE_FIELDS, + HOME_EXCLUDED_FIELDS, +} from './projectViews/constants'; +import {ROOT_URL} from 'js/constants'; +import {endpoints} from 'js/api.endpoints'; + +/** + * Component responsible for rendering organization projects route + * (`#/organization/projects`). + */ +export default function MyOrgProjectsRoute() { + const orgQuery = useOrganizationQuery(); + const [apiUrl, setApiUrl] = useState(null); + + // We need to load organization data to build the api url. + useEffect(() => { + if (orgQuery.data) { + setApiUrl(endpoints.ORG_ASSETS_URL.replace(':organization_id', orgQuery.data.id)); + } + }, [orgQuery.data]); + + // Display spinner until everything is ready to go forward. + if (!apiUrl) { + return ; + } + + return ( + + ); +} diff --git a/jsapp/js/projects/projectViews/constants.ts b/jsapp/js/projects/projectViews/constants.ts index 58ee83b2c5..9c18ef300c 100644 --- a/jsapp/js/projects/projectViews/constants.ts +++ b/jsapp/js/projects/projectViews/constants.ts @@ -5,6 +5,11 @@ export const HOME_VIEW = { name: t('My Projects'), }; +export const ORG_VIEW = { + uid: 'kobo_my_organization_projects', + name: t('##organization name## Projects'), +}; + export interface ProjectsFilterDefinition { fieldName?: ProjectFieldName; condition?: FilterConditionName; diff --git a/jsapp/js/projects/projectViews/viewSwitcher.tsx b/jsapp/js/projects/projectViews/viewSwitcher.tsx index 6f52727525..143e1c6313 100644 --- a/jsapp/js/projects/projectViews/viewSwitcher.tsx +++ b/jsapp/js/projects/projectViews/viewSwitcher.tsx @@ -8,12 +8,13 @@ import cx from 'classnames'; import Icon from 'js/components/common/icon'; import KoboDropdown from 'js/components/common/koboDropdown'; -// Stores +// Stores and hooks import projectViewsStore from './projectViewsStore'; +import {useOrganizationQuery} from 'js/account/organization/organizationQuery'; // Constants import {PROJECTS_ROUTES} from 'js/router/routerConstants'; -import {HOME_VIEW} from './constants'; +import {HOME_VIEW, ORG_VIEW} from './constants'; // Styles import styles from './viewSwitcher.module.scss'; @@ -32,11 +33,14 @@ function ViewSwitcher(props: ViewSwitcherProps) { // We track the menu visibility for the trigger icon. const [isMenuVisible, setIsMenuVisible] = useState(false); const [projectViews] = useState(() => projectViewsStore); + const orgQuery = useOrganizationQuery(); const navigate = useNavigate(); const onOptionClick = (viewUid: string) => { if (viewUid === HOME_VIEW.uid || viewUid === null) { navigate(PROJECTS_ROUTES.MY_PROJECTS); + } else if (viewUid === ORG_VIEW.uid) { + navigate(PROJECTS_ROUTES.MY_ORG_PROJECTS); } else { navigate(PROJECTS_ROUTES.CUSTOM_VIEW.replace(':viewUid', viewUid)); // The store keeps a number of assets of each view, and that number @@ -45,8 +49,16 @@ function ViewSwitcher(props: ViewSwitcherProps) { } }; + const hasMultipleOptions = ( + projectViews.views.length !== 0 || + orgQuery.data?.is_mmo + ); + const organizationName = orgQuery.data?.name || t('Organization'); + let triggerLabel = HOME_VIEW.name; - if (props.selectedViewUid !== HOME_VIEW.uid) { + if (props.selectedViewUid === ORG_VIEW.uid) { + triggerLabel = ORG_VIEW.name.replace('##organization name##', organizationName); + } else if (props.selectedViewUid !== HOME_VIEW.uid) { triggerLabel = projectViews.getView(props.selectedViewUid)?.name || '-'; } @@ -55,9 +67,9 @@ function ViewSwitcher(props: ViewSwitcherProps) { return null; } - // If there are no custom views defined, there's no point in displaying - // the dropdown, we will display a "simple" header. - if (projectViews.views.length === 0) { + // If there is only one option in the switcher, there is no point in making + // this piece of UI interactive. We display a "simple" header instead. + if (!hasMultipleOptions) { return ( + } + {/* This is the list of all options for custom views. These are only being added if custom views are defined (at least one). */} {projectViews.views.map((view) => ( diff --git a/jsapp/js/projects/routes.tsx b/jsapp/js/projects/routes.tsx index 2aa3222dfb..3076166d1c 100644 --- a/jsapp/js/projects/routes.tsx +++ b/jsapp/js/projects/routes.tsx @@ -2,10 +2,14 @@ import React from 'react'; import {Navigate, Route} from 'react-router-dom'; import RequireAuth from 'js/router/requireAuth'; import {PROJECTS_ROUTES} from 'js/router/routerConstants'; +import {RequireOrgPermissions} from 'js/router/RequireOrgPermissions.component'; const MyProjectsRoute = React.lazy( () => import(/* webpackPrefetch: true */ './myProjectsRoute') ); +const MyOrgProjectsRoute = React.lazy( + () => import(/* webpackPrefetch: true */ './myOrgProjectsRoute') +); const CustomViewRoute = React.lazy( () => import(/* webpackPrefetch: true */ './customViewRoute') ); @@ -25,6 +29,19 @@ export default function routes() { } /> + + + + + + } + /> { + const orgQuery = useOrganizationQuery(); + + if (orgQuery.isPending) {return ;} + if (orgQuery.error) {return ;} // TODO: Nicier error page. + + return children; +}; diff --git a/jsapp/js/router/validateOrgPermissions.component.tsx b/jsapp/js/router/RequireOrgPermissions.component.tsx similarity index 83% rename from jsapp/js/router/validateOrgPermissions.component.tsx rename to jsapp/js/router/RequireOrgPermissions.component.tsx index a35ba98f1b..e0e559819e 100644 --- a/jsapp/js/router/validateOrgPermissions.component.tsx +++ b/jsapp/js/router/RequireOrgPermissions.component.tsx @@ -1,8 +1,11 @@ -import React, {Suspense, useEffect} from 'react'; +import type React from 'react'; +import {Suspense, useEffect} from 'react'; import {useNavigate} from 'react-router-dom'; import LoadingSpinner from 'js/components/common/loadingSpinner'; -import {useOrganizationQuery} from 'js/account/stripe.api'; -import {OrganizationUserRole} from '../account/stripe.types'; +import { + useOrganizationQuery, + OrganizationUserRole, +} from 'js/account/organization/organizationQuery'; interface Props { children: React.ReactNode; @@ -16,7 +19,7 @@ interface Props { * or members of MMOs. Defaults to allowing access for all users, so you must supply * any restrictions. */ -export const ValidateOrgPermissions = ({ +export const RequireOrgPermissions = ({ children, redirectRoute, validRoles = undefined, diff --git a/jsapp/js/router/router.tsx b/jsapp/js/router/router.tsx index ef5b719f20..70012e873f 100644 --- a/jsapp/js/router/router.tsx +++ b/jsapp/js/router/router.tsx @@ -52,7 +52,7 @@ export const router = createHashRouter( element={} /> {accountRoutes()} - {projectsRoutes()} + {projectsRoutes()} } /> { const DEFAULT_COLUMN_SIZE = { size: 200, // starting column size - minSize: 100, // enforced during column resizing + minSize: 60, // enforced during column resizing maxSize: 600, // enforced during column resizing }; diff --git a/jsapp/scss/components/_kobo.navigation.scss b/jsapp/scss/components/_kobo.navigation.scss index 89e7a5dfc6..ba7fb22344 100644 --- a/jsapp/scss/components/_kobo.navigation.scss +++ b/jsapp/scss/components/_kobo.navigation.scss @@ -140,30 +140,7 @@ overflow-y: auto; .account-box__menu-item--avatar { - display: inline-block; - margin-right: 15px; - vertical-align: top; - } - - .account-box__menu-item--mini-profile { - color: colors.$kobo-gray-700; - display: inline-block; - width: calc(100% - 70px); - line-height: 20px; - margin-top: 5px; margin-bottom: 12px; - - span { - display: inline-block; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - width: 100%; - } - - .account-username { - font-size: 18px; - } } .account-box__menu-item--settings { @@ -245,9 +222,8 @@ // Form title + desc in header, editable -// On smaller screens we hide both of them -.main-header .main-header__icon, -.main-header { +// On smaller screens we hide the icon +.main-header .main-header__icon { display: none; } @@ -310,8 +286,7 @@ } @include breakpoints.breakpoint(mediumAndUp) { - .main-header .main-header__icon, - .main-header { + .main-header .main-header__icon { display: initial; } diff --git a/jsapp/svg-icons/users.svg b/jsapp/svg-icons/users.svg new file mode 100644 index 0000000000..86cac0349c --- /dev/null +++ b/jsapp/svg-icons/users.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/kobo/apps/audit_log/audit_actions.py b/kobo/apps/audit_log/audit_actions.py index a85b3ef563..d8a29c4c33 100644 --- a/kobo/apps/audit_log/audit_actions.py +++ b/kobo/apps/audit_log/audit_actions.py @@ -14,6 +14,7 @@ class AuditAction(models.TextChoices): DISABLE_SHARING = 'disable-sharing' DISCONNECT_PROJECT = 'disconnect-project' ENABLE_SHARING = 'enable-sharing' + EXPORT = 'export' IN_TRASH = 'in-trash' MODIFY_IMPORTED_FIELDS = 'modify-imported-fields' MODIFY_SERVICE = 'modify-service' diff --git a/kobo/apps/audit_log/base_views.py b/kobo/apps/audit_log/base_views.py index 1632772768..f697f267ca 100644 --- a/kobo/apps/audit_log/base_views.py +++ b/kobo/apps/audit_log/base_views.py @@ -83,8 +83,8 @@ def perform_destroy(self, instance): field_label = field[0] if isinstance(field, tuple) else field value = get_nested_field(instance, field_path) audit_log_data[field_label] = value - self.request._request.initial_data = audit_log_data self.perform_destroy_override(instance) + self.request._request.initial_data = audit_log_data def perform_destroy_override(self, instance): super().perform_destroy(instance) diff --git a/kobo/apps/audit_log/models.py b/kobo/apps/audit_log/models.py index 2bc70292cb..1778aaaa59 100644 --- a/kobo/apps/audit_log/models.py +++ b/kobo/apps/audit_log/models.py @@ -333,6 +333,8 @@ def create_from_request(cls, request): 'paired-data-list': cls.create_from_paired_data_request, 'asset-file-detail': cls.create_from_file_request, 'asset-file-list': cls.create_from_file_request, + 'asset-export-list': cls.create_from_export_request, + 'exporttask-list': cls.create_from_v1_export, } url_name = request.resolver_match.url_name method = url_name_to_action.get(url_name, None) @@ -491,6 +493,10 @@ def create_from_paired_data_request(cls, request): AuditAction.MODIFY_IMPORTED_FIELDS, ) + @classmethod + def create_from_export_request(cls, request): + cls.create_from_related_request(request, None, AuditAction.EXPORT, None, None) + @staticmethod def sharing_change(old_fields, new_fields): old_enabled = old_fields.get('enabled', False) @@ -550,17 +556,21 @@ def create_from_related_request( 'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, 'ip_address': get_client_ip(request), 'source': get_human_readable_client_user_agent(request), - label: source_data, } + if label: + metadata.update({label: source_data}) if updated_data is None: action = delete_action elif initial_data is None: action = add_action else: action = modify_action - ProjectHistoryLog.objects.create( - user=request.user, object_id=object_id, action=action, metadata=metadata - ) + if action: + # some actions on related objects do not need to be logged, + # eg deleting an ExportTask + ProjectHistoryLog.objects.create( + user=request.user, object_id=object_id, action=action, metadata=metadata + ) @classmethod def create_from_import_task(cls, task: ImportTask): @@ -598,3 +608,20 @@ def create_from_import_task(cls, task: ImportTask): action=AuditAction.UPDATE_NAME, metadata=metadata, ) + + @classmethod + def create_from_v1_export(cls, request): + updated_data = getattr(request, 'updated_data', None) + if not updated_data: + return + ProjectHistoryLog.objects.create( + user=request.user, + object_id=updated_data['asset_id'], + action=AuditAction.EXPORT, + metadata={ + 'asset_uid': updated_data['asset_uid'], + 'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + 'ip_address': get_client_ip(request), + 'source': get_human_readable_client_user_agent(request), + }, + ) diff --git a/kobo/apps/audit_log/signals.py b/kobo/apps/audit_log/signals.py index cd143bf208..daab6dc9cb 100644 --- a/kobo/apps/audit_log/signals.py +++ b/kobo/apps/audit_log/signals.py @@ -5,6 +5,7 @@ from kpi.models import ImportTask from kpi.tasks import import_in_background from kpi.utils.log import logging + from .models import AccessLog, ProjectHistoryLog diff --git a/kobo/apps/audit_log/tasks.py b/kobo/apps/audit_log/tasks.py index b7aae9f272..e3f86fefe8 100644 --- a/kobo/apps/audit_log/tasks.py +++ b/kobo/apps/audit_log/tasks.py @@ -8,6 +8,7 @@ from kobo.apps.audit_log.models import ( AccessLog, AuditLog, + ProjectHistoryLog, ) from kobo.celery import celery_app from kpi.utils.log import logging @@ -20,26 +21,32 @@ def batch_delete_audit_logs_by_id(ids): logging.info(f'Deleted {count} audit logs from database') -@celery_app.task() -def spawn_access_log_cleaning_tasks(): - """ - Enqueue tasks to delete access logs older than ACCESS_LOG_LIFESPAN days old. +def enqueue_logs_for_deletion(LogModel: AuditLog, log_lifespan: int): + """Delete the logs for an audit log proxy model considering a lifespan + given in number of days. - ACCESS_LOG_LIFESPAN is configured via constance. Ids are batched into multiple tasks. """ - expiration_date = timezone.now() - timedelta( - days=config.ACCESS_LOG_LIFESPAN + days=log_lifespan ) expired_logs = ( - AccessLog.objects.filter(date_created__lt=expiration_date) + LogModel.objects.filter(date_created__lt=expiration_date) .values_list('id', flat=True) .iterator() ) for id_batch in chunked( - expired_logs, settings.ACCESS_LOG_DELETION_BATCH_SIZE + expired_logs, settings.LOG_DELETION_BATCH_SIZE ): # queue up a new task for each batch of expired ids batch_delete_audit_logs_by_id.delay(ids=id_batch) + + +@celery_app.task() +def spawn_logs_cleaning_tasks(): + """ + Enqueue tasks to delete logs older than the configured lifespan + """ + enqueue_logs_for_deletion(AccessLog, config.ACCESS_LOG_LIFESPAN) + enqueue_logs_for_deletion(ProjectHistoryLog, config.PROJECT_HISTORY_LOG_LIFESPAN) diff --git a/kobo/apps/audit_log/tests/test_project_history_logs.py b/kobo/apps/audit_log/tests/test_project_history_logs.py index e386bc4733..693a072eef 100644 --- a/kobo/apps/audit_log/tests/test_project_history_logs.py +++ b/kobo/apps/audit_log/tests/test_project_history_logs.py @@ -807,3 +807,58 @@ def test_create_from_import_task(self, file_or_url, change_name, use_v2): NEW: new_asset.name, }, ) + + def test_export_creates_log(self): + self.asset.deploy(backend='mock', active=True) + request_data = { + 'fields_from_all_versions': True, + 'fields': [], + 'group_sep': '/', + 'hierarchy_in_labels': False, + 'lang': '_default', + 'multiple_select': 'both', + 'type': 'xls', + 'xls_types_as_text': False, + 'include_media_url': True, + } + self._base_project_history_log_test( + method=self.client.post, + url=reverse( + 'api_v2:asset-export-list', + kwargs={ + 'parent_lookup_asset': self.asset.uid, + }, + ), + expected_action=AuditAction.EXPORT, + request_data=request_data, + expected_subtype=PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + ) + + def test_export_v1_creates_log(self): + self.asset.deploy(backend='mock', active=True) + request_data = { + 'fields_from_all_versions': True, + 'fields': [], + 'group_sep': '/', + 'hierarchy_in_labels': False, + 'lang': '_default', + 'multiple_select': 'both', + 'type': 'xls', + 'xls_types_as_text': False, + 'include_media_url': True, + 'source': reverse('api_v2:asset-detail', kwargs={'uid': self.asset.uid}), + } + # can't use _base_project_history_log_test because + # the old endpoint doesn't like format=json + self.client.post( + path=reverse('exporttask-list'), + data=request_data, + ) + + log_query = ProjectHistoryLog.objects.filter( + metadata__asset_uid=self.asset.uid, action=AuditAction.EXPORT + ) + self.assertEqual(log_query.count(), 1) + log = log_query.first() + self._check_common_metadata(log.metadata, PROJECT_HISTORY_LOG_PROJECT_SUBTYPE) + self.assertEqual(log.object_id, self.asset.id) diff --git a/kobo/apps/audit_log/tests/test_tasks.py b/kobo/apps/audit_log/tests/test_tasks.py index b2042f55f6..b593dc004d 100644 --- a/kobo/apps/audit_log/tests/test_tasks.py +++ b/kobo/apps/audit_log/tests/test_tasks.py @@ -5,12 +5,18 @@ from django.test import override_settings from django.utils import timezone -from kobo.apps.audit_log.models import AccessLog +from kobo.apps.audit_log.models import ( + AccessLog, + ProjectHistoryLog, +) from kobo.apps.audit_log.tasks import ( batch_delete_audit_logs_by_id, - spawn_access_log_cleaning_tasks, + enqueue_logs_for_deletion, + spawn_logs_cleaning_tasks, ) from kobo.apps.kobo_auth.shortcuts import User +from kpi.constants import PROJECT_HISTORY_LOG_PROJECT_SUBTYPE +from kpi.models import Asset from kpi.tests.base_test_case import BaseTestCase @@ -19,27 +25,57 @@ class AuditLogTasksTestCase(BaseTestCase): fixtures = ['test_data'] + def setUp(self): + self.user = User.objects.get(username='someuser') + self.three_days_ago = timezone.now() - timedelta(days=3) + self.asset = Asset.objects.get(pk=1) + + def test_delete_logs(self): + AccessLog.objects.create( + user=self.user, date_created=self.three_days_ago + ) + ProjectHistoryLog.objects.create( + user=self.user, + date_created=self.three_days_ago, + metadata={ + 'asset_uid': self.asset.uid, + 'ip_address': '0.0.0.0', + 'source': 'source', + 'log_subtype': PROJECT_HISTORY_LOG_PROJECT_SUBTYPE, + 'latest_version_uid': '0', + }, + object_id=self.asset.id, + ) + + self.assertEqual(AccessLog.objects.count(), 1) + enqueue_logs_for_deletion(AccessLog, 1) + self.assertEqual(AccessLog.objects.count(), 0) + # Ensure that the delete_logs function only deletes the objects for the given + # log class + self.assertEqual(ProjectHistoryLog.objects.count(), 1) + enqueue_logs_for_deletion(ProjectHistoryLog, 1) + self.assertEqual(ProjectHistoryLog.objects.count(), 0) + def test_spawn_deletion_task_identifies_expired_logs(self): """ Test the spawning task correctly identifies which logs to delete. Separated for easier debugging of the spawning vs deleting steps """ - user = User.objects.get(username='someuser') old_log = AccessLog.objects.create( - user=user, + user=self.user, date_created=timezone.now() - timedelta(days=1, hours=1), ) older_log = AccessLog.objects.create( - user=user, + user=self.user, date_created=timezone.now() - timedelta(days=2) ) - new_log = AccessLog.objects.create(user=user) + new_log = AccessLog.objects.create(user=self.user) with patch( 'kobo.apps.audit_log.tasks.batch_delete_audit_logs_by_id.delay' ) as patched_spawned_task: - spawn_access_log_cleaning_tasks() + spawn_logs_cleaning_tasks() # get the list of ids passed for any call to the actual deletion task id_lists = [kwargs['ids'] for _, _, kwargs in patched_spawned_task.mock_calls] @@ -49,28 +85,26 @@ def test_spawn_deletion_task_identifies_expired_logs(self): self.assertIn(older_log.id, all_deleted_ids) self.assertNotIn(new_log.id, all_deleted_ids) - @override_settings(ACCESS_LOG_DELETION_BATCH_SIZE=2) + @override_settings(LOG_DELETION_BATCH_SIZE=2) def test_spawn_task_batches_ids(self): - three_days_ago = timezone.now() - timedelta(days=3) - user = User.objects.get(username='someuser') old_log_1 = AccessLog.objects.create( - user=user, date_created=three_days_ago + user=self.user, date_created=self.three_days_ago ) old_log_2 = AccessLog.objects.create( - user=user, date_created=three_days_ago + user=self.user, date_created=self.three_days_ago ) old_log_3 = AccessLog.objects.create( - user=user, date_created=three_days_ago + user=self.user, date_created=self.three_days_ago ) with patch( 'kobo.apps.audit_log.tasks.batch_delete_audit_logs_by_id.delay' ) as patched_spawned_task: - spawn_access_log_cleaning_tasks() + spawn_logs_cleaning_tasks() # Should be 2 batches self.assertEqual(patched_spawned_task.call_count, 2) - # make sure all batches were <= ACCESS_LOG_DELETION_BATCH_SIZE + # make sure all batches were <= LOG_DELETION_BATCH_SIZE all_deleted_ids = [] for task_call in patched_spawned_task.mock_calls: _, _, kwargs = task_call @@ -84,10 +118,9 @@ def test_spawn_task_batches_ids(self): self.assertIn(old_log_3.id, all_deleted_ids) def test_batch_delete_audit_logs_by_id(self): - user = User.objects.get(username='someuser') - log_1 = AccessLog.objects.create(user=user) - log_2 = AccessLog.objects.create(user=user) - log_3 = AccessLog.objects.create(user=user) + log_1 = AccessLog.objects.create(user=self.user) + log_2 = AccessLog.objects.create(user=self.user) + log_3 = AccessLog.objects.create(user=self.user) self.assertEqual(AccessLog.objects.count(), 3) batch_delete_audit_logs_by_id(ids=[log_1.id, log_2.id]) diff --git a/kobo/apps/hook/tasks.py b/kobo/apps/hook/tasks.py index 4d2521dcff..09d25d4084 100644 --- a/kobo/apps/hook/tasks.py +++ b/kobo/apps/hook/tasks.py @@ -143,10 +143,10 @@ def failures_reports(): html_content = html_template.render(variables) msg = EmailMultiAlternatives( - translation.gettext('REST Services Failure Report'), - text_content, - constance.config.SUPPORT_EMAIL, - [record.get('email')], + subject=translation.gettext('REST Services Failure Report'), + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[record.get('email')], ) msg.attach_alternative(html_content, 'text/html') email_messages.append(msg) diff --git a/kobo/apps/hook/tests/hook_test_case.py b/kobo/apps/hook/tests/hook_test_case.py index 6dc51ce796..bdf20dec0b 100644 --- a/kobo/apps/hook/tests/hook_test_case.py +++ b/kobo/apps/hook/tests/hook_test_case.py @@ -2,6 +2,7 @@ import uuid from kpi.tests.kpi_test_case import KpiTestCase + from ..models import Hook from ..utils.tests.mixins import HookTestCaseMixin diff --git a/kobo/apps/hook/utils/tests/mixins.py b/kobo/apps/hook/utils/tests/mixins.py index 6453751044..d166d78d94 100644 --- a/kobo/apps/hook/utils/tests/mixins.py +++ b/kobo/apps/hook/utils/tests/mixins.py @@ -5,11 +5,11 @@ from django.urls import reverse from rest_framework import status -from kpi.constants import SUBMISSION_FORMAT_TYPE_JSON, SUBMISSION_FORMAT_TYPE_XML -from kpi.exceptions import BadFormatException from kobo.apps.hook.constants import HOOK_LOG_FAILED from kobo.apps.hook.exceptions import HookRemoteServerDownError from kobo.apps.hook.models import HookLog +from kpi.constants import SUBMISSION_FORMAT_TYPE_JSON, SUBMISSION_FORMAT_TYPE_XML +from kpi.exceptions import BadFormatException class HookTestCaseMixin: @@ -31,15 +31,13 @@ def _create_hook(self, return_response_only=False, **kwargs): data = { 'name': kwargs.get('name', 'some external service with token'), 'endpoint': kwargs.get('endpoint', 'http://external.service.local/'), - 'settings': kwargs.get('settings', { - 'custom_headers': { - 'X-Token': '1234abcd' - } - }), + 'settings': kwargs.get( + 'settings', {'custom_headers': {'X-Token': '1234abcd'}} + ), 'export_type': format_type, 'active': kwargs.get('active', True), 'subset_fields': kwargs.get('subset_fields', []), - 'payload_template': kwargs.get('payload_template', None) + 'payload_template': kwargs.get('payload_template', None), } response = self.client.post(url, data, format='json') @@ -98,10 +96,13 @@ def _send_and_wait_for_retry(self): service_definition.send() # Retrieve the corresponding log - url = reverse('hook-log-list', kwargs={ - 'parent_lookup_asset': self.hook.asset.uid, - 'parent_lookup_hook': self.hook.uid - }) + url = reverse( + 'hook-log-list', + kwargs={ + 'parent_lookup_asset': self.hook.asset.uid, + 'parent_lookup_hook': self.hook.uid, + }, + ) response = self.client.get(url) first_hooklog_response = response.data.get('results')[0] diff --git a/kobo/apps/kobo_auth/models.py b/kobo/apps/kobo_auth/models.py index f81891682d..4aec847447 100644 --- a/kobo/apps/kobo_auth/models.py +++ b/kobo/apps/kobo_auth/models.py @@ -6,7 +6,7 @@ OPENROSA_APP_LABELS, ) from kobo.apps.openrosa.libs.permissions import get_model_permission_codenames -from kobo.apps.organizations.models import create_organization, Organization +from kobo.apps.organizations.models import Organization, create_organization from kpi.utils.database import update_autofield_sequence, use_db from kpi.utils.permissions import is_user_anonymous @@ -57,9 +57,11 @@ def organization(self): return # Database allows multiple organizations per user, but we restrict it to one. - if organization := Organization.objects.filter( - organization_users__user=self - ).order_by('-organization_users__created').first(): + if ( + organization := Organization.objects.filter(organization_users__user=self) + .order_by('-organization_users__created') + .first() + ): return organization try: diff --git a/kobo/apps/openrosa/apps/api/tests/viewsets/test_xform_submission_api.py b/kobo/apps/openrosa/apps/api/tests/viewsets/test_xform_submission_api.py index cf0319fa7e..a4cacb3661 100644 --- a/kobo/apps/openrosa/apps/api/tests/viewsets/test_xform_submission_api.py +++ b/kobo/apps/openrosa/apps/api/tests/viewsets/test_xform_submission_api.py @@ -378,9 +378,9 @@ def test_post_submission_json_without_submission_key(self): self.assertContains(response, 'No submission key provided.', status_code=400) def test_submission_blocking_flag(self): - # Set 'submissions_suspended' True in the profile metadata to test if + # Set 'submissions_suspended' True in the profile to test if # submission do fail with the flag set - self.xform.user.profile.metadata['submissions_suspended'] = True + self.xform.user.profile.submissions_suspended = True self.xform.user.profile.save() # No need auth for this test @@ -421,7 +421,7 @@ def test_submission_blocking_flag(self): f.seek(0) # check that users can submit data again when flag is removed - self.xform.user.profile.metadata['submissions_suspended'] = False + self.xform.user.profile.submissions_suspended = False self.xform.user.profile.save() request = self.factory.post( @@ -429,3 +429,71 @@ def test_submission_blocking_flag(self): ) response = self.view(request, username=username) self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_submission_customizable_confirmation_message(self): + s = 'transport_with_custom_attribute' + media_file = '1335783522563.jpg' + xml_files = [ + 'transport_with_custom_attribute_01', + 'transport_with_custom_attribute_02', + 'transport_with_no_custom_attribute' + ] + + path = os.path.join( + self.main_directory, + 'fixtures', + 'transportation', + 'instances', + s, + media_file, + ) + with open(path, 'rb') as f: + f = InMemoryUploadedFile( + f, + 'media_file', + media_file, + 'image/jpg', + os.path.getsize(path), + None, + ) + for xml_file in xml_files: + submission_path = os.path.join( + self.main_directory, + 'fixtures', + 'transportation', + 'instances', + s, + xml_file + '.xml', + ) + with open(submission_path) as sf: + data = {'xml_submission_file': sf, 'media_file': f} + request = self.factory.post('/submission', data) + response = self.view(request) + self.assertEqual(response.status_code, 401) + + # rewind the file and redo the request since they were + # consumed + sf.seek(0) + f.seek(0) + request = self.factory.post('/submission', data) + auth = DigestAuth('bob', 'bobbob') + request.META.update(auth(request.META, response)) + response = self.view(request, username=self.user.username) + if xml_file == 'transport_with_custom_attribute_01': + self.assertContains( + response, 'Custom submit message', status_code=201 + ) + elif xml_file == 'transport_with_custom_attribute_02': + self.assertContains( + response, 'Successful submission.', status_code=201 + ) + elif xml_file == ( + 'transport_with_custom_attribute_and_different_root' + ): + self.assertContains( + response, 'Custom submit message', status_code=201 + ) + else: + self.assertContains( + response, 'Successful submission.', status_code=201 + ) diff --git a/kobo/apps/openrosa/apps/api/utils/xml.py b/kobo/apps/openrosa/apps/api/utils/xml.py new file mode 100644 index 0000000000..f98b22c2d0 --- /dev/null +++ b/kobo/apps/openrosa/apps/api/utils/xml.py @@ -0,0 +1,48 @@ +from lxml import etree +from typing import Optional + +from kpi.utils.log import logging + + +def extract_confirmation_message(xml_string: str) -> Optional[str]: + """ + Extracts the confirmation message from the XML string based on the + `kobo:submitMessage` attribute. + """ + if isinstance(xml_string, str): + xml_string = xml_string.encode('utf-8') + parser = etree.XMLParser(recover=True) + root = etree.fromstring(xml_string, parser=parser) + + namespaces = root.nsmap + + # Extract the kobo:submitMessage attribute from the root element + try: + confirmation_message_xpath = root.xpath( + '@kobo:submitMessage', namespaces=namespaces + ) + except (etree.XPathEvalError, TypeError): + return + + if not confirmation_message_xpath: + return + + confirmation_message_xpath = confirmation_message_xpath[0].strip() + _, submit_message_root_tag, *other_parts = ( + confirmation_message_xpath.split('/') + ) + confirmation_message_xpath = f'/{root.tag}/' + '/'.join(other_parts) + + try: + # Evaluate the XPath expression to find the message + confirmation_message_element = root.xpath(confirmation_message_xpath) + except etree.XPathEvalError as e: + logging.error( + 'Failed to extract confirmation message: ' + str(e), + exc_info=True + ) + else: + if confirmation_message_element: + return confirmation_message_element[0].text + + return diff --git a/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py b/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py index 7854c2ab0f..17ae389a95 100644 --- a/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py +++ b/kobo/apps/openrosa/apps/api/viewsets/xform_submission_api.py @@ -27,7 +27,9 @@ TokenAuthentication, ) from kpi.utils.object_permission import get_database_user + from ..utils.rest_framework.viewsets import OpenRosaGenericViewSet +from ..utils.xml import extract_confirmation_message xml_error_re = re.compile('>(.*)<') @@ -187,6 +189,10 @@ def create(self, request, *args, **kwargs): return self.error_response(error, is_json_request, request) context = self.get_serializer_context() + if instance.xml and ( + confirmation_message := extract_confirmation_message(instance.xml) + ): + context['confirmation_message'] = confirmation_message serializer = SubmissionSerializer(instance, context=context) return Response(serializer.data, diff --git a/kobo/apps/openrosa/apps/logger/management/commands/populate_submission_counters.py b/kobo/apps/openrosa/apps/logger/management/commands/populate_submission_counters.py index cbaf2f0142..1943294250 100644 --- a/kobo/apps/openrosa/apps/logger/management/commands/populate_submission_counters.py +++ b/kobo/apps/openrosa/apps/logger/management/commands/populate_submission_counters.py @@ -7,7 +7,7 @@ from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction -from django.db.models import Count, Value, F, DateField +from django.db.models import Count, DateField, F, Value from django.db.models.functions import Cast, Concat from django.utils import timezone @@ -22,14 +22,14 @@ class Command(BaseCommand): - help = "Updates monthly and daily submission counters" + help = 'Updates monthly and daily submission counters' def add_arguments(self, parser): parser.add_argument( '--chunks', type=int, default=2000, - help="Number of records to process per query" + help='Number of records to process per query', ) days_default = settings.DAILY_COUNTERS_MAX_DAYS @@ -38,8 +38,8 @@ def add_arguments(self, parser): type=int, default=days_default, help=( - f"Number of days taken into account to populate the counters. " - f"Default is {days_default}" + f'Number of days taken into account to populate the counters. ' + f'Default is {days_default}' ), ) @@ -212,15 +212,15 @@ def suspend_submissions_for_user(self, user: settings.AUTH_USER_MODEL): user_profile.metadata = {} # Set the flag `submissions_suspended` to true if it is not already. - if not user_profile.metadata.get('submissions_suspended'): + if not user_profile.submissions_suspended: # We are using the flag `submissions_suspended` to prevent # new submissions from coming in while the # counters are being calculated. - user_profile.metadata['submissions_suspended'] = True - user_profile.save(update_fields=['metadata']) + user_profile.submissions_suspended = True + user_profile.save(update_fields=['submissions_suspended']) def release_old_locks(self): - updates = {'submissions_suspended': False} + updates = {} if self._force: updates['counters_updates_status'] = 'not-complete' @@ -231,12 +231,12 @@ def release_old_locks(self): 'metadata', updates=updates, ), + submissions_suspended=False, ) def update_user_profile(self, user: settings.AUTH_USER_MODEL): # Update user's profile (and lock the related row) updates = { - 'submissions_suspended': False, 'counters_updates_status': 'complete', } UserProfile.objects.filter( @@ -246,4 +246,5 @@ def update_user_profile(self, user: settings.AUTH_USER_MODEL): 'metadata', updates=updates, ), + submissions_suspended=False, ) diff --git a/kobo/apps/openrosa/apps/logger/management/commands/update_attachment_storage_bytes.py b/kobo/apps/openrosa/apps/logger/management/commands/update_attachment_storage_bytes.py index 0f6e328964..bdf382c327 100644 --- a/kobo/apps/openrosa/apps/logger/management/commands/update_attachment_storage_bytes.py +++ b/kobo/apps/openrosa/apps/logger/management/commands/update_attachment_storage_bytes.py @@ -3,7 +3,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand -from django.db.models import Sum, OuterRef, Subquery +from django.db.models import OuterRef, Subquery, Sum from kobo.apps.openrosa.apps.logger.models.attachment import Attachment from kobo.apps.openrosa.apps.logger.models.xform import XForm @@ -28,7 +28,7 @@ def add_arguments(self, parser): '--chunks', type=int, default=2000, - help="Number of records to process per query" + help='Number of records to process per query', ) parser.add_argument( @@ -199,24 +199,19 @@ def _lock_user_profile(self, user: settings.AUTH_USER_MODEL): user_profile.metadata = {} # Set the flag to true if it was never set. - if not user_profile.metadata.get('submissions_suspended'): + if not user_profile.submissions_suspended: # We are using the flag `submissions_suspended` to prevent # new submissions from coming in while the # `attachment_storage_bytes` is being calculated. - user_profile.metadata['submissions_suspended'] = True - user_profile.save(update_fields=['metadata']) + user_profile.submissions_suspended = True + user_profile.save(update_fields=['submissions_suspended']) def _release_locks(self): # Release any locks on the users' profile from getting submissions if self._verbosity > 1: self.stdout.write('Releasing submission locksâ€Ļ') - UserProfile.objects.all().update( - metadata=ReplaceValues( - 'metadata', - updates={'submissions_suspended': False}, - ), - ) + UserProfile.objects.all().update(submissions_suspended=False) def _reset_user_profile_counters(self): @@ -251,7 +246,6 @@ def _update_user_profile(self, user: settings.AUTH_USER_MODEL): # Update user's profile (and lock the related row) updates = { - 'submissions_suspended': False, 'attachments_counting_status': 'complete', } @@ -271,4 +265,5 @@ def _update_user_profile(self, user: settings.AUTH_USER_MODEL): 'metadata', updates=updates, ), + submissions_suspended=False, ) diff --git a/kobo/apps/openrosa/apps/logger/migrations/0029_populate_daily_xform_counters_for_year.py b/kobo/apps/openrosa/apps/logger/migrations/0029_populate_daily_xform_counters_for_year.py index 920bc32709..e15b6ec373 100644 --- a/kobo/apps/openrosa/apps/logger/migrations/0029_populate_daily_xform_counters_for_year.py +++ b/kobo/apps/openrosa/apps/logger/migrations/0029_populate_daily_xform_counters_for_year.py @@ -1,31 +1,9 @@ -from django.conf import settings -from django.core.management import call_command from django.db import migrations - -def populate_daily_counts_for_year(apps, schema_editor): - if settings.SKIP_HEAVY_MIGRATIONS: - print( - """ - !!! ATTENTION !!! - If you have existing projects, you need to run this management command: - - > python manage.py populate_submission_counters -f --skip-monthly - - Until you do, total usage counts from the KPI endpoints - /api/v2/service_usage and /api/v2/asset_usage will be incorrect - """ - ) - else: - print( - """ - This might take a while. If it is too slow, you may want to re-run the - migration with SKIP_HEAVY_MIGRATIONS=True and run the following management command: - - > python manage.py populate_submission_counters -f --skip-monthly - """ - ) - call_command('populate_submission_counters', force=True, skip_monthly=True) +# NOTE: +# This migrations was moved to 0037 in order to avoid conflict with the new +# field user_profile.submissions_suspended introduced on main migration 0017. +# This migration is obsolete and will be deleted in a future version. class Migration(migrations.Migration): @@ -38,8 +16,4 @@ class Migration(migrations.Migration): # We don't do anything when migrating in reverse # Just set DAILY_COUNTER_MAX_DAYS back to 31 and counters will be auto-deleted operations = [ - migrations.RunPython( - populate_daily_counts_for_year, - migrations.RunPython.noop, - ), ] diff --git a/kobo/apps/openrosa/apps/logger/migrations/0030_backfill_lost_monthly_counters.py b/kobo/apps/openrosa/apps/logger/migrations/0030_backfill_lost_monthly_counters.py index f72c4c584e..0f72384a90 100644 --- a/kobo/apps/openrosa/apps/logger/migrations/0030_backfill_lost_monthly_counters.py +++ b/kobo/apps/openrosa/apps/logger/migrations/0030_backfill_lost_monthly_counters.py @@ -1,64 +1,4 @@ from django.db import migrations -from django.db.migrations.recorder import MigrationRecorder -from django.db.models import DateField, F, Sum, Value -from django.db.models.functions import Cast, Concat, ExtractMonth, ExtractYear -from django.utils import timezone - -from kobo.apps.openrosa.apps.logger.utils.counters import ( - delete_null_user_daily_counters, -) - - -def populate_missing_monthly_counters(apps, schema_editor): - - DailyXFormSubmissionCounter = apps.get_model('logger', 'DailyXFormSubmissionCounter') # noqa - MonthlyXFormSubmissionCounter = apps.get_model('logger', 'MonthlyXFormSubmissionCounter') # noqa - - if not DailyXFormSubmissionCounter.objects.all().exists(): - return - - previous_migration = MigrationRecorder.Migration.objects.filter( - app='logger', name='0029_populate_daily_xform_counters_for_year' - ).first() - - # Delete monthly counters in the range if any (to avoid conflicts in bulk_create below) - MonthlyXFormSubmissionCounter.objects.annotate( - date=Cast( - Concat( - F('year'), Value('-'), F('month'), Value('-'), 1 - ), - DateField(), - ) - ).filter(date__gte=previous_migration.applied.date().replace(day=1)).delete() - - records = ( - DailyXFormSubmissionCounter.objects.filter( - date__range=[ - previous_migration.applied.date().replace(day=1), - timezone.now().date() - ] - ) - .annotate(year=ExtractYear('date'), month=ExtractMonth('date')) - .values('user_id', 'xform_id', 'month', 'year') - .annotate(total=Sum('counter')) - ).order_by('year', 'month', 'user_id') - - # Do not use `ignore_conflicts=True` to ensure all counters are successfully - # create. - # TODO use `update_conflicts` with Django 4.2 and avoid `.delete()` above - MonthlyXFormSubmissionCounter.objects.bulk_create( - [ - MonthlyXFormSubmissionCounter( - year=r['year'], - month=r['month'], - user_id=r['user_id'], - xform_id=r['xform_id'], - counter=r['total'], - ) - for r in records - ], - batch_size=5000 - ) class Migration(migrations.Migration): @@ -68,12 +8,4 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython( - delete_null_user_daily_counters, - migrations.RunPython.noop, - ), - migrations.RunPython( - populate_missing_monthly_counters, - migrations.RunPython.noop, - ), ] diff --git a/kobo/apps/openrosa/apps/logger/migrations/0031_remove_null_user_daily_counters.py b/kobo/apps/openrosa/apps/logger/migrations/0031_remove_null_user_daily_counters.py index db961696b9..32eddecfe2 100644 --- a/kobo/apps/openrosa/apps/logger/migrations/0031_remove_null_user_daily_counters.py +++ b/kobo/apps/openrosa/apps/logger/migrations/0031_remove_null_user_daily_counters.py @@ -1,21 +1,11 @@ -from django.conf import settings from django.db import migrations -from kobo.apps.openrosa.apps.logger.utils.counters import ( - delete_null_user_daily_counters, -) - class Migration(migrations.Migration): dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('logger', '0030_backfill_lost_monthly_counters'), ] operations = [ - migrations.RunPython( - delete_null_user_daily_counters, - migrations.RunPython.noop, - ), ] diff --git a/kobo/apps/openrosa/apps/logger/migrations/0032_alter_daily_submission_counter_user.py b/kobo/apps/openrosa/apps/logger/migrations/0032_alter_daily_submission_counter_user.py index c70bb7f67d..06dfc802a3 100644 --- a/kobo/apps/openrosa/apps/logger/migrations/0032_alter_daily_submission_counter_user.py +++ b/kobo/apps/openrosa/apps/logger/migrations/0032_alter_daily_submission_counter_user.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('logger', '0031_remove_null_user_daily_counters'), + ('logger', '0028_add_user_to_daily_submission_counters'), ] operations = [ diff --git a/kobo/apps/openrosa/apps/logger/migrations/0039_populate_counters.py b/kobo/apps/openrosa/apps/logger/migrations/0039_populate_counters.py new file mode 100644 index 0000000000..24557bb053 --- /dev/null +++ b/kobo/apps/openrosa/apps/logger/migrations/0039_populate_counters.py @@ -0,0 +1,120 @@ +from django.conf import settings +from django.core.management import call_command +from django.db import migrations +from django.db.migrations.recorder import MigrationRecorder +from django.db.models import DateField, F, Sum, Value +from django.db.models.functions import Cast, Concat, ExtractMonth, ExtractYear +from django.utils import timezone + +from kobo.apps.openrosa.apps.logger.utils.counters import ( + delete_null_user_daily_counters, +) + + +def populate_missing_monthly_counters(apps, schema_editor): + + DailyXFormSubmissionCounter = apps.get_model( + 'logger', 'DailyXFormSubmissionCounter' + ) # noqa + MonthlyXFormSubmissionCounter = apps.get_model( + 'logger', 'MonthlyXFormSubmissionCounter' + ) # noqa + + if not DailyXFormSubmissionCounter.objects.all().exists(): + return + + previous_migration = MigrationRecorder.Migration.objects.filter( + app='logger', name='0029_populate_daily_xform_counters_for_year' + ).first() + + # Delete monthly counters in the range if any + # (to avoid conflicts in bulk_create below) + MonthlyXFormSubmissionCounter.objects.annotate( + date=Cast( + Concat(F('year'), Value('-'), F('month'), Value('-'), 1), + DateField(), + ) + ).filter(date__gte=previous_migration.applied.date().replace(day=1)).delete() + + records = ( + DailyXFormSubmissionCounter.objects.filter( + date__range=[ + previous_migration.applied.date().replace(day=1), + timezone.now().date(), + ] + ) + .annotate(year=ExtractYear('date'), month=ExtractMonth('date')) + .values('user_id', 'xform_id', 'month', 'year') + .annotate(total=Sum('counter')) + ).order_by('year', 'month', 'user_id') + + # Do not use `ignore_conflicts=True` to ensure all counters are successfully + # create. + # TODO use `update_conflicts` with Django 4.2 and avoid `.delete()` above + MonthlyXFormSubmissionCounter.objects.bulk_create( + [ + MonthlyXFormSubmissionCounter( + year=r['year'], + month=r['month'], + user_id=r['user_id'], + xform_id=r['xform_id'], + counter=r['total'], + ) + for r in records + ], + batch_size=5000, + ) + + +def populate_daily_counts_for_year(apps, schema_editor): + if settings.SKIP_HEAVY_MIGRATIONS: + print( + """ + !!! ATTENTION !!! + If you have existing projects, you need to run this management command: + + > python manage.py populate_submission_counters -f --skip-monthly + + Until you do, total usage counts from the KPI endpoints + /api/v2/service_usage and /api/v2/asset_usage will be incorrect + """ + ) + else: + print( + """ + This might take a while. If it is too slow, you may want to re-run the + migration with SKIP_HEAVY_MIGRATIONS=True and run the following management + command: + + > python manage.py populate_submission_counters -f --skip-monthly + """ + ) + call_command('populate_submission_counters', force=True, skip_monthly=True) + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('logger', '0038_add_mongo_uuid_field_to_xform'), + ('main', '0017_userprofile_submissions_suspended'), + ] + + # We don't do anything when migrating in reverse + # Just set DAILY_COUNTER_MAX_DAYS back to 31 and counters will be auto-deleted + operations = [ + migrations.RunPython( + populate_daily_counts_for_year, + migrations.RunPython.noop, + ), + migrations.RunPython( + delete_null_user_daily_counters, + migrations.RunPython.noop, + ), + ] + + replaces = [ + ('logger', '0029_populate_daily_xform_counters_for_year'), + ('logger', '0030_backfill_lost_monthly_counters'), + ('logger', '0031_remove_null_user_daily_counters'), + ] diff --git a/kobo/apps/openrosa/apps/logger/models/instance.py b/kobo/apps/openrosa/apps/logger/models/instance.py index 6a6737078a..d9819ceeee 100644 --- a/kobo/apps/openrosa/apps/logger/models/instance.py +++ b/kobo/apps/openrosa/apps/logger/models/instance.py @@ -1,11 +1,6 @@ # coding: utf-8 from hashlib import sha256 -try: - from zoneinfo import ZoneInfo -except ImportError: - from backports.zoneinfo import ZoneInfo - import reversion from django.apps import apps from django.contrib.gis.db import models @@ -134,7 +129,7 @@ def check_active(self, force): 'main', 'UserProfile' ) # noqa - Avoid circular imports profile, created = UserProfile.objects.get_or_create(user=self.xform.user) - if not created and profile.metadata.get('submissions_suspended', False): + if not created and profile.submissions_suspended: raise TemporarilyUnavailableError() return diff --git a/kobo/apps/openrosa/apps/logger/models/xform.py b/kobo/apps/openrosa/apps/logger/models/xform.py index e4ee5e7d74..e4ae64448e 100644 --- a/kobo/apps/openrosa/apps/logger/models/xform.py +++ b/kobo/apps/openrosa/apps/logger/models/xform.py @@ -139,9 +139,9 @@ def asset(self): ) except Asset.DoesNotExist: try: - asset = Asset.objects.only( - 'pk', 'name', 'uid', 'owner_id' - ).get(_deployment_data__formid=self.pk) + asset = Asset.objects.only('pk', 'name', 'uid', 'owner_id').get( + _deployment_data__formid=self.pk + ) except Asset.DoesNotExist: # An `Asset` object needs to be returned to avoid 500 while # Enketo is fetching for project XML (e.g: /formList, /manifest) @@ -288,11 +288,7 @@ def update(self, *args, **kwargs): def url(self): return reverse( - 'download_xform', - kwargs={ - 'username': self.user.username, - 'pk': self.pk - } + 'download_xform', kwargs={'username': self.user.username, 'pk': self.pk} ) @property diff --git a/kobo/apps/openrosa/apps/main/migrations/0017_userprofile_submissions_suspended.py b/kobo/apps/openrosa/apps/main/migrations/0017_userprofile_submissions_suspended.py new file mode 100644 index 0000000000..e24e43000f --- /dev/null +++ b/kobo/apps/openrosa/apps/main/migrations/0017_userprofile_submissions_suspended.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-09-02 21:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0016_drop_old_restservice_tables'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='submissions_suspended', + field=models.BooleanField(default=False), + ), + ] diff --git a/kobo/apps/openrosa/apps/main/models/user_profile.py b/kobo/apps/openrosa/apps/main/models/user_profile.py index 934a1651fb..09508e8179 100644 --- a/kobo/apps/openrosa/apps/main/models/user_profile.py +++ b/kobo/apps/openrosa/apps/main/models/user_profile.py @@ -35,6 +35,7 @@ class UserProfile(models.Model): metadata = models.JSONField(default=dict, blank=True) is_mfa_active = LazyDefaultBooleanField(default=False) validated_password = models.BooleanField(default=True) + submissions_suspended = models.BooleanField(default=False) class Meta: app_label = 'main' diff --git a/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/1335783522563.jpg b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/1335783522563.jpg new file mode 100755 index 0000000000..e8d953e387 Binary files /dev/null and b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/1335783522563.jpg differ diff --git a/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_01.xml b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_01.xml new file mode 100644 index 0000000000..4d62b2f32e --- /dev/null +++ b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_01.xml @@ -0,0 +1 @@ +Custom submit messageuuid:7g0a1508-c3b7-4c99-be00-9b237c26bcfb diff --git a/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_02.xml b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_02.xml new file mode 100644 index 0000000000..92bd2e2be6 --- /dev/null +++ b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_02.xml @@ -0,0 +1 @@ +uuid:4f7a1508-c3b7-5f33-be00-9b237c26bcdr diff --git a/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_and_different_root.xml b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_and_different_root.xml new file mode 100644 index 0000000000..207c86657e --- /dev/null +++ b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_custom_attribute_and_different_root.xml @@ -0,0 +1 @@ +uuid:4f7a1508-c3b7-5f33-be00-9b237c26bcdr diff --git a/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_no_custom_attribute.xml b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_no_custom_attribute.xml new file mode 100644 index 0000000000..31727e3135 --- /dev/null +++ b/kobo/apps/openrosa/apps/main/tests/fixtures/transportation/instances/transport_with_custom_attribute/transport_with_no_custom_attribute.xml @@ -0,0 +1 @@ +uuid:7g0a1508-c3b7-4c99-be00-9b237c26bcfb diff --git a/kobo/apps/openrosa/apps/main/urls.py b/kobo/apps/openrosa/apps/main/urls.py index 225fe1f0f6..4a2a7985ff 100644 --- a/kobo/apps/openrosa/apps/main/urls.py +++ b/kobo/apps/openrosa/apps/main/urls.py @@ -1,128 +1,187 @@ # coding: utf-8 from django.conf import settings - from django.urls import include, re_path from django.views.generic import RedirectView from django.views.i18n import JavaScriptCatalog from kobo.apps.openrosa import koboform -from kobo.apps.openrosa.apps.api.urls import BriefcaseApi -from kobo.apps.openrosa.apps.api.urls import XFormListApi -from kobo.apps.openrosa.apps.api.urls import XFormSubmissionApi -from kobo.apps.openrosa.apps.api.urls import router, router_with_patch_list +from kobo.apps.openrosa.apps.api.urls import ( + BriefcaseApi, + XFormListApi, + XFormSubmissionApi, + router, + router_with_patch_list, +) +from kobo.apps.openrosa.apps.logger.views import ( + bulksubmission, + bulksubmission_form, + download_jsonform, + download_xlsform, +) # exporting stuff from kobo.apps.openrosa.apps.viewer.views import ( attachment_url, create_export, delete_export, - export_progress, - export_list, export_download, + export_list, + export_progress, ) -from kobo.apps.openrosa.apps.logger.views import ( - bulksubmission, - bulksubmission_form, - download_xlsform, - download_jsonform, -) - urlpatterns = [ # change Language re_path(r'^i18n/', include('django.conf.urls.i18n')), re_path('^api/v1/', include(router.urls)), re_path('^api/v1/', include(router_with_patch_list.urls)), - # main website views + re_path(r'^$', RedirectView.as_view(url=koboform.redirect_url('/')), name='home'), + # Bring back old url because it's still used by `kpi` + re_path(r'^attachment/$', attachment_url, name='attachment_url'), + re_path(r'^attachment/(?P[^/]+)$', attachment_url, name='attachment_url'), re_path( - r'^$', RedirectView.as_view(url=koboform.redirect_url('/')), name='home' + r'^{}$'.format(settings.MEDIA_URL.lstrip('/')), + attachment_url, + name='attachment_url', ), - # Bring back old url because it's still used by `kpi` - re_path(r"^attachment/$", attachment_url, name='attachment_url'), - re_path(r"^attachment/(?P[^/]+)$", - attachment_url, name='attachment_url'), - re_path(r"^{}$".format(settings.MEDIA_URL.lstrip('/')), attachment_url, name='attachment_url'), - re_path(r"^{}(?P[^/]+)$".format(settings.MEDIA_URL.lstrip('/')), - attachment_url, name='attachment_url'), - re_path(r'^jsi18n/$', JavaScriptCatalog.as_view(packages=['kobo.apps.openrosa.apps.main', 'kobo.apps.openrosa.apps.viewer']), - name='javascript-catalog'), - re_path( - r'^(?P[^/]+)/$', - RedirectView.as_view(url=koboform.redirect_url('/')), - name='user_profile', + re_path( + r'^{}(?P[^/]+)$'.format(settings.MEDIA_URL.lstrip('/')), + attachment_url, + name='attachment_url', + ), + re_path( + r'^jsi18n/$', + JavaScriptCatalog.as_view( + packages=['kobo.apps.openrosa.apps.main', 'kobo.apps.openrosa.apps.viewer'] + ), + name='javascript-catalog', + ), + re_path( + r'^(?P[^/]+)/$', + RedirectView.as_view(url=koboform.redirect_url('/')), + name='user_profile', ), # briefcase api urls - re_path(r"^view/submissionList$", - BriefcaseApi.as_view({'get': 'list', 'head': 'list'}), - name='view-submission-list'), - re_path(r"^view/downloadSubmission$", - BriefcaseApi.as_view({'get': 'retrieve', 'head': 'retrieve'}), - name='view-download-submission'), - re_path(r"^formUpload$", - BriefcaseApi.as_view({'post': 'create', 'head': 'create'}), - name='form-upload'), - re_path(r"^upload$", - BriefcaseApi.as_view({'post': 'create', 'head': 'create'}), - name='upload'), - + re_path( + r'^view/submissionList$', + BriefcaseApi.as_view({'get': 'list', 'head': 'list'}), + name='view-submission-list', + ), + re_path( + r'^view/downloadSubmission$', + BriefcaseApi.as_view({'get': 'retrieve', 'head': 'retrieve'}), + name='view-download-submission', + ), + re_path( + r'^formUpload$', + BriefcaseApi.as_view({'post': 'create', 'head': 'create'}), + name='form-upload', + ), + re_path( + r'^upload$', + BriefcaseApi.as_view({'post': 'create', 'head': 'create'}), + name='upload', + ), # exporting stuff - re_path(r"^(?P\w+)/exports/(?P[^/]+)/(?P\w+)" - r"/new$", create_export, name='create_export'), - re_path(r"^(?P\w+)/exports/(?P[^/]+)/(?P\w+)" - r"/delete$", delete_export, name='delete_export'), - re_path(r"^(?P\w+)/exports/(?P[^/]+)/(?P\w+)" - r"/progress$", export_progress, name='export_progress'), - re_path(r"^(?P\w+)/exports/(?P[^/]+)/(?P\w+)" - r"/$", export_list, name='export_list'), - re_path(r"^(?P\w+)/exports/(?P[^/]+)/(?P\w+)" - "/(?P[^/]+)$", - export_download, name='export_download'), - + re_path( + r'^(?P\w+)/exports/(?P[^/]+)/(?P\w+)' + r'/new$', + create_export, + name='create_export', + ), + re_path( + r'^(?P\w+)/exports/(?P[^/]+)/(?P\w+)' + r'/delete$', + delete_export, + name='delete_export', + ), + re_path( + r'^(?P\w+)/exports/(?P[^/]+)/(?P\w+)' + r'/progress$', + export_progress, + name='export_progress', + ), + re_path( + r'^(?P\w+)/exports/(?P[^/]+)/(?P\w+)' r'/$', + export_list, + name='export_list', + ), + re_path( + r'^(?P\w+)/exports/(?P[^/]+)/(?P\w+)' + '/(?P[^/]+)$', + export_download, + name='export_download', + ), # odk data urls - re_path(r"^submission$", - XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}), - name='submissions'), - re_path(r"^formList$", - XFormListApi.as_view({'get': 'list'}), name='form-list'), - re_path(r"^(?P\w+)/formList$", - XFormListApi.as_view({'get': 'list'}), name='form-list'), - re_path(r"^(?P\w+)/xformsManifest/(?P[\d+^/]+)$", - XFormListApi.as_view({'get': 'manifest'}), - name='manifest-url'), - re_path(r"^xformsManifest/(?P[\d+^/]+)$", - XFormListApi.as_view({'get': 'manifest'}), - name='manifest-url'), - re_path(r"^(?P\w+)/xformsMedia/(?P[\d+^/]+)" - r"/(?P[\d+^/.]+)$", - XFormListApi.as_view({'get': 'media'}), name='xform-media'), - re_path(r"^(?P\w+)/xformsMedia/(?P[\d+^/]+)" - r"/(?P[\d+^/.]+)\.(?P[a-z0-9]+)$", - XFormListApi.as_view({'get': 'media'}), name='xform-media'), - re_path(r"^xformsMedia/(?P[\d+^/]+)/(?P[\d+^/.]+)$", - XFormListApi.as_view({'get': 'media'}), name='xform-media'), - re_path(r"^xformsMedia/(?P[\d+^/]+)/(?P[\d+^/.]+)\." - r"(?P[a-z0-9]+)$", - XFormListApi.as_view({'get': 'media'}), name='xform-media'), - re_path(r"^(?P\w+)/submission$", - XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}), - name='submissions'), - re_path(r"^(?P\w+)/bulk-submission$", - bulksubmission), - re_path(r"^(?P\w+)/bulk-submission-form$", - bulksubmission_form), - re_path(r'^forms/(?P[\d+^/]+)/form\.xml$', - XFormListApi.as_view({'get': 'retrieve'}), - name='download_xform'), - re_path(r'^(?P\w+)/forms/(?P[\d+^/]+)/form\.xml$', - XFormListApi.as_view({'get': 'retrieve'}), - name='download_xform'), - re_path(r"^(?P\w+)/forms/(?P[^/]+)/form\.xls$", - download_xlsform, - name="download_xlsform"), - re_path(r"^(?P\w+)/forms/(?P[^/]+)/form\.json", - download_jsonform, - name="download_jsonform"), - re_path(r'^favicon\.ico', - RedirectView.as_view(url='/static/images/favicon.ico')), + re_path( + r'^submission$', + XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}), + name='submissions', + ), + re_path(r'^formList$', XFormListApi.as_view({'get': 'list'}), name='form-list'), + re_path( + r'^(?P\w+)/formList$', + XFormListApi.as_view({'get': 'list'}), + name='form-list', + ), + re_path( + r'^(?P\w+)/xformsManifest/(?P[\d+^/]+)$', + XFormListApi.as_view({'get': 'manifest'}), + name='manifest-url', + ), + re_path( + r'^xformsManifest/(?P[\d+^/]+)$', + XFormListApi.as_view({'get': 'manifest'}), + name='manifest-url', + ), + re_path( + r'^(?P\w+)/xformsMedia/(?P[\d+^/]+)' r'/(?P[\d+^/.]+)$', + XFormListApi.as_view({'get': 'media'}), + name='xform-media', + ), + re_path( + r'^(?P\w+)/xformsMedia/(?P[\d+^/]+)' + r'/(?P[\d+^/.]+)\.(?P[a-z0-9]+)$', + XFormListApi.as_view({'get': 'media'}), + name='xform-media', + ), + re_path( + r'^xformsMedia/(?P[\d+^/]+)/(?P[\d+^/.]+)$', + XFormListApi.as_view({'get': 'media'}), + name='xform-media', + ), + re_path( + r'^xformsMedia/(?P[\d+^/]+)/(?P[\d+^/.]+)\.' + r'(?P[a-z0-9]+)$', + XFormListApi.as_view({'get': 'media'}), + name='xform-media', + ), + re_path( + r'^(?P\w+)/submission$', + XFormSubmissionApi.as_view({'post': 'create', 'head': 'create'}), + name='submissions', + ), + re_path(r'^(?P\w+)/bulk-submission$', bulksubmission), + re_path(r'^(?P\w+)/bulk-submission-form$', bulksubmission_form), + re_path( + r'^forms/(?P[\d+^/]+)/form\.xml$', + XFormListApi.as_view({'get': 'retrieve'}), + name='download_xform', + ), + re_path( + r'^(?P\w+)/forms/(?P[\d+^/]+)/form\.xml$', + XFormListApi.as_view({'get': 'retrieve'}), + name='download_xform', + ), + re_path( + r'^(?P\w+)/forms/(?P[^/]+)/form\.xls$', + download_xlsform, + name='download_xlsform', + ), + re_path( + r'^(?P\w+)/forms/(?P[^/]+)/form\.json', + download_jsonform, + name='download_jsonform', + ), + re_path(r'^favicon\.ico', RedirectView.as_view(url='/static/images/favicon.ico')), ] diff --git a/kobo/apps/openrosa/libs/filters.py b/kobo/apps/openrosa/libs/filters.py index 61b178d057..aea410edba 100644 --- a/kobo/apps/openrosa/libs/filters.py +++ b/kobo/apps/openrosa/libs/filters.py @@ -58,8 +58,7 @@ def _get_objects_for_org_admin(self, request, queryset, view): # Only check for specific view and action if not ( - view.action - in self.ORG_ADMIN_EXEMPT_VIEWS.get(view.__class__.__name__, {}) + view.action in self.ORG_ADMIN_EXEMPT_VIEWS.get(view.__class__.__name__, {}) ): return diff --git a/kobo/apps/openrosa/libs/serializers/data_serializer.py b/kobo/apps/openrosa/libs/serializers/data_serializer.py index a97e0fb80f..705c95d5c2 100644 --- a/kobo/apps/openrosa/libs/serializers/data_serializer.py +++ b/kobo/apps/openrosa/libs/serializers/data_serializer.py @@ -110,8 +110,11 @@ def to_representation(self, obj): if not hasattr(obj, 'xform'): return super().to_representation(obj) + message = self.context.get( + 'confirmation_message', t('Successful submission.') + ) return { - 'message': t("Successful submission."), + 'message': message, 'formid': obj.xform.id_string, 'encrypted': obj.xform.encrypted, 'instanceID': 'uuid:%s' % obj.uuid, diff --git a/kobo/apps/openrosa/libs/utils/logger_tools.py b/kobo/apps/openrosa/libs/utils/logger_tools.py index ba15daf5dd..0f9601529f 100644 --- a/kobo/apps/openrosa/libs/utils/logger_tools.py +++ b/kobo/apps/openrosa/libs/utils/logger_tools.py @@ -80,9 +80,9 @@ from kpi.deployment_backends.kc_access.storage import ( default_kobocat_storage as default_storage, ) -from kpi.utils.object_permission import get_database_user from kpi.utils.hash import calculate_hash from kpi.utils.mongo_helper import MongoHelper +from kpi.utils.object_permission import get_database_user OPEN_ROSA_VERSION_HEADER = 'X-OpenRosa-Version' HTTP_OPEN_ROSA_VERSION_HEADER = 'HTTP_X_OPENROSA_VERSION' diff --git a/kobo/apps/organizations/admin/__init__.py b/kobo/apps/organizations/admin/__init__.py index debd1e39d5..5a2df4ebad 100644 --- a/kobo/apps/organizations/admin/__init__.py +++ b/kobo/apps/organizations/admin/__init__.py @@ -1,7 +1,6 @@ -# flake8: noqa: F401 from .organization import OrgAdmin -from .organization_owner import OrgOwnerAdmin from .organization_invite import OrgInvitationAdmin +from .organization_owner import OrgOwnerAdmin from .organization_user import OrgUserAdmin __all__ = ['OrgAdmin', 'OrgOwnerAdmin', 'OrgInvitationAdmin', 'OrgUserAdmin'] diff --git a/kobo/apps/organizations/admin/organization.py b/kobo/apps/organizations/admin/organization.py index f538d3e628..bd0f64a856 100644 --- a/kobo/apps/organizations/admin/organization.py +++ b/kobo/apps/organizations/admin/organization.py @@ -4,14 +4,12 @@ from organizations.base_admin import BaseOrganizationAdmin from kobo.apps.kobo_auth.shortcuts import User -from .organization_owner import OwnerInline -from .organization_user import OrgUserInline -from ..models import ( - Organization, - OrganizationUser, -) + +from ..models import Organization, OrganizationUser from ..tasks import transfer_user_ownership_to_org from ..utils import revoke_org_asset_perms +from .organization_owner import OwnerInline +from .organization_user import OrgUserInline @admin.register(Organization) @@ -62,8 +60,7 @@ def _get_new_members_queryset( ) queryset = ( - User.objects - .filter( + User.objects.filter( organizations_organizationuser__organization_id=organization_id ) .filter(id__in=users_in_multiple_orgs) diff --git a/kobo/apps/organizations/admin/organization_invite.py b/kobo/apps/organizations/admin/organization_invite.py index c63f521576..87751c72ba 100644 --- a/kobo/apps/organizations/admin/organization_invite.py +++ b/kobo/apps/organizations/admin/organization_invite.py @@ -1,4 +1,5 @@ from django.contrib import admin + from ..models import OrganizationInvitation diff --git a/kobo/apps/organizations/admin/organization_user.py b/kobo/apps/organizations/admin/organization_user.py index b11832478c..99a556312f 100644 --- a/kobo/apps/organizations/admin/organization_user.py +++ b/kobo/apps/organizations/admin/organization_user.py @@ -3,6 +3,7 @@ from django.contrib import admin, messages from django.db.models import Count from django.utils.safestring import mark_safe +from django_request_cache import cache_for_request from import_export import resources from import_export.admin import ImportExportModelAdmin from import_export.fields import Field @@ -10,8 +11,9 @@ from organizations.base_admin import BaseOrganizationUserAdmin from kobo.apps.kobo_auth.shortcuts import User + from ..forms import OrgUserAdminForm -from ..models import OrganizationUser +from ..models import Organization, OrganizationUser from ..tasks import transfer_user_ownership_to_org from ..utils import revoke_org_asset_perms @@ -111,6 +113,42 @@ class OrgUserResource(resources.ModelResource): class Meta: model = OrganizationUser + def after_import(self, dataset, result, **kwargs): + super().after_import(dataset, result, **kwargs) + dry_run = kwargs.get('dry_run', False) + + if not dry_run: + new_organization_user_ids = [] + for row in result.rows: + if row.import_type == 'new': + new_organization_user_ids.append(row.object_id) + + if new_organization_user_ids: + user_ids = OrganizationUser.objects.values_list( + 'user_id', flat=True + ).filter(pk__in=new_organization_user_ids) + for user_id in user_ids: + transfer_user_ownership_to_org.delay(user_id) + + def before_import_row(self, row, **kwargs): + + if not (organization := self._get_organization(row.get('organization'))): + raise ValueError(f"Organization {row.get('organization')} does not exist") + if not organization.is_mmo: + raise ValueError( + f"Organization {row.get('organization')} is not multi-member" + ) + + return super().before_import_row(row, **kwargs) + + @staticmethod + @cache_for_request + def _get_organization(organization_id: str) -> Organization | None: + if organization_id: + return Organization.objects.filter(pk=organization_id).first() + + return + @admin.register(OrganizationUser) class OrgUserAdmin(ImportExportModelAdmin, BaseOrganizationUserAdmin): @@ -128,9 +166,11 @@ def get_search_results(self, request, queryset, search_term): and app_label == 'organizations' and model_name == 'organizationowner' ): - queryset = queryset.annotate( - user_count=Count('organization__organization_users') - ).filter(user_count__lte=1).order_by('user__username') + queryset = ( + queryset.annotate(user_count=Count('organization__organization_users')) + .filter(user_count__lte=1) + .order_by('user__username') + ) return super().get_search_results(request, queryset, search_term) diff --git a/kobo/apps/organizations/forms.py b/kobo/apps/organizations/forms.py index bf45473c91..ace01968e6 100644 --- a/kobo/apps/organizations/forms.py +++ b/kobo/apps/organizations/forms.py @@ -1,5 +1,6 @@ from django import forms from django.core.exceptions import ValidationError + from .models import OrganizationUser diff --git a/kobo/apps/organizations/migrations/0006_update_organization_name.py b/kobo/apps/organizations/migrations/0006_update_organization_name.py index 6c1e3b8ee0..98fa7905e9 100644 --- a/kobo/apps/organizations/migrations/0006_update_organization_name.py +++ b/kobo/apps/organizations/migrations/0006_update_organization_name.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.15 on 2024-10-25 16:08 -from django.db import migrations from django.core.paginator import Paginator +from django.db import migrations def update_organization_names(apps, schema_editor): diff --git a/kobo/apps/organizations/models.py b/kobo/apps/organizations/models.py index 9aac437b01..a51d2662af 100644 --- a/kobo/apps/organizations/models.py +++ b/kobo/apps/organizations/models.py @@ -1,4 +1,6 @@ +from functools import partial from typing import Literal + from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import models @@ -6,8 +8,11 @@ from django_request_cache import cache_for_request if settings.STRIPE_ENABLED: - from djstripe.models import Customer, Subscription -from functools import partial + from djstripe.models import Customer, Subscription + + from kobo.apps.stripe.constants import ( + ACTIVE_STRIPE_STATUSES, + ) from organizations.abstract import ( AbstractOrganization, @@ -17,8 +22,8 @@ ) from organizations.utils import create_organization as create_organization_base -from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES from kpi.fields import KpiUidField + from .constants import ( ORG_ADMIN_ROLE, ORG_EXTERNAL_ROLE, @@ -27,7 +32,6 @@ ) from .exceptions import NotMultiMemberOrganizationException - OrganizationRole = Literal[ ORG_ADMIN_ROLE, ORG_EXTERNAL_ROLE, ORG_MEMBER_ROLE, ORG_OWNER_ROLE ] @@ -49,25 +53,45 @@ def add_user(self, user, is_admin=False): @cache_for_request def active_subscription_billing_details(self): """ - Retrieve the billing dates and interval for the organization's newest active subscription + Retrieve the billing dates, interval, and product/price metadata for the + organization's newest subscription Returns None if Stripe is not enabled - The status types that are considered 'active' are determined by ACTIVE_STRIPE_STATUSES + The status types that are considered 'active' are determined by + ACTIVE_STRIPE_STATUSES """ # Only check for subscriptions if Stripe is enabled - if settings.STRIPE_ENABLED: - return Organization.objects.prefetch_related('djstripe_customers').filter( - djstripe_customers__subscriptions__status__in=ACTIVE_STRIPE_STATUSES, - djstripe_customers__subscriber=self.id, - ).order_by( - '-djstripe_customers__subscriptions__start_date' - ).values( - billing_cycle_anchor=F('djstripe_customers__subscriptions__billing_cycle_anchor'), - current_period_start=F('djstripe_customers__subscriptions__current_period_start'), - current_period_end=F('djstripe_customers__subscriptions__current_period_end'), - recurring_interval=F('djstripe_customers__subscriptions__items__price__recurring__interval'), - ).first() - - return None + if not settings.STRIPE_ENABLED: + return None + + return ( + Organization.objects.prefetch_related('djstripe_customers') + .filter( + djstripe_customers__subscriptions__status__in=ACTIVE_STRIPE_STATUSES, + djstripe_customers__subscriber=self.id, + ) + .order_by('-djstripe_customers__subscriptions__start_date') + .values( + billing_cycle_anchor=F( + 'djstripe_customers__subscriptions__billing_cycle_anchor' + ), + current_period_start=F( + 'djstripe_customers__subscriptions__current_period_start' + ), + current_period_end=F( + 'djstripe_customers__subscriptions__current_period_end' + ), + recurring_interval=F( + 'djstripe_customers__subscriptions__items__price__recurring__interval' # noqa: E501 + ), + product_metadata=F( + 'djstripe_customers__subscriptions__items__price__product__metadata' + ), + price_metadata=F( + 'djstripe_customers__subscriptions__items__price__metadata' + ), + ) + .first() + ) @cache_for_request def canceled_subscription_billing_cycle_anchor(self): @@ -76,19 +100,39 @@ def canceled_subscription_billing_cycle_anchor(self): """ # Only check for subscriptions if Stripe is enabled if settings.STRIPE_ENABLED: - qs = Organization.objects.prefetch_related('djstripe_customers').filter( + qs = ( + Organization.objects.prefetch_related('djstripe_customers') + .filter( djstripe_customers__subscriptions__status='canceled', djstripe_customers__subscriber=self.id, - ).order_by( - '-djstripe_customers__subscriptions__ended_at' - ).values( + ) + .order_by('-djstripe_customers__subscriptions__ended_at') + .values( anchor=F('djstripe_customers__subscriptions__ended_at'), - ).first() + ) + .first() + ) if qs: return qs['anchor'] return None + @classmethod + def get_from_user_id(cls, user_id: int): + """ + Get organization that this user is a member of. + """ + # TODO: validate this is the correct way to get a user's organization + org = ( + cls.objects.filter( + organization_users__user__id=user_id, + ) + .order_by('-organization_users__created') + .first() + ) + + return org + @property def email(self): """ @@ -177,7 +221,8 @@ def active_subscription_statuses(self): try: customer = Customer.objects.get(subscriber=self.organization.id) subscriptions = Subscription.objects.filter( - customer=customer, status="active" + customer=customer, + status__in=ACTIVE_STRIPE_STATUSES, ) unique_plans = set() diff --git a/kobo/apps/organizations/permissions.py b/kobo/apps/organizations/permissions.py index 7564a57eda..f37dc36757 100644 --- a/kobo/apps/organizations/permissions.py +++ b/kobo/apps/organizations/permissions.py @@ -1,18 +1,20 @@ from django.http import Http404 from rest_framework import permissions +from rest_framework.permissions import IsAuthenticated from kobo.apps.organizations.constants import ORG_EXTERNAL_ROLE +from kobo.apps.organizations.models import Organization from kpi.mixins.validation_password_permission import ValidationPasswordPermissionMixin from kpi.utils.object_permission import get_database_user -class IsOrgAdmin( - ValidationPasswordPermissionMixin, permissions.BasePermission -): +class IsOrgAdminPermission(ValidationPasswordPermissionMixin, IsAuthenticated): """ - Object-level permission to only allow admin members of an object to access it. + Object-level permission to only allow admin (and owner) members of an object + to access it. Assumes the model instance has an `is_admin` attribute. """ + def has_permission(self, request, view): self.validate_password(request) return super().has_permission(request=request, view=view) @@ -27,18 +29,22 @@ def has_object_permission(self, request, view, obj): return obj.is_admin(user) -class IsOrgAdminOrReadOnly(IsOrgAdmin): - """ - Object-level permission to only allow admin members of an object to edit it. - Assumes the model instance has an `is_admin` attribute. - """ +class HasOrgRolePermission(IsOrgAdminPermission): + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + + organization = Organization.objects.filter( + id=view.kwargs.get('organization_id') + ).first() + if organization and not self.has_object_permission( + request, view, organization + ): + return False + return True def has_object_permission(self, request, view, obj): - - # Read permissions are allowed to any request, - # so we'll always allow GET, HEAD or OPTIONS requests. - if request.method in permissions.SAFE_METHODS: + obj = obj if isinstance(obj, Organization) else obj.organization + if super().has_object_permission(request, view, obj): return True - - # Instance must have an attribute named `is_admin` - return obj.is_admin(request.user) + return request.method in permissions.SAFE_METHODS diff --git a/kobo/apps/organizations/serializers.py b/kobo/apps/organizations/serializers.py index 2c0e0fdcd1..ae13041394 100644 --- a/kobo/apps/organizations/serializers.py +++ b/kobo/apps/organizations/serializers.py @@ -1,20 +1,76 @@ +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as t from rest_framework import serializers +from rest_framework.reverse import reverse from kobo.apps.organizations.models import ( - create_organization, Organization, OrganizationOwner, OrganizationUser, + create_organization, ) from kpi.utils.object_permission import get_database_user + from .constants import ORG_EXTERNAL_ROLE class OrganizationUserSerializer(serializers.ModelSerializer): + user = serializers.HyperlinkedRelatedField( + queryset=get_user_model().objects.all(), + lookup_field='username', + view_name='user-kpi-detail', + ) + role = serializers.CharField() + user__has_mfa_enabled = serializers.BooleanField( + source='has_mfa_enabled', read_only=True + ) + url = serializers.SerializerMethodField() + date_joined = serializers.DateTimeField( + source='user.date_joined', format='%Y-%m-%dT%H:%M:%SZ' + ) + user__username = serializers.ReadOnlyField(source='user.username') + user__extra_details__name = serializers.ReadOnlyField( + source='user.extra_details.data.name' + ) + user__email = serializers.ReadOnlyField(source='user.email') + user__is_active = serializers.ReadOnlyField(source='user.is_active') class Meta: model = OrganizationUser - fields = ['user', 'organization'] + fields = [ + 'url', + 'user', + 'user__username', + 'user__email', + 'user__extra_details__name', + 'role', + 'user__has_mfa_enabled', + 'date_joined', + 'user__is_active' + ] + + def get_url(self, obj): + request = self.context.get('request') + return reverse( + 'organization-members-detail', + kwargs={ + 'organization_id': obj.organization.id, + 'user__username': obj.user.username + }, + request=request + ) + + def update(self, instance, validated_data): + if role := validated_data.get('role', None): + validated_data['is_admin'] = role == 'admin' + return super().update(instance, validated_data) + + def validate_role(self, role): + if role not in ['admin', 'member']: + raise serializers.ValidationError( + {'role': t("Invalid role. Only 'admin' or 'member' are allowed")} + ) + return role class OrganizationOwnerSerializer(serializers.ModelSerializer): diff --git a/kobo/apps/organizations/tasks.py b/kobo/apps/organizations/tasks.py index c3effa3b33..40d19df1a7 100644 --- a/kobo/apps/organizations/tasks.py +++ b/kobo/apps/organizations/tasks.py @@ -2,8 +2,8 @@ from more_itertools import chunked from kobo.apps.kobo_auth.shortcuts import User -from kobo.celery import celery_app from kobo.apps.project_ownership.utils import create_invite +from kobo.celery import celery_app @celery_app.task( diff --git a/kobo/apps/organizations/tests/admin/__init__.py b/kobo/apps/organizations/tests/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/kobo/apps/organizations/tests/admin/test_organization_admin.py b/kobo/apps/organizations/tests/admin/test_organization_admin.py new file mode 100644 index 0000000000..e27b48b3fc --- /dev/null +++ b/kobo/apps/organizations/tests/admin/test_organization_admin.py @@ -0,0 +1,93 @@ +from django.test import TestCase +from django.urls import reverse + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.models import Organization +from kpi.constants import PERM_MANAGE_ASSET +from kpi.models.asset import Asset + + +class TestOrganizationAdminTestCase(TestCase): + + fixtures = ['test_data'] + + def setUp(self): + # Create an Organization instance + self.organization = Organization.objects.create( + id='org1234', name='Test Organization', mmo_override=True + ) + + self.someuser = User.objects.get(username='someuser') + self.anotheruser = User.objects.get(username='anotheruser') + self.admin = User.objects.get(username='admin') + + self.organization.add_user(self.someuser) # someuser becomes the owner + + self.asset = Asset.objects.create(owner=self.anotheruser, name='Test Asset') + self.client.force_login(self.admin) + + def test_adding_member_does_transfer_their_assets(self): + assert self.organization.organization_users.count() == 1 + assert self.anotheruser.organization != self.organization + + self._manage_user_in_org(remove=False) + + assert self.organization.organization_users.count() == 2 + assert self.anotheruser.organization == self.organization + + self.asset.refresh_from_db() + assert self.asset.owner == self.organization.owner_user_object + assert self.asset.owner == self.someuser + assert self.asset.has_perm(self.anotheruser, PERM_MANAGE_ASSET) + + def test_removing_member_does_revoke_their_perms(self): + self._manage_user_in_org(remove=False) + assert self.organization.organization_users.count() == 2 + assert self.anotheruser.organization == self.organization + assert self.asset.has_perm(self.anotheruser, PERM_MANAGE_ASSET) + + self._manage_user_in_org(remove=True) + + assert self.organization.organization_users.count() == 1 + self.asset.refresh_from_db() + assert not self.asset.get_perms(self.anotheruser) + assert self.anotheruser.organization != self.organization + + def _manage_user_in_org(self, remove: bool = False): + + payload = { + 'name': self.organization.name, + 'slug': self.organization.slug, + 'is_active': 'on', + 'mmo_override': 'on', + 'owner-0-id': self.organization.owner.id, + 'owner-0-organization': self.organization.id, + 'owner-TOTAL_FORMS': 1, + 'owner-INITIAL_FORMS': 1, + 'owner-MIN_NUM_FORMS': 0, + 'owner-MAX_NUM_FORMS': 1, + 'organization_users-TOTAL_FORMS': 1, + 'organization_users-INITIAL_FORMS': 0, + 'organization_users-MIN_NUM_FORMS': 0, + 'organization_users-0-user': self.anotheruser.pk, + 'organization_users-0-id': '', + 'organization_users-0-organization': self.organization.id, + 'organization_users-__prefix__-id': '', + 'organization_users-__prefix__-organization': self.organization.id, + } + + if remove: + organization_user_id = self.organization.organization_users.get( + user=self.anotheruser + ).pk + payload['organization_users-0-DELETE'] = 'on' + payload['organization_users-0-id'] = organization_user_id + payload['organization_users-INITIAL_FORMS'] = 1 + + url = reverse( + 'admin:organizations_organization_change', + kwargs={'object_id': self.organization.id}, + ) + response = self.client.post(url, data=payload, follow=True) + assert 'was changed successfully' in str(response.content) + return response diff --git a/kobo/apps/organizations/tests/test_organization_members_api.py b/kobo/apps/organizations/tests/test_organization_members_api.py new file mode 100644 index 0000000000..eb9e74b33b --- /dev/null +++ b/kobo/apps/organizations/tests/test_organization_members_api.py @@ -0,0 +1,131 @@ +from ddt import ddt, data, unpack +from django.urls import reverse +from rest_framework import status + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.tests.test_organizations_api import ( + BaseOrganizationAssetApiTestCase +) +from kpi.urls.router_api_v2 import URL_NAMESPACE + + +@ddt +class OrganizationMemberAPITestCase(BaseOrganizationAssetApiTestCase): + fixtures = ['test_data'] + URL_NAMESPACE = URL_NAMESPACE + + def setUp(self): + super().setUp() + self.organization = self.someuser.organization + self.owner_user = self.someuser + self.member_user = self.alice + self.admin_user = self.anotheruser + self.external_user = self.bob + + self.list_url = reverse( + self._get_endpoint('organization-members-list'), + kwargs={'organization_id': self.organization.id}, + ) + self.detail_url = lambda username: reverse( + self._get_endpoint('organization-members-detail'), + kwargs={ + 'organization_id': self.organization.id, + 'user__username': username + }, + ) + + @data( + ('owner', status.HTTP_200_OK), + ('admin', status.HTTP_200_OK), + ('member', status.HTTP_200_OK), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_list_members_with_different_roles(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + response = self.client.get(self.list_url) + self.assertEqual(response.status_code, expected_status) + + @data( + ('owner', status.HTTP_200_OK), + ('admin', status.HTTP_200_OK), + ('member', status.HTTP_200_OK), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_retrieve_member_details_with_different_roles( + self, user_role, expected_status + ): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + response = self.client.get(self.detail_url(self.member_user)) + self.assertEqual(response.status_code, expected_status) + + @data( + ('owner', status.HTTP_200_OK), + ('admin', status.HTTP_200_OK), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_update_member_role_with_different_roles(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + data = {'role': 'admin'} + response = self.client.patch(self.detail_url(self.member_user), data) + self.assertEqual(response.status_code, expected_status) + + @data( + ('owner', status.HTTP_204_NO_CONTENT), + ('admin', status.HTTP_204_NO_CONTENT), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_delete_member_with_different_roles(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + response = self.client.delete(self.detail_url(self.member_user)) + self.assertEqual(response.status_code, expected_status) + if expected_status == status.HTTP_204_NO_CONTENT: + # Confirm deletion + response = self.client.get(self.detail_url(self.member_user)) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse( + User.objects.filter(username=f'{user_role}_user').exists() + ) + + @data( + ('owner', status.HTTP_405_METHOD_NOT_ALLOWED), + ('admin', status.HTTP_405_METHOD_NOT_ALLOWED), + ('member', status.HTTP_403_FORBIDDEN), + ('external', status.HTTP_404_NOT_FOUND), + ('anonymous', status.HTTP_401_UNAUTHORIZED), + ) + @unpack + def test_post_request_is_not_allowed(self, user_role, expected_status): + if user_role == 'anonymous': + self.client.logout() + else: + user = getattr(self, f'{user_role}_user') + self.client.force_login(user) + data = {'role': 'admin'} + response = self.client.post(self.list_url, data) + self.assertEqual(response.status_code, expected_status) diff --git a/kobo/apps/organizations/tests/test_organizations.py b/kobo/apps/organizations/tests/test_organizations.py index 24d4a6acae..6eaf9e2fe5 100644 --- a/kobo/apps/organizations/tests/test_organizations.py +++ b/kobo/apps/organizations/tests/test_organizations.py @@ -1,13 +1,13 @@ from django.test import TestCase from kobo.apps.kobo_auth.shortcuts import User -from kobo.apps.organizations.models import Organization from kobo.apps.organizations.constants import ( ORG_ADMIN_ROLE, ORG_EXTERNAL_ROLE, ORG_MEMBER_ROLE, ORG_OWNER_ROLE, ) +from kobo.apps.organizations.models import Organization class OrganizationTestCase(TestCase): diff --git a/kobo/apps/organizations/tests/test_organizations_api.py b/kobo/apps/organizations/tests/test_organizations_api.py index 02665895ea..a3fa635543 100644 --- a/kobo/apps/organizations/tests/test_organizations_api.py +++ b/kobo/apps/organizations/tests/test_organizations_api.py @@ -1,26 +1,25 @@ +from datetime import timedelta from unittest.mock import patch import responses +from ddt import data, ddt, unpack from django.contrib.auth.models import Permission from django.urls import reverse -from ddt import ddt, data, unpack +from django.utils import timezone +from django.utils.http import parse_http_date from model_bakery import baker from rest_framework import status -from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.hook.utils.tests.mixins import HookTestCaseMixin +from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.organizations.models import Organization -from kpi.constants import ( - PERM_ADD_SUBMISSIONS, - PERM_MANAGE_ASSET, - PERM_VIEW_ASSET, -) +from kpi.constants import PERM_ADD_SUBMISSIONS, PERM_MANAGE_ASSET, PERM_VIEW_ASSET from kpi.models.asset import Asset -from kpi.tests.base_test_case import BaseTestCase, BaseAssetTestCase +from kpi.tests.base_test_case import BaseAssetTestCase, BaseTestCase from kpi.tests.utils.mixins import ( AssetFileTestCaseMixin, - SubmissionEditTestCaseMixin, SubmissionDeleteTestCaseMixin, + SubmissionEditTestCaseMixin, SubmissionValidationStatusTestCaseMixin, SubmissionViewTestCaseMixin, ) @@ -62,7 +61,12 @@ def test_anonymous_user(self): def test_create(self): data = {'name': 'my org'} res = self.client.post(self.url_list, data) - self.assertContains(res, data['name'], status_code=201) + self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_delete(self): + self._insert_data() + res = self.client.delete(self.url_detail) + self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) def test_list(self): self._insert_data() @@ -90,6 +94,20 @@ def test_update(self): res = self.client.patch(self.url_detail, data) self.assertEqual(res.status_code, 403) + @patch('kpi.utils.usage_calculator.CachedClass._cache_last_updated') + def test_service_usage_date_header(self, mock_cache_last_updated): + self._insert_data() + url_service_usage = reverse( + self._get_endpoint('organizations-service-usage'), + kwargs={'id': self.organization.id}, + ) + now = timezone.now() + mock_cache_last_updated.return_value = now - timedelta(seconds=3) + self.client.get(url_service_usage) + response = self.client.get(url_service_usage) + last_updated_timestamp = parse_http_date(response.headers['Date']) + assert (now.timestamp() - last_updated_timestamp) > 3 + def test_api_response_includes_is_mmo_with_mmo_override(self): """ Test that is_mmo is True when mmo_override is enabled and there is no @@ -249,7 +267,7 @@ def _create_asset_by_bob(self): 'label': 'How many pages?', } ], - } + }, ) assert response.status_code == status.HTTP_201_CREATED assert response.data['owner__username'] == self.bob.username @@ -336,7 +354,7 @@ def test_can_list(self, username, expected_status_code): def test_list_not_found_as_anonymous(self): self.client.logout() response = self.client.get(self.org_assets_list_url) - assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_list_only_organization_assets(self): # The organization's assets endpoint only returns assets where the `owner` @@ -460,9 +478,7 @@ def test_can_update_asset( assert_detail_url = reverse( self._get_endpoint('asset-detail'), kwargs={'uid': asset_uid} ) - data = { - 'name': 'Week-end breakfast' - } + data = {'name': 'Week-end breakfast'} self.client.force_login(user) response = self.client.patch(assert_detail_url, data) @@ -496,7 +512,7 @@ def test_can_delete_asset( self._get_endpoint('asset-detail'), # Use JSON format to prevent HtmlRenderer from returning a 200 status # instead of 204. - kwargs={'uid': asset_uid, 'format': 'json'} + kwargs={'uid': asset_uid, 'format': 'json'}, ) self.client.force_login(user) @@ -593,7 +609,7 @@ class OrganizationAdminsDataApiTestCase( SubmissionEditTestCaseMixin, SubmissionDeleteTestCaseMixin, SubmissionViewTestCaseMixin, - BaseOrganizationAdminsDataApiTestCase + BaseOrganizationAdminsDataApiTestCase, ): """ This test suite shares logic with `SubmissionEditApiTests`, @@ -712,19 +728,23 @@ def test_can_add_rest_services(self): def test_can_list_rest_services(self): hook = self._create_hook() - list_url = reverse(self._get_endpoint('hook-list'), kwargs={ - 'parent_lookup_asset': self.asset.uid - }) + list_url = reverse( + self._get_endpoint('hook-list'), + kwargs={'parent_lookup_asset': self.asset.uid}, + ) response = self.client.get(list_url) assert response.status_code == status.HTTP_200_OK assert response.data['count'] == 1 assert response.data['results'][0]['uid'] == hook.uid - detail_url = reverse('hook-detail', kwargs={ - 'parent_lookup_asset': self.asset.uid, - 'uid': hook.uid, - }) + detail_url = reverse( + 'hook-detail', + kwargs={ + 'parent_lookup_asset': self.asset.uid, + 'uid': hook.uid, + }, + ) response = self.client.get(detail_url) assert response.status_code == status.HTTP_200_OK diff --git a/kobo/apps/organizations/tests/test_organizations_model.py b/kobo/apps/organizations/tests/test_organizations_model.py new file mode 100644 index 0000000000..c29d6f2414 --- /dev/null +++ b/kobo/apps/organizations/tests/test_organizations_model.py @@ -0,0 +1,22 @@ +from model_bakery import baker + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.models import Organization +from kpi.tests.kpi_test_case import BaseTestCase + + +class OrganizationsModelTestCase(BaseTestCase): + fixtures = ['test_data'] + + def setUp(self): + self.user = User.objects.get(username='someuser') + self.anotheruser = User.objects.get(username='anotheruser') + self.organization = baker.make(Organization, id='org_abcd1234') + self.organization.add_user(user=self.user, is_admin=True) + + def test_get_from_user_id(self): + org = Organization.get_from_user_id(self.user.pk) + assert org.pk == self.organization.pk + + org = Organization.get_from_user_id(self.anotheruser.pk) + assert org.pk != self.organization.pk diff --git a/kobo/apps/organizations/types.py b/kobo/apps/organizations/types.py new file mode 100644 index 0000000000..e9570c319d --- /dev/null +++ b/kobo/apps/organizations/types.py @@ -0,0 +1,3 @@ +from typing import Literal + +UsageType = Literal['characters', 'seconds', 'submission', 'storage'] diff --git a/kobo/apps/organizations/utils.py b/kobo/apps/organizations/utils.py index 4b5cc18503..954162dd5e 100644 --- a/kobo/apps/organizations/utils.py +++ b/kobo/apps/organizations/utils.py @@ -1,16 +1,16 @@ from datetime import datetime from typing import Union -from zoneinfo import ZoneInfo from dateutil.relativedelta import relativedelta +from django.apps import apps from django.utils import timezone +from zoneinfo import ZoneInfo from kobo.apps.organizations.models import Organization -from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission -def get_monthly_billing_dates(organization: Union[Organization, None]): +def get_monthly_billing_dates(organization: Union['Organization', None]): """Returns start and end dates of an organization's monthly billing cycle""" now = timezone.now().replace(tzinfo=ZoneInfo('UTC')) @@ -69,7 +69,7 @@ def get_monthly_billing_dates(organization: Union[Organization, None]): return period_start, period_end -def get_yearly_billing_dates(organization: Union[Organization, None]): +def get_yearly_billing_dates(organization: Union['Organization', None]): """Returns start and end dates of an organization's annual billing cycle""" now = timezone.now().replace(tzinfo=ZoneInfo('UTC')) first_of_this_year = datetime(now.year, 1, 1, tzinfo=ZoneInfo('UTC')) @@ -105,9 +105,10 @@ def revoke_org_asset_perms(organization: Organization, user_ids: list[int]): Revokes permissions assigned to removed members on all assets belonging to the organization. """ + Asset = apps.get_model('kpi', 'Asset') # noqa subquery = Asset.objects.values_list('pk', flat=True).filter( owner=organization.owner_user_object ) ObjectPermission.objects.filter( - asset_id__in=subquery, user_id__in=user_ids + asset_id__in=subquery, user_id__in=user_ids ).delete() diff --git a/kobo/apps/organizations/views.py b/kobo/apps/organizations/views.py index af959e6cc0..7fd0e59470 100644 --- a/kobo/apps/organizations/views.py +++ b/kobo/apps/organizations/views.py @@ -1,29 +1,37 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.db.models import QuerySet +from django.db.models import ( + QuerySet, + Case, + When, + Value, + CharField, + OuterRef, +) +from django.db.models.expressions import Exists from django.utils.decorators import method_decorator +from django.utils.http import http_date from django.views.decorators.cache import cache_page from django_dont_vary_on.decorators import only_vary_on -from kpi import filters -from rest_framework import viewsets, status +from rest_framework import status, viewsets from rest_framework.decorators import action -from rest_framework.response import Response from rest_framework.request import Request +from rest_framework.response import Response +from kpi import filters from kpi.constants import ASSET_TYPE_SURVEY from kpi.filters import AssetOrderingFilter, SearchFilter from kpi.models.asset import Asset -from kpi.paginators import AssetUsagePagination -from kpi.permissions import IsAuthenticated from kpi.serializers.v2.service_usage import ( CustomAssetUsageSerializer, ServiceUsageSerializer, ) from kpi.utils.object_permission import get_database_user from kpi.views.v2.asset import AssetViewSet -from .models import Organization -from .permissions import IsOrgAdmin, IsOrgAdminOrReadOnly -from .serializers import OrganizationSerializer +from .models import Organization, OrganizationOwner, OrganizationUser +from .permissions import HasOrgRolePermission, IsOrgAdminPermission +from .serializers import OrganizationSerializer, OrganizationUserSerializer +from ..accounts.mfa.models import MfaMethod from ..stripe.constants import ACTIVE_STRIPE_STATUSES @@ -77,13 +85,11 @@ class OrganizationViewSet(viewsets.ModelViewSet): queryset = Organization.objects.all() serializer_class = OrganizationSerializer lookup_field = 'id' - permission_classes = (IsAuthenticated, IsOrgAdminOrReadOnly) - pagination_class = AssetUsagePagination + permission_classes = [HasOrgRolePermission] + http_method_names = ['get', 'patch'] @action( - detail=True, - methods=['GET'], - permission_classes=[IsOrgAdmin] + detail=True, methods=['GET'], permission_classes=[IsOrgAdminPermission] ) def assets(self, request: Request, *args, **kwargs): """ @@ -145,6 +151,7 @@ def service_usage(self, request, pk=None, *args, **kwargs): > "current_month_start": {string (date), ISO format}, > "current_year_start": {string (date), ISO format}, > "billing_period_end": {string (date), ISO format}|{None}, + > "last_updated": {string (date), ISO format}, > } ### CURRENT ENDPOINT """ @@ -158,7 +165,14 @@ def service_usage(self, request, pk=None, *args, **kwargs): get_database_user(request.user), context=context, ) - return Response(data=serializer.data) + response = Response( + data=serializer.data, + headers={ + 'Date': http_date(serializer.calculator.get_last_updated().timestamp()) + }, + ) + + return response @action(detail=True, methods=['get']) def asset_usage(self, request, pk=None, *args, **kwargs): @@ -210,7 +224,7 @@ def asset_usage(self, request, pk=None, *args, **kwargs): )[0] except IndexError: return Response( - {'error': "There was a problem finding the organization."}, + {'error': 'There was a problem finding the organization.'}, status=status.HTTP_400_BAD_REQUEST, ) @@ -257,3 +271,184 @@ def asset_usage(self, request, pk=None, *args, **kwargs): page, many=True, context=context ) return self.get_paginated_response(serializer.data) + + +class OrganizationMemberViewSet(viewsets.ModelViewSet): + """ + The API uses `ModelViewSet` instead of `NestedViewSetMixin` to maintain + explicit control over the queryset. + + ## Organization Members API + + This API allows authorized users to view and manage organization members and + their roles, including promoting or demoting members (eg. to admin). + + * Manage members and their roles within an organization. + * Update member roles (promote/demote). + + ### List Members + + Retrieves all members in the specified organization. + +
    +    GET /api/v2/organizations/{organization_id}/members/
    +    
    + + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/members/ + + > Response 200 + + > { + > "count": 2, + > "next": null, + > "previous": null, + > "results": [ + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ + > members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "owner", + > "user__has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "user__is_active": true + > }, + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/ \ + > members/john_doe/", + > "user": "http://[kpi]/api/v2/users/john_doe/", + > "user__username": "john_doe", + > "user__email": "john_doe@example.com", + > "user__name": "John Doe", + > "role": "admin", + > "user__has_mfa_enabled": false, + > "date_joined": "2024-10-21T06:38:45Z", + > "user__is_active": true + > } + > ] + > } + + + ### Retrieve Member Details + + Retrieves the details of a specific member within an organization by username. + +
    +    GET /api/v2/organizations/{organization_id}/members/{username}/
    +    
    + + > Example + > + > curl -X GET https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + > Response 200 + + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "owner", + > "user__has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "user__is_active": true + > } + + ### Update Member Role + + Updates the role of a member within the organization to `admin` or + `member`. + + - **admin**: Grants the member admin privileges within the organization + - **member**: Revokes admin privileges, setting the member as a regular user + +
    +    PATCH /api/v2/organizations/{organization_id}/members/{username}/
    +    
    + + > Example + > + > curl -X PATCH https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + > Payload + + > { + > "role": "admin" + > } + + > Response 200 + + > { + > "url": "http://[kpi]/api/v2/organizations/org_12345/members/foo_bar/", + > "user": "http://[kpi]/api/v2/users/foo_bar/", + > "user__username": "foo_bar", + > "user__email": "foo_bar@example.com", + > "user__name": "Foo Bar", + > "role": "admin", + > "user__has_mfa_enabled": true, + > "date_joined": "2024-08-11T12:36:32Z", + > "user__is_active": true + > } + + + ### Remove Member + + Delete an organization member. + +
    +    DELETE /api/v2/organizations/{organization_id}/members/{username}/
    +    
    + + > Example + > + > curl -X DELETE https://[kpi]/api/v2/organizations/org_12345/members/foo_bar/ + + ## Permissions + + - The user must be authenticated to perform these actions. + - Owners and admins can manage members and roles. + - Members can view the list but cannot update roles or delete members. + + ## Notes + + - **Role Validation**: Only valid roles ('admin', 'member') are accepted + in updates. + """ + serializer_class = OrganizationUserSerializer + permission_classes = [HasOrgRolePermission] + http_method_names = ['get', 'patch', 'delete'] + lookup_field = 'user__username' + + def get_queryset(self): + organization_id = self.kwargs['organization_id'] + + # Subquery to check if the user has an active MFA method + mfa_subquery = MfaMethod.objects.filter( + user=OuterRef('user_id'), + is_active=True + ).values('pk') + + # Subquery to check if the user is the owner + owner_subquery = OrganizationOwner.objects.filter( + organization_id=organization_id, + organization_user=OuterRef('pk') + ).values('pk') + + # Annotate with role based on organization ownership and admin status + queryset = OrganizationUser.objects.filter( + organization_id=organization_id + ).select_related('user__extra_details').annotate( + role=Case( + When(Exists(owner_subquery), then=Value('owner')), + When(is_admin=True, then=Value('admin')), + default=Value('member'), + output_field=CharField() + ), + has_mfa_enabled=Exists(mfa_subquery) + ) + return queryset diff --git a/kobo/apps/project_ownership/models/invite.py b/kobo/apps/project_ownership/models/invite.py index fdf0a91a4a..2af6a2cf7a 100644 --- a/kobo/apps/project_ownership/models/invite.py +++ b/kobo/apps/project_ownership/models/invite.py @@ -8,6 +8,7 @@ from kpi.fields import KpiUidField from kpi.models.abstract_models import AbstractTimeStampedModel from kpi.utils.mailer import EmailMessage, Mailer + from .choices import InviteStatusChoices @@ -96,7 +97,7 @@ def send_acceptance_email(self): plain_text_content_or_template='emails/accepted_invite.txt', template_variables=template_variables, html_content_or_template='emails/accepted_invite.html', - language=self.recipient.extra_details.data.get('last_ui_language') + language=self.recipient.extra_details.data.get('last_ui_language'), ) Mailer.send(email_message) @@ -127,7 +128,7 @@ def send_invite_email(self): plain_text_content_or_template='emails/new_invite.txt', template_variables=template_variables, html_content_or_template='emails/new_invite.html', - language=self.recipient.extra_details.data.get('last_ui_language') + language=self.recipient.extra_details.data.get('last_ui_language'), ) Mailer.send(email_message) @@ -153,7 +154,7 @@ def send_refusal_email(self): plain_text_content_or_template='emails/declined_invite.txt', template_variables=template_variables, html_content_or_template='emails/declined_invite.html', - language=self.recipient.extra_details.data.get('last_ui_language') + language=self.recipient.extra_details.data.get('last_ui_language'), ) Mailer.send(email_message) @@ -165,11 +166,7 @@ def create(self, **kwargs): return super().create(invite_type=InviteType.ORG_MEMBERSHIP, **kwargs) def get_queryset(self): - return ( - super() - .get_queryset() - .filter(invite_type=InviteType.ORG_MEMBERSHIP) - ) + return super().get_queryset().filter(invite_type=InviteType.ORG_MEMBERSHIP) class OrgMembershipAutoInvite(Invite): diff --git a/kobo/apps/project_ownership/models/transfer.py b/kobo/apps/project_ownership/models/transfer.py index 4ab95dfa95..664d64108c 100644 --- a/kobo/apps/project_ownership/models/transfer.py +++ b/kobo/apps/project_ownership/models/transfer.py @@ -19,6 +19,7 @@ from kpi.fields import KpiUidField from kpi.models import Asset, ObjectPermission from kpi.models.abstract_models import AbstractTimeStampedModel + from ..exceptions import TransferAlreadyProcessedException from ..tasks import async_task, send_email_to_admins from ..utils import get_target_folder @@ -288,8 +289,8 @@ def _update_invite_status(self): This method must be called within a transaction because of the lock acquired the object row (with `select_for_update`) """ - invite = self.get_invite_model().objects.select_for_update().get( - pk=self.invite_id + invite = ( + self.get_invite_model().objects.select_for_update().get(pk=self.invite_id) ) previous_status = invite.status is_complete = True diff --git a/kobo/apps/project_ownership/serializers/invite.py b/kobo/apps/project_ownership/serializers/invite.py index 769f964250..035b9ecd28 100644 --- a/kobo/apps/project_ownership/serializers/invite.py +++ b/kobo/apps/project_ownership/serializers/invite.py @@ -8,7 +8,7 @@ from kpi.fields import RelativePrefixHyperlinkedRelatedField from kpi.models import Asset -from .transfer import TransferListSerializer + from ..models import ( Invite, InviteStatusChoices, @@ -17,6 +17,7 @@ TransferStatusTypeChoices, ) from ..utils import create_invite, update_invite +from .transfer import TransferListSerializer class InviteSerializer(serializers.ModelSerializer): diff --git a/kobo/apps/project_ownership/tasks.py b/kobo/apps/project_ownership/tasks.py index f69d674471..42a030499f 100644 --- a/kobo/apps/project_ownership/tasks.py +++ b/kobo/apps/project_ownership/tasks.py @@ -135,23 +135,31 @@ def mark_as_expired(): """ # Avoid circular import Invite = apps.get_model('project_ownership', 'Invite') # noqa + TransferStatus = apps.get_model('project_ownership', 'TransferStatus') # noqa expiry_threshold = timezone.now() - timedelta( days=config.PROJECT_OWNERSHIP_INVITE_EXPIRY ) invites_to_update = [] + transfer_statuses_to_update = [] for invite in Invite.objects.filter( date_created__lte=expiry_threshold, status=InviteStatusChoices.PENDING, ): invite.status = InviteStatusChoices.EXPIRED invites_to_update.append(invite) + # Mark transfers as cancelled + for transfer in invite.transfers.all(): + for transfer_status in transfer.statuses.all(): + transfer_status.status = TransferStatusChoices.CANCELLED + transfer_statuses_to_update.append(transfer_status) if not invites_to_update: return # Notify senders + TransferStatus.objects.bulk_update(transfer_statuses_to_update, fields=['status']) Invite.objects.bulk_update(invites_to_update, fields=['status']) email_messages = [] diff --git a/kobo/apps/project_ownership/tests/api/v2/test_api.py b/kobo/apps/project_ownership/tests/api/v2/test_api.py index f0d77721dc..7abafd1a42 100644 --- a/kobo/apps/project_ownership/tests/api/v2/test_api.py +++ b/kobo/apps/project_ownership/tests/api/v2/test_api.py @@ -4,6 +4,7 @@ from constance.test import override_config from django.conf import settings from django.contrib.auth import get_user_model +from django.test import override_settings from django.utils import timezone from rest_framework import status from rest_framework.reverse import reverse @@ -358,6 +359,9 @@ def __add_submissions(self): 'kobo.apps.project_ownership.tasks.move_media_files', MagicMock() ) + @override_settings( + CACHES={'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}} + ) @override_config(PROJECT_OWNERSHIP_AUTO_ACCEPT_INVITES=True) def test_account_usage_transferred_to_new_user(self): today = timezone.now() diff --git a/kobo/apps/project_ownership/utils.py b/kobo/apps/project_ownership/utils.py index dd4f1bcd7d..9423f058a6 100644 --- a/kobo/apps/project_ownership/utils.py +++ b/kobo/apps/project_ownership/utils.py @@ -2,17 +2,17 @@ import time from typing import Literal, Optional, Union -from django.db import transaction from django.apps import apps +from django.db import transaction from django.utils import timezone - -from kobo.apps.openrosa.apps.main.models import MetaData from kobo.apps.openrosa.apps.logger.models.attachment import Attachment +from kobo.apps.openrosa.apps.main.models import MetaData from kobo.apps.project_ownership.models import InviteStatusChoices from kpi.models.asset import Asset, AssetFile -from .exceptions import AsyncTaskException + from .constants import ASYNC_TASK_HEARTBEAT, FILE_MOVE_CHUNK_SIZE +from .exceptions import AsyncTaskException from .models.choices import TransferStatusChoices, TransferStatusTypeChoices @@ -28,15 +28,9 @@ def create_invite( TransferStatus = apps.get_model('project_ownership', 'TransferStatus') with transaction.atomic(): - invite = InviteModel.objects.create( - sender=sender, - recipient=recipient - ) + invite = InviteModel.objects.create(sender=sender, recipient=recipient) transfers = Transfer.objects.bulk_create( - [ - Transfer(invite=invite, asset=asset) - for asset in assets - ] + [Transfer(invite=invite, asset=asset) for asset in assets] ) statuses = [] for transfer in transfers: @@ -244,9 +238,7 @@ def update_invite( for transfer in invite.transfers.all(): if invite.status != InviteStatusChoices.IN_PROGRESS: - transfer.statuses.update( - status=TransferStatusChoices.CANCELLED - ) + transfer.statuses.update(status=TransferStatusChoices.CANCELLED) else: transfer.transfer_project() diff --git a/kobo/apps/stripe/admin.py b/kobo/apps/stripe/admin.py new file mode 100644 index 0000000000..b00096b966 --- /dev/null +++ b/kobo/apps/stripe/admin.py @@ -0,0 +1,59 @@ +from django.contrib import admin, messages +from django.contrib.admin import ModelAdmin +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.utils.translation import ngettext + +from kobo.apps.stripe.models import PlanAddOn + + +@admin.register(PlanAddOn) +class PlanAddOnAdmin(ModelAdmin): + list_display = ( + 'organization', + 'product', + 'quantity', + 'is_available', + 'created', + ) + list_filter = ( + 'charge__livemode', + 'created', + 'product', + 'organization', + ) + search_fields = ('organization__id', 'id', 'organization__name', 'product__id') + readonly_fields = ('valid_tags',) + actions = ('_delete', 'make_add_ons') + universal_actions = ['make_add_ons'] + change_list_template = 'admin/add-ons/change_list.html' + + @admin.action(description='Make add-ons for existing Charges') + def make_add_ons(self, request, queryset): + created = PlanAddOn.make_add_ons_from_existing_charges() + self.message_user( + request, + ngettext( + '%d plan add-on was created.', + '%d plan add-ons were created.', + created, + ) + % created, + messages.SUCCESS, + ) + + def changelist_view(self, request, extra_context=None): + if ( + 'action' in request.POST + and request.POST['action'] in self.universal_actions + ): + if not request.POST.getlist(ACTION_CHECKBOX_NAME): + post = request.POST.copy() + post.update({ACTION_CHECKBOX_NAME: str(1)}) + request._set_post(post) + return super(PlanAddOnAdmin, self).changelist_view(request, extra_context) + + def get_queryset(self, request): + return super().get_queryset(request).prefetch_related('organization', 'product') + + def valid_tags(self, obj): + return obj.product.metadata.get('valid_tags', '') diff --git a/kobo/apps/stripe/constants.py b/kobo/apps/stripe/constants.py index b79dca0795..82e9926dfb 100644 --- a/kobo/apps/stripe/constants.py +++ b/kobo/apps/stripe/constants.py @@ -1,4 +1,6 @@ # coding: utf-8 +from datetime import timedelta + ACTIVE_STRIPE_STATUSES = [ 'active', 'past_due', @@ -16,3 +18,12 @@ 'name': None, 'feature_list': [], } + +ORGANIZATION_USAGE_MAX_CACHE_AGE = timedelta(minutes=15) + +USAGE_LIMIT_MAP = { + 'characters': 'mt_characters', + 'seconds': 'asr_seconds', + 'storage': 'storage_bytes', + 'submission': 'submission', +} diff --git a/kobo/apps/stripe/migrations/0001_initial.py b/kobo/apps/stripe/migrations/0001_initial.py new file mode 100644 index 0000000000..0146f59cf6 --- /dev/null +++ b/kobo/apps/stripe/migrations/0001_initial.py @@ -0,0 +1,102 @@ +# Generated by Django 3.2.15 on 2023-11-10 14:56 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import kobo.apps.stripe.utils +import kpi.fields.kpi_uid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('djstripe', '0011_2_7'), + ('organizations', '0001_squashed_0004_remove_organization_uid'), + ] + + operations = [ + migrations.CreateModel( + name='PlanAddOn', + fields=[ + ( + 'id', + kpi.fields.kpi_uid.KpiUidField( + _null=False, primary_key=True, uid_prefix='addon_' + ), + ), + ( + 'created', + models.DateTimeField( + help_text='The time when the add-on purchased.' + ), + ), + ( + 'quantity', + models.PositiveIntegerField( + default=1, + validators=[django.core.validators.MinValueValidator(1)], + ), + ), + ( + 'usage_limits', + models.JSONField( + default=kobo.apps.stripe.utils.get_default_add_on_limits, + help_text='The historical usage limits when the add-on was ' + 'purchased.\n Multiply this value by `quantity` to get ' + 'the total limits for this add-on. Possible keys:\n' + '"submission_limit", "asr_seconds_limit", and/or' + '"mt_characters_limit"', + ), + ), + ( + 'limits_remaining', + models.JSONField( + default=kobo.apps.stripe.utils.get_default_add_on_limits, + help_text="The amount of each of the add-on's individual " + 'limits left to use."', + ), + ), + ( + 'charge', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='djstripe.charge', + to_field='id', + ), + ), + ( + 'organization', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='organizations.organization', + ), + ), + ( + 'product', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to='djstripe.product', + to_field='id', + ), + ), + ], + options={ + 'verbose_name': 'plan add-on', + 'verbose_name_plural': 'plan add-ons', + }, + ), + migrations.AddIndex( + model_name='planaddon', + index=models.Index( + fields=['organization', 'limits_remaining', 'charge'], + name='stripe_plan_organiz_b3d2e4_idx', + ), + ), + ] diff --git a/kobo/apps/stripe/migrations/__init__.py b/kobo/apps/stripe/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/kobo/apps/stripe/models.py b/kobo/apps/stripe/models.py new file mode 100644 index 0000000000..66073893e4 --- /dev/null +++ b/kobo/apps/stripe/models.py @@ -0,0 +1,254 @@ +from typing import List + +from django.contrib import admin +from django.core.exceptions import ObjectDoesNotExist +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import F, IntegerField, Sum +from django.db.models.functions import Cast, Coalesce +from django.db.models.signals import post_save +from django.dispatch import receiver +from djstripe.enums import PaymentIntentStatus +from djstripe.models import Charge, Price, Subscription + +from kobo.apps.organizations.models import Organization +from kobo.apps.organizations.types import UsageType +from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES, USAGE_LIMIT_MAP +from kobo.apps.stripe.utils import get_default_add_on_limits +from kpi.fields import KpiUidField +from kpi.utils.django_orm_helper import DeductUsageValue + + +class PlanAddOn(models.Model): + id = KpiUidField(uid_prefix='addon_', primary_key=True) + created = models.DateTimeField(help_text='The time when the add-on purchased.') + organization = models.ForeignKey( + 'organizations.Organization', + to_field='id', + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(1)]) + usage_limits = models.JSONField( + default=get_default_add_on_limits, + help_text='''The historical usage limits when the add-on was purchased. + Multiply this value by `quantity` to get the total limits for this add-on. + Possible keys: + "submission_limit", "asr_seconds_limit", and/or "mt_characters_limit"''', + ) + limits_remaining = models.JSONField( + default=get_default_add_on_limits, + help_text="The amount of each of the add-on's individual limits left to use.", + ) + product = models.ForeignKey( + 'djstripe.Product', + to_field='id', + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + charge = models.ForeignKey( + 'djstripe.Charge', to_field='id', on_delete=models.CASCADE + ) + + class Meta: + verbose_name = 'plan add-on' + verbose_name_plural = 'plan add-ons' + indexes = [ + models.Index(fields=['organization', 'limits_remaining', 'charge']), + ] + + @staticmethod + def create_or_update_one_time_add_on(charge: Charge): + """ + Create a PlanAddOn object from a Charge object, if the Charge is for a + one-time add-on. + + Returns True if a PlanAddOn was created, false otherwise. + """ + if ( + charge.payment_intent.status != PaymentIntentStatus.succeeded + or not charge.metadata.get('price_id', None) + or not charge.metadata.get('quantity', None) + ): + # make sure the charge is for a successful addon purchase + return False + + try: + product = Price.objects.get(id=charge.metadata.get('price_id', '')).product + organization = Organization.objects.get( + id=charge.metadata['organization_id'] + ) + except ObjectDoesNotExist: + # no product/price/org/subscription, just bail + return False + + if product.metadata.get('product_type', '') != 'addon_onetime': + # might be some other type of payment + return False + + tags = product.metadata.get('valid_tags', '').split(',') + if ( + tags + and ('all' not in tags) + and not Subscription.objects.filter( + customer__subscriber=organization, + items__price__product__metadata__has_key__in=[tags], + status__in=ACTIVE_STRIPE_STATUSES, + ).exists() + ): + # this user doesn't have the subscription level they need for this addon + return False + + quantity = int(charge.metadata['quantity']) + usage_limits = {} + limits_remaining = {} + for limit_type in get_default_add_on_limits().keys(): + limit_value = charge.metadata.get(limit_type, None) + if limit_value is not None: + usage_limits[limit_type] = int(limit_value) + limits_remaining[limit_type] = int(limit_value) * quantity + + if not len(usage_limits): + # not a valid plan add-on + return False + + add_on, add_on_created = PlanAddOn.objects.get_or_create( + charge=charge, created=charge.djstripe_created + ) + if add_on_created: + add_on.product = product + add_on.quantity = int(charge.metadata['quantity']) + add_on.organization = organization + add_on.usage_limits = usage_limits + add_on.limits_remaining = limits_remaining + add_on.save() + return add_on_created + + @staticmethod + def get_organization_totals( + organization: 'Organization', usage_type: UsageType + ) -> (int, int): + """ + Returns the total limit and the total remaining usage for a given organization + and usage type. + """ + usage_mapped = USAGE_LIMIT_MAP[usage_type] + limit_key = f'{usage_mapped}_limit' + limit_field = f'limits_remaining__{limit_key}' + usage_field = f'usage_limits__{limit_key}' + totals = PlanAddOn.objects.filter( + organization__id=organization.id, + limits_remaining__has_key=limit_key, + usage_limits__has_key=limit_key, + charge__refunded=False, + ).aggregate( + total_usage_limit=Coalesce( + Sum(Cast(usage_field, output_field=IntegerField()) * F('quantity')), + 0, + output_field=IntegerField(), + ), + total_remaining=Coalesce( + Sum(Cast(limit_field, output_field=IntegerField())), + 0, + ), + ) + + return totals['total_usage_limit'], totals['total_remaining'] + + @property + def is_expended(self): + """ + Whether the addon is at/over its usage limits. + """ + for limit_type, limit_value in self.limits_remaining.items(): + if limit_value > 0: + return False + return True + + @admin.display(boolean=True, description='available') + def is_available(self): + return not (self.is_expended or self.charge.refunded) and bool( + self.organization + ) + + def deduct(self, limit_type, amount_used): + """ + Deducts the add on usage counter for limit_type by amount_used. + Returns the amount of this add-on that was used (up to its limit). + Will return 0 if limit_type does not apply to this add-on. + """ + if limit_type in self.usage_limits.keys(): + limit_available = self.limits_remaining.get(limit_type) + amount_to_use = min(amount_used, limit_available) + PlanAddOn.objects.filter(pk=self.id).update( + limits_remaining=DeductUsageValue( + 'limits_remaining', keyname=limit_type, amount=amount_used + ) + ) + return amount_to_use + return 0 + + @staticmethod + def make_add_ons_from_existing_charges(): + """ + Create a PlanAddOn object for each eligible Charge object in the database. + Does not refresh Charge data from Stripe. + Returns the number of PlanAddOns created. + """ + created_count = 0 + # TODO: This should filter out charges that are already matched to an add on + for charge in Charge.objects.all().iterator(chunk_size=500): + if PlanAddOn.create_or_update_one_time_add_on(charge): + created_count += 1 + return created_count + + @staticmethod + def deduct_add_ons_for_organization( + organization: 'Organization', usage_type: UsageType, amount: int + ): + """ + Deducts the usage counter for limit_type by amount_used for a given user. + Will always spend the add-on with the most used first, so that add-ons + are used up in FIFO order. + + Returns the amount of usage that was not applied to an add-on. + """ + usage_mapped = USAGE_LIMIT_MAP[usage_type] + limit_key = f'{usage_mapped}_limit' + metadata_key = f'limits_remaining__{limit_key}' + add_ons = PlanAddOn.objects.filter( + organization__id=organization.id, + limits_remaining__has_key=limit_key, + charge__refunded=False, + **{f'{metadata_key}__gt': 0}, + ).order_by(metadata_key) + remaining = amount + for add_on in add_ons.iterator(): + if add_on.is_available(): + remaining -= add_on.deduct(limit_type=limit_key, amount_used=remaining) + return remaining + + @property + def total_usage_limits(self): + """ + The total usage limits for this add-on, based on the usage_limits for a single + add-on and the quantity. + """ + return {key: value * self.quantity for key, value in self.usage_limits.items()} + + @property + def valid_tags(self) -> List: + """ + The tag metadata (on the subscription product/price) needed to view/purchase + this add-on. If the org that purchased this add-on no longer has that a plan + with those tags, the add-on will be inactive. If the add-on doesn't require a + tag, this property will return an empty list. + """ + return self.product.metadata.get('valid_tags', '').split(',') + + +@receiver(post_save, sender=Charge) +def make_add_on_for_charge(sender, instance, created, **kwargs): + PlanAddOn.create_or_update_one_time_add_on(instance) diff --git a/kobo/apps/stripe/serializers.py b/kobo/apps/stripe/serializers.py index 3d2346f7f7..422511b02f 100644 --- a/kobo/apps/stripe/serializers.py +++ b/kobo/apps/stripe/serializers.py @@ -2,27 +2,28 @@ from djstripe.models import ( Price, Product, - Session, Subscription, SubscriptionItem, SubscriptionSchedule, ) from rest_framework import serializers +from kobo.apps.stripe.models import PlanAddOn -class OneTimeAddOnSerializer(serializers.ModelSerializer): - payment_intent = serializers.SlugRelatedField( - slug_field='status', - read_only=True, - many=False, - ) +class OneTimeAddOnSerializer(serializers.ModelSerializer): class Meta: - model = Session + model = PlanAddOn fields = ( - 'metadata', + 'id', 'created', - 'payment_intent', + 'is_available', + 'quantity', + 'usage_limits', + 'total_usage_limits', + 'limits_remaining', + 'organization', + 'product', ) diff --git a/kobo/apps/stripe/templates/admin/add-ons/change_list.html b/kobo/apps/stripe/templates/admin/add-ons/change_list.html new file mode 100644 index 0000000000..86810d482d --- /dev/null +++ b/kobo/apps/stripe/templates/admin/add-ons/change_list.html @@ -0,0 +1,8 @@ +{% extends "admin/change_list.html" %} +{% load i18n static admin_list %} + +{% block result_list %} + {% if action_form and actions_on_top %}{% admin_actions %}{% endif %} + {% result_list cl %} + {% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %} +{% endblock %} diff --git a/kobo/apps/stripe/tests/test_customer_portal_api.py b/kobo/apps/stripe/tests/test_customer_portal_api.py index 9e6421e0d8..00763e7d2e 100644 --- a/kobo/apps/stripe/tests/test_customer_portal_api.py +++ b/kobo/apps/stripe/tests/test_customer_portal_api.py @@ -1,9 +1,10 @@ +from unittest.mock import patch +from urllib.parse import urlencode + from django.urls import reverse -from djstripe.models import Customer, Subscription, Price, Product +from djstripe.models import Customer, Price, Product, Subscription from model_bakery import baker from rest_framework import status -from urllib.parse import urlencode -from unittest.mock import patch from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.organizations.models import Organization @@ -34,9 +35,7 @@ def _create_stripe_data(self, create_subscription=True, product_type='plan'): self.customer = baker.make( Customer, subscriber=self.organization, livemode=False ) - self.product = baker.make( - Product, metadata={'product_type': product_type} - ) + self.product = baker.make(Product, metadata={'product_type': product_type}) self.price = baker.make( Price, product=self.product, diff --git a/kobo/apps/stripe/tests/test_one_time_addons_api.py b/kobo/apps/stripe/tests/test_one_time_addons_api.py index 1be1513fef..7c699d1ebb 100644 --- a/kobo/apps/stripe/tests/test_one_time_addons_api.py +++ b/kobo/apps/stripe/tests/test_one_time_addons_api.py @@ -1,14 +1,25 @@ +from ddt import data, ddt from django.urls import reverse -from djstripe.enums import BillingScheme -from djstripe.models import Customer, PaymentIntent +from django.utils import timezone +from djstripe.models import ( + Charge, + Customer, + PaymentIntent, + Price, + Product, + Subscription, +) from model_bakery import baker from rest_framework import status from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.organizations.models import Organization +from kobo.apps.stripe.constants import USAGE_LIMIT_MAP +from kobo.apps.stripe.models import PlanAddOn from kpi.tests.kpi_test_case import BaseTestCase +@ddt class OneTimeAddOnAPITestCase(BaseTestCase): fixtures = ['test_data'] @@ -17,58 +28,183 @@ def setUp(self): self.client.force_login(self.someuser) self.url = reverse('addons-list') self.price_id = 'price_305dfs432ltnjw' - - def _insert_data(self): self.organization = baker.make(Organization) self.organization.add_user(self.someuser, is_admin=True) self.customer = baker.make(Customer, subscriber=self.organization) + baker.make( + Subscription, + customer=self.customer, + status='active', + ) + + def _create_product(self, metadata=None): + if not metadata: + metadata = { + 'product_type': 'addon_onetime', + 'submission_limit': 2000, + 'valid_tags': 'all', + } + self.product = baker.make( + Product, + active=True, + metadata=metadata, + ) + self.price = baker.make( + Price, active=True, product=self.product, type='one_time' + ) + self.product.save() - def _create_session_and_payment_intent(self): - payment_intent = baker.make( + def _create_payment(self, payment_status='succeeded', refunded=False, quantity=1): + payment_total = quantity * 2000 + self.payment_intent = baker.make( PaymentIntent, customer=self.customer, - status='succeeded', - payment_method_types=["card"], + status=payment_status, + payment_method_types=['card'], livemode=False, + amount=payment_total, + amount_capturable=payment_total, + amount_received=payment_total, ) - session = baker.make( - 'djstripe.Session', + self.charge = baker.prepare( + Charge, customer=self.customer, - metadata={ - 'organization_id': self.organization.id, - 'price_id': self.price_id, - }, - mode='payment', - payment_intent=payment_intent, - payment_method_types=["card"], - items__price__livemode=False, - items__price__billing_scheme=BillingScheme.per_unit, + refunded=refunded, + created=timezone.now(), + payment_intent=self.payment_intent, + paid=True, + status=payment_status, livemode=False, + amount_refunded=0 if refunded else payment_total, + amount=payment_total, ) + self.charge.metadata = { + 'price_id': self.price.id, + 'organization_id': self.organization.id, + 'quantity': quantity, + **(self.product.metadata or {}), + } + self.charge.save() def test_no_addons(self): response = self.client.get(self.url) assert response.status_code == status.HTTP_200_OK assert response.data['results'] == [] - def test_get_endpoint(self): - self._insert_data() - self._create_session_and_payment_intent() + def test_get_addon(self): + self._create_product() + self._create_payment() + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 + assert response.data['results'][0]['product'] == self.product.id + + def test_multiple_addons(self): + self._create_product() + self._create_payment() + self._create_payment() + self._create_payment() + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 3 + + def test_no_addons_for_invalid_product_metadata(self): + self._create_product( + metadata={'product_type': 'subscription', 'submission_limit': 2000} + ) + self._create_payment() + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 0 + + self._create_product( + metadata={ + 'product_type': 'addon_onetime', + 'not_a_real_limit_key': 2000, + 'valid_tags': 'all', + } + ) + self._create_payment() + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 0 + + def test_addon_inactive_for_refunded_charge(self): + self._create_product() + self._create_payment(refunded=True) + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 1 + assert not response.data['results'][0]['is_available'] + + def test_no_addon_for_cancelled_charge(self): + self._create_product() + self._create_payment(payment_status='cancelled') + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] == 0 + + def test_total_limits_reflect_addon_quantity(self): + limit = 2000 + quantity = 9 + self._create_product( + metadata={ + 'product_type': 'addon_onetime', + 'asr_seconds_limit': limit, + 'valid_tags': 'all', + } + ) + self._create_payment(quantity=quantity) response = self.client.get(self.url) assert response.status_code == status.HTTP_200_OK assert response.data['count'] == 1 + asr_seconds = response.data['results'][0]['total_usage_limits'][ + 'asr_seconds_limit' + ] + assert asr_seconds == limit * quantity def test_anonymous_user(self): - self._insert_data() - self._create_session_and_payment_intent() + self._create_product() + self._create_payment() self.client.logout() response = self.client.get(self.url) assert response.status_code == status.HTTP_401_UNAUTHORIZED def test_not_own_addon(self): - self._insert_data() - self._create_session_and_payment_intent() + self._create_product() + self._create_payment() self.client.force_login(User.objects.get(username='anotheruser')) response_get_list = self.client.get(self.url) assert response_get_list.status_code == status.HTTP_200_OK assert response_get_list.data['results'] == [] + + @data('characters', 'seconds') + def test_get_user_totals(self, usage_type): + limit = 2000 + quantity = 5 + usage_limit_key = f'{USAGE_LIMIT_MAP[usage_type]}_limit' + self._create_product( + metadata={ + 'product_type': 'addon_onetime', + usage_limit_key: limit, + 'valid_tags': 'all', + } + ) + self._create_payment() + self._create_payment() + self._create_payment(quantity=quantity) + + total_limit, remaining = PlanAddOn.get_organization_totals( + self.organization, usage_type + ) + assert total_limit == limit * (quantity + 2) + assert remaining == limit * (quantity + 2) + + PlanAddOn.deduct_add_ons_for_organization( + self.organization, usage_type, limit * quantity + ) + total_limit, remaining = PlanAddOn.get_organization_totals( + self.organization, usage_type + ) + assert total_limit == limit * (quantity + 2) + assert remaining == limit * 2 diff --git a/kobo/apps/stripe/tests/test_organization_usage.py b/kobo/apps/stripe/tests/test_organization_usage.py index ef2c57a82f..e07773be59 100644 --- a/kobo/apps/stripe/tests/test_organization_usage.py +++ b/kobo/apps/stripe/tests/test_organization_usage.py @@ -9,6 +9,7 @@ import pytest from dateutil.relativedelta import relativedelta +from ddt import data, ddt from django.core.cache import cache from django.test import override_settings from django.urls import reverse @@ -20,15 +21,18 @@ from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.organizations.models import Organization, OrganizationUser +from kobo.apps.stripe.constants import USAGE_LIMIT_MAP from kobo.apps.stripe.tests.utils import ( generate_enterprise_subscription, generate_plan_subscription, ) +from kobo.apps.stripe.utils import get_organization_plan_limit from kobo.apps.trackers.tests.submission_utils import ( add_mock_submissions, create_mock_assets, ) from kpi.tests.api.v2.test_api_asset_usage import AssetUsageAPITestCase +from kpi.tests.kpi_test_case import BaseTestCase from kpi.tests.test_usage_calculator import BaseServiceUsageTestCase @@ -458,3 +462,50 @@ def test_users_without_enterprise_see_only_their_usage(self): response = self.client.get(self.detail_url) assert response.status_code == status.HTTP_200_OK assert response.data['count'] == 1 + + +@ddt +class OrganizationsUtilsTestCase(BaseTestCase): + fixtures = ['test_data'] + + def setUp(self): + self.organization = baker.make( + Organization, id='123456abcdef', name='test organization' + ) + self.someuser = User.objects.get(username='someuser') + self.anotheruser = User.objects.get(username='anotheruser') + self.newuser = baker.make(User, username='newuser') + self.organization.add_user(self.anotheruser, is_admin=True) + + def test_get_plan_community_limit(self): + generate_enterprise_subscription(self.organization) + limit = get_organization_plan_limit(self.organization, 'seconds') + assert limit == 2000 # TODO get the limits from the community plan, overrides + limit = get_organization_plan_limit(self.organization, 'characters') + assert limit == 2000 # TODO get the limits from the community plan, overrides + + @data('characters', 'seconds') + def test_get_suscription_limit(self, usage_type): + stripe_key = f'{USAGE_LIMIT_MAP[usage_type]}_limit' + product_metadata = { + stripe_key: '1234', + 'product_type': 'plan', + 'plan_type': 'enterprise', + } + generate_plan_subscription(self.organization, metadata=product_metadata) + limit = get_organization_plan_limit(self.organization, usage_type) + assert limit == 1234 + + # Currently submissions and storage are the only usage types that can be + # 'unlimited' + @data('submission', 'storage') + def test_get_suscription_limit_unlimited(self, usage_type): + stripe_key = f'{USAGE_LIMIT_MAP[usage_type]}_limit' + product_metadata = { + stripe_key: 'unlimited', + 'product_type': 'plan', + 'plan_type': 'enterprise', + } + generate_plan_subscription(self.organization, metadata=product_metadata) + limit = get_organization_plan_limit(self.organization, usage_type) + assert limit == float('inf') diff --git a/kobo/apps/stripe/utils.py b/kobo/apps/stripe/utils.py index f64e19b3ed..c6dbb45381 100644 --- a/kobo/apps/stripe/utils.py +++ b/kobo/apps/stripe/utils.py @@ -1,10 +1,83 @@ -from math import ceil, floor +from math import ceil, floor, inf from django.conf import settings -from djstripe.models import Price +from django.db.models import F +from kobo.apps.organizations.models import Organization +from kobo.apps.organizations.types import UsageType +from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES, USAGE_LIMIT_MAP -def get_total_price_for_quantity(price: Price, quantity: int): + +def generate_return_url(product_metadata): + """ + Determine which frontend page Stripe should redirect users to + after they make a purchase or manage their account. + """ + base_url = settings.KOBOFORM_URL + '/#/account/' + return_page = 'addons' if product_metadata['product_type'] == 'addon' else 'plan' + return base_url + return_page + + +def get_default_add_on_limits(): + return { + 'submission_limit': 0, + 'asr_seconds_limit': 0, + 'mt_characters_limit': 0, + } + + +def get_organization_plan_limit( + organization: Organization, usage_type: UsageType +) -> int | float: + """ + Get organization plan limit for a given usage type + """ + if not settings.STRIPE_ENABLED: + return None + stripe_key = f'{USAGE_LIMIT_MAP[usage_type]}_limit' + query_product_type = ( + 'djstripe_customers__subscriptions__items__price__' + 'product__metadata__product_type' + ) + query_status__in = 'djstripe_customers__subscriptions__status__in' + organization_filter = Organization.objects.filter( + id=organization.id, + **{ + query_status__in: ACTIVE_STRIPE_STATUSES, + query_product_type: 'plan', + }, + ) + + field_price_limit = ( + 'djstripe_customers__subscriptions__items__' f'price__metadata__{stripe_key}' + ) + field_product_limit = ( + 'djstripe_customers__subscriptions__items__' + f'price__product__metadata__{stripe_key}' + ) + current_limit = organization_filter.values( + price_limit=F(field_price_limit), + product_limit=F(field_product_limit), + prod_metadata=F( + 'djstripe_customers__subscriptions__items__price__product__metadata' + ), + ).first() + relevant_limit = None + if current_limit is not None: + relevant_limit = current_limit.get('price_limit') or current_limit.get( + 'product_limit' + ) + if relevant_limit is None: + # TODO: get the limits from the community plan, overrides + relevant_limit = 2000 + # Limits in Stripe metadata are strings. They may be numbers or 'unlimited' + if relevant_limit == 'unlimited': + return inf + + return int(relevant_limit) + + +def get_total_price_for_quantity(price: 'djstripe.models.Price', quantity: int): """ Calculate a total price (dividing and rounding as necessary) for an item quantity and djstripe Price object @@ -17,14 +90,3 @@ def get_total_price_for_quantity(price: Price, quantity: int): else: total_price = floor(total_price) return total_price * price.unit_amount - -def generate_return_url(product_metadata): - """ - Determine which frontend page Stripe should redirect users to - after they make a purchase or manage their account. - """ - base_url = settings.KOBOFORM_URL + '/#/account/' - return_page = ( - 'addons' if product_metadata['product_type'] == 'addon' else 'plan' - ) - return base_url + return_page diff --git a/kobo/apps/stripe/views.py b/kobo/apps/stripe/views.py index 7b388c950c..95811c0240 100644 --- a/kobo/apps/stripe/views.py +++ b/kobo/apps/stripe/views.py @@ -10,7 +10,6 @@ Customer, Price, Product, - Session, Subscription, SubscriptionItem, SubscriptionSchedule, @@ -23,6 +22,7 @@ from kobo.apps.organizations.models import Organization from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES +from kobo.apps.stripe.models import PlanAddOn from kobo.apps.stripe.serializers import ( ChangePlanSerializer, CheckoutLinkSerializer, @@ -31,26 +31,23 @@ ProductSerializer, SubscriptionSerializer, ) -from kobo.apps.stripe.utils import ( - generate_return_url, - get_total_price_for_quantity, -) +from kobo.apps.stripe.utils import generate_return_url, get_total_price_for_quantity from kpi.permissions import IsAuthenticated -# Lists the one-time purchases made by the organization that the logged-in user owns class OneTimeAddOnViewSet(viewsets.ReadOnlyModelViewSet): + """ + Lists the one-time add-ons for the authenticated user's organization. + """ permission_classes = (IsAuthenticated,) serializer_class = OneTimeAddOnSerializer - queryset = Session.objects.all() + queryset = PlanAddOn.objects.all() def get_queryset(self): return self.queryset.filter( - livemode=settings.STRIPE_LIVE_MODE, - customer__subscriber__owner__organization_user__user=self.request.user, - mode='payment', - payment_intent__status__in=['succeeded', 'processing'], - ).prefetch_related('payment_intent') + charge__livemode=settings.STRIPE_LIVE_MODE, + organization__organization_users__user=self.request.user, + ) class ChangePlanView(APIView): @@ -90,7 +87,10 @@ def modify_subscription(price, subscription, quantity): stripe.api_key = djstripe_settings.STRIPE_SECRET_KEY subscription_item = subscription.items.get() # Exit immediately if the price/quantity we're changing to is the price/quantity they're currently subscribed to - if quantity == subscription_item.quantity and price.id == subscription_item.price.id: + if ( + quantity == subscription_item.quantity + and price.id == subscription_item.price.id + ): return Response( {'status': 'already subscribed'}, status=status.HTTP_400_BAD_REQUEST, @@ -200,7 +200,7 @@ class CheckoutLinkView(APIView): serializer_class = CheckoutLinkSerializer @staticmethod - def generate_payment_link(price, user, organization_id, quantity): + def generate_payment_link(price, user, organization_id, quantity=1): if organization_id: # Get the organization for the logged-in user and provided organization ID organization = Organization.objects.get( @@ -246,12 +246,24 @@ def generate_payment_link(price, user, organization_id, quantity): return session['url'] @staticmethod - def start_checkout_session(customer_id, price, organization_id, user, quantity): - checkout_mode = ( - 'payment' if price.type == 'one_time' else 'subscription' - ) + def start_checkout_session(customer_id, price, organization_id, user, quantity=1): kwargs = {} - if checkout_mode == 'subscription': + if price.type == 'one_time': + checkout_mode = 'payment' + kwargs['payment_intent_data'] = { + 'metadata': { + 'organization_id': organization_id, + 'price_id': price.id, + 'quantity': quantity, + # product metadata contains the usage limit values + # for one-time add-ons + **(price.product.metadata or {}), + }, + } + else: + checkout_mode = 'subscription' + # subscriptions in Stripe can only be purchased one at a time + quantity = 1 kwargs['subscription_data'] = { 'metadata': { 'kpi_owner_username': user.username, @@ -260,6 +272,7 @@ def start_checkout_session(customer_id, price, organization_id, user, quantity): 'organization_id': organization_id, }, } + return stripe.checkout.Session.create( api_key=djstripe_settings.STRIPE_SECRET_KEY, allow_promotion_codes=True, @@ -282,7 +295,8 @@ def start_checkout_session(customer_id, price, organization_id, user, quantity): 'kpi_owner_username': user.username, }, mode=checkout_mode, - success_url=generate_return_url(price.product.metadata) + f'?checkout={price.id}', + success_url=generate_return_url(price.product.metadata) + + f'?checkout={price.id}', **kwargs, ) @@ -323,8 +337,9 @@ def generate_portal_link(user, organization_id, price, quantity): ) if not customer: + error_str = f"Couldn't find customer with organization id {organization_id}" return Response( - {'error': f"Couldn't find customer with organization id {organization_id}"}, + {'error': error_str}, status=status.HTTP_400_BAD_REQUEST, ) @@ -361,7 +376,10 @@ def generate_portal_link(user, organization_id, price, quantity): ) if not len(all_configs): - return Response({'error': "Missing Stripe billing configuration."}, status=status.HTTP_502_BAD_GATEWAY) + return Response( + {'error': 'Missing Stripe billing configuration.'}, + status=status.HTTP_502_BAD_GATEWAY, + ) """ Recurring add-ons and the Enterprise plan aren't included in the default billing configuration. diff --git a/kobo/apps/subsequences/integrations/google/google_transcribe.py b/kobo/apps/subsequences/integrations/google/google_transcribe.py index 726d5984c7..16626ca619 100644 --- a/kobo/apps/subsequences/integrations/google/google_transcribe.py +++ b/kobo/apps/subsequences/integrations/google/google_transcribe.py @@ -11,6 +11,7 @@ from google.cloud import speech from kpi.utils.log import logging + from ...constants import GOOGLETS from ...exceptions import ( AudioTooLongError, @@ -72,6 +73,7 @@ def begin_google_operation( submission_uuid = self.submission.submission_uuid flac_content, duration = content total_seconds = int(duration.total_seconds()) + # Create the parameters required for the transcription speech_client = speech.SpeechClient(credentials=self.credentials) config = speech.RecognitionConfig( diff --git a/kobo/apps/subsequences/tests/test_submission_extras_api_post.py b/kobo/apps/subsequences/tests/test_submission_extras_api_post.py index 4b9de37062..372fc6dcdc 100644 --- a/kobo/apps/subsequences/tests/test_submission_extras_api_post.py +++ b/kobo/apps/subsequences/tests/test_submission_extras_api_post.py @@ -26,7 +26,9 @@ PERM_VIEW_SUBMISSIONS, ) from kpi.models.asset import Asset +from kpi.tests.base_test_case import BaseTestCase from kpi.utils.fuzzy_int import FuzzyInt + from ..constants import GOOGLETS, GOOGLETX from ..models import SubmissionExtras @@ -389,11 +391,11 @@ def test_change_language_list(self): # validate(package, schema) -class GoogleNLPSubmissionTest(APITestCase): +class GoogleNLPSubmissionTest(BaseTestCase): + fixtures = ['test_data'] + def setUp(self): - self.user = User.objects.create_user( - username='someuser', email='user@example.com' - ) + self.user = User.objects.get(username='someuser') self.asset = Asset( content={'survey': [{'type': 'audio', 'label': 'q1', 'name': 'q1'}]} ) @@ -406,12 +408,11 @@ def setUp(self): self.asset.deploy(backend='mock', active=True) self.asset_url = f'/api/v2/assets/{self.asset.uid}/?format=json' self.client.force_login(self.user) - transcription_service = TranscriptionService.objects.create(code='goog') - translation_service = TranslationService.objects.create(code='goog') + transcription_service = TranscriptionService.objects.get(code='goog') + translation_service = TranslationService.objects.get(code='goog') language = Language.objects.create(name='', code='') language_region = LanguageRegion.objects.create(language=language, name='', code='') - TranscriptionServiceLanguageM2M.objects.create( language=language, region=language_region, diff --git a/kobo/apps/trackers/tests/test_utils.py b/kobo/apps/trackers/tests/test_utils.py new file mode 100644 index 0000000000..05c11d1f07 --- /dev/null +++ b/kobo/apps/trackers/tests/test_utils.py @@ -0,0 +1,121 @@ +from ddt import data, ddt +from django.utils import timezone +from djstripe.models import Charge, PaymentIntent, Price, Product +from model_bakery import baker + +from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.organizations.models import Organization +from kobo.apps.stripe.constants import USAGE_LIMIT_MAP +from kobo.apps.stripe.tests.utils import generate_plan_subscription +from kobo.apps.trackers.utils import ( + get_organization_remaining_usage, + update_nlp_counter, +) +from kpi.models.asset import Asset +from kpi.tests.kpi_test_case import BaseTestCase + + +@ddt +class TrackersUtilitiesTestCase(BaseTestCase): + fixtures = ['test_data'] + + def setUp(self): + self.organization = baker.make( + Organization, id='123456abcdef', name='test organization' + ) + self.someuser = User.objects.get(username='someuser') + self.anotheruser = User.objects.get(username='anotheruser') + self.organization.add_user(self.someuser, is_admin=True) + + self.asset = Asset.objects.create( + content={'survey': [{'type': 'text', 'label': 'q1', 'name': 'q1'}]}, + owner=self.someuser, + asset_type='survey', + name='test asset', + ) + self.asset.deploy(backend='mock', active=True) + + def _create_product(self, product_metadata): + product = baker.make( + Product, + active=True, + metadata=product_metadata, + ) + price = baker.make(Price, active=True, product=product, type='one_time') + product.save() + return product, price + + def _make_payment( + self, + price, + customer, + payment_status='succeeded', + quantity=1, + ): + payment_total = quantity * 2000 + payment_intent = baker.make( + PaymentIntent, + customer=customer, + status=payment_status, + payment_method_types=['card'], + livemode=False, + amount=payment_total, + amount_capturable=payment_total, + amount_received=payment_total, + ) + charge = baker.prepare( + Charge, + customer=customer, + refunded=False, + created=timezone.now(), + payment_intent=payment_intent, + paid=True, + status=payment_status, + livemode=False, + amount_refunded=0, + amount=payment_total, + ) + charge.metadata = { + 'price_id': price.id, + 'organization_id': self.organization.id, + 'quantity': quantity, + **(price.product.metadata or {}), + } + charge.save() + return charge + + @data('characters', 'seconds') + def test_organization_usage_utils(self, usage_type): + usage_key = f'{USAGE_LIMIT_MAP[usage_type]}_limit' + sub_metadata = { + usage_key: '1000', + 'product_type': 'plan', + 'plan_type': 'enterprise', + } + subscription = generate_plan_subscription( + self.organization, metadata=sub_metadata + ) + addon_metadata = { + 'product_type': 'addon_onetime', + usage_key: '2000', + 'valid_tags': 'all', + } + product, price = self._create_product(addon_metadata) + self._make_payment(price, subscription.customer, quantity=2) + + total_limit = 2000 * 2 + 1000 + remaining = get_organization_remaining_usage(self.organization, usage_type) + assert remaining == total_limit + + update_nlp_counter( + USAGE_LIMIT_MAP[usage_type], 1000, self.someuser.id, self.asset.id + ) + + remaining = get_organization_remaining_usage(self.organization, usage_type) + assert remaining == total_limit - 1000 + + update_nlp_counter( + USAGE_LIMIT_MAP[usage_type], 1500, self.someuser.id, self.asset.id + ) + remaining = get_organization_remaining_usage(self.organization, usage_type) + assert remaining == total_limit - 2500 diff --git a/kobo/apps/trackers/utils.py b/kobo/apps/trackers/utils.py index 57f7e0221a..1e7325127f 100644 --- a/kobo/apps/trackers/utils.py +++ b/kobo/apps/trackers/utils.py @@ -1,10 +1,16 @@ -from typing import Optional +from typing import Optional, Union from django.apps import apps from django.db.models import F from django.utils import timezone +from django_request_cache import cache_for_request +from kobo.apps.organizations.models import Organization +from kobo.apps.organizations.types import UsageType +from kobo.apps.stripe.constants import USAGE_LIMIT_MAP +from kobo.apps.stripe.utils import get_organization_plan_limit from kpi.utils.django_orm_helper import IncrementValue +from kpi.utils.usage_calculator import ServiceUsageCalculator def update_nlp_counter( @@ -24,9 +30,11 @@ def update_nlp_counter( on the service user_id (int): id of the asset owner asset_id (int) or None: Primary key for Asset Model + counter_id (int) or None: Primary key for NLPUsageCounter instance """ # Avoid circular import NLPUsageCounter = apps.get_model('trackers', 'NLPUsageCounter') # noqa + organization = Organization.get_from_user_id(user_id) if not counter_id: date = timezone.now() @@ -44,10 +52,69 @@ def update_nlp_counter( kwargs = {} if service.endswith('asr_seconds'): kwargs['total_asr_seconds'] = F('total_asr_seconds') + amount + if asset_id is not None and organization is not None: + handle_usage_deduction(organization, 'seconds', amount) if service.endswith('mt_characters'): kwargs['total_mt_characters'] = F('total_mt_characters') + amount + if asset_id is not None and organization is not None: + handle_usage_deduction(organization, 'characters', amount) NLPUsageCounter.objects.filter(pk=counter_id).update( counters=IncrementValue('counters', keyname=service, increment=amount), **kwargs, ) + + +@cache_for_request +def get_organization_usage(organization: Organization, usage_type: UsageType) -> int: + """ + Get the used amount for a given organization and usage type + """ + usage_calc = ServiceUsageCalculator( + organization.owner.organization_user.user, organization, disable_cache=True + ) + usage = usage_calc.get_nlp_usage_by_type(USAGE_LIMIT_MAP[usage_type]) + + return usage + + +def get_organization_remaining_usage( + organization: Organization, usage_type: UsageType +) -> Union[int, None]: + """ + Get the organization remaining usage count for a given limit type + """ + PlanAddOn = apps.get_model('stripe', 'PlanAddOn') # noqa + + plan_limit = get_organization_plan_limit(organization, usage_type) + if plan_limit is None: + plan_limit = 0 + usage = get_organization_usage(organization, usage_type) + addon_limit, addon_remaining = PlanAddOn.get_organization_totals( + organization, + usage_type, + ) + plan_remaining = max(0, plan_limit - usage) # if negative, they have 0 remaining + total_remaining = addon_remaining + plan_remaining + + return total_remaining + + +def handle_usage_deduction( + organization: Organization, usage_type: UsageType, amount: int +): + """ + Deducts the specified usage type for this organization by the given amount + """ + PlanAddOn = apps.get_model('stripe', 'PlanAddOn') + + plan_limit = get_organization_plan_limit(organization, usage_type) + current_usage = get_organization_usage(organization, usage_type) + if current_usage is None: + current_usage = 0 + new_total_usage = current_usage + amount + if new_total_usage > plan_limit: + deduction = ( + amount if current_usage >= plan_limit else new_total_usage - plan_limit + ) + PlanAddOn.deduct_add_ons_for_organization(organization, usage_type, deduction) diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 648c6e1b09..9dc4aa7a57 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -17,8 +17,9 @@ from pymongo import MongoClient from kobo.apps.stripe.constants import FREE_TIER_EMPTY_DISPLAY, FREE_TIER_NO_THRESHOLDS -from kpi.utils.json import LazyJSONSerializable from kpi.constants import PERM_DELETE_ASSET, PERM_MANAGE_ASSET +from kpi.utils.json import LazyJSONSerializable + from ..static_lists import EXTRA_LANG_INFO, SECTOR_CHOICE_DEFAULTS env = environ.Env() @@ -588,15 +589,20 @@ ), 'Email message to sent to admins on failure.', ), - 'USE_TEAM_LABEL': ( - True, - 'Use the term "Team" instead of "Organization" when Stripe is not enabled', + 'PROJECT_HISTORY_LOG_LIFESPAN': ( + 60, + 'Length of time days to keep project history logs.', + 'positive_int', ), 'ACCESS_LOG_LIFESPAN': ( 60, 'Length of time in days to keep access logs.', - 'positive_int' - ) + 'positive_int', + ), + 'USE_TEAM_LABEL': ( + True, + 'Use the term "Team" instead of "Organization" when Stripe is not enabled', + ), } CONSTANCE_ADDITIONAL_FIELDS = { @@ -662,6 +668,7 @@ 'FRONTEND_MAX_RETRY_TIME', 'USE_TEAM_LABEL', 'ACCESS_LOG_LIFESPAN', + 'PROJECT_HISTORY_LOG_LIFESPAN' ), 'Rest Services': ( 'ALLOW_UNSECURED_HOOK_ENDPOINTS', @@ -907,8 +914,6 @@ def __init__(self, *args, **kwargs): STATICFILES_DIRS = ( os.path.join(BASE_DIR, 'jsapp'), os.path.join(BASE_DIR, 'static'), - ('mocha', os.path.join(BASE_DIR, 'node_modules', 'mocha'),), - ('chai', os.path.join(BASE_DIR, 'node_modules', 'chai'),), ) if os.path.exists(os.path.join(BASE_DIR, 'dkobo', 'jsapp')): @@ -1232,11 +1237,11 @@ def dj_stripe_request_callback_method(): 'schedule': crontab(minute=0, hour=0), 'options': {'queue': 'kpi_low_priority_queue'} }, - 'delete-expired-access-logs': { - 'task': 'kobo.apps.audit_log.tasks.spawn_access_log_cleaning_tasks', + 'delete-expired-logs': { + 'task': 'kobo.apps.audit_log.tasks.spawn_logs_cleaning_tasks', 'schedule': crontab(minute=0, hour=0), 'options': {'queue': 'kpi_low_priority_queue'} - } + }, } @@ -1788,7 +1793,7 @@ def dj_stripe_request_callback_method(): 'application/x-zip-compressed' ] -ACCESS_LOG_DELETION_BATCH_SIZE = 1000 +LOG_DELETION_BATCH_SIZE = 1000 # Silence Django Guardian warning. Authentication backend is hooked, but # Django Guardian does not recognize it because it is extended diff --git a/kpi/backends.py b/kpi/backends.py index 8fe4f65d3c..f1fb74429d 100644 --- a/kpi/backends.py +++ b/kpi/backends.py @@ -1,7 +1,7 @@ # coding: utf-8 +from django.conf import settings from django.contrib.auth.backends import ModelBackend as DjangoModelBackend from django.contrib.auth.management import DEFAULT_DB_ALIAS -from django.conf import settings from .utils.database import get_thread_local from .utils.object_permission import get_database_user diff --git a/kpi/db_routers.py b/kpi/db_routers.py index 43cb1cf83f..cc5ff1a709 100644 --- a/kpi/db_routers.py +++ b/kpi/db_routers.py @@ -1,10 +1,9 @@ from django.conf import settings from django.contrib.auth.management import DEFAULT_DB_ALIAS -from kobo.apps.openrosa.libs.constants import ( - OPENROSA_APP_LABELS, -) +from kobo.apps.openrosa.libs.constants import OPENROSA_APP_LABELS from kpi.utils.database import get_thread_local + from .constants import SHADOW_MODEL_APP_LABELS, SHARED_APP_LABELS from .exceptions import ReadOnlyModelError diff --git a/kpi/deployment_backends/openrosa_backend.py b/kpi/deployment_backends/openrosa_backend.py index 40f953e4dd..78fe35d2b7 100644 --- a/kpi/deployment_backends/openrosa_backend.py +++ b/kpi/deployment_backends/openrosa_backend.py @@ -15,6 +15,7 @@ import requests from defusedxml import ElementTree as DET from django.conf import settings +from django.core.cache.backends.base import InvalidCacheBackendError from django.core.files import File from django.core.files.base import ContentFile from django.db.models import F, Sum @@ -66,12 +67,12 @@ from kpi.models.asset_file import AssetFile from kpi.models.object_permission import ObjectPermission from kpi.models.paired_data import PairedData -from kpi.utils.django_orm_helper import UpdateJSONFieldAttributes from kpi.utils.files import ExtendedContentFile from kpi.utils.log import logging from kpi.utils.mongo_helper import MongoHelper from kpi.utils.object_permission import get_database_user from kpi.utils.xml import fromstring_preserve_root_xmlns, xml_tostring + from ..exceptions import BadFormatException from .base_backend import BaseDeploymentBackend from .kc_access.utils import assign_applicable_kc_permissions, kc_transaction_atomic @@ -850,13 +851,16 @@ def rename_enketo_id_key(self, previous_owner_username: str): parsed_url = urlparse(settings.KOBOCAT_URL) domain_name = parsed_url.netloc asset_uid = self.asset.uid - enketo_redis_client = get_redis_connection('enketo_redis_main') - try: + enketo_redis_client = get_redis_connection('enketo_redis_main') enketo_redis_client.rename( src=f'or:{domain_name}/{previous_owner_username},{asset_uid}', dst=f'or:{domain_name}/{self.asset.owner.username},{asset_uid}', ) + except InvalidCacheBackendError: + # TODO: This handles the case when the cache is disabled and + # get_redis_connection fails, though we may need better error handling here + pass except redis.exceptions.ResponseError: # original does not exist, weird but don't raise a 500 for that pass @@ -1151,7 +1155,6 @@ def sync_media_files(self, file_type: str = AssetFile.FORM_MEDIA): 'md5': metadata['file_hash'], 'from_kpi': metadata['from_kpi'], } - metadata_filenames = metadata_files.keys() queryset = self._get_metadata_queryset(file_type=file_type) @@ -1259,19 +1262,13 @@ def xform_id_string(self): @contextmanager def suspend_submissions(user_ids: list[int]): UserProfile.objects.filter(user_id__in=user_ids).update( - metadata=UpdateJSONFieldAttributes( - 'metadata', - updates={'submissions_suspended': True}, - ), + submissions_suspended=True ) try: yield finally: UserProfile.objects.filter(user_id__in=user_ids).update( - metadata=UpdateJSONFieldAttributes( - 'metadata', - updates={'submissions_suspended': False}, - ), + submissions_suspended=False ) def transfer_submissions_ownership(self, previous_owner_username: str) -> bool: @@ -1352,7 +1349,11 @@ def _save_openrosa_metadata(self, file_: SyncBackendMediaInterface): } if not file_.is_remote_url: - metadata['data_file'] = file_.content + # Ensure file has not been read before + file_.content.seek(0) + file_content = file_.content.read() + file_.content.seek(0) + metadata['data_file'] = ContentFile(file_content, file_.filename) MetaData.objects.create(**metadata) diff --git a/kpi/filters.py b/kpi/filters.py index 7b3eac83a9..81bcd96ca1 100644 --- a/kpi/filters.py +++ b/kpi/filters.py @@ -1,15 +1,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.core.exceptions import FieldError -from django.db.models import ( - Case, - Count, - F, - IntegerField, - Q, - Value, - When, -) +from django.db.models import Case, Count, F, IntegerField, Q, Value, When from django.db.models.query import QuerySet from django.utils.datastructures import MultiValueDictKeyError from rest_framework import filters @@ -17,10 +9,10 @@ from kpi.constants import ( ASSET_SEARCH_DEFAULT_FIELD_LOOKUPS, - ASSET_STATUS_SHARED, ASSET_STATUS_DISCOVERABLE, ASSET_STATUS_PRIVATE, ASSET_STATUS_PUBLIC, + ASSET_STATUS_SHARED, PERM_DISCOVER_ASSET, PERM_PARTIAL_SUBMISSIONS, PERM_VIEW_ASSET, @@ -33,7 +25,6 @@ ) from kpi.models.asset import AssetDeploymentStatus, UserAssetSubscription from kpi.utils.django_orm_helper import OrderCustomCharField -from kpi.utils.query_parser import get_parsed_parameters, parse, ParseError from kpi.utils.object_permission import ( get_anonymous_user, get_database_user, @@ -41,6 +32,8 @@ get_perm_ids_from_code_names, ) from kpi.utils.permissions import is_user_anonymous +from kpi.utils.query_parser import ParseError, get_parsed_parameters, parse + from .models import Asset, ObjectPermission @@ -417,9 +410,7 @@ def filter_queryset(self, request, queryset, view): if organization.is_admin_only(user): # Admins do not receive explicit permission assignments, # but they have the same access to assets as the organization owner. - org_assets = Asset.objects.filter( - owner=organization.owner_user_object - ) + org_assets = Asset.objects.filter(owner=organization.owner_user_object) else: org_assets = Asset.objects.none() diff --git a/kpi/mixins/object_permission.py b/kpi/mixins/object_permission.py index a5f33b5909..81bc8e6b6c 100644 --- a/kpi/mixins/object_permission.py +++ b/kpi/mixins/object_permission.py @@ -16,15 +16,15 @@ from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.project_views.models.project_view import ProjectView from kpi.constants import ( - ASSET_TYPES_WITH_CHILDREN, ASSET_TYPE_SURVEY, + ASSET_TYPES_WITH_CHILDREN, PERM_FROM_KC_ONLY, PREFIX_PARTIAL_PERMS, ) from kpi.deployment_backends.kc_access.utils import ( - remove_applicable_kc_permissions, assign_applicable_kc_permissions, kc_transaction_atomic, + remove_applicable_kc_permissions, ) from kpi.models.object_permission import ObjectPermission from kpi.utils.object_permission import ( @@ -581,9 +581,7 @@ def get_perms(self, user_obj: settings.AUTH_USER_MODEL) -> list[str]: 'codename', flat=True ) ) - project_views_perms = get_project_view_user_permissions_for_asset( - self, user - ) + project_views_perms = get_project_view_user_permissions_for_asset(self, user) other_perms = [] if self.owner and self.owner.organization.is_admin_only(user): @@ -968,9 +966,7 @@ def __get_permissions_for_content_type( if codename__startswith is not None: filters['codename__startswith'] = codename__startswith - permissions = Permission.objects.filter(**filters).values_list( - 'pk', 'codename' - ) + permissions = Permission.objects.filter(**filters).values_list('pk', 'codename') return permissions diff --git a/kpi/models/import_export_task.py b/kpi/models/import_export_task.py index 532586b5dd..6c901fa1b4 100644 --- a/kpi/models/import_export_task.py +++ b/kpi/models/import_export_task.py @@ -816,6 +816,16 @@ def _run_task(self, messages): else: self.save(update_fields=['result', 'last_submission_time']) + @property + def asset(self): + source_url = self.data.get('source', False) + if not source_url: + raise Exception('no source specified for the export') + try: + return resolve_url_to_asset(source_url) + except Asset.DoesNotExist: + raise self.InaccessibleData + def delete(self, *args, **kwargs): # removing exported file from storage self.result.delete(save=False) @@ -833,13 +843,7 @@ def get_export_object( submission_ids = self.data.get('submission_ids', []) if source is None: - source_url = self.data.get('source', False) - if not source_url: - raise Exception('no source specified for the export') - try: - source = resolve_url_to_asset(source_url) - except Asset.DoesNotExist: - raise self.InaccessibleData + source = self.asset source_perms = source.get_perms(self.user) if ( diff --git a/kpi/permissions.py b/kpi/permissions.py index 18a70c8a2f..9ca6ed2fa1 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -284,12 +284,8 @@ def __init__(self, *args, **kwargs): } def has_object_permission(self, request, view, obj): - if ( - view.action == 'submission' - or ( - view.action == 'retrieve' - and request.accepted_renderer.format == 'xml' - ) + if view.action == 'submission' or ( + view.action == 'retrieve' and request.accepted_renderer.format == 'xml' ): return True diff --git a/kpi/serializers/v2/asset.py b/kpi/serializers/v2/asset.py index 1ac3840738..6fb44a56f4 100644 --- a/kpi/serializers/v2/asset.py +++ b/kpi/serializers/v2/asset.py @@ -64,6 +64,7 @@ user_has_project_view_asset_perm, view_has_perm, ) + from .asset_export_settings import AssetExportSettingsSerializer from .asset_file import AssetFileSerializer from .asset_permission_assignment import AssetPermissionAssignmentSerializer @@ -724,7 +725,7 @@ def get_project_ownership(self, asset) -> Optional[dict]: ), 'sender': transfer.invite.sender.username, 'recipient': transfer.invite.recipient.username, - 'status': transfer.status + 'status': transfer.invite.status } def get_exports(self, obj: Asset) -> str: diff --git a/kpi/serializers/v2/service_usage.py b/kpi/serializers/v2/service_usage.py index e82b160911..36adab02ff 100644 --- a/kpi/serializers/v2/service_usage.py +++ b/kpi/serializers/v2/service_usage.py @@ -107,6 +107,7 @@ class ServiceUsageSerializer(serializers.Serializer): current_month_end = serializers.SerializerMethodField() current_year_start = serializers.SerializerMethodField() current_year_end = serializers.SerializerMethodField() + last_updated = serializers.SerializerMethodField() def __init__(self, instance=None, data=empty, **kwargs): super().__init__(instance=instance, data=data, **kwargs) @@ -131,6 +132,9 @@ def get_current_year_end(self, user): def get_current_year_start(self, user): return self.calculator.current_year_start.isoformat() + def get_last_updated(self, user): + return self.calculator.get_last_updated().isoformat() + def get_total_nlp_usage(self, user): return self.calculator.get_nlp_usage_counters() diff --git a/kpi/tasks.py b/kpi/tasks.py index 4ba283d8c3..a2553b644a 100644 --- a/kpi/tasks.py +++ b/kpi/tasks.py @@ -1,7 +1,5 @@ -# coding: utf-8 import time -import constance import requests from django.apps import apps from django.conf import settings @@ -49,7 +47,7 @@ def export_task_in_background( mail.send_mail( subject='Project View Report Complete', message=msg, - from_email=constance.config.SUPPORT_EMAIL, + from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[user.email], fail_silently=False, ) diff --git a/kpi/templates/browser_tests.html b/kpi/templates/browser_tests.html deleted file mode 100644 index 4ab23f1588..0000000000 --- a/kpi/templates/browser_tests.html +++ /dev/null @@ -1,23 +0,0 @@ - - -{% load render_bundle from webpack_loader %} -{% load static %} - - - KPI tests - - - - - - - -
    - {% render_bundle 'browsertests' 'js' %} - - - diff --git a/kpi/tests/api/test_api_environment.py b/kpi/tests/api/test_api_environment.py index e1d2dfaf6a..e37ac9d291 100644 --- a/kpi/tests/api/test_api_environment.py +++ b/kpi/tests/api/test_api_environment.py @@ -126,6 +126,9 @@ def setUp(self): ), 'open_rosa_server': settings.KOBOCAT_URL, 'terms_of_service__sitewidemessage__exists': False, + 'project_history_log_lifespan': ( + constance.config.PROJECT_HISTORY_LOG_LIFESPAN + ), 'use_team_label': constance.config.USE_TEAM_LABEL, } @@ -313,7 +316,7 @@ def test_free_tier_override_uses_organization_owner_join_date( def test_social_apps(self): # GET mutates state, call it first to test num queries later self.client.get(self.url, format='json') - queries = FuzzyInt(18, 26) + queries = FuzzyInt(18, 27) with self.assertNumQueries(queries): response = self.client.get(self.url, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -332,7 +335,7 @@ def test_social_apps(self): def test_social_apps_no_custom_data(self): SocialAppCustomData.objects.all().delete() self.client.get(self.url, format='json') - queries = FuzzyInt(18, 26) + queries = FuzzyInt(18, 27) with self.assertNumQueries(queries): response = self.client.get(self.url, format='json') diff --git a/kpi/tests/api/v1/test_api_assets.py b/kpi/tests/api/v1/test_api_assets.py index 2a788c087e..6bf9255f7e 100644 --- a/kpi/tests/api/v1/test_api_assets.py +++ b/kpi/tests/api/v1/test_api_assets.py @@ -56,6 +56,12 @@ class AssetDetailApiTests(test_api_assets.AssetDetailApiTests): def test_assignable_permissions(self): pass + @unittest.skip( + reason='`project_ownership` property only exists in v2 endpoint' + ) + def test_ownership_transfer_status(self): + pass + class AssetsXmlExportApiTests(KpiTestCase): fixtures = ['test_data'] diff --git a/kpi/tests/api/v2/test_api_asset_permission_assignment.py b/kpi/tests/api/v2/test_api_asset_permission_assignment.py index 122dab9716..9162bce1e4 100644 --- a/kpi/tests/api/v2/test_api_asset_permission_assignment.py +++ b/kpi/tests/api/v2/test_api_asset_permission_assignment.py @@ -24,9 +24,7 @@ from kpi.utils.object_permission import get_anonymous_user -class BaseApiAssetPermissionTestCase( - PermissionAssignmentTestCaseMixin, KpiTestCase -): +class BaseApiAssetPermissionTestCase(PermissionAssignmentTestCaseMixin, KpiTestCase): fixtures = ['test_data'] URL_NAMESPACE = ROUTER_URL_NAMESPACE diff --git a/kpi/tests/api/v2/test_api_assets.py b/kpi/tests/api/v2/test_api_assets.py index 2680653313..f5fc584f6e 100644 --- a/kpi/tests/api/v2/test_api_assets.py +++ b/kpi/tests/api/v2/test_api_assets.py @@ -8,6 +8,7 @@ from rest_framework import status from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.project_ownership.models import Invite, InviteStatusChoices, Transfer from kobo.apps.project_views.models.project_view import ProjectView from kpi.constants import ( PERM_CHANGE_ASSET, @@ -26,11 +27,11 @@ BaseTestCase, ) from kpi.tests.kpi_test_case import KpiTestCase +from kpi.tests.utils.mixins import AssetFileTestCaseMixin from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE from kpi.utils.hash import calculate_hash from kpi.utils.object_permission import get_anonymous_user from kpi.utils.project_views import get_region_for_view -from kpi.tests.utils.mixins import AssetFileTestCaseMixin class AssetListApiTests(BaseAssetTestCase): @@ -1262,6 +1263,31 @@ def test_can_update_data_sharing(self): # exist after `PATCH` self.assertTrue('fields' in data_sharing) + def test_ownership_transfer_status(self): + # No transfer yet, no status + response = self.client.get(self.asset_url) + assert response.data['project_ownership'] is None + + anotheruser = User.objects.get(username='anotheruser') + invite = Invite.objects.create(sender=self.asset.owner, recipient=anotheruser) + Transfer.objects.create(invite=invite, asset=self.asset) + + # Invite has been created, but not accepted/declined yet + response = self.client.get(self.asset_url) + assert ( + response.data['project_ownership']['status'] + == InviteStatusChoices.PENDING + ) + + # Simulate expiration + invite.status = InviteStatusChoices.EXPIRED + invite.save() + response = self.client.get(self.asset_url) + assert ( + response.data['project_ownership']['status'] + == InviteStatusChoices.EXPIRED + ) + class AssetsXmlExportApiTests(KpiTestCase): diff --git a/kpi/tests/api/v2/test_api_collections.py b/kpi/tests/api/v2/test_api_collections.py index d435ddc7f0..35e80cbc55 100644 --- a/kpi/tests/api/v2/test_api_collections.py +++ b/kpi/tests/api/v2/test_api_collections.py @@ -50,16 +50,12 @@ def test_create_collection(self): self.assertEqual(response.data['name'], 'my collection') def test_collection_detail(self): - url = reverse( - self._get_endpoint("asset-detail"), kwargs={"uid": self.coll.uid} - ) - response = self.client.get(url, format="json") - self.assertEqual(response.data["name"], "test collection") + url = reverse(self._get_endpoint('asset-detail'), kwargs={'uid': self.coll.uid}) + response = self.client.get(url, format='json') + self.assertEqual(response.data['name'], 'test collection') def test_collection_delete(self): - url = reverse( - self._get_endpoint("asset-detail"), kwargs={"uid": self.coll.uid} - ) + url = reverse(self._get_endpoint('asset-detail'), kwargs={'uid': self.coll.uid}) # DRF will return 200 if JSON format is not specified # FIXME: why is `format='json'` as a keyword argument not working?! # https://www.django-rest-framework.org/api-guide/testing/#using-the-format-argument @@ -69,9 +65,7 @@ def test_collection_delete(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_collection_rename(self): - url = reverse( - self._get_endpoint("asset-detail"), kwargs={"uid": self.coll.uid} - ) + url = reverse(self._get_endpoint('asset-detail'), kwargs={'uid': self.coll.uid}) response = self.client.get(url, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['name'], 'test collection') @@ -96,7 +90,7 @@ def test_collection_list(self): def test_collection_filtered_list(self): - another_user = User.objects.get(username="anotheruser") + another_user = User.objects.get(username='anotheruser') list_url = reverse(self._get_endpoint('asset-list')) block = Asset.objects.create( asset_type=ASSET_TYPE_BLOCK, @@ -187,7 +181,7 @@ def test_collection_filtered_list(self): # Logged in as another user, retrieve public and discoverable collections. # Should have 1 because it returns all public collections no matter # if user has subscribed to it or not. - self.login_as_other_user(username="anotheruser", password="anotheruser") + self.login_as_other_user(username='anotheruser', password='anotheruser') query_string = 'status=public-discoverable&q=asset_type:collection' url = f'{list_url}?{query_string}' response = self.client.get(url) @@ -220,7 +214,7 @@ def test_collection_filtered_list(self): def test_collection_statuses_and_access_types(self): - another_user = User.objects.get(username="anotheruser") + another_user = User.objects.get(username='anotheruser') public_collection = Asset.objects.create( asset_type=ASSET_TYPE_COLLECTION, @@ -247,25 +241,19 @@ def test_collection_statuses_and_access_types(self): # Make `public_collection` and `subscribed_collection` public-discoverable public_collection.assign_perm(AnonymousUser(), PERM_DISCOVER_ASSET) subscribed_collection.assign_perm(AnonymousUser(), PERM_DISCOVER_ASSET) - shared_subscribed_collection.assign_perm( - AnonymousUser(), PERM_DISCOVER_ASSET - ) + shared_subscribed_collection.assign_perm(AnonymousUser(), PERM_DISCOVER_ASSET) # Make `shared_collection` and `shared_subscribed_collection` shared shared_collection.assign_perm(self.someuser, PERM_VIEW_ASSET) shared_subscribed_collection.assign_perm(self.someuser, PERM_VIEW_ASSET) # Subscribe `someuser` to `subscribed_collection`. - subscription_url = reverse( - self._get_endpoint('userassetsubscription-list') - ) + subscription_url = reverse(self._get_endpoint('userassetsubscription-list')) asset_detail_url = self.absolute_reverse( self._get_endpoint('asset-detail'), kwargs={'uid': subscribed_collection.uid}, ) - response = self.client.post( - subscription_url, data={'asset': asset_detail_url} - ) + response = self.client.post(subscription_url, data={'asset': asset_detail_url}) assert response.status_code == status.HTTP_201_CREATED # Subscribe `someuser` to `shared_subscribed_collection`. @@ -273,9 +261,7 @@ def test_collection_statuses_and_access_types(self): self._get_endpoint('asset-detail'), kwargs={'uid': shared_subscribed_collection.uid}, ) - response = self.client.post( - subscription_url, data={'asset': asset_detail_url} - ) + response = self.client.post(subscription_url, data={'asset': asset_detail_url}) assert response.status_code == status.HTTP_201_CREATED list_url = reverse(self._get_endpoint('asset-list')) @@ -307,10 +293,7 @@ def test_collection_statuses_and_access_types(self): for collection in response.data['results']: expected_collection = expected[collection.get('name')] assert expected_collection['status'] == collection['status'] - assert ( - expected_collection['access_types'] - == collection['access_types'] - ) + assert expected_collection['access_types'] == collection['access_types'] def test_collection_subscribe(self): public_collection = Asset.objects.create( @@ -320,7 +303,7 @@ def test_collection_subscribe(self): ) public_collection.assign_perm(AnonymousUser(), PERM_DISCOVER_ASSET) - self.login_as_other_user(username="anotheruser", password="anotheruser") + self.login_as_other_user(username='anotheruser', password='anotheruser') asset_list_url = reverse(self._get_endpoint('asset-list')) coll_list_url = f'{asset_list_url}?q=asset_type:collection' @@ -363,13 +346,12 @@ def test_get_subscribed_collection(self): ) public_collection.assign_perm(AnonymousUser(), PERM_DISCOVER_ASSET) - self.login_as_other_user(username="anotheruser", password="anotheruser") + self.login_as_other_user(username='anotheruser', password='anotheruser') asset_list_url = reverse(self._get_endpoint('asset-list')) coll_list_url = f'{asset_list_url}?q=asset_type:collection' sub_list_url = reverse(self._get_endpoint('userassetsubscription-list')) - subscrbd_coll_url = \ - f"{asset_list_url}?q=parent__uid:{public_collection.uid}" + subscrbd_coll_url = f'{asset_list_url}?q=parent__uid:{public_collection.uid}' pub_coll_url = BaseTestCase.absolute_reverse( self._get_endpoint('asset-detail'), kwargs={'uid': public_collection.uid}, @@ -384,11 +366,11 @@ def test_get_subscribed_collection(self): data = {'asset': pub_coll_url} response = self.client.post(sub_list_url, data, format='json') self.assertEqual(response.status_code, status.HTTP_201_CREATED) - assert response.data["asset"] == pub_coll_url + assert response.data['asset'] == pub_coll_url # we should be able to get its children with its uid expected_child_uid = [ - c for c in public_collection.children.values_list("uid", flat=True) + c for c in public_collection.children.values_list('uid', flat=True) ] response = self.client.get(subscrbd_coll_url) response_child_uid = [c['uid'] for c in response.data['results']] @@ -403,14 +385,14 @@ def test_collection_unsubscribe(self): public_collection.assign_perm(AnonymousUser(), PERM_DISCOVER_ASSET) # subscribe with the ORM - another_user = User.objects.get(username="anotheruser") + another_user = User.objects.get(username='anotheruser') subscription = UserAssetSubscription.objects.create( user=another_user, asset=public_collection ) asset_list_url = reverse(self._get_endpoint('asset-list')) coll_list_url = f'{asset_list_url}?q=asset_type:collection' - self.login_as_other_user(username="anotheruser", password="anotheruser") + self.login_as_other_user(username='anotheruser', password='anotheruser') # we should see the collection in our asset list response = self.client.get(coll_list_url) @@ -434,7 +416,7 @@ def test_collection_unsubscribe(self): self.assertEqual(response.data['count'], 0) def test_collection_cannot_subscribe_if_not_public(self): - self.login_as_other_user(username="anotheruser", password="anotheruser") + self.login_as_other_user(username='anotheruser', password='anotheruser') asset_list_url = reverse(self._get_endpoint('asset-list')) coll_list_url = f'{asset_list_url}?q=asset_type:collection' sub_list_url = reverse(self._get_endpoint('userassetsubscription-list')) diff --git a/kpi/tests/api/v2/test_api_permissions.py b/kpi/tests/api/v2/test_api_permissions.py index b71f979b61..ff63d5b49b 100644 --- a/kpi/tests/api/v2/test_api_permissions.py +++ b/kpi/tests/api/v2/test_api_permissions.py @@ -11,8 +11,8 @@ ) from kpi.models import Asset, ObjectPermission from kpi.tests.kpi_test_case import KpiTestCase -from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE from kpi.tests.utils.mixins import PermissionAssignmentTestCaseMixin +from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE from kpi.utils.object_permission import get_anonymous_user @@ -50,8 +50,11 @@ def test_cannot_create_asset(self): url = reverse(self._get_endpoint('asset-list')) data = {'name': 'my asset', 'content': ''} response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED, - msg="anonymous user cannot create a asset") + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED, + msg='anonymous user cannot create a asset', + ) class ApiPermissionsPublicAssetTestCase(KpiTestCase): @@ -658,9 +661,7 @@ def test_inherited_viewable_collection_not_deletable(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) -class ApiAssignedPermissionsTestCase( - PermissionAssignmentTestCaseMixin, KpiTestCase -): +class ApiAssignedPermissionsTestCase(PermissionAssignmentTestCaseMixin, KpiTestCase): """ An obnoxiously large amount of code to test that the endpoint for listing assigned permissions complies with the following rules: @@ -711,9 +712,7 @@ def test_anon_only_sees_owner_and_anon_permissions(self): kwargs={'username': username}, ) ) - self.assertSetEqual( - set((a['user'] for a in response.data)), set(user_urls) - ) + self.assertSetEqual(set(a['user'] for a in response.data), set(user_urls)) def test_user_sees_relevant_permissions_on_assigned_objects(self): # A user with explicitly-assigned permissions should see their diff --git a/kpi/tests/api/v2/test_api_service_usage.py b/kpi/tests/api/v2/test_api_service_usage.py index 68af6b2bbb..60a159cc29 100644 --- a/kpi/tests/api/v2/test_api_service_usage.py +++ b/kpi/tests/api/v2/test_api_service_usage.py @@ -1,3 +1,4 @@ +from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -42,9 +43,7 @@ def test_check_api_response(self): assert ( response.data['total_nlp_usage']['mt_characters_all_time'] == 6726 ) - assert ( - response.data['total_storage_bytes'] == self.expected_file_size() - ) + assert response.data['total_storage_bytes'] == self.expected_file_size() def test_multiple_forms(self): """ @@ -65,6 +64,9 @@ def test_multiple_forms(self): self.expected_file_size() * 3 ) + @override_settings( + CACHES={'default': {'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}} + ) def test_service_usages_with_projects_in_trash_bin(self): self.test_multiple_forms() # Simulate trash bin diff --git a/kpi/tests/api/v2/test_api_submissions.py b/kpi/tests/api/v2/test_api_submissions.py index 890f881a27..182cce899a 100644 --- a/kpi/tests/api/v2/test_api_submissions.py +++ b/kpi/tests/api/v2/test_api_submissions.py @@ -5,9 +5,8 @@ import string import uuid from datetime import datetime -from zoneinfo import ZoneInfo - from unittest import mock +from zoneinfo import ZoneInfo import lxml import pytest diff --git a/kpi/tests/base_test_case.py b/kpi/tests/base_test_case.py index 9955115b0f..7b066bc494 100644 --- a/kpi/tests/base_test_case.py +++ b/kpi/tests/base_test_case.py @@ -10,6 +10,7 @@ from kobo.apps.kobo_auth.shortcuts import User from kpi.models.asset import Asset from kpi.models.object_permission import ObjectPermission + # `baker_generators` needs to be imported to give baker extra support from kpi.tests.utils import baker_generators # noqa from kpi.urls.router_api_v2 import URL_NAMESPACE as ROUTER_URL_NAMESPACE @@ -75,9 +76,7 @@ class BaseAssetTestCase(BaseTestCase): EMPTY_SURVEY = {'survey': [], 'schema': SCHEMA_VERSION, 'settings': {}} - def create_asset( - self, asset_type='survey', content: dict = None, name: str = None - ): + def create_asset(self, asset_type='survey', content: dict = None, name: str = None): """ Create a new, empty asset as the currently logged-in user """ diff --git a/kpi/tests/kpi_test_case.py b/kpi/tests/kpi_test_case.py index 0bd4dcdce5..659311fd1e 100644 --- a/kpi/tests/kpi_test_case.py +++ b/kpi/tests/kpi_test_case.py @@ -7,10 +7,12 @@ from rest_framework import status from kpi.constants import ASSET_TYPE_COLLECTION + +from ..models.asset import Asset + # FIXME: Remove the following line when the permissions API is in place. from .base_test_case import BaseTestCase from .test_permissions import BasePermissionsTestCase -from ..models.asset import Asset class KpiTestCase(BaseTestCase, BasePermissionsTestCase): diff --git a/kpi/tests/test_cache_utils.py b/kpi/tests/test_cache_utils.py new file mode 100644 index 0000000000..c2e74fdbb0 --- /dev/null +++ b/kpi/tests/test_cache_utils.py @@ -0,0 +1,55 @@ +from json import dumps, loads +from unittest.mock import patch + +from kpi.utils.cache import CachedClass, cached_class_property + + +class MockCachedClass(CachedClass): + CACHE_TTL = 10 + + def __init__(self): + self._setup_cache() + self.counter = 0 + self.dict_value = {'value': 0, 'other_value': 'test'} + + def _get_cache_hash(self): + return 'test' + + @cached_class_property(key='dict_value', serializer=dumps, deserializer=loads) + def get_dict(self): + self.dict_value['value'] += 1 + return self.dict_value + + @cached_class_property(key='int_value', serializer=str, deserializer=int) + def get_number(self): + self.counter += 1 + return self.counter + + +def test_cached_class_int_property(): + instance = MockCachedClass() + instance._clear_cache() + assert instance.get_number() == 1 + assert instance.get_number() == 1 + instance._clear_cache() + assert instance.get_number() == 2 + + +def test_cached_class_dict_property(): + instance = MockCachedClass() + instance._clear_cache() + assert instance.get_dict() == {'value': 1, 'other_value': 'test'} + assert instance.get_dict()['value'] == 1 + instance._clear_cache() + assert instance.get_dict()['value'] == 2 + + +def clear_mock_cache(self): + self._clear_cache() + + +@patch('kpi.utils.cache.CachedClass._handle_cache_expiration', clear_mock_cache) +def test_override_cache(): + instance = MockCachedClass() + assert instance.get_dict()['value'] == 1 + assert instance.get_dict()['value'] == 2 diff --git a/kpi/tests/test_deployment_backends.py b/kpi/tests/test_deployment_backends.py index 2a8f63126d..a3064d57c9 100644 --- a/kpi/tests/test_deployment_backends.py +++ b/kpi/tests/test_deployment_backends.py @@ -1,10 +1,15 @@ -# coding: utf-8 import pytest +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.test import TestCase from kobo.apps.kobo_auth.shortcuts import User +from kobo.apps.openrosa.apps.main.models import MetaData +from kpi.deployment_backends.kc_access.storage import ( + default_kobocat_storage, +) from kpi.exceptions import DeploymentDataException -from kpi.models.asset import Asset +from kpi.models.asset import Asset, AssetFile from kpi.models.asset_version import AssetVersion @@ -151,3 +156,44 @@ def test_save_data_with_deferred_fields(self): # altered directly with self.assertRaises(DeploymentDataException) as e: asset.save() + + def test_sync_media_files(self): + + asset_file = AssetFile( + asset=self.asset, + user=self.asset.owner, + file_type=AssetFile.FORM_MEDIA, + ) + asset_file.content = ContentFile(b'foo', name='foo.txt') + asset_file.save() + assert ( + MetaData.objects.filter(xform=self.asset.deployment.xform).count() + == 0 + ) + meta_data = None + try: + self.asset.deployment.sync_media_files() + assert ( + MetaData.objects.filter( + xform=self.asset.deployment.xform + ).count() + == 1 + ) + meta_data = MetaData.objects.filter( + xform=self.asset.deployment.xform + ).first() + + assert default_kobocat_storage.exists(str(meta_data.data_file)) + assert not default_storage.exists(str(meta_data.data_file)) + + with default_kobocat_storage.open( + str(meta_data.data_file), 'r' + ) as f: + assert f.read() == 'foo' + finally: + # Clean-up + if meta_data: + data_file_path = str(meta_data.data_file) + meta_data.delete() + if default_kobocat_storage.exists(data_file_path): + default_kobocat_storage.delete(data_file_path) diff --git a/kpi/tests/test_export_tasks.py b/kpi/tests/test_export_tasks.py index ceb089f385..9072d65a28 100644 --- a/kpi/tests/test_export_tasks.py +++ b/kpi/tests/test_export_tasks.py @@ -51,7 +51,7 @@ def test_export_task_success(self, mock_get_project_view, mock_send_mail): mock_send_mail.assert_called_once_with( subject='Project View Report Complete', message=expected_message, - from_email='help@kobotoolbox.org', + from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=['test@example.com'], fail_silently=False, ) diff --git a/kpi/tests/test_extended_file_field.py b/kpi/tests/test_extended_file_field.py index 1ffa3982e2..6f561d5788 100644 --- a/kpi/tests/test_extended_file_field.py +++ b/kpi/tests/test_extended_file_field.py @@ -1,6 +1,6 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage -from django.test import override_settings, TestCase +from django.test import TestCase from kpi.models.asset import Asset from kpi.models.asset_file import AssetFile diff --git a/kpi/tests/test_permissions.py b/kpi/tests/test_permissions.py index 15b5fa3f2e..337d7833c5 100644 --- a/kpi/tests/test_permissions.py +++ b/kpi/tests/test_permissions.py @@ -1,5 +1,6 @@ # coding: utf-8 import unittest + from django.contrib.auth.models import AnonymousUser from django.test import TestCase @@ -21,6 +22,7 @@ ) from kpi.exceptions import BadPermissionsException from kpi.utils.object_permission import get_all_objects_for_user + from ..models import ObjectPermission from ..models.asset import Asset @@ -862,9 +864,7 @@ def test_user_without_perms_get_anonymous_perms(self): self.assertFalse(anonymous_user.has_perm(PERM_VIEW_SUBMISSIONS, asset)) asset.assign_perm(anonymous_user, PERM_VIEW_SUBMISSIONS) self.assertTrue(grantee.has_perm(PERM_VIEW_SUBMISSIONS, asset)) - self.assertTrue( - asset.get_perms(grantee), asset.get_perms(anonymous_user) - ) + self.assertTrue(asset.get_perms(grantee), asset.get_perms(anonymous_user)) def test_org_admin_inherited_and_implied_permissions(self): """ diff --git a/kpi/tests/test_usage_calculator.py b/kpi/tests/test_usage_calculator.py index 8c4b455082..e8a86fe7b8 100644 --- a/kpi/tests/test_usage_calculator.py +++ b/kpi/tests/test_usage_calculator.py @@ -3,6 +3,7 @@ from dateutil.relativedelta import relativedelta from django.conf import settings +from django.core.cache import cache from django.test import override_settings from django.urls import reverse from django.utils import timezone @@ -10,6 +11,7 @@ from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.organizations.models import Organization +from kobo.apps.stripe.constants import USAGE_LIMIT_MAP from kobo.apps.stripe.tests.utils import generate_enterprise_subscription from kobo.apps.trackers.models import NLPUsageCounter from kpi.models import Asset @@ -31,6 +33,7 @@ class BaseServiceUsageTestCase(BaseAssetTestCase): def setUp(self): super().setUp() self.client.login(username='anotheruser', password='anotheruser') + cache.clear() @classmethod def setUpTestData(cls): @@ -158,6 +161,20 @@ def setUp(self): self.add_nlp_trackers() self.add_submissions(count=5) + def test_disable_cache(self): + calculator = ServiceUsageCalculator(self.anotheruser, None, disable_cache=True) + nlp_usage_A = calculator.get_nlp_usage_counters() + self.add_nlp_trackers() + nlp_usage_B = calculator.get_nlp_usage_counters() + assert ( + 2 * nlp_usage_A['asr_seconds_current_month'] + == nlp_usage_B['asr_seconds_current_month'] + ) + assert ( + 2 * nlp_usage_A['mt_characters_current_month'] + == nlp_usage_B['mt_characters_current_month'] + ) + def test_nlp_usage_counters(self): calculator = ServiceUsageCalculator(self.anotheruser, None) nlp_usage = calculator.get_nlp_usage_counters() @@ -166,16 +183,6 @@ def test_nlp_usage_counters(self): assert nlp_usage['mt_characters_current_month'] == 5473 assert nlp_usage['mt_characters_all_time'] == 6726 - def test_storage_usage(self): - calculator = ServiceUsageCalculator(self.anotheruser, None) - assert calculator.get_storage_usage() == 5 * self.expected_file_size() - - def test_submission_counters(self): - calculator = ServiceUsageCalculator(self.anotheruser, None) - submission_counters = calculator.get_submission_counters() - assert submission_counters['current_month'] == 5 - assert submission_counters['all_time'] == 5 - def test_no_data(self): calculator = ServiceUsageCalculator(self.someuser, None) nlp_usage = calculator.get_nlp_usage_counters() @@ -208,3 +215,16 @@ def test_organization_setup(self): assert nlp_usage['mt_characters_all_time'] == 6726 assert calculator.get_storage_usage() == 5 * self.expected_file_size() + + assert calculator.get_nlp_usage_by_type(USAGE_LIMIT_MAP['characters']) == 5473 + assert calculator.get_nlp_usage_by_type(USAGE_LIMIT_MAP['seconds']) == 4586 + + def test_storage_usage(self): + calculator = ServiceUsageCalculator(self.anotheruser, None) + assert calculator.get_storage_usage() == 5 * self.expected_file_size() + + def test_submission_counters(self): + calculator = ServiceUsageCalculator(self.anotheruser, None) + submission_counters = calculator.get_submission_counters() + assert submission_counters['current_month'] == 5 + assert submission_counters['all_time'] == 5 diff --git a/kpi/tests/utils/mixins.py b/kpi/tests/utils/mixins.py index 96f4e3158a..14b3ec7f61 100644 --- a/kpi/tests/utils/mixins.py +++ b/kpi/tests/utils/mixins.py @@ -35,9 +35,7 @@ def asset_file_payload(self): 'metadata': json.dumps({'source': 'http://geojson.org/'}), } - def create_asset_file( - self, payload=None, status_code=status.HTTP_201_CREATED - ): + def create_asset_file(self, payload=None, status_code=status.HTTP_201_CREATED): payload = self.asset_file_payload if payload is None else payload response = self.client.get(self.list_url) @@ -93,9 +91,7 @@ def verify_asset_file(self, response, payload=None, form_media=False): response_metadata.pop('mimetype', None) response_metadata.pop('hash', None) - self.assertEqual( - json.dumps(response_metadata), posted_payload['metadata'] - ) + self.assertEqual(json.dumps(response_metadata), posted_payload['metadata']) for field in 'file_type', 'description': self.assertEqual(response_dict[field], posted_payload[field]) @@ -180,12 +176,11 @@ def _delete_submissions(self): class SubmissionEditTestCaseMixin: def _get_edit_link(self): - ee_url = ( - f'{settings.ENKETO_URL}/{settings.ENKETO_EDIT_INSTANCE_ENDPOINT}' - ) + ee_url = f'{settings.ENKETO_URL}/{settings.ENKETO_EDIT_INSTANCE_ENDPOINT}' # Mock Enketo response responses.add_callback( - responses.POST, ee_url, + responses.POST, + ee_url, callback=enketo_edit_instance_response, content_type='application/json', ) @@ -250,9 +245,7 @@ def _delete_statuses(self): def _update_status( self, username: str, status_uid: str = 'validation_status_not_approved' ): - data = { - 'validation_status.uid': status_uid - } + data = {'validation_status.uid': status_uid} response = self.client.patch(self.validation_status_url, data=data) assert response.status_code == status.HTTP_200_OK @@ -283,9 +276,7 @@ def _validate_statuses( assert response.status_code == status.HTTP_200_OK if empty: - statuses = [ - not s['_validation_status'] for s in response.data['results'] - ] + statuses = [not s['_validation_status'] for s in response.data['results']] else: statuses = [ s['_validation_status']['uid'] == uid @@ -299,13 +290,12 @@ def _validate_statuses( class SubmissionViewTestCaseMixin: def _get_view_link(self): - ee_url = ( - f'{settings.ENKETO_URL}/{settings.ENKETO_VIEW_INSTANCE_ENDPOINT}' - ) + ee_url = f'{settings.ENKETO_URL}/{settings.ENKETO_VIEW_INSTANCE_ENDPOINT}' # Mock Enketo response responses.add_callback( - responses.POST, ee_url, + responses.POST, + ee_url, callback=enketo_view_instance_response, content_type='application/json', ) @@ -333,7 +323,7 @@ class PermissionAssignmentTestCaseMixin: def get_asset_perm_assignment_list_url(self, asset): return reverse( self._get_endpoint('asset-permission-assignment-list'), - kwargs={'parent_lookup_asset': asset.uid} + kwargs={'parent_lookup_asset': asset.uid}, ) def get_urls_for_asset_perm_assignment_objs(self, perm_assignments, asset): diff --git a/kpi/urls/__init__.py b/kpi/urls/__init__.py index cb3cbd7579..6b805a5858 100644 --- a/kpi/urls/__init__.py +++ b/kpi/urls/__init__.py @@ -7,7 +7,6 @@ from hub.models import ConfigurationFile from kpi.views import ( authorized_application_authenticate_user, - browser_tests, home, modern_browsers, ) @@ -42,7 +41,6 @@ authorized_application_authenticate_user, name='authenticate_user', ), - path('browser_tests/', browser_tests), path('modern_browsers/', modern_browsers), re_path(r'^i18n/', include('django.conf.urls.i18n')), # Translation catalog for client code. diff --git a/kpi/urls/router_api_v2.py b/kpi/urls/router_api_v2.py index 7c7dbd2575..411d77de2b 100644 --- a/kpi/urls/router_api_v2.py +++ b/kpi/urls/router_api_v2.py @@ -6,7 +6,7 @@ from kobo.apps.hook.views.v2.hook import HookViewSet from kobo.apps.hook.views.v2.hook_log import HookLogViewSet from kobo.apps.languages.urls import router as language_router -from kobo.apps.organizations.views import OrganizationViewSet +from kobo.apps.organizations.views import OrganizationViewSet, OrganizationMemberViewSet from kobo.apps.project_ownership.urls import router as project_ownership_router from kobo.apps.project_views.views import ProjectViewViewSet from kpi.views.v2.asset import AssetViewSet @@ -140,6 +140,12 @@ def get_urls(self, *args, **kwargs): router_api_v2.register(r'imports', ImportTaskViewSet) router_api_v2.register(r'organizations', OrganizationViewSet, basename='organizations',) +router_api_v2.register( + r'organizations/(?P[^/.]+)/members', + OrganizationMemberViewSet, + basename='organization-members', +) + router_api_v2.register(r'permissions', PermissionViewSet) router_api_v2.register(r'project-views', ProjectViewViewSet) router_api_v2.register(r'service_usage', diff --git a/kpi/utils/cache.py b/kpi/utils/cache.py index 3fe3af1b6b..d2cb5f5189 100644 --- a/kpi/utils/cache.py +++ b/kpi/utils/cache.py @@ -1,7 +1,9 @@ -# -*- coding: utf-8 -*- +from datetime import timedelta from functools import wraps -from django_request_cache import get_request_cache +from django.utils import timezone +from django_redis import get_redis_connection +from django_request_cache import cache_for_request, get_request_cache def void_cache_for_request(keys): @@ -14,6 +16,7 @@ def void_cache_for_request(keys): keys (tuple): Substrings to be searched for """ + def _void_cache_for_request(func): @wraps(func) def wrapper(*args, **kwargs): @@ -27,5 +30,104 @@ def wrapper(*args, **kwargs): if key.startswith(prefixed_keys): delattr(cache, key) return func(*args, **kwargs) + return wrapper + return _void_cache_for_request + + +class CachedClass: + """ + Handles a mapping cache for a class. It supports only getter methods that + receive no parameters but the self. + + The inheriting class must define a function _get_cache_hash that creates a + unique name string for the underlying HSET. + + The inheriting class should call self._setup_cache() at __init__ + + The TTL is configured through the class property CACHE_TTL. + + This class must be used with the decorator defined below, + cached_class_property, which will be used to specify what class methods are + cached. + """ + + CACHE_TTL = None + + @cache_for_request + def _cache_last_updated(self): + if not self._cache_available: + return timezone.now() + + remaining_seconds = self._redis_client.ttl(self._cache_hash_str) + return timezone.now() - timedelta(seconds=self.CACHE_TTL - remaining_seconds) + + def _clear_cache(self): + if not self._cache_available: + return + + self._redis_client.delete(self._cache_hash_str) + self._cached_hset = {} + + def _handle_cache_expiration(self): + """ + Checks if the hset is initialized, and initializes if necessary + """ + if not self._cache_available: + return + + if not self._cached_hset.get(b'__initialized__'): + self._redis_client.hset(self._cache_hash_str, '__initialized__', 'True') + self._redis_client.expire(self._cache_hash_str, self.CACHE_TTL) + self._cached_hset[b'__initialized__'] = True + + def _setup_cache(self): + """ + Sets up the cache client and the cache hash name for the hset + """ + if getattr(self, '_cache_available', None) is False: + return + + self._redis_client = None + self._cache_available = True + self._cached_hset = {} + try: + self._redis_client = get_redis_connection('default') + except NotImplementedError: + self._cache_available = False + return + + self._cache_hash_str = self._get_cache_hash() + assert self.CACHE_TTL > 0, 'Set a valid value for CACHE_TTL' + self._cached_hset = self._redis_client.hgetall(self._cache_hash_str) + self._handle_cache_expiration() + + +def cached_class_property(key, serializer=str, deserializer=str): + """ + Function decorator that takes a key to store/retrieve a cached key value and + serializer and deserializer functions to convert the value for storage or + retrieval, respectively. + """ + + def cached_key_getter(func): + def wrapper(self): + if getattr(self, '_cache_available', None) is False: + return func(self) + + self._handle_cache_expiration() + value = self._cached_hset.get(key.encode()) + if value is None: + value = func(self) + serialized_value = serializer(value) + self._redis_client.hset(self._cache_hash_str, key, serialized_value) + self._cached_hset[key.encode()] = serialized_value + else: + value = deserializer(value) + + return value + + return wrapper + + return cached_key_getter diff --git a/kpi/utils/django_orm_helper.py b/kpi/utils/django_orm_helper.py index 57e2dd55ee..ffd8b0b471 100644 --- a/kpi/utils/django_orm_helper.py +++ b/kpi/utils/django_orm_helper.py @@ -1,9 +1,8 @@ from __future__ import annotations import json -from typing import Optional, Literal -from django.db.models import Lookup, Field +from django.db.models import Field, Lookup from django.db.models.expressions import Func, Value @@ -40,6 +39,31 @@ def __init__(self, expression: str, keyname: str, increment: int, **extra): ) +class DeductUsageValue(Func): + + function = 'jsonb_set' + usage_value = "COALESCE(%(expressions)s ->> '%(keyname)s', '0')::int" + template = ( + '%(function)s(%(expressions)s,' + '\'{"%(keyname)s"}\',' + '(' + f'CASE WHEN {usage_value} > %(amount)s ' + f'THEN {usage_value} - %(amount)s ' + 'ELSE 0 ' + 'END ' + ')::text::jsonb)' + ) + arity = 1 + + def __init__(self, expression: str, keyname: str, amount: int, **extra): + super().__init__( + expression, + keyname=keyname, + amount=amount, + **extra, + ) + + class OrderCustomCharField(Func): """ DO NOT use on fields other than CharField while the application maintains @@ -87,7 +111,7 @@ class RemoveJSONFieldAttribute(Func): """ arg_joiner = ' #- ' - template = "%(expressions)s" + template = '%(expressions)s' arity = 2 def __init__( @@ -116,7 +140,7 @@ class UpdateJSONFieldAttributes(Func): > structure is merged """ arg_joiner = ' || ' - template = "%(expressions)s" + template = '%(expressions)s' arity = 2 def __init__( diff --git a/kpi/utils/mailer.py b/kpi/utils/mailer.py index e627e636f7..e9265993c1 100644 --- a/kpi/utils/mailer.py +++ b/kpi/utils/mailer.py @@ -3,7 +3,7 @@ from typing import Union from smtplib import SMTPException -from constance import config +from django.conf import settings from django.core.mail import send_mail, EmailMultiAlternatives, get_connection from django.template.loader import get_template from django.utils.translation import activate, gettext as t @@ -27,7 +27,7 @@ def __init__( if isinstance(to, str): self.to = [to] - self.from_ = config.SUPPORT_EMAIL if not from_ else from_ + self.from_ = settings.DEFAULT_FROM_EMAIL if not from_ else from_ if language: # Localize templates diff --git a/kpi/utils/usage_calculator.py b/kpi/utils/usage_calculator.py index eaf4aacca0..12ea46b8dc 100644 --- a/kpi/utils/usage_calculator.py +++ b/kpi/utils/usage_calculator.py @@ -1,5 +1,7 @@ +from json import dumps, loads from typing import Optional +from django.apps import apps from django.conf import settings from django.db.models import Q, Sum from django.db.models.functions import Coalesce @@ -7,20 +9,26 @@ from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.openrosa.apps.logger.models import DailyXFormSubmissionCounter, XForm -from kobo.apps.organizations.models import Organization from kobo.apps.organizations.utils import ( get_monthly_billing_dates, get_yearly_billing_dates, ) from kobo.apps.stripe.constants import ACTIVE_STRIPE_STATUSES -from kobo.apps.trackers.models import NLPUsageCounter +from kpi.utils.cache import CachedClass, cached_class_property -class ServiceUsageCalculator: - def __init__(self, user: User, organization: Optional[Organization]): +class ServiceUsageCalculator(CachedClass): + CACHE_TTL = settings.ENDPOINT_CACHE_DURATION + + def __init__( + self, + user: User, + organization: Optional['Organization'], + disable_cache: bool = False, + ): self.user = user self.organization = organization - + self._cache_available = not disable_cache self._user_ids = [user.pk] self._user_id_query = self._filter_by_user([user.pk]) if organization and settings.STRIPE_ENABLED: @@ -50,14 +58,38 @@ def __init__(self, user: User, organization: Optional[Organization]): ) self.current_month_filter = Q(date__range=[self.current_month_start, now]) self.current_year_filter = Q(date__range=[self.current_year_start, now]) + self._setup_cache() - def _filter_by_user(self, user_ids: list) -> Q: - """ - Turns a list of user ids into a query object to filter by + def get_nlp_usage_by_type(self, usage_key: str) -> int: + """Returns the usage for a given organization and usage key. The usage key + should be the value from the USAGE_LIMIT_MAP found in the stripe kobo app. """ - return Q(user_id__in=user_ids) + if self.organization is None: + return None + + billing_details = self.organization.active_subscription_billing_details() + if not billing_details: + return None + + interval = billing_details['recurring_interval'] + nlp_usage = self.get_nlp_usage_counters() + + cached_usage = { + 'asr_seconds': nlp_usage[f'asr_seconds_current_{interval}'], + 'mt_characters': nlp_usage[f'mt_characters_current_{interval}'], + } + + return cached_usage[usage_key] + def get_last_updated(self): + return self._cache_last_updated() + + @cached_class_property( + key='nlp_usage_counters', serializer=dumps, deserializer=loads + ) def get_nlp_usage_counters(self): + NLPUsageCounter = apps.get_model('trackers', 'NLPUsageCounter') # noqa + nlp_tracking = ( NLPUsageCounter.objects.only( 'date', 'total_asr_seconds', 'total_mt_characters' @@ -90,6 +122,7 @@ def get_nlp_usage_counters(self): return total_nlp_usage + @cached_class_property(key='storage_usage', serializer=str, deserializer=int) def get_storage_usage(self): """ Get the storage used by non-(soft-)deleted projects for all users @@ -108,6 +141,9 @@ def get_storage_usage(self): return total_storage_bytes['bytes_sum'] or 0 + @cached_class_property( + key='submission_counters', serializer=dumps, deserializer=loads + ) def get_submission_counters(self): """ Calculate submissions for all users' projects even their deleted ones @@ -133,3 +169,15 @@ def get_submission_counters(self): total_submission_count[submission_key] = count if count is not None else 0 return total_submission_count + + def _get_cache_hash(self): + if self.organization is None: + return f'user-{self.user.id}' + else: + return f'organization-{self.organization.id}' + + def _filter_by_user(self, user_ids: list) -> Q: + """ + Turns a list of user ids into a query object to filter by + """ + return Q(user_id__in=user_ids) diff --git a/kpi/views/__init__.py b/kpi/views/__init__.py index af26887763..e375b4d370 100644 --- a/kpi/views/__init__.py +++ b/kpi/views/__init__.py @@ -17,10 +17,6 @@ def home(request): return TemplateResponse(request, 'index.html') -def browser_tests(request): - return TemplateResponse(request, 'browser_tests.html') - - def modern_browsers(request): return TemplateResponse(request, 'modern_browsers.html') diff --git a/kpi/views/environment.py b/kpi/views/environment.py index 1f590da181..747382f7b6 100644 --- a/kpi/views/environment.py +++ b/kpi/views/environment.py @@ -52,6 +52,10 @@ class EnvironmentView(APIView): 'USE_TEAM_LABEL', ] + OTHER_CONFIGS = [ + 'PROJECT_HISTORY_LOG_LIFESPAN', + ] + @classmethod def process_simple_configs(cls): return { @@ -64,30 +68,18 @@ def process_simple_configs(cls): 'FREE_TIER_THRESHOLDS', ] - @classmethod - def process_json_configs(cls): + def get(self, request, *args, **kwargs): data = {} - for key in cls.JSON_CONFIGS: - value = getattr(constance.config, key) - try: - value = to_python_object(value) - except json.JSONDecodeError: - logging.error( - f'Configuration value for `{key}` has invalid JSON' - ) - continue - data[key.lower()] = value - return data - - @staticmethod - def split_with_newline_kludge(value): - """ - django-constance formerly (before 2.7) used `\r\n` for newlines but - later changed that to `\n` alone. See #3825, #3831. This fix-up process - is *only* needed for settings that existed prior to this change; do not - use it when adding new settings. - """ - return (line.strip('\r') for line in value.split('\n')) + data.update(self.process_simple_configs()) + data.update(self.process_json_configs()) + data.update(self.process_choice_configs()) + data.update(self.process_mfa_configs(request)) + data.update(self.process_password_configs(request)) + data.update(self.process_project_metadata_configs(request)) + data.update(self.process_user_metadata_configs(request)) + data.update(self.process_other_configs(request)) + data.update(self.static_configs(request)) + return Response(data) @classmethod def process_choice_configs(cls): @@ -114,6 +106,21 @@ def process_choice_configs(cls): data['interface_languages'] = settings.LANGUAGES return data + @classmethod + def process_json_configs(cls): + data = {} + for key in cls.JSON_CONFIGS: + value = getattr(constance.config, key) + try: + value = to_python_object(value) + except json.JSONDecodeError: + logging.error( + f'Configuration value for `{key}` has invalid JSON' + ) + continue + data[key.lower()] = value + return data + @staticmethod def process_mfa_configs(request): data = {} @@ -149,13 +156,6 @@ def process_project_metadata_configs(request): } return data - @staticmethod - def process_user_metadata_configs(request): - data = { - 'user_metadata_fields': I18nUtils.get_metadata_fields('user') - } - return data - @staticmethod def process_other_configs(request): data = {} @@ -169,12 +169,17 @@ def process_other_configs(request): data['asr_mt_features_enabled'] = check_asr_mt_access_for_user(request.user) data['submission_placeholder'] = SUBMISSION_PLACEHOLDER + for key in EnvironmentView.OTHER_CONFIGS: + data[key.lower()] = getattr(constance.config, key) + if settings.STRIPE_ENABLED: from djstripe.models import APIKey try: data['stripe_public_key'] = str( - APIKey.objects.get(type='publishable', livemode=settings.STRIPE_LIVE_MODE).secret + APIKey.objects.get( + type='publishable', livemode=settings.STRIPE_LIVE_MODE + ).secret ) except MultipleObjectsReturned as e: raise MultipleObjectsReturned( @@ -209,18 +214,22 @@ def process_other_configs(request): return data + @staticmethod + def process_user_metadata_configs(request): + data = { + 'user_metadata_fields': I18nUtils.get_metadata_fields('user') + } + return data + + @staticmethod + def split_with_newline_kludge(value): + """ + django-constance formerly (before 2.7) used `\r\n` for newlines but + later changed that to `\n` alone. See #3825, #3831. This fix-up process + is *only* needed for settings that existed prior to this change; do not + use it when adding new settings. + """ + return (line.strip('\r') for line in value.split('\n')) + def static_configs(self, request): return {'open_rosa_server': settings.KOBOCAT_URL} - - def get(self, request, *args, **kwargs): - data = {} - data.update(self.process_simple_configs()) - data.update(self.process_json_configs()) - data.update(self.process_choice_configs()) - data.update(self.process_mfa_configs(request)) - data.update(self.process_password_configs(request)) - data.update(self.process_project_metadata_configs(request)) - data.update(self.process_user_metadata_configs(request)) - data.update(self.process_other_configs(request)) - data.update(self.static_configs(request)) - return Response(data) diff --git a/kpi/views/v1/export_task.py b/kpi/views/v1/export_task.py index 95c2d261bc..42ed479b81 100644 --- a/kpi/views/v1/export_task.py +++ b/kpi/views/v1/export_task.py @@ -5,14 +5,14 @@ from rest_framework.response import Response from rest_framework.reverse import reverse +from kobo.apps.audit_log.base_views import AuditLoggedNoUpdateModelViewSet from kpi.models import Asset, ExportTask from kpi.serializers import ExportTaskSerializer from kpi.tasks import export_in_background from kpi.utils.models import remove_string_prefix, resolve_url_to_asset -from kpi.views.no_update_model import NoUpdateModelViewSet -class ExportTaskViewSet(NoUpdateModelViewSet): +class ExportTaskViewSet(AuditLoggedNoUpdateModelViewSet): """ ## This document is for a deprecated version of kpi's API. @@ -133,6 +133,7 @@ class ExportTaskViewSet(NoUpdateModelViewSet): queryset = ExportTask.objects.all() serializer_class = ExportTaskSerializer lookup_field = 'uid' + log_type = 'project-history' def get_queryset(self, *args, **kwargs): if self.request.user.is_anonymous: @@ -198,6 +199,7 @@ def create(self, request, *args, **kwargs): except Asset.DoesNotExist: raise serializers.ValidationError( {'source': 'The specified asset does not exist.'}) + request._request.updated_data = {'asset_id': source.id, 'asset_uid': source.uid} # Complain if it's not deployed if not source.has_deployment: raise serializers.ValidationError( diff --git a/kpi/views/v2/asset.py b/kpi/views/v2/asset.py index 829302b6fe..1af06ba083 100644 --- a/kpi/views/v2/asset.py +++ b/kpi/views/v2/asset.py @@ -559,9 +559,7 @@ def get_object_override(self): # content, not previous versions. Previous versions are handled in # `kobo.apps.reports.report_data.build_formpack()` if self.request.method == 'GET': - repair_file_column_content_and_save( - asset, include_versions=False - ) + repair_file_column_content_and_save(asset, include_versions=False) return asset diff --git a/kpi/views/v2/asset_snapshot.py b/kpi/views/v2/asset_snapshot.py index efbae3f12b..de47f30fc2 100644 --- a/kpi/views/v2/asset_snapshot.py +++ b/kpi/views/v2/asset_snapshot.py @@ -74,11 +74,8 @@ def filter_queryset(self, queryset): if not user.is_anonymous: owned_snapshots = queryset.filter(owner=user) - return ( - owned_snapshots - | RelatedAssetPermissionsFilter().filter_queryset( - self.request, queryset, view=self - ) + return owned_snapshots | RelatedAssetPermissionsFilter().filter_queryset( + self.request, queryset, view=self ) @action( @@ -103,9 +100,11 @@ def form_list(self, request, *args, **kwargs): def get_object(self): try: - snapshot = self.queryset.select_related('asset').defer( - 'asset__content' - ).get(uid=self.kwargs[self.lookup_field]) + snapshot = ( + self.queryset.select_related('asset') + .defer('asset__content') + .get(uid=self.kwargs[self.lookup_field]) + ) except AssetSnapshot.DoesNotExist: raise Http404 diff --git a/kpi/views/v2/export_task.py b/kpi/views/v2/export_task.py index 281ab24a2e..32f9ca8623 100644 --- a/kpi/views/v2/export_task.py +++ b/kpi/views/v2/export_task.py @@ -5,17 +5,17 @@ ) from rest_framework_extensions.mixins import NestedViewSetMixin +from kobo.apps.audit_log.base_views import AuditLoggedNoUpdateModelViewSet from kpi.filters import SearchFilter from kpi.models import ExportTask from kpi.permissions import ExportTaskPermission from kpi.serializers.v2.export_task import ExportTaskSerializer from kpi.utils.object_permission import get_database_user from kpi.utils.viewset_mixins import AssetNestedObjectViewsetMixin -from kpi.views.no_update_model import NoUpdateModelViewSet class ExportTaskViewSet( - AssetNestedObjectViewsetMixin, NestedViewSetMixin, NoUpdateModelViewSet + AssetNestedObjectViewsetMixin, NestedViewSetMixin, AuditLoggedNoUpdateModelViewSet ): """ ## List of export tasks endpoints @@ -159,6 +159,8 @@ class ExportTaskViewSet( search_default_field_lookups = [ 'uid__icontains', ] + log_type = 'project-history' + logged_fields = [('object_id', 'asset.id')] def get_queryset(self): user = get_database_user(self.request.user) diff --git a/package-lock.json b/package-lock.json index 63ac11913d..8b2cad543d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,7 @@ }, "devDependencies": { "@babel/preset-env": "^7.22.5", + "@jest/create-cache-key-function": "^29.7.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@storybook/addon-a11y": "^7.0.24", "@storybook/addon-actions": "^7.0.24", @@ -96,6 +97,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@types/chai": "^4.3.1", + "@types/coffeescript": "^2.5.7", "@types/gtag.js": "^0.0.12", "@types/jest": "^29.5.13", "@types/jquery": "^3.5.10", @@ -115,7 +117,6 @@ "@types/lodash.union": "^4.6.7", "@types/lodash.values": "^4.3.7", "@types/lodash.zip": "^4.2.7", - "@types/mocha": "^9.1.1", "@types/react": "^18.3.3", "@types/react-document-title": "^2.0.9", "@types/react-dom": "^18.3.0", @@ -149,8 +150,6 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "mocha": "^7.2.0", - "mocha-chrome": "^2.2.0", "moment": "^2.30.1", "patch-package": "^6.5.1", "postcss": "^8.4.39", @@ -179,7 +178,8 @@ "webpack-cli": "^5.1.4", "webpack-dev-middleware": "^6.1.3", "webpack-dev-server": "^4.15.2", - "webpack-extract-translation-keys-plugin": "^6.1.0" + "webpack-extract-translation-keys-plugin": "^6.1.0", + "whatwg-fetch": "^3.6.20" }, "engines": { "node": "^20.17.0 || ^22.4.1" @@ -8261,6 +8261,16 @@ "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", "dev": true }, + "node_modules/@types/coffeescript": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@types/coffeescript/-/coffeescript-2.5.7.tgz", + "integrity": "sha512-UXsSC38arfQNqQIKck80YBjL+FdQSPiWJ2lGb3E/bAZAmjuC0WLIOSYMZydiDI9ni6GW/4W3GSdvL8JJpf3jAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.1.15" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -8713,13 +8723,6 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, - "node_modules/@types/mocha": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", - "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.5.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", @@ -9743,15 +9746,6 @@ "node": ">=0.4.2" } }, - "node_modules/ansi-colors": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", - "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -9953,15 +9947,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-find-index": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -10073,27 +10058,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.reduce": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", - "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-array-method-boxes-properly": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array.prototype.toreversed": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz", @@ -10936,12 +10900,6 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, "node_modules/browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -11223,29 +11181,6 @@ "node": ">=6" } }, - "node_modules/camelcase-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", - "integrity": "sha512-Ej37YKYbFUI8QiYlvj9YHb6/Z60dZyPJW0Cs8sFilMbd2lP0bw3ylAq9yJkK4lcTA2dID5fG8LjmJYbO7kWb7Q==", - "dev": true, - "dependencies": { - "camelcase": "^4.1.0", - "map-obj": "^2.0.0", - "quick-lru": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/camelcase-keys/node_modules/camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001662", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001662.tgz", @@ -11404,113 +11339,6 @@ "node": ">=10" } }, - "node_modules/chrome-launcher": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.11.2.tgz", - "integrity": "sha512-jx0kJDCXdB2ARcDMwNCtrf04oY1Up4rOmVu+fqJ5MTPOOIG8EhRcEU9NZfXZc6dMw9FU8o1r21PNp8V2M0zQ+g==", - "dev": true, - "dependencies": { - "@types/node": "*", - "is-wsl": "^2.1.0", - "lighthouse-logger": "^1.0.0", - "mkdirp": "0.5.1", - "rimraf": "^2.6.1" - } - }, - "node_modules/chrome-launcher/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/chrome-launcher/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/chrome-launcher/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chrome-launcher/node_modules/minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", - "dev": true - }, - "node_modules/chrome-launcher/node_modules/mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", - "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", - "dev": true, - "dependencies": { - "minimist": "0.0.8" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/chrome-launcher/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/chrome-remote-interface": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.28.2.tgz", - "integrity": "sha512-F7mjof7rWvRNsJqhVXuiFU/HWySCxTA9tzpLxUJxVfdLkljwFJ1aMp08AnwXRmmP7r12/doTDOMwaNhFCJsacw==", - "dev": true, - "dependencies": { - "commander": "2.11.x", - "ws": "^7.2.0" - }, - "bin": { - "chrome-remote-interface": "bin/client.js" - } - }, - "node_modules/chrome-remote-interface/node_modules/commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "dev": true - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -11520,16 +11348,6 @@ "node": ">=6.0" } }, - "node_modules/chrome-unmirror": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chrome-unmirror/-/chrome-unmirror-0.1.0.tgz", - "integrity": "sha512-HmQgCN2UTpcrP85oOGnKpkGJFyOUwjsjnPBZlE8MkG0i+NoynGIkuPDZFKh+K4NLQlPiKKde16FAQ98JC1j8ew==", - "dev": true, - "engines": { - "node": ">=0.10.0", - "npm": ">=2.0.0" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -11657,81 +11475,6 @@ "node": ">=8" } }, - "node_modules/cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "dev": true, - "dependencies": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "node_modules/cliui/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -11817,11 +11560,12 @@ } }, "node_modules/coffee-script": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.11.1.tgz", - "integrity": "sha512-NIWm59Fh1zkXq6TS6PQvSO3AR9DbGq1IBNZHa1E3fUCNmJhIwLf1YKcWgaHqaU7zWGC/OE2V7K3GVAXFzcmu+A==", + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", "deprecated": "CoffeeScript on NPM has moved to \"coffeescript\" (no hyphen)", "dev": true, + "license": "MIT", "bin": { "cake": "bin/cake", "coffee": "bin/coffee" @@ -12532,18 +12276,6 @@ "integrity": "sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ==", "dev": true }, - "node_modules/currently-unhandled": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", - "dev": true, - "dependencies": { - "array-find-index": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/d3": { "version": "3.5.17", "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", @@ -12742,19 +12474,6 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, - "node_modules/deep-assign": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/deep-assign/-/deep-assign-3.0.0.tgz", - "integrity": "sha512-YX2i9XjJ7h5q/aQ/IM9PEwEnDqETAIYbggmdDB3HLTlSgo1CxPsj6pvhPG68rq6SVE0+p+6Ywsm5fTYNrYtBWw==", - "deprecated": "Check out `lodash.merge` or `merge-options` instead.", - "dev": true, - "dependencies": { - "is-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -13027,15 +12746,6 @@ "node": ">= 4.0.0" } }, - "node_modules/diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -13521,12 +13231,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", - "dev": true - }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -15888,15 +15592,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "node_modules/growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true, - "engines": { - "node": ">=4.x" - } - }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -16515,95 +16210,6 @@ "node": ">=8" } }, - "node_modules/import-local": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", - "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", - "dev": true, - "dependencies": { - "pkg-dir": "^3.0.0", - "resolve-cwd": "^2.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-local/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-local/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-local/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/import-local/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/import-local/node_modules/pkg-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", - "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", - "dev": true, - "dependencies": { - "find-up": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -16811,29 +16417,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -17084,15 +16667,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -20185,12 +19759,6 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, - "node_modules/json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -20495,31 +20063,6 @@ "immediate": "~3.0.5" } }, - "node_modules/lighthouse-logger": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", - "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", - "dev": true, - "dependencies": { - "debug": "^2.6.9", - "marky": "^1.2.2" - } - }, - "node_modules/lighthouse-logger/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/lighthouse-logger/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -20534,34 +20077,6 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, - "node_modules/load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/load-json-file/node_modules/parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, - "dependencies": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -20744,18 +20259,6 @@ "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==" }, - "node_modules/log-symbols": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", - "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", - "dev": true, - "dependencies": { - "chalk": "^2.4.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/loglevel": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", @@ -20845,25 +20348,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loud-rejection": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", - "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", - "dev": true, - "dependencies": { - "currently-unhandled": "^0.4.1", - "signal-exit": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/loud-rejection/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -21018,15 +20502,6 @@ "tmpl": "1.0.5" } }, - "node_modules/map-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", - "integrity": "sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/map-or-similar": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", @@ -21045,12 +20520,6 @@ "react": ">= 0.14.0" } }, - "node_modules/marky": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz", - "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==", - "dev": true - }, "node_modules/mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -21125,44 +20594,6 @@ "map-or-similar": "^1.5.0" } }, - "node_modules/meow": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", - "integrity": "sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig==", - "dev": true, - "dependencies": { - "camelcase-keys": "^4.0.0", - "decamelize-keys": "^1.0.0", - "loud-rejection": "^1.0.0", - "minimist-options": "^3.0.1", - "normalize-package-data": "^2.3.4", - "read-pkg-up": "^3.0.0", - "redent": "^2.0.0", - "trim-newlines": "^2.0.0", - "yargs-parser": "^10.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/meow/node_modules/camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/meow/node_modules/yargs-parser": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", - "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", - "dev": true, - "dependencies": { - "camelcase": "^4.1.0" - } - }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -21292,19 +20723,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minimist-options": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz", - "integrity": "sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==", - "dev": true, - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0" - }, - "engines": { - "node": ">= 4" - } - }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -21572,323 +20990,6 @@ } } }, - "node_modules/mocha": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz", - "integrity": "sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==", - "dev": true, - "dependencies": { - "ansi-colors": "3.2.3", - "browser-stdout": "1.3.1", - "chokidar": "3.3.0", - "debug": "3.2.6", - "diff": "3.5.0", - "escape-string-regexp": "1.0.5", - "find-up": "3.0.0", - "glob": "7.1.3", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "3.13.1", - "log-symbols": "3.0.0", - "minimatch": "3.0.4", - "mkdirp": "0.5.5", - "ms": "2.1.1", - "node-environment-flags": "1.0.6", - "object.assign": "4.1.0", - "strip-json-comments": "2.0.1", - "supports-color": "6.0.0", - "which": "1.3.1", - "wide-align": "1.1.3", - "yargs": "13.3.2", - "yargs-parser": "13.1.2", - "yargs-unparser": "1.6.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" - } - }, - "node_modules/mocha-chrome": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/mocha-chrome/-/mocha-chrome-2.2.0.tgz", - "integrity": "sha512-RXP6Q2mlM2X+eO2Z8gribmiH4J9x5zu/JcTZ3deQSwiC5260BzizOc0eD1NWP3JuypGCKRwReicv4KCNIFtTZQ==", - "dev": true, - "dependencies": { - "chalk": "^2.0.1", - "chrome-launcher": "^0.11.2", - "chrome-remote-interface": "^0.28.0", - "chrome-unmirror": "^0.1.0", - "debug": "^4.1.1", - "deep-assign": "^3.0.0", - "import-local": "^2.0.0", - "loglevel": "^1.4.1", - "meow": "^5.0.0", - "nanobus": "^4.2.0" - }, - "bin": { - "mocha-chrome": "cli.js" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/mocha/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/mocha/node_modules/chokidar": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", - "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.1", - "braces": "~3.0.2", - "glob-parent": "~5.1.0", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.2.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.1.1" - } - }, - "node_modules/mocha/node_modules/debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", - "dev": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/mocha/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/mocha/node_modules/fsevents": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", - "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", - "deprecated": "\"Please update to latest v2.3 or v2.2\"", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/mocha/node_modules/glob": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", - "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mocha/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/mocha/node_modules/js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/mocha/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/mocha/node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mocha/node_modules/mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dev": true, - "dependencies": { - "minimist": "^1.2.5" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", - "dev": true - }, - "node_modules/mocha/node_modules/object.assign": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", - "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "dev": true, - "dependencies": { - "define-properties": "^1.1.2", - "function-bind": "^1.1.1", - "has-symbols": "^1.0.0", - "object-keys": "^1.0.11" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mocha/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mocha/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/mocha/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/mocha/node_modules/readdirp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", - "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", - "dev": true, - "dependencies": { - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/mocha/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mocha/node_modules/supports-color": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", - "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/mocha/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -21931,23 +21032,6 @@ "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "dev": true }, - "node_modules/nanoassert": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz", - "integrity": "sha512-C40jQ3NzfkP53NsO8kEOFd79p4b9kDXQMwgiY1z8ZwrDZgUyom0AHwGegF4Dm99L+YoYhuaB0ceerUcXmqr1rQ==", - "dev": true - }, - "node_modules/nanobus": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/nanobus/-/nanobus-4.5.0.tgz", - "integrity": "sha512-7sBZo9wthqNJ7QXnfVXZL7fkKJLN55GLOdX+RyZT34UOvxxnFtJe/c7K0ZRLAKOvaY1xJThFFn0Usw2H9R6Frg==", - "dev": true, - "dependencies": { - "nanoassert": "^1.1.0", - "nanotiming": "^7.2.0", - "remove-array-items": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -21966,25 +21050,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/nanoscheduler": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/nanoscheduler/-/nanoscheduler-1.0.3.tgz", - "integrity": "sha512-jBbrF3qdU9321r8n9X7yu18DjP31Do2ItJm3mWrt90wJTrnDO+HXpoV7ftaUglAtjgj9s+OaCxGufbvx6pvbEQ==", - "dev": true, - "dependencies": { - "nanoassert": "^1.1.0" - } - }, - "node_modules/nanotiming": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/nanotiming/-/nanotiming-7.3.1.tgz", - "integrity": "sha512-l3lC7v/PfOuRWQa8vV29Jo6TG10wHtnthLElFXs4Te4Aas57Fo4n1Q8LH9n+NDh9riOzTVvb2QNBhTS4JUKNjw==", - "dev": true, - "dependencies": { - "nanoassert": "^1.1.0", - "nanoscheduler": "^1.0.2" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -22101,25 +21166,6 @@ "node": "*" } }, - "node_modules/node-environment-flags": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", - "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", - "dev": true, - "dependencies": { - "object.getownpropertydescriptors": "^2.0.3", - "semver": "^5.7.0" - } - }, - "node_modules/node-environment-flags/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -22449,27 +21495,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.getownpropertydescriptors": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", - "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", - "dev": true, - "dependencies": { - "array.prototype.reduce": "^1.0.6", - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "gopd": "^1.0.1", - "safe-array-concat": "^1.1.2" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object.groupby": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", @@ -23262,15 +22287,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -24034,15 +23050,6 @@ } ] }, - "node_modules/quick-lru": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", - "integrity": "sha512-tRS7sTgyxMXtLum8L65daJnHUhfDUgboRdcWW2bR9vBfrj2+O5HSMbQOJfJJjIVSPFqbBCF37FpwWXGitDc5tA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/quote-stream": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz", @@ -24581,112 +23588,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", - "dev": true, - "dependencies": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", - "integrity": "sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==", - "dev": true, - "dependencies": { - "find-up": "^2.0.0", - "read-pkg": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up/node_modules/find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", - "dev": true, - "dependencies": { - "locate-path": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up/node_modules/locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", - "dev": true, - "dependencies": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up/node_modules/p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "dependencies": { - "p-try": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up/node_modules/p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", - "dev": true, - "dependencies": { - "p-limit": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up/node_modules/p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg-up/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/read-pkg/node_modules/path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "dependencies": { - "pify": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/readable-stream": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", @@ -24754,37 +23655,6 @@ "node": ">= 10.13.0" } }, - "node_modules/redent": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", - "integrity": "sha512-XNwrTx77JQCEMXTeb8movBKuK75MgH0RZkujNuDKCezemx/voapl9i2gCSi8WWm8+ox5ycJi1gxF22fR7c0Ciw==", - "dev": true, - "dependencies": { - "indent-string": "^3.0.0", - "strip-indent": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/redent/node_modules/indent-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", - "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/redent/node_modules/strip-indent": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", - "integrity": "sha512-RsSNPLpq6YUL7QYy44RnPVTn/lcVZtb48Uof3X5JLbF4zD/Gs7ZFDv2HWol+leoQN2mT86LAzSshGfkTlSOpsA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -24963,12 +23833,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/remove-array-items": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/remove-array-items/-/remove-array-items-1.1.1.tgz", - "integrity": "sha512-MXW/jtHyl5F1PZI7NbpS8SOtympdLuF20aoWJT5lELR1p/HJDd5nqW8Eu9uLh/hCRY3FgvrIT5AwDCgBODklcA==", - "dev": true - }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -25199,12 +24063,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, "node_modules/require-relative": { "version": "0.8.7", "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", @@ -25248,27 +24106,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-cwd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz", - "integrity": "sha512-ccu8zQTrzVr954472aUVPLEcB3YpKSYR3cg/3lo1okzobPBM+1INXBbBZlDbnI/hbEocnf8j0QVo43hQKrbchg==", - "dev": true, - "dependencies": { - "resolve-from": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", - "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -28471,15 +27308,6 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, - "node_modules/trim-newlines": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz", - "integrity": "sha512-MTBWv3jhVjTU7XR3IQHllbiJs8sc75a80OEhB6or/q7pLTWgQ0bMGQXXYQSrSuXe6WiKWDZ5txXY5P59a/coVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -30097,6 +28925,13 @@ "node": ">=0.10.0" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -30192,12 +29027,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true - }, "node_modules/which-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", @@ -30217,58 +29046,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, - "dependencies": { - "string-width": "^1.0.2 || 2" - } - }, - "node_modules/wide-align/node_modules/ansi-regex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", - "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/wide-align/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/wide-align/node_modules/string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "dependencies": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/wide-align/node_modules/strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", - "dev": true, - "dependencies": { - "ansi-regex": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -30432,27 +29209,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -30487,12 +29243,6 @@ "node": ">=0.4" } }, - "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -30507,171 +29257,6 @@ "node": ">= 6" } }, - "node_modules/yargs": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", - "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", - "dev": true, - "dependencies": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.2" - } - }, - "node_modules/yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", - "dev": true, - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - }, - "node_modules/yargs-unparser": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", - "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", - "dev": true, - "dependencies": { - "flat": "^4.1.0", - "lodash": "^4.17.15", - "yargs": "^13.3.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs-unparser/node_modules/flat": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz", - "integrity": "sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA==", - "dev": true, - "dependencies": { - "is-buffer": "~2.0.3" - }, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "node_modules/yargs/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "dependencies": { - "locate-path": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/yargs/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "dependencies": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yargs/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "dependencies": { - "ansi-regex": "^4.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", @@ -36057,6 +34642,15 @@ "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", "dev": true }, + "@types/coffeescript": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@types/coffeescript/-/coffeescript-2.5.7.tgz", + "integrity": "sha512-UXsSC38arfQNqQIKck80YBjL+FdQSPiWJ2lGb3E/bAZAmjuC0WLIOSYMZydiDI9ni6GW/4W3GSdvL8JJpf3jAA==", + "dev": true, + "requires": { + "@types/babel__core": "^7.1.15" + } + }, "@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -36496,12 +35090,6 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, - "@types/mocha": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", - "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", - "dev": true - }, "@types/node": { "version": "22.5.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.5.tgz", @@ -37335,12 +35923,6 @@ "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", "optional": true }, - "ansi-colors": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.3.tgz", - "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==", - "dev": true - }, "ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -37496,12 +36078,6 @@ "is-array-buffer": "^3.0.4" } }, - "array-find-index": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "dev": true - }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -37580,21 +36156,6 @@ "es-shim-unscopables": "^1.0.0" } }, - "array.prototype.reduce": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.7.tgz", - "integrity": "sha512-mzmiUCVwtiD4lgxYP8g7IYy8El8p2CSMePvIbTS7gchKir/L1fgJrk0yDKmAX6mnRQFKNADYIk8nNlTris5H1Q==", - "dev": true, - "requires": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-array-method-boxes-properly": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "is-string": "^1.0.7" - } - }, "array.prototype.toreversed": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz", @@ -38215,12 +36776,6 @@ "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", "dev": true }, - "browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, "browserify-zlib": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.1.4.tgz", @@ -38427,25 +36982,6 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true }, - "camelcase-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz", - "integrity": "sha512-Ej37YKYbFUI8QiYlvj9YHb6/Z60dZyPJW0Cs8sFilMbd2lP0bw3ylAq9yJkK4lcTA2dID5fG8LjmJYbO7kWb7Q==", - "dev": true, - "requires": { - "camelcase": "^4.1.0", - "map-obj": "^2.0.0", - "quick-lru": "^1.0.0" - }, - "dependencies": { - "camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==", - "dev": true - } - } - }, "caniuse-lite": { "version": "1.0.30001662", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001662.tgz", @@ -38553,108 +37089,12 @@ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", "dev": true }, - "chrome-launcher": { - "version": "0.11.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.11.2.tgz", - "integrity": "sha512-jx0kJDCXdB2ARcDMwNCtrf04oY1Up4rOmVu+fqJ5MTPOOIG8EhRcEU9NZfXZc6dMw9FU8o1r21PNp8V2M0zQ+g==", - "dev": true, - "requires": { - "@types/node": "*", - "is-wsl": "^2.1.0", - "lighthouse-logger": "^1.0.0", - "mkdirp": "0.5.1", - "rimraf": "^2.6.1" - }, - "dependencies": { - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", - "dev": true - }, - "mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", - "dev": true, - "requires": { - "minimist": "0.0.8" - } - }, - "rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "requires": { - "glob": "^7.1.3" - } - } - } - }, - "chrome-remote-interface": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.28.2.tgz", - "integrity": "sha512-F7mjof7rWvRNsJqhVXuiFU/HWySCxTA9tzpLxUJxVfdLkljwFJ1aMp08AnwXRmmP7r12/doTDOMwaNhFCJsacw==", - "dev": true, - "requires": { - "commander": "2.11.x", - "ws": "^7.2.0" - }, - "dependencies": { - "commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", - "dev": true - } - } - }, "chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true }, - "chrome-unmirror": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/chrome-unmirror/-/chrome-unmirror-0.1.0.tgz", - "integrity": "sha512-HmQgCN2UTpcrP85oOGnKpkGJFyOUwjsjnPBZlE8MkG0i+NoynGIkuPDZFKh+K4NLQlPiKKde16FAQ98JC1j8ew==", - "dev": true - }, "ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -38746,68 +37186,6 @@ } } }, - "cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "dev": true, - "requires": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - }, - "wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" - } - } - } - }, "clone": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", @@ -38874,9 +37252,9 @@ } }, "coffee-script": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.11.1.tgz", - "integrity": "sha512-NIWm59Fh1zkXq6TS6PQvSO3AR9DbGq1IBNZHa1E3fUCNmJhIwLf1YKcWgaHqaU7zWGC/OE2V7K3GVAXFzcmu+A==", + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", "dev": true }, "coffeelint": { @@ -38885,7 +37263,7 @@ "integrity": "sha512-6mzgOo4zb17WfdrSui/cSUEgQ0AQkW3gXDht+6lHkfkqGUtSYKwGdGcXsDfAyuScVzTlTtKdfwkAlJWfqul7zg==", "dev": true, "requires": { - "coffee-script": "~1.11.0", + "coffee-script": "^1.12.0", "glob": "^7.0.6", "ignore": "^3.0.9", "optimist": "^0.6.1", @@ -39404,15 +37782,6 @@ "integrity": "sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ==", "dev": true }, - "currently-unhandled": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", - "dev": true, - "requires": { - "array-find-index": "^1.0.1" - } - }, "d3": { "version": "3.5.17", "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", @@ -39554,15 +37923,6 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, - "deep-assign": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/deep-assign/-/deep-assign-3.0.0.tgz", - "integrity": "sha512-YX2i9XjJ7h5q/aQ/IM9PEwEnDqETAIYbggmdDB3HLTlSgo1CxPsj6pvhPG68rq6SVE0+p+6Ywsm5fTYNrYtBWw==", - "dev": true, - "requires": { - "is-obj": "^1.0.0" - } - }, "deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -39760,12 +38120,6 @@ "debug": "4" } }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true - }, "diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -40161,12 +38515,6 @@ "which-typed-array": "^1.1.15" } }, - "es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", - "dev": true - }, "es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -41982,12 +40330,6 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, - "growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "dev": true - }, "gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -42416,70 +40758,6 @@ "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", "dev": true }, - "import-local": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-2.0.0.tgz", - "integrity": "sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==", - "dev": true, - "requires": { - "pkg-dir": "^3.0.0", - "resolve-cwd": "^2.0.0" - }, - "dependencies": { - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true - }, - "pkg-dir": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", - "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", - "dev": true, - "requires": { - "find-up": "^3.0.0" - } - } - } - }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -42637,12 +40915,6 @@ "has-tostringtag": "^1.0.0" } }, - "is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "dev": true - }, "is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -42804,12 +41076,6 @@ "has-tostringtag": "^1.0.0" } }, - "is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "dev": true - }, "is-path-cwd": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", @@ -44938,12 +43204,6 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, - "json-parse-better-errors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", - "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", - "dev": true - }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -45200,33 +43460,6 @@ "immediate": "~3.0.5" } }, - "lighthouse-logger": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", - "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", - "dev": true, - "requires": { - "debug": "^2.6.9", - "marky": "^1.2.2" - }, - "dependencies": { - "debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - } - } - }, "lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -45238,30 +43471,6 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, - "load-json-file": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", - "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.2", - "parse-json": "^4.0.0", - "pify": "^3.0.0", - "strip-bom": "^3.0.0" - }, - "dependencies": { - "parse-json": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", - "dev": true, - "requires": { - "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1" - } - } - } - }, "loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -45434,15 +43643,6 @@ "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==" }, - "log-symbols": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", - "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", - "dev": true, - "requires": { - "chalk": "^2.4.2" - } - }, "loglevel": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz", @@ -45509,24 +43709,6 @@ "js-tokens": "^3.0.0 || ^4.0.0" } }, - "loud-rejection": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", - "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", - "dev": true, - "requires": { - "currently-unhandled": "^0.4.1", - "signal-exit": "^3.0.0" - }, - "dependencies": { - "signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - } - } - }, "loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -45658,12 +43840,6 @@ "tmpl": "1.0.5" } }, - "map-obj": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz", - "integrity": "sha512-TzQSV2DiMYgoF5RycneKVUzIa9bQsj/B3tTgsE3dOGqlzHnGIDaC7XBE7grnA+8kZPnfqSGFe95VHc2oc0VFUQ==", - "dev": true - }, "map-or-similar": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", @@ -45677,12 +43853,6 @@ "dev": true, "requires": {} }, - "marky": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz", - "integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==", - "dev": true - }, "mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -45739,40 +43909,6 @@ "map-or-similar": "^1.5.0" } }, - "meow": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-5.0.0.tgz", - "integrity": "sha512-CbTqYU17ABaLefO8vCU153ZZlprKYWDljcndKKDCFcYQITzWCXZAVk4QMFZPgvzrnUQ3uItnIE/LoUOwrT15Ig==", - "dev": true, - "requires": { - "camelcase-keys": "^4.0.0", - "decamelize-keys": "^1.0.0", - "loud-rejection": "^1.0.0", - "minimist-options": "^3.0.1", - "normalize-package-data": "^2.3.4", - "read-pkg-up": "^3.0.0", - "redent": "^2.0.0", - "trim-newlines": "^2.0.0", - "yargs-parser": "^10.0.0" - }, - "dependencies": { - "camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha512-FxAv7HpHrXbh3aPo4o2qxHay2lkLY3x5Mw3KeE4KQE8ysVfziWeRZDwcjauvwBSGEC/nXUPzZy8zeh4HokqOnw==", - "dev": true - }, - "yargs-parser": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", - "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", - "dev": true, - "requires": { - "camelcase": "^4.1.0" - } - } - } - }, "merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -45866,16 +44002,6 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" }, - "minimist-options": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-3.0.2.tgz", - "integrity": "sha512-FyBrT/d0d4+uiZRbqznPXqw3IpZZG3gl3wKWiX784FycUKVwBt0uLBFkQrtE4tZOrgo78nZp2jnKz3L65T5LdQ==", - "dev": true, - "requires": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0" - } - }, "minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -46073,245 +44199,6 @@ "integrity": "sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==", "requires": {} }, - "mocha": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.2.0.tgz", - "integrity": "sha512-O9CIypScywTVpNaRrCAgoUnJgozpIofjKUYmJhiCIJMiuYnLI6otcb1/kpW9/n/tJODHGZ7i8aLQoDVsMtOKQQ==", - "dev": true, - "requires": { - "ansi-colors": "3.2.3", - "browser-stdout": "1.3.1", - "chokidar": "3.3.0", - "debug": "3.2.6", - "diff": "3.5.0", - "escape-string-regexp": "1.0.5", - "find-up": "3.0.0", - "glob": "7.1.3", - "growl": "1.10.5", - "he": "1.2.0", - "js-yaml": "3.13.1", - "log-symbols": "3.0.0", - "minimatch": "3.0.4", - "mkdirp": "0.5.5", - "ms": "2.1.1", - "node-environment-flags": "1.0.6", - "object.assign": "4.1.0", - "strip-json-comments": "2.0.1", - "supports-color": "6.0.0", - "which": "1.3.1", - "wide-align": "1.1.3", - "yargs": "13.3.2", - "yargs-parser": "13.1.2", - "yargs-unparser": "1.6.0" - }, - "dependencies": { - "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "requires": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "chokidar": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.3.0.tgz", - "integrity": "sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A==", - "dev": true, - "requires": { - "anymatch": "~3.1.1", - "braces": "~3.0.2", - "fsevents": "~2.1.1", - "glob-parent": "~5.1.0", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.2.0" - } - }, - "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", - "dev": true, - "requires": { - "ms": "^2.1.1" - } - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "fsevents": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz", - "integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==", - "dev": true, - "optional": true - }, - "glob": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", - "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "requires": { - "is-glob": "^4.0.1" - } - }, - "js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, - "requires": { - "brace-expansion": "^1.1.7" - } - }, - "mkdirp": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", - "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", - "dev": true, - "requires": { - "minimist": "^1.2.5" - } - }, - "ms": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", - "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", - "dev": true - }, - "object.assign": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", - "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", - "dev": true, - "requires": { - "define-properties": "^1.1.2", - "function-bind": "^1.1.1", - "has-symbols": "^1.0.0", - "object-keys": "^1.0.11" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true - }, - "readdirp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.2.0.tgz", - "integrity": "sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ==", - "dev": true, - "requires": { - "picomatch": "^2.0.4" - } - }, - "strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true - }, - "supports-color": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.0.0.tgz", - "integrity": "sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - }, - "which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } - } - }, - "mocha-chrome": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/mocha-chrome/-/mocha-chrome-2.2.0.tgz", - "integrity": "sha512-RXP6Q2mlM2X+eO2Z8gribmiH4J9x5zu/JcTZ3deQSwiC5260BzizOc0eD1NWP3JuypGCKRwReicv4KCNIFtTZQ==", - "dev": true, - "requires": { - "chalk": "^2.0.1", - "chrome-launcher": "^0.11.2", - "chrome-remote-interface": "^0.28.0", - "chrome-unmirror": "^0.1.0", - "debug": "^4.1.1", - "deep-assign": "^3.0.0", - "import-local": "^2.0.0", - "loglevel": "^1.4.1", - "meow": "^5.0.0", - "nanobus": "^4.2.0" - } - }, "moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -46345,48 +44232,12 @@ "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "dev": true }, - "nanoassert": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz", - "integrity": "sha512-C40jQ3NzfkP53NsO8kEOFd79p4b9kDXQMwgiY1z8ZwrDZgUyom0AHwGegF4Dm99L+YoYhuaB0ceerUcXmqr1rQ==", - "dev": true - }, - "nanobus": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/nanobus/-/nanobus-4.5.0.tgz", - "integrity": "sha512-7sBZo9wthqNJ7QXnfVXZL7fkKJLN55GLOdX+RyZT34UOvxxnFtJe/c7K0ZRLAKOvaY1xJThFFn0Usw2H9R6Frg==", - "dev": true, - "requires": { - "nanoassert": "^1.1.0", - "nanotiming": "^7.2.0", - "remove-array-items": "^1.0.0" - } - }, "nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true }, - "nanoscheduler": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/nanoscheduler/-/nanoscheduler-1.0.3.tgz", - "integrity": "sha512-jBbrF3qdU9321r8n9X7yu18DjP31Do2ItJm3mWrt90wJTrnDO+HXpoV7ftaUglAtjgj9s+OaCxGufbvx6pvbEQ==", - "dev": true, - "requires": { - "nanoassert": "^1.1.0" - } - }, - "nanotiming": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/nanotiming/-/nanotiming-7.3.1.tgz", - "integrity": "sha512-l3lC7v/PfOuRWQa8vV29Jo6TG10wHtnthLElFXs4Te4Aas57Fo4n1Q8LH9n+NDh9riOzTVvb2QNBhTS4JUKNjw==", - "dev": true, - "requires": { - "nanoassert": "^1.1.0", - "nanoscheduler": "^1.0.2" - } - }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -46489,24 +44340,6 @@ } } }, - "node-environment-flags": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.6.tgz", - "integrity": "sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw==", - "dev": true, - "requires": { - "object.getownpropertydescriptors": "^2.0.3", - "semver": "^5.7.0" - }, - "dependencies": { - "semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true - } - } - }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -46744,21 +44577,6 @@ "es-object-atoms": "^1.0.0" } }, - "object.getownpropertydescriptors": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.8.tgz", - "integrity": "sha512-qkHIGe4q0lSYMv0XI4SsBTJz3WaURhLvd0lKSgtVuOsJ2krg4SgMw3PIRQFMp07yi++UR3se2mkcLqsBNpBb/A==", - "dev": true, - "requires": { - "array.prototype.reduce": "^1.0.6", - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "gopd": "^1.0.1", - "safe-array-concat": "^1.1.2" - } - }, "object.groupby": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", @@ -47345,12 +45163,6 @@ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, - "pify": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", - "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", - "dev": true - }, "pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -47860,12 +45672,6 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, - "quick-lru": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz", - "integrity": "sha512-tRS7sTgyxMXtLum8L65daJnHUhfDUgboRdcWW2bR9vBfrj2+O5HSMbQOJfJJjIVSPFqbBCF37FpwWXGitDc5tA==", - "dev": true - }, "quote-stream": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/quote-stream/-/quote-stream-1.0.2.tgz", @@ -48247,89 +46053,6 @@ "prop-types": "^15.6.2" } }, - "read-pkg": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", - "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", - "dev": true, - "requires": { - "load-json-file": "^4.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^3.0.0" - }, - "dependencies": { - "path-type": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", - "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", - "dev": true, - "requires": { - "pify": "^3.0.0" - } - } - } - }, - "read-pkg-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz", - "integrity": "sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==", - "dev": true, - "requires": { - "find-up": "^2.0.0", - "read-pkg": "^3.0.0" - }, - "dependencies": { - "find-up": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", - "integrity": "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==", - "dev": true, - "requires": { - "locate-path": "^2.0.0" - } - }, - "locate-path": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", - "integrity": "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==", - "dev": true, - "requires": { - "p-locate": "^2.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", - "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", - "dev": true, - "requires": { - "p-try": "^1.0.0" - } - }, - "p-locate": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", - "integrity": "sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==", - "dev": true, - "requires": { - "p-limit": "^1.1.0" - } - }, - "p-try": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", - "integrity": "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==", - "dev": true - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true - } - } - }, "readable-stream": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", @@ -48389,30 +46112,6 @@ "resolve": "^1.20.0" } }, - "redent": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-2.0.0.tgz", - "integrity": "sha512-XNwrTx77JQCEMXTeb8movBKuK75MgH0RZkujNuDKCezemx/voapl9i2gCSi8WWm8+ox5ycJi1gxF22fR7c0Ciw==", - "dev": true, - "requires": { - "indent-string": "^3.0.0", - "strip-indent": "^2.0.0" - }, - "dependencies": { - "indent-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz", - "integrity": "sha512-BYqTHXTGUIvg7t1r4sJNKcbDZkL92nkXA8YtRpbjFHRHGDL/NtUeiBJMeE60kIFN/Mg8ESaWQvftaYMGJzQZCQ==", - "dev": true - }, - "strip-indent": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-2.0.0.tgz", - "integrity": "sha512-RsSNPLpq6YUL7QYy44RnPVTn/lcVZtb48Uof3X5JLbF4zD/Gs7ZFDv2HWol+leoQN2mT86LAzSshGfkTlSOpsA==", - "dev": true - } - } - }, "redux": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", @@ -48555,12 +46254,6 @@ "unist-util-visit": "^2.0.0" } }, - "remove-array-items": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/remove-array-items/-/remove-array-items-1.1.1.tgz", - "integrity": "sha512-MXW/jtHyl5F1PZI7NbpS8SOtympdLuF20aoWJT5lELR1p/HJDd5nqW8Eu9uLh/hCRY3FgvrIT5AwDCgBODklcA==", - "dev": true - }, "renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -48732,12 +46425,6 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true }, - "require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, "require-relative": { "version": "0.8.7", "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", @@ -48772,23 +46459,6 @@ "supports-preserve-symlinks-flag": "^1.0.0" } }, - "resolve-cwd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-2.0.0.tgz", - "integrity": "sha512-ccu8zQTrzVr954472aUVPLEcB3YpKSYR3cg/3lo1okzobPBM+1INXBbBZlDbnI/hbEocnf8j0QVo43hQKrbchg==", - "dev": true, - "requires": { - "resolve-from": "^3.0.0" - }, - "dependencies": { - "resolve-from": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz", - "integrity": "sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==", - "dev": true - } - } - }, "resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -51232,12 +48902,6 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, - "trim-newlines": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-2.0.0.tgz", - "integrity": "sha512-MTBWv3jhVjTU7XR3IQHllbiJs8sc75a80OEhB6or/q7pLTWgQ0bMGQXXYQSrSuXe6WiKWDZ5txXY5P59a/coVA==", - "dev": true - }, "ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -52367,6 +50031,12 @@ } } }, + "whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true + }, "whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -52437,12 +50107,6 @@ "is-weakset": "^2.0.3" } }, - "which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true - }, "which-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", @@ -52456,48 +50120,6 @@ "has-tostringtag": "^1.0.2" } }, - "wide-align": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", - "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, - "requires": { - "string-width": "^1.0.2 || 2" - }, - "dependencies": { - "ansi-regex": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", - "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true - }, - "string-width": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", - "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, - "requires": { - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^4.0.0" - } - }, - "strip-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", - "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", - "dev": true, - "requires": { - "ansi-regex": "^3.0.0" - } - } - } - }, "wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -52618,13 +50240,6 @@ } } }, - "ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "requires": {} - }, "xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", @@ -52648,12 +50263,6 @@ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, - "y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true - }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -52665,139 +50274,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, - "yargs": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", - "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", - "dev": true, - "requires": { - "cliui": "^5.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^13.1.2" - }, - "dependencies": { - "ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true - }, - "emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true - }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, - "requires": { - "locate-path": "^3.0.0" - } - }, - "is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true - }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, - "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - } - }, - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, - "requires": { - "p-limit": "^2.0.0" - } - }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", - "dev": true - }, - "string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, - "requires": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" - } - }, - "strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, - "requires": { - "ansi-regex": "^4.1.0" - } - } - } - }, - "yargs-parser": { - "version": "13.1.2", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", - "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", - "dev": true, - "requires": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - } - }, - "yargs-unparser": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-1.6.0.tgz", - "integrity": "sha512-W9tKgmSn0DpSatfri0nx52Joq5hVXgeLiqR/5G0sZNDoLZFOr/xjBUDcShCOGNsBnEMNo1KAMBkTej1Hm62HTw==", - "dev": true, - "requires": { - "flat": "^4.1.0", - "lodash": "^4.17.15", - "yargs": "^13.3.0" - }, - "dependencies": { - "flat": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/flat/-/flat-4.1.1.tgz", - "integrity": "sha512-FmTtBsHskrU6FJ2VxCnsDb84wu9zhmO3cUX2kGFb5tuwhfXxGciiT0oRY+cck35QmG+NmGh5eLz6lLCpWTqwpA==", - "dev": true, - "requires": { - "is-buffer": "~2.0.3" - } - } - } - }, "yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index f499422045..d93907f11d 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "devDependencies": { "@babel/preset-env": "^7.22.5", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", + "@jest/create-cache-key-function": "^29.7.0", "@storybook/addon-a11y": "^7.0.24", "@storybook/addon-actions": "^7.0.24", "@storybook/addon-essentials": "^7.0.24", @@ -92,6 +93,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", "@types/chai": "^4.3.1", + "@types/coffeescript": "^2.5.7", "@types/gtag.js": "^0.0.12", "@types/jest": "^29.5.13", "@types/jquery": "^3.5.10", @@ -111,7 +113,6 @@ "@types/lodash.union": "^4.6.7", "@types/lodash.values": "^4.3.7", "@types/lodash.zip": "^4.2.7", - "@types/mocha": "^9.1.1", "@types/react": "^18.3.3", "@types/react-document-title": "^2.0.9", "@types/react-dom": "^18.3.0", @@ -145,8 +146,6 @@ "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "mocha": "^7.2.0", - "mocha-chrome": "^2.2.0", "moment": "^2.30.1", "patch-package": "^6.5.1", "postcss": "^8.4.39", @@ -175,7 +174,8 @@ "webpack-cli": "^5.1.4", "webpack-dev-middleware": "^6.1.3", "webpack-dev-server": "^4.15.2", - "webpack-extract-translation-keys-plugin": "^6.1.0" + "webpack-extract-translation-keys-plugin": "^6.1.0", + "whatwg-fetch": "^3.6.20" }, "overrides": { "reflux": { @@ -192,15 +192,16 @@ "@typescript-eslint/utils": "^6", "@typescript-eslint/rule-tester": "^6", "eslint": "$eslint" - } + }, + "coffee-script": "$coffeescript" }, "scripts": { "preinstall": "npm run hint", "postinstall": "patch-package && npm run copy-fonts", "build": "npm run hint build && webpack --config webpack/prod.config.js", "watch": "npm run hint watch && webpack-dev-server --config webpack/dev.server.js", - "test": "npm run hint test && webpack --config webpack/test.config.js && mocha-chrome test/tests.html", - "test-autobuild": "npm run hint test-autobuild && webpack --config webpack/test.autobuild.js", + "test": "jest --config ./jsapp/jest/unit.config.ts", + "test-watch": "jest --config ./jsapp/jest/unit.config.ts --watch", "lint": "eslint 'jsapp/js/**/*.{es6,js,jsx,ts,tsx}' --ext .es6,.js,.jsx,.ts,.tsx", "lint-coffee": "coffeelint -f ./coffeelint.json jsapp/xlform/src test", "lint-styles": "stylelint 'jsapp/**/*.{css,scss}' --ip jsapp/compiled --ip jsapp/fonts", diff --git a/patches/mocha-chrome+2.2.0.patch b/patches/mocha-chrome+2.2.0.patch deleted file mode 100644 index 147d8086a1..0000000000 --- a/patches/mocha-chrome+2.2.0.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/mocha-chrome/lib/client.js b/node_modules/mocha-chrome/lib/client.js -index 7629126..cd3d163 100644 ---- a/node_modules/mocha-chrome/lib/client.js -+++ b/node_modules/mocha-chrome/lib/client.js -@@ -9,7 +9,7 @@ module.exports = async function connectClient(instance, log, options) { - return fs.readFileSync(filePath, 'utf-8'); - } - -- const client = await CDP({ port: instance.port }); -+ const client = await CDP({ port: instance.port, host: '127.0.0.1' }); - const { DOM, DOMStorage, Console, Network, Page, Runtime } = client; - const mochaOptions = `window.mochaOptions = ${JSON.stringify(options.mocha)}`; - diff --git a/scripts/hints.js b/scripts/hints.js index da3a8e8b4c..20dc989397 100644 --- a/scripts/hints.js +++ b/scripts/hints.js @@ -28,15 +28,6 @@ const hints = { ${s.darkblue}Enjoy a quicker-launching dev server! `, - 'test-autobuild': ` - This will rebuild the js tests on change. - - Open ${s.underline}file://${process.cwd()}/test/tests.html${s.nounderline} - to see the test results in your browser. - - Reload the page to re-run the tests. - `, - SKIP_TS_CHECK: `${s.red} Skipping TypeScript check (${s.magenta}SKIP_TS_CHECK${s.red}) ${s.normal}`, diff --git a/test/helper/fauxChai.coffee b/test/helper/fauxChai.coffee index d75a34853a..157e52e127 100644 --- a/test/helper/fauxChai.coffee +++ b/test/helper/fauxChai.coffee @@ -6,26 +6,38 @@ module.exports = expect: (x)-> toBe: (y)-> chaiExpect(x).to.equal(y) + return toThrow: (e)-> chaiExpect(x).to.throw(e) + return toBeDefined: ()-> chaiExpect(x).not.to.be.a('undefined') + return toContain: (y)-> chaiExpect(x).to.contain(y) + return toEqual: (y)-> chaiExpect(x).eql(y) + return toBeTruthy: -> chaiExpect(x).to.be.ok + return toBeUndefined: -> chaiExpect(x).to.be.a('undefined') + return 'not': toEqual: (y)-> chaiExpect(x).to.not.eql(y) + return toBe: (y)-> chaiExpect(x).to.not.equal(y) + return toThrow: (e)-> chaiExpect(x).to.not.throw(e) + return toBeTruthy: -> chaiExpect(x).to.not.be.ok + return toBeDefined: -> chaiExpect(x).to.be.a('undefined') + return diff --git a/test/helper/phantomjs-shims.js b/test/helper/phantomjs-shims.js deleted file mode 100644 index a715cc529e..0000000000 --- a/test/helper/phantomjs-shims.js +++ /dev/null @@ -1,34 +0,0 @@ -(function () { - - var Ap = Array.prototype; - var slice = Ap.slice; - var Fp = Function.prototype; - - if (!Fp.bind) { - // PhantomJS doesn't support Function.prototype.bind natively, so - // polyfill it whenever this module is required. - Fp.bind = function (context) { - var func = this; - var args = slice.call(arguments, 1); - - function bound() { - var invokedAsConstructor = func.prototype && (this instanceof func); - return func.apply( - // Ignore the context parameter when invoking the bound function - // as a constructor. Note that this includes not only constructor - // invocations using the new keyword but also calls to base class - // constructors such as BaseClass.call(this, ...) or super(...). - !invokedAsConstructor && context || this, - args.concat(slice.call(arguments)) - ); - } - - // The bound function must share the .prototype of the unbound - // function so that any object created by one constructor will count - // as an instance of both constructors. - bound.prototype = func.prototype; - - return bound; - }; - } -})(); diff --git a/test/index.js b/test/index.js deleted file mode 100644 index cbf8919300..0000000000 --- a/test/index.js +++ /dev/null @@ -1,31 +0,0 @@ -var chai = require('chai'); -var expect = chai.expect; - -window.jQuery = window.$ = require('jquery'); -require('jquery-ui/ui/widgets/sortable'); - -require('./xlform/aliases.tests'); -require('./xlform/choices.tests'); -require('./xlform/csv.tests'); -require('./xlform/deserializer.tests'); -require('./xlform/group.tests'); -require('./xlform/inputParser.tests'); -require('./xlform/translations.tests'); -// require('./xlform/integration.tests'); -require('./xlform/model.tests'); -require('./xlform/survey.tests'); -require('./xlform/utils.tests'); - -require('../jsapp/js/utils.tests'); -require('../jsapp/js/components/permissions/permParser.tests'); -require('../jsapp/js/components/permissions/utils.tests'); -require('../jsapp/js/components/permissions/userAssetPermsEditor.tests'); -require('../jsapp/js/components/formBuilder/formBuilderUtils.tests'); -require('../jsapp/js/assetUtils.tests'); -require('../jsapp/js/components/locking/lockingUtils.tests'); -require('../jsapp/js/components/submissions/submissionUtils.tests'); -require('../jsapp/js/projects/customViewStore.tests') -require('../jsapp/js/projects/projectViews/utils.tests'); -require('../jsapp/js/components/processing/processingUtils.tests'); -require('../jsapp/js/components/processing/routes.utils.tests'); -require('../jsapp/js/components/submissions/tableUtils.tests'); diff --git a/test/tests.html b/test/tests.html deleted file mode 100644 index d4556841a5..0000000000 --- a/test/tests.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - KPI Tests - - - - - - - -
    - - - - - diff --git a/test/xlform/group.tests.coffee b/test/xlform/group.tests.coffee index 6b295cf231..05e22c97b0 100644 --- a/test/xlform/group.tests.coffee +++ b/test/xlform/group.tests.coffee @@ -244,6 +244,7 @@ do -> ,end group,, """ ### + return describe 'group creation', -> beforeEach -> diff --git a/test/xlform/inputParser.tests.coffee b/test/xlform/inputParser.tests.coffee index b1eaf860be..bb4b700db6 100644 --- a/test/xlform/inputParser.tests.coffee +++ b/test/xlform/inputParser.tests.coffee @@ -20,6 +20,7 @@ do -> describe '. loadChoiceLists()"', -> list = new $choices.ChoiceList() $inputParser.loadChoiceLists($surveys.pizza_survey.main().choices, list) + return describe '. parse()"', -> describe ' translated surveys', -> @@ -62,6 +63,7 @@ do -> ] for i in [0, 1] expect(results[i]).toEqual(expected[i]) + return it 'parses group hierarchy', -> results = $inputParser.parseArr('survey', [ diff --git a/test/xlform/model.tests.coffee b/test/xlform/model.tests.coffee index 1819c72c60..e3fc57a7a2 100644 --- a/test/xlform/model.tests.coffee +++ b/test/xlform/model.tests.coffee @@ -42,6 +42,7 @@ xlform_survey_model = ($model)-> it "ensures every node has access to the parent survey", -> @pizzaSurvey.getSurvey + return it "can append a survey to another", -> dead_simple = @createSurvey(['text,q1,Question1,q1hint', 'text,q2,Question2,q2hint']) @@ -325,6 +326,7 @@ xlform_survey_model = ($model)-> survey_kuids = _as_json.survey.map((r)=>r['$kuid']) for kuid in survey_kuids expect(kuid).toBeDefined() + return describe "automatic naming", -> it "can import questions without names", -> diff --git a/test/xlform/utils.tests.coffee b/test/xlform/utils.tests.coffee index e23f49f70c..268e63e498 100644 --- a/test/xlform/utils.tests.coffee +++ b/test/xlform/utils.tests.coffee @@ -1,5 +1,6 @@ {expect} = require('../helper/fauxChai') $utils = require("../../jsapp/xlform/src/model.utils") +_ = require('underscore') pasted = [ ["list_name", "name", "label", "state", "county"], @@ -126,7 +127,8 @@ do -> splitted = $utils.split_paste(pasted) expect(splitted.length).toEqual(expectation.length) for i in [0..splitted.length] - _eqKeyVals(splitted[i], expectation[i]) + _eqKeyVals(splitted[i], expectation[i]) + return describe 'sluggify', -> it 'lowerCases: true', -> @@ -142,6 +144,7 @@ do -> ] for str in valid_xml expect($utils.isValidXmlTag(str)).toBeTruthy() + return it 'isValidXmlTag fails with invalid strings', -> invalid_xml = [ '1xyz', @@ -150,6 +153,7 @@ do -> ] for str in invalid_xml expect($utils.isValidXmlTag(str)).not.toBeTruthy() + return it 'handles a number of strings consistenly', -> inp_exps = [ @@ -164,4 +168,5 @@ do -> [str, additionals] = inps _out = $utils.sluggifyLabel(str, additionals) expect(_out).toBe(exps) + return diff --git a/tsconfig.json b/tsconfig.json index b9f6f06ade..01e7baeb20 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ }, "target": "es2017", "module": "es2020", + "lib": ["ES2021"], "esModuleInterop": true, "moduleResolution": "node", "strict": true, diff --git a/webpack/dev.server.js b/webpack/dev.server.js index 27f2c074ce..884324cb16 100644 --- a/webpack/dev.server.js +++ b/webpack/dev.server.js @@ -27,7 +27,6 @@ let devConfig = WebpackCommon({ }, entry: { app: ['./jsapp/js/main.es6'], - browsertests: path.resolve(__dirname, '../test/index.js'), }, output: { library: 'KPI', diff --git a/webpack/prod.config.js b/webpack/prod.config.js index 5a4002e656..cffd976ec3 100644 --- a/webpack/prod.config.js +++ b/webpack/prod.config.js @@ -30,7 +30,6 @@ const prodConfig = WebpackCommon({ }, entry: { app: './jsapp/js/main.es6', - browsertests: path.resolve(__dirname, '../test/index.js'), }, output: { path: path.resolve(__dirname, '../jsapp/compiled/'), diff --git a/webpack/test.autobuild.js b/webpack/test.autobuild.js deleted file mode 100644 index 2e17e977cf..0000000000 --- a/webpack/test.autobuild.js +++ /dev/null @@ -1,16 +0,0 @@ -const testConfig = require('./test.config'); - -// Auto-builds tests when the tests or js files change. -// Doesn't re-run tests or auto-reload any pages. - -// It's useful for visiting file://{/path/to/kpi}/test/tests.html -// to troubleshoot failing tests (refresh to re-run). - -// You can use interactive browser console to inspect logged objects. -// example: console.log(expected, actual) in a test file. - -module.exports = { - ...testConfig, - watch: true, - stats: {}, // un-hide output from test.config.js -}; diff --git a/webpack/test.config.js b/webpack/test.config.js deleted file mode 100644 index 984b6049e6..0000000000 --- a/webpack/test.config.js +++ /dev/null @@ -1,28 +0,0 @@ -const path = require('path'); -const WebpackCommon = require('./webpack.common'); - -const testConfig = WebpackCommon({ - mode: 'development', - entry: path.resolve(__dirname, '../test/index.js'), - output: { - library: 'tests', - path: path.resolve(__dirname, '../test/compiled/'), - filename: 'webpack-built-tests.js', - }, - // mainly for hiding stylelint output - stats: { - all: false, - modulesSpace: 0, - errors: true, - errorDetails: true, - }, -}); - -// Print speed measurements if env variable MEASURE_WEBPACK_PLUGIN_SPEED is set -if (process.env.MEASURE_WEBPACK_PLUGIN_SPEED) { - const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); - const smp = new SpeedMeasurePlugin(); - module.exports = smp.wrap(testConfig); -} else { - module.exports = testConfig; -}