diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 5bcbe6346b..e35d8f8de1 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -18,6 +18,7 @@ jobs: REDIS_SESSION_URL: redis://localhost:6379 SERVICE_ACCOUNT_BACKEND_URL: redis://localhost:6379/4 CACHE_URL: redis://localhost:6379/3 + ENKETO_REDIS_MAIN_URL: redis://localhost:6379/0 strategy: matrix: python-version: ['3.8', '3.10'] diff --git a/jsapp/js/account/accountFieldsEditor.component.tsx b/jsapp/js/account/accountFieldsEditor.component.tsx index ed11c85661..f426de31a1 100644 --- a/jsapp/js/account/accountFieldsEditor.component.tsx +++ b/jsapp/js/account/accountFieldsEditor.component.tsx @@ -34,11 +34,6 @@ interface AccountFieldsEditorProps { */ values: AccountFieldsValues; onChange: (fields: AccountFieldsValues) => void; - /** - * Handles the require authentication checkbox. If not provided, the checkbox - * will be displayed. - */ - isRequireAuthDisplayed?: boolean; } /** @@ -87,23 +82,6 @@ export default function AccountFieldsEditor(props: AccountFieldsEditorProps) { return (
-
- {/* Privacy */} - {props.isRequireAuthDisplayed !== false && ( -
- - - - onAnyFieldChange('require_auth', isChecked) - } - label={t('Require authentication to see forms and submit data')} - /> -
- )} -
-
{/* Full name */} {isFieldToBeDisplayed('name') && ( diff --git a/jsapp/js/account/accountSettings.scss b/jsapp/js/account/accountSettings.scss index dfa689d56e..c79fef751a 100644 --- a/jsapp/js/account/accountSettings.scss +++ b/jsapp/js/account/accountSettings.scss @@ -52,6 +52,23 @@ font-size: 16px; } } + + .form-modal__item--anonymous-submission-notice { + display: flex; + line-height: sizes.$x24; + background-color: colors.$kobo-light-amber; + margin-top: sizes.$x12; + padding: sizes.$x18; + border-radius: sizes.$x8; + + .anonymous-submission-notice-copy { + padding-left: sizes.$x16; + padding-right: sizes.$x8; + > * { + display: inline; + } + } + } } .account-settings__actions { diff --git a/jsapp/js/account/accountSettingsRoute.tsx b/jsapp/js/account/accountSettingsRoute.tsx index 66bce0dd8c..f5df0aa86c 100644 --- a/jsapp/js/account/accountSettingsRoute.tsx +++ b/jsapp/js/account/accountSettingsRoute.tsx @@ -7,6 +7,8 @@ import './accountSettings.scss'; import {notify, stringToColor} from 'js/utils'; import {dataInterface} from '../dataInterface'; import AccountFieldsEditor from './accountFieldsEditor.component'; +import Icon from 'js/components/common/icon'; +import envStore from 'js/envStore'; import { getInitialAccountFieldsValues, getProfilePatchData, @@ -15,6 +17,7 @@ import type { AccountFieldsValues, AccountFieldsErrors, } from './account.constants'; +import {HELP_ARTICLE_ANON_SUBMISSIONS_URL} from 'js/constants'; bem.AccountSettings = makeBem(null, 'account-settings'); bem.AccountSettings__left = makeBem(bem.AccountSettings, 'left'); @@ -151,6 +154,33 @@ const AccountSettings = observer(() => { {sessionStore.isInitialLoadComplete && form.isUserDataLoaded && ( + + +
+ + {t( + '"Require authentication to see forms and submit data" has been moved.' + )} + +   +
+ {t( + 'This privacy feature is now a per-project setting. New projects will require authentication by default.' + )} +
+   + + {t('Learn more about these changes here.')} + +
+
+ void; +} + +export default function AnonymousSubmission(props: AnonymousSubmissionProps) { + return ( +
+ + + + +
+ ); +} diff --git a/jsapp/js/components/anonymousSubmission.module.scss b/jsapp/js/components/anonymousSubmission.module.scss new file mode 100644 index 0000000000..a64aef0a3a --- /dev/null +++ b/jsapp/js/components/anonymousSubmission.module.scss @@ -0,0 +1,13 @@ +@use 'scss/sizes'; +@use '~kobo-common/src/styles/colors'; + +.root { + display: flex; + padding-bottom: sizes.$x12; + padding-top: sizes.$x8; + border-bottom: 1px solid colors.$kobo-gray-96; +} + +a { + margin-left: sizes.$x8; +} diff --git a/jsapp/js/components/formLanding.js b/jsapp/js/components/formLanding.js index 2c557d44d5..a3d84e095d 100644 --- a/jsapp/js/components/formLanding.js +++ b/jsapp/js/components/formLanding.js @@ -10,60 +10,109 @@ import sessionStore from 'js/stores/session'; import PopoverMenu from 'js/popoverMenu'; import LoadingSpinner from 'js/components/common/loadingSpinner'; import InlineMessage from 'js/components/common/inlineMessage'; +import Icon from 'js/components/common/icon'; import mixins from '../mixins'; import {actions} from '../actions'; import DocumentTitle from 'react-document-title'; import CopyToClipboard from 'react-copy-to-clipboard'; -import { - MODAL_TYPES, - COLLECTION_METHODS, -} from '../constants'; +import {MODAL_TYPES, COLLECTION_METHODS} from '../constants'; import {ROUTES} from 'js/router/routerConstants'; -import { - formatTime, - notify, -} from 'utils'; -import { - Link, -} from 'react-router-dom'; +import {formatTime, notify} from 'utils'; +import {buildUserUrl, ANON_USERNAME} from 'js/users/utils'; +import {Link} from 'react-router-dom'; import {withRouter} from 'js/router/legacy'; -import {userCan, userCanRemoveSharedProject} from 'js/components/permissions/utils'; +import envStore from 'js/envStore'; +import { + userCan, + userCanRemoveSharedProject, +} from 'js/components/permissions/utils'; +import permConfig from 'js/components/permissions/permConfig'; +import {PERMISSIONS_CODENAMES} from 'js/components/permissions/permConstants'; +import ToggleSwitch from 'js/components/common/toggleSwitch'; +import {HELP_ARTICLE_ANON_SUBMISSIONS_URL} from 'js/constants'; +import AnonymousSubmission from './anonymousSubmission.component'; const DVCOUNT_LIMIT_MINIMUM = 20; +const ANON_CAN_ADD_PERM_URL = permConfig.getPermissionByCodename( + PERMISSIONS_CODENAMES.add_submissions +).url; class FormLanding extends React.Component { - constructor(props){ + constructor(props) { super(props); this.state = { selectedCollectMethod: COLLECTION_METHODS.offline_url.id, DVCOUNT_LIMIT: DVCOUNT_LIMIT_MINIMUM, nextPageUrl: null, - nextPagesVersions: [] + nextPagesVersions: [], + anonymousSubmissions: false, + anonymousPermissions: [], }; autoBind(this); } - componentDidMount () { + componentDidMount() { // reset loaded versions when new one is deployed - this.listenTo(actions.resources.deployAsset.completed, this.resetLoadedVersions); + this.listenTo( + actions.resources.deployAsset.completed, + this.resetLoadedVersions + ); + this.listenTo( + actions.permissions.getAssetPermissions.completed, + this.onAssetPermissionsUpdated + ); + + actions.permissions.getAssetPermissions(this.props.params.uid); + } + + onAssetPermissionsUpdated(response) { + const publicPerms = response.filter( + (assignment) => assignment.user === buildUserUrl(ANON_USERNAME) + ); + const anonCanAdd = publicPerms.filter( + (perm) => perm.permission === ANON_CAN_ADD_PERM_URL + )[0]; + + this.setState({ + anonymousPermissions: publicPerms, + anonymousSubmissions: anonCanAdd ? true : false, + }); + } + updateAssetAnonymousSubmissions() { + const permission = this.state.anonymousPermissions.find( + (perm) => + perm.permission === + permConfig.getPermissionByCodename(PERMISSIONS_CODENAMES.add_submissions).url + ); + if (this.state.anonymousSubmissions) { + actions.permissions.removeAssetPermission( + this.props.params.uid, + permission.url + ); + } else { + actions.permissions.assignAssetPermission(this.props.params.uid, { + user: buildUserUrl(ANON_USERNAME), + permission: ANON_CAN_ADD_PERM_URL, + }); + } } resetLoadedVersions() { this.setState({ DVCOUNT_LIMIT: DVCOUNT_LIMIT_MINIMUM, nextPageUrl: null, - nextPagesVersions: [] + nextPagesVersions: [], }); } - enketoPreviewModal (evt) { + enketoPreviewModal(evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.ENKETO_PREVIEW, - assetid: this.state.uid + assetid: this.state.uid, }); } callUnarchiveAsset() { this.unarchiveAsset(); } - renderFormInfo (userCanEdit) { + renderFormInfo(userCanEdit) { var dvcount = this.state.deployed_versions.count; var undeployedVersion; if (!this.isCurrentVersionDeployed()) { @@ -71,63 +120,66 @@ class FormLanding extends React.Component { dvcount = dvcount + 1; } return ( - - - - {dvcount > 0 ? `v${dvcount}` : ''} - - {undeployedVersion && userCanEdit && - -  {undeployedVersion} - - } - - {t('Last Modified')} :  - {formatTime(this.state.date_modified)} -  - - {this.state.summary.row_count || '0'}  - {t('questions')} - - + + + + {dvcount > 0 ? `v${dvcount}` : ''} - - {userCanEdit && this.state.deployment_status === 'deployed' && - - {t('redeploy')} - - } - {userCanEdit && this.state.deployment_status === 'draft' && - - {t('deploy')} - - } - {userCanEdit && this.state.deployment_status === 'archived' && - - {t('unarchive')} - - } + {undeployedVersion && userCanEdit && ( + +  {undeployedVersion} + + )} + + {t('Last Modified')} :  + {formatTime(this.state.date_modified)} -  + + {this.state.summary.row_count || '0'}  + {t('questions')} + - ); + + {userCanEdit && this.state.deployment_status === 'deployed' && ( + + {t('redeploy')} + + )} + {userCanEdit && this.state.deployment_status === 'draft' && ( + + {t('deploy')} + + )} + {userCanEdit && this.state.deployment_status === 'archived' && ( + + {t('unarchive')} + + )} + + + ); } - showSharingModal (evt) { + showSharingModal(evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.SHARING, - assetid: this.state.uid + assetid: this.state.uid, }); } - showReplaceProjectModal (evt) { + showReplaceProjectModal(evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.REPLACE_PROJECT, - asset: this.state + asset: this.state, }); } isCurrentVersionDeployed() { @@ -137,38 +189,49 @@ class FormLanding extends React.Component { this.state.deployed_version_id ) { const deployed_version = this.state.deployed_versions.results.find( - (version) => {return version.uid === this.state.deployed_version_id;} + (version) => { + return version.uid === this.state.deployed_version_id; + } ); return deployed_version.content_hash === this.state.version__content_hash; } return false; } isFormRedeploymentNeeded() { - return !this.isCurrentVersionDeployed() && userCan('change_asset', this.state); + return ( + !this.isCurrentVersionDeployed() && userCan('change_asset', this.state) + ); } hasLanguagesDefined(translations) { - return translations && (translations.length > 1 || translations[0] !== null); + return ( + translations && (translations.length > 1 || translations[0] !== null) + ); } - showLanguagesModal (evt) { + showLanguagesModal(evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.FORM_LANGUAGES, - asset: this.state + asset: this.state, }); } - showEncryptionModal (evt) { + showEncryptionModal(evt) { evt.preventDefault(); stores.pageState.showModal({ type: MODAL_TYPES.ENCRYPT_FORM, - asset: this.state + asset: this.state, }); } loadMoreVersions() { - if (this.state.DVCOUNT_LIMIT + DVCOUNT_LIMIT_MINIMUM <= this.state.deployed_versions.count + DVCOUNT_LIMIT_MINIMUM) { - this.setState({ DVCOUNT_LIMIT: this.state.DVCOUNT_LIMIT + DVCOUNT_LIMIT_MINIMUM }); + if ( + this.state.DVCOUNT_LIMIT + DVCOUNT_LIMIT_MINIMUM <= + this.state.deployed_versions.count + DVCOUNT_LIMIT_MINIMUM + ) { + this.setState({ + DVCOUNT_LIMIT: this.state.DVCOUNT_LIMIT + DVCOUNT_LIMIT_MINIMUM, + }); } let urlToLoad = null; - if(this.state.nextPageUrl) { + if (this.state.nextPageUrl) { urlToLoad = this.state.nextPageUrl; } else if (this.state.deployed_versions.next) { urlToLoad = this.state.deployed_versions.next; @@ -184,45 +247,58 @@ class FormLanding extends React.Component { }); } } - renderHistory () { + renderHistory() { var dvcount = this.state.deployed_versions.count; const versionsToDisplay = this.state.deployed_versions.results.concat( this.state.nextPagesVersions ); const isLoggedIn = sessionStore.isLoggedIn; return ( - + - - {t('Form history')} - + {t('Form history')} - {t('Version')} - {t('Last Modified')} - {isLoggedIn && - {t('Clone')} - } + + {t('Version')} + + + {t('Last Modified')} + + {isLoggedIn && ( + + {t('Clone')} + + )} {versionsToDisplay.map((item, n) => { if (dvcount - n > 0) { return ( - = this.state.DVCOUNT_LIMIT ? 'hidden' : ''} > + = this.state.DVCOUNT_LIMIT ? 'hidden' : ''} + > {`v${dvcount - n}`} - {item.uid === this.state.deployed_version_id && this.state.deployment__active && - - {t('Deployed')} - - } + {item.uid === this.state.deployed_version_id && + this.state.deployment__active && ( + + {t('Deployed')} + + )} {formatTime(item.date_deployed)} - {isLoggedIn && + {isLoggedIn && ( - } + )} ); } })} - {this.state.deployed_versions.count > 1 && + {this.state.deployed_versions.count > 1 && ( - {this.state.historyExpanded ? t('Hide full history') : t('Show full history')} + {this.state.historyExpanded + ? t('Hide full history') + : t('Show full history')} - {(this.state.historyExpanded && this.state.DVCOUNT_LIMIT < dvcount) && - - {t('Load more')} - - } + {this.state.historyExpanded && + this.state.DVCOUNT_LIMIT < dvcount && ( + + {t('Load more')} + + )} - } + )} ); } - renderCollectData () { + renderCollectData() { const deployment__links_list = []; Object.keys(COLLECTION_METHODS).forEach((methodId) => { const methodDef = COLLECTION_METHODS[methodId]; - deployment__links_list.push( - { - key: methodDef.id, - label: methodDef.label, - desc: methodDef.desc, - } - ); + deployment__links_list.push({ + key: methodDef.id, + label: methodDef.label, + desc: methodDef.desc, + }); }); const chosenMethod = this.state.selectedCollectMethod; @@ -278,13 +355,13 @@ class FormLanding extends React.Component { return ( - {t('Collect data')} + {t('Collect data')} {deployment__links_list.map((c) => { @@ -303,31 +380,36 @@ class FormLanding extends React.Component { - - {this.renderCollectLink()} - + {this.renderCollectLink()} - + {chosenMethod !== COLLECTION_METHODS.android.id && - COLLECTION_METHODS[chosenMethod].desc - } + COLLECTION_METHODS[chosenMethod].desc} - {chosenMethod === COLLECTION_METHODS.iframe_url.id && + {chosenMethod === COLLECTION_METHODS.iframe_url.id && (
                 {``}
               
- } + )} - {chosenMethod === COLLECTION_METHODS.android.id && + {chosenMethod === COLLECTION_METHODS.android.id && (
  1. {t('Install')}   - KoboCollect + + KoboCollect +   {t('on your Android device.')}
  2. -
  3. {t('Click on')} {t('to open settings.')}
  4. +
  5. + {t('Click on')} {' '} + {t('to open settings.')} +
  6. {t('Enter the server URL')}  {kobocollect_url}  @@ -336,9 +418,19 @@ class FormLanding extends React.Component {
  7. {t('Open "Get Blank Form" and select this project. ')}
  8. {t('Open "Enter Data."')}
- } - + )}
+ + {userCan('change_asset', this.state) && ( + + this.updateAssetAnonymousSubmissions()} + /> + + )}
); @@ -350,9 +442,11 @@ class FormLanding extends React.Component { if (chosenMethod === COLLECTION_METHODS.android.id) { return ( - + href={COLLECTION_METHODS.android.url} + > {t('Download KoboCollect')} ); @@ -362,9 +456,11 @@ class FormLanding extends React.Component { return ( - + {t('Link missing')} ); @@ -374,7 +470,9 @@ class FormLanding extends React.Component { return ( `} - onCopy={() => {notify(t('Copied to clipboard'));}} + onCopy={() => { + notify(t('Copied to clipboard')); + }} options={{format: 'text/plain'}} > - + href={chosenMethodLink} + > {t('Open')} @@ -437,27 +540,35 @@ class FormLanding extends React.Component { return ( - {userCanEdit ? - - + {userCanEdit ? ( + + - : - + data-tip={t( + 'Editing capabilities not granted, you can only view this form' + )} + > - } + )} - + data-tip={t('Preview')} + > - {userCanEdit && + {userCanEdit && ( - } + )} - +
} > {downloads.map((dl) => { return ( - - - {t('Download')}  - {dl.format.toString().toUpperCase()} - - ); + + + {t('Download')}  + {dl.format.toString().toUpperCase()} + + ); })} - {userCanEdit && + {userCanEdit && ( - + {t('Share this project')} - } + )} - {( - isLoggedIn && - userCanRemoveSharedProject(this.state) - ) && - - + {isLoggedIn && userCanRemoveSharedProject(this.state) && ( + + {t('Remove shared project')} - } + )} - {isLoggedIn && + {isLoggedIn && ( - + {t('Clone this project')} - } + )} - {isLoggedIn && + {isLoggedIn && ( - + {t('Create template')} - } + )} - {userCanEdit && this.state.content.survey.length > 0 && + {userCanEdit && this.state.content.survey.length > 0 && ( - + {t('Manage translations')} - } - { /* temporarily disabled + )} + {/* temporarily disabled {t('Manage Encryption')} - */ } + */} ); } - renderLanguages (canEdit) { + renderLanguages(canEdit) { let translations = this.state.content.translations; return ( @@ -548,40 +657,36 @@ class FormLanding extends React.Component { {t('Languages:')}   {!this.hasLanguagesDefined(translations) && - t('This project has no languages defined yet') - } - {this.hasLanguagesDefined(translations) && + t('This project has no languages defined yet')} + {this.hasLanguagesDefined(translations) && (
    {translations.map((langString, n) => { - return ( -
  • - {langString || t('Unnamed language')} -
  • - ); + return
  • {langString || t('Unnamed language')}
  • ; })}
- } + )} - {canEdit && + {canEdit && ( + onClick={this.showLanguagesModal} + > - } + )} ); } - render () { + render() { var docTitle = this.state.name || t('Untitled'); const userCanEdit = userCan('change_asset', this.state); const isLoggedIn = sessionStore.isLoggedIn; if (this.state.uid === undefined) { - return (); + return ; } return ( @@ -590,38 +695,40 @@ class FormLanding extends React.Component { - {this.state.deployment__active ? t('Current version') : - this.state.has_deployment ? t('Archived version') : - t('Draft version')} + {this.state.deployment__active + ? t('Current version') + : this.state.has_deployment + ? t('Archived version') + : t('Draft version')} {this.renderButtons(userCanEdit)} - {this.isFormRedeploymentNeeded() && + {this.isFormRedeploymentNeeded() && ( - } + )} {this.renderFormInfo(userCanEdit)} {this.renderLanguages(userCanEdit)} + {this.state.deployed_versions.count > 0 && this.renderHistory()} {this.state.deployed_versions.count > 0 && - this.renderHistory() - } - {this.state.deployed_versions.count > 0 && this.state.deployment__active && + this.state.deployment__active && isLoggedIn && - this.renderCollectData() - } + this.renderCollectData()} - ); + ); } } @@ -629,7 +736,7 @@ reactMixin(FormLanding.prototype, mixins.dmix); reactMixin(FormLanding.prototype, Reflux.ListenerMixin); FormLanding.contextTypes = { - router: PropTypes.object + router: PropTypes.object, }; export default withRouter(FormLanding); diff --git a/jsapp/js/components/permissions/publicShareSettings.component.tsx b/jsapp/js/components/permissions/publicShareSettings.component.tsx index e4fdbafcde..3c13e1aa3d 100644 --- a/jsapp/js/components/permissions/publicShareSettings.component.tsx +++ b/jsapp/js/components/permissions/publicShareSettings.component.tsx @@ -8,6 +8,12 @@ import {ANON_USERNAME_URL} from 'js/users/utils'; import {ROOT_URL} from 'js/constants'; import type {PermissionCodename} from './permConstants'; import type {PermissionResponse} from 'jsapp/js/dataInterface'; +import envStore from 'js/envStore'; +import Icon from 'js/components/common/icon'; +import ToggleSwitch from 'js/components/common/toggleSwitch'; +import AnonymousSubmission from '../anonymousSubmission.component'; + +const HELP_ARTICLE_ANON_SUBMISSIONS_URL = 'managing_permissions.html'; interface PublicShareSettingsProps { publicPerms: PermissionResponse[]; @@ -41,6 +47,8 @@ class PublicShareSettings extends React.Component { const anonCanViewPermUrl = permConfig.getPermissionByCodename('view_asset')?.url; + const anonCanAddPermUrl = + permConfig.getPermissionByCodename('add_submissions')?.url; const anonCanViewDataPermUrl = permConfig.getPermissionByCodename('view_submissions')?.url; @@ -54,9 +62,25 @@ class PublicShareSettings extends React.Component { (perm) => perm.permission === anonCanViewDataPermUrl )[0] ); + const anonCanAddData = Boolean( + this.props.publicPerms.filter( + (perm) => perm.permission === anonCanAddPermUrl + )[0] + ); return ( + + + + + + {t('Share publicly by link')} + + - - -

{t('Share publicly by link')}

- + perm.permission === + permConfig.getPermissionByCodename('add_submissions')?.url + ); if (userViewAssetPerm) { actions.permissions.removeAssetPermission( this.props.assetUid, userViewAssetPerm.url ); } + + // We have to remove this permission seprately as it can be granted without + // `view_asset`. + if (userAddSubmissionsPerm) { + actions.permissions.removeAssetPermission( + this.props.assetUid, + userAddSubmissionsPerm.url + ); + } } onPermissionsEditorSubmitEnd(isSuccess: boolean) { diff --git a/jsapp/js/constants.ts b/jsapp/js/constants.ts index 1c63df47f7..f585c2d8f7 100644 --- a/jsapp/js/constants.ts +++ b/jsapp/js/constants.ts @@ -638,4 +638,6 @@ const constants = { USAGE_WARNING_RATIO, }; +export const HELP_ARTICLE_ANON_SUBMISSIONS_URL = 'managing_permissions.html'; + export default constants; diff --git a/jsapp/scss/components/_kobo.form-view.scss b/jsapp/scss/components/_kobo.form-view.scss index 84ba3f3874..029d834719 100644 --- a/jsapp/scss/components/_kobo.form-view.scss +++ b/jsapp/scss/components/_kobo.form-view.scss @@ -1,4 +1,5 @@ @use "scss/_variables"; +@use 'scss/sizes'; @use 'scss/z-indexes'; @use '~kobo-common/src/styles/colors'; @@ -293,6 +294,10 @@ $side-tabs-width-mobile: 70px; padding: 20px; } + &.form-view__cell--small-padding { + padding: sizes.$x10 sizes.$x20 sizes.$x20 sizes.$x20; + } + &.form-view__cell--padding-small { padding: 10px 20px; } diff --git a/jsapp/scss/components/_kobo.tooltips.scss b/jsapp/scss/components/_kobo.tooltips.scss index 50d8c6cdbb..12c2747f90 100644 --- a/jsapp/scss/components/_kobo.tooltips.scss +++ b/jsapp/scss/components/_kobo.tooltips.scss @@ -96,6 +96,12 @@ Additional class names: transform: translate(0); } + .wrapped-tooltip [data-tip]::after, + .wrapped-tooltip[data-tip]::after { + width: 300px; + white-space: normal; + } + // more actions in asset-row adjustment .asset-row .popover-menu [data-tip]::after { left: -60%; diff --git a/kobo/apps/__init__.py b/kobo/apps/__init__.py index ed36d2c8cf..fa5bed6bcd 100644 --- a/kobo/apps/__init__.py +++ b/kobo/apps/__init__.py @@ -1,5 +1,4 @@ # coding: utf-8 -import kombu.exceptions from django.apps import AppConfig from django.core.checks import register, Tags diff --git a/kobo/apps/accounts/apps.py b/kobo/apps/accounts/apps.py index 6bbd80ea79..a671f69624 100644 --- a/kobo/apps/accounts/apps.py +++ b/kobo/apps/accounts/apps.py @@ -1,6 +1,4 @@ from django.apps import AppConfig -from django.conf import settings -from django.core.checks import Error, register # Config to set custom name for app in django admin UI diff --git a/kobo/apps/accounts/migrations/0002_create_extra_details_for_every_user.py b/kobo/apps/accounts/migrations/0002_create_extra_details_for_every_user.py index 3d155eab61..4ca91e7bf0 100644 --- a/kobo/apps/accounts/migrations/0002_create_extra_details_for_every_user.py +++ b/kobo/apps/accounts/migrations/0002_create_extra_details_for_every_user.py @@ -4,18 +4,14 @@ from django.core.paginator import Paginator from django.db import migrations +from kpi.constants import SKIP_HEAVY_MIGRATIONS_GUIDANCE + def create_extra_user_detail(apps, schema_editor): if settings.SKIP_HEAVY_MIGRATIONS: return - print( - """ - This migration might take a while. If it is too slow, you may want to - re-run migrations with SKIP_HEAVY_MIGRATIONS=True and apply this one - manually from the django shell. - """ - ) + print(SKIP_HEAVY_MIGRATIONS_GUIDANCE) User = apps.get_model('auth', 'User') ExtraUserDetail = apps.get_model('hub', 'ExtraUserDetail') diff --git a/kobo/apps/service_health/views.py b/kobo/apps/service_health/views.py index be1b0b01aa..25b651b983 100644 --- a/kobo/apps/service_health/views.py +++ b/kobo/apps/service_health/views.py @@ -4,7 +4,7 @@ import requests from django.conf import settings -from django.core.cache import cache +from django.core.cache import cache, caches from django.http import HttpResponse from kobo.celery import celery_app @@ -75,6 +75,7 @@ def service_health(request): 'Enketo': lambda: requests.get( settings.ENKETO_INTERNAL_URL, timeout=10 ).raise_for_status(), + 'Enketo Redis (main)': lambda: caches['enketo_redis_main'].set('a', True, 1), } check_results = [] diff --git a/kobo/apps/superuser_stats/tasks.py b/kobo/apps/superuser_stats/tasks.py index f204e546ca..8a8935579e 100644 --- a/kobo/apps/superuser_stats/tasks.py +++ b/kobo/apps/superuser_stats/tasks.py @@ -14,7 +14,7 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import User -from django.core.files.storage import get_storage_class +from django.core.files.storage import default_storage from django.db.models import ( CharField, Count, @@ -77,7 +77,6 @@ def get_row_for_country(code_: str, label_: str): 'Count', ] - default_storage = get_storage_class()() with default_storage.open(output_filename, 'w') as output_file: writer = csv.writer(output_file) writer.writerow(columns) @@ -596,7 +595,6 @@ def generate_user_details_report( 'city', 'bio', 'organization', - 'require_auth', 'primarySector', 'organization_website', 'twitter', @@ -644,7 +642,6 @@ def get_row_value( .order_by('id') ) - default_storage = get_storage_class()() with default_storage.open(output_filename, 'w') as f: columns = USER_COLS + EXTRA_DETAILS_COLS writer = csv.writer(f) diff --git a/kobo/apps/trash_bin/models/project.py b/kobo/apps/trash_bin/models/project.py index 89c483d195..dc3288770d 100644 --- a/kobo/apps/trash_bin/models/project.py +++ b/kobo/apps/trash_bin/models/project.py @@ -8,7 +8,7 @@ from kpi.deployment_backends.kc_access.utils import kc_transaction_atomic from kpi.fields import KpiUidField from kpi.models.asset import Asset, AssetDeploymentStatus -from kpi.utils.django_orm_helper import ReplaceValues +from kpi.utils.django_orm_helper import UpdateJSONFieldAttributes from . import BaseTrash @@ -49,7 +49,7 @@ def toggle_asset_statuses( kc_update_params = {'downloadable': active} update_params = { - '_deployment_data': ReplaceValues( + '_deployment_data': UpdateJSONFieldAttributes( '_deployment_data', updates={'active': active}, ), diff --git a/kobo/settings/base.py b/kobo/settings/base.py index 7bc7df7815..335307beec 100644 --- a/kobo/settings/base.py +++ b/kobo/settings/base.py @@ -1,7 +1,6 @@ # coding: utf-8 import logging import os -import re import string import subprocess from datetime import datetime @@ -612,6 +611,7 @@ def __init__(self, *args, **kwargs): ALLOWED_ANONYMOUS_PERMISSIONS = ( 'kpi.view_asset', 'kpi.discover_asset', + 'kpi.add_submissions', 'kpi.view_submissions', ) @@ -1301,7 +1301,10 @@ def dj_stripe_request_callback_method(): CACHES = { # Set CACHE_URL to override - 'default': env.cache(default='redis://redis_cache:6380/3'), + 'default': env.cache_url(default='redis://redis_cache:6380/3'), + 'enketo_redis_main': env.cache_url( + 'ENKETO_REDIS_MAIN_URL', default='redis://change-me.invalid/0' + ), } # How long to retain cached responses for kpi endpoints diff --git a/kpi/constants.py b/kpi/constants.py index 71c5d0da84..269394aca8 100644 --- a/kpi/constants.py +++ b/kpi/constants.py @@ -110,3 +110,11 @@ 'tags__name__icontains', 'uid__icontains', ] + +SKIP_HEAVY_MIGRATIONS_GUIDANCE = ( + """ + This migration might take a while. If it is too slow, you may want to + re-run migrations with SKIP_HEAVY_MIGRATIONS=True and apply this one + manually from the django shell. + """ +) diff --git a/kpi/deployment_backends/base_backend.py b/kpi/deployment_backends/base_backend.py index bffc8d32f3..9f099bda6c 100644 --- a/kpi/deployment_backends/base_backend.py +++ b/kpi/deployment_backends/base_backend.py @@ -24,7 +24,7 @@ ) from kpi.models.asset_file import AssetFile from kpi.models.paired_data import PairedData -from kpi.utils.django_orm_helper import ReplaceValues +from kpi.utils.django_orm_helper import UpdateJSONFieldAttributes class BaseDeploymentBackend(abc.ABC): @@ -79,6 +79,12 @@ def bulk_update_submissions( def calculated_submission_count(self, user: 'auth.User', **kwargs): pass + @abc.abstractmethod + def set_enketo_open_rosa_server( + self, require_auth: bool, enketo_id: str = None + ): + pass + @property @abc.abstractmethod def submission_count_since_date(self, start_date: Optional[datetime.date] = None): @@ -109,6 +115,11 @@ def duplicate_submission( ) -> dict: pass + @property + @abc.abstractmethod + def enketo_id(self): + pass + @abc.abstractmethod def get_attachment( self, @@ -285,8 +296,12 @@ def save_to_db(self, updates: dict): self.store_data(updates) self.asset.set_deployment_status() + + # never save `_stored_data_key` attribute + updates.pop('_stored_data_key', None) + self.asset.__class__.objects.filter(id=self.asset.pk).update( - _deployment_data=ReplaceValues( + _deployment_data=UpdateJSONFieldAttributes( '_deployment_data', updates=updates, ), @@ -328,6 +343,7 @@ def status(self): def store_data(self, values: dict): """ Saves in memory only; writes nothing to the database """ + values = copy.deepcopy(values) self.__stored_data_key = ShortUUID().random(24) values['_stored_data_key'] = self.__stored_data_key self.asset._deployment_data.update(values) # noqa diff --git a/kpi/deployment_backends/kc_access/shadow_models.py b/kpi/deployment_backends/kc_access/shadow_models.py index b3f13ded93..a12633bdd5 100644 --- a/kpi/deployment_backends/kc_access/shadow_models.py +++ b/kpi/deployment_backends/kc_access/shadow_models.py @@ -1,7 +1,6 @@ # coding: utf-8 from __future__ import annotations -from datetime import datetime from typing import Optional from django.conf import settings @@ -391,7 +390,6 @@ class Meta(ShadowModel.Meta): class KobocatUserProfile(ShadowModel): """ From onadata/apps/main/models/user_profile.py - Not read-only because we need write access to `require_auth` """ class Meta(ShadowModel.Meta): db_table = 'main_userprofile' @@ -411,10 +409,7 @@ class Meta(ShadowModel.Meta): home_page = models.CharField(max_length=255, blank=True) twitter = models.CharField(max_length=255, blank=True) description = models.CharField(max_length=255, blank=True) - require_auth = models.BooleanField( - default=False, - verbose_name="Require authentication to see forms and submit data" - ) + require_auth = models.BooleanField(default=True) address = models.CharField(max_length=255, blank=True) phonenumber = models.CharField(max_length=30, blank=True) num_of_submissions = models.IntegerField(default=0) @@ -482,8 +477,9 @@ class Meta(ShadowModel.Meta): XFORM_TITLE_LENGTH = 255 xls = models.FileField(null=True) xml = models.TextField() - user = models.ForeignKey(KobocatUser, related_name='xforms', null=True, - on_delete=models.CASCADE) + user = models.ForeignKey( + KobocatUser, related_name='xforms', null=True, on_delete=models.CASCADE + ) shared = models.BooleanField(default=False) shared_data = models.BooleanField(default=False) downloadable = models.BooleanField(default=True) @@ -497,6 +493,7 @@ class Meta(ShadowModel.Meta): attachment_storage_bytes = models.BigIntegerField(default=0) kpi_asset_uid = models.CharField(max_length=32, null=True) pending_delete = models.BooleanField(default=False) + require_auth = models.BooleanField(default=True) @property def md5_hash(self): diff --git a/kpi/deployment_backends/kc_access/utils.py b/kpi/deployment_backends/kc_access/utils.py index 5d71e5be7b..13b17386ce 100644 --- a/kpi/deployment_backends/kc_access/utils.py +++ b/kpi/deployment_backends/kc_access/utils.py @@ -92,7 +92,6 @@ def get_kc_profile_data(user_id): 'address', 'city', 'country', - 'require_auth', 'twitter', 'metadata', ] @@ -115,28 +114,6 @@ def get_kc_profile_data(user_id): return result -def set_kc_require_auth(user_id, require_auth): - """ - Configure whether or not authentication is required to see and submit data - to a user's projects. - WRITES to KobocatUserProfile.require_auth - - :param int user_id: ID/primary key of the :py:class:`User` object. - :param bool require_auth: The desired setting. - """ - user = User.objects.get(pk=user_id) - _trigger_kc_profile_creation(user) - with transaction.atomic(): - token, _ = Token.objects.get_or_create(user=user) - try: - KobocatUserProfile.objects.filter(user_id=user_id).update( - require_auth=require_auth - ) - except ProgrammingError as e: - raise ProgrammingError('set_kc_require_auth error accessing ' - 'kobocat tables: {}'.format(repr(e))) - - def _get_content_type_kwargs_for_related(obj): r""" Given an `obj` with a `KC_CONTENT_TYPE_KWARGS` dictionary attribute, @@ -291,6 +268,7 @@ def set_kc_anonymous_permissions_xform_flags(obj, kpi_codenames, xform_id, if remove: flags = {flag: not value for flag, value in flags.items()} xform_updates.update(flags) + # Write to the KC database KobocatXForm.objects.filter(pk=xform_id).update(**xform_updates) diff --git a/kpi/deployment_backends/kobocat_backend.py b/kpi/deployment_backends/kobocat_backend.py index d11a7389be..7a58a1e533 100644 --- a/kpi/deployment_backends/kobocat_backend.py +++ b/kpi/deployment_backends/kobocat_backend.py @@ -12,9 +12,6 @@ from typing import Generator, Optional, Union from urllib.parse import urlparse from xml.etree import ElementTree as ET - -from django.db.models.functions import Coalesce - try: from zoneinfo import ZoneInfo except ImportError: @@ -23,13 +20,15 @@ import requests from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from lxml import etree from django.core.files import File from django.db.models import Sum +from django.db.models.functions import Coalesce from django.db.models.query import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as t +from django_redis import get_redis_connection from kobo_service_account.utils import get_request_headers +from lxml import etree from rest_framework import status from rest_framework.reverse import reverse @@ -55,7 +54,6 @@ from kpi.interfaces.sync_backend_media import SyncBackendMediaInterface from kpi.models.asset_file import AssetFile from kpi.models.object_permission import ObjectPermission -from kpi.models.paired_data import PairedData from kpi.utils.log import logging from kpi.utils.mongo_helper import MongoHelper from kpi.utils.object_permission import get_database_user @@ -585,6 +583,13 @@ def edit_submission( kc_response, expected_response_format='xml' ) + @property + def enketo_id(self): + if not (enketo_id := self.get_data('enketo_id')): + self.get_enketo_survey_links() + enketo_id = self.get_data('enketo_id') + return enketo_id + @staticmethod def external_to_internal_url(url): """ @@ -810,6 +815,9 @@ def get_data_download_links(self): return links def get_enketo_survey_links(self): + if not self.get_data('backend_response'): + return {} + data = { 'server_url': '{}/{}'.format( settings.KOBOCAT_URL.rstrip('/'), @@ -817,6 +825,7 @@ def get_enketo_survey_links(self): ), 'form_id': self.backend_response['id_string'] } + try: response = requests.post( f'{settings.ENKETO_URL}/{settings.ENKETO_SURVEY_ENDPOINT}', @@ -835,6 +844,33 @@ def get_enketo_survey_links(self): except ValueError: logging.error('Received invalid JSON from Enketo', exc_info=True) return {} + + try: + enketo_id = links.pop('enketo_id') + except KeyError: + logging.error( + 'Invalid response from Enketo: `enketo_id` is not found', + exc_info=True, + ) + return {} + + stored_enketo_id = self.get_data('enketo_id') + if stored_enketo_id != enketo_id: + if stored_enketo_id: + logging.warning( + f'Enketo ID has changed from {stored_enketo_id} to {enketo_id}' + ) + self.save_to_db({'enketo_id': enketo_id}) + + if self.xform.require_auth: + # Unfortunately, EE creates unique ID based on OpenRosa server URL. + # Thus, we need to always generated the ID with the same URL + # (i.e.: with username) to be retro-compatible and then, + # overwrite the OpenRosa server URL again. + self.set_enketo_open_rosa_server( + require_auth=True, enketo_id=enketo_id + ) + for discard in ('enketo_id', 'code', 'preview_iframe_url'): try: del links[discard] @@ -1082,6 +1118,30 @@ def set_asset_uid(self, force: bool = False) -> bool: }) return True + def set_enketo_open_rosa_server( + self, require_auth: bool, enketo_id: str = None + ): + # Kobocat handles Open Rosa requests with different accesses. + # - Authenticated access, https://[kc] + # - Anonymous access, https://[kc]/username + # Enketo generates its unique ID based on the server URL. + # Thus, if the project requires authentication, we need to update Redis + # directly to keep the same ID and let Enketo submit data to correct + # endpoint + if not enketo_id: + enketo_id = self.enketo_id + + server_url = settings.KOBOCAT_URL.rstrip('/') + if not require_auth: + server_url = f'{server_url}/{self.asset.owner.username}' + + enketo_redis_client = get_redis_connection('enketo_redis_main') + enketo_redis_client.hset( + f'id:{enketo_id}', + 'openRosaServer', + server_url, + ) + def set_has_kpi_hooks(self): """ `PATCH` `has_kpi_hooks` boolean of related KoBoCAT XForm. @@ -1298,6 +1358,7 @@ def xform(self): 'id_string', 'num_of_submissions', 'attachment_storage_bytes', + 'require_auth', ) .select_related( 'user' diff --git a/kpi/deployment_backends/mock_backend.py b/kpi/deployment_backends/mock_backend.py index 76a5a881f3..61bcead2b0 100644 --- a/kpi/deployment_backends/mock_backend.py +++ b/kpi/deployment_backends/mock_backend.py @@ -304,6 +304,10 @@ def duplicate_submission( self.asset.deployment.mock_submissions([duplicated_submission]) return duplicated_submission + @property + def enketo_id(self): + return 'self' + def get_attachment( self, submission_id_or_uuid: Union[int, str], @@ -379,14 +383,11 @@ def get_data_download_links(self): return {} def get_enketo_survey_links(self): - # `self` is a demo Enketo form, but there's no guarantee it'll be - # around forever. return { - 'offline_url': 'https://enke.to/_/#self', - 'url': 'https://enke.to/::self', - 'iframe_url': 'https://enke.to/i/::self', - 'preview_url': 'https://enke.to/preview/::self', - # 'preview_iframe_url': 'https://enke.to/preview/i/::self', + 'offline_url': f'https://example.org/_/#{self.enketo_id}', + 'url': f'https://example.org/::#{self.enketo_id}', + 'iframe_url': f'https://example.org/i/::#{self.enketo_id}', + 'preview_url': f'https://example.org/preview/::#{self.enketo_id}', } def get_submission_detail_url(self, submission_id: int) -> str: @@ -527,6 +528,11 @@ def set_asset_uid(self, **kwargs) -> bool: 'backend_response': backend_response }) + def set_enketo_open_rosa_server( + self, require_auth: bool, enketo_id: str = None + ): + pass + def set_has_kpi_hooks(self): """ Store a boolean which indicates that KPI has active hooks (or not) @@ -676,6 +682,13 @@ def sync_media_files(self, file_type: str = AssetFile.FORM_MEDIA): for obj in queryset: assert issubclass(obj.__class__, SyncBackendMediaInterface) + @property + def xform(self): + """ + Dummy property, only present to be mocked by unit tests + """ + pass + @classmethod def __prepare_bulk_update_data(cls, updates: dict) -> dict: """ diff --git a/kpi/migrations/0054_set_deployment_data__stored_data_key_to_null.py b/kpi/migrations/0054_set_deployment_data__stored_data_key_to_null.py new file mode 100644 index 0000000000..d9631223bf --- /dev/null +++ b/kpi/migrations/0054_set_deployment_data__stored_data_key_to_null.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.15 on 2023-11-02 15:30 + +from django.conf import settings +from django.db import migrations + +from kpi.constants import SKIP_HEAVY_MIGRATIONS_GUIDANCE +from kpi.utils.django_orm_helper import RemoveJSONFieldAttribute + + +def set_stored_data_key_to_null(apps, schema_editor): + if settings.SKIP_HEAVY_MIGRATIONS: + return + + print(SKIP_HEAVY_MIGRATIONS_GUIDANCE) + + Asset = apps.get_model('kpi', 'Asset') # noqa + + # it's faster to bulk update '_stored_data_key' than trying to remove it + Asset.objects.filter(_deployment_data__has_key='_stored_data_key').update( + _deployment_data=RemoveJSONFieldAttribute( + '_deployment_data', + attribute_dotted_path='_stored_data_key', + ), + ) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('kpi', '0053_set_user_fullname_required_by_default'), + ] + + operations = [ + migrations.RunPython(set_stored_data_key_to_null, noop), + ] diff --git a/kpi/migrations/0055_set_require_auth_per_project.py b/kpi/migrations/0055_set_require_auth_per_project.py new file mode 100644 index 0000000000..1bfd59ded7 --- /dev/null +++ b/kpi/migrations/0055_set_require_auth_per_project.py @@ -0,0 +1,81 @@ +# Generated by Django 3.2.15 on 2023-11-02 15:30 +from itertools import islice + +from django.conf import settings +from django.db import migrations + +from kpi.constants import PERM_ADD_SUBMISSIONS, SKIP_HEAVY_MIGRATIONS_GUIDANCE +from kpi.deployment_backends.kc_access.shadow_models import KobocatUserProfile + +CHUNK_SIZE = 2000 + + +def assign_add_submissions_to_anonymous_users(apps, schema_editor): + if settings.SKIP_HEAVY_MIGRATIONS or settings.TESTING: + return + + print(SKIP_HEAVY_MIGRATIONS_GUIDANCE) + + Asset = apps.get_model('kpi', 'Asset') # noqa + ObjectPermission = apps.get_model('kpi', 'ObjectPermission') # noqa + Permission = apps.get_model('auth', 'Permission') # noqa + permission_id = Permission.objects.get(codename=PERM_ADD_SUBMISSIONS).pk + + owner_iter = ( + KobocatUserProfile.objects.filter(require_auth=False) + .values_list('user_id', flat=True) + .iterator(chunk_size=CHUNK_SIZE) + ) + + while True: + owner_ids = list(islice(owner_iter, CHUNK_SIZE)) + if not owner_ids: + break + ObjectPermission.objects.bulk_create( + [ + ObjectPermission( + permission_id=permission_id, + user_id=settings.ANONYMOUS_USER_ID, + asset_id=asset_id, + deny=False, + inherited=False, + ) + for asset_id in Asset.objects.filter( + owner_id__in=owner_ids + ).values_list('pk', flat=True) + ], + ignore_conflicts=True, + ) + + +def remove_add_submissions_from_anonymous_users(apps, schema_editor): + if settings.SKIP_HEAVY_MIGRATIONS: + return + + print(SKIP_HEAVY_MIGRATIONS_GUIDANCE) + + Asset = apps.get_model('kpi', 'Asset') # noqa + ObjectPermission = apps.get_model('kpi', 'ObjectPermission') # noqa + Permission = apps.get_model('auth', 'Permission') # noqa + permission_id = Permission.objects.get(codename=PERM_ADD_SUBMISSIONS).pk + + ObjectPermission.objects.filter( + deny=False, + inherited=False, + permission_id=permission_id, + user_id=settings.ANONYMOUS_USER_ID, + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('kpi', '0054_set_deployment_data__stored_data_key_to_null'), + ] + + operations = [ + migrations.RunPython( + assign_add_submissions_to_anonymous_users, + remove_add_submissions_from_anonymous_users, + ), + ] diff --git a/kpi/mixins/object_permission.py b/kpi/mixins/object_permission.py index 1899b35bc6..d164551876 100644 --- a/kpi/mixins/object_permission.py +++ b/kpi/mixins/object_permission.py @@ -30,6 +30,8 @@ from kpi.utils.object_permission import ( get_database_user, perm_parse, + post_assign_perm, + post_remove_perm, ) from kpi.utils.permissions import is_user_anonymous from kpi.utils.project_views import ( @@ -539,8 +541,15 @@ def assign_perm(self, user_obj, perm, deny=False, defer_recalc=False, if defer_recalc: return new_permission - self._update_partial_permissions(user_obj, perm, - partial_perms=partial_perms) + self._update_partial_permissions( + user_obj, perm, partial_perms=partial_perms + ) + post_assign_perm.send( + sender=self.__class__, + instance=self, + user=user_obj, + codename=codename, + ) # Recalculate all descendants self.recalculate_descendants_perms() @@ -700,6 +709,14 @@ def remove_perm(self, user_obj, perm, defer_recalc=False, skip_kc=False): return self._update_partial_permissions(user_obj, perm, remove=True) + + post_remove_perm.send( + sender=self.__class__, + instance=self, + user=user_obj, + codename=codename, + ) + # Recalculate all descendants self.recalculate_descendants_perms() diff --git a/kpi/models/asset.py b/kpi/models/asset.py index bbe96b2e42..a604ee8b2a 100644 --- a/kpi/models/asset.py +++ b/kpi/models/asset.py @@ -379,7 +379,6 @@ class Meta: if p not in (PERM_MANAGE_ASSET, PERM_PARTIAL_SUBMISSIONS) ) ), - PERM_ADD_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_VIEW_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_PARTIAL_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_CHANGE_SUBMISSIONS: ( @@ -415,6 +414,7 @@ class Meta: KC_CONTENT_TYPE_KWARGS = {'app_label': 'logger', 'model': 'xform'} # KC records anonymous access as flags on the `XForm` KC_ANONYMOUS_PERMISSIONS_XFORM_FLAGS = { + PERM_ADD_SUBMISSIONS: {'require_auth': False}, PERM_VIEW_SUBMISSIONS: {'shared': True, 'shared_data': True} } diff --git a/kpi/permissions.py b/kpi/permissions.py index 7f139e98ec..a4dcb31f4f 100644 --- a/kpi/permissions.py +++ b/kpi/permissions.py @@ -3,7 +3,6 @@ from typing import Union -from django.contrib.auth.models import User from django.http import Http404 from kobo_service_account.utils import get_real_user from rest_framework import exceptions, permissions @@ -16,6 +15,7 @@ PERM_VIEW_ASSET, PERM_VIEW_SUBMISSIONS, ) +from kpi.exceptions import DeploymentNotFound from kpi.mixins.validation_password_permission import ValidationPasswordPermissionMixin from kpi.models.asset import Asset from kpi.utils.object_permission import get_database_user @@ -468,11 +468,11 @@ def has_object_permission(self, request, view, obj): Otherwise, the paired source (the parent project) data may be exposed to anyone. """ - # Check whether `asset` owner's account requires authentication: + # Check whether the project requires authentication try: - require_auth = obj.asset.owner.extra_details.data['require_auth'] - except (User.extra_details.RelatedObjectDoesNotExist, KeyError): - require_auth = False + require_auth = obj.asset.deployment.xform.require_auth + except (DeploymentNotFound, AttributeError): + require_auth = True real_user = get_real_user(request) diff --git a/kpi/serializers/current_user.py b/kpi/serializers/current_user.py index f017e0e255..301d7b7a60 100644 --- a/kpi/serializers/current_user.py +++ b/kpi/serializers/current_user.py @@ -20,7 +20,6 @@ from kobo.apps.accounts.serializers import SocialAccountSerializer from kobo.apps.constance_backends.utils import to_python_object from kpi.deployment_backends.kc_access.utils import get_kc_profile_data -from kpi.deployment_backends.kc_access.utils import set_kc_require_auth from kpi.fields import WritableJSONField from kpi.utils.gravatar_url import gravatar_url @@ -124,16 +123,9 @@ def to_representation(self, obj): except KeyError: pass - # `require_auth` needs to be read from KC every time - # except during testing, when KC's database is not available - if ( - settings.KOBOCAT_URL - and settings.KOBOCAT_INTERNAL_URL - and not settings.TESTING - ): - extra_details['require_auth'] = get_kc_profile_data(obj.pk).get( - 'require_auth', False - ) + # TODO Remove `require_auth` when front end do not use it anymore. + # It is not used anymore by back end. Still there for retro-compatibility + extra_details['require_auth'] = True return rep @@ -214,15 +206,6 @@ def update(self, instance, validated_data): extra_details_obj, _ = ExtraUserDetail.objects.get_or_create( user=instance ) - if ( - settings.KOBOCAT_URL - and settings.KOBOCAT_INTERNAL_URL - and 'require_auth' in extra_details['data'] - ): - # `require_auth` needs to be written back to KC - set_kc_require_auth( - instance.pk, extra_details['data']['require_auth'] - ) # This is a PATCH, so retain existing values for keys that were # not included in the request diff --git a/kpi/signals.py b/kpi/signals.py index 98d4c1e823..6c14184331 100644 --- a/kpi/signals.py +++ b/kpi/signals.py @@ -1,12 +1,15 @@ -# coding: utf-8 +from typing import Union + from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth.models import User, AnonymousUser from django.db.models.signals import post_save, post_delete + from django.dispatch import receiver from rest_framework.authtoken.models import Token from taggit.models import Tag from kobo.apps.hook.models.hook import Hook +from kpi.constants import PERM_ADD_SUBMISSIONS from kpi.deployment_backends.kc_access.shadow_models import ( KobocatToken, KobocatUser, @@ -15,8 +18,13 @@ grant_kc_model_level_perms, kc_transaction_atomic, ) +from kpi.exceptions import DeploymentNotFound from kpi.models import Asset, TagUid -from kpi.utils.permissions import grant_default_model_level_perms +from kpi.utils.object_permission import post_assign_perm, post_remove_perm +from kpi.utils.permissions import ( + grant_default_model_level_perms, + is_user_anonymous, +) @receiver(post_save, sender=User) @@ -100,3 +108,39 @@ def post_delete_asset(sender, instance, **kwargs): else: if parent: parent.update_languages() + + +@receiver(post_assign_perm, sender=Asset) +def post_assign_asset_perm( + sender, + instance, + user: Union['auth.User', 'AnonymousUser'], + codename: str, + **kwargs +): + + if not (is_user_anonymous(user) and codename == PERM_ADD_SUBMISSIONS): + return + + try: + instance.deployment.set_enketo_open_rosa_server(require_auth=False) + except DeploymentNotFound: + return + + +@receiver(post_remove_perm, sender=Asset) +def post_remove_asset_perm( + sender, + instance, + user: Union['auth.User', 'AnonymousUser'], + codename: str, + **kwargs +): + + if not (is_user_anonymous(user) and codename == PERM_ADD_SUBMISSIONS): + return + + try: + instance.deployment.set_enketo_open_rosa_server(require_auth=True) + except DeploymentNotFound: + return diff --git a/kpi/tests/api/v2/test_api_asset_permission_assignment.py b/kpi/tests/api/v2/test_api_asset_permission_assignment.py index 91c2a0788b..6c747e1448 100644 --- a/kpi/tests/api/v2/test_api_asset_permission_assignment.py +++ b/kpi/tests/api/v2/test_api_asset_permission_assignment.py @@ -682,7 +682,7 @@ def test_no_assignments_saved_on_error(self): bad_assignments = [ { 'user': 'AnonymousUser', - 'permission': PERM_ADD_SUBMISSIONS, # should return a 400 + 'permission': PERM_DELETE_SUBMISSIONS, # should return a 400 }, { 'user': 'someuser', diff --git a/kpi/tests/api/v2/test_api_paired_data.py b/kpi/tests/api/v2/test_api_paired_data.py index 6956c6dd7c..a35d731777 100644 --- a/kpi/tests/api/v2/test_api_paired_data.py +++ b/kpi/tests/api/v2/test_api_paired_data.py @@ -1,5 +1,7 @@ # coding: utf-8 import unittest +from mock import patch, MagicMock, PropertyMock + from django.contrib.auth.models import User from django.urls import reverse from rest_framework import status @@ -376,8 +378,6 @@ def test_get_external_with_auth_on(self): # collectors need to have 'add_submission' permission to view the paired # data. self.client.logout() - self.anotheruser.extra_details.data['require_auth'] = True - self.anotheruser.extra_details.save() self.login_as_other_user('quidam', 'quidam') response = self.client.get(self.external_xml_url) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) @@ -391,21 +391,13 @@ def test_get_external_with_no_auth(self): # When owner's destination asset does not require any authentications, # everybody can see their data self.client.logout() - response = self.client.get(self.external_xml_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_get_external_from_owner_with_extra_detail(self): - self.deploy_source() - # When owner's destination asset does not require any authentications, - # everybody can see their data - self.client.logout() - - # Remove owner's extra detail - ExtraUserDetail.objects.filter(user=self.anotheruser).delete() - self.anotheruser.refresh_from_db() - - response = self.client.get(self.external_xml_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) + with patch( + 'kpi.deployment_backends.backends.MockDeploymentBackend.xform', + MagicMock(), + ) as xf_mock: + xf_mock.require_auth = False + response = self.client.get(self.external_xml_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) @unittest.skip(reason='Skip until mock back end supports XML submissions') def test_get_external_with_changed_source_fields(self): diff --git a/kpi/tests/test_deployment_backends.py b/kpi/tests/test_deployment_backends.py index 894db4268f..64612e9277 100644 --- a/kpi/tests/test_deployment_backends.py +++ b/kpi/tests/test_deployment_backends.py @@ -136,8 +136,11 @@ def test_save_data(self): # Using the deployment should work self.asset.deployment.store_data({'direct_access': True}) + self.assertTrue('_stored_data_key' in self.asset._deployment_data) self.asset.save() + self.assertFalse('_stored_data_key' in self.asset._deployment_data) self.asset.refresh_from_db() + self.assertFalse('_stored_data_key' in self.asset._deployment_data) self.assertNotEqual(self.asset._deployment_data, deployment_data) # noqa self.assertTrue(self.asset._deployment_data['direct_access']) # noqa diff --git a/kpi/tests/test_permissions.py b/kpi/tests/test_permissions.py index d1a84c0ccc..b88f9d58a0 100644 --- a/kpi/tests/test_permissions.py +++ b/kpi/tests/test_permissions.py @@ -255,7 +255,6 @@ def test_remove_collection_inherited_permission(self): def test_implied_asset_grant_permissions(self): implications = { PERM_CHANGE_ASSET: (PERM_VIEW_ASSET,), - PERM_ADD_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_VIEW_SUBMISSIONS: (PERM_VIEW_ASSET,), PERM_CHANGE_SUBMISSIONS: ( PERM_VIEW_ASSET, @@ -304,6 +303,9 @@ def test_remove_implied_asset_permissions(self): sorted(asset.get_perms(grantee)), sorted(expected_perms) ) asset.remove_perm(grantee, PERM_VIEW_ASSET) + # `add_submissions` does not imply `view_asset` anymore. + self.assertListEqual(asset.get_perms(grantee), [PERM_ADD_SUBMISSIONS]) + asset.remove_perm(grantee, PERM_ADD_SUBMISSIONS) self.assertListEqual(asset.get_perms(grantee), []) asset.assign_perm(grantee, PERM_VALIDATE_SUBMISSIONS) @@ -350,7 +352,6 @@ def test_implied_asset_deny_permissions(self): user=grantee, deny=True).values_list( 'permission__codename', flat=True) ), [ - PERM_ADD_SUBMISSIONS, PERM_CHANGE_ASSET, PERM_CHANGE_SUBMISSIONS, PERM_DELETE_SUBMISSIONS, @@ -378,7 +379,6 @@ def test_contradict_implied_asset_deny_permissions(self): user=grantee, deny=True).values_list( 'permission__codename', flat=True) ), [ - PERM_ADD_SUBMISSIONS, PERM_CHANGE_ASSET, PERM_CHANGE_SUBMISSIONS, PERM_DELETE_SUBMISSIONS, diff --git a/kpi/utils/django_orm_helper.py b/kpi/utils/django_orm_helper.py index 7c9a142676..57e2dd55ee 100644 --- a/kpi/utils/django_orm_helper.py +++ b/kpi/utils/django_orm_helper.py @@ -77,10 +77,36 @@ def __init__( super().__init__(expression, order_list=order_list, **extra) -class ReplaceValues(Func): +class RemoveJSONFieldAttribute(Func): + + """ + Remove attribute from models.JSONField. It supports nested attributes by + targeting the attribute with its dotted path. + E.g., to remove `foo1` in `{"foo": {"foo1": "bar1", "foo2": "bar2"}}`, + `foo.foo1` should be passed as `attribute_dotted_path` parameter. + """ + + arg_joiner = ' #- ' + template = "%(expressions)s" + arity = 2 + + def __init__( + self, + expression: str, + attribute_dotted_path: str, + **extra, + ): + super().__init__( + expression, + Value('{' + attribute_dotted_path.replace('.', ',') + '}'), + **extra, + ) + + +class UpdateJSONFieldAttributes(Func): """ - Updates several properties at once of a models.JSONField without overwriting the - whole document. + Updates several attributes at once of a models.JSONField without overwriting + the whole document. Avoids race conditions when document is saved in two different transactions at the same time. (i.e.: `Asset._deployment['status']`) https://www.postgresql.org/docs/current/functions-json.html diff --git a/kpi/utils/object_permission.py b/kpi/utils/object_permission.py index 9cb17e05a9..dce24fa3f6 100644 --- a/kpi/utils/object_permission.py +++ b/kpi/utils/object_permission.py @@ -2,6 +2,7 @@ from collections import defaultdict from typing import Union +import django.dispatch from django.apps import apps from django.conf import settings from django.contrib.auth.models import User, Permission, AnonymousUser @@ -282,3 +283,7 @@ def perm_parse(perm, obj=None): app_label = obj_app_label codename = perm return app_label, codename + + +post_assign_perm = django.dispatch.Signal() +post_remove_perm = django.dispatch.Signal() diff --git a/kpi/utils/project_view_exports.py b/kpi/utils/project_view_exports.py index 7a651fd943..df9d1038ac 100644 --- a/kpi/utils/project_view_exports.py +++ b/kpi/utils/project_view_exports.py @@ -60,7 +60,6 @@ 'city', 'bio', 'organization', - 'require_auth', 'primarySector', 'organization_website', 'twitter', diff --git a/kpi/views/current_user.py b/kpi/views/current_user.py index ac5b5cde52..f176586f61 100644 --- a/kpi/views/current_user.py +++ b/kpi/views/current_user.py @@ -44,7 +44,6 @@ class CurrentUserViewSet(viewsets.ModelViewSet): > "linkedin": string, > "instagram": string, > "organization": string, - > "require_auth": boolean, > "last_ui_language": string, > "organization_website": sting, > },