You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by ma...@apache.org on 2022/07/08 19:19:41 UTC

[airavata-django-portal] branch AIRAVATA-3562 updated (09582cf3 -> 8a516305)

This is an automated email from the ASF dual-hosted git repository.

machristie pushed a change to branch AIRAVATA-3562
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git


    from 09582cf3 AIRAVATA-3566 Refactor code, pushing logic into model
     new bd823913 AIRAVATA-3567 Add UserProfile.is_ext_user_profile_valid
     new feb1d8c3 AIRAVATA-3567 Support saving all ext user profile values in one REST call
     new 87fe6cd6 AIRAVATA-3567 email template for user profile complete admin email
     new 56a8fbc3 AIRAVATA-3568 Redirect to user profile editor and require completing ext user profile fields
     new 8a516305 AIRAVATA-3568 Unit tests for user profile completeness check

The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../js/models/ExtendedUserProfileValue.js          |  27 ++++
 .../static/django_airavata_api/js/models/User.js   |   3 +-
 .../django_airavata_api/js/service_config.js       |  10 ++
 .../js/services/ServiceFactory.js                  |   3 +
 django_airavata/apps/api/view_utils.py             |   5 +
 django_airavata/apps/auth/middleware.py            |  10 +-
 .../auth/migrations/0017_auto_20220616_1831.py     |  55 ++++++++
 django_airavata/apps/auth/models.py                |  29 ++++-
 django_airavata/apps/auth/serializers.py           |  19 ++-
 .../js/containers/UserProfileContainer.vue         |   9 +-
 .../js/store/modules/extendedUserProfile.js        |  21 +--
 django_airavata/apps/auth/tests/test_middleware.py | 123 ++++++++++++++++++
 django_airavata/apps/auth/tests/test_models.py     | 143 +++++++++++++++++++++
 django_airavata/apps/auth/utils.py                 |  36 ++++--
 django_airavata/apps/auth/views.py                 |  26 +++-
 15 files changed, 486 insertions(+), 33 deletions(-)
 create mode 100644 django_airavata/apps/auth/migrations/0017_auto_20220616_1831.py
 create mode 100644 django_airavata/apps/auth/tests/test_middleware.py


[airavata-django-portal] 01/05: AIRAVATA-3567 Add UserProfile.is_ext_user_profile_valid

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch AIRAVATA-3562
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit bd823913ab74ee62947662ff4eb8c820e5042b37
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Jun 14 14:01:50 2022 -0400

    AIRAVATA-3567 Add UserProfile.is_ext_user_profile_valid
---
 django_airavata/apps/auth/models.py            |  18 +++-
 django_airavata/apps/auth/tests/test_models.py | 143 +++++++++++++++++++++++++
 2 files changed, 160 insertions(+), 1 deletion(-)

diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index 3bae9e25..d4cd2695 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -109,6 +109,19 @@ class UserProfile(models.Model):
             result.append('last_name')
         return result
 
+    @property
+    def is_ext_user_profile_valid(self):
+        fields = ExtendedUserProfileField.objects.filter(deleted=False)
+        for field in fields:
+            try:
+                value = self.extended_profile_values.filter(ext_user_profile_field=field).get()
+                if not value.valid:
+                    return False
+            except ExtendedUserProfileValue.DoesNotExist:
+                if field.required:
+                    return False
+        return True
+
     def is_non_empty(self, value: str):
         return value is not None and value.strip() != ""
 
@@ -248,7 +261,7 @@ class ExtendedUserProfileFieldLink(models.Model):
 
 class ExtendedUserProfileValue(models.Model):
     ext_user_profile_field = models.ForeignKey(ExtendedUserProfileField, on_delete=models.SET_NULL, null=True)
-    user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="extended_profile")
+    user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="extended_profile_values")
     created_date = models.DateTimeField(auto_now_add=True)
     updated_date = models.DateTimeField(auto_now=True)
 
@@ -300,6 +313,9 @@ class ExtendedUserProfileValue(models.Model):
 
     @property
     def valid(self):
+        # if the field is deleted, whatever the value, consider it valid
+        if self.ext_user_profile_field.deleted:
+            return True
         if self.ext_user_profile_field.required:
             if self.value_type == 'text':
                 return self.text.text_value and len(self.text.text_value.strip()) > 0
diff --git a/django_airavata/apps/auth/tests/test_models.py b/django_airavata/apps/auth/tests/test_models.py
index 73b18331..677fc8fc 100644
--- a/django_airavata/apps/auth/tests/test_models.py
+++ b/django_airavata/apps/auth/tests/test_models.py
@@ -134,6 +134,15 @@ class ExtendedUserProfileValueTestCase(TestCase):
             text_value="")
         self.assertFalse(value.valid)
 
+    def test_valid_of_text_empty_deleted(self):
+        """Invalid value but field is deleted so valid should be true."""
+        field = models.ExtendedUserProfileTextField.objects.create(
+            name="test", order=1, required=True, deleted=True)
+        value = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field, user_profile=self.user_profile,
+            text_value="")
+        self.assertTrue(value.valid, "Although value is empty but required, since the field is deleted, consider it not invalid")
+
     def test_valid_of_text_empty_no_required(self):
         field = models.ExtendedUserProfileTextField.objects.create(
             name="test", order=1, required=False)
