Skip to content

Commit 8825097

Browse files
Merge pull request #5666 from raft-tech/5621-add-regional-user-support-for-stt-feedback-reports-page
implement regional feedback reports support
2 parents 1d29acc + 60d6de1 commit 8825097

17 files changed

+675
-175
lines changed

tdrs-backend/tdpservice/reports/test/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,27 @@ def bad_report_source_data(bad_report_source_zip_file, fake_file_name):
174174
"file": bad_report_source_zip_file,
175175
"original_filename": fake_file_name,
176176
}
177+
178+
179+
@pytest.fixture
180+
def regional_report_file_instance(regional_user, stt):
181+
"""Return a persisted ReportFile tied to an STT in the regional user's region."""
182+
return ReportFileFactory.create(
183+
stt=stt,
184+
user=regional_user,
185+
)
186+
187+
188+
@pytest.fixture
189+
def other_region_report_file_instance(regional_user):
190+
"""Return a persisted ReportFile tied to an STT outside the regional user's region."""
191+
from tdpservice.stts.models import STT, Region
192+
193+
other_region, _ = Region.objects.get_or_create(id=99)
194+
other_stt, _ = STT.objects.get_or_create(
195+
name="Other Region STT", region=other_region, stt_code="99"
196+
)
197+
return ReportFileFactory.create(
198+
stt=other_stt,
199+
user=regional_user,
200+
)

