diff --git a/dependencies/pip/dev_requirements.in b/dependencies/pip/dev_requirements.in index e0caaba195..794ee14201 100644 --- a/dependencies/pip/dev_requirements.in +++ b/dependencies/pip/dev_requirements.in @@ -6,3 +6,4 @@ pytest-django pytest-env pytest mock +mongomock diff --git a/dependencies/pip/dev_requirements.txt b/dependencies/pip/dev_requirements.txt index 605956f335..d2f1bc3e9c 100644 --- a/dependencies/pip/dev_requirements.txt +++ b/dependencies/pip/dev_requirements.txt @@ -69,6 +69,8 @@ decorator==4.4.1 # via # ipython # traitlets +deepmerge==0.3.0 + # via -r dependencies/pip/requirements.in defusedxml==0.6.0 # via djangorestframework-xml dicttoxml==1.7.4 @@ -201,6 +203,8 @@ markdown==3.1.1 # django-markdownx mock==3.0.5 # via -r dependencies/pip/dev_requirements.in +mongomock==3.22.1 + # via -r dependencies/pip/dev_requirements.in more-itertools==7.2.0 # via # pytest @@ -298,6 +302,8 @@ responses==0.10.6 # via -r dependencies/pip/requirements.in s3transfer==0.2.1 # via boto3 +sentinels==1.0.0 + # via mongomock shortuuid==0.5.0 # via -r dependencies/pip/requirements.in six==1.13.0 @@ -307,6 +313,7 @@ six==1.13.0 # django-extensions # jsonschema # mock + # mongomock # packaging # prompt-toolkit # pynacl diff --git a/dependencies/pip/external_services.txt b/dependencies/pip/external_services.txt index 4d7a7dfa89..aa836e1815 100644 --- a/dependencies/pip/external_services.txt +++ b/dependencies/pip/external_services.txt @@ -52,6 +52,8 @@ cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery +deepmerge==0.3.0 + # via -r dependencies/pip/requirements.in defusedxml==0.6.0 # via djangorestframework-xml dicttoxml==1.7.4 diff --git a/dependencies/pip/requirements.in b/dependencies/pip/requirements.in index 295a23d5bc..9fca5759dc 100644 --- a/dependencies/pip/requirements.in +++ b/dependencies/pip/requirements.in @@ -76,7 +76,7 @@ tabulate unicodecsv uWSGI Werkzeug -xlrd +xlrde xlwt xlutils XlsxWriter @@ -85,3 +85,6 @@ XlsxWriter pyopenssl ndg-httpsclient pyasn1 + +# This package is only needed for unit tests but MockBackend is loaded even on production environment +deepmerge diff --git a/dependencies/pip/requirements.txt b/dependencies/pip/requirements.txt index c0702d35ef..8c4da38491 100644 --- a/dependencies/pip/requirements.txt +++ b/dependencies/pip/requirements.txt @@ -52,6 +52,8 @@ cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery +deepmerge==0.3.0 + # via -r dependencies/pip/requirements.in defusedxml==0.6.0 # via djangorestframework-xml dicttoxml==1.7.4 diff --git a/dependencies/pip/travis_ci.txt b/dependencies/pip/travis_ci.txt index 178a0b710f..8b4b5729e2 100644 --- a/dependencies/pip/travis_ci.txt +++ b/dependencies/pip/travis_ci.txt @@ -61,6 +61,8 @@ cryptography==2.8 # via pyopenssl cssselect==1.1.0 # via pyquery +deepmerge==0.3.0 + # via -r dependencies/pip/requirements.in defusedxml==0.6.0 # via djangorestframework-xml dicttoxml==1.7.4 diff --git a/hub/models.py b/hub/models.py index c8caf55c20..27ec00a050 100644 --- a/hub/models.py +++ b/hub/models.py @@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404 from markitup.fields import MarkupField -from kpi.models.object_permission import get_anonymous_user +from kpi.utils.object_permission import get_database_user class SitewideMessage(models.Model): @@ -76,8 +76,7 @@ class PerUserSetting(models.Model): value_when_not_matched = models.CharField(max_length=2048, blank=True) def user_matches(self, user, ignore_invalid_queries=True): - if user.is_anonymous: - user = get_anonymous_user() + user = get_database_user(user) manager = user._meta.model.objects queryset = manager.none() for user_query in self.user_queries: diff --git a/jsapp/js/actions.es6 b/jsapp/js/actions.es6 index 323f8b0d4e..5ee455d219 100644 --- a/jsapp/js/actions.es6 +++ b/jsapp/js/actions.es6 @@ -19,6 +19,7 @@ import libraryActions from './actions/library'; import submissionsActions from './actions/submissions'; import formMediaActions from './actions/mediaActions'; import exportsActions from './actions/exportsActions'; +import dataShareActions from './actions/dataShareActions'; import { notify, replaceSupportEmail, @@ -34,6 +35,7 @@ export const actions = { submissions: submissionsActions, media: formMediaActions, exports: exportsActions, + dataShare: dataShareActions, }; actions.navigation = Reflux.createActions([ diff --git a/jsapp/js/actions/dataShareActions.es6 b/jsapp/js/actions/dataShareActions.es6 new file mode 100644 index 0000000000..424b8b5dd9 --- /dev/null +++ b/jsapp/js/actions/dataShareActions.es6 @@ -0,0 +1,126 @@ +/** + * Dynamic data attachment related actions + */ + +import Reflux from 'reflux'; +import alertify from 'alertifyjs'; +import {dataInterface} from 'js/dataInterface'; +import {MAX_DISPLAYED_STRING_LENGTH} from 'js/constants'; +import { + getAssetUIDFromUrl, + truncateFile, + truncateString, +} from 'js/utils'; + +const dataShareActions = Reflux.createActions({ + attachToSource: {children: ['started', 'completed', 'failed']}, + detachSource: {children: ['completed', 'failed']}, + patchSource: {children: ['started', 'completed', 'failed']}, + getAttachedSources: {children: ['completed', 'failed']}, + getSharingEnabledAssets: {children: ['completed', 'failed']}, + toggleDataSharing: {children: ['completed', 'failed']}, + updateColumnFilters: {children: ['completed', 'failed']}, +}); + +dataShareActions.attachToSource.listen((assetUid, data) => { + dataInterface.attachToSource(assetUid, data) + .done(dataShareActions.attachToSource.completed) + .fail(dataShareActions.attachToSource.failed); + dataShareActions.attachToSource.started(); +}); +dataShareActions.attachToSource.failed.listen((response) => { + alertify.error( + response?.responseJSON?.filename[0] || + response?.responseJSON || + t('Failed to attach to source') + ); +}); + +dataShareActions.detachSource.listen((attachmentUrl) => { + dataInterface.detachSource(attachmentUrl) + .done(dataShareActions.detachSource.completed) + .fail(dataShareActions.detachSource.failed); +}); +dataShareActions.detachSource.failed.listen((response) => { + alertify.error(response?.responseJSON || t('Failed to detach from source')); +}); + +dataShareActions.patchSource.listen((attachmentUrl, data) => { + dataInterface.patchSource(attachmentUrl, data) + .done(dataShareActions.patchSource.completed) + .fail(dataShareActions.patchSource.failed); + dataShareActions.patchSource.started(); +}); +dataShareActions.patchSource.failed.listen((response) => { + alertify(response?.responseJSON || t('Failed to patch source')); +}); + +dataShareActions.getAttachedSources.listen((assetUid) => { + dataInterface.getAttachedSources(assetUid) + .done((response) => { + // We create our own object from backend response because: + // 1. We need to truncate the filename and display this instead + // 2. We need both the current asset URL as well as it's source data URL + let allSources = []; + + // TODO: Check is pagination is an issue, if so we should try to use the + // backend response directly + response.results.forEach((source) => { + let sourceUid = getAssetUIDFromUrl(source.source); + allSources.push({ + sourceName: truncateString( + source.source__name, + MAX_DISPLAYED_STRING_LENGTH.connect_projects, + ), + // Source's asset url + sourceUrl: source.source, + sourceUid: sourceUid, + // Fields that the connecting project has selected to import + linkedFields: source.fields, + filename: truncateFile( + source.filename, + MAX_DISPLAYED_STRING_LENGTH.connect_projects, + ), + // Source project attachment endpoint + attachmentUrl: source.url, + }); + }); + + dataShareActions.getAttachedSources.completed(allSources); + }) + .fail(dataShareActions.getAttachedSources.failed); +}); + +dataShareActions.getSharingEnabledAssets.listen(() => { + dataInterface.getSharingEnabledAssets() + .done(dataShareActions.getSharingEnabledAssets.completed) + .fail(dataShareActions.getSharingEnabledAssets.failed); +}); +dataShareActions.getSharingEnabledAssets.failed.listen(() => { + alertify.error(t('Failed to retrieve sharing enabled assets')); +}); + +// The next two actions have the same endpoint but must be handled very +// differently so we leave them as seperate actions + +// TODO: Improve the parameters so these functions are clearly different from +// each other +dataShareActions.toggleDataSharing.listen((uid, data) => { + dataInterface.patchDataSharing(uid, data) + .done(dataShareActions.toggleDataSharing.completed) + .fail(dataShareActions.toggleDataSharing.failed); +}); +dataShareActions.toggleDataSharing.failed.listen((response) => { + alertify.error(response?.responseJSON?.detail || t('Failed to toggle sharing')) +}); + +dataShareActions.updateColumnFilters.listen((uid, data) => { + dataInterface.patchDataSharing(uid, data) + .done(dataShareActions.updateColumnFilters.completed) + .fail(dataShareActions.updateColumnFilters.failed); +}); +dataShareActions.updateColumnFilters.failed.listen((response) => { + alertify.error(response?.responseJSON || t('Failed to update column filters')); +}); + +export default dataShareActions; diff --git a/jsapp/js/app.es6 b/jsapp/js/app.es6 index 6f8b9d7ac3..79204ef7f6 100644 --- a/jsapp/js/app.es6 +++ b/jsapp/js/app.es6 @@ -298,6 +298,7 @@ export var routes = ( + diff --git a/jsapp/js/bem.es6 b/jsapp/js/bem.es6 index 87b964e211..abf2d2959d 100644 --- a/jsapp/js/bem.es6 +++ b/jsapp/js/bem.es6 @@ -156,7 +156,6 @@ bem.FormView__subs = bem.FormView.__('subs'); // end used in header.es6 bem.FormView__toptabs = bem.FormView.__('toptabs'); bem.FormView__sidetabs = bem.FormView.__('sidetabs'); -bem.FormView__tab = bem.FormView.__('tab', ''); bem.FormView__label = bem.FormView.__('label'); bem.FormView__group = bem.FormView.__('group'); diff --git a/jsapp/js/components/dataAttachments/connect-projects.scss b/jsapp/js/components/dataAttachments/connect-projects.scss new file mode 100644 index 0000000000..8005296e82 --- /dev/null +++ b/jsapp/js/components/dataAttachments/connect-projects.scss @@ -0,0 +1,255 @@ +@import "scss/_colors"; + +.connect-projects { + i.k-icon { + font-size: 32px; + margin-right: 5px; + } + + .form-view__cell--page-title { + font-size: 14px !important; + display: flex; + margin-top: 20px !important; + i { + margin-top: 10px; + } + } + + .connect-projects__export { + display: block; + margin-top: 20px; + + .connect-projects__export-options { + display: flex; + justify-content: space-between; + padding-bottom: 12px; + + .toggle-switch { + .toggle-switch__label { + font-weight: bold; + } + } + + // TODO: Create a BEM element that acts as column wrappers (and use + // modifiers for different columns) + .checkbox { + width: 50%; + } + } + + .connect-projects__export-multicheckbox { + display: flex; + justify-content: space-between; + position: relative; + padding-top: 12px; + border-top: 1px solid; + border-color: $kobo-gray-92; + + .connect-projects__export-hint { + width: 45%; + } + + .multi-checkbox { + height: 200px; + width: 50%; + } + } + } + + .connect-projects__import { + .connect-projects__import-form { + position: relative; + display: flex; + margin-top: 10px; + + .kobo-select__wrapper { + width: 50%; + margin-right: 50px; + .kobo-select__placeholder { + color: $kobo-gray-24; + } + } + + .text-box { + width: 35%; + margin-top: 0px; + margin-right: 24px; + .text-box__input { + padding: 9px 10px; + background-color: $kobo-white; + color: $kobo-gray-24; + } + } + } + } + .connect-projects__import-list { + margin-top: 20px; + + label { + margin-top: 20px; + font-size: 14px; + font-weight: bold; + color: $kobo-gray-40; + } + + .connect-projects__import-list-item, + .connect-projects__import-list-item--no-imports { + position: relative; + display: flex; + justify-content: space-between; + margin-top: 7px; + margin-bottom: 10px; + border-bottom: 1px solid; + border-color: $kobo-gray-92; + } + + .connect-projects__import-list-item--no-imports { + font-style: italic; + color: $kobo-gray-65; + // Match vertial height of a regular list item + padding: 11px 0 11px 11px; + } + + .connect-projects__import-list-item { + padding-bottom: 10px; + + i.k-icon-check { + color: $kobo-blue; + } + + .connect-projects__import-labels { + position: absolute; + top: 6px; + left: 32px; + font-weight: 500; + + i.k-icon-check { + color: $kobo-blue; + font-weight: bold; + } + + .connect-projects__import-labels-source { + margin-left: 24px; + font-weight: 400; + color: $kobo-gray-40; + } + } + + .connect-projects__import-options { + position: relative; + .kobo-light-button { + position: absolute; + right: 0; + padding-bottom: 25px; + + i.k-icon { + font-size: 24px; + } + + &:not(:first-child) { + margin-right: 45px; + } + } + + } + } + .loading__inner { + i { + vertical-align: text-bottom; + } + } + } +} + +.form-modal__form.form-modal__form--data-attachment-columns { + color: $kobo-gray-55; + + .bulk-options { + margin-top: 14px; + display: flex; + justify-content: space-between; + + .bulk-options__description { + font-weight: bold; + } + + .bulk-options__buttons { + span { + margin: 12px; + } + + a { + text-decoration: underline; + cursor: pointer; + } + } + } + + .multi-checkbox { + margin-top: 12px; + height: 200px; + } + + .loading { + margin-top: 12px; + } + + .modal__footer { + text-align: center; + + button { + padding-left: 64px; + padding-right: 64px; + } + } + +} + + +// Compensate for when sidebar(s) messes up modal a bit + +//TODO: Clean this up via PR changes +@media + (min-width: 1000px) and (max-width: 1140px), + (min-width: 770px) and (max-width: 860px), + (max-width: 700px) { + .connect-projects__export-multicheckbox { + display: block !important; + + .multi-checkbox { + margin-top: 12px; + width: 100% !important; + overflow-x: scroll; + } + } + + .connect-projects__import-form { + display: block !important; + + .kobo-select__wrapper { + width: 100% !important; + margin-bottom: 12px; + } + + .text-box { + width: 100%; + } + + .kobo-button { + display: block; + margin-top: 12px auto 0; + width: 70%; + } + } +} + +@media screen and (max-width: 530px) { + .connect-projects__export-options { + display: block !important; + + .checkbox { + margin-top: 20px; + width: 100% !important; + } + } +} diff --git a/jsapp/js/components/dataAttachments/connectProjects.es6 b/jsapp/js/components/dataAttachments/connectProjects.es6 new file mode 100644 index 0000000000..af3e80a36c --- /dev/null +++ b/jsapp/js/components/dataAttachments/connectProjects.es6 @@ -0,0 +1,579 @@ +import React from 'react'; +import autoBind from 'react-autobind'; +import alertify from 'alertifyjs'; +import dataAttachmentsUtils from 'js/components/dataAttachments/dataAttachmentsUtils'; +import Select from 'react-select'; +import ToggleSwitch from 'js/components/common/toggleSwitch'; +import Checkbox from 'js/components/common/checkbox'; +import TextBox from 'js/components/common/textBox'; +import MultiCheckbox from 'js/components/common/multiCheckbox'; +import {actions} from 'js/actions'; +import {stores} from 'js/stores'; +import {bem} from 'js/bem'; +import {generateAutoname} from 'js/utils'; +import {LoadingSpinner} from 'js/ui'; + +import { + MODAL_TYPES, + MAX_DISPLAYED_STRING_LENGTH, +} from 'js/constants'; + +import './connect-projects.scss'; + +const DYNAMIC_DATA_ATTACHMENTS_SUPPORT_URL = 'dynamic_data_attachment.html'; + +/* + * Modal for connecting project data + * + * @prop {object} asset + */ +class ConnectProjects extends React.Component { + constructor(props) { + super(props); + this.state = { + isInitialised: false, + isLoading: false, + // `data_sharing` is an empty object if never enabled before + isShared: props.asset.data_sharing?.enabled || false, + isSharingAnyQuestions: Boolean(props.asset.data_sharing?.fields?.length) || false, + attachedSources: [], + sharingEnabledAssets: null, + newSource: null, + newFilename: '', + columnsToDisplay: [], + fieldsErrors: {}, + }; + + if (this.state.isShared) { + this.state.columnsToDisplay = dataAttachmentsUtils.generateColumnFilters( + this.props.asset.data_sharing.fields, + this.props.asset.content.survey, + ); + } + + + autoBind(this); + + this.unlisteners = []; + } + + /* + * Setup + */ + + componentDidMount() { + this.unlisteners.push( + actions.dataShare.attachToSource.started.listen( + this.markComponentAsLoading + ), + actions.dataShare.attachToSource.completed.listen( + this.refreshAttachmentList + ), + actions.dataShare.attachToSource.failed.listen( + this.onAttachToSourceFailed + ), + actions.dataShare.detachSource.completed.listen( + this.refreshAttachmentList + ), + actions.dataShare.patchSource.started.listen( + this.markComponentAsLoading + ), + actions.dataShare.patchSource.completed.listen( + this.onPatchSourceCompleted + ), + actions.dataShare.getSharingEnabledAssets.completed.listen( + this.onGetSharingEnabledAssetsCompleted + ), + actions.dataShare.getAttachedSources.completed.listen( + this.onGetAttachedSourcesCompleted + ), + actions.dataShare.toggleDataSharing.completed.listen( + this.onToggleDataSharingCompleted + ), + actions.dataShare.updateColumnFilters.completed.listen( + this.onUpdateColumnFiltersCompleted + ), + actions.dataShare.updateColumnFilters.failed.listen( + this.stopLoading + ), + actions.dataShare.detachSource.failed.listen( + this.stopLoading + ), + actions.dataShare.patchSource.completed.listen( + this.stopLoading + ), + actions.dataShare.patchSource.failed.listen( + this.stopLoading + ), + ); + + this.refreshAttachmentList(); + + actions.dataShare.getSharingEnabledAssets(); + } + + componentWillUnmount() { + this.unlisteners.forEach((clb) => {clb();}); + } + + /* + * `actions` Listeners + */ + + onAttachToSourceFailed(response) { + this.setState({ + isLoading: false, + fieldsErrors: response?.responseJSON || t('Please check file name'), + }); + } + + onGetAttachedSourcesCompleted(response) { + this.setState({ + isInitialised: true, + isLoading: false, + attachedSources: response, + }); + } + + onGetSharingEnabledAssetsCompleted(response) { + this.setState({sharingEnabledAssets: response}); + } + + onToggleDataSharingCompleted(response) { + this.setState({ + isShared: response.data_sharing.enabled, + isSharingAnyQuestions: false, + columnsToDisplay: dataAttachmentsUtils.generateColumnFilters( + [], + this.props.asset.content.survey, + ), + }); + } + + // Safely update state after guaranteed column changes + onUpdateColumnFiltersCompleted(response) { + this.setState({ + isLoading: false, + columnsToDisplay: dataAttachmentsUtils.generateColumnFilters( + response.data_sharing.fields, + this.props.asset.content.survey, + ), + }); + } + + onPatchSourceCompleted() { + actions.dataShare.getAttachedSources(this.props.asset.uid); + } + + refreshAttachmentList() { + this.setState({ + newSource: null, + newFilename: '', + fieldsErrors: {}, + }); + actions.dataShare.getAttachedSources(this.props.asset.uid); + } + + stopLoading() { + this.setState({isLoading: false}); + } + + /* + * UI Listeners + */ + + onFilenameChange(newVal) { + this.setState({ + newFilename: newVal, + fieldsErrors: {}, + }); + } + + onSourceChange(newVal) { + this.setState({ + newSource: newVal, + newFilename: generateAutoname( + newVal?.name, + 0, + MAX_DISPLAYED_STRING_LENGTH.connect_projects + ), + fieldsErrors: {}, + }); + } + + onConfirmAttachment(evt) { + evt.preventDefault(); + if (this.state.newFilename !== '' && this.state.newSource?.url) { + this.setState({ + fieldsErrors: {}, + }); + + this.showColumnFilterModal( + this.props.asset, + this.state.newSource, + this.state.newFilename, + [], + ); + } else { + if (!this.state.newSource?.url) { + this.setState((state) => { + return { + fieldsErrors: { + ...state.fieldsErrors, + source: t('No project selected') + } + } + }); + } + if (this.state.newFilename === '') { + this.setState((state) => { + return { + fieldsErrors: { + ...state.fieldsErrors, + filename: t('Field is empty') + } + } + }); + } + } + } + + onRemoveAttachment(newVal) { + this.setState({isLoading: true}); + actions.dataShare.detachSource(newVal); + } + + onToggleSharingData() { + const data = { + data_sharing: { + enabled: !this.state.isShared, + fields: [], + }, + }; + + if (!this.state.isShared) { + let dialog = alertify.dialog('confirm'); + let opts = { + title: `${t('Privacy Notice')}`, + message: t('This will attach the full dataset from "##ASSET_NAME##" as a background XML file to this form. While not easily visible, it is technically possible for anyone entering data to your form to retrieve and view this dataset. Do not use this feature if "##ASSET_NAME##" includes sensitive data.').replaceAll('##ASSET_NAME##', this.props.asset.name), + labels: {ok: t('Acknowledge and continue'), cancel: t('Cancel')}, + onok: () => { + actions.dataShare.toggleDataSharing(this.props.asset.uid, data); + dialog.destroy(); + }, + oncancel: dialog.destroy, + }; + dialog.set(opts).show(); + } else { + actions.dataShare.toggleDataSharing(this.props.asset.uid, data); + } + } + + onColumnSelected(columnList) { + this.setState({isLoading: true}); + + const fields = []; + columnList.forEach((item) => { + if (item.checked) { + fields.push(item.label); + } + }); + + const data = { + data_sharing: { + enabled: this.state.isShared, + fields: fields, + }, + }; + + actions.dataShare.updateColumnFilters(this.props.asset.uid, data); + } + + onSharingCheckboxChange(checked) { + let columnsToDisplay = this.state.columnsToDisplay; + if (!checked) { + columnsToDisplay = []; + const data = { + data_sharing: { + enabled: this.state.isShared, + fields: [], + }, + }; + actions.dataShare.updateColumnFilters(this.props.asset.uid, data); + } + + this.setState({ + columnsToDisplay: columnsToDisplay, + isSharingAnyQuestions: checked, + }); + } + + /* + * Utilities + */ + + generateFilteredAssetList() { + let attachedSourceUids = []; + this.state.attachedSources.forEach((item) => { + attachedSourceUids.push(item.sourceUid); + }); + + // Filter out attached projects from displayed asset list + return ( + this.state.sharingEnabledAssets.results.filter( + item => !attachedSourceUids.includes(item.uid) + ) + ); + } + + showColumnFilterModal(asset, source, filename, fields, attachmentUrl) { + stores.pageState.showModal( + { + type: MODAL_TYPES.DATA_ATTACHMENT_COLUMNS, + asset: asset, + source: source, + filename: filename, + fields: fields, + attachmentUrl: attachmentUrl, + } + ); + } + + markComponentAsLoading() { + this.setState({isLoading: true}); + } + + /* + * Rendering + */ + + //TODO: Use BEM elements instead + + renderSelect() { + if (this.state.sharingEnabledAssets !== null) { + let sharingEnabledAssets = this.generateFilteredAssetList(); + const selectClassNames = ['kobo-select__wrapper']; + if (this.state.fieldsErrors?.source) { + selectClassNames.push('kobo-select__wrapper--error'); + } + return( +
+