@@ -238,3 +247,137 @@ class ExtendedUserProfileValueTestCase(TestCase):
             ext_user_profile_field=field, user_profile=self.user_profile,
             other_value="Some write-in value.")
         self.assertTrue(value.valid)
+
+
+class UserProfileTestCase(TestCase):
+
+    def setUp(self) -> None:
+        User = get_user_model()
+        user = User.objects.create_user("testuser")
+        self.user_profile: models.UserProfile = models.UserProfile.objects.create(user=user)
+
+    def test_is_ext_user_profile_valid_no_fields(self):
+        self.assertTrue(self.user_profile.is_ext_user_profile_valid)
+
+    def test_is_ext_user_profile_valid_some_fields_some_values_valid(self):
+        """Values for all fields, but only some are valid"""
+        field1 = models.ExtendedUserProfileTextField.objects.create(
+            name="test1", order=1, required=True)
+        field2 = models.ExtendedUserProfileTextField.objects.create(
+            name="test2", order=2, required=True)
+        field3 = models.ExtendedUserProfileTextField.objects.create(
+            name="test3", order=3, required=True)
+        value1 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field1, user_profile=self.user_profile,
+            text_value="Answer #1"
+        )
+        value2 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field2, user_profile=self.user_profile,
+            text_value="Answer #2"
+        )
+        value3 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field3, user_profile=self.user_profile,
+            text_value=""  # intentionally blank
+        )
+        self.assertTrue(value1.valid)
+        self.assertTrue(value2.valid)
+        self.assertFalse(value3.valid)
+        self.assertFalse(self.user_profile.is_ext_user_profile_valid)
+
+    def test_is_ext_user_profile_valid_some_fields_all_values_valid(self):
+        """Values for all fields, and all values are valid."""
+        field1 = models.ExtendedUserProfileTextField.objects.create(
+            name="test1", order=1, required=True)
+        field2 = models.ExtendedUserProfileTextField.objects.create(
+            name="test2", order=2, required=True)
+        field3 = models.ExtendedUserProfileTextField.objects.create(
+            name="test3", order=3, required=True)
+        value1 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field1, user_profile=self.user_profile,
+            text_value="Answer #1"
+        )
+        value2 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field2, user_profile=self.user_profile,
+            text_value="Answer #2"
+        )
+        value3 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field3, user_profile=self.user_profile,
+            text_value="Answer #3"
+        )
+        self.assertTrue(value1.valid)
+        self.assertTrue(value2.valid)
+        self.assertTrue(value3.valid)
+        self.assertTrue(self.user_profile.is_ext_user_profile_valid)
+
+    def test_is_ext_user_profile_valid_some_fields_some_not_required_values_missing(self):
+        """Some values are missing but they are optional."""
+        field1 = models.ExtendedUserProfileTextField.objects.create(
+            name="test1", order=1, required=True)
+        field2 = models.ExtendedUserProfileTextField.objects.create(
+            name="test2", order=2, required=False)
+        field3 = models.ExtendedUserProfileTextField.objects.create(
+            name="test3", order=3, required=False)
+        value1 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field1, user_profile=self.user_profile,
+            text_value="Answer #1"
+        )
+        self.assertTrue(value1.valid)
+        self.assertFalse(models.ExtendedUserProfileValue.objects.filter(
+            user_profile=self.user_profile, ext_user_profile_field=field2).exists(),
+            "No value for field2")
+        self.assertFalse(models.ExtendedUserProfileValue.objects.filter(
+            user_profile=self.user_profile, ext_user_profile_field=field3).exists(),
+            "No value for field3")
+        self.assertTrue(self.user_profile.is_ext_user_profile_valid)
+
+    def test_is_ext_user_profile_valid_some_fields_some_required_values_missing(self):
+        """Some required values are missing."""
+        field1 = models.ExtendedUserProfileTextField.objects.create(
+            name="test1", order=1, required=True)
+        field2 = models.ExtendedUserProfileTextField.objects.create(
+            name="test2", order=2, required=True)
+        field3 = models.ExtendedUserProfileTextField.objects.create(
+            name="test3", order=3, required=False)
+        value1 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field1, user_profile=self.user_profile,
+            text_value="Answer #1"
+        )
+        self.assertTrue(value1.valid)
+        self.assertFalse(models.ExtendedUserProfileValue.objects.filter(
+            user_profile=self.user_profile, ext_user_profile_field=field2).exists(),
+            "No value for field2, but field2 is required")
+        self.assertFalse(models.ExtendedUserProfileValue.objects.filter(
+            user_profile=self.user_profile, ext_user_profile_field=field3).exists(),
+            "No value for field3")
+        self.assertFalse(self.user_profile.is_ext_user_profile_valid)
+
+    def test_is_ext_user_profile_valid_some_fields_invalid_value_but_field_deleted(self):
+        """Value is invalid but field is deleted so it shouldn't count."""
+        field1 = models.ExtendedUserProfileTextField.objects.create(
+            name="test1", order=1, required=True)
+        field2 = models.ExtendedUserProfileTextField.objects.create(
+            name="test2", order=2, required=True)
+        field3 = models.ExtendedUserProfileTextField.objects.create(
+            name="test3", order=3, required=True)
+
+        value1 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field1, user_profile=self.user_profile,
+            text_value="Answer #1"
+        )
+        value2 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field2, user_profile=self.user_profile,
+            text_value="Answer #2"
+        )
+        value3 = models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field3, user_profile=self.user_profile,
+            text_value=""
+        )
+        self.assertTrue(value1.valid)
+        self.assertTrue(value2.valid)
+        self.assertFalse(value3.valid)
+        self.assertFalse(self.user_profile.is_ext_user_profile_valid)
+
+        field3.deleted = True
+        field3.save()
+        self.assertTrue(value3.valid)
+        self.assertTrue(self.user_profile.is_ext_user_profile_valid)


