Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/setup/administrators/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,12 @@ Should Applicant identities be obscured from External Reviewers

----

Should applicants be able to see the detailed answers of the determinations on their own applications. The determination message is always shown to them.

DETERMINATION_DETAILS_ACCESS_APPLICANT = env.bool('DETERMINATION_DETAILS_ACCESS_APPLICANT', True)

----

Should staff be able to access/see draft submissions.

SUBMISSIONS_DRAFT_ACCESS_STAFF = env.bool('SUBMISSIONS_DRAFT_ACCESS_STAFF', False)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@
{% include "forms/includes/form_errors.html" with form=form %}
{% block determination_information %}{% endblock %}

{% if detailed_data_staff_only %}
<div role="note" class="my-4 alert alert-info alert-soft">
{% heroicon_outline "eye-slash" size=20 aria_hidden=true class="opacity-80 shrink-0" %}
<span>
{% trans "Only the determination message is visible for the applicant, the answers to the other questions are not." %}
</span>
</div>
{% endif %}

<form
class="form"
action="" method="post"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,28 +49,43 @@ <h2 class="pb-1 mb-2 font-medium border-b text-h3 border-base-300 question">
</div>
</section>

{% for group in determination.detailed_data.values %}
<section>
{% if group.title %}
<h2 class="section-header">{{ group.title|nh3 }}</h2>
{% if show_detailed_data %}
<fieldset class="{% if detailed_data_staff_only %}py-2 px-4 rounded-box border border-base-300{% endif %}">
{% if detailed_data_staff_only %}
<legend class="px-2 fieldset-legend">
{% heroicon_mini "eye-slash" size=16 aria_hidden=true class="opacity-80" %}
{% trans "Not visible for applicant" %}
</legend>
{% endif %}

{% for question, answer in group.questions %}
<h3 class="pb-1 mb-2 font-medium border-b text-h3 border-base-300 question">{{ question }}</h3>
{% if answer %}
{% if answer == True or answer == False %}
{{ answer|yesno:_("Agree,Disagree") }}
{% else %}
<div class="max-w-none prose">
{{ answer|nh3 }}
</div>
{% endif %}
{% else %}
-
{% endif %}
{% endfor %}
<section>
{% endfor %}
<div class="flex flex-col gap-8">
{% for group in determination.detailed_data.values %}
<section class="space-y-8">
{% if group.title %}
<h2 class="section-header">{{ group.title|nh3 }}</h2>
{% endif %}

{% for question, answer in group.questions %}
<div>
<h3 class="pb-1 mb-2 font-medium border-b text-h3 border-base-300 question">{{ question }}</h3>
{% if answer %}
{% if answer == True or answer == False %}
{{ answer|yesno:_("Agree,Disagree") }}
{% else %}
<div class="max-w-none prose">
{{ answer|nh3 }}
</div>
{% endif %}
{% else %}
-
{% endif %}
</div>
{% endfor %}
</section>
{% endfor %}
</div>
</fieldset>
{% endif %}

</div>
{% endblock %}
115 changes: 114 additions & 1 deletion hypha/apply/determinations/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,36 @@
from hypha.apply.activity.models import Activity
from hypha.apply.determinations.options import ACCEPTED, NEEDS_MORE_INFO, REJECTED
from hypha.apply.determinations.views import BatchDeterminationCreateView
from hypha.apply.funds.models.co_applicants import (
CoApplicant,
CoApplicantInvite,
CoApplicantInviteStatus,
)
from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory
from hypha.apply.projects.models.project import CONTRACTING, DRAFT
from hypha.apply.users.tests.factories import StaffFactory, UserFactory
from hypha.apply.users.roles import APPLICANT_GROUP_NAME
from hypha.apply.users.tests.factories import (
ApplicantFactory,
GroupFactory,
ReviewerFactory,
StaffFactory,
UserFactory,
)
from hypha.apply.utils.testing import BaseViewTestCase

from .factories import DeterminationFactory


def make_co_applicant(submission, user):
"""Create a CoApplicant on the submission."""
invite = CoApplicantInvite.objects.create(
submission=submission,
invited_user_email=user.email,
status=CoApplicantInviteStatus.ACCEPTED,
)
return CoApplicant.objects.create(submission=submission, user=user, invite=invite)


class StaffDeterminationsTestCase(BaseViewTestCase):
user_factory = StaffFactory
url_name = "funds:submissions:determinations:{}"
Expand All @@ -34,6 +56,16 @@ def test_can_access_determination(self):
self.assertContains(response, self.user.full_name)
self.assertContains(response, submission.get_absolute_url())

