Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consents for training requests #2363

Merged
merged 11 commits into from
Apr 7, 2023
Merged
31 changes: 31 additions & 0 deletions amy/consents/migrations/0008_trainingrequestconsent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 3.2.18 on 2023-03-11 15:26

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('workshops', '0256_auto_20230220_2051'),
('consents', '0007_term_short_description'),
]

operations = [
migrations.CreateModel(
name='TrainingRequestConsent',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_updated_at', models.DateTimeField(auto_now=True, null=True)),
('archived_at', models.DateTimeField(blank=True, null=True)),
('term', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='consents.term')),
('term_option', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='consents.termoption')),
('training_request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='workshops.trainingrequest')),
],
),
migrations.AddConstraint(
model_name='trainingrequestconsent',
constraint=models.UniqueConstraint(condition=models.Q(('archived_at__isnull', True)), fields=('training_request', 'term'), name='training_request__term__unique__when__archived_at__null'),
),
]
52 changes: 38 additions & 14 deletions amy/consents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from autoemails.mixins import RQJobsMixin
from consents.exceptions import TermOptionDoesNotBelongToTermException
from workshops.mixins import CreatedUpdatedArchivedMixin
from workshops.models import STR_LONG, STR_MED, Person
from workshops.models import STR_LONG, STR_MED, Person, TrainingRequest


class TermQuerySet(QuerySet):
Expand Down Expand Up @@ -202,20 +202,13 @@ def active(self):
return self.filter(archived_at=None)


class Consent(CreatedUpdatedArchivedMixin, models.Model):
person = models.ForeignKey(Person, on_delete=models.CASCADE)
class BaseConsent(CreatedUpdatedArchivedMixin, models.Model):
term = models.ForeignKey(Term, on_delete=models.PROTECT)
term_option = models.ForeignKey(TermOption, on_delete=models.PROTECT, null=True)
objects = Manager.from_queryset(ConsentQuerySet)()

class Meta:
constraints = [
models.UniqueConstraint(
fields=["person", "term"],
name="person__term__unique__when__archived_at__null",
condition=models.Q(archived_at__isnull=True),
),
]
abstract = True

def save(self, *args, **kwargs):
if self.term_option and self.term.pk != self.term_option.term.pk:
Expand All @@ -230,6 +223,23 @@ def is_archived(self) -> bool:
def is_active(self) -> bool:
return self.archived_at is None

def archive(self) -> None:
self.archived_at = timezone.now()
self.save()


class Consent(BaseConsent):
person = models.ForeignKey(Person, on_delete=models.CASCADE)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["person", "term"],
name="person__term__unique__when__archived_at__null",
condition=models.Q(archived_at__isnull=True),
),
]

