- {% for activity in activities %}
+{# `|length` evaluates the queryset once and caches it, so the count below and #}
+{# the loop further down reuse a single query rather than issuing a COUNT each. #}
+{% with activity_count=activities|length %}
+ {% if activity_count > 1 or activity_count == 1 and not preview_activity %}
+
+
+ {% trans "Status & Activity" %} ({{ activity_count }})
+ {% if preview_activity %}
+
+ {% include "activity/ui/activity-action-item.html" with activity=preview_activity no_timeline=True mini=True %}
+
+ {% endif %}
+
+
+
+ {% for activity in activities %}
- {% if activity.type == "comment" %}
- {% include "activity/ui/activity-comment-item.html" with activity=activity mini=True %}
- {% elif activity %}
- {% include "activity/ui/activity-action-item.html" with activity=activity mini=True %}
- {% endif %}
- {% endfor %}
+ {% if activity.type == "comment" %}
+ {% include "activity/ui/activity-comment-item.html" with activity=activity mini=True %}
+ {% elif activity %}
+ {% include "activity/ui/activity-action-item.html" with activity=activity mini=True %}
+ {% endif %}
+ {% endfor %}
+
+
+
+ {% else %}
+
+
{% trans "Status & Activity" %} ({{ activity_count }})
+
+ {% if preview_activity %}
+ {% include "activity/ui/activity-action-item.html" with activity=preview_activity no_timeline=True mini=True %}
+ {% else %}
+
{% heroicon_outline 'chat-bubble-left-right' class='inline size-4' %}{% trans "When you leave a comment or change the statuses, it'll show up here!" %}
+ {% endif %}
-
-{% else %}
-
-
{% trans "Status & Activity" %} ({{ activities.count }})
-
- {% if preview_activity %}
- {% include "activity/ui/activity-action-item.html" with activity=preview_activity no_timeline=True mini=True %}
- {% else %}
-
{% heroicon_outline 'chat-bubble-left-right' class='inline size-4' %}{% trans "When you leave a comment or change the statuses, it'll show up here!" %}
- {% endif %}
-
-
-{% endif %}
+ {% endif %}
+{% endwith %}
diff --git a/hypha/apply/projects/tests/factories.py b/hypha/apply/projects/tests/factories.py
index 13913d05a5..30c0fad1cd 100644
--- a/hypha/apply/projects/tests/factories.py
+++ b/hypha/apply/projects/tests/factories.py
@@ -27,6 +27,7 @@
ProjectFormPointer,
ProjectReportForm,
ProjectSettings,
+ ProjectSOW,
ProjectSOWForm,
)
@@ -140,6 +141,14 @@ class Meta:
project = factory.SubFactory(ProjectFactory)
+class ProjectSOWFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = ProjectSOW
+ skip_postgeneration_save = True
+
+ project = factory.SubFactory(ProjectFactory)
+
+
class ProjectSettingsFactory(factory.django.DjangoModelFactory):
site = factory.LazyFunction(lambda: ApplySiteFactory())
diff --git a/hypha/apply/projects/tests/test_object_activity_partials.py b/hypha/apply/projects/tests/test_object_activity_partials.py
new file mode 100644
index 0000000000..28aa5eb760
--- /dev/null
+++ b/hypha/apply/projects/tests/test_object_activity_partials.py
@@ -0,0 +1,157 @@
+"""Access control tests for the object "Status & Activity" partials.
+
+These partials render comment bodies, so they must be scoped to the project in
+the URL and gated on the requesting user's access to that project.
+"""
+
+from django.test import TestCase
+from django.urls import reverse
+
+from hypha.apply.activity import services
+from hypha.apply.activity.models import COMMENT
+from hypha.apply.activity.tests.factories import ActivityFactory
+from hypha.apply.projects.reports.tests.factories import ReportFactory
+from hypha.apply.users.tests.factories import (
+ ApplicantFactory,
+ ContractingFactory,
+ FinanceFactory,
+ StaffFactory,
+)
+
+from ..models.project import INVOICING_AND_REPORTING
+from .factories import (
+ InvoiceFactory,
+ ProjectFactory,
+ ProjectFormPointerFactory,
+ ProjectSOWFactory,
+)
+
+
+class BaseObjectActivityPartialTestCase(TestCase):
+ """Builds one project with every commentable object hanging off it"""
+
+ def setUp(self):
+ self.vendor = ApplicantFactory()
+ self.project = ProjectFactory(status=INVOICING_AND_REPORTING, user=self.vendor)
+ self.other_project = ProjectFactory(status=INVOICING_AND_REPORTING)
+
+ self.invoice = InvoiceFactory(project=self.project)
+ self.report = ReportFactory(project=self.project, is_submitted=True)
+ self.sow = ProjectSOWFactory(project=self.project)
+ self.pfp = ProjectFormPointerFactory(project=self.project)
+
+ def urls(self, project=None):
+ project = project or self.project
+ return {
+ "invoice": reverse(
+ "apply:projects:partial-invoice-status",
+ kwargs={"pk": project.pk, "invoice_pk": self.invoice.pk},
+ ),
+ "report": reverse(
+ "apply:projects:partial-report-status",
+ kwargs={"pk": project.pk, "report_pk": self.report.pk},
+ ),
+ "sow": reverse(
+ "apply:projects:partial-sow-status",
+ kwargs={"pk": project.pk, "sow_pk": self.sow.pk},
+ ),
+ "pf": reverse(
+ "apply:projects:partial-pf-status",
+ kwargs={"pk": project.pk, "pfp_pk": self.pfp.pk},
+ ),
+ }
+
+
+class TestObjectActivityPartialAccess(BaseObjectActivityPartialTestCase):
+ def test_staff_can_view_all(self):
+ self.client.force_login(StaffFactory())
+ for name, url in self.urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_finance_can_view_all(self):
+ self.client.force_login(FinanceFactory())
+ for name, url in self.urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_contracting_can_view_project_forms_only(self):
+ """Contracting has project access, but not report or invoice access
+
+ `view_report` and `invoice_access` exclude contracting, exactly as
+ `ReportDetailView` and `InvoiceAccessMixin` do on the pages themselves.
+ """
+ self.client.force_login(ContractingFactory())
+ urls = self.urls()
+ for name in ["report", "invoice"]:
+ with self.subTest(name):
+ self.assertEqual(
+ self.client.get(urls.pop(name), secure=True).status_code, 403
+ )
+ for name, url in urls.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_project_vendor_can_view_all(self):
+ self.client.force_login(self.vendor)
+ for name, url in self.urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_unrelated_applicant_is_denied(self):
+ """The core regression: an applicant on another project must not read these"""
+ self.client.force_login(ApplicantFactory())
+ for name, url in self.urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 403)
+
+ def test_anonymous_is_redirected_to_login(self):
+ for name, url in self.urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 302)
+
+ def test_objects_are_scoped_to_the_project_in_the_url(self):
+ """A valid object id under the wrong project pk must 404, not resolve"""
+ self.client.force_login(StaffFactory())
+ for name, url in self.urls(project=self.other_project).items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 404)
+
+ def test_comment_body_not_leaked_to_unrelated_applicant(self):
+ comment = ActivityFactory(
+ source=self.project.submission,
+ related_object=self.invoice,
+ user=StaffFactory(),
+ message="a confidential internal note",
+ )
+ self.client.force_login(ApplicantFactory())
+ response = self.client.get(self.urls()["invoice"], secure=True)
+ self.assertEqual(response.status_code, 403)
+ self.assertNotContains(response, comment.message, status_code=403)
+
+ def test_post_is_not_allowed(self):
+ self.client.force_login(StaffFactory())
+ for name, url in self.urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.post(url, secure=True).status_code, 405)
+
+
+class TestObjectActivityPartialContent(BaseObjectActivityPartialTestCase):
+ def test_edited_comment_is_only_rendered_once(self):
+ """Superseded revisions (`current=False`) must not be listed"""
+ staff = StaffFactory()
+ comment = ActivityFactory(
+ source=self.project.submission,
+ related_object=self.invoice,
+ user=staff,
+ type=COMMENT,
+ message="the original message",
+ )
+ # Clones the old revision as `current=False`, keeping the relation.
+ services.edit_comment(comment, "the edited message")
+
+ self.client.force_login(staff)
+ response = self.client.get(self.urls()["invoice"], secure=True)
+
+ self.assertEqual(len(response.context["activities"]), 1)
+ self.assertNotContains(response, "the original message")
diff --git a/hypha/apply/projects/tests/test_permissions.py b/hypha/apply/projects/tests/test_permissions.py
index 869940a2cf..a2c4e63532 100644
--- a/hypha/apply/projects/tests/test_permissions.py
+++ b/hypha/apply/projects/tests/test_permissions.py
@@ -9,6 +9,7 @@
- can_access_project
- can_edit_paf
- can_view_contract_category_documents
+ - can_view_invoice
- PAF approval functions (can_update_paf_approvers, can_update_assigned_paf_approvers,
can_assign_paf_approvers, can_update_paf_status)
"""
@@ -52,9 +53,11 @@
can_update_project_status,
can_upload_contract,
can_view_contract_category_documents,
+ can_view_invoice,
)
from .factories import (
ContractFactory,
+ InvoiceFactory,
PAFApprovalsFactory,
PAFReviewerRoleFactory,
ProjectFactory,
@@ -747,3 +750,44 @@ def test_parallel_reviewer_can_update_any_unapproved(self):
approver, self.project, request=request
)
self.assertTrue(ok)
+
+
+class TestCanViewInvoice(TestCase):
+ """`can_view_invoice` backs both `InvoiceAccessMixin` and the invoice partials"""
+
+ def setUp(self):
+ self.vendor = ApplicantFactory()
+ self.project = ProjectFactory(status=INVOICING_AND_REPORTING, user=self.vendor)
+ self.invoice = InvoiceFactory(project=self.project)
+
+ def test_anonymous_cant(self):
+ can_view, _reason = can_view_invoice(AnonymousUser(), self.invoice)
+ self.assertFalse(can_view)
+
+ def test_staff_and_finance_can(self):
+ for user in [StaffFactory(), FinanceFactory()]:
+ with self.subTest(user.roles):
+ self.assertTrue(can_view_invoice(user, self.invoice)[0])
+
+ def test_vendor_can(self):
+ self.assertTrue(can_view_invoice(self.vendor, self.invoice)[0])
+
+ def test_contracting_cant(self):
+ """Contracting has project access but no business with invoices"""
+ self.assertFalse(can_view_invoice(ContractingFactory(), self.invoice)[0])
+
+ def test_unrelated_applicant_cant(self):
+ self.assertFalse(can_view_invoice(ApplicantFactory(), self.invoice)[0])
+
+ def test_co_applicant_needs_the_invoices_permission(self):
+ without = ApplicantFactory()
+ make_co_applicant(self.project, without, permissions=[])
+ self.assertFalse(can_view_invoice(without, self.invoice)[0])
+
+ with_perm = ApplicantFactory()
+ make_co_applicant(
+ self.project,
+ with_perm,
+ permissions=[CoApplicantProjectPermission.INVOICES],
+ )
+ self.assertTrue(can_view_invoice(with_perm, self.invoice)[0])
diff --git a/hypha/apply/projects/tests/test_project_partials.py b/hypha/apply/projects/tests/test_project_partials.py
new file mode 100644
index 0000000000..2c72e4fd67
--- /dev/null
+++ b/hypha/apply/projects/tests/test_project_partials.py
@@ -0,0 +1,107 @@
+"""Access control tests for the project detail HTMX partials.
+
+Every partial under `views/project_partials.py` takes a project pk from the URL,
+so each must confirm the requesting user may see that project - and the
+invoice-scoped ones must confirm the invoice belongs to it.
+"""
+
+from django.test import TestCase
+from django.urls import reverse
+
+from hypha.apply.users.tests.factories import (
+ ApplicantFactory,
+ ContractingFactory,
+ FinanceFactory,
+ StaffFactory,
+)
+
+from ..models.project import INVOICING_AND_REPORTING
+from .factories import InvoiceFactory, ProjectFactory
+
+
+class BaseProjectPartialTestCase(TestCase):
+ def setUp(self):
+ self.vendor = ApplicantFactory()
+ self.project = ProjectFactory(status=INVOICING_AND_REPORTING, user=self.vendor)
+ self.invoice = InvoiceFactory(project=self.project)
+
+ def project_urls(self, project=None):
+ project = project or self.project
+ return {
+ name: reverse(f"apply:projects:{name}", kwargs={"pk": project.pk})
+ for name in [
+ "project_lead",
+ "project_title",
+ "project_information",
+ "supporting_documents",
+ "contract_documents",
+ "partial-invoices-status",
+ "partial-rejected-invoices-status",
+ ]
+ }
+
+ def invoice_urls(self, project=None):
+ project = project or self.project
+ return {
+ name: reverse(
+ f"apply:projects:{name}",
+ kwargs={"pk": project.pk, "invoice_pk": self.invoice.pk},
+ )
+ for name in [
+ "partial-invoice-detail-actions",
+ "partial-invoice-tags",
+ ]
+ }
+
+
+class TestProjectPartialAccess(BaseProjectPartialTestCase):
+ def test_staff_can_view_everything(self):
+ self.client.force_login(StaffFactory())
+ for name, url in {**self.project_urls(), **self.invoice_urls()}.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_project_vendor_can_view_everything(self):
+ self.client.force_login(self.vendor)
+ for name, url in {**self.project_urls(), **self.invoice_urls()}.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_unrelated_applicant_is_denied(self):
+ self.client.force_login(ApplicantFactory())
+ for name, url in {**self.project_urls(), **self.invoice_urls()}.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 403)
+
+ def test_anonymous_is_redirected(self):
+ for name, url in {**self.project_urls(), **self.invoice_urls()}.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 302)
+
+ def test_contracting_has_project_access_but_not_invoice_access(self):
+ self.client.force_login(ContractingFactory())
+ for name, url in self.project_urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+ for name, url in self.invoice_urls().items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 403)
+
+ def test_finance_can_view_everything(self):
+ self.client.force_login(FinanceFactory())
+ for name, url in {**self.project_urls(), **self.invoice_urls()}.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 200)
+
+ def test_invoice_is_scoped_to_the_project_in_the_url(self):
+ other_project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ self.client.force_login(StaffFactory())
+ for name, url in self.invoice_urls(project=other_project).items():
+ with self.subTest(name):
+ self.assertEqual(self.client.get(url, secure=True).status_code, 404)
+
+ def test_post_is_not_allowed(self):
+ self.client.force_login(StaffFactory())
+ for name, url in {**self.project_urls(), **self.invoice_urls()}.items():
+ with self.subTest(name):
+ self.assertEqual(self.client.post(url, secure=True).status_code, 405)
diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py
index 09b77ee2d6..8e7213f9a1 100644
--- a/hypha/apply/projects/tests/test_views.py
+++ b/hypha/apply/projects/tests/test_views.py
@@ -974,7 +974,7 @@ def test_other_cant(self):
self.assertEqual(response.status_code, 403)
def test_activity_renders(self):
- invoice = InvoiceFactory()
+ invoice = InvoiceFactory(project__user=self.user)
invoice_added_msg = ActivityAdapter.messages[MESSAGES.CREATE_INVOICE].lower()
ActivityFactory(
message=invoice_added_msg,
@@ -993,6 +993,18 @@ def test_activity_renders(self):
self.assertContains(response, invoice_added_msg)
+ def test_other_activity_cant_be_read(self):
+ """Activity on another vendor's invoice must not be readable"""
+ invoice = InvoiceFactory()
+ response = self.client.get(
+ reverse(
+ "apply:projects:partial-invoice-status",
+ kwargs={"pk": invoice.project.pk, "invoice_pk": invoice.pk},
+ ),
+ secure=True,
+ )
+ self.assertEqual(response.status_code, 403)
+
class TestApplicantEditInvoiceView(BaseViewTestCase):
base_view_name = "invoice-edit"
diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py
index fe71d1d8ec..26210ad920 100644
--- a/hypha/apply/projects/views/payment.py
+++ b/hypha/apply/projects/views/payment.py
@@ -84,6 +84,7 @@
InvoiceExportManager,
)
from ..models.project import Project
+from ..permissions import can_view_invoice
from ..service_utils import batch_update_invoices_status, handle_tasks_on_invoice_update
from ..tables import AdminInvoiceListTable, FinanceInvoiceTable
@@ -97,29 +98,8 @@ def get_object(self):
return get_object_or_404(project.invoices.all(), pk=self.kwargs["invoice_pk"])
def test_func(self):
- if self.request.user.is_apply_staff:
- return True
-
- if self.request.user.is_finance:
- return True
-
- if self.request.user == self.get_object().project.user:
- return True
-
- if self.request.user.is_applicant:
- co_applicant = (
- self.get_object()
- .project.submission.co_applicants.filter(user=self.request.user)
- .first()
- )
- if (
- co_applicant
- and CoApplicantProjectPermission.INVOICES
- in co_applicant.project_permission
- ):
- return True
-
- return False
+ can_view, _reason = can_view_invoice(self.request.user, self.get_object())
+ return can_view
@method_decorator(staff_or_finance_required, name="dispatch")
diff --git a/hypha/apply/projects/views/project_partials.py b/hypha/apply/projects/views/project_partials.py
index 9d2d23c858..6fbcf3f25c 100644
--- a/hypha/apply/projects/views/project_partials.py
+++ b/hypha/apply/projects/views/project_partials.py
@@ -2,13 +2,14 @@
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
-from django.db.models import Manager, Model, Q, QuerySet
+from django.core.exceptions import PermissionDenied
+from django.db.models import Model, Q
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_GET
+from rolepermissions.checkers import has_object_permission
from hypha.apply.activity.models import Activity
-from hypha.apply.projects.reports.models import Report
from ..models.payment import Invoice
from ..models.project import (
@@ -18,27 +19,93 @@
ProjectFormPointer,
ProjectSOW,
)
+from ..permissions import has_permission
+
+
+def get_accessible_project(request: HttpRequest, pk: int) -> Project:
+ """Retrieve a project, raising if the requesting user has no access to it"""
+ project = get_object_or_404(Project, pk=pk)
+ has_permission("project_access", request.user, object=project, raise_exception=True)
+ return project
+
+
+def get_accessible_invoice(request: HttpRequest, pk: int, invoice_pk: int) -> Invoice:
+ """Retrieve an invoice scoped to its project, raising if the user can't see it"""
+ project = get_object_or_404(Project, pk=pk)
+ invoice = get_object_or_404(project.invoices, pk=invoice_pk)
+ has_permission("invoice_access", request.user, object=invoice, raise_exception=True)
+ return invoice
+
+
+def get_object_activity(request: HttpRequest, obj: Model) -> HttpResponse:
+ """A generic view function to be leveraged by more specific object views
+
+ The caller is responsible for resolving `obj` and for checking that the
+ requesting user is allowed to see it.
+
+ Args:
+ request: request used to retrieve partial
+ obj: the object to get activity for
+
+ Returns:
+ A rendered object_status.html template containing all status/activity relating to the specific object
+ """
+ user = request.user
+
+ related_type_pk = ContentType.objects.get_for_model(obj).pk
+
+ activities = (
+ Activity.objects.filter(
+ related_content_type=related_type_pk, related_object_id=obj.pk
+ )
+ .exclude(current=False)
+ .visible_to(user)
+ )
+
+ preview_activity = (
+ Activity.actions.filter(
+ related_content_type=related_type_pk, related_object_id=obj.pk
+ )
+ .visible_to(user)
+ .first()
+ )
+
+ return render(
+ request,
+ "application_projects/partials/object_status.html",
+ context={
+ "object": obj,
+ "preview_activity": preview_activity,
+ "activities": activities,
+ "user": user,
+ # Determine if the collapsible be open by default
+ "open": True if request.GET.get("open") == "true" else False,
+ },
+ )
@login_required
+@require_GET
def partial_project_lead(request, pk):
- project = get_object_or_404(Project, pk=pk)
+ project = get_accessible_project(request, pk)
return render(
request, "application_projects/partials/project_lead.html", {"object": project}
)
@login_required
+@require_GET
def partial_project_title(request, pk):
- project = get_object_or_404(Project, pk=pk)
+ project = get_accessible_project(request, pk)
return render(
request, "application_projects/partials/project_title.html", {"object": project}
)
@login_required
+@require_GET
def partial_project_information(request, pk):
- project = get_object_or_404(Project, pk=pk)
+ project = get_accessible_project(request, pk)
return render(
request,
"application_projects/partials/project_information.html",
@@ -49,7 +116,7 @@ def partial_project_information(request, pk):
@login_required
@require_GET
def partial_supporting_documents(request, pk):
- project = get_object_or_404(Project, pk=pk)
+ project = get_accessible_project(request, pk)
ctx = {"object": project}
ctx["all_document_categories"] = DocumentCategory.objects.all()
ctx["remaining_document_categories"] = DocumentCategory.objects.filter(
@@ -63,7 +130,7 @@ def partial_supporting_documents(request, pk):
@login_required
@require_GET
def partial_contracting_documents(request, pk):
- project = get_object_or_404(Project, pk=pk)
+ project = get_accessible_project(request, pk)
ctx = {"object": project}
ctx["all_contract_document_categories"] = ContractDocumentCategory.objects.all()
ctx["remaining_contract_document_categories"] = (
@@ -86,6 +153,7 @@ def partial_contracting_documents(request, pk):
@login_required
+@require_GET
def partial_get_invoice_status_table(
request: HttpRequest, pk: int, rejected: Optional[bool] = False
):
@@ -100,7 +168,7 @@ def partial_get_invoice_status_table(
Returns:
HttpResponse containing the table of requested invoices
"""
- invoices = get_object_or_404(Project, pk=pk).invoices
+ invoices = get_accessible_project(request, pk).invoices
return render(
request,
@@ -113,110 +181,87 @@ def partial_get_invoice_status_table(
)
-def get_object_activity(
- request: HttpRequest, object_class: Model | Manager | QuerySet, object_pk: int
-) -> HttpResponse:
- """A generic view function to be leveraged by more specific object views
-
- Args:
- object_class: A Model, Manager or QuerySet of the object to get activity for
- object_pk: the pk of the object to get activity for
-
- Returns:
- A rendered object_status.html template containing all status/activity relating to the specific object
- """
- object = get_object_or_404(object_class, pk=object_pk)
- user = request.user
-
- related_type_pk = ContentType.objects.get_for_model(object_class).pk
-
- activities = Activity.objects.filter(
- related_content_type=related_type_pk, related_object_id=object_pk
- ).visible_to(user)
-
- preview_activity = (
- Activity.actions.filter(
- related_content_type=related_type_pk, related_object_id=object_pk
- )
- .visible_to(user)
- .first()
- )
-
- return render(
- request,
- "application_projects/partials/object_status.html",
- context={
- "object": object,
- "preview_activity": preview_activity,
- "activities": activities,
- "user": user,
- # Determine if the collapsible be open by default
- "open": True if request.GET.get("open") == "true" else False,
- },
- )
-
-
@login_required
-def partial_get_invoice_status(request: HttpRequest, invoice_pk: int, *args, **kwargs):
+@require_GET
+def partial_get_invoice_status(request: HttpRequest, pk: int, invoice_pk: int):
"""
Partial to get the invoice status for invoice detail view
Args:
request: request used to retrieve partial
+ pk: PK of the project the invoice belongs to
invoice_pk: ID of the invoice to retrieve the status of
Returns:
HttpResponse containing the activity of requested invoice
"""
- return get_object_activity(request, Invoice, invoice_pk)
+ invoice = get_accessible_invoice(request, pk, invoice_pk)
+ return get_object_activity(request, invoice)
@login_required
-def partial_get_report_status(request: HttpRequest, report_pk: int, *args, **kwargs):
+@require_GET
+def partial_get_report_status(request: HttpRequest, pk: int, report_pk: int):
"""
- Partial to get the invoice status for invoice detail view
+ Partial to get the report status for the report detail view
Args:
request: request used to retrieve partial
- invoice_pk: ID of the invoice to retrieve the status of
+ pk: PK of the project the report belongs to
+ report_pk: ID of the report to retrieve the status of
Returns:
- HttpResponse containing the activity of requested invoice
+ HttpResponse containing the activity of requested report
"""
- return get_object_activity(request, Report, report_pk)
+ project = get_accessible_project(request, pk)
+ report = get_object_or_404(project.reports, pk=report_pk)
+ # `project_access` alone would expose future/skipped reports, mirror the
+ # check made by `ReportDetailView.dispatch`.
+ if not has_object_permission("view_report", request.user, report):
+ raise PermissionDenied
+ return get_object_activity(request, report)
@login_required
-def partial_get_sow_status(request: HttpRequest, sow_pk: int, *args, **kwargs):
+@require_GET
+def partial_get_sow_status(request: HttpRequest, pk: int, sow_pk: int):
"""
Partial to get the SOW status for SOW detail view
Args:
request: request used to retrieve partial
+ pk: PK of the project the SOW belongs to
sow_pk: ID of the SOW to retrieve the status of
Returns:
HttpResponse containing activity
"""
- return get_object_activity(request, ProjectSOW, sow_pk)
+ project = get_accessible_project(request, pk)
+ sow = get_object_or_404(ProjectSOW, pk=sow_pk, project=project)
+ return get_object_activity(request, sow)
@login_required
-def partial_get_pf_status(request: HttpRequest, pfp_pk: int, *args, **kwargs):
+@require_GET
+def partial_get_pf_status(request: HttpRequest, pk: int, pfp_pk: int):
"""
Partial to get the project form status for approval detail view
Args:
request: request used to retrieve partial
+ pk: PK of the project the project form belongs to
pfp_pk: ID of the ProjectFormPointer to retrieve status of the project form for
Returns:
- HttpResponse containing the activity of requested invoice
+ HttpResponse containing the activity of the requested project form
"""
- return get_object_activity(request, ProjectFormPointer, pfp_pk)
+ project = get_accessible_project(request, pk)
+ pfp = get_object_or_404(ProjectFormPointer, pk=pfp_pk, project=project)
+ return get_object_activity(request, pfp)
@login_required
+@require_GET
def partial_get_invoice_detail_actions(request: HttpRequest, pk: int, invoice_pk: int):
"""
Partial to get the actions for the invoice detail view
@@ -229,7 +274,7 @@ def partial_get_invoice_detail_actions(request: HttpRequest, pk: int, invoice_pk
Returns:
HttpResponse containing the status line of requested invoice
"""
- invoice = get_object_or_404(Invoice, pk=invoice_pk)
+ invoice = get_accessible_invoice(request, pk, invoice_pk)
user = request.user
return render(
@@ -240,8 +285,9 @@ def partial_get_invoice_detail_actions(request: HttpRequest, pk: int, invoice_pk
@login_required
+@require_GET
def partial_get_invoice_tags(request: HttpRequest, pk: int, invoice_pk: int):
- invoice = get_object_or_404(Invoice, pk=invoice_pk)
+ invoice = get_accessible_invoice(request, pk, invoice_pk)
return render(
request,
"application_projects/partials/invoice_tags.html",