@override_settings(DETERMINATION_DETAILS_ACCESS_APPLICANT=False)
def test_can_see_detailed_data_when_hidden_from_applicants(self):
submission = ApplicationSubmissionFactory(status="in_discussion")
determination = DeterminationFactory(
submission=submission, author=self.user, submitted=True
)
response = self.get_page(determination)
self.assertTrue(response.context["show_detailed_data"])
self.assertContains(response, "Goals and principles")

def test_lead_can_access_determination(self):
submission = ApplicationSubmissionFactory(
status="in_discussion", lead=self.user
Expand Down Expand Up @@ -562,6 +594,87 @@ def test_message_created_if_determination_exists(self):
self.assertEqual(len(response.context["messages"]), 5)


class ApplicantDeterminationDetailTestCase(BaseViewTestCase):
user_factory = ApplicantFactory
url_name = "funds:submissions:determinations:{}"
base_view_name = "detail"

def get_kwargs(self, instance):
return {"submission_pk": instance.submission.id, "pk": instance.pk}

def determination_for_user(self):
submission = ApplicationSubmissionFactory(
status="in_discussion", user=self.user
)
return DeterminationFactory(submission=submission, submitted=True)

def test_can_see_detailed_data_by_default(self):
determination = self.determination_for_user()
response = self.get_page(determination)
self.assertTrue(response.context["show_detailed_data"])
self.assertContains(response, "Goals and principles")

@override_settings(DETERMINATION_DETAILS_ACCESS_APPLICANT=False)
def test_cant_see_detailed_data_when_disabled(self):
determination = self.determination_for_user()
response = self.get_page(determination)
self.assertFalse(response.context["show_detailed_data"])
self.assertNotContains(response, "Goals and principles")
# The determination message is always shown to the applicant.
self.assertContains(response, determination.message)


class ReviewerDeterminationDetailTestCase(BaseViewTestCase):
user_factory = ReviewerFactory
url_name = "funds:submissions:determinations:{}"
base_view_name = "detail"

def get_kwargs(self, instance):
return {"submission_pk": instance.submission.id, "pk": instance.pk}

def determination_for_user(self):
"""A determination on the reviewer's own application.

Users can hold both roles; ViewDispatcher routes them to the reviewer
view because it checks `is_reviewer` before `is_applicant`.
"""
self.user.groups.add(GroupFactory(name=APPLICANT_GROUP_NAME))
submission = ApplicationSubmissionFactory(
status="in_discussion", user=self.user
)
return DeterminationFactory(submission=submission, submitted=True)

def test_can_see_detailed_data_on_other_submissions(self):
determination = DeterminationFactory(
submission=ApplicationSubmissionFactory(status="in_discussion"),
submitted=True,
)
response = self.get_page(determination)
self.assertTrue(response.context["show_detailed_data"])

def test_can_see_detailed_data_on_own_submission_by_default(self):
determination = self.determination_for_user()
response = self.get_page(determination)
self.assertTrue(response.context["show_detailed_data"])

@override_settings(DETERMINATION_DETAILS_ACCESS_APPLICANT=False)
def test_cant_see_detailed_data_on_own_submission_when_disabled(self):
determination = self.determination_for_user()
response = self.get_page(determination)
self.assertFalse(response.context["show_detailed_data"])
self.assertNotContains(response, "Goals and principles")
# The determination message is always shown to the applicant.
self.assertContains(response, determination.message)

@override_settings(DETERMINATION_DETAILS_ACCESS_APPLICANT=False)
def test_cant_see_detailed_data_as_co_applicant_when_disabled(self):
submission = ApplicationSubmissionFactory(status="in_discussion")
make_co_applicant(submission, self.user)
determination = DeterminationFactory(submission=submission, submitted=True)
response = self.get_page(determination)
self.assertFalse(response.context["show_detailed_data"])


class UserDeterminationFormTestCase(BaseViewTestCase):
user_factory = UserFactory
url_name = "funds:submissions:determinations:{}"
Expand Down
78 changes: 63 additions & 15 deletions hypha/apply/determinations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from hypha.apply.activity.messaging import MESSAGES, messenger
from hypha.apply.activity.models import Activity
from hypha.apply.funds.models import ApplicationSubmission
from hypha.apply.funds.permissions import is_submission_applicant
from hypha.apply.funds.workflows import DETERMINATION_OUTCOMES
from hypha.apply.funds.workflows.models.stage import Concept
from hypha.apply.projects.models import Project
Expand Down Expand Up @@ -109,8 +110,24 @@ def outcome_choices_for_phase(submission, user):
return available_choices


class DeterminationFormViewMixin:
"""Lets staff know when the answers they give are withheld from applicants.

Only the determination message is shown to the applicant when
DETERMINATION_DETAILS_ACCESS_APPLICANT is turned off.
"""

def get_context_data(self, **kwargs):
return super().get_context_data(
detailed_data_staff_only=not settings.DETERMINATION_DETAILS_ACCESS_APPLICANT,
**kwargs,
)


@method_decorator(staff_required, name="dispatch")
class BatchDeterminationCreateView(BaseStreamForm, CreateView):
class BatchDeterminationCreateView(
DeterminationFormViewMixin, BaseStreamForm, CreateView
):
submission_form_class = BatchDeterminationForm
template_name = "determinations/batch_determination_form.html"

Expand Down Expand Up @@ -273,7 +290,9 @@ def get_success_url(self):


@method_decorator(staff_required, name="dispatch")
class DeterminationCreateOrUpdateView(BaseStreamForm, CreateOrUpdateView):
class DeterminationCreateOrUpdateView(
DeterminationFormViewMixin, BaseStreamForm, CreateOrUpdateView
):
submission_form_class = DeterminationModelForm
model = Determination
template_name = "determinations/determination_form.html"
Expand Down Expand Up @@ -527,8 +546,30 @@ def should_redirect(cls, request, submission, action):
)


