diff --git a/app/projects/admin.py b/app/projects/admin.py index 650e34ac..d677b212 100644 --- a/app/projects/admin.py +++ b/app/projects/admin.py @@ -17,7 +17,6 @@ from projects.models import ChallengeTaskSubmissionDownload from projects.models import Bucket from projects.models import InstitutionalOfficial -from projects.models import InstitutionalMember class GroupAdmin(admin.ModelAdmin): @@ -64,10 +63,6 @@ class InstitutionalOfficialAdmin(admin.ModelAdmin): list_display = ('user', 'institution', 'project', 'created', 'modified', ) readonly_fields = ('created', 'modified', ) -class InstitutionalMemberAdmin(admin.ModelAdmin): - list_display = ('email', 'official', 'user', 'created', 'modified', ) - readonly_fields = ('created', 'modified', ) - class HostedFileAdmin(admin.ModelAdmin): list_display = ('long_name', 'project', 'hostedfileset', 'file_name', 'file_location', 'order', 'created', 'modified',) list_filter = ('project', ) @@ -106,7 +101,6 @@ class ChallengeTaskSubmissionDownloadAdmin(admin.ModelAdmin): admin.site.register(Participant, ParticipantAdmin) admin.site.register(Institution, InstitutionAdmin) admin.site.register(InstitutionalOfficial, InstitutionalOfficialAdmin) -admin.site.register(InstitutionalMember, InstitutionalMemberAdmin) admin.site.register(HostedFile, HostedFileAdmin) admin.site.register(HostedFileSet, HostedFileSetAdmin) admin.site.register(HostedFileDownload, HostedFileDownloadAdmin) diff --git a/app/projects/api.py b/app/projects/api.py index 63948a7b..af40edc6 100644 --- a/app/projects/api.py +++ b/app/projects/api.py @@ -44,7 +44,6 @@ from projects.models import SIGNED_FORM_REJECTED from projects.models import HostedFileSet from projects.models import InstitutionalOfficial -from projects.models import InstitutionalMember logger = logging.getLogger(__name__) @@ -672,9 +671,12 @@ def save_signed_agreement_form(request): # Retain lists if len(value) > 1: - fields[key] = value + + # Only retain valid values + valid_values = [v for v in value if v] + fields[key] = valid_values if valid_values else "" else: - fields[key] = next(iter(value), None) + fields[key] = next(iter(value), "") # Save fields signed_agreement_form.fields = fields @@ -802,13 +804,52 @@ def submit_user_permission_request(request): return response # Create a new participant record if one does not exist already. - Participant.objects.get_or_create( + participant, created = Participant.objects.get_or_create( user=request.user, project=project ) + # Check if this project allows institutional signers + if project.institutional_signers: + + # Check if this is a member + try: + official = InstitutionalOfficial.objects.get( + project=project, + member_emails__contains=request.user.email, + ) + + # Check if they have access + official_participant = Participant.objects.get(user=official.user) + if official_participant.permission == "VIEW": + + # Approve signed agreement forms + for signed_agreement_form in SignedAgreementForm.objects.filter(project=project, user=request.user): + + # If allows institutional signers, auto-approve + if signed_agreement_form.agreement_form.institutional_signers: + + signed_agreement_form.status = "A" + signed_agreement_form.save() + + # Grant this user access immediately if all agreement forms are accepted + for agreement_form in project.agreement_forms.all(): + if not SignedAgreementForm.objects.filter( + agreement_form=agreement_form, + project=project, + user=request.user, + status="A" + ): + break + else: + participant.permission = "VIEW" + participant.save() + + except ObjectDoesNotExist: + pass + # Check if there are administrators to notify. - if project.project_supervisors: + if project.project_supervisors and not participant.permission: # Convert the comma separated string of emails into a list. supervisor_emails = project.project_supervisors.split(",") @@ -832,6 +873,8 @@ def submit_user_permission_request(request): except Exception as e: logger.exception(e) + elif participant.permission: + logger.debug(f"Request has been auto-approved due to an institutional signer") else: logger.warning(f"Project '{project}' has not supervisors to alert on access requests") @@ -855,6 +898,10 @@ def submit_user_permission_request(request): "success", "Your request for access has been submitted", "thumbs-up" ) + # Reload page if approved + if participant.permission == "VIEW": + response['X-IC-Script'] += "setTimeout(function() { location.reload(); }, 2000);" + return response @@ -927,6 +974,10 @@ def update_institutional_members(request): # Get the list member_emails = [m.lower() for m in request.POST.getlist("member-emails", [])] + # Get deletions and additions + deleted_member_emails = list(set(official.member_emails) - set(member_emails)) + added_member_emails = list(set(member_emails) - set(official.member_emails)) + # Check for duplicates if len(set(member_emails)) < len(member_emails): @@ -940,31 +991,34 @@ def update_institutional_members(request): return response - # Iterate existing members - for member in InstitutionalMember.objects.filter(official=official): + # Save the official with updated emails + official.member_emails = member_emails + official.save() - # Check if in list - if member.email.lower() in member_emails: - logger.debug(f"Update institutional members: Member '{member.email}' already exists") + # Iterate removed emails and remove access + for email in deleted_member_emails: + try: + participant = Participant.objects.get(project=official.project, user__email=email, permission="VIEW") - # Remove email from list - member_emails.remove(member.email.lower()) + # Revoke it if found + participant.permission = None + participant.save() - elif member.email.lower() not in member_emails: - logger.debug(f"Update institutional members: Member '{member.email}' will be deleted") + except ObjectDoesNotExist: + pass - # Delete them - member.delete() + # Iterate added emails and add access if waiting + for email in added_member_emails: + try: + official_participant = Participant.objects.get(project=official.project, user=official.user) + participant = Participant.objects.get(project=official.project, user__email=email) - # Create members from remaining email addresses - for member_email in member_emails: - logger.debug(f"Update institutional members: Member '{member_email}' will be created") + # Add access if found + participant.permission = official_participant.permission + participant.save() - # Create them - InstitutionalMember.objects.create( - official=official, - email=member_email, - ) + except ObjectDoesNotExist: + pass # Create the response. response = HttpResponse(status=201) diff --git a/app/projects/migrations/0106_agreementform_institutional_signers_and_more.py b/app/projects/migrations/0106_agreementform_institutional_signers_and_more.py new file mode 100644 index 00000000..e324fd76 --- /dev/null +++ b/app/projects/migrations/0106_agreementform_institutional_signers_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.14 on 2024-08-22 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0105_agreementform_template'), + ] + + operations = [ + migrations.AddField( + model_name='agreementform', + name='institutional_signers', + field=models.BooleanField(default=False, help_text='Allows institutional signers to sign for their members. This will auto-approve this agreement form for members whose institutional official has had their agreement form approved.'), + ), + migrations.AddField( + model_name='dataproject', + name='institutional_signers', + field=models.BooleanField(default=False, help_text='Allows institutional signers to sign for their members. This will auto-approve agreement forms for members whose institutional official has had their agreement forms approved.'), + ), + migrations.AddField( + model_name='institutionalofficial', + name='member_emails', + field=models.JSONField(default=[]), + preserve_default=False, + ), + migrations.DeleteModel( + name='InstitutionalMember', + ), + ] diff --git a/app/projects/models.py b/app/projects/models.py index cd3271e7..2c9af33e 100644 --- a/app/projects/models.py +++ b/app/projects/models.py @@ -260,6 +260,7 @@ class AgreementForm(models.Model): " the person who they submitted their signed agreement form to." ) template = models.CharField(max_length=300, blank=True, null=True) + institutional_signers = models.BooleanField(default=False, help_text="Allows institutional signers to sign for their members. This will auto-approve this agreement form for members whose institutional official has had their agreement form approved.") # Meta created = models.DateTimeField(auto_now_add=True) @@ -363,6 +364,10 @@ class DataProject(models.Model): help_text="Set this to a specific bucket where this project's files should be stored." ) + # Automate approval of members covered by an already-approved institutional signer + institutional_signers = models.BooleanField(default=False, help_text="Allows institutional signers to sign for their members. This will auto-approve agreement forms for members whose institutional official has had their agreement forms approved.") + + # Meta created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) @@ -399,19 +404,7 @@ class InstitutionalOfficial(models.Model): project = models.ForeignKey(DataProject, on_delete=models.PROTECT) institution = models.TextField(null=False, blank=False) signed_agreement_form = models.ForeignKey("SignedAgreementForm", on_delete=models.PROTECT) - - # Meta - created = models.DateTimeField(auto_now_add=True) - modified = models.DateTimeField(auto_now=True) - - -class InstitutionalMember(models.Model): - """ - This represents a member of an institution. - """ - official = models.ForeignKey(InstitutionalOfficial, on_delete=models.PROTECT) - email = models.TextField(null=False, blank=False) - user = models.ForeignKey(User, on_delete=models.PROTECT, null=True, blank=True) + member_emails = models.JSONField(null=False, blank=False, editable=True) # Meta created = models.DateTimeField(auto_now_add=True) diff --git a/app/projects/panels.py b/app/projects/panels.py index 96dc029e..262f5ee7 100644 --- a/app/projects/panels.py +++ b/app/projects/panels.py @@ -70,14 +70,3 @@ class DataProjectInstitutionalOfficialPanel(DataProjectPanel): def __init__(self, title, bootstrap_color, template, additional_context=None): super().__init__(title, bootstrap_color, template, additional_context) - - -class DataProjectInstitutionalMemberPanel(DataProjectPanel): - """ - This class holds information needed to display panels on the DataProject - page that outline institutional officials and the members they are representing. - """ - - def __init__(self, title, bootstrap_color, template, status, additional_context=None): - super().__init__(title, bootstrap_color, template, additional_context) - self.status = status diff --git a/app/projects/signals.py b/app/projects/signals.py index afc5247d..b37dc9e4 100644 --- a/app/projects/signals.py +++ b/app/projects/signals.py @@ -9,7 +9,6 @@ from projects.models import SignedAgreementForm from projects.models import TEAM_ACTIVE, TEAM_DEACTIVATED, TEAM_READY from projects.models import InstitutionalOfficial -from projects.models import InstitutionalMember import logging logger = logging.getLogger(__name__) @@ -144,15 +143,6 @@ def signed_agreement_form_pre_save_handler(sender, **kwargs): institution=instance.fields["institute-name"], project=instance.project, signed_agreement_form=instance, + member_emails=instance.fields["member-emails"], ) official.save() - - # Iterate members - for email in instance.fields["member-emails"]: - - # Create member - member = InstitutionalMember.objects.create( - official=official, - email=email, - ) - member.save() diff --git a/app/projects/templatetags/projects_extras.py b/app/projects/templatetags/projects_extras.py index c7403e2d..e1a6c038 100644 --- a/app/projects/templatetags/projects_extras.py +++ b/app/projects/templatetags/projects_extras.py @@ -19,6 +19,10 @@ def get_html_form_file_contents(form_file_path): return render_to_string(form_file_path) +@register.simple_tag +def get_agreement_form_template(form_file_path, context={}): + return render_to_string(form_file_path, context=context) + @register.filter def get_login_url(current_uri): diff --git a/app/projects/views.py b/app/projects/views.py index 82a18ba9..a0d4b045 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -29,7 +29,6 @@ from projects.models import SignedAgreementForm from projects.models import Group from projects.models import InstitutionalOfficial -from projects.models import InstitutionalMember from projects.panels import SIGNUP_STEP_COMPLETED_STATUS from projects.panels import SIGNUP_STEP_CURRENT_STATUS from projects.panels import SIGNUP_STEP_FUTURE_STATUS @@ -39,7 +38,6 @@ from projects.panels import DataProjectActionablePanel from projects.panels import DataProjectSharedTeamsPanel from projects.panels import DataProjectInstitutionalOfficialPanel -from projects.panels import DataProjectInstitutionalMemberPanel # Get an instance of a logger logger = logging.getLogger(__name__) @@ -332,27 +330,41 @@ def get_signup_context(self, context): else: - # Add panel for institutional members - self.panel_institutional_member(context) + # Set institutional context + self.get_institutional_context(context) # Agreement forms step (if needed). self.setup_panel_sign_agreement_forms(context) - # Show JWT step (if needed). - self.setup_panel_show_jwt(context) - # Access request step (if needed). self.setup_panel_request_access(context) + # Show JWT step (if needed). + self.setup_panel_show_jwt(context) + # Team setup step (if needed). self.setup_panel_team(context) # TODO commented out until this is ready. # Static page that lets user know to wait. # self.step_pending_review(context) - return context + def get_institutional_context(self, context): + """ + Prepares context for institional signer status, if applicable + """ + # Check if this project/agreement form accepts institutional signers + if self.project.institutional_signers: + try: + # Check for one + context["institutional_official"] = InstitutionalOfficial.objects.get( + project=self.project, + member_emails__contains=self.request.user.email, + ) + except ObjectDoesNotExist: + pass + def get_participate_context(self, context): """ Adds to the view's context anything needed for participating in a DataProject @@ -575,7 +587,10 @@ def setup_panel_sign_agreement_forms(self, context): bootstrap_color='default', template=template, status=step_status, - additional_context={'agreement_form': form} + additional_context={ + 'agreement_form': form, + "institutional_official": context.get("institutional_official"), + } ) context['setup_panels'].append(panel) @@ -795,65 +810,21 @@ def panel_institutional_official(self, context): Builds the context needed for the institutional official to manage the members that they provide signing authority for. """ - # Setup context - additional_context = {} - try: # Check for an institutional official linked to this user official = InstitutionalOfficial.objects.get(user=self.request.user) - # Add to context - additional_context["official"] = official - except ObjectDoesNotExist: - pass - - try: - # Check for an institutional member linked to this user - member = InstitutionalMember.objects.get(email=self.request.user.email) - - # Add to context - additional_context["member"] = member - except ObjectDoesNotExist: - pass - - if additional_context: + # Add a panel panel = DataProjectInstitutionalOfficialPanel( title='Institutional Official', bootstrap_color='default', template='projects/participate/institutional-official.html', - additional_context=additional_context + additional_context={ + "official": official, + } ) context['actionable_panels'].append(panel) - - def panel_institutional_member(self, context): - """ - Builds the context needed for the institutional official to manage - the members that they provide signing authority for. - """ - try: - # Check for an institutional member linked to this user - member = InstitutionalMember.objects.get(official__project=self.project, email=self.request.user.email) - - # Add to context - additional_context = { - "member": member, - } - - # This step is never completed. - step_status = self.get_step_status('institutional_member', False) - - # Add the panel - panel = DataProjectInstitutionalMemberPanel( - title='Institutional Member', - bootstrap_color='default', - template='projects/participate/institutional-member.html', - status=step_status, - additional_context=additional_context - ) - - context['setup_panels'].append(panel) - except ObjectDoesNotExist: pass @@ -931,23 +902,6 @@ def is_user_granted_access(self, context): considered having been granted access to participate in this DataProject. Returns a boolean. """ - # Check for institutional access - try: - # Only perform this check for authenticated users - if self.request.user.is_authenticated: - member = InstitutionalMember.objects.get(official__project=self.project, email=self.request.user.email) - logger.debug(f"Institutional member found under official: {member.official.user.email}") - - # Check if official has access - official_participant = Participant.objects.get(project=self.project, user=member.official.user) - if official_participant.permission == "VIEW": - logger.debug(f"Institutional official has access, granting access to member") - return True - else: - logger.debug(f"Institutional official does not have access") - - except ObjectDoesNotExist: - logger.debug(f"No institutional member found") # Does user not have VIEW permissions? if not context['has_view_permission']: diff --git a/app/static/agreementforms/4ce-dua.html b/app/static/agreementforms/4ce-dua.html index d174c14e..dfd949d1 100644 --- a/app/static/agreementforms/4ce-dua.html +++ b/app/static/agreementforms/4ce-dua.html @@ -99,10 +99,12 @@

I am a (select one):

an individual, requesting Data under this Agreement on behalf of themself
+ {% if institutional_official %} + {% endif %}