diff --git a/app/projects/api.py b/app/projects/api.py index af40edc..6c64b15 100644 --- a/app/projects/api.py +++ b/app/projects/api.py @@ -3,8 +3,6 @@ import json import logging -from hypatio.auth0authenticate import user_auth_and_jwt - from django.conf import settings from django.contrib import messages from django.contrib.auth.models import User @@ -20,6 +18,7 @@ from django.core.files.base import ContentFile from dal import autocomplete +from hypatio.auth0authenticate import user_auth_and_jwt from contact.views import email_send from hypatio import file_services as fileservice from hypatio.file_services import get_download_url @@ -644,43 +643,75 @@ def save_signed_agreement_form(request): logger.debug('%s already has signed the agreement form "%s" for project "%s".', request.user.email, agreement_form.name, project.project_key) return HttpResponse(status=400) + # Check if this agreement form has a specified form class + fields = {} + if agreement_form.form_class: + try: + form = agreement_form.form( + request=request, + project=project, + data=request.POST, + ) + + # Check validity + if not form.is_valid(): + logger.debug(form.errors.as_json()) + + # Setup the script run. + response = HttpResponse(content=form.errors.as_json(), status=400) + response['X-IC-Script'] = "notify('{}', '{}', 'glyphicon glyphicon-{}');".format( + "warning", f"The agreement form contained errors, please review", "warning-sign" + ) + return response + + # Use the data from the form + fields = form.cleaned_data + + except Exception as e: + logger.exception(f"Agreement form error: {e}", exc_info=True) + return HttpResponse(status=500) + + else: + try: + # Set fields that we do not need to persist here + exclusions = [ + "csrfmiddlewaretoken", "project_key", "agreement_form_id", + "agreement_text" + ] + + # Save form fields + for key, value in dict(request.POST.lists()).items(): + + # Check exclusions + if key.lower() in exclusions: + continue + + # Retain lists + if len(value) > 1: + + # 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), "") + + except Exception as e: + logger.exception( + f"HYP/Projects/API: Fields error: {e}", + exc_info=True, + extra={"form": agreement_form.short_name, "fields": request.POST,} + ) + signed_agreement_form = SignedAgreementForm( user=request.user, agreement_form=agreement_form, project=project, date_signed=datetime.now(), - agreement_text=agreement_text + agreement_text=agreement_text, + fields=fields, ) - signed_agreement_form.save() - # Persist fields to JSON field on object try: - # Set fields that we do not need to persist here - exclusions = [ - "csrfmiddlewaretoken", "project_key", "agreement_form_id", - "agreement_text" - ] - - # Save form fields - fields = {} - for key, value in dict(request.POST.lists()).items(): - - # Check exclusions - if key.lower() in exclusions: - continue - - # Retain lists - if len(value) > 1: - - # 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), "") - - # Save fields - signed_agreement_form.fields = fields - # Check for a template if agreement_form.template: @@ -721,9 +752,6 @@ def save_signed_agreement_form(request): "signed_agreement_form": signed_agreement_form, }) - # Save - signed_agreement_form.save() - except Exception as e: logger.exception( f"HYP/Projects/API: Fields error: {e}", @@ -731,6 +759,9 @@ def save_signed_agreement_form(request): extra={"form": agreement_form.short_name, "fields": request.POST,} ) + # Save the agreement form + signed_agreement_form.save() + return HttpResponse(status=200) @user_auth_and_jwt diff --git a/app/projects/forms.py b/app/projects/forms.py new file mode 100644 index 0000000..d673aa7 --- /dev/null +++ b/app/projects/forms.py @@ -0,0 +1,202 @@ +from django import forms +from django.http import HttpRequest +import django.forms.fields as fields +import django.forms.widgets as widgets +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +from hypatio.scireg_services import get_current_user_profile +from projects.models import DataProject, AgreementForm + + +class MultiValueValidationError(ValidationError): + def __init__(self, errors): + clean_errors = [ + f"{message} (item {key})" for message, key, value in errors + ] + super().__init__(clean_errors) + self.error_detail = errors + + +class MultiValueFieldWidget(widgets.Input): + + def __init__(self, param_name: str) -> None: + super().__init__() + self.param_name: str = param_name + + def value_from_datadict(self, data, *args): + return data.getlist(self.param_name) + + +class MultiValueField(fields.Field): + + def __init__(self, + subfield: fields.Field, + param_name: str, + *args, **kwargs) -> None: + print(kwargs) + super().__init__( + widget=MultiValueFieldWidget(param_name), + *args, **kwargs, + ) + self.error_messages["required"] = _( + "Please specify one or more '{}' arguments." + ).format(param_name) + self.subfield = subfield + + def clean(self, values): + if len(values) == 0 and self.required: + raise ValidationError(self.error_messages["required"]) + result = [] + errors = [] + for i, value in enumerate(values): + try: + result.append(self.subfield.clean(value)) + except ValidationError as e: + if self.required: + errors.append((e.message, i, value)) + if len(errors): + raise MultiValueValidationError(errors) + return result + + +class AgreementFormForm(forms.Form): + + def __init__( + self, + request: HttpRequest, + project: DataProject, + agreement_form: AgreementForm, + *args, + **kwargs + ): + # Get initial data + if not kwargs.get("initial"): + kwargs["initial"] = self.set_initial(request, project, agreement_form) + super().__init__(*args, **kwargs) + + def set_initial(self, request: HttpRequest, project: DataProject, agreement_form: AgreementForm) -> dict[str, object]: + """ + Returns initial data for when the form is rendered. + + :param request: The current request + :type request: HttpRequest + :param project: The current data project + :type project: DataProject + :param agreement_form: The current agreement form this form is for + :type agreement_form: AgreementForm + :return: A dictionary of fields mapped to values + :rtype: dict[str, object] + """ + return {} + + def get_data(self, request: HttpRequest, project: DataProject, agreement_form: AgreementForm) -> dict[str, object]: + """ + Returns the data to be set on the agreement form object. + + :param request: The request + :type request: HttpRequest + :param project: The data project + :type project: DataProject + :param agreement_form: The agreement form this form is for + :type agreement_form: AgreementForm + :return: A dictionary of fields mapped to values + :rtype: dict[str, object] + """ + return self.cleaned_data + + +class AgreementForm4CEDUAForm(AgreementFormForm): + + REGISTRANT_INDIVIDUAL = "individual" + REGISTRANT_MEMBER = "member" + REGISTRANT_OFFICIAL = "official" + REGISTRANT_CHOICES = ( + (REGISTRANT_INDIVIDUAL, "an individual, requesting Data under this Agreement on behalf of themself"), + (REGISTRANT_MEMBER, "an institutional member, requesting Data under this Agreement signed by a representing institutional official"), + (REGISTRANT_OFFICIAL, "an institutional official, requesting Data under this Agreement on behalf of their institution and its agents and employees"), + ) + registrant_is = forms.ChoiceField(label="I am a", choices=REGISTRANT_CHOICES) + signer_name = forms.CharField(label="Name/Title", max_length=300, required=False) + signer_phone = forms.CharField(label="Phone", max_length=300, required=False) + signer_email = forms.CharField(label="E-mail", max_length=300, required=False) + signer_signature = forms.CharField(label="Electronic Signature (Full Name)", max_length=300, required=False) + date = forms.CharField(label="Date", max_length=50, required=False) + institute_name = forms.CharField(label="Institution Name", max_length=300, required=False) + institute_address = forms.CharField(label="Institution Address", max_length=300, required=False) + institute_city = forms.CharField(label="Institution City", max_length=300, required=False) + institute_state = forms.CharField(label="Institution State", max_length=300, required=False) + institute_zip = forms.CharField(label="Institution Zip", max_length=300, required=False) + member_emails = MultiValueField(forms.EmailField(), "member_emails", required=False) + + def clean(self): + cleaned_data = super().clean() + + # Handle conditional requirements + if cleaned_data.get("registrant_is") in [self.REGISTRANT_INDIVIDUAL, self.REGISTRANT_OFFICIAL]: + + # Set required fields under these conditions + required_fields = [ + "signer_name", + "signer_phone", + "signer_email", + "signer_signature", + "date", + ] + + # Check required fields + for field in required_fields: + if not cleaned_data.get(field): + self.add_error(field, "This is a required field") + + if cleaned_data.get("registrant_is") == self.REGISTRANT_OFFICIAL: + + # Set required fields under these conditions + required_fields = [ + "institute_name", + "institute_address", + "institute_city", + "institute_state", + "institute_zip", + "member_emails", + ] + + # Check required fields + for field in required_fields: + if not cleaned_data.get(field): + self.add_error(field, "This is a required field") + + def set_initial(self, request: HttpRequest, project: DataProject, agreement_form: AgreementForm) -> dict[str, object]: + """ + Returns initial data for when the form is rendered. + + :param request: The current request + :type request: HttpRequest + :param project: The current data project + :type project: DataProject + :param agreement_form: The current agreement form this form is for + :type agreement_form: AgreementForm + :return: A dictionary of fields mapped to values + :rtype: dict[str, object] + """ + initial = {} + # TODO: Disabled until we get the HTML updated to use form values + ''' + # Set initial data for this form + user_jwt = request.COOKIES.get("DBMI_JWT", None) + profile = next(iter(get_current_user_profile(user_jwt).get("results", [])), {}) + + # Build dictionary + initial = { + "signer_name": f"{profile['first_name']} {profile['last_name']}", + "signer_email": request.user.email, + "signer_phone": profile.get("phone_number"), + "institute_name": profile.get("institution"), + "institute_address": profile.get("street_address1"), + "institute_city": profile.get("city"), + "institute_state": profile.get("state"), + "institute_zip": profile.get("zipcode"), + "institute_country": profile.get("country"), + } + ''' + return initial diff --git a/app/projects/migrations/0107_agreementform_form_class.py b/app/projects/migrations/0107_agreementform_form_class.py new file mode 100644 index 0000000..968b238 --- /dev/null +++ b/app/projects/migrations/0107_agreementform_form_class.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-27 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0106_agreementform_institutional_signers_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='agreementform', + name='form_class', + field=models.CharField(blank=True, max_length=300, null=True), + ), + ] diff --git a/app/projects/models.py b/app/projects/models.py index 2c9af33..a7684fe 100644 --- a/app/projects/models.py +++ b/app/projects/models.py @@ -1,5 +1,6 @@ import uuid import re +import importlib from datetime import datetime import boto3 @@ -12,6 +13,8 @@ from django.core.files.uploadedfile import UploadedFile from django.utils.translation import gettext_lazy as _ +import projects + import logging logger = logging.getLogger(__name__) @@ -261,6 +264,7 @@ class AgreementForm(models.Model): ) 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.") + form_class = models.CharField(max_length=300, null=True, blank=True) # Meta created = models.DateTimeField(auto_now_add=True) @@ -279,6 +283,27 @@ def clean(self): if self.type == AGREEMENT_FORM_TYPE_FILE and not self.content and not self.form_file_path: raise ValidationError("If the form type is file, the content field should be populated with the agreement form's HTML.") + def form(self, request, project, *args, **kwargs) -> "projects.forms.AgreementFormForm": + + try: + if not self.form_class: + return None + + # Create class from string + components = self.form_class.rsplit(".", 1) + module = importlib.import_module(components[0]) + form_class = getattr(module, components[1]) + + # Instantiate object + form = form_class(request, project, self, *args, **kwargs) + + return form + + except Exception as e: + logger.exception(f"Agreement form form error: {e}", exc_info=True) + + return None + def agreement_form_4ce_dua_status_change(self, signed_agreement_form): """ This method handles status changes on signed versions of this agreement diff --git a/app/projects/views.py b/app/projects/views.py index a0d4b04..fe98bd5 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -538,17 +538,17 @@ def setup_panel_sign_agreement_forms(self, context): agreement_forms = self.project.agreement_forms.order_by('order', '-name') # Each form will be a separate step. - for form in agreement_forms: - logger.debug(f"{self.project.project_key}/{form.short_name}: Checking panel signed agreement form") + for agreement_form in agreement_forms: + logger.debug(f"{self.project.project_key}/{agreement_form.short_name}: Checking panel signed agreement form") # Only include Pending or Approved forms when searching. signed_forms = SignedAgreementForm.objects.filter( user=self.request.user, project=self.project, - agreement_form=form, + agreement_form=agreement_form, status__in=["P", "A"] ) - logger.debug(f"{self.project.project_key}/{form.short_name}: Found {len(signed_forms)} signed P/A forms") + logger.debug(f"{self.project.project_key}/{agreement_form.short_name}: Found {len(signed_forms)} signed P/A forms") # If this project accepts agreement forms from other projects, check those too if not signed_forms and self.project.shares_agreement_forms: @@ -556,31 +556,41 @@ def setup_panel_sign_agreement_forms(self, context): # Fetch without a specific project signed_forms = SignedAgreementForm.objects.filter( user=self.request.user, - agreement_form=form, + agreement_form=agreement_form, status__in=["P", "A"] ) - logger.debug(f"{self.project.project_key}/{form.short_name}: Found {len(signed_forms)} shared signed P/A forms") + logger.debug(f"{self.project.project_key}/{agreement_form.short_name}: Found {len(signed_forms)} shared signed P/A forms") # If the form has already been signed, then the step should be complete. step_complete = signed_forms.count() > 0 - logger.debug(f"{self.project.project_key}/{form.short_name}: Step is completed: {step_complete}") + logger.debug(f"{self.project.project_key}/{agreement_form.short_name}: Step is completed: {step_complete}") # If the form lives externally, then the step will be marked as permanent because we cannot tell if it was completed. - permanent_step = form.type == AGREEMENT_FORM_TYPE_EXTERNAL_LINK + permanent_step = agreement_form.type == AGREEMENT_FORM_TYPE_EXTERNAL_LINK - step_status = self.get_step_status(form.short_name, step_complete, permanent_step) - logger.debug(f"{self.project.project_key}/{form.short_name}: Step status: {step_status}") + step_status = self.get_step_status(agreement_form.short_name, step_complete, permanent_step) + logger.debug(f"{self.project.project_key}/{agreement_form.short_name}: Step status: {step_status}") - title = 'Form: {name}'.format(name=form.name) + title = 'Form: {name}'.format(name=agreement_form.name) - if not form.type or form.type == AGREEMENT_FORM_TYPE_STATIC or form.type == AGREEMENT_FORM_TYPE_MODEL: + if not agreement_form.type or agreement_form.type == AGREEMENT_FORM_TYPE_STATIC or agreement_form.type == AGREEMENT_FORM_TYPE_MODEL: template = 'projects/signup/sign-agreement-form.html' - elif form.type == AGREEMENT_FORM_TYPE_FILE: + elif agreement_form.type == AGREEMENT_FORM_TYPE_FILE: template = 'projects/signup/upload-agreement-form.html' - elif form.type == AGREEMENT_FORM_TYPE_EXTERNAL_LINK: + elif agreement_form.type == AGREEMENT_FORM_TYPE_EXTERNAL_LINK: template = 'projects/signup/sign-external-agreement-form.html' else: raise Exception("Agreement form type Not implemented") + + # Check if this agreement form has a specified form class + form = None + if agreement_form.form_class: + try: + # Initialize an instance of the form + form = agreement_form.form(self.request, self.project) + + except Exception as e: + logger.exception(f"Agreement form error: {e}", exc_info=True) panel = DataProjectSignupPanel( title=title, @@ -588,7 +598,8 @@ def setup_panel_sign_agreement_forms(self, context): template=template, status=step_status, additional_context={ - 'agreement_form': form, + "agreement_form": agreement_form, + "form": form, "institutional_official": context.get("institutional_official"), } ) diff --git a/app/static/agreementforms/4ce-dua.html b/app/static/agreementforms/4ce-dua.html index dfd949d..6f5b4e0 100644 --- a/app/static/agreementforms/4ce-dua.html +++ b/app/static/agreementforms/4ce-dua.html @@ -66,7 +66,7 @@
Should the User (i) inadvertently receives identifiable information or otherwise identifies a subject, or (ii) becomes aware of any use or disclosure of the Data not provided for or permitted by this Agreement, the User shall immediately notify Harvard via email to XXXXXX@hms.harvard.edu, and follow Harvard’s reasonable written instructions, which may include return or destruction of Data.
+Should the User (i) inadvertently receives identifiable information or otherwise identifies a subject, or (ii) becomes aware of any use or disclosure of the Data not provided for or permitted by this Agreement, the User shall immediately notify Harvard via email to cassandra_perry@hms.harvard.edu, and follow Harvard’s reasonable written instructions, which may include return or destruction of Data.
Individuals who will need access to the data and for whom institutional official is signing on behalf of
+