[airavata-django-portal] 03/05: AIRAVATA-3567 email template for user profile complete admin email

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch AIRAVATA-3562
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 87fe6cd60e5d7df819a9c3051656ad1825dd4a33
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Thu Jun 16 16:58:32 2022 -0400

    AIRAVATA-3567 email template for user profile complete admin email
---
 .../auth/migrations/0017_auto_20220616_1831.py     | 55 ++++++++++++++++++++++
 django_airavata/apps/auth/models.py                | 11 +++++
 django_airavata/apps/auth/serializers.py           |  4 +-
 django_airavata/apps/auth/utils.py                 | 36 ++++++++++----
 django_airavata/apps/auth/views.py                 |  3 +-
 5 files changed, 99 insertions(+), 10 deletions(-)

diff --git a/django_airavata/apps/auth/migrations/0017_auto_20220616_1831.py b/django_airavata/apps/auth/migrations/0017_auto_20220616_1831.py
new file mode 100644
index 00000000..47ed7952
--- /dev/null
+++ b/django_airavata/apps/auth/migrations/0017_auto_20220616_1831.py
@@ -0,0 +1,55 @@
+# Generated by Django 3.2.11 on 2022-06-16 18:31
+
+from django.db import migrations
+
+from django_airavata.apps.auth.models import (
+    USER_PROFILE_COMPLETED_TEMPLATE,
+)
+
+
+def default_templates(apps, schema_editor):
+
+    EmailTemplate = apps.get_model("django_airavata_auth", "EmailTemplate")
+    user_profile_completed_template = EmailTemplate(
+        template_type=USER_PROFILE_COMPLETED_TEMPLATE,
+        subject="User {{first_name}} {{last_name}} ({{username}}) has completed their profile",
+        body="""
+            <p>Gateway Portal: {{http_host}}</p>
+            <p>Tenant: {{gateway_id}}</p>
+            <h3>User Profile</h3>
+            <p>Username: {{username}}</p>
+            <p>Name: {{first_name}} {{last_name}}</p>
+            <p>Email: {{email}}</p>
+            {% if extended_profile_values %}
+            <h3>Extended User Profile</h3>
+            <table><tr><th>Name</th><th>Value</th></tr>
+            {% for value in extended_profile_values %}
+                <tr><td>{{ value.ext_user_profile_field.name }}</td>
+                {% if value.value_display_list and value.value_display_list|length > 1 %}
+                <td><ul>
+                    {% for display_item in value.value_display_list %}
+                    <li>{{ display_item }}</li>
+                    {% endfor %}
+                </ul></td>
+                {% elif value.value_display_list and value.value_display_list|length == 1 %}
+                <td>{{ value.value_display_list|first }}</td>
+                {% else %}
+                <td>{{ value.value_display_list }}</td>
+                {% endif %}
+                </tr>
+            {% endfor %}
+            </table>
+            {% endif %}
+        """.strip())
+    user_profile_completed_template.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_airavata_auth', '0016_extendeduserprofilefield_required'),
+    ]
+
+    operations = [
+        migrations.RunPython(default_templates)
+    ]
diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py
index d4cd2695..6cc658ea 100644
--- a/django_airavata/apps/auth/models.py
+++ b/django_airavata/apps/auth/models.py
@@ -11,6 +11,7 @@ NEW_USER_EMAIL_TEMPLATE = 2
 PASSWORD_RESET_EMAIL_TEMPLATE = 3
 USER_ADDED_TO_GROUP_TEMPLATE = 4
 VERIFY_EMAIL_CHANGE_TEMPLATE = 5
+USER_PROFILE_COMPLETED_TEMPLATE = 6
 
 
 class EmailVerification(models.Model):
@@ -29,6 +30,7 @@ class EmailTemplate(models.Model):
         (PASSWORD_RESET_EMAIL_TEMPLATE, 'Password Reset Email Template'),
         (USER_ADDED_TO_GROUP_TEMPLATE, 'User Added to Group Template'),
         (VERIFY_EMAIL_CHANGE_TEMPLATE, 'Verify Email Change Template'),
