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 @@

VIII. Integration and Severability

IX. Reporting Requirement

-

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.

X. Ownership

@@ -92,21 +92,21 @@

XIII. Miscellaneous

-

I am a (select one):

+

I am a (select one): *

-
+
{% if institutional_official %} {% endif %}
@@ -120,47 +120,48 @@

I am a (select one):

Institutional Member

{% endif %} -
+

Institutional Official

-

Institution Details

- - + +
- - + +
- - + +
- - + +
- - + +

Member Details

-
+

Individuals who will need access to the data and for whom institutional official is signing on behalf of

+
{% for email in member_emails %}
- +
{% empty %}
- +
@@ -170,27 +171,27 @@

Member Details

-