diff --git a/Dockerfile b/Dockerfile index 95392965e9..373946c656 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ ENV KPI_LOGS_DIR=/srv/logs \ NGINX_STATIC_DIR=/srv/static \ KPI_SRC_DIR=/srv/src/kpi \ KPI_MEDIA_DIR=/srv/src/kpi/media \ + OPENROSA_MEDIA_DIR=/srv/src/kobocat/media \ KPI_NODE_PATH=/srv/src/kpi/node_modules \ TMP_DIR=/srv/tmp \ UWSGI_USER=kobo \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index faa90c7204..e2f181bb4a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -79,10 +79,19 @@ fi echo 'Restore permissions on logs folder' chown -R "${UWSGI_USER}:${UWSGI_GROUP}" "${KPI_LOGS_DIR}" -# This can take a while when starting a container with lots of media files. -# Maybe we should add a disclaimer as we do in KoBoCAT to let the users -# do it themselves -chown -R "${UWSGI_USER}:${UWSGI_GROUP}" "${KPI_MEDIA_DIR}" +# `chown -R` becomes very slow once a fair amount of media has been collected, +# so reset ownership of the media directory *only* (i.e., non-recursive) +echo 'Resetting ownership of media directories...' +chown "${UWSGI_USER}:${UWSGI_GROUP}" "${KPI_MEDIA_DIR}" +chown "${UWSGI_USER}:${UWSGI_GROUP}" "${OPENROSA_MEDIA_DIR}" +echo 'Done.' +echo '%%%%%%% NOTICE %%%%%%%' +echo '% To avoid long delays, we no longer reset ownership *recursively*' +echo '% every time this container starts. If you have trouble with' +echo '% permissions, please run the following command inside the KPI container:' +echo "% chown -R \"${UWSGI_USER}:${UWSGI_GROUP}\" \"${KPI_MEDIA_DIR}\"" +echo "% chown -R \"${UWSGI_USER}:${UWSGI_GROUP}\" \"${OPENROSA_MEDIA_DIR}\"" +echo '%%%%%%%%%%%%%%%%%%%%%%' echo 'KPI initialization completed.' diff --git a/jsapp/js/account/accountSettings.scss b/jsapp/js/account/accountSettings.scss index dc5306a24b..de1e44dc56 100644 --- a/jsapp/js/account/accountSettings.scss +++ b/jsapp/js/account/accountSettings.scss @@ -39,18 +39,6 @@ .form-modal__item--username { min-height: 32px; padding-left: sizes.$x50; - - .account-box__initials { - display: inline-block; - vertical-align: middle; - margin-right: 15px; - } - - h4 { - display: inline-block; - vertical-align: middle; - font-size: 16px; - } } } diff --git a/jsapp/js/account/accountSettingsRoute.tsx b/jsapp/js/account/accountSettingsRoute.tsx index ca97f40000..91e7b6ea69 100644 --- a/jsapp/js/account/accountSettingsRoute.tsx +++ b/jsapp/js/account/accountSettingsRoute.tsx @@ -7,10 +7,10 @@ import {unstable_usePrompt as usePrompt} from 'react-router-dom'; import bem, {makeBem} from 'js/bem'; import sessionStore from 'js/stores/session'; import './accountSettings.scss'; -import {notify, stringToColor} from 'js/utils'; +import {notify} from 'js/utils'; import {dataInterface} from '../dataInterface'; import AccountFieldsEditor from './accountFieldsEditor.component'; -import Icon from 'js/components/common/icon'; +import Avatar from 'js/components/common/avatar'; import envStore from 'js/envStore'; import { getInitialAccountFieldsValues, @@ -134,9 +134,6 @@ const AccountSettings = observer(() => { }; const accountName = sessionStore.currentAccount.username; - const initialsStyle = { - background: `#${stringToColor(accountName)}`, - }; return ( @@ -152,11 +149,7 @@ const AccountSettings = observer(() => { - - {accountName.charAt(0)} - - -

{accountName}