tdrs-backend/tdpservice/reports/test/test_views.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,81 @@ def test_latest_param_returns_empty_when_no_reports(
347347

348348
assert resp.status_code == status.HTTP_200_OK
349349
assert len(resp.data["results"]) == 0
350+
351+
352+
@pytest.mark.django_db
353+
class TestReportFileViewAsRegionalStaff:
354+
"""Tests for an OFA Regional Staff user interacting with /v1/reports/ endpoints."""
355+
356+
root_url = "/v1/reports/"
357+
358+
@pytest.fixture
359+
def api_client_logged_in(self, api_client, regional_user):
360+
"""Return an API client authenticated as a regional staff user."""
361+
api_client.login(username=regional_user.username, password="test_password")
362+
return api_client
363+
364+
def test_can_list_reports(
365+
self, api_client_logged_in, regional_report_file_instance
366+
):
367+
"""Regional staff can list report files (200 OK)."""
368+
resp = api_client_logged_in.get(self.root_url)
369+
assert resp.status_code == status.HTTP_200_OK
370+
371+
def test_only_sees_reports_in_own_region(
372+
self,
373+
api_client_logged_in,
374+
regional_user,
375+
regional_report_file_instance,
376+
other_region_report_file_instance,
377+
):
378+
"""Regional staff only sees reports for STTs in their region."""
379+
resp = api_client_logged_in.get(self.root_url)
380+
381+
assert resp.status_code == status.HTTP_200_OK
382+
383+
returned_ids = [row["id"] for row in resp.data["results"]]
384+
assert regional_report_file_instance.id in returned_ids
385+
assert other_region_report_file_instance.id not in returned_ids
386+
387+
def test_cannot_create_report_files(
388+
self, api_client_logged_in, report_file_data
389+
):
390+
"""Regional staff cannot create report files (403)."""
391+
resp = api_client_logged_in.post(
392+
self.root_url, report_file_data, format="multipart"
393+
)
394+
assert resp.status_code == status.HTTP_403_FORBIDDEN
395+
396+
def test_can_download_report_in_own_region(
397+
self, api_client_logged_in, regional_report_file_instance
398+
):
399+
"""Regional staff can download report files for STTs in their region."""
400+
resp = api_client_logged_in.get(
401+
f"{self.root_url}{regional_report_file_instance.id}/download/"
402+
)
403+
assert resp.status_code == status.HTTP_200_OK
404+
405+
def test_cannot_download_report_outside_region(
406+
self, api_client_logged_in, other_region_report_file_instance
407+
):
408+
"""Regional staff cannot download report files outside their region."""
409+
resp = api_client_logged_in.get(
410+
f"{self.root_url}{other_region_report_file_instance.id}/download/"
411+
)
412+
# Returns 404 because get_queryset filters out reports outside the region
413+
assert resp.status_code == status.HTTP_404_NOT_FOUND
414+
415+
def test_stt_query_param_filters_results(
416+
self,
417+
api_client_logged_in,
418+
regional_user,
419+
regional_report_file_instance,
420+
stt,
421+
):
422+
"""Regional staff can filter reports by STT query param."""
423+
resp = api_client_logged_in.get(f"{self.root_url}?stt={stt.id}")
424+
425+
assert resp.status_code == status.HTTP_200_OK
426+
returned_ids = [row["id"] for row in resp.data["results"]]
427+
assert regional_report_file_instance.id in returned_ids

tdrs-backend/tdpservice/reports/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,18 @@ def get_queryset(self):
2727
if self.request.user.is_data_analyst and hasattr(self.request.user, 'stt'):
2828
queryset = queryset.filter(stt=self.request.user.stt)
2929

30+
# Regional Staff should only see reports for STTs in their region
31+
if self.request.user.is_regional_staff and hasattr(self.request.user, 'regions'):
32+
user_regions = self.request.user.regions.all()
33+
queryset = queryset.filter(stt__region__in=user_regions)
34+
3035
# Query params for adding additional filters to queryset
3136
year = self.request.query_params.get('year')
3237
latest = self.request.query_params.get('latest')
38+
stt = self.request.query_params.get('stt')
3339

40+
if stt:
41+
queryset = queryset.filter(stt_id=stt)
3442
if year:
3543
queryset = queryset.filter(year=year)
3644
if latest and latest.lower() == 'true':
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from django.db import migrations
2+
3+
from tdpservice.users.permissions import (
4+
create_perms,
5+
get_permission_ids_for_model,
6+
view_permissions_q,
7+
)
8+
9+
10+
def set_regional_staff_permissions(apps, schema_editor):
11+
"""Add view_reportfile permission to OFA Regional Staff group."""
12+
regional_staff = apps.get_model("auth", "Group").objects.get(
13+
name="OFA Regional Staff"
14+
)
15+
16+
report_file_permissions = get_permission_ids_for_model(
17+
"reports", "reportfile", filters=[view_permissions_q]
18+
)
19+
20+
regional_staff.permissions.add(*report_file_permissions)
21+
22+
23+
def unset_regional_staff_permissions(apps, schema_editor):
24+
"""Remove view_reportfile permission from OFA Regional Staff group."""
25+
regional_staff = apps.get_model("auth", "Group").objects.get(
26+
name="OFA Regional Staff"
27+
)
28+
29+
report_file_permissions = get_permission_ids_for_model(
30+
"reports", "reportfile", filters=[view_permissions_q]
31+
)
32+
33+
regional_staff.permissions.remove(*report_file_permissions)
34+
35+
36+
class Migration(migrations.Migration):
37+
38+
dependencies = [
39+
("auth", "__latest__"),
40+
("users", "0055_add_ofa_sys_admin_report_permissions"),
41+
]
42+
43+
operations = [
44+
migrations.RunPython(
45+
create_perms,
46+
reverse_code=migrations.RunPython.noop,
47+
),
48+
migrations.RunPython(
49+
set_regional_staff_permissions,
50+
reverse_code=unset_regional_staff_permissions,
51+
),
52+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.2.10 on 2026-03-18 16:13
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('users', '0056_add_regional_staff_report_permissions'),
10+
('users', '0056_historicaluser_historicalregionmeta_and_more'),
11+
]
12+
13+
operations = [
14+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.2.10 on 2026-03-18 21:05
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('users', '0057_alter_userchangerequest_status'),
10+
('users', '0057_merge_20260318_1613'),
11+
]
12+
13+
operations = [
14+
]

tdrs-backend/tdpservice/users/permissions.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,20 +180,31 @@ def has_permission(self, request, view):
180180
return False
181181

182182
# Data Analysts are limited to only report files for their designated STT
183-
# NOTE: Will Regional Staff use report files?
184183
if has_permission and request.user.is_data_analyst:
185184
return is_own_stt(request.user, get_requested_stt(request, view))
186185

186+
# Regional Staff are not allowed to create report files
187+
# but can list and download reports for STTs in their region
188+
if has_permission and request.user.is_regional_staff:
189+
if hasattr(view, "action") and view.action in ["create"]:
190+
return False
191+
return is_own_region(request.user, get_requested_stt(request, view))
192+
187193
return has_permission
188194

189195
def has_object_permission(self, request, view, obj):
190-
"""Check if a user cn interact with a specific report file, based on STT."""
196+
"""Check if a user can interact with a specific report file, based on STT."""
191197
# Data Analysts can only see report files uploaded for their designated STT
192198
if request.user.is_data_analyst:
193199
user_stt = request.user.stt.id if hasattr(request.user, "stt") else None
194200
return user_stt == obj.stt_id
195201

196-
# NOTE: Will Regional Staff user report files?
202+
# Regional Staff can only see report files for STTs in their region
203+
if request.user.is_regional_staff:
204+
user_regions = (
205+
request.user.regions.all() if hasattr(request.user, "regions") else None
206+
)
207+
return obj.stt.region in user_regions
197208

198209
return super().has_object_permission(request, view, obj)
199210

tdrs-frontend/cypress/e2e/feedback-reports/admin-feedback-reports.feature

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,3 @@ Feature: Admin Feedback Reports
5959
And 'Admin Alex' selects fiscal year '2025'
6060
Then 'Admin Alex' sees the upload form for fiscal year '2025'
6161

62-
# Permission tests - users who should NOT have access
63-
64-
Scenario: OFA Regional Staff cannot see Feedback Reports nav item
65-
Given 'Regional Staff Cypress' logs in
66-
Then 'Regional Staff Cypress' does not see Feedback Reports in the navigation

tdrs-frontend/cypress/e2e/feedback-reports/admin-feedback-reports.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,3 @@ Then('{string} sees the error about missing date', () => {
133133
cy.contains(fr.ERROR_MESSAGES.NO_DATE).should('exist')
134134
})
135135

136-
// ──────────────────────────────────────────────────────────
137-
// Permission Tests - No Access
138-
// ──────────────────────────────────────────────────────────
139-
140-
Then('{string} does not see Feedback Reports in the navigation', () => {
141-
cy.visit('/')
142-
cy.contains('Welcome to TDP', { timeout: 30000 }).should('exist')
143-
cy.get('.usa-nav__primary').should('exist')
144-
cy.get('.usa-nav__primary').contains('Feedback Reports').should('not.exist')
145-
})

tdrs-frontend/src/components/FeedbackReports/FeedbackReportAlert.jsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const saveDismissedState = (year, reportCreatedAt) => {
2727
* Fetches the latest report internally using the `latest=true` query param.
2828
* Can be dismissed by the user, with state persisted in localStorage per fiscal year.
2929
*/
30-
const FeedbackReportAlert = () => {
30+
const FeedbackReportAlert = ({ stt = null }) => {
3131
const { yearInputValue, quarterInputValue } = useReportsContext()
3232
const [latestReportDate, setLatestReportDate] = useState(null)
3333
const [isDismissed, setIsDismissed] = useState(false)
@@ -40,15 +40,18 @@ const FeedbackReportAlert = () => {
4040
return
4141
}
4242

43+
const params = {
44+
year: yearInputValue,
45+
quarter: quarterInputValue,
46+
latest: 'true',
47+
}
48+
if (stt) {
49+
params.stt = stt.id
50+
}
51+
4352
const { data, ok, error } = await get(
4453
`${process.env.REACT_APP_BACKEND_URL}/reports/`,
45-
{
46-
params: {
47-
year: yearInputValue,
48-
quarter: quarterInputValue,
49-
latest: 'true',
50-
},
51-
}
54+
{ params }
5255
)
5356

5457
if (ok && data?.results?.length > 0) {
@@ -74,7 +77,7 @@ const FeedbackReportAlert = () => {
7477
}
7578

7679
fetchLatestFeedbackReport()
77-
}, [yearInputValue, quarterInputValue])
80+
}, [yearInputValue, quarterInputValue, stt])
7881

7982
const handleDismiss = useCallback(() => {
8083
if (yearInputValue && latestReportDate) {
@@ -93,7 +96,12 @@ const FeedbackReportAlert = () => {
9396
})
9497

9598
return (
96-
<div className="usa-alert usa-alert--info margin-top-4 margin-bottom-4">
99+
<div
100+
className="usa-alert usa-alert--info margin-top-4 margin-bottom-4"
101+
role="region"
102+
aria-labelledby="feedback-alert-text"
103+
tabIndex={-1}
104+
>
97105
<div
98106
className="usa-alert__body"
99107
style={{
@@ -102,9 +110,13 @@ const FeedbackReportAlert = () => {
102110
alignItems: 'flex-start',
103111
}}
104112
>
105-
<p className="usa-alert__text">
113+
<p className="usa-alert__text" id="feedback-alert-text">
106114
Feedback Reports Available as of {formattedDate}. Please{' '}
107-
<a href={`/feedback-reports?year=${yearInputValue}`}>
115+
<a
116+
href={`/feedback-reports?year=${yearInputValue}${stt ? `&stt=${stt.name}` : ''}`}
117+
target="_blank"
118+
rel="noopener noreferrer"
119+
>
108120
review the feedback
109121
</a>{' '}
110122
and if needed, resubmit complete and accurate data.

0 commit comments

Comments
 (0)