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 4e87cde731c5a235e065af05d7c870f9209cb78a Author: Marcus Christie <[email protected]> AuthorDate: Fri Mar 11 12:56:06 2022 -0500 AIRAVATA-3563 DB and REST API for extended user profile fields --- ...ld_extendeduserprofilefieldlink_extendeduser.py | 107 ++++++++++++++++++ django_airavata/apps/auth/models.py | 109 +++++++++++++++++++ django_airavata/apps/auth/serializers.py | 120 +++++++++++++++++++++ django_airavata/apps/auth/urls.py | 1 + django_airavata/apps/auth/views.py | 17 +++ 5 files changed, 354 insertions(+) diff --git a/django_airavata/apps/auth/migrations/0014_extendeduserprofileagreementfield_extendeduserprofilefield_extendeduserprofilefieldlink_extendeduser.py b/django_airavata/apps/auth/migrations/0014_extendeduserprofileagreementfield_extendeduserprofilefield_extendeduserprofilefieldlink_extendeduser.py new file mode 100644 index 0000000..2b5601c --- /dev/null +++ b/django_airavata/apps/auth/migrations/0014_extendeduserprofileagreementfield_extendeduserprofilefield_extendeduserprofilefieldlink_extendeduser.py @@ -0,0 +1,107 @@ +# Generated by Django 3.2.11 on 2022-03-11 14:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_airavata_auth', '0013_auto_20220118_1650'), + ] + + operations = [ + migrations.CreateModel( + name='ExtendedUserProfileField', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64)), + ('help_text', models.TextField(blank=True)), + ('order', models.IntegerField()), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('deleted', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='ExtendedUserProfileAgreementField', + fields=[ + ('field_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='user_agreement', serialize=False, to='django_airavata_auth.extendeduserprofilefield')), + ('checkbox_label', models.TextField(blank=True)), + ], + bases=('django_airavata_auth.extendeduserprofilefield',), + ), + migrations.CreateModel( + name='ExtendedUserProfileMultiChoiceField', + fields=[ + ('field_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='multi_choice', serialize=False, to='django_airavata_auth.extendeduserprofilefield')), + ('other', models.BooleanField(default=False)), + ], + bases=('django_airavata_auth.extendeduserprofilefield',), + ), + migrations.CreateModel( + name='ExtendedUserProfileSingleChoiceField', + fields=[ + ('field_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='single_choice', serialize=False, to='django_airavata_auth.extendeduserprofilefield')), + ('other', models.BooleanField(default=False)), + ], + bases=('django_airavata_auth.extendeduserprofilefield',), + ), + migrations.CreateModel( + name='ExtendedUserProfileTextField', + fields=[ + ('field_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='text', serialize=False, to='django_airavata_auth.extendeduserprofilefield')), + ], + bases=('django_airavata_auth.extendeduserprofilefield',), + ), + migrations.CreateModel( + name='ExtendedUserProfileInfo', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id_value', models.BigIntegerField(null=True)), + ('text_value', models.CharField(blank=True, max_length=255)), + ('created_date', models.DateTimeField(auto_now_add=True)), + ('updated_date', models.DateTimeField(auto_now=True)), + ('ext_user_profile_field', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_airavata_auth.extendeduserprofilefield')), + ('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extended_profile', to='django_airavata_auth.userprofile')), + ], + ), + migrations.CreateModel( + name='ExtendedUserProfileFieldLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.TextField()), + ('url', models.URLField()), + ('order', models.IntegerField()), + ('display_link', models.BooleanField(default=True)), + ('display_inline', models.BooleanField(default=False)), + ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='links', to='django_airavata_auth.extendeduserprofilefield')), + ], + ), + migrations.CreateModel( + name='ExtendedUserProfileSingleChoiceFieldChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('display_text', models.CharField(max_length=255)), + ('order', models.IntegerField()), + ('deleted', models.BooleanField(default=False)), + ('single_choice_field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='django_airavata_auth.extendeduserprofilesinglechoicefield')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ExtendedUserProfileMultiChoiceFieldChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('display_text', models.CharField(max_length=255)), + ('order', models.IntegerField()), + ('deleted', models.BooleanField(default=False)), + ('multi_choice_field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='django_airavata_auth.extendeduserprofilemultichoicefield')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/django_airavata/apps/auth/models.py b/django_airavata/apps/auth/models.py index 54395e0..a4aa13e 100644 --- a/django_airavata/apps/auth/models.py +++ b/django_airavata/apps/auth/models.py @@ -149,3 +149,112 @@ class PendingEmailChange(models.Model): max_length=36, unique=True, default=uuid.uuid4) created_date = models.DateTimeField(auto_now_add=True) verified = models.BooleanField(default=False) + + +class ExtendedUserProfileField(models.Model): + name = models.CharField(max_length=64) + help_text = models.TextField(blank=True) + order = models.IntegerField() + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now=True) + deleted = models.BooleanField(default=False) + + def __str__(self) -> str: + return f"{self.name} ({self.id})" + + @property + def field_type(self): + if hasattr(self, 'text'): + return 'text' + elif hasattr(self, 'single_choice'): + return 'single_choice' + elif hasattr(self, 'multi_choice'): + return 'multi_choice' + elif hasattr(self, 'user_agreement'): + return 'user_agreement' + else: + raise Exception("Could not determine field_type") + + +class ExtendedUserProfileTextField(ExtendedUserProfileField): + field_ptr = models.OneToOneField(ExtendedUserProfileField, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + related_name="text") + + +class ExtendedUserProfileSingleChoiceField(ExtendedUserProfileField): + field_ptr = models.OneToOneField(ExtendedUserProfileField, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + related_name="single_choice") + other = models.BooleanField(default=False) + + +class ExtendedUserProfileFieldChoice(models.Model): + display_text = models.CharField(max_length=255) + order = models.IntegerField() + deleted = models.BooleanField(default=False) + + class Meta: + abstract = True + + def __str__(self) -> str: + return f"{self.display_text} ({self.id})" + + +class ExtendedUserProfileSingleChoiceFieldChoice(ExtendedUserProfileFieldChoice): + single_choice_field = models.ForeignKey(ExtendedUserProfileSingleChoiceField, on_delete=models.CASCADE, related_name="choices") + + +class ExtendedUserProfileMultiChoiceField(ExtendedUserProfileField): + field_ptr = models.OneToOneField(ExtendedUserProfileField, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + related_name="multi_choice") + other = models.BooleanField(default=False) + + +class ExtendedUserProfileMultiChoiceFieldChoice(ExtendedUserProfileFieldChoice): + multi_choice_field = models.ForeignKey(ExtendedUserProfileMultiChoiceField, on_delete=models.CASCADE, related_name="choices") + + +class ExtendedUserProfileAgreementField(ExtendedUserProfileField): + field_ptr = models.OneToOneField(ExtendedUserProfileField, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + related_name="user_agreement") + # if no checkbox label, then some default text will be used + checkbox_label = models.TextField(blank=True) + + +class ExtendedUserProfileFieldLink(models.Model): + label = models.TextField() + url = models.URLField() + order = models.IntegerField() + display_link = models.BooleanField(default=True) + display_inline = models.BooleanField(default=False) + # Technically any field can have links + field = models.ForeignKey(ExtendedUserProfileField, on_delete=models.CASCADE, related_name="links") + + def __str__(self) -> str: + return f"{self.label} {self.url}" + + +class ExtendedUserProfileInfo(models.Model): + ext_user_profile_field = models.ForeignKey(ExtendedUserProfileField, on_delete=models.SET_NULL, null=True) + id_value = models.BigIntegerField(null=True) + text_value = models.CharField(max_length=255, blank=True) + user_profile = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="extended_profile") + created_date = models.DateTimeField(auto_now_add=True) + updated_date = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + if self.id_value: + return f"{self.ext_user_profile_field.name} {self.id_value}" + else: + return f"{self.ext_user_profile_field.name} {self.text_value}" diff --git a/django_airavata/apps/auth/serializers.py b/django_airavata/apps/auth/serializers.py index c10b918..6508fae 100644 --- a/django_airavata/apps/auth/serializers.py +++ b/django_airavata/apps/auth/serializers.py @@ -98,3 +98,123 @@ class UserSerializer(serializers.ModelSerializer): "url": verification_uri, }) utils.send_email_to_user(models.VERIFY_EMAIL_CHANGE_TEMPLATE, context) + + +class ExtendedUserProfileFieldChoiceSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False) + display_text = serializers.CharField() + order = serializers.IntegerField() + + +class ExtendedUserProfileFieldLinkSerializer(serializers.Serializer): + id = serializers.IntegerField(required=False) + label = serializers.CharField() + url = serializers.URLField() + order = serializers.IntegerField() + display_link = serializers.BooleanField(default=True) + display_inline = serializers.BooleanField(default=False) + + +class ExtendedUserProfileFieldSerializer(serializers.ModelSerializer): + field_type = serializers.ChoiceField(choices=["text", "single_choice", "multi_choice", "user_agreement"]) + other = serializers.BooleanField(required=False) + choices = ExtendedUserProfileFieldChoiceSerializer(required=False, many=True) + checkbox_label = serializers.CharField(allow_blank=True, required=False) + links = ExtendedUserProfileFieldLinkSerializer(required=False, many=True) + + class Meta: + model = models.ExtendedUserProfileField + fields = ['id', 'name', 'help_text', 'order', 'created_date', + 'updated_date', 'field_type', 'other', 'choices', 'checkbox_label', 'links'] + read_only_fields = ('created_date', 'updated_date') + + def to_representation(self, instance): + result = super().to_representation(instance) + if instance.field_type == 'single_choice': + result['other'] = instance.single_choice.other + result['choices'] = ExtendedUserProfileFieldChoiceSerializer(instance.single_choice.choices.filter(deleted=False).order_by('order'), many=True).data + if instance.field_type == 'multi_choice': + result['other'] = instance.multi_choice.other + result['choices'] = ExtendedUserProfileFieldChoiceSerializer(instance.multi_choice.choices.filter(deleted=False).order_by('order'), many=True).data + if instance.field_type == 'user_agreement': + result['checkbox_label'] = instance.user_agreement.checkbox_label + result['links'] = ExtendedUserProfileFieldLinkSerializer(instance.links.order_by('order'), many=True).data + return result + + def create(self, validated_data): + field_type = validated_data.pop('field_type') + other = validated_data.pop('other', False) + choices = validated_data.pop('choices', []) + checkbox_label = validated_data.pop('checkbox_label', '') + links = validated_data.pop('links', []) + if field_type == 'text': + instance = models.ExtendedUserProfileTextField.objects.create(**validated_data) + elif field_type == 'single_choice': + instance = models.ExtendedUserProfileSingleChoiceField.objects.create(**validated_data, other=other) + # add choices + for choice in choices: + choice.pop('id', None) + instance.choices.create(**choice) + elif field_type == 'multi_choice': + instance = models.ExtendedUserProfileMultiChoiceField.objects.create(**validated_data, other=other) + # add choices + for choice in choices: + choice.pop('id', None) + instance.choices.create(**choice) + elif field_type == 'user_agreement': + instance = models.ExtendedUserProfileAgreementField.objects.create(**validated_data, checkbox_label=checkbox_label) + else: + raise Exception(f"Unrecognized field type: {field_type}") + # create links + for link in links: + link.pop('id', None) + instance.links.create(**link) + return instance + + def update(self, instance, validated_data): + instance.name = validated_data['name'] + instance.help_text = validated_data['help_text'] + instance.order = validated_data['order'] + # logger.debug(f"instance.field_type={instance.field_type}, validated_data={validated_data}") + if instance.field_type == 'single_choice': + instance.single_choice.other = validated_data.get('other', instance.single_choice.other) + choices = validated_data.pop('choices', None) + if choices: + choice_ids = [choice['id'] for choice in choices if 'id' in choice] + # Soft delete any choices that are not in the list + instance.single_choice.choices.exclude(id__in=choice_ids).update(deleted=True) + for choice in choices: + choice_id = choice.pop('id', None) + models.ExtendedUserProfileSingleChoiceFieldChoice.objects.update_or_create( + id=choice_id, + defaults=choice, + ) + elif instance.field_type == 'multi_choice': + instance.multi_choice.other = validated_data.get('other', instance.multi_choice.other) + choices = validated_data.pop('choices', None) + if choices: + choice_ids = [choice['id'] for choice in choices if 'id' in choice] + # Soft delete any choices that are not in the list + instance.multi_choice.choices.exclude(id__in=choice_ids).update(deleted=True) + for choice in choices: + choice_id = choice.pop('id', None) + models.ExtendedUserProfileMultiChoiceFieldChoice.objects.update_or_create( + id=choice_id, + defaults=choice, + ) + elif instance.field_type == 'user_agreement': + instance.user_agreement.checkbox_label = validated_data.pop('checkbox_label', instance.user_agreement.checkbox_label) + + # update links + links = validated_data.pop('links', []) + link_ids = [link['id'] for link in links if 'id' in link] + instance.links.exclude(id__in=link_ids).delete() + for link in links: + link_id = link.pop('id', None) + models.ExtendedUserProfileFieldLink.objects.update_or_create( + id=link_id, + defaults=link, + ) + + instance.save() + return instance diff --git a/django_airavata/apps/auth/urls.py b/django_airavata/apps/auth/urls.py index 89909a5..5ab9dcf 100644 --- a/django_airavata/apps/auth/urls.py +++ b/django_airavata/apps/auth/urls.py @@ -7,6 +7,7 @@ from . import views router = routers.DefaultRouter() router.register(r'users', views.UserViewSet, basename='user') +router.register(r'extended-user-profile-fields', views.ExtendedUserProfileFieldViewset, basename='extend-user-profile-field') app_name = 'django_airavata_auth' urlpatterns = [ re_path(r'^', include(router.urls)), diff --git a/django_airavata/apps/auth/views.py b/django_airavata/apps/auth/views.py index edf8861..65aa3ec 100644 --- a/django_airavata/apps/auth/views.py +++ b/django_airavata/apps/auth/views.py @@ -28,6 +28,7 @@ from rest_framework import 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.auth import serializers from . import forms, iam_admin_client, models, utils @@ -701,3 +702,19 @@ def get_client_secret(access_token, client_endpoint): r = requests.get(client_endpoint + "/client-secret", headers=headers) r.raise_for_status() return r.json()['value'] + + +class ExtendedUserProfileFieldViewset(viewsets.ModelViewSet): + serializer_class = serializers.ExtendedUserProfileFieldSerializer + queryset = models.ExtendedUserProfileField.objects.all().order_by('order') + permission_classes = [IsInAdminsGroupPermission] + + def get_queryset(self): + queryset = super().get_queryset() + if self.action == 'list': + queryset = queryset.filter(deleted=False) + return queryset + + def perform_destroy(self, instance): + instance.deleted = True + instance.save()
