Merge branch 'master' into develop
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue
index 7f1371b..965fdd0 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/components/users/field-editors/ExtendedUserProfileFieldEditor.vue
@@ -6,6 +6,19 @@
>This field is required.</b-form-invalid-feedback
>
</b-form-group>
+ <b-form-group
+ label="Checkbox Label"
+ label-cols="3"
+ v-if="extendedUserProfileField.field_type === 'user_agreement'"
+ >
+ <b-form-input
+ v-model="checkbox_label"
+ :state="validateState($v.checkbox_label)"
+ />
+ <b-form-invalid-feedback :state="validateState($v.checkbox_label)"
+ >This field is required.</b-form-invalid-feedback
+ >
+ </b-form-group>
<b-form-group label="Help text" label-cols="3">
<b-form-input v-model="help_text" />
</b-form-group>
@@ -34,7 +47,7 @@
<b-input-group-append>
<b-button
@click="handleChoiceMoveUp(choice)"
- :disabled="index === 0"
+ :disabled="index === String(0)"
v-b-tooltip.hover.left
title="Move Up"
>
@@ -43,7 +56,8 @@
<b-button
@click="handleChoiceMoveDown(choice)"
:disabled="
- index === extendedUserProfileField.choices.length - 1
+ index ===
+ String(extendedUserProfileField.choices.length - 1)
"
v-b-tooltip.hover.left
title="Move Down"
@@ -189,7 +203,7 @@
<script>
import { mapGetters, mapMutations } from "vuex";
import { validationMixin } from "vuelidate";
-import { required } from "vuelidate/lib/validators";
+import { required, requiredIf } from "vuelidate/lib/validators";
import { errors } from "django-airavata-common-ui";
export default {
mixins: [validationMixin],
@@ -205,6 +219,15 @@
this.$v.name.$touch();
},
},
+ checkbox_label: {
+ get() {
+ return this.extendedUserProfileField.checkbox_label;
+ },
+ set(value) {
+ this.setCheckboxLabel({ value, field: this.extendedUserProfileField });
+ this.$v.checkbox_label.$touch();
+ },
+ },
help_text: {
get() {
return this.extendedUserProfileField.help_text;
@@ -249,12 +272,18 @@
valid() {
return !this.$v.$invalid;
},
+ checkboxLabelIsRequired() {
+ return this.extendedUserProfileField.field_type === "user_agreement";
+ },
},
validations() {
return {
name: {
required,
},
+ checkbox_label: {
+ required: requiredIf("checkboxLabelIsRequired"),
+ },
choices: {
$each: {
display_text: {
@@ -277,6 +306,7 @@
methods: {
...mapMutations("extendedUserProfile", [
"setName",
+ "setCheckboxLabel",
"setHelpText",
"setRequired",
"setOther",
diff --git a/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js b/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js
index a058bee..1c0d456 100644
--- a/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js
+++ b/django_airavata/apps/admin/static/django_airavata_admin/src/store/modules/extendedUserProfile.js
@@ -94,6 +94,9 @@
setName(state, { value, field }) {
setFieldProp(state, field, "name", value);
},
+ setCheckboxLabel(state, { value, field }) {
+ setFieldProp(state, field, "checkbox_label", value);
+ },
setHelpText(state, { value, field }) {
setFieldProp(state, field, "help_text", value);
},
diff --git a/django_airavata/apps/api/exceptions.py b/django_airavata/apps/api/exceptions.py
index 7caf17c..0676c12 100644
--- a/django_airavata/apps/api/exceptions.py
+++ b/django_airavata/apps/api/exceptions.py
@@ -4,6 +4,7 @@
from django.core.exceptions import ObjectDoesNotExist
from django.http import JsonResponse
from rest_framework import status
+from rest_framework.exceptions import NotAuthenticated
from rest_framework.response import Response
from rest_framework.views import exception_handler
from thrift.Thrift import TException
@@ -43,6 +44,11 @@
{'detail': str(exc)},
status=status.HTTP_404_NOT_FOUND)
+ if isinstance(exc, NotAuthenticated):
+ log.debug("NotAuthenticated", exc_info=exc)
+ if response is not None:
+ response.data['is_authenticated'] = False
+
# Generic handler
if response is None:
log.error("API exception", exc_info=exc)
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js b/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js
index 7832993..d69f70f 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/errors/ErrorUtils.js
@@ -22,4 +22,38 @@
isNotFoundError(error) {
return this.isAPIException(error) && error.details.status === 404;
},
+ /**
+ * Return true if the error is an unauthenticated error, i.e., the user needs
+ * to log in again.
+ *
+ * @param {Error} error
+ * @returns
+ * @see {@link buildLoginUrl} for utility to build re-login url
+ */
+ isUnauthenticatedError(error) {
+ return (
+ this.isAPIException(error) &&
+ [401, 403].includes(error.details.status) &&
+ "is_authenticated" in error.details.response &&
+ error.details.response.is_authenticated === false
+ );
+ },
+ /**
+ * Build a url that takes the user to the login page.
+ *
+ * @param {boolean} includeNextParameter - Add a 'next' url to the login url
+ * that will take the user back to this page after login
+ * @returns
+ */
+ buildLoginUrl(includeNextParameter = true) {
+ let loginUrl = "/auth/login";
+ if (includeNextParameter) {
+ let currentURL = window.location.pathname;
+ if (window.location.search) {
+ currentURL += window.location.search;
+ }
+ loginUrl += `?next=${encodeURIComponent(currentURL)}`;
+ }
+ return loginUrl;
+ },
};
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js
index fbde9a2..41ece6a 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledError.js
@@ -1,3 +1,5 @@
+import ErrorUtils from "./ErrorUtils";
+
let idSequence = 0;
class UnhandledError {
constructor({
@@ -15,6 +17,10 @@
this.suppressLogging = suppressLogging;
this.createdDate = new Date();
}
+
+ get isUnauthenticatedError() {
+ return ErrorUtils.isUnauthenticatedError(this.error);
+ }
}
export default UnhandledError;
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js
index 35074fd..10d75b1 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/errors/UnhandledErrorDispatcher.js
@@ -21,10 +21,22 @@
}
reportUnhandledError(unhandledError) {
+ // Ignore unauthenticated errors that have already been displayed
+ if (
+ unhandledError.isUnauthenticatedError &&
+ UnhandledErrorList.list.some((e) => e.isUnauthenticatedError)
+ ) {
+ return;
+ }
+
if (!unhandledError.suppressDisplay) {
UnhandledErrorList.add(unhandledError);
}
- if (!unhandledError.suppressLogging) {
+ if (
+ !unhandledError.suppressLogging &&
+ // Don't log unauthenticated errors
+ !unhandledError.isUnauthenticatedError
+ ) {
ErrorReporter.reportUnhandledError(unhandledError);
}
}
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js b/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js
index 8a52802..f11e7d1 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/utils/FetchUtils.js
@@ -1,3 +1,4 @@
+import ErrorUtils from "../errors/ErrorUtils";
import UnhandledErrorDispatcher from "../errors/UnhandledErrorDispatcher";
import Cache from "./Cache";
@@ -266,7 +267,8 @@
if (showSpinner) {
decrementCount();
}
- if (!ignoreErrors) {
+ // Always report unauthenticated errors so user knows they need to re-authenticate
+ if (!ignoreErrors || ErrorUtils.isUnauthenticatedError(error)) {
this.reportError(error);
}
throw error;
diff --git a/django_airavata/apps/api/tests/test_views.py b/django_airavata/apps/api/tests/test_views.py
index 22d30e7..201538d 100644
--- a/django_airavata/apps/api/tests/test_views.py
+++ b/django_airavata/apps/api/tests/test_views.py
@@ -468,3 +468,26 @@
group_manager_mock.getGroup.assert_not_called()
group_manager_mock.addUsersToGroup.assert_not_called()
user_added_to_group_handler.assert_not_called()
+
+
+@override_settings(
+ GATEWAY_ID=GATEWAY_ID,
+ PORTAL_ADMINS=PORTAL_ADMINS
+)
+class ExceptionHandlingTest(TestCase):
+
+ def setUp(self):
+ self.user = User.objects.create_user('testuser')
+ self.factory = APIRequestFactory()
+
+ def test_unauthenticated_request(self):
+
+ url = reverse('django_airavata_api:group-list')
+ data = {}
+ request = self.factory.post(url, data)
+ # Deliberately not authenticating user for request
+ group_create = views.GroupViewSet.as_view({'post': 'create'})
+ response = group_create(request)
+ self.assertEquals(403, response.status_code)
+ self.assertIn('is_authenticated', response.data)
+ self.assertFalse(response.data['is_authenticated'])
diff --git a/django_airavata/static/common/js/components/NotificationsDisplay.vue b/django_airavata/static/common/js/components/NotificationsDisplay.vue
index 1954218..b711344 100644
--- a/django_airavata/static/common/js/components/NotificationsDisplay.vue
+++ b/django_airavata/static/common/js/components/NotificationsDisplay.vue
@@ -1,16 +1,36 @@
<template>
<div id="notifications-display">
<transition-group name="fade" tag="div">
- <b-alert
- v-for="unhandledError in unhandledErrors"
- variant="danger"
- :key="unhandledError.id"
- show
- dismissible
- @dismissed="dismissUnhandledError(unhandledError)"
- >
- {{ unhandledError.message }}
- </b-alert>
+ <template v-for="unhandledError in unhandledErrors">
+ <b-alert
+ v-if="isUnauthenticatedError(unhandledError.error)"
+ variant="warning"
+ :key="unhandledError.id"
+ show
+ dismissible
+ @dismissed="dismissUnhandledError(unhandledError)"
+ >
+ Your login session has expired. Please
+ <b-link class="alert-link" :href="loginLinkWithNext"
+ >log in again</b-link
+ >. You can also
+ <b-link class="alert-link" :href="loginLink" target="_blank"
+ >login in a separate tab
+ <i class="fa fa-external-link-alt" aria-hidden="true"></i
+ ></b-link>
+ and then return to this tab and try again.
+ </b-alert>
+ <b-alert
+ v-else
+ variant="danger"
+ :key="unhandledError.id"
+ show
+ dismissible
+ @dismissed="dismissUnhandledError(unhandledError)"
+ >
+ {{ unhandledError.message }}
+ </b-alert>
+ </template>
<b-alert
v-for="notification in notifications"
:variant="variant(notification)"
@@ -86,6 +106,9 @@
}.bind(this);
setTimeout(pollAPIServerStatus.bind(this), this.pollingDelay);
},
+ isUnauthenticatedError(error) {
+ return errors.ErrorUtils.isUnauthenticatedError(error);
+ },
},
computed: {
apiServerDown() {
@@ -130,6 +153,12 @@
: false;
return notificationsApiServerDown || unhandledErrorsApiServerDown;
},
+ loginLinkWithNext() {
+ return errors.ErrorUtils.buildLoginUrl();
+ },
+ loginLink() {
+ return errors.ErrorUtils.buildLoginUrl(false);
+ },
},
watch: {
/*
diff --git a/docs/tutorial/custom_ui_tutorial.md b/docs/tutorial/custom_ui_tutorial.md
index 9d2782b..df88322 100644
--- a/docs/tutorial/custom_ui_tutorial.md
+++ b/docs/tutorial/custom_ui_tutorial.md
@@ -1199,7 +1199,7 @@
greetings in several languages.
1. In the `$HOME/custom_ui_tutorial_app/custom_ui_tutorial_app/views.py` file,
- we add the following import:
+ we add the following imports:
<button class="btn" data-clipboard-target="#jsonresponse">
Copy to clipboard
@@ -1208,6 +1208,7 @@
```python
from django.http import JsonResponse
+from rest_framework.decorators import api_view
```
</div>
@@ -1220,7 +1221,7 @@
<div id="languages">
```python
-@login_required
+@api_view()
def languages(request):
return JsonResponse({'languages': [{
'lang': 'French',
diff --git a/tests/settings.py b/tests/settings.py
index 7bc12f0..e65a3bc 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -10,7 +10,10 @@
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': os.path.join(BASEDIR, 'db.sqlite3'),
+ 'TEST': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': os.path.join(BASEDIR, 'test-db.sqlite3'),
+ }
}
}