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 && (
-
{t('Install')}
- KoboCollect
+
+ KoboCollect
+
{t('on your Android device.')}
- - {t('Click on')} {t('to open settings.')}
+ -
+ {t('Click on')} {' '}
+ {t('to open settings.')}
+
-
{t('Enter the server URL')}
{kobocollect_url}
@@ -336,9 +418,19 @@ class FormLanding extends React.Component {
- {t('Open "Get Blank Form" and select this project. ')}
- {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'}}
>
}
>
{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,
> },