+        (USER_PROFILE_COMPLETED_TEMPLATE, 'User Profile Completed Template'),
     )
     template_type = models.IntegerField(
         primary_key=True, choices=TEMPLATE_TYPE_CHOICES)
@@ -311,6 +313,15 @@ class ExtendedUserProfileValue(models.Model):
                 return "No"
         return None
 
+    @property
+    def value_display_list(self):
+        """Same as value_display except coerced always to a list."""
+        value_display = self.value_display
+        if value_display is not None and not isinstance(value_display, list):
+            return [value_display]
+        else:
+            return value_display
+
     @property
     def valid(self):
         # if the field is deleted, whatever the value, consider it valid
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index 70db769c..c02162ae 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -228,7 +228,9 @@ class ExtendedUserProfileFieldSerializer(serializers.ModelSerializer):
 class ExtendedUserProfileValueSerializer(serializers.ModelSerializer):
     id = serializers.IntegerField(label='ID', required=False)
     text_value = serializers.CharField(required=False, allow_blank=True)
-    choices = serializers.ListField(child=serializers.IntegerField(), required=False)
+    # choices must be write_only so that DRF ignores trying to deserialized this related field
+    # deserialization is handled explicitly in to_representation, see below
+    choices = serializers.ListField(child=serializers.IntegerField(), required=False, write_only=True)
     other_value = serializers.CharField(required=False, allow_blank=True)
     agreement_value = serializers.BooleanField(required=False)
 
diff --git a/django_airavata/apps/auth/utils.py b/django_airavata/apps/auth/utils.py
index d7ebda8b..cfa53c04 100644
--- a/django_airavata/apps/auth/utils.py
+++ b/django_airavata/apps/auth/utils.py
@@ -129,14 +129,7 @@ def send_new_user_email(request, username, email, first_name, last_name):
     })
     subject = Template(new_user_email_template.subject).render(context)
     body = Template(new_user_email_template.body).render(context)
-    msg = EmailMessage(subject=subject,
-                       body=body,
-                       from_email=f'"{settings.PORTAL_TITLE}" <{settings.SERVER_EMAIL}>',
-                       to=[f'"{a[0]}" <{a[1]}>' for a in getattr(settings,
-                                                                 'PORTAL_ADMINS',
-                                                                 settings.ADMINS)])
-    msg.content_subtype = 'html'
-    msg.send()
+    send_email_to_admins(subject, body)
 
 
 def send_admin_alert_about_uninitialized_username(request, username, email, first_name, last_name):