@classmethod
def create_unset_consents_for_term(cls, term: Term) -> None:
"""
Expand All @@ -247,10 +257,6 @@ def create_unset_consents_for_term(cls, term: Term) -> None:
for person in Person.objects.all()
)

def archive(self) -> None:
self.archived_at = timezone.now()
self.save()

@classmethod
def archive_all_for_term(cls, terms: Iterable[Term]) -> None:
consents = cls.objects.filter(term__in=terms).active()
Expand Down Expand Up @@ -282,3 +288,21 @@ def reconsent(consent: Consent, term_option: TermOption) -> "Consent":
term_option=term_option,
person_id=consent.person.pk,
)


class TrainingRequestConsent(BaseConsent):
"""
A consent for a training request. People filling out the training request form
should accept all required consents. This model is used to store the consents.
"""

training_request = models.ForeignKey(TrainingRequest, on_delete=models.CASCADE)

class Meta:
constraints = [
models.UniqueConstraint(
fields=["training_request", "term"],
name="training_request__term__unique__when__archived_at__null",
condition=models.Q(archived_at__isnull=True),
),
]
135 changes: 97 additions & 38 deletions amy/extforms/forms.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from typing import Iterable, cast

from captcha.fields import ReCaptchaField
from crispy_forms.layout import HTML, Div, Field
from crispy_forms.layout import HTML, Div, Field, Layout
from django import forms
from django.core.exceptions import ValidationError
from django.db.models.fields import BLANK_CHOICE_DASH

from consents.forms import option_display_value
from consents.models import Term, TrainingRequestConsent
from extrequests.forms import (
SelfOrganisedSubmissionBaseForm,
WorkshopInquiryRequestBaseForm,
Expand Down Expand Up @@ -59,7 +64,7 @@ class Meta:
"max_travelling_frequency_other",
"reason",
"user_notes",
"data_privacy_agreement",
# "data_privacy_agreement",
"code_of_conduct_agreement",
"training_completion_agreement",
"workshop_teaching_agreement",
Expand All @@ -85,54 +90,70 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

# Only active and required terms.
self.terms = (
Term.objects.prefetch_active_options()
.filter(required_type=Term.PROFILE_REQUIRE_TYPE)
.order_by("slug")
)

self.set_consent_fields(self.terms)

# set up a layout object for the helper
self.helper.layout = self.helper.build_default_layout(self)

# set up RadioSelectWithOther widget so that it can display additional
# field inline
self["occupation"].field.widget.other_field = self["occupation_other"]
self["domains"].field.widget.other_field = self["domains_other"]
self["previous_training"].field.widget.other_field = self[
"previous_training_other"
]
self["previous_experience"].field.widget.other_field = self[
"previous_experience_other"
]
self["teaching_frequency_expectation"].field.widget.other_field = self[
"teaching_frequency_expectation_other"
]
self["max_travelling_frequency"].field.widget.other_field = self[
"max_travelling_frequency_other"
self.set_other_fields(self.helper.layout)
self.set_fake_required_fields()
self.set_accordion(self.helper.layout)
self.set_hr(self.helper.layout)

def set_other_field(self, field_name: str, layout: Layout) -> None:
"""
Set up a field so that it can be displayed as a separate widget.
"""
WidgetType = self._meta.widgets[field_name].__class__ # type: ignore
cast(WidgetType, self[field_name].field.widget).other_field = self[
f"{field_name}_other"
]

# remove that additional field
self.helper.layout.fields.remove("occupation_other")
self.helper.layout.fields.remove("domains_other")
self.helper.layout.fields.remove("previous_training_other")
self.helper.layout.fields.remove("previous_experience_other")
self.helper.layout.fields.remove("teaching_frequency_expectation_other")
self.helper.layout.fields.remove("max_travelling_frequency_other")

layout.fields.remove(f"{field_name}_other")

def set_other_fields(self, layout: Layout) -> None:
"""
Set fields that have "Other" counterpart as a separate widget.
"""
# Set up "*WithOther" widgets so that they can display additional
# inline fields. The original "*other" fields are removed from the layout.
self.set_other_field("occupation", layout)
self.set_other_field("domains", layout)
self.set_other_field("previous_training", layout)
self.set_other_field("previous_experience", layout)
self.set_other_field("teaching_frequency_expectation", layout)
self.set_other_field("max_travelling_frequency", layout)

def set_fake_required_fields(self) -> None:
# fake requiredness of the registration code / group name
self["group_name"].field.widget.fake_required = True
self["group_name"].field.widget.fake_required = True # type: ignore

def set_accordion(self, layout: Layout) -> None:
# special accordion display for the review process
self["review_process"].field.widget.subfields = {
self["review_process"].field.widget.subfields = { # type: ignore
"preapproved": [
self["group_name"],
],
"open": [], # this option doesn't require any additional fields
}
self["review_process"].field.widget.notes = TrainingRequest.REVIEW_CHOICES_NOTES
self[
"review_process"
].field.widget.notes = TrainingRequest.REVIEW_CHOICES_NOTES # type: ignore

# get current position of `review_process` field
pos_index = self.helper.layout.fields.index("review_process")
pos_index = layout.fields.index("review_process")

self.helper.layout.fields.remove("review_process")
self.helper.layout.fields.remove("group_name")
layout.fields.remove("review_process")
layout.fields.remove("group_name")

# insert div+field at previously saved position
self.helper.layout.insert(
layout.insert(
pos_index,
Div(
Field(
Expand All @@ -142,12 +163,33 @@ def __init__(self, *args, **kwargs):
),
)

def set_hr(self, layout: Layout) -> None:
# add <HR> around "underrepresented*" fields
index = self.helper.layout.fields.index("underrepresented")
self.helper.layout.insert(index, HTML(self.helper.hr()))

index = self.helper.layout.fields.index("underrepresented_details")
self.helper.layout.insert(index + 1, HTML(self.helper.hr()))
index = layout.fields.index("underrepresented")
layout.insert(index, HTML(self.helper.hr()))

index = layout.fields.index("underrepresented_details")
layout.insert(index + 1, HTML(self.helper.hr()))

def set_consent_fields(self, terms: Iterable[Term]) -> None:
for term in terms:
self.fields[term.slug] = self.create_consent_field(term)

def create_consent_field(self, term: Term) -> forms.ChoiceField:
options = [(opt.pk, option_display_value(opt)) for opt in term.options]
required = term.required_type == Term.PROFILE_REQUIRE_TYPE
initial = None
attrs = {"class": "border border-warning"} if initial is None else {}

field = forms.ChoiceField(
choices=BLANK_CHOICE_DASH + options,
label=term.content,
required=required,
initial=initial,
help_text=term.help_text or "",
widget=forms.Select(attrs=attrs),
)
return field

def clean(self):
super().clean()
Expand All @@ -174,6 +216,23 @@ def clean(self):
if errors:
raise ValidationError(errors)

def save(self, *args, **kwargs) -> None:
training_request = super().save(*args, **kwargs)
new_consents: list[TrainingRequestConsent] = []
for term in self.terms:
option_id = self.cleaned_data.get(term.slug)
if not option_id:
continue
new_consents.append(
TrainingRequestConsent(
training_request=training_request,
term_option_id=option_id,
term_id=term.pk,
)
)
TrainingRequestConsent.objects.bulk_create(new_consents)
return training_request


class WorkshopRequestExternalForm(WorkshopRequestBaseForm):
captcha = ReCaptchaField()
Expand Down
18 changes: 18 additions & 0 deletions amy/extforms/tests/test_training_request_form.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.core import mail
from django.urls import reverse

from consents.models import Term, TermOptionChoices
from extforms.views import TrainingRequestCreate
from workshops.models import Role, TrainingRequest
from workshops.tests.base import TestBase
Expand Down Expand Up @@ -45,6 +46,23 @@ def setUp(self):
"agreed_to_teach_workshops": "on",
"privacy_consent": True,
}
self.data.update(self.add_terms_to_payload())

def add_terms_to_payload(self) -> dict[str, int]:
data = {}
terms = (
Term.objects.prefetch_active_options()
.filter(required_type=Term.PROFILE_REQUIRE_TYPE)
.order_by("slug")
)
for term in terms:
option = next(
option
for option in term.options
if option.option_type == TermOptionChoices.AGREE
)
data[term.slug] = option.pk
return data

def test_request_added(self):
email = "[email protected]"
Expand Down
5 changes: 5 additions & 0 deletions amy/extrequests/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1590,3 +1590,8 @@ class TrainingRequestsMergeForm(forms.Form):
initial=DEFAULT,
widget=forms.RadioSelect,
)
trainingrequestconsent_set = forms.ChoiceField(
choices=TWO,
initial=DEFAULT,
widget=forms.RadioSelect,
)
Loading