Skip to content

Commit a12e36b

Browse files
authored
Merge pull request #727 from PROCOLLAB-github/fix/dev-program-manager-project-detail-access
DEV: fix program manager project detail access
2 parents 4dea5bd + 89769ff commit a12e36b

3 files changed

Lines changed: 264 additions & 36 deletions

File tree

‎projects/permissions.py‎

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,30 @@
1313
from projects.models import Project
1414

1515

16+
def has_project_read_involvement(user, project: Project) -> bool:
17+
"""Shared restricted READ visibility; program/admin roles grant no WRITE rights."""
18+
if not user or not user.is_authenticated:
19+
return False
20+
21+
if user.is_superuser or user.is_staff or project.leader_id == user.id:
22+
return True
23+
24+
if project.collaborator_set.filter(user_id=user.id).exists():
25+
return True
26+
27+
if project.invite_set.filter(user_id=user.id).exists():
28+
return True
29+
30+
return (
31+
PartnerProgramProject.objects.filter(project_id=project.id)
32+
.filter(
33+
Q(partner_program__managers__id=user.id)
34+
| Q(partner_program__experts__user_id=user.id)
35+
)
36+
.exists()
37+
)
38+
39+
1640
class ProjectVisibilityPermission(BasePermission):
1741
"""
1842
Ограничивает доступ к непубличным проектам.
@@ -31,7 +55,10 @@ def has_permission(self, request, view):
3155
if not project_id and view.__module__.startswith("projects."):
3256
project_id = view.kwargs.get("id") or view.kwargs.get("pk")
3357

34-
if not project_id and getattr(getattr(view, "queryset", None), "model", None) is Project:
58+
if (
59+
not project_id
60+
and getattr(getattr(view, "queryset", None), "model", None) is Project
61+
):
3562
project_id = view.kwargs.get("pk")
3663

3764
if not project_id:
@@ -58,29 +85,7 @@ def has_object_permission(self, request, view, obj):
5885
def _can_view_project(self, request, project: Project) -> bool:
5986
if project.is_public:
6087
return True
61-
62-
user = getattr(request, "user", None)
63-
if not user or not user.is_authenticated:
64-
return False
65-
66-
if user.is_superuser or user.is_staff:
67-
return True
68-
69-
if project.leader_id == user.id:
70-
return True
71-
72-
if project.collaborator_set.filter(user_id=user.id).exists():
73-
return True
74-
75-
if project.invite_set.filter(user_id=user.id).exists():
76-
return True
77-
78-
return PartnerProgramProject.objects.filter(
79-
project_id=project.id,
80-
).filter(
81-
Q(partner_program__managers__id=user.id)
82-
| Q(partner_program__experts__user_id=user.id)
83-
).exists()
88+
return has_project_read_involvement(getattr(request, "user", None), project)
8489

8590

8691
class IsProjectLeaderOrReadOnlyForNonDrafts(BasePermission):
@@ -123,7 +128,12 @@ def has_object_permission(self, request, view, obj):
123128

124129
class HasInvolvementInProjectOrReadOnly(BasePermission):
125130
"""
126-
Allows access to read to everyone involved in the project, and to update only to project leader.
131+
READ: published projects pass this gate; drafts use shared restricted visibility.
132+
133+
WRITE: preserve existing involvement rules (published: leader; draft: leader,
134+
collaborator or invite). Program managers/experts and admins are read-only
135+
unless they independently satisfy those existing write rules.
136+
ProjectVisibilityPermission separately guards private projects.
127137
"""
128138

129139
def has_permission(self, request, view) -> bool:
@@ -132,6 +142,9 @@ def has_permission(self, request, view) -> bool:
132142
return False
133143

134144
def has_object_permission(self, request, view, obj):
145+
if request.method in SAFE_METHODS:
146+
return not obj.draft or has_project_read_involvement(request.user, obj)
147+
135148
if not obj.draft:
136149
# not a draft, read-only for everyone, write only for leader
137150
if (request.method in SAFE_METHODS) or (request.user == obj.leader):
@@ -195,9 +208,7 @@ def _prepare_exception_detail(
195208
datetime_finished + timedelta(seconds=self._SECONDS_AFTER_CANT_EDIT)
196209
)
197210
days_until_resolution: int = (
198-
int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24)
199-
- days_from_end_program
200-
- 1
211+
int(self._SECONDS_AFTER_CANT_EDIT / 60 / 60 / 24) - days_from_end_program - 1
201212
)
202213
return {
203214
"program_name": program_profile.partner_program.name,
@@ -222,7 +233,9 @@ def has_permission(self, request, view):
222233
return False
223234

224235
project_pk = view.kwargs.get("project_pk")
225-
project_id = project_pk or view.kwargs.get("project_id") or request.data.get("project")
236+
project_id = (
237+
project_pk or view.kwargs.get("project_id") or request.data.get("project")
238+
)
226239
if not project_id:
227240
return False
228241

@@ -259,10 +272,14 @@ def has_permission(self, request, view):
259272

260273
submission_deadline = program.get_project_submission_deadline()
261274
if submission_deadline and submission_deadline < timezone.now():
262-
raise ValidationError({"partner_program_id": "Срок подачи проектов в программу завершён."})
275+
raise ValidationError(
276+
{"partner_program_id": "Срок подачи проектов в программу завершён."}
277+
)
263278

264279
if program.datetime_finished < timezone.now():
265-
raise ValidationError({"partner_program_id": "Нельзя выбрать завершённую программу."})
280+
raise ValidationError(
281+
{"partner_program_id": "Нельзя выбрать завершённую программу."}
282+
)
266283

267284
if program.is_manager(request.user):
268285
return True

‎projects/serializers.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,11 @@ class ProjectDetailSerializer(serializers.ModelSerializer):
261261
trl = serializers.IntegerField(required=False, allow_null=True)
262262

263263
def get_partner_program(self, project):
264-
try:
265-
link = project.program_links.select_related("partner_program").get()
266-
return PartnerProgramProjectSerializer(link).data
267-
except PartnerProgramProject.DoesNotExist:
268-
return None
264+
"""Keep the legacy singular contract: choose the earliest link by pk."""
265+
link = (
266+
project.program_links.select_related("partner_program").order_by("pk").first()
267+
)
268+
return PartnerProgramProjectSerializer(link).data if link is not None else None
269269

270270
@classmethod
271271
def get_partner_program_tags(cls, project):
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Program analytics links must open project detail without granting write access."""
2+
3+
from django.contrib.contenttypes.models import ContentType
4+
from django.core.cache import cache
5+
from django.db import connection
6+
from django.test import TestCase
7+
from django.test.utils import CaptureQueriesContext
8+
from rest_framework.test import APIClient
9+
10+
from invites.models import Invite
11+
from project_rates.tests.helpers import create_rate_expert
12+
from projects.serializers import ProjectDetailSerializer, PartnerProgramProjectSerializer
13+
14+
from .helpers import (
15+
create_collaborator,
16+
create_partner_program,
17+
create_project,
18+
create_user,
19+
link_project_to_program,
20+
)
21+
22+
23+
class ProgramProjectDetailAccessTests(TestCase):
24+
@classmethod
25+
def setUpTestData(cls):
26+
cls.program = create_partner_program(is_competitive=True)
27+
cls.other_program = create_partner_program(is_competitive=True)
28+
cls.project = create_project(draft=True, is_public=False)
29+
cls.collaborator = create_collaborator(cls.project).user
30+
cls.link = link_project_to_program(cls.project, cls.program)
31+
cls.manager = create_user(prefix="detail-manager")
32+
cls.program.managers.add(cls.manager)
33+
cls.expert = create_rate_expert(program=cls.program)
34+
cls.other_manager = create_user(prefix="other-manager")
35+
cls.other_program.managers.add(cls.other_manager)
36+
cls.other_expert = create_rate_expert(program=cls.other_program)
37+
cls.staff = create_user(prefix="detail-staff")
38+
cls.staff.is_staff = True
39+
cls.staff.save(update_fields=["is_staff"])
40+
cls.superuser = create_user(prefix="detail-superuser")
41+
cls.superuser.is_superuser = True
42+
cls.superuser.save(update_fields=["is_superuser"])
43+
cls.invited = create_user(prefix="detail-invited")
44+
Invite.objects.create(project=cls.project, user=cls.invited)
45+
cls.outsider = create_user(prefix="detail-outsider")
46+
47+
def setUp(self):
48+
self.client = APIClient()
49+
self.url = f"/projects/{self.project.pk}/"
50+
51+
def assert_readable(self, user):
52+
self.client.force_authenticate(user)
53+
response = self.client.get(self.url)
54+
self.assertEqual(response.status_code, 200, response.data)
55+
self.assertEqual(response.data["id"], self.project.pk)
56+
57+
def test_linked_manager_can_read_private_draft(self):
58+
self.assert_readable(self.manager)
59+
60+
def test_linked_expert_can_read_private_draft(self):
61+
self.assert_readable(self.expert)
62+
63+
def test_linked_manager_safe_methods_remain_read_only(self):
64+
self.client.force_authenticate(self.manager)
65+
for method in ("head", "options"):
66+
with self.subTest(method=method):
67+
self.assertEqual(getattr(self.client, method)(self.url).status_code, 200)
68+
69+
def test_staff_without_superuser_can_read_private_draft(self):
70+
self.assertFalse(self.staff.is_superuser)
71+
self.assert_readable(self.staff)
72+
73+
def test_superuser_without_staff_can_read_private_draft(self):
74+
self.assertFalse(self.superuser.is_staff)
75+
self.assert_readable(self.superuser)
76+
77+
def test_existing_project_roles_can_read_private_draft(self):
78+
for user in (self.project.leader, self.collaborator, self.invited):
79+
with self.subTest(user=user.pk):
80+
self.assert_readable(user)
81+
82+
def test_unrelated_and_other_program_roles_cannot_read_private_draft(self):
83+
for user in (self.outsider, self.other_manager, self.other_expert):
84+
with self.subTest(user=user.pk):
85+
self.client.force_authenticate(user)
86+
self.assertEqual(self.client.get(self.url).status_code, 403)
87+
88+
def test_anonymous_cannot_read_private_draft(self):
89+
self.assertIn(self.client.get(self.url).status_code, (401, 403))
90+
91+
def test_public_flag_does_not_expose_draft_to_outsiders(self):
92+
self.project.is_public = True
93+
self.project.save(update_fields=["is_public"])
94+
for user in (None, self.outsider, self.other_manager, self.other_expert):
95+
with self.subTest(user=user):
96+
self.client.force_authenticate(user)
97+
self.assertIn(self.client.get(self.url).status_code, (401, 403))
98+
self.assert_readable(self.manager)
99+
self.assert_readable(self.expert)
100+
101+
def test_published_private_and_public_visibility_is_preserved(self):
102+
self.project.draft = False
103+
self.project.save(update_fields=["draft"])
104+
self.assert_readable(self.manager)
105+
self.client.force_authenticate(self.outsider)
106+
self.assertEqual(self.client.get(self.url).status_code, 403)
107+
self.project.is_public = True
108+
self.project.save(update_fields=["is_public"])
109+
self.assert_readable(self.outsider)
110+
self.assert_readable(None)
111+
112+
def test_read_roles_do_not_gain_write_access_to_drafts_or_published_projects(self):
113+
original_name = self.project.name
114+
for draft in (True, False):
115+
self.project.draft = draft
116+
self.project.save(update_fields=["draft"])
117+
for user in (self.manager, self.expert, self.staff, self.superuser):
118+
self.client.force_authenticate(user)
119+
for method in ("patch", "put", "delete"):
120+
with self.subTest(draft=draft, user=user.pk, method=method):
121+
response = getattr(self.client, method)(
122+
self.url, {"name": "Unauthorized change"}, format="json"
123+
)
124+
self.assertEqual(response.status_code, 403, response.data)
125+
self.project.refresh_from_db()
126+
self.assertEqual(self.project.name, original_name)
127+
128+
def test_manager_opens_exact_project_from_attention_with_one_or_two_links(self):
129+
self.client.force_authenticate(self.manager)
130+
self.assertNotEqual(self.project.leader_id, self.manager.pk)
131+
self.assertFalse(self.project.collaborator_set.filter(user=self.manager).exists())
132+
self.assertFalse(self.project.invite_set.filter(user=self.manager).exists())
133+
for count in (1, 2):
134+
with self.subTest(program_links=count):
135+
if count == 2:
136+
link_project_to_program(self.project, self.other_program)
137+
attention = self.client.get(
138+
f"/programs/{self.program.pk}/manager-overview/projects-not-submitted/"
139+
)
140+
self.assertEqual(attention.status_code, 200, attention.data)
141+
self.assertEqual(attention.data["count"], 1)
142+
row = attention.data["results"][0]
143+
self.assertEqual(row["program_project_id"], self.link.pk)
144+
detail = self.client.get(f"/projects/{row['project']['id']}/")
145+
self.assertEqual(detail.status_code, 200, detail.data)
146+
self.assertEqual(detail.data["id"], self.project.pk)
147+
self.assertEqual(
148+
detail.data["partner_program"]["program_link_id"], self.link.pk
149+
)
150+
self.assertFalse(self.project.collaborator_set.filter(user=self.manager).exists())
151+
self.assertFalse(self.project.invite_set.filter(user=self.manager).exists())
152+
153+
154+
class ProjectDetailProgramLinkTests(TestCase):
155+
@classmethod
156+
def setUpTestData(cls):
157+
cls.project = create_project(draft=False, is_public=True)
158+
# Create programs in the opposite order to links: selection is by link pk.
159+
cls.later_link_program = create_partner_program(name="Created first")
160+
cls.first_link_program = create_partner_program(name="Linked first")
161+
162+
def setUp(self):
163+
self.client = APIClient()
164+
self.client.force_authenticate(self.project.leader)
165+
self.url = f"/projects/{self.project.pk}/"
166+
167+
def detail(self):
168+
response = self.client.get(self.url)
169+
self.assertEqual(response.status_code, 200, response.data)
170+
return response.data
171+
172+
def test_no_program_links_returns_null(self):
173+
self.assertIsNone(self.detail()["partner_program"])
174+
175+
def test_single_program_link_preserves_legacy_contract(self):
176+
link = link_project_to_program(self.project, self.first_link_program)
177+
self.assertEqual(
178+
self.detail()["partner_program"], PartnerProgramProjectSerializer(link).data
179+
)
180+
181+
def test_multiple_links_return_earliest_link_deterministically(self):
182+
first = link_project_to_program(self.project, self.first_link_program)
183+
link_project_to_program(self.project, self.later_link_program, submitted=True)
184+
for _ in range(2):
185+
data = self.detail()["partner_program"]
186+
self.assertEqual(data, PartnerProgramProjectSerializer(first).data)
187+
self.assertEqual(data["program_id"], self.first_link_program.pk)
188+
self.assertFalse(data["is_submitted"])
189+
190+
def test_single_link_serializer_uses_one_join_and_existing_field_queries(self):
191+
link_project_to_program(self.project, self.first_link_program)
192+
# Existing contract: joined link/program, program fields, field values.
193+
with self.assertNumQueries(3):
194+
ProjectDetailSerializer().get_partner_program(self.project)
195+
196+
def test_detail_queries_do_not_grow_with_number_of_program_links(self):
197+
link_project_to_program(self.project, self.first_link_program)
198+
self.detail() # Warm only the existing View record and ContentType cache.
199+
ContentType.objects.get_for_model(self.project)
200+
cache.clear()
201+
with CaptureQueriesContext(connection) as single:
202+
self.detail()
203+
for _ in range(7):
204+
link_project_to_program(self.project, create_partner_program())
205+
cache.clear()
206+
with CaptureQueriesContext(connection) as multiple:
207+
self.detail()
208+
self.assertEqual(len(multiple), len(single))
209+
# Only one selected program is serialized, irrespective of link count.
210+
with self.assertNumQueries(3):
211+
ProjectDetailSerializer().get_partner_program(self.project)

0 commit comments

Comments
 (0)