+
{sessionStore.isInitialLoadComplete && form.isUserDataLoaded && ( diff --git a/jsapp/js/account/changePasswordRoute.component.tsx b/jsapp/js/account/changePasswordRoute.component.tsx index a8e8171351..0fdf9fb853 100644 --- a/jsapp/js/account/changePasswordRoute.component.tsx +++ b/jsapp/js/account/changePasswordRoute.component.tsx @@ -3,13 +3,13 @@ import DocumentTitle from 'react-document-title'; import {observer} from 'mobx-react'; import sessionStore from 'js/stores/session'; import bem, {makeBem} from 'js/bem'; -import {stringToColor} from 'js/utils'; import {withRouter} from 'js/router/legacy'; import type {WithRouterProps} from 'jsapp/js/router/legacy'; import './accountSettings.scss'; import styles from './changePasswordRoute.module.scss'; import UpdatePasswordForm from './security/password/updatePasswordForm.component'; import Button from 'js/components/common/button'; +import Avatar from 'js/components/common/avatar'; bem.AccountSettings = makeBem(null, 'account-settings'); bem.AccountSettings__left = makeBem(bem.AccountSettings, 'left'); @@ -28,7 +28,6 @@ const ChangePasswordRoute = class ChangePassword extends React.Component @@ -44,10 +43,7 @@ const ChangePasswordRoute = class ChangePassword extends React.Component - - {accountName.charAt(0)} - -

{accountName}

+
diff --git a/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx b/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx index 096f0f6b0d..eeaed7cde3 100644 --- a/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx +++ b/jsapp/js/account/security/accessLogs/accessLogsSection.component.tsx @@ -2,21 +2,21 @@ import React from 'react'; // Partial components -import Button from 'js/components/common/button'; +// import Button from 'js/components/common/button'; import PaginatedQueryUniversalTable from 'js/universalTable/paginatedQueryUniversalTable.component'; // Utilities import useAccessLogsQuery, {type AccessLog} from 'js/query/queries/accessLogs.query'; import {formatTime} from 'js/utils'; -import sessionStore from 'js/stores/session'; +// import sessionStore from 'js/stores/session'; // Styles import securityStyles from 'js/account/security/securityRoute.module.scss'; export default function AccessLogsSection() { - function logOutAllSessions() { - sessionStore.logOutAll(); - } + // function logOutAllSessions() { + // sessionStore.logOutAll(); + // } return ( <> @@ -43,11 +43,21 @@ export default function AccessLogsSection() { columns={[ // The `key`s of these columns are matching the `AccessLog` interface // properties (from `accessLogs.query.ts` file) using dot notation. - {key: 'metadata.source', label: t('Source')}, + { + key: 'metadata.source', + label: t('Source'), + cellFormatter: (log: AccessLog) => { + if (log.metadata.auth_type === 'submission-group') { + return t('Data Submissions (##count##)').replace('##count##', String(log.count)); + } else { + return log.metadata.source; + } + }, + }, { key: 'date_created', label: t('Last activity'), - cellFormatter: (date: string) => formatTime(date), + cellFormatter: (log: AccessLog) => formatTime(log.date_created), }, {key: 'metadata.ip_address', label: t('IP Address')}, ]} diff --git a/jsapp/js/bem.ts b/jsapp/js/bem.ts index 56a53623eb..d6ca07e275 100644 --- a/jsapp/js/bem.ts +++ b/jsapp/js/bem.ts @@ -15,6 +15,8 @@ interface bemInstances { /** * Container for holding all BEM definitions. + * + * @deprecated Use CSS Modules and regular HTML tags. */ const bem: bemInstances = {} @@ -45,6 +47,8 @@ interface BemInstance extends React.ComponentClass { * For first parameter pass `null` to create a Block component, * or pass existing Block component to create Element component (a child). * Please no angle brackets for `htmlTagName`. + * + * @deprecated Use CSS Modules and regular HTML tags. */ export function makeBem( parent: BemInstance | null, diff --git a/jsapp/js/bemComponents.ts b/jsapp/js/bemComponents.ts index dc1045a95d..1d31b80773 100644 --- a/jsapp/js/bemComponents.ts +++ b/jsapp/js/bemComponents.ts @@ -151,7 +151,6 @@ bem.LoginBox = makeBem(null, 'login-box'); bem.AccountBox = makeBem(null, 'account-box'); bem.AccountBox__name = makeBem(bem.AccountBox, 'name', 'div'); -bem.AccountBox__initials = makeBem(bem.AccountBox, 'initials', 'span'); bem.AccountBox__menu = makeBem(bem.AccountBox, 'menu', 'ul'); bem.AccountBox__menuLI = makeBem(bem.AccountBox, 'menu-li', 'li'); bem.AccountBox__menuItem = makeBem(bem.AccountBox, 'menu-item', 'div'); diff --git a/jsapp/js/components/assetsTable/assetsTable.tsx b/jsapp/js/components/assetsTable/assetsTable.tsx index 7f27043faf..2efa48e809 100644 --- a/jsapp/js/components/assetsTable/assetsTable.tsx +++ b/jsapp/js/components/assetsTable/assetsTable.tsx @@ -84,13 +84,17 @@ interface AssetsTableState { } /** - * DEPRECATED-ish (see below) * Displays a table of assets. This old-ish component is handling three routes: * - My Library * - Public Collections * - Single Collection * The new and shiny component that handles Projects List is `ProjectsTable`, * and in the future it should become (if possible) the only one to be used. + * + * @deprecated There is no clear replacement as of yet. We have a better + * `ProjectsTable` component, but ultimately wa are aiming at using + * `react-table` more. See `UniversalTable` for the possible "final" way of + * handling tables. */ export default class AssetsTable extends React.Component< AssetsTableProps, diff --git a/jsapp/js/components/common/avatar.module.scss b/jsapp/js/components/common/avatar.module.scss index 6edff6476b..8df8599844 100644 --- a/jsapp/js/components/common/avatar.module.scss +++ b/jsapp/js/components/common/avatar.module.scss @@ -2,25 +2,45 @@ @use 'scss/colors'; @use 'scss/mixins'; -.root { +$avatar-size-s: 24px; +$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; + + .initials { + width: $size; + height: $size; + border-radius: $size; + line-height: $size; + font-size: $size * 0.6; + } +} + +.avatar { @include mixins.centerRowFlex; } +.avatar-size-s { + @include avatar-size($avatar-size-s); +} + +.avatar-size-m { + @include avatar-size($avatar-size-m); +} + +.avatar-size-l { + @include avatar-size($avatar-size-l); +} + .initials { - width: sizes.$x20; text-align: center; text-transform: uppercase; - padding: 0 sizes.$x6; - border-radius: sizes.$x20; display: inline-block; vertical-align: middle; - line-height: sizes.$x20; - font-size: sizes.$x12; color: colors.$kobo-white; // actual background color is provided by JS, this is just safeguard background-color: colors.$kobo-storm; - - &:not(:last-child) { - margin-right: sizes.$x5; - } } diff --git a/jsapp/js/components/common/avatar.stories.tsx b/jsapp/js/components/common/avatar.stories.tsx new file mode 100644 index 0000000000..8896581e4e --- /dev/null +++ b/jsapp/js/components/common/avatar.stories.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type {ComponentStory, ComponentMeta} from '@storybook/react'; + +import Avatar from './avatar'; +import type {AvatarSize} from './avatar'; + +const avatarSizes: AvatarSize[] = ['s', 'm', 'l']; + +export default { + title: 'common/Avatar', + component: Avatar, + argTypes: { + username: {type: 'string'}, + size: { + options: avatarSizes, + control: {type: 'select'}, + }, + isUsernameVisible: {type: 'boolean'}, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Primary = Template.bind({}); +Primary.args = { + username: 'Leszek', + size: avatarSizes[0], + isUsernameVisible: true, +}; + +// 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) +'Alfa', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel', +'India', 'Juliett', 'Kilo', 'Lima', 'Mike', 'November', 'Oscar', 'Papa', +'Quebec', 'Romeo', 'Sierra', 'Tango', 'Uniform', 'Victor', 'Whiskey', 'Xray', +'Yankee', 'Zulu', +// Top 100 most popular names in the world (https://forebears.io/earth/forenames) +'Maria', 'Nushi', 'Mohammed', 'Jose', 'Muhammad', 'Mohamed', 'Wei', 'Mohammad', +'Ahmed', 'Yan', 'Ali', 'John', 'David', 'Li', 'Abdul', 'Ana', 'Ying', 'Michael', +'Juan', 'Anna', 'Mary', 'Jean', 'Robert', 'Daniel', 'Luis', 'Carlos', 'James', +'Antonio', 'Joseph', 'Hui', 'Elena', 'Francisco', 'Hong', 'Marie', 'Min', 'Lei', +'Yu', 'Ibrahim', 'Peter', 'Fatima', 'Aleksandr', 'Richard', 'Xin', 'Bin', +'Paul', 'Ping', 'Lin', 'Olga', 'Sri', 'Pedro', 'William', 'Rosa', 'Thomas', +'Jorge', 'Yong', 'Elizabeth', 'Sergey', 'Ram', 'Patricia', 'Hassan', 'Anita', +'Manuel', 'Victor', 'Sandra', 'Ming', 'Siti', 'Miguel', 'Emmanuel', 'Samuel', +'Ling', 'Charles', 'Sarah', 'Mario', 'Joao', 'Tatyana', 'Mark', 'Rita', +'Martin', 'Svetlana', 'Patrick', 'Natalya', 'Qing', 'Ahmad', 'Martha', 'Andrey', +'Sunita', 'Andrea', 'Christine', 'Irina', 'Laura', 'Linda', 'Marina', 'Carmen', +'Ghulam', 'Vladimir', 'Barbara', 'Angela', 'George', 'Roberto', 'Peng', +]; +export const BulkTest = () => ( +
+ {bulkUsernames.map((username) => ( +
+ +
+ ))} +
+); diff --git a/jsapp/js/components/common/avatar.tsx b/jsapp/js/components/common/avatar.tsx index 45c6164bae..827ef16fbd 100644 --- a/jsapp/js/components/common/avatar.tsx +++ b/jsapp/js/components/common/avatar.tsx @@ -1,19 +1,48 @@ -import {stringToColor} from 'jsapp/js/utils'; import React from 'react'; +import cx from 'classnames'; import styles from './avatar.module.scss'; +export type AvatarSize = 'l' | 'm' | 's'; + +/** + * A simple function that generates hsl color from given string. Saturation and + * lightness is not random, just the hue. + */ +function stringToHSL(string: string, saturation: number, lightness: number) { + let hash = 0; + for (let i = 0; i < string.length; i++) { + hash = string.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + return `hsl(${(hash % 360)}, ${saturation}%, ${lightness}%)`; +} + interface AvatarProps { + /** + * First letter of the username would be used as avatar. Whole username would + * be used to generate the color of the avatar. + */ username: string; + /** + * Username is not being displayed by default. + */ + isUsernameVisible?: boolean; + size: AvatarSize; } export default function Avatar(props: AvatarProps) { return ( -
-
+
+
{props.username.charAt(0)}
- + {props.isUsernameVisible && + + }
); } diff --git a/jsapp/js/components/common/modal.scss b/jsapp/js/components/common/modal.scss index afe422b619..5a1a2576c9 100644 --- a/jsapp/js/components/common/modal.scss +++ b/jsapp/js/components/common/modal.scss @@ -337,6 +337,9 @@ $modal-custom-header-height: sizes.$x60; cursor: pointer; } +// TODO: This class has been removed from the actual ProjectSettings component, +// but this rule is still used in a few places. We should remove it once we have +// switched them to style modules. .project-settings { // make sure it doesn't get too small (but only inside modal) .modal & { @@ -437,16 +440,6 @@ $modal-custom-header-height: sizes.$x60; } } -@media screen and (min-height: 600px) { - // on bigger screens make templates-list scrollable to always display back/next buttons - .project-settings.project-settings--choose-template { - .templates-list { - max-height: 380px; - overflow-y: auto; - } - } -} - .form-modal__item--http-headers { .form-modal__item--http-header-row { @include mixins.centerRowFlex; diff --git a/jsapp/js/components/common/modal.tsx b/jsapp/js/components/common/modal.tsx index 81f409d876..a48f6323eb 100644 --- a/jsapp/js/components/common/modal.tsx +++ b/jsapp/js/components/common/modal.tsx @@ -43,9 +43,9 @@ interface ModalProps { } /** - * DEPRECATED: please use `KoboModal` - * * A generic modal component. + * + * @deprecated Please use `KoboModal`. */ export default class Modal extends React.Component { static Footer = Footer; diff --git a/jsapp/js/components/formSummary.js b/jsapp/js/components/formSummary.js index daa01d5c1c..4d108eef41 100644 --- a/jsapp/js/components/formSummary.js +++ b/jsapp/js/components/formSummary.js @@ -7,10 +7,8 @@ import mixins from 'js/mixins'; import bem from 'js/bem'; import DocumentTitle from 'react-document-title'; import Icon from 'js/components/common/icon'; +import Avatar from 'js/components/common/avatar'; import {getFormDataTabs} from './formViewSideTabs'; -import { - stringToColor, -} from 'utils'; import {getUsernameFromUrl, ANON_USERNAME} from 'js/users/utils'; import {MODAL_TYPES} from 'js/constants'; import './formSummary.scss'; @@ -154,13 +152,12 @@ class FormSummary extends React.Component { } { team.map((username, ind) => - - - - {username.charAt(0)} - - - + )} diff --git a/jsapp/js/components/formSummary.scss b/jsapp/js/components/formSummary.scss index fe47ff7186..006ba16f0f 100644 --- a/jsapp/js/components/formSummary.scss +++ b/jsapp/js/components/formSummary.scss @@ -120,6 +120,7 @@ display: flex; justify-content: flex-start; flex-wrap: wrap; + gap: 10px; } .user-row { diff --git a/jsapp/js/components/formSummaryProjectInfo.tsx b/jsapp/js/components/formSummaryProjectInfo.tsx index 2c77e2f298..c1fbb427e0 100644 --- a/jsapp/js/components/formSummaryProjectInfo.tsx +++ b/jsapp/js/components/formSummaryProjectInfo.tsx @@ -98,7 +98,11 @@ export default function FormSummaryProjectInfo( {t('Owner')} {isSelfOwned(props.asset) && t('me')} {!isSelfOwned(props.asset) && ( - + )} diff --git a/jsapp/js/components/header/accountMenu.tsx b/jsapp/js/components/header/accountMenu.tsx index 3a0f2c94e7..6963459bc1 100644 --- a/jsapp/js/components/header/accountMenu.tsx +++ b/jsapp/js/components/header/accountMenu.tsx @@ -3,7 +3,7 @@ import {useNavigate} from 'react-router-dom'; import PopoverMenu from 'js/popoverMenu'; import sessionStore from 'js/stores/session'; import bem from 'js/bem'; -import {currentLang, stringToColor} from 'js/utils'; +import {currentLang} from 'js/utils'; import envStore from 'js/envStore'; import type {LabelValuePair} from 'js/dataInterface'; import {dataInterface} from 'js/dataInterface'; @@ -11,6 +11,7 @@ import {actions} from 'js/actions'; import {ACCOUNT_ROUTES} from 'js/account/routes.constants'; import {isAnyRouteBlockerActive} from 'js/router/routerUtils'; import Button from 'js/components/common/button'; +import Avatar from 'js/components/common/avatar'; /** * UI element that display things only for logged-in user. An avatar that gives @@ -78,20 +79,16 @@ export default function AccountMenu() { ? sessionStore.currentAccount.email : ''; - const initialsStyle = {background: `#${stringToColor(accountName)}`}; - const accountMenuLabel = ( - - {accountName.charAt(0)} - - ); - return ( - + } + > - {accountMenuLabel} + diff --git a/jsapp/js/components/modalForms/projectSettings.es6 b/jsapp/js/components/modalForms/projectSettings.es6 index d7ac1f4973..9c1be451b2 100644 --- a/jsapp/js/components/modalForms/projectSettings.es6 +++ b/jsapp/js/components/modalForms/projectSettings.es6 @@ -8,7 +8,8 @@ import Button from 'js/components/common/button'; import clonedeep from 'lodash.clonedeep'; import TextBox from 'js/components/common/textBox'; import WrappedSelect from 'js/components/common/wrappedSelect'; -import bem from 'js/bem'; +import cx from 'classnames'; +import styles from 'js/components/modalForms/projectSettings.module.scss'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import InlineMessage from 'js/components/common/inlineMessage'; import assetUtils from 'js/assetUtils'; @@ -46,7 +47,6 @@ const VIA_URL_SUPPORT_URL = 'xls_url.html'; * 1. When creating new project * 2. When replacing project with new one * 3. When editing project in /settings - * 4. When editing or creating asset in Form Builder * * Identifying the purpose is done by checking `context` and `formAsset`. * @@ -157,7 +157,6 @@ class ProjectSettings extends React.Component { case PROJECT_SETTINGS_CONTEXTS.REPLACE: return this.displayStep(this.STEPS.FORM_SOURCE); case PROJECT_SETTINGS_CONTEXTS.EXISTING: - case PROJECT_SETTINGS_CONTEXTS.BUILDER: return this.displayStep(this.STEPS.PROJECT_DETAILS); default: throw new Error(`Unknown context: ${this.props.context}!`); @@ -171,7 +170,6 @@ class ProjectSettings extends React.Component { case PROJECT_SETTINGS_CONTEXTS.REPLACE: return t('Replace form'); case PROJECT_SETTINGS_CONTEXTS.EXISTING: - case PROJECT_SETTINGS_CONTEXTS.BUILDER: default: return t('Project settings'); } @@ -720,6 +718,10 @@ class ProjectSettings extends React.Component { return label; } + checkModalStyle() { + return this.props.context !== PROJECT_SETTINGS_CONTEXTS.EXISTING ? styles.modal : null; + } + renderChooseTemplateButton() { return (
+ ); } renderStepChooseTemplate() { return ( - +
- +
{this.renderBackButton()}
+ ); } renderStepUploadFile() { return ( - - +
+
{t('Import an XLSForm from your computer.')} - +
{!this.state.isUploadFilePending && } - +
{this.renderBackButton()} - - +
+ ); } renderStepImportUrl() { return ( - -
+
+
{t('Enter a valid XLSForm URL in the field below.')}
{ envStore.isReady && @@ -835,7 +837,7 @@ class ProjectSettings extends React.Component { }
- +
- +
- +
{this.renderBackButton()}
+ ); } @@ -874,17 +876,16 @@ class ProjectSettings extends React.Component { const descriptionField = envStore.data.getProjectMetadataField('description'); return ( - {this.props.context === PROJECT_SETTINGS_CONTEXTS.EXISTING && - +
} - - {/* Project Name */} - - {/* form builder displays name in different place */} - {this.props.context !== PROJECT_SETTINGS_CONTEXTS.BUILDER && - - - - } +
+ {/* Project Name */} +
+ +
{/* Description */} {descriptionField && - +
- +
} {/* Sector */} {sectorField && - +
- +
} {/* Country */} {countryField && - +
- +
} {/* Operational Purpose of Data */} {operationalPurposeField && - +
- +
} {/* Does this project collect personally identifiable information? */} {collectsPiiField && - +
- +
} {(this.props.context === PROJECT_SETTINGS_CONTEXTS.NEW || this.props.context === PROJECT_SETTINGS_CONTEXTS.REPLACE) && - +
{/* Don't allow going back if asset already exist */} {!this.state.formAsset && this.renderBackButton() @@ -1014,12 +1021,12 @@ class ProjectSettings extends React.Component { )} /> - +
} {userCan('manage_asset', this.state.formAsset) && this.props.context === PROJECT_SETTINGS_CONTEXTS.EXISTING && - - +
+
{this.isArchived() &&
{this.isArchivable() && - +
{t('Archive project to stop accepting submissions.')} - +
} {this.isArchived() && - +
{t('Unarchive project to resume accepting submissions.')} - +
} -
+
} {isSelfOwned && this.props.context === PROJECT_SETTINGS_CONTEXTS.EXISTING && - +
} -
- +
+ ); } diff --git a/jsapp/js/components/modalForms/projectSettings.module.scss b/jsapp/js/components/modalForms/projectSettings.module.scss new file mode 100644 index 0000000000..b98621a4dd --- /dev/null +++ b/jsapp/js/components/modalForms/projectSettings.module.scss @@ -0,0 +1,140 @@ +@use 'scss/colors'; +@use 'scss/breakpoints'; + +.uploadInstructions { + margin-bottom: 20px; + text-align: initial; +} + +.modal { + width: 100%; +} + +.projectDetailsView { + margin-bottom: 30px; +} + +.projectDetails .inputWrapper { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: flex-end; + column-gap: 4%; + .input { + min-width: 75%; // Most rows are wide enough to get their own row, + flex: 1; // and when they do, they grow to occupy the full available width + } + .sector, + .country { + min-width: 40%; // These fields can sit next to each other in pairs. + } +} + +.input { + &:not(:last-child) { + margin-bottom: 15px; + } + + textarea { + overflow: hidden; + resize: none; + height: auto; + } +} + +.inputInline { + display: inline-block; + &:not(:last-child) { + margin-bottom: 0; + margin-right: 20px; + } +} + +.modalSubheader { + background: colors.$kobo-gray-200; + padding: 20px 30px; + margin: -30px -30px 20px; + color: colors.$kobo-gray-700; + + i { + margin: 5px 10px 10px 3px; + font-size: 24px; + float: left; + } +} + +$buttons-spacing: 10px; + +.sourceButtons { + margin: 0 auto; + max-width: 500px; + + button { + display: inline-block; + vertical-align: top; + border: none; + background: colors.$kobo-gray-200; + border-radius: 6px; + color: colors.$kobo-gray-700; + cursor: pointer; + margin: 0.5 * $buttons-spacing; + padding: $buttons-spacing; + width: calc(50% - #{$buttons-spacing}); + min-height: 120px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.25); + + i { + display: block; + margin: 3px auto; + font-size: 34px; + color: currentColor; + } + + &:hover { + color: colors.$kobo-gray-800; + background-color: colors.$kobo-gray-300; + } + + &:active { + // makes the shadow smaller and moves button down by small bit + // to make it look pressed-in + transform: translateY(1px); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.25); + } + } +} + +.modalFooter { + margin-top: 20px; + text-align: right; + display: flex; + justify-content: flex-end; + width: 100%; + gap: 10px; +} + +.saveChanges { + text-align: right; + margin-bottom: 20px; +} + +@media screen and (min-width: breakpoints.$b768) { + .modal { + min-width: 600px; + max-width: 750px; + } + + .sourceButtons button { + margin: $buttons-spacing; + padding: 2 * $buttons-spacing; + width: calc(50% - #{2 * $buttons-spacing}); + } +} + +@media screen and (min-height: 600px) { + // on bigger screens make templates-list scrollable to always display back/next buttons + .chooseTemplate:first-child { + max-height: 380px; + overflow-y: auto; + } +} diff --git a/jsapp/js/components/permissions/sharingForm.scss b/jsapp/js/components/permissions/sharingForm.scss index fb12b98684..d91e85162d 100644 --- a/jsapp/js/components/permissions/sharingForm.scss +++ b/jsapp/js/components/permissions/sharingForm.scss @@ -94,11 +94,7 @@ $s-gray-row-spacing: 10px; .user-row__avatar { margin-right: 12px; - } - - .user-row__name { flex: 2; - line-height: 2.2em; } .user-row__perms { diff --git a/jsapp/js/components/permissions/userPermissionRow.component.tsx b/jsapp/js/components/permissions/userPermissionRow.component.tsx index 281d3bb391..6d81cf34ca 100644 --- a/jsapp/js/components/permissions/userPermissionRow.component.tsx +++ b/jsapp/js/components/permissions/userPermissionRow.component.tsx @@ -3,13 +3,14 @@ import alertify from 'alertifyjs'; import assetStore from 'js/assetStore'; import {actions} from 'js/actions'; import bem from 'js/bem'; -import {stringToColor, escapeHtml} from 'js/utils'; +import {escapeHtml} from 'js/utils'; import UserAssetPermsEditor from './userAssetPermsEditor.component'; import permConfig from './permConfig'; import type {PermissionBase, PermissionResponse} from 'js/dataInterface'; import type {AssignablePermsMap} from './sharingForm.component'; import {getPermLabel, getFriendlyPermName} from './utils'; import Button from 'js/components/common/button'; +import Avatar from 'js/components/common/avatar'; interface UserPermissionRowProps { assetUid: string; @@ -134,10 +135,6 @@ export default class UserPermissionRow extends React.Component< } render() { - const initialsStyle = { - background: `#${stringToColor(this.props.username)}`, - }; - const modifiers = []; if (!this.props.isPendingOwner && this.props.permissions.length === 0) { modifiers.push('deleted'); @@ -150,13 +147,9 @@ export default class UserPermissionRow extends React.Component< - - {this.props.username.charAt(0)} - + - {this.props.username} - {this.props.isUserOwner && ( {t('is owner')} )} diff --git a/jsapp/js/components/processing/singleProcessingStore.ts b/jsapp/js/components/processing/singleProcessingStore.ts index 5780d01590..1ab15aaed9 100644 --- a/jsapp/js/components/processing/singleProcessingStore.ts +++ b/jsapp/js/components/processing/singleProcessingStore.ts @@ -923,7 +923,7 @@ class SingleProcessingStore extends Reflux.Store { } requestAutoTranslation(languageCode: string) { - this.data.isFetchingData = true; + this.data.isPollingForTranslation = true; processingActions.requestAutoTranslation( this.currentAssetUid, this.currentQuestionXpath, diff --git a/jsapp/js/constants.ts b/jsapp/js/constants.ts index 1ae9801679..1fae91c21b 100644 --- a/jsapp/js/constants.ts +++ b/jsapp/js/constants.ts @@ -89,7 +89,6 @@ export const PROJECT_SETTINGS_CONTEXTS = Object.freeze({ NEW: 'newForm', EXISTING: 'existingForm', REPLACE: 'replaceProject', - BUILDER: 'formBuilderAside', }); export const update_states = { diff --git a/jsapp/js/editorMixins/editableForm.es6 b/jsapp/js/editorMixins/editableForm.es6 index 30549aaef6..211425631f 100644 --- a/jsapp/js/editorMixins/editableForm.es6 +++ b/jsapp/js/editorMixins/editableForm.es6 @@ -9,13 +9,11 @@ import SurveyScope from '../models/surveyScope'; import {cascadeMixin} from './cascadeMixin'; import AssetNavigator from './assetNavigator'; import alertify from 'alertifyjs'; -import ProjectSettings from '../components/modalForms/projectSettings'; import MetadataEditor from 'js/components/metadataEditor'; import {escapeHtml} from '../utils'; import { ASSET_TYPES, AVAILABLE_FORM_STYLES, - PROJECT_SETTINGS_CONTEXTS, update_states, NAME_MAX_LENGTH, META_QUESTION_TYPES, diff --git a/jsapp/js/global.d.ts b/jsapp/js/global.d.ts index c6df00f871..80d4345200 100644 --- a/jsapp/js/global.d.ts +++ b/jsapp/js/global.d.ts @@ -242,8 +242,7 @@ interface HashHistoryListenData { declare module 'react-autobind' { /** - * NOTE: please DO NOT USE unless refactoring old code, as the autobind - * project was abandoned years ago. Just use regular `.bind(this)`. + * @deprecated Use regular `.bind(this)`. */ function autoBind(thisToBeBound: any): void; export default autoBind; diff --git a/jsapp/js/mixins.tsx b/jsapp/js/mixins.tsx index 09fd9119ef..a92f9a01db 100644 --- a/jsapp/js/mixins.tsx +++ b/jsapp/js/mixins.tsx @@ -1,17 +1,3 @@ -/** - * Mixins to be used via react-mixin plugin. These extend components with the - * methods defined within the given mixin, using the component as `this`. - * - * NOTE: please try using mixins as less as possible - when needing a method - * from here, move it out to separete file (utils?), import here to avoid - * breaking the code and use the separete file instead of mixin. - * - * TODO: think about moving out of mixins, as they are deprecated in new React - * versions and considered harmful (see - * https://reactjs.org/blog/2016/07/13/mixins-considered-harmful.html). - * See: https://github.com/kobotoolbox/kpi/issues/3907 - */ - import React from 'react'; import alertify from 'alertifyjs'; import {PROJECT_SETTINGS_CONTEXTS, MODAL_TYPES, ASSET_TYPES} from './constants'; @@ -120,6 +106,23 @@ interface MixinsObject { }; } +/** + * Mixins to be used via react-mixin plugin. These extend components with the + * methods defined within the given mixin, using the component as `this`. + * + * NOTE: please try using mixins as less as possible - when needing a method + * from here, move it out to separete file (utils?), import here to avoid + * breaking the code and use the separete file instead of mixin. + * + * TODO: think about moving out of mixins, as they are deprecated in new React + * versions and considered harmful (see + * https://reactjs.org/blog/2016/07/13/mixins-considered-harmful.html). + * See: https://github.com/kobotoolbox/kpi/issues/3907 + * + * @deprecated Use some of the utils functions spread throught many files in + * the repo (search for files with "utils" in the name). Some of the functions + * below have direct replacements mentioned. + */ const mixins: MixinsObject = { dmix: { afterCopy() { @@ -294,6 +297,11 @@ const mixins: MixinsObject = { removeAssetSharing(this.props.params.uid); }, }, + /** + * @deprecated Please refer to `dropzone.utils.tsx` file and update the code + * there accordingly to your needs. You might end up needing to move and + * update one of the functions found here. + */ droppable: { /* * returns an interval-driven promise @@ -465,9 +473,6 @@ const mixins: MixinsObject = { ); }, - // NOTE: this is a DEPRECATED method of handling Dropzone. Please refer to - // `dropzone.utils.tsx` file and update the code there accordingly to your - // needs. dropFiles(files: File[], rejectedFiles: File[], {}, pms = {}) { files.map((file) => { const reader = new FileReader(); @@ -499,6 +504,9 @@ const mixins: MixinsObject = { } }, }, + /** + * @deprecated Use `routerUtils.ts` instead. + */ contextRouter: { isFormList() { return ( diff --git a/jsapp/js/projects/projectViews/viewSwitcher.module.scss b/jsapp/js/projects/projectViews/viewSwitcher.module.scss index c37aaad366..e4cc0a641b 100644 --- a/jsapp/js/projects/projectViews/viewSwitcher.module.scss +++ b/jsapp/js/projects/projectViews/viewSwitcher.module.scss @@ -22,7 +22,9 @@ font-size: sizes.$x20; font-weight: 800; color: colors.$kobo-gray-800; - padding: sizes.$x6 sizes.$x16; + padding: 2px sizes.$x16; + // we want it to be 32px height + line-height: 28px; max-width: sizes.$x400; :global { diff --git a/jsapp/js/projects/projectsTable/projectsTableRow.tsx b/jsapp/js/projects/projectsTable/projectsTableRow.tsx index 3facc2f74d..b3af22035c 100644 --- a/jsapp/js/projects/projectsTable/projectsTableRow.tsx +++ b/jsapp/js/projects/projectsTable/projectsTableRow.tsx @@ -49,7 +49,13 @@ export default function ProjectsTableRow(props: ProjectsTableRowProps) { if (isSelfOwned(props.asset)) { return t('me'); } else { - return ; + return ( + + ); } case 'ownerFullName': return 'owner__name' in props.asset ? props.asset.owner__name : null; diff --git a/jsapp/js/query/queries/accessLogs.query.ts b/jsapp/js/query/queries/accessLogs.query.ts index ea2bf8df1d..dfa7bea64e 100644 --- a/jsapp/js/query/queries/accessLogs.query.ts +++ b/jsapp/js/query/queries/accessLogs.query.ts @@ -4,23 +4,21 @@ import type {PaginatedResponse} from 'js/dataInterface'; import {fetchGet} from 'js/api'; export interface AccessLog { - app_label: 'kobo_auth' | string; - model_name: 'User' | string; - object_id: number; /** User URL */ user: string; user_uid: string; + /** Date string */ + date_created: string; username: string; - action: 'auth' | string; metadata: { + auth_type: 'digest' | 'submission-group' | string; + // Both `source` and `ip_address` appear only for `digest` type /** E.g. "Firefox (Ubuntu)" */ - source: string; - auth_type: 'Digest' | string; - ip_address: string; + source?: string; + ip_address?: string; }; - /** Date string */ - date_created: string; - log_type: 'access' | string; + /** For `submission-group` type, here is the number of submisssions. */ + count: number; } async function getAccessLogs(limit: number, offset: number) { diff --git a/jsapp/js/router/legacy.tsx b/jsapp/js/router/legacy.tsx index aec4d981c6..67c5a7941f 100644 --- a/jsapp/js/router/legacy.tsx +++ b/jsapp/js/router/legacy.tsx @@ -11,6 +11,9 @@ import { import type {Router} from '@remix-run/router'; // https://stackoverflow.com/a/70754791/443457 +/** + * @deprecated Use `getCurrentPath` from `routerUtils.ts`. + */ const getRoutePath = (location: Location, params: Params): string => { const {pathname} = location; @@ -44,7 +47,8 @@ export interface WithRouterProps { * This is for class based components, which cannot use hooks * Attempts to mimic both react router 3 and 5! * https://v5.reactrouter.com/web/api/withRouter - * Use hooks instead when possible + * + * @deprecated Use hooks instead when possible. */ export function withRouter(Component: FC | typeof React.Component) { function ComponentWithRouterProp(props: any) { @@ -60,17 +64,25 @@ export function withRouter(Component: FC | typeof React.Component) { return ComponentWithRouterProp; } +/** + * @deprecated Use some of the functions from `routerUtils.ts`. + */ function getCurrentRoute() { return router!.state.location.pathname; } /** * Reimplementation of router v3 isActive + * + * @deprecated Use some of the functions from `routerUtils.ts`. */ export function routerIsActive(route: string) { return getCurrentRoute().startsWith(route); } +/** + * @deprecated Use `getRouteAssetUid` from `routerUtils.ts`. + */ export function routerGetAssetId() { const current = getCurrentRoute(); if (current) { diff --git a/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx b/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx index 167d49059c..595ffe9ad9 100644 --- a/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx +++ b/jsapp/js/universalTable/paginatedQueryUniversalTable.component.tsx @@ -18,7 +18,7 @@ interface PaginatedQueryUniversalTableProps { // Below are props from `UniversalTable` that should come from the parent // component (these are kind of "configuration" props). The other // `UniversalTable` props are being handled here internally. - columns: UniversalTableColumn[]; + columns: UniversalTableColumn[]; } const PAGE_SIZES = [10, 30, 50, 100]; diff --git a/jsapp/js/universalTable/universalTable.component.tsx b/jsapp/js/universalTable/universalTable.component.tsx index eafe0c980d..23fdb007bb 100644 --- a/jsapp/js/universalTable/universalTable.component.tsx +++ b/jsapp/js/universalTable/universalTable.component.tsx @@ -21,7 +21,7 @@ import {generateUuid} from 'js/utils'; // Styles import styles from './universalTable.module.scss'; -export interface UniversalTableColumn { +export interface UniversalTableColumn { /** * Pairs to data object properties. It is using dot notation, so it's possible * to match data from a nested object :ok:. @@ -40,14 +40,15 @@ export interface UniversalTableColumn { size?: number; /** * This is an optional formatter function that will be used when rendering - * the cell value. Without it a literal text value will be rendered. + * the cell value. Without it a literal text value will be rendered. For more + * flexibility, function receives whole original data object. */ - cellFormatter?: (value: string) => React.ReactNode; + cellFormatter?: (value: DataItem) => React.ReactNode; } interface UniversalTableProps { /** A list of column definitions */ - columns: UniversalTableColumn[]; + columns: UniversalTableColumn[]; data: DataItem[]; // PAGINATION // To see footer with pagination you need to pass all these below: @@ -133,7 +134,7 @@ export default function UniversalTable( header: () => columnDef.label, cell: (cellProps: CellContext) => { if (columnDef.cellFormatter) { - return columnDef.cellFormatter(cellProps.getValue()); + return columnDef.cellFormatter(cellProps.row.original); } else { return cellProps.renderValue(); } diff --git a/jsapp/js/utils.ts b/jsapp/js/utils.ts index 1a6bcb87fc..ce3d8494ca 100644 --- a/jsapp/js/utils.ts +++ b/jsapp/js/utils.ts @@ -254,42 +254,6 @@ export function getLangString(obj: LangObject): string | undefined { } } -export function stringToColor(str: string, prc?: number) { - // Higher prc = lighter color, lower = darker - prc = typeof prc === 'number' ? prc : -15; - const hash = function (word: string) { - let h = 0; - for (let i = 0; i < word.length; i++) { - h = word.charCodeAt(i) + ((h << 5) - h); - } - return h; - }; - const shade = function (color: string, prc2: number) { - const num = parseInt(color, 16); - const amt = Math.round(2.55 * prc2); - const R = (num >> 16) + amt; - const G = ((num >> 8) & 0x00ff) + amt; - const B = (num & 0x0000ff) + amt; - return ( - 0x1000000 + - (R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 + - (G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 + - (B < 255 ? (B < 1 ? 0 : B) : 255) - ) - .toString(16) - .slice(1); - }; - const intToRgba = function (i: number) { - const color = - ((i >> 24) & 0xff).toString(16) + - ((i >> 16) & 0xff).toString(16) + - ((i >> 8) & 0xff).toString(16) + - (i & 0xff).toString(16); - return color; - }; - return shade(intToRgba(hash(str)), prc); -} - export function isAValidUrl(url: string) { try { new URL(url); @@ -465,7 +429,7 @@ export function generateUuid() { * This is a function from `/jsapp/xlform/src/model.utils.coffee`. It's being * used to generate some unique-ish ids. * - * DEPRECATED. We should definitely use `generateUuid`! + * @deprecated Use `generateUuid`. */ export function txtid() { const o = 'AAnCAnn'.replace(/[AaCn]/g, (c) => { diff --git a/jsapp/scss/components/_kobo.bem.ui.scss b/jsapp/scss/components/_kobo.bem.ui.scss index 6699734169..16b3642a61 100644 --- a/jsapp/scss/components/_kobo.bem.ui.scss +++ b/jsapp/scss/components/_kobo.bem.ui.scss @@ -48,29 +48,6 @@ iframe { border: none; } -// Flexbox helps us maintain a layout when some fields may be customized -// or removed. -// Place sector and country side-by-side, when both fields exist and we're -// not in the narrow form builder sidebar. Similar to registration screen. -.form-modal__form.project-settings--project-details:not( - .project-settings--narrow - ) - > .form-modal__item--wrapper { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: flex-end; - column-gap: 4%; - .form-modal__item { - min-width: 75%; // Most rows are wide enough to get their own row, - flex: 1; // and when they do, they grow to occupy the full available width - } - .form-modal__item--sector, - .form-modal__item--country { - min-width: 40%; // These fields can sit next to each other in pairs. - } -} - // modal forms .form-modal__item { &:not(:last-child) { diff --git a/jsapp/scss/components/_kobo.navigation.scss b/jsapp/scss/components/_kobo.navigation.scss index b6366e3794..9999d3786f 100644 --- a/jsapp/scss/components/_kobo.navigation.scss +++ b/jsapp/scss/components/_kobo.navigation.scss @@ -311,30 +311,6 @@ } } -// Initials avatar, used in header, sharing modal and account settings -.account-box__initials { - width: 32px; - text-align: center; - text-transform: uppercase; - padding: 6px; - border-radius: 32px; - display: inline-block; - vertical-align: middle; - line-height: 20px; - font-size: 16px; - color: colors.$kobo-white; -} - -.account-box__menu-item--avatar { - .account-box__initials { - width: 48px; - padding: 10px; - border-radius: 48px; - line-height: 28px; - font-size: 24px; - } -} - .git-rev { display: none; } diff --git a/jsapp/scss/stylesheets/partials/_registration.scss b/jsapp/scss/stylesheets/partials/_registration.scss index efe806dcea..993fc237d2 100644 --- a/jsapp/scss/stylesheets/partials/_registration.scss +++ b/jsapp/scss/stylesheets/partials/_registration.scss @@ -54,7 +54,8 @@ } hr { - border-top: 1px solid colors.$kobo-storm; + border-top: 1px solid colors.$kobo-light-storm; + opacity: 0.4; } // accounts/{{provider}}/login variant @@ -78,6 +79,11 @@ max-width: 500px; // to be revised padding-bottom: 60px; + .registration__legal { + margin-top: 10px; + margin-bottom: -20px; + } + h1 { font-weight: bold; } @@ -94,7 +100,7 @@ // SSO section styles .registration__sso { font-size: variables.$base-font-size; - padding-bottom: 20px; + padding-bottom: 10px; // Login page variant &.registration__sso--login { @@ -259,7 +265,7 @@ } span.required { - color: colors.$kobo-red; + color: colors.$kobo-mid-red; margin-left: 3px; } @@ -331,7 +337,7 @@ form.registration--login > p > span.helptext{ } .registration__create-or-forgot { - margin: 20px 0; + margin: 20px 0 0; display: flex; flex-direction: row; justify-content: space-between; @@ -339,33 +345,43 @@ form.registration--login > p > span.helptext{ gap: 10px; } +.registration__legal { + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; +} + +.registration__legal .links { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + gap: 20px; +} + +.registration__legal a { + color: colors.$kobo-white; + text-decoration: none !important; + font-size: 14px; +} + +.registration__legal a:hover { + text-decoration: underline !important; + opacity: 1; +} + .registration__footer { clear: both; text-align: center; - margin: 20px 10px 10px 10px; + margin: 30px; text-shadow: colors.$kobo-gray-900 0 0 2px; // contrast against photo background - - a { - color: colors.$kobo-white; - - &:hover { - color: colors.$kobo-light-storm; - } - } - - .registration__legal { - margin: 0 20px; - display: flex; - flex-direction: row; - justify-content: center; - flex-wrap: wrap; - gap: 20px; - } } .error-message { font-size: variables.$base-font-size; - color: colors.$kobo-red; + color: colors.$kobo-mid-red; background-color: rgba(colors.$kobo-mid-red, 0.075); } @@ -432,6 +448,10 @@ form.registration--login > p > span.helptext{ order: 2; } + .registration__legal { + order: 4; + } + div.field { margin-bottom: 12px; } diff --git a/kobo/apps/accounts/templates/account/login.html b/kobo/apps/accounts/templates/account/login.html index f18f3ffa23..a319d8020a 100644 --- a/kobo/apps/accounts/templates/account/login.html +++ b/kobo/apps/accounts/templates/account/login.html @@ -73,6 +73,6 @@

{% trans "Log in using SSO" %}

{% endif %} - + {% include "../legal/registration_legal.html" with config=config %} {% endblock %} diff --git a/kobo/apps/accounts/templates/account/signup.html b/kobo/apps/accounts/templates/account/signup.html index cb5dcfae6d..317b25a7f2 100644 --- a/kobo/apps/accounts/templates/account/signup.html +++ b/kobo/apps/accounts/templates/account/signup.html @@ -90,5 +90,6 @@

{% trans "Register using SSO" %}

{% endif %}
+ {% include "../legal/registration_legal.html" with config=config %} {% endblock %} diff --git a/kobo/apps/accounts/templates/legal/registration_legal.html b/kobo/apps/accounts/templates/legal/registration_legal.html new file mode 100644 index 0000000000..ba08e0ad63 --- /dev/null +++ b/kobo/apps/accounts/templates/legal/registration_legal.html @@ -0,0 +1,18 @@ +{% load i18n %} +{% if config.TERMS_OF_SERVICE_URL or config.PRIVACY_POLICY_URL %} + +{% endif %} diff --git a/kobo/apps/accounts/templates/socialaccount/login.html b/kobo/apps/accounts/templates/socialaccount/login.html index 5ab16d6ea3..b44e6201a4 100644 --- a/kobo/apps/accounts/templates/socialaccount/login.html +++ b/kobo/apps/accounts/templates/socialaccount/login.html @@ -38,5 +38,6 @@

{% trans "Log in with " %} {{ appname }}

{% trans "or" %} {% trans "go back" %}

+ {% include "../legal/registration_legal.html" with config=config %} {% endblock %} diff --git a/kobo/apps/accounts/templates/socialaccount/signup.html b/kobo/apps/accounts/templates/socialaccount/signup.html index d2cec03898..7c8aa78593 100644 --- a/kobo/apps/accounts/templates/socialaccount/signup.html +++ b/kobo/apps/accounts/templates/socialaccount/signup.html @@ -43,6 +43,7 @@

{% blocktrans %}Welcome to KoboToolbox!{% endblocktrans %}

> {% trans "Save and Access" %} + {% include "../legal/registration_legal.html" with config=config %} {% endblock %} diff --git a/kobo/apps/audit_log/tests/test_one_time_auth.py b/kobo/apps/audit_log/tests/test_one_time_auth.py index 1c1f60d742..13fc8caa8c 100644 --- a/kobo/apps/audit_log/tests/test_one_time_auth.py +++ b/kobo/apps/audit_log/tests/test_one_time_auth.py @@ -133,13 +133,13 @@ def side_effect(request): 'kpi.authentication.OPOAuth2Authentication.authenticate', 'kobo.apps.openrosa.libs.authentication.BasicAuthentication.authenticate', ) - def test_any_auth_for_submissions(self, authetication_method): + def test_any_auth_for_submissions(self, authentication_method): """ Test most one-time authenticated submissions result in a submission access log """ with patch( - authetication_method, + authentication_method, return_value=(TestOneTimeAuthentication.user, 'something'), ): # assume the submission works, we don't actually care @@ -161,10 +161,6 @@ def test_any_auth_for_submissions(self, authetication_method): def test_digest_auth_for_submissions(self): """ Test digest-authenticated submissions result in a submission access log -======= - Test digest authentications for submissions result in an audit log being created - with the 'Submission' type ->>>>>>> main """ def side_effect(request): diff --git a/kobo/apps/audit_log/views.py b/kobo/apps/audit_log/views.py index bd84924e6d..94a15025e7 100644 --- a/kobo/apps/audit_log/views.py +++ b/kobo/apps/audit_log/views.py @@ -13,7 +13,7 @@ class AuditLogViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): """ Audit logs - Lists the actions performed (delete, update, create) by users. + Lists actions performed by users. Only available for superusers. For now, only `DELETE`s are logged @@ -29,23 +29,89 @@ class AuditLogViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): > Response 200 > { - > "count": 1, + > "count": 2, > "next": null, > "previous": null, > "results": [ > { > "app_label": "foo", > "model_name": "bar", - > "user": "http://kf.kobo.local/users/kobo_user/", + > "user": "http://kf.kobo.local/api/v2/users/kobo_user/", + > "user_uid": "u12345", > "action": "delete", + > "date_created": "2024-10-01T00:01:00Z", > "log_type": "asset-management", - > } + > }, + > { + > "app_label": "kobo_auth", + > "model_name": "user", + > "user": "http://kf.kobo.local/api/v2/users/another_user/", + > "user_uid": "u12345", + > "username": "another_user", + > "action": "auth", + > "metadata": { + > "source": "Firefox (Ubuntu)", + > "auth_type": "Digest", + > "ip_address": "1.2.3.4" + > }, + > "date_created": "2024-10-01T00:00:00Z", + > "log_type": "access" + > }, > ] > } Results from this endpoint can be filtered by a Boolean query specified in the `q` parameter. + **Filterable fields:** + + 1. app_label + + 2. model_name + + 3. action + + a. Available actions: + + i. create + ii. delete + iii. in-trash + iv. put-back + v. remove + vi. update + vii. auth + + 4. log_type + + a. Available log types: + + i. access + ii. project-history + iii. data-editing + iv. submission-management + v. user-management + vi. asset-management + + 5. date_created + + 6. user_uid + + 7. user__* + + a. user__username + + b. user__email + + c. user__is_superuser + + 8. metadata__* + + a. metadata__asset_uid + + b. metadata__auth_type + + c. some logs may have additional filterable fields in the metadata + **Some examples:** 1. All deleted submissions
@@ -63,6 +129,9 @@ class AuditLogViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): 5. All deleted submissions submitted after a specific date **and time**
`/api/v2/audit-logs/?q=action:delete AND date_created__gte:"2022-11-15 20:34"` + 6. All authentications from superusers
+ `api/v2/audit-logs/?q=action:auth AND user__is_superuser:True + *Notes: Do not forget to wrap search terms in double-quotes if they contain spaces (e.g. date and time "2022-11-15 20:34")* @@ -94,7 +163,7 @@ class AllAccessLogViewSet(AuditLogViewSet): Lists all access logs for all users. Only available to superusers. - Submissions will be grouped together by hour + Submissions will be grouped together by user by hour
     GET /api/v2/access-logs/
@@ -117,15 +186,15 @@ class AllAccessLogViewSet(AuditLogViewSet):
     >                    "username": "admin",
     >                    "metadata": {
     >                        "source": "Firefox (Ubuntu)",
-    >                        "auth_type": "Digest",
+    >                        "auth_type": "digest",
     >                        "ip_address": "172.18.0.6"
     >                   },
     >                    "date_created": "2024-08-19T16:48:58Z",
     >                },
     >                {
-    >                    "user": "http://localhost/api/v2/users/admin/",
-    >                    "user_uid": "u12345",
-    >                    "username": "admin",
+    >                    "user": "http://localhost/api/v2/users/someuser/",
+    >                    "user_uid": "u5678",
+    >                    "username": "someuser",
     >                    "metadata": {
     >                        "auth_type": "submission-group",
     >                    },
@@ -135,8 +204,59 @@ class AllAccessLogViewSet(AuditLogViewSet):
     >           ]
     >       }
 
-    This endpoint can be filtered and paginated the same as the /audit-logs endpoint
+    Results from this endpoint can be filtered by a Boolean query
+    specified in the `q` parameter.
+
+    **Filterable fields:**
+
+    1. date_created
+
+    2. user_uid
+
+    3. user__*
+
+        a. user__username
+
+        b. user__email
+
+        c. user__is_superuser
+
+    4. metadata__*
+
+        a. metadata__auth_type
+
+            available auth types:
+
+            i. django-loginas
 
+            ii. token
+
+            iii. digest
+
+            iv. basic
+
+            v. submission-group
+
+            vi. kpi.backends.ModelBackend
+
+            vii. authorized-application
+
+            viii. oauth2
+
+            ix. unknown
+
+        b. metadata__source
+
+        c. metadata__ip_address
+
+        d. metadata__initial_user_uid
+
+        e. metadata__initial_user_username
+
+        f. metadata__authorized_app_name
+
+    This endpoint can be paginated with 'offset' and 'limit' parameters, eg
+    >      curl -X GET https://[kpi-url]/access-logs/?offset=100&limit=50
     """
 
     queryset = AccessLog.objects.with_submissions_grouped().order_by('-date_created')
@@ -152,12 +272,12 @@ class AccessLogViewSet(AuditLogViewSet):
     Submissions will be grouped together by hour
 
     
-    GET /api/v2/access-logs/me
+    GET /api/v2/access-logs/me/
     
> Example > - > curl -X GET https://[kpi-url]/access-logs/me + > curl -X GET https://[kpi-url]/access-logs/me/ > Response 200 @@ -191,7 +311,7 @@ class AccessLogViewSet(AuditLogViewSet): > } This endpoint can be paginated with 'offset' and 'limit' parameters, eg - > curl -X GET https://[kpi-url]/access-logs/me?offset=100&limit=50 + > curl -X GET https://[kpi-url]/access-logs/me/?offset=100&limit=50 will return entries 100-149 diff --git a/kobo/apps/kobo_auth/management/commands/createsuperuser.py b/kobo/apps/kobo_auth/management/commands/createsuperuser.py index 4170f82e95..de53e657a9 100644 --- a/kobo/apps/kobo_auth/management/commands/createsuperuser.py +++ b/kobo/apps/kobo_auth/management/commands/createsuperuser.py @@ -1,17 +1,18 @@ +from allauth.account.models import EmailAddress from django.conf import settings from django.contrib.auth.management.commands.createsuperuser import ( Command as CreateSuperuserCommand, ) - from kobo.apps.kobo_auth.shortcuts import User from kobo.apps.openrosa.apps.main.models.user_profile import UserProfile class Command(CreateSuperuserCommand): - def handle(self, *args, **options): super().handle(*args, **options) + + # Fix any superuser missing a user profile or email address UserProfile.objects.bulk_create( [ UserProfile(user_id=superuser_pk, validated_password=True) @@ -19,10 +20,20 @@ def handle(self, *args, **options): .values_list('pk', flat=True) .filter(is_superuser=True) .exclude( - pk__in=UserProfile.objects.values_list( - 'user_id', flat=True - ).filter(user__is_superuser=True) + pk__in=UserProfile.objects.values_list('user_id', flat=True).filter( + user__is_superuser=True + ) ) ], ignore_conflicts=True, ) + + EmailAddress.objects.bulk_create( + [ + EmailAddress(user=user, email=user.email, verified=True, primary=True) + for user in User.objects.filter( + is_superuser=True, emailaddress=None + ).exclude(email='') + ], + ignore_conflicts=True, + ) diff --git a/kobo/apps/kobo_auth/tests.py b/kobo/apps/kobo_auth/tests.py new file mode 100644 index 0000000000..59e47c52a7 --- /dev/null +++ b/kobo/apps/kobo_auth/tests.py @@ -0,0 +1,20 @@ +from allauth.account.models import EmailAddress +from django.core.management import call_command +from django.test import TestCase + +from kobo.apps.openrosa.apps.main.models.user_profile import UserProfile + +from .models import User + + +class KoboAuthTestCase(TestCase): + def test_createsuperuser(self): + call_command( + 'createsuperuser', + interactive=False, + username='admin', + email='admin@example.com', + ) + self.assertTrue(User.objects.exists()) + self.assertTrue(UserProfile.objects.exists()) + self.assertTrue(EmailAddress.objects.exists()) diff --git a/kobo/apps/openrosa/apps/api/mongo_helper.py b/kobo/apps/openrosa/apps/api/mongo_helper.py deleted file mode 100644 index 8990837310..0000000000 --- a/kobo/apps/openrosa/apps/api/mongo_helper.py +++ /dev/null @@ -1,183 +0,0 @@ -import re - - -from kobo.apps.openrosa.libs.utils.common_tags import NESTED_RESERVED_ATTRIBUTES -from kobo.apps.openrosa.libs.utils.string import base64_encodestring - - -class MongoHelper: - - KEY_WHITELIST = ['$or', '$and', '$exists', '$in', '$gt', '$gte', - '$lt', '$lte', '$regex', '$options', '$all'] - ENCODING_SUBSTITUTIONS = [ - (re.compile(r'^\$'), base64_encodestring('$').strip()), - (re.compile(r'\.'), base64_encodestring('.').strip()), - ] - DECODING_SUBSTITUTIONS = [ - (re.compile(r'^' + base64_encodestring('$').strip()), '$'), - (re.compile(base64_encodestring('.').strip()), '.'), - ] - - @classmethod - def to_readable_dict(cls, d): - """ - Updates encoded attributes of a dict with human-readable attributes. - For example: - { "myLg==attribute": True } => { "my.attribute": True } - - :param d: dict - :return: dict - """ - - for key, value in list(d.items()): - if type(value) == list: - value = [cls.to_readable_dict(e) - if type(e) == dict else e for e in value] - elif type(value) == dict: - value = cls.to_readable_dict(value) - - if cls._is_attribute_encoded(key): - del d[key] - d[cls.decode(key)] = value - - return d - - @classmethod - def to_safe_dict(cls, d, reading=False): - """ - Updates invalid attributes of a dict by encoding disallowed characters - and, when `reading=False`, expanding dotted keys into nested dicts for - `NESTED_RESERVED_ATTRIBUTES` - - :param d: dict - :param reading: boolean. - :return: dict - - Example: - - >>> d = { - '_validation_status.other.nested': 'lorem', - '_validation_status.uid': 'approved', - 'my.string.with.dots': 'yes' - } - >>> MongoHelper.to_safe_dict(d) - { - 'myLg==stringLg==withLg==dots': 'yes', - '_validation_status': { - 'other': { - 'nested': 'lorem' - }, - 'uid': 'approved' - } - } - >>> MongoHelper.to_safe_dict(d, reading=True) - { - 'myLg==stringLg==withLg==dots': 'yes', - '_validation_status.other.nested': 'lorem', - '_validation_status.uid': 'approved' - } - """ - for key, value in list(d.items()): - if type(value) == list: - value = [cls.to_safe_dict(e, reading=reading) - if type(e) == dict else e for e in value] - elif type(value) == dict: - value = cls.to_safe_dict(value, reading=reading) - elif key == '_id': - try: - d[key] = int(value) - except ValueError: - # if it is not an int don't convert it - pass - - if cls._is_nested_reserved_attribute(key): - # If we want to write into Mongo, we need to transform the dot delimited string into a dict - # Otherwise, for reading, Mongo query engine reads dot delimited string as a nested object. - # Drawback, if a user uses a reserved property with dots, it will be converted as well. - if not reading and key.count(".") > 0: - tree = {} - t = tree - parts = key.split(".") - last_index = len(parts) - 1 - for index, part in enumerate(parts): - v = value if index == last_index else {} - t = t.setdefault(part, v) - del d[key] - first_part = parts[0] - if first_part not in d: - d[first_part] = {} - - # We update the main dict with new dict. - # We use dict_for_mongo again on the dict to ensure, no invalid characters are children - # elements - d[first_part].update(cls.to_safe_dict(tree[first_part])) - - elif cls.is_attribute_invalid(key): - del d[key] - d[cls.encode(key)] = value - - return d - - @classmethod - def encode(cls, key): - """ - Replace characters not allowed in Mongo keys with their base64-encoded - representations - - :param key: string - :return: string - """ - for pattern, repl in cls.ENCODING_SUBSTITUTIONS: - key = re.sub(pattern, repl, key) - return key - - @classmethod - def decode(cls, key): - """ - Replace base64-encoded characters not allowed in Mongo keys with their - original representations - - :param key: string - :return: string - """ - for pattern, repl in cls.DECODING_SUBSTITUTIONS: - key = re.sub(pattern, repl, key) - return key - - @classmethod - def is_attribute_invalid(cls, key): - """ - Checks if an attribute can't be passed to Mongo as is. - :param key: - :return: - """ - return key not in \ - cls.KEY_WHITELIST and (key.startswith('$') or key.count('.') > 0) - - @classmethod - def _is_attribute_encoded(cls, key): - """ - Checks if an attribute has been encoded when saved in Mongo. - - :param key: string - :return: string - """ - return ( - key not in cls.KEY_WHITELIST and ( - key.startswith('JA==') or - key.count('Lg==') > 0 - ) - ) - - @staticmethod - def _is_nested_reserved_attribute(key): - """ - Checks if key starts with one of variables values declared in NESTED_RESERVED_ATTRIBUTES - - :param key: string - :return: boolean - """ - for reserved_attribute in NESTED_RESERVED_ATTRIBUTES: - if key.startswith("{}.".format(reserved_attribute)): - return True - return False diff --git a/kobo/apps/openrosa/apps/viewer/models/data_dictionary.py b/kobo/apps/openrosa/apps/viewer/models/data_dictionary.py index 4048e82208..0fc84ba253 100644 --- a/kobo/apps/openrosa/apps/viewer/models/data_dictionary.py +++ b/kobo/apps/openrosa/apps/viewer/models/data_dictionary.py @@ -13,13 +13,13 @@ from kobo.apps.openrosa.apps.logger.models.xform import XForm from kobo.apps.openrosa.apps.logger.xform_instance_parser import clean_and_parse_xml -from kobo.apps.openrosa.apps.api.mongo_helper import MongoHelper from kobo.apps.openrosa.libs.utils.common_tags import UUID, SUBMISSION_TIME, TAGS, NOTES from kobo.apps.openrosa.libs.utils.export_tools import ( question_types_to_exclude, DictOrganizer, ) from kobo.apps.openrosa.libs.utils.model_tools import queryset_iterator, set_uuid +from kpi.utils.mongo_helper import MongoHelper class ColumnRename(models.Model): diff --git a/kobo/apps/openrosa/apps/viewer/models/parsed_instance.py b/kobo/apps/openrosa/apps/viewer/models/parsed_instance.py index 63122f43cf..655178d7f6 100644 --- a/kobo/apps/openrosa/apps/viewer/models/parsed_instance.py +++ b/kobo/apps/openrosa/apps/viewer/models/parsed_instance.py @@ -1,4 +1,3 @@ -# coding: utf-8 import json from bson import json_util @@ -9,7 +8,7 @@ from pymongo.errors import PyMongoError from kobo.apps.hook.utils.services import call_services -from kobo.apps.openrosa.apps.api.mongo_helper import MongoHelper +from kobo.celery import celery_app from kobo.apps.openrosa.apps.logger.models import Instance, Note from kobo.apps.openrosa.libs.utils.common_tags import ( ATTACHMENTS, @@ -25,8 +24,8 @@ ) from kobo.apps.openrosa.libs.utils.decorators import apply_form_field_names from kobo.apps.openrosa.libs.utils.model_tools import queryset_iterator -from kobo.celery import celery_app from kpi.utils.log import logging +from kpi.utils.mongo_helper import MongoHelper # this is Mongo Collection where we will store the parsed submissions xform_instances = settings.MONGO_DB.instances @@ -97,7 +96,7 @@ def query_mongo(cls, username, id_string, query, fields, sort, start=0, return [ { 'count': xform_instances.count_documents( - query, maxTimeMS=settings.MONGO_QUERY_TIMEOUT + query, maxTimeMS=MongoHelper.get_max_time_ms() ) } ] @@ -149,7 +148,7 @@ def query_mongo_minimal( return [ { 'count': xform_instances.count_documents( - query, maxTimeMS=settings.MONGO_QUERY_TIMEOUT + query, maxTimeMS=MongoHelper.get_max_time_ms() ) } ] @@ -178,7 +177,7 @@ def query_mongo_no_paging(cls, query, fields, count=False): return [ { 'count': xform_instances.count_documents( - query, maxTimeMS=settings.MONGO_QUERY_TIMEOUT + query, maxTimeMS=MongoHelper.get_max_time_ms() ) } ] @@ -210,7 +209,7 @@ def _get_mongo_cursor(cls, query, fields): return xform_instances.find( query, fields_to_select, - max_time_ms=settings.MONGO_QUERY_TIMEOUT, + max_time_ms=MongoHelper.get_max_time_ms(), ) @classmethod diff --git a/kobo/apps/openrosa/apps/viewer/tests/test_export_builder.py b/kobo/apps/openrosa/apps/viewer/tests/test_export_builder.py index 1360178557..0d676d17cd 100644 --- a/kobo/apps/openrosa/apps/viewer/tests/test_export_builder.py +++ b/kobo/apps/openrosa/apps/viewer/tests/test_export_builder.py @@ -7,12 +7,13 @@ from openpyxl import load_workbook from pyxform.builder import create_survey_from_xls -from kobo.apps.openrosa.apps.api.mongo_helper import MongoHelper from kobo.apps.openrosa.apps.main.tests.test_base import TestBase from kobo.apps.openrosa.apps.viewer.tests.export_helpers import viewer_fixture_path from kobo.apps.openrosa.libs.utils.export_tools import ( dict_to_joined_export, - ExportBuilder) + ExportBuilder, +) +from kpi.utils.mongo_helper import MongoHelper def _logger_fixture_path(*args): diff --git a/kobo/apps/openrosa/libs/serializers/data_serializer.py b/kobo/apps/openrosa/libs/serializers/data_serializer.py index cdff1a553c..a97e0fb80f 100644 --- a/kobo/apps/openrosa/libs/serializers/data_serializer.py +++ b/kobo/apps/openrosa/libs/serializers/data_serializer.py @@ -7,7 +7,7 @@ from kobo.apps.openrosa.apps.logger.models.xform import XForm from kobo.apps.openrosa.apps.viewer.models.parsed_instance import ParsedInstance -from kobo.apps.openrosa.apps.api.mongo_helper import MongoHelper +from kpi.utils.mongo_helper import MongoHelper class DataSerializer(serializers.HyperlinkedModelSerializer): diff --git a/kobo/apps/openrosa/libs/utils/export_tools.py b/kobo/apps/openrosa/libs/utils/export_tools.py index 6d34280f04..4b0ea8370a 100644 --- a/kobo/apps/openrosa/libs/utils/export_tools.py +++ b/kobo/apps/openrosa/libs/utils/export_tools.py @@ -18,7 +18,6 @@ from pyxform.section import Section, RepeatingSection from kobo.apps.kobo_auth.shortcuts import User -from kobo.apps.openrosa.apps.api.mongo_helper import MongoHelper from kobo.apps.openrosa.apps.logger.models import Attachment, Instance, XForm from kobo.apps.openrosa.apps.viewer.models.export import Export from kobo.apps.openrosa.libs.utils.viewer_tools import create_attachments_zipfile @@ -41,6 +40,7 @@ from kpi.deployment_backends.kc_access.storage import ( default_kobocat_storage as default_storage, ) +from kpi.utils.mongo_helper import MongoHelper # this is Mongo Collection where we will store the parsed submissions xform_instances = settings.MONGO_DB.instances @@ -491,10 +491,12 @@ def write_row(data, work_sheet, fields, work_sheet_titles): indices = {} survey_name = self.survey.name for d in data: - joined_export = dict_to_joined_export(d, index, indices, - survey_name) + joined_export = dict_to_joined_export( + d, index, indices, survey_name + ) output = ExportBuilder.decode_mongo_encoded_section_names( - joined_export) + joined_export + ) # attach meta fields (index, parent_index, parent_table) # output has keys for every section if survey_name not in output: @@ -618,11 +620,14 @@ def generate_export(export_type, extension, username, id_string, def query_mongo(username, id_string, query=None): - query = json.loads(query, object_hook=json_util.object_hook)\ - if query else {} + query = ( + json.loads(query, object_hook=json_util.object_hook) if query else {} + ) query = MongoHelper.to_safe_dict(query) query[USERFORM_ID] = '{0}_{1}'.format(username, id_string) - return xform_instances.find(query, max_time_ms=settings.MONGO_QUERY_TIMEOUT) + return xform_instances.find( + query, max_time_ms=MongoHelper.get_max_time_ms() + ) def should_create_new_export(xform, export_type): diff --git a/kobo/apps/openrosa/libs/utils/logger_tools.py b/kobo/apps/openrosa/libs/utils/logger_tools.py index ed2184ab0b..91a1c8c113 100644 --- a/kobo/apps/openrosa/libs/utils/logger_tools.py +++ b/kobo/apps/openrosa/libs/utils/logger_tools.py @@ -86,6 +86,7 @@ ) from kpi.utils.object_permission import get_database_user from kpi.utils.hash import calculate_hash +from kpi.utils.mongo_helper import MongoHelper OPEN_ROSA_VERSION_HEADER = 'X-OpenRosa-Version' HTTP_OPEN_ROSA_VERSION_HEADER = 'HTTP_X_OPENROSA_VERSION' @@ -435,7 +436,7 @@ def mongo_sync_status(remongo=False, update_all=False, user=None, xform=None): userform_id = '%s_%s' % (user.username, xform.id_string) mongo_count = mongo_instances.count_documents( {common_tags.USERFORM_ID: userform_id}, - maxTimeMS=settings.MONGO_QUERY_TIMEOUT + maxTimeMS=MongoHelper.get_max_time_ms() ) if instance_count != mongo_count or update_all: @@ -920,7 +921,7 @@ def _update_mongo_for_xform(xform, only_update_missing=True): [rec[common_tags.ID] for rec in mongo_instances.find( {common_tags.USERFORM_ID: userform_id}, {common_tags.ID: 1}, - max_time_ms=settings.MONGO_QUERY_TIMEOUT + max_time_ms=MongoHelper.get_max_time_ms() )]) sys.stdout.write('Total no of mongo instances: %d\n' % len(mongo_ids)) # get the difference diff --git a/kobo/apps/project_ownership/constants.py b/kobo/apps/project_ownership/constants.py new file mode 100644 index 0000000000..c7ebca32be --- /dev/null +++ b/kobo/apps/project_ownership/constants.py @@ -0,0 +1,2 @@ +ASYNC_TASK_HEARTBEAT = 60 * 5 # every 5 minutes +FILE_MOVE_CHUNK_SIZE = 1000 diff --git a/kobo/apps/project_ownership/management/commands/resume_failed_transfers_2_024_25_fix.py b/kobo/apps/project_ownership/management/commands/resume_failed_transfers_2_024_25_fix.py index dcdb17117b..7fd77a88a1 100644 --- a/kobo/apps/project_ownership/management/commands/resume_failed_transfers_2_024_25_fix.py +++ b/kobo/apps/project_ownership/management/commands/resume_failed_transfers_2_024_25_fix.py @@ -1,4 +1,3 @@ -from django.core.management import call_command from django.core.management.base import BaseCommand from ...models import ( @@ -18,7 +17,6 @@ class Command(BaseCommand): def handle(self, *args, **options): - usernames = set() verbosity = options['verbosity'] for transfer_status in TransferStatus.objects.filter( @@ -48,20 +46,33 @@ def handle(self, *args, **options): if verbosity: self.stdout.write(f'Resuming `{transfer.asset}` transfer…') self._move_data(transfer) - move_attachments(transfer) - move_media_files(transfer) + + # We do not want any error to break the management command, + # so we catch any exception and log it for later purpose. + try: + if verbosity: + self.stdout.write('\tMoving attachments…') + move_attachments(transfer) + except Exception as e: + self.stderr.write(f'Failed to move attachments: {e}') + TransferStatus.objects.filter( + transfer=transfer, + status_type=TransferStatusTypeChoices.ATTACHMENTS, + ).update(error=str(e), status=TransferStatusChoices.FAILED) + + try: + if verbosity: + self.stdout.write('\tMoving media files…') + move_media_files(transfer) + except Exception as e: + self.stderr.write(f'Failed to move media files: {e}') + TransferStatus.objects.filter( + transfer=transfer, + status_type=TransferStatusTypeChoices.MEDIA_FILES, + ).update(error=str(e), status=TransferStatusChoices.FAILED) + if verbosity: self.stdout.write('\tDone!') - usernames.add(transfer.invite.recipient.username) - - # Update attachment storage bytes counters - for username in usernames: - call_command( - 'update_attachment_storage_bytes', - verbosity=verbosity, - force=True, - username=username, - ) def _move_data(self, transfer: Transfer): diff --git a/kobo/apps/project_ownership/utils.py b/kobo/apps/project_ownership/utils.py index 33bca9b75d..631331bd47 100644 --- a/kobo/apps/project_ownership/utils.py +++ b/kobo/apps/project_ownership/utils.py @@ -1,13 +1,16 @@ import os +import time from typing import Optional from django.apps import apps from django.utils import timezone -from kobo.apps.openrosa.apps.logger.models import Attachment + from kobo.apps.openrosa.apps.main.models import MetaData +from kobo.apps.openrosa.apps.logger.models.attachment import Attachment from kpi.models.asset import AssetFile from .exceptions import AsyncTaskException +from .constants import ASYNC_TASK_HEARTBEAT, FILE_MOVE_CHUNK_SIZE from .models.choices import TransferStatusChoices, TransferStatusTypeChoices @@ -57,27 +60,39 @@ def move_attachments(transfer: 'project_ownership.Transfer'): media_file__startswith=f'{transfer.asset.owner.username}/' ) - for attachment in attachments.iterator(): - # Pretty slow but it should run in celery task. We want to be the - # path of the file is saved right away. It lets us resume when it stopped - # in case of failure. - if not ( - target_folder := get_target_folder( - transfer.invite.sender.username, - transfer.invite.recipient.username, - attachment.media_file.name, - ) - ): - continue - else: - attachment.media_file.move(target_folder) - attachment.save(update_fields=['media_file']) - - # We only need to update `date_modified` to update task heart beat. - # No need to use `TransferStatus.update_status()` and - # its lock mechanism. - transfer.statuses.filter(status_type=async_task_type).update( - date_modified=timezone.now() + attachments_to_update = [] + try: + heartbeat = int(time.time()) + # Moving files is pretty slow, thus it should run in a celery task. + for attachment in attachments.iterator(): + if not ( + target_folder := get_target_folder( + transfer.invite.sender.username, + transfer.invite.recipient.username, + attachment.media_file.name, + ) + ): + continue + else: + # We want to be sure the path of the file is saved no matter what. + # Thanks to try/finally block, if updates are still pending, they + # should be saved in case of errors. + # It lets us resume when it stopped in case of failure. + attachment.media_file.move(target_folder) + attachments_to_update.append(attachment) + + if len(attachments_to_update) > FILE_MOVE_CHUNK_SIZE: + Attachment.objects.bulk_update( + attachments_to_update, fields=['media_file'] + ) + attachments_to_update = [] + + heartbeat = _update_heartbeat(heartbeat, transfer, async_task_type) + + finally: + if attachments_to_update: + Attachment.objects.bulk_update( + attachments_to_update, fields=['media_file'] ) _mark_task_as_successful(transfer, async_task_type) @@ -100,41 +115,53 @@ def move_media_files(transfer: 'project_ownership.Transfer'): ) } - for media_file in media_files: - if not ( - target_folder := get_target_folder( - transfer.invite.sender.username, - transfer.invite.recipient.username, - media_file.content.name, - ) - ): - continue - else: - # Pretty slow but it should run in celery task. We want to be sure the - # path of the file is saved right away. It lets us resume when it stopped - # in case of failure. - media_file.content.move(target_folder) - old_md5 = media_file.metadata.pop('hash', None) - media_file.set_md5_hash() - if old_md5 in kc_files.keys(): - kc_obj = kc_files[old_md5] - if kc_target_folder := get_target_folder( + media_files_to_update = [] + metadata_to_update = [] + try: + heartbeat = int(time.time()) + # Moving files is pretty slow, thus it should run in a celery task. + for media_file in media_files: + if not ( + target_folder := get_target_folder( transfer.invite.sender.username, transfer.invite.recipient.username, - kc_obj.data_file.name, - ): - kc_obj.data_file.move(kc_target_folder) - kc_obj.file_hash = media_file.md5_hash - kc_obj.save(update_fields=['data_file', 'file_hash']) - - media_file.save(update_fields=['content', 'metadata']) - - # We only need to update `date_modified` to update task heart beat. - # No need to use `TransferStatus.update_status()` and - # its lock mechanism. - transfer.statuses.filter(status_type=async_task_type).update( - date_modified=timezone.now() - ) + media_file.content.name, + ) + ): + continue + else: + # We want to be sure the path of the file is saved no matter what. + # Thanks to try/finally block, if updates are still pending, they + # should be saved in case of errors. + # It lets us resume when it stopped in case of failure. + media_file.content.move(target_folder) + old_md5 = media_file.metadata.pop('hash', None) + media_file.set_md5_hash() + if old_md5 in kc_files.keys(): + kc_obj = kc_files[old_md5] + if kc_target_folder := get_target_folder( + transfer.invite.sender.username, + transfer.invite.recipient.username, + kc_obj.data_file.name, + ): + kc_obj.data_file.move(kc_target_folder) + kc_obj.file_hash = media_file.md5_hash + metadata_to_update.append(kc_obj) + + media_files_to_update.append(media_file) + heartbeat = _update_heartbeat(heartbeat, transfer, async_task_type) + + finally: + # No need to use chunk size for media files like we do for attachments, + # because the odds are pretty low that more than 100 media files are + # linked to the project. + if metadata_to_update: + media_files_to_update.append(media_file) + + if media_files_to_update: + AssetFile.objects.bulk_update( + media_files_to_update, fields=['content', 'metadata'] + ) _mark_task_as_successful(transfer, async_task_type) @@ -164,3 +191,19 @@ def _mark_task_as_successful( TransferStatus.update_status( transfer.pk, TransferStatusChoices.SUCCESS, async_task_type ) + + +def _update_heartbeat( + heartbeat: int, transfer: 'project_ownership.Transfer', async_task_type: str +) -> int: + + if heartbeat + ASYNC_TASK_HEARTBEAT >= time.time(): + # We only need to update `date_modified` to update task heartbeat. + # No need to use `TransferStatus.update_status()` and + # its lock mechanism. + transfer.statuses.filter(status_type=async_task_type).update( + date_modified=timezone.now() + ) + return int(time.time()) + + return heartbeat diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 3a57f5726d..8a85af50c5 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -1558,6 +1558,10 @@ def dj_stripe_request_callback_method(): # If a request or task makes a database query and then times out, the database # server should not spin forever attempting to fulfill that query. +# ⚠️⚠️ +# These settings should never be used directly. +# Use MongoHelper.get_max_time_ms() in the code instead +# ⚠️⚠️ MONGO_QUERY_TIMEOUT = SYNCHRONOUS_REQUEST_TIME_LIMIT + 5 # seconds MONGO_CELERY_QUERY_TIMEOUT = CELERY_TASK_TIME_LIMIT + 10 # seconds diff --git a/kpi/templates/base.html b/kpi/templates/base.html index 881c87d801..77e0d05193 100644 --- a/kpi/templates/base.html +++ b/kpi/templates/base.html @@ -23,19 +23,6 @@ {% endif %}