@@ -174,6 +167,33 @@ def send_admin_alert_about_uninitialized_username(request, username, email, firs
     portal.
     </p>
     """.strip()).render(context)
+    send_email_to_admins(subject, body)
+
+
+def send_admin_user_completed_profile(request, user_profile):
+    domain, port = split_domain_port(request.get_host())
+    user = user_profile.user
+    extended_profile_values = user_profile.extended_profile_values.filter(
+        ext_user_profile_field__deleted=False).order_by("ext_user_profile_field__order").all()
+    context = Context({
+        "username": user.username,
+        "email": user.email,
+        "first_name": user.first_name,
+        "last_name": user.last_name,
+        "portal_title": settings.PORTAL_TITLE,
+        "gateway_id": settings.GATEWAY_ID,
+        "http_host": domain,
+        "extended_profile_values": extended_profile_values
+    })
+
+    user_profile_completed_template = models.EmailTemplate.objects.get(
+        pk=models.USER_PROFILE_COMPLETED_TEMPLATE)
+    subject = Template(user_profile_completed_template.subject).render(context)
+    body = Template(user_profile_completed_template.body).render(context)
+    send_email_to_admins(subject, body)
+
+
+def send_email_to_admins(subject, body):
     msg = EmailMessage(subject=subject,
                        body=body,
                        from_email=f'"{settings.PORTAL_TITLE}" <{settings.SERVER_EMAIL}>',
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index eb016607..a2c0775f 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -751,6 +751,7 @@ class ExtendedUserProfileValueViewset(mixins.CreateModelMixin,
         return queryset
 
     @action(methods=['POST'], detail=False, url_path="save-all")
+    @atomic
     def save_all(self, request, format=None):
         user = request.user
         user_profile: models.UserProfile = user.user_profile
@@ -761,7 +762,7 @@ class ExtendedUserProfileValueViewset(mixins.CreateModelMixin,
 
         new_valid = user_profile.is_ext_user_profile_valid
         if not old_valid and new_valid:
-            logger.info("TODO: send email to admin")
+            utils.send_admin_user_completed_profile(request, user_profile)
 
         serializer = self.get_serializer(values, many=True)
         return Response(serializer.data)


[airavata-django-portal] 02/05: AIRAVATA-3567 Support saving all ext user profile values in one REST call

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch AIRAVATA-3562
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit feb1d8c3d8c005471a7404309b00423a24bc2b0f
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Tue Jun 14 17:15:45 2022 -0400

    AIRAVATA-3567 Support saving all ext user profile values in one REST call
---
 .../js/models/ExtendedUserProfileValue.js          | 27 ++++++++++++++++++++++
 .../django_airavata_api/js/service_config.js       | 10 ++++++++
 .../js/services/ServiceFactory.js                  |  3 +++
 django_airavata/apps/auth/serializers.py           |  8 +++++++
 .../js/store/modules/extendedUserProfile.js        | 21 ++++++-----------
 django_airavata/apps/auth/views.py                 | 16 +++++++++++++
 6 files changed, 71 insertions(+), 14 deletions(-)

diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/ExtendedUserProfileValue.js b/django_airavata/apps/api/static/django_airavata_api/js/models/ExtendedUserProfileValue.js
index bb417a6d..02618cb0 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/ExtendedUserProfileValue.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/ExtendedUserProfileValue.js
@@ -16,4 +16,31 @@ export default class ExtendedUserProfileValue extends BaseModel {
   constructor(data = {}) {
     super(FIELDS, data);
   }
+
+  toJSON() {
+    const copy = Object.assign({}, this);
+    // Remove unnecessary properties
+    switch (this.value_type) {
+      case "text":
+        delete copy["other_value"];
+        delete copy["choices"];
+        delete copy["agreement_value"];
+        break;
+      case "single_choice":
+      case "multi_choice":
+        delete copy["text_value"];
+        delete copy["agreement_value"];
+        break;
+      case "user_agreement":
+        delete copy["text_value"];
+        delete copy["other_value"];
+        delete copy["choices"];
+        break;
+      default:
+        // eslint-disable-next-line no-console
+        console.error("Unrecognized value type", this.value_type);
+        break;
+    }
+    return copy;
+  }
 }
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/service_config.js b/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
index bef3bfe0..13aba7ca 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/service_config.js
@@ -249,6 +249,16 @@ export default {
     viewSet: true,
     modelClass: ExtendedUserProfileValue,
     queryParams: ["username"],
+    methods: {
+      saveAll: {
+        url: "/auth/extended-user-profile-values/save-all/",
+        requestType: "post",
+        modelClass: ExtendedUserProfileValue,
+        bodyParams: {
+          name: "data",
+        },
+      },
+    },
   },
   FullExperiments: {
     url: "/api/full-experiments",
diff --git a/django_airavata/apps/api/static/django_airavata_api/js/services/ServiceFactory.js b/django_airavata/apps/api/static/django_airavata_api/js/services/ServiceFactory.js
index 5aa1e61b..29bf84af 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/services/ServiceFactory.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/services/ServiceFactory.js
@@ -274,6 +274,9 @@ class ServiceFactory {
           }
         };
         let resultHandler = (data) => {
+          if (Array.isArray(data)) {
+            return data.map((item) => resultHandler(item));
+          }
           return config.modelClass ? new config.modelClass(data) : data;
         };
         switch (config.requestType.toLowerCase()) {
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index dbafa415..70db769c 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -226,6 +226,7 @@ class ExtendedUserProfileFieldSerializer(serializers.ModelSerializer):
 
 
 class ExtendedUserProfileValueSerializer(serializers.ModelSerializer):
+    id = serializers.IntegerField(label='ID', required=False)
     text_value = serializers.CharField(required=False, allow_blank=True)
     choices = serializers.ListField(child=serializers.IntegerField(), required=False)
     other_value = serializers.CharField(required=False, allow_blank=True)
@@ -259,6 +260,13 @@ class ExtendedUserProfileValueSerializer(serializers.ModelSerializer):
         user = request.user
         user_profile = user.user_profile
 
+        # Support create/update in the many=True situation. When many=True and
+        # .save() is called, .create() will be called on each value. Here we
+        # need to see if there is an id and if so call .update() instead.
+        if "id" in validated_data:
+            instance = models.ExtendedUserProfileValue.objects.get(id=validated_data["id"])
+            return self.update(instance, validated_data)
+
         ext_user_profile_field = validated_data.pop('ext_user_profile_field')
         if ext_user_profile_field.field_type == 'text':
             text_value = validated_data.pop('text_value')
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/store/modules/extendedUserProfile.js b/django_airavata/apps/auth/static/django_airavata_auth/js/store/modules/extendedUserProfile.js
index 9fef5c74..084731b4 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/store/modules/extendedUserProfile.js
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/store/modules/extendedUserProfile.js
@@ -64,20 +64,10 @@ const actions = {
     commit("setExtendedUserProfileValues", { extendedUserProfileValues });
   },
   async saveExtendedUserProfileValues({ state, commit }) {
-    for (const value of state.extendedUserProfileValues) {
-      // Create or update each value
-      if (value.id) {
-        await services.ExtendedUserProfileValueService.update({
-          lookup: value.id,
-          data: value,
-        });
-      } else {
-        const extendedUserProfileValue = await services.ExtendedUserProfileValueService.create(
-          { data: value }
-        );
-        commit("updateExtendedUserProfileValue", { extendedUserProfileValue });
-      }
-    }
+    const extendedUserProfileValues = await services.ExtendedUserProfileValueService.saveAll(
+      { data: state.extendedUserProfileValues }
+    );
+    commit("updateExtendedUserProfileValues", { extendedUserProfileValues });
   },
 };
 
@@ -184,6 +174,9 @@ const mutations = {
     );
     state.extendedUserProfileValues.splice(index, 1, extendedUserProfileValue);
   },
+  updateExtendedUserProfileValues(state, { extendedUserProfileValues }) {
+    state.extendedUserProfileValues = extendedUserProfileValues;
+  },
 };
 
 export default {
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index 95520b42..eb016607 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -749,3 +749,19 @@ class ExtendedUserProfileValueViewset(mixins.CreateModelMixin,
         else:
             queryset = user.user_profile.extended_profile.all()
         return queryset
+
+    @action(methods=['POST'], detail=False, url_path="save-all")
+    def save_all(self, request, format=None):
+        user = request.user
+        user_profile: models.UserProfile = user.user_profile
+        old_valid = user_profile.is_ext_user_profile_valid
+        serializer: serializers.ExtendedUserProfileValueSerializer = self.get_serializer(data=request.data, many=True)
+        serializer.is_valid(raise_exception=True)
+        values = serializer.save()
+
+        new_valid = user_profile.is_ext_user_profile_valid
+        if not old_valid and new_valid:
+            logger.info("TODO: send email to admin")
+
+        serializer = self.get_serializer(values, many=True)
+        return Response(serializer.data)


[airavata-django-portal] 04/05: AIRAVATA-3568 Redirect to user profile editor and require completing ext user profile fields

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch AIRAVATA-3562
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 56a8fbc30c744222837844d415b39dc4b7a868b7
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Jul 8 09:54:12 2022 -0400

    AIRAVATA-3568 Redirect to user profile editor and require completing ext user profile fields
---
 .../apps/api/static/django_airavata_api/js/models/User.js      |  3 ++-
 django_airavata/apps/api/view_utils.py                         |  5 +++++
 django_airavata/apps/auth/middleware.py                        | 10 ++++++++--
 django_airavata/apps/auth/serializers.py                       |  7 ++++++-
 .../js/containers/UserProfileContainer.vue                     |  9 +++++++--
 django_airavata/apps/auth/views.py                             |  9 ++++++---
 6 files changed, 34 insertions(+), 9 deletions(-)

diff --git a/django_airavata/apps/api/static/django_airavata_api/js/models/User.js b/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
index e4144445..198a927d 100644
--- a/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
+++ b/django_airavata/apps/api/static/django_airavata_api/js/models/User.js
@@ -8,7 +8,8 @@ const FIELDS = [
   "email",
   "pending_email_change",
   "complete",
-  "username_valid"
+  "username_valid",
+  "ext_user_profile_valid",
 ];
 
 export default class User extends BaseModel {
diff --git a/django_airavata/apps/api/view_utils.py b/django_airavata/apps/api/view_utils.py
index ddef7e30..3f06ad00 100644
--- a/django_airavata/apps/api/view_utils.py
+++ b/django_airavata/apps/api/view_utils.py
@@ -221,3 +221,8 @@ class IsInAdminsGroupPermission(permissions.BasePermission):
                     request.is_read_only_gateway_admin)
         else:
             return request.is_gateway_admin
+
+
+class ReadOnly(permissions.BasePermission):
+    def has_permission(self, request, view):
+        return request.method in permissions.SAFE_METHODS
diff --git a/django_airavata/apps/auth/middleware.py b/django_airavata/apps/auth/middleware.py
index 02269563..8e374051 100644
--- a/django_airavata/apps/auth/middleware.py
+++ b/django_airavata/apps/auth/middleware.py
@@ -90,8 +90,14 @@ def user_profile_completeness_check(get_response):
             reverse('django_airavata_auth:user_profile'),
             reverse('django_airavata_auth:logout'),
         ]
-        if (hasattr(request.user, "user_profile") and
-            not request.user.user_profile.is_complete and
+        incomplete_user_profile = (hasattr(request.user, "user_profile") and
+                                   not request.user.user_profile.is_complete)
+        # Exclude admin's from the ext user profile check since they will be
+        # creating/editing the ext user profile fields
+        invalid_ext_user_profile = (not getattr(request, "is_gateway_admin", False) and
+                                    hasattr(request.user, "user_profile") and
+                                    not request.user.user_profile.is_ext_user_profile_valid)
+        if ((incomplete_user_profile or invalid_ext_user_profile) and
             request.path not in allowed_paths and
                 'text/html' in request.META['HTTP_ACCEPT']):
             return redirect('django_airavata_auth:user_profile')
diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py
index c02162ae..06d8def8 100644
--- a/django_airavata/apps/auth/serializers.py
+++ b/django_airavata/apps/auth/serializers.py
@@ -27,11 +27,13 @@ class UserSerializer(serializers.ModelSerializer):
     pending_email_change = serializers.SerializerMethodField()
     complete = serializers.SerializerMethodField()
     username_valid = serializers.SerializerMethodField()
+    ext_user_profile_valid = serializers.SerializerMethodField()
 
     class Meta:
         model = get_user_model()
         fields = ['id', 'username', 'first_name', 'last_name', 'email',
-                  'pending_email_change', 'complete', 'username_valid']
+                  'pending_email_change', 'complete', 'username_valid',
+                  'ext_user_profile_valid']
         read_only_fields = ('username',)
 
     def get_pending_email_change(self, instance):
@@ -49,6 +51,9 @@ class UserSerializer(serializers.ModelSerializer):
     def get_username_valid(self, instance):
         return instance.user_profile.is_username_valid
 
+    def get_ext_user_profile_valid(self, instance):
+        return instance.user_profile.is_ext_user_profile_valid
+
     @atomic
     def update(self, instance, validated_data):
         request = self.context['request']
diff --git a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
index 3e8d101f..c70772c4 100644
--- a/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
+++ b/django_airavata/apps/auth/static/django_airavata_auth/js/containers/UserProfileContainer.vue
@@ -13,7 +13,7 @@
         In the meantime, please complete as much of your profile as possible.
       </p>
     </b-alert>
-    <b-alert v-else-if="user && !user.complete" show>
+    <b-alert v-else-if="mustComplete" show
       >Please complete your user profile before continuing.</b-alert
     >
     <b-card>
@@ -33,7 +33,7 @@
       <b-button variant="primary" @click="onSave">Save</b-button>
     </b-card>
     <b-link
-      v-if="user && user.complete"
+      v-if="!mustComplete"
       class="text-muted small"
       href="/workspace/dashboard"
       >Return to Dashboard</b-link
@@ -77,6 +77,11 @@ export default {
   computed: {
     ...mapGetters("userProfile", ["user"]),
     ...mapGetters("extendedUserProfile", ["extendedUserProfileFields"]),
+    mustComplete() {
+      return (
+        this.user && (!this.user.complete || !this.user.ext_user_profile_valid)
+      );
+    },
   },
   methods: {
     ...mapActions("userProfile", [
diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py
index a2c0775f..0b4fdd93 100644
--- a/django_airavata/apps/auth/views.py
+++ b/django_airavata/apps/auth/views.py
@@ -28,7 +28,10 @@ from rest_framework import mixins, permissions, viewsets
 from rest_framework.decorators import action
 from rest_framework.response import Response
 
-from django_airavata.apps.api.view_utils import IsInAdminsGroupPermission
+from django_airavata.apps.api.view_utils import (
+    IsInAdminsGroupPermission,
+    ReadOnly
+)
 from django_airavata.apps.auth import serializers
 
 from . import forms, iam_admin_client, models, utils
@@ -707,7 +710,7 @@ def get_client_secret(access_token, client_endpoint):
 class ExtendedUserProfileFieldViewset(viewsets.ModelViewSet):
     serializer_class = serializers.ExtendedUserProfileFieldSerializer
     queryset = models.ExtendedUserProfileField.objects.all().order_by('order')
-    permission_classes = [IsInAdminsGroupPermission]
+    permission_classes = [permissions.IsAuthenticated, IsInAdminsGroupPermission | ReadOnly]
 
     def get_queryset(self):
         queryset = super().get_queryset()
@@ -747,7 +750,7 @@ class ExtendedUserProfileValueViewset(mixins.CreateModelMixin,
             if username is not None:
                 queryset = queryset.filter(user_profile__user__username=username)
         else:
-            queryset = user.user_profile.extended_profile.all()
+            queryset = user.user_profile.extended_profile_values.all()
         return queryset
 
     @action(methods=['POST'], detail=False, url_path="save-all")


[airavata-django-portal] 05/05: AIRAVATA-3568 Unit tests for user profile completeness check

Posted by ma...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

machristie pushed a commit to branch AIRAVATA-3562
in repository https://gitbox.apache.org/repos/asf/airavata-django-portal.git

commit 8a516305c54616c78e841f8b9e787a7bec4cb193
Author: Marcus Christie <ma...@apache.org>
AuthorDate: Fri Jul 8 15:18:54 2022 -0400

    AIRAVATA-3568 Unit tests for user profile completeness check
---
 django_airavata/apps/auth/tests/test_middleware.py | 123 +++++++++++++++++++++
 1 file changed, 123 insertions(+)

diff --git a/django_airavata/apps/auth/tests/test_middleware.py b/django_airavata/apps/auth/tests/test_middleware.py
new file mode 100644
index 00000000..81091afb
--- /dev/null
+++ b/django_airavata/apps/auth/tests/test_middleware.py
@@ -0,0 +1,123 @@
+
+from unittest.mock import MagicMock, sentinel
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import AnonymousUser
+from django.http import HttpResponseRedirect
+from django.test import RequestFactory, TestCase
+from django.urls import reverse
+
+from django_airavata.apps.auth import models
+from django_airavata.apps.auth.middleware import (
+    user_profile_completeness_check
+)
+
+
+class UserProfileCompletenessCheckTestCase(TestCase):
+
+    def setUp(self):
+        User = get_user_model()
+        self.user: User = User.objects.create_user("testuser")
+        self.user_profile: models.UserProfile = models.UserProfile.objects.create(user=self.user)
+        self.factory = RequestFactory()
+
+    def _middleware_passes_through(self, request):
+        get_response = MagicMock(return_value=sentinel.response)
+        response = user_profile_completeness_check(get_response)(request)
+        get_response.assert_called()
+        self.assertIs(response, sentinel.response)
+
+    def _middleware_redirects_to_user_profile(self, request):
+        get_response = MagicMock(return_value=sentinel.response)
+        response = user_profile_completeness_check(get_response)(request)
+        get_response.assert_not_called()
+        self.assertIsInstance(response, HttpResponseRedirect)
+        self.assertEqual(response.url, reverse('django_airavata_auth:user_profile'))
+
+    def test_not_authenticated(self):
+        """Test that completeness check is skipped when not authenticated."""
+        request = self.factory.get(reverse('django_airavata_workspace:dashboard'))
+        request.user = AnonymousUser()
+        self.assertFalse(request.user.is_authenticated)
+        self._middleware_passes_through(request)
+
+    def test_user_profile_is_incomplete(self):
+        """Test user profile incomplete, should redirect to user_profile view."""
+        request = self.factory.get(reverse('django_airavata_workspace:dashboard'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        self.assertTrue(request.user.is_authenticated)
+        self.assertFalse(self.user_profile.is_complete)
+        self._middleware_redirects_to_user_profile(request)
+
+    def test_user_profile_is_incomplete_but_allowed(self):
+        """Test user profile incomplete, but should be able to access user_profile."""
+        request = self.factory.get(reverse('django_airavata_auth:user_profile'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        self.assertTrue(request.user.is_authenticated)
+        self.assertFalse(self.user_profile.is_complete)
+        self._middleware_passes_through(request)
+
+    def test_user_profile_is_complete(self):
+        """Test user profile is complete, should pass through."""
+        request = self.factory.get(reverse('django_airavata_workspace:dashboard'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        self.user.first_name = "Test"
+        self.user.last_name = "User"
+        self.user.email = "testuser@gateway.edu"
+        self.assertTrue(request.user.is_authenticated)
+        self.assertTrue(self.user_profile.is_complete)
+        self.assertTrue(self.user_profile.is_ext_user_profile_valid)
+        self._middleware_passes_through(request)
+
+    def test_user_profile_is_complete_but_ext_up_is_invalid(self):
+        """Test user profile is complete, but ext user prof is invalid."""
+        request = self.factory.get(reverse('django_airavata_workspace:dashboard'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        self.user.first_name = "Test"
+        self.user.last_name = "User"
+        self.user.email = "testuser@gateway.edu"
+        models.ExtendedUserProfileTextField.objects.create(name="test1", order=1, required=True)
+        self.assertTrue(request.user.is_authenticated)
+        self.assertTrue(self.user_profile.is_complete)
+        self.assertFalse(self.user_profile.is_ext_user_profile_valid)
+        self._middleware_redirects_to_user_profile(request)
+
+    def test_user_profile_is_complete_and_ext_up_is_valid(self):
+        """Test user profile is complete and ext user prof is valid."""
+        request = self.factory.get(reverse('django_airavata_workspace:dashboard'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        self.user.first_name = "Test"
+        self.user.last_name = "User"
+        self.user.email = "testuser@gateway.edu"
+        field1 = models.ExtendedUserProfileTextField.objects.create(name="test1", order=1, required=True)
+        models.ExtendedUserProfileTextValue.objects.create(
+            ext_user_profile_field=field1, user_profile=self.user_profile,
+            text_value="Answer #1"
+        )
+        self.assertTrue(self.user_profile.is_ext_user_profile_valid)
+        self.assertTrue(request.user.is_authenticated)
+        self.assertTrue(self.user_profile.is_complete)
+        self.assertEqual(1, len(self.user_profile.extended_profile_values.all()))
+        self._middleware_passes_through(request)
+
+    def test_user_profile_is_complete_ext_up_is_invalid_but_user_is_admin(self):
+        """Test user profile is complete, ext user prof is invalid, but user is gateway admin."""
+        request = self.factory.get(reverse('django_airavata_workspace:dashboard'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        request.is_gateway_admin = True
+        self.user.first_name = "Admin"
+        self.user.last_name = "User"
+        self.user.email = "admin@gateway.edu"
+        models.ExtendedUserProfileTextField.objects.create(name="test1", order=1, required=True)
+        self.assertFalse(self.user_profile.is_ext_user_profile_valid)
+        self.assertTrue(request.user.is_authenticated)
+        self.assertTrue(self.user_profile.is_complete)
+        self._middleware_passes_through(request)
+
+    def test_user_profile_is_incomplete_but_logout_allowed(self):
+        """Test user profile incomplete, but should be able to access logout."""
+        request = self.factory.get(reverse('django_airavata_auth:logout'), HTTP_ACCEPT=['text/html'])
+        request.user = self.user
+        self.assertTrue(request.user.is_authenticated)
+        self.assertFalse(self.user_profile.is_complete)
+        self._middleware_passes_through(request)