diff --git a/docs/setup/administrators/configuration.md b/docs/setup/administrators/configuration.md
index 3f9b79b3c9..1519799fa8 100644
--- a/docs/setup/administrators/configuration.md
+++ b/docs/setup/administrators/configuration.md
@@ -248,6 +248,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)
diff --git a/hypha/apply/determinations/templates/determinations/determination_detail.html b/hypha/apply/determinations/templates/determinations/determination_detail.html
index 96c362be0d..15eb999ccd 100644
--- a/hypha/apply/determinations/templates/determinations/determination_detail.html
+++ b/hypha/apply/determinations/templates/determinations/determination_detail.html
@@ -49,28 +49,30 @@
- {% for group in determination.detailed_data.values %}
-
- {% if group.title %}
-
- {% endif %}
+ {% if show_detailed_data %}
+ {% for group in determination.detailed_data.values %}
+
+ {% if group.title %}
+
+ {% endif %}
- {% for question, answer in group.questions %}
- {{ question }}
- {% if answer %}
- {% if answer == True or answer == False %}
- {{ answer|yesno:_("Agree,Disagree") }}
+ {% for question, answer in group.questions %}
+ {{ question }}
+ {% if answer %}
+ {% if answer == True or answer == False %}
+ {{ answer|yesno:_("Agree,Disagree") }}
+ {% else %}
+
+ {{ answer|nh3 }}
+
+ {% endif %}
{% else %}
-
- {{ answer|nh3 }}
-
+ -
{% endif %}
- {% else %}
- -
- {% endif %}
- {% endfor %}
-
- {% endfor %}
+ {% endfor %}
+
+ {% endfor %}
+ {% endif %}
{% endblock %}
diff --git a/hypha/apply/determinations/tests/test_views.py b/hypha/apply/determinations/tests/test_views.py
index 598628e123..64b72452fd 100644
--- a/hypha/apply/determinations/tests/test_views.py
+++ b/hypha/apply/determinations/tests/test_views.py
@@ -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:{}"
@@ -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
@@ -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:{}"
diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py
index f686db873c..0410692340 100644
--- a/hypha/apply/determinations/views.py
+++ b/hypha/apply/determinations/views.py
@@ -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
@@ -527,8 +528,23 @@ 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):
+ return super().get_context_data(
+ show_detailed_data=self.show_detailed_data, **kwargs
+ )
+
+
@method_decorator(staff_required, name="dispatch")
-class AdminDeterminationDetailView(DetailView):
+class AdminDeterminationDetailView(DeterminationDetailedDataMixin, DetailView):
model = Determination
def get_object(self, queryset=None):
@@ -556,9 +572,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"]
@@ -579,7 +604,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):
@@ -601,9 +626,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"]
@@ -615,18 +644,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)
diff --git a/hypha/apply/funds/permissions.py b/hypha/apply/funds/permissions.py
index 59cec18a3d..6be92a682d 100644
--- a/hypha/apply/funds/permissions.py
+++ b/hypha/apply/funds/permissions.py
@@ -22,6 +22,13 @@ def has_permission(action, user, object=None, raise_exception=True):
return value, reason
+def is_submission_applicant(user, submission) -> bool:
+ """Is the user the applicant, or a co-applicant, on this submission."""
+ return (
+ user == submission.user or submission.co_applicants.filter(user=user).exists()
+ )
+
+
def can_take_submission_actions(user, submission):
if not user.is_authenticated:
return False, _("Login Required")
@@ -214,11 +221,7 @@ def can_view_submission(user, submission):
if submission.is_archive and not can_view_archived_submissions(user):
return False, _("Archived Submission")
- if (
- user.is_apply_staff
- or submission.user == user
- or submission.co_applicants.filter(user=user).exists()
- ):
+ if user.is_apply_staff or is_submission_applicant(user, submission):
return True, ""
# By default, reviewers can see all submissions. This can be configured in Wagtail Admin > Apply > Reviewer Settings
diff --git a/hypha/apply/funds/views/submission_detail.py b/hypha/apply/funds/views/submission_detail.py
index 289cb291f6..0f9da34a57 100644
--- a/hypha/apply/funds/views/submission_detail.py
+++ b/hypha/apply/funds/views/submission_detail.py
@@ -38,6 +38,7 @@
can_alter_archived_submissions,
get_archive_view_groups,
has_permission,
+ is_submission_applicant,
)
from ..workflows import DRAFT_STATE
@@ -129,10 +130,7 @@ def dispatch(self, request, *args, **kwargs):
# If the requesting user submitted the application, return the Applicant view.
# Reviewers may sometimes be applicants as well.
# or if requesting user is a co-applicant to application, return the Applicant view.
- if (
- submission.user == request.user
- or submission.co_applicants.filter(user=request.user).exists()
- ):
+ if is_submission_applicant(request.user, submission):
return ApplicantSubmissionDetailView.as_view()(request, *args, **kwargs)
if submission.status == DRAFT_STATE:
raise Http404
@@ -161,10 +159,7 @@ def dispatch(self, request, *args, **kwargs):
# If the requesting user submitted the application, return the Applicant view.
# Reviewers may sometimes be applicants as well.
# or if requesting user is a co-applicant to application, return the Applicant view.
- if (
- submission.user == request.user
- or submission.co_applicants.filter(user=request.user).exists()
- ):
+ if is_submission_applicant(request.user, submission):
return ApplicantSubmissionDetailView.as_view()(request, *args, **kwargs)
# Only allow community reviewers in submission with a community review state.
if not submission.community_review:
@@ -188,10 +183,7 @@ def dispatch(self, request, *args, **kwargs):
"submission_view", request.user, object=submission, raise_exception=True
)
# This view is only for applicants and co-applicants.
- if (
- submission.user != request.user
- and not submission.co_applicants.filter(user=request.user).exists()
- ):
+ if not is_submission_applicant(request.user, submission):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
diff --git a/hypha/apply/funds/views/submission_edit.py b/hypha/apply/funds/views/submission_edit.py
index 9491fbd55c..c660672a66 100644
--- a/hypha/apply/funds/views/submission_edit.py
+++ b/hypha/apply/funds/views/submission_edit.py
@@ -59,6 +59,7 @@
from ..models import ApplicationSubmission
from ..permissions import (
has_permission,
+ is_submission_applicant,
)
from ..workflows.constants import (
DRAFT_STATE,
@@ -324,10 +325,7 @@ def buttons( # type: ignore[return]
class ApplicantSubmissionEditView(BaseSubmissionEditView):
def dispatch(self, request, *args, **kwargs):
submission = self.get_object()
- if (
- request.user != submission.user
- and not submission.co_applicants.filter(user=request.user).exists()
- ):
+ if not is_submission_applicant(request.user, submission):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index b0c9114aeb..d9731017b1 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -165,6 +165,12 @@
# Should Applicant identities be obscured from External Reviewers
HIDE_IDENTITY_FROM_REVIEWERS = env.bool("HIDE_IDENTITY_FROM_REVIEWERS", False)
+# 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)