class DeterminationDetailedDataMixin:
"""Controls whether the determination's detailed answers are rendered.

The determination message is always shown, the answers to the individual
determination form questions are opt-out for applicants.
"""

show_detailed_data = True

def get_context_data(self, **kwargs):
# Let staff know when the answers they see are withheld from applicants.
detailed_data_staff_only = (
not settings.DETERMINATION_DETAILS_ACCESS_APPLICANT
and self.request.user.is_apply_staff
)
return super().get_context_data(
show_detailed_data=self.show_detailed_data,
detailed_data_staff_only=detailed_data_staff_only,
**kwargs,
)


@method_decorator(staff_required, name="dispatch")
class AdminDeterminationDetailView(DetailView):
class AdminDeterminationDetailView(DeterminationDetailedDataMixin, DetailView):
model = Determination

def get_object(self, queryset=None):
Expand Down Expand Up @@ -556,9 +597,18 @@ def dispatch(self, request, *args, **kwargs):


@method_decorator(login_required, name="dispatch")
class ReviewerDeterminationDetailView(DetailView):
class ReviewerDeterminationDetailView(DeterminationDetailedDataMixin, DetailView):
model = Determination

@property
def show_detailed_data(self):
# Reviewers are routed here ahead of the applicant view, so a reviewer
# looking at a determination on their own application is still subject
# to the applicant setting.
if is_submission_applicant(self.request.user, self.submission):
return settings.DETERMINATION_DETAILS_ACCESS_APPLICANT
return True

def get_object(self, queryset=None):
return get_object_or_404(
self.model, submission=self.submission, id=self.kwargs["pk"]
Expand All @@ -579,7 +629,7 @@ def dispatch(self, request, *args, **kwargs):


@method_decorator(login_required, name="dispatch")
class CommunityDeterminationDetailView(DetailView):
class CommunityDeterminationDetailView(DeterminationDetailedDataMixin, DetailView):
model = Determination

def get_queryset(self):
Expand All @@ -601,9 +651,13 @@ def dispatch(self, request, *args, **kwargs):


@method_decorator(login_required, name="dispatch")
class ApplicantDeterminationDetailView(DetailView):
class ApplicantDeterminationDetailView(DeterminationDetailedDataMixin, DetailView):
model = Determination

@property
def show_detailed_data(self):
return settings.DETERMINATION_DETAILS_ACCESS_APPLICANT

def get_object(self, queryset=None):
return get_object_or_404(
self.model, submission=self.submission, id=self.kwargs["pk"]
Expand All @@ -615,18 +669,12 @@ def dispatch(self, request, *args, **kwargs):
)
determination = self.get_object()

if (
request.user != self.submission.user
and not self.submission.co_applicants.filter(user=request.user).exists
):
if not is_submission_applicant(request.user, self.submission):
raise PermissionDenied

if determination.is_draft:
return HttpResponseRedirect(
reverse_lazy(
"apply:submissions:determinations:detail",
args=(self.submission.id,),
)
reverse_lazy("apply:submissions:detail", args=(self.submission.id,))
)

return super().dispatch(request, *args, **kwargs)
Expand All @@ -640,7 +688,7 @@ class DeterminationDetailView(ViewDispatcher):


@method_decorator(staff_required, name="dispatch")
class DeterminationEditView(BaseStreamForm, UpdateView):
class DeterminationEditView(DeterminationFormViewMixin, BaseStreamForm, UpdateView):
submission_form_class = DeterminationModelForm
model = Determination
template_name = "determinations/determination_form.html"
Expand Down
Loading