From 47c752118870e642576cf4b62dcbb8374231477f Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 2 Sep 2026 14:59:29 +0200 Subject: [PATCH 1/5] Fix bug in ApplicantDeterminationDetailView permission check. --- hypha/apply/determinations/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index f686db873c..525f9ffcd3 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -617,7 +617,7 @@ def dispatch(self, request, *args, **kwargs): if ( request.user != self.submission.user - and not self.submission.co_applicants.filter(user=request.user).exists + and not self.submission.co_applicants.filter(user=request.user).exists() ): raise PermissionDenied From 405595436799be29e33bcba8b093132fa3bdbcb3 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 2 Sep 2026 17:36:48 +0200 Subject: [PATCH 2/5] Add DETERMINATION_DETAILS_ACCESS_APPLICANT, true by default. Allows orgs to hide determination details from applicants. --- docs/setup/administrators/configuration.md | 6 +++ .../determinations/determination_detail.html | 40 ++++++++-------- .../apply/determinations/tests/test_views.py | 46 ++++++++++++++++++- hypha/apply/determinations/views.py | 27 +++++++++-- hypha/settings/base.py | 6 +++ 5 files changed, 101 insertions(+), 24 deletions(-) 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..4fd31e76db 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 %} -

{{ group.title|nh3 }}

- {% endif %} + {% if show_detailed_data %} + {% for group in determination.detailed_data.values %} +
+ {% if group.title %} +

{{ group.title|nh3 }}

+ {% 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..4dbbf5cba5 100644 --- a/hypha/apply/determinations/tests/test_views.py +++ b/hypha/apply/determinations/tests/test_views.py @@ -10,7 +10,11 @@ from hypha.apply.determinations.views import BatchDeterminationCreateView 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.tests.factories import ( + ApplicantFactory, + StaffFactory, + UserFactory, +) from hypha.apply.utils.testing import BaseViewTestCase from .factories import DeterminationFactory @@ -34,6 +38,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 +576,36 @@ 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 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 525f9ffcd3..af61363805 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -527,8 +527,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,7 +571,7 @@ def dispatch(self, request, *args, **kwargs): @method_decorator(login_required, name="dispatch") -class ReviewerDeterminationDetailView(DetailView): +class ReviewerDeterminationDetailView(DeterminationDetailedDataMixin, DetailView): model = Determination def get_object(self, queryset=None): @@ -579,7 +594,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 +616,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"] 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) From 10a485485e9b8bcf9b14d7f23ddb5d3f64e6ca81 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 2 Sep 2026 17:55:18 +0200 Subject: [PATCH 3/5] Fix redirect when draft. --- .../templates/determinations/determination_detail.html | 2 +- hypha/apply/determinations/views.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/hypha/apply/determinations/templates/determinations/determination_detail.html b/hypha/apply/determinations/templates/determinations/determination_detail.html index 4fd31e76db..15eb999ccd 100644 --- a/hypha/apply/determinations/templates/determinations/determination_detail.html +++ b/hypha/apply/determinations/templates/determinations/determination_detail.html @@ -70,7 +70,7 @@

{{ q - {% endif %} {% endfor %} -
+
{% endfor %} {% endif %} diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index af61363805..b94c42b482 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -642,10 +642,7 @@ def dispatch(self, request, *args, **kwargs): 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) From a3606da2bf10c302aab841c194f4129b4c99b50f Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 2 Sep 2026 19:30:59 +0200 Subject: [PATCH 4/5] Make so check also work for reviewers that also are applicants. --- .../apply/determinations/tests/test_views.py | 69 +++++++++++++++++++ hypha/apply/determinations/views.py | 21 ++++-- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/hypha/apply/determinations/tests/test_views.py b/hypha/apply/determinations/tests/test_views.py index 4dbbf5cba5..64b72452fd 100644 --- a/hypha/apply/determinations/tests/test_views.py +++ b/hypha/apply/determinations/tests/test_views.py @@ -8,10 +8,18 @@ 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.roles import APPLICANT_GROUP_NAME from hypha.apply.users.tests.factories import ( ApplicantFactory, + GroupFactory, + ReviewerFactory, StaffFactory, UserFactory, ) @@ -20,6 +28,16 @@ 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:{}" @@ -606,6 +624,57 @@ def test_cant_see_detailed_data_when_disabled(self): 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 b94c42b482..8d01818d11 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -527,6 +527,13 @@ def should_redirect(cls, request, submission, action): ) +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() + ) + + class DeterminationDetailedDataMixin: """Controls whether the determination's detailed answers are rendered. @@ -574,6 +581,15 @@ def dispatch(self, request, *args, **kwargs): 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"] @@ -634,10 +650,7 @@ 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: From ca5d6a2d07231acbbc8e6c68681b58f5eacf6dee Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 2 Sep 2026 19:36:08 +0200 Subject: [PATCH 5/5] Move is_submission_applicant and reuse it. --- hypha/apply/determinations/views.py | 8 +------- hypha/apply/funds/permissions.py | 13 ++++++++----- hypha/apply/funds/views/submission_detail.py | 16 ++++------------ hypha/apply/funds/views/submission_edit.py | 6 ++---- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index 8d01818d11..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,13 +528,6 @@ def should_redirect(cls, request, submission, action): ) -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() - ) - - class DeterminationDetailedDataMixin: """Controls whether the determination's detailed answers are rendered. 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)