Skip to content

Commit ed6e767

Browse files
authored
OPS-5521: edit project form (#5540)
1 parent ad15d5b commit ed6e767

19 files changed

Lines changed: 1156 additions & 196 deletions

File tree

backend/openapi.yml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3566,8 +3566,11 @@ paths:
35663566
operationId: updateProject
35673567
summary: Update an existing Project
35683568
description: |
3569-
Update a Project by ID. The project_type field cannot be changed.
3570-
Only the fields provided in the request body will be updated.
3569+
Update a Project by ID. Only the fields provided in the request body
3570+
will be updated. Authorization is enforced per-project: only users
3571+
associated with the project (creator, team leaders, agreement team
3572+
members, COR/ACOR, division directors, portfolio team leaders) and
3573+
users with the BUDGET_TEAM or SYSTEM_OWNER roles may update a project.
35713574
parameters:
35723575
- $ref: "#/components/parameters/simulatedError"
35733576
- in: path
@@ -3611,6 +3614,8 @@ paths:
36113614
description: Bad Request - Invalid input or validation error
36123615
"401":
36133616
description: Unauthorized
3617+
"403":
3618+
description: Forbidden - User is not authorized to update this project
36143619
"404":
36153620
description: Not Found - Project does not exist
36163621
"500":
@@ -6700,6 +6705,17 @@ components:
67006705
type: string
67016706
enum: [RESEARCH, ADMINISTRATIVE_AND_SUPPORT]
67026707
example: RESEARCH
6708+
_meta:
6709+
type: object
6710+
description: |
6711+
Server-computed metadata for this project, scoped to the current user.
6712+
properties:
6713+
isEditable:
6714+
type: boolean
6715+
description: |
6716+
True when the current user is authorized to update this project
6717+
(see PATCH /projects/{id} for the authorization rules).
6718+
example: true
67036719
required:
67046720
- title
67056721
ResearchProjects:
@@ -8272,6 +8288,17 @@ components:
82728288
type: string
82738289
format: date-time
82748290
example: "2023-04-06T20:33:38.292475Z"
8291+
_meta:
8292+
type: object
8293+
description: |
8294+
Server-computed metadata for this project, scoped to the current user.
8295+
properties:
8296+
isEditable:
8297+
type: boolean
8298+
description: |
8299+
True when the current user is authorized to update this project
8300+
(see PATCH /projects/{id} for the authorization rules).
8301+
example: true
82758302
required:
82768303
- id
82778304
- project_type

backend/ops_api/ops/resources/projects.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from flask import Response, current_app, request
2+
from flask_jwt_extended import get_current_user
23
from typing_extensions import List
34

45
from models import AdministrativeAndSupportProject, Project, ProjectType, ResearchProject
@@ -9,6 +10,7 @@
910
from ops_api.ops.auth.decorators import is_authorized
1011
from ops_api.ops.base_views import BaseItemAPI, BaseListAPI
1112
from ops_api.ops.schemas.projects import (
13+
MetaSchema,
1214
ProjectCreationRequestSchema,
1315
ProjectFundingRequestSchema,
1416
ProjectFundingResponseSchema,
@@ -22,6 +24,7 @@
2224
)
2325
from ops_api.ops.services.projects import ProjectsService
2426
from ops_api.ops.utils.events import OpsEventHandler
27+
from ops_api.ops.utils.projects_helpers import check_project_user_association
2528
from ops_api.ops.utils.response import make_response_with_headers
2629

2730

@@ -37,15 +40,18 @@ def get(self, id: int) -> Response:
3740
project = service.get(id)
3841
match project.project_type:
3942
case ProjectType.RESEARCH:
40-
research_schema = ResearchProjectResponse()
41-
serialized_project = research_schema.dump(project)
43+
schema = ResearchProjectResponse(exclude=["_meta"])
4244
case ProjectType.ADMINISTRATIVE_AND_SUPPORT:
4345
# No separate schema for admin project yet but there will be
44-
admin_schema = ProjectResponse()
45-
serialized_project = admin_schema.dump(project)
46+
schema = ProjectResponse(exclude=["_meta"])
4647
case _:
47-
general_schema = ProjectResponse()
48-
serialized_project = general_schema.dump(project)
48+
schema = ProjectResponse(exclude=["_meta"])
49+
serialized_project = schema.dump(project)
50+
51+
current_user = get_current_user()
52+
serialized_project["_meta"] = MetaSchema().dump(
53+
{"isEditable": check_project_user_association(project, current_user)}
54+
)
4955

5056
return make_response_with_headers(serialized_project)
5157

backend/ops_api/ops/schemas/projects.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from decimal import Decimal
33
from typing import Optional
44

5-
from marshmallow import Schema, fields, pre_dump
5+
from marshmallow import EXCLUDE, Schema, fields, pre_dump
66

77
from models import ProjectSortCondition, ProjectType
88
from ops_api.ops.schemas.pagination import PaginationListSchema
@@ -25,6 +25,13 @@ class ProjectListGetRequestSchema(PaginationListSchema):
2525
sort_fiscal_year = fields.List(fields.Integer(), required=False, load_default=[])
2626

2727

28+
class MetaSchema(Schema):
29+
class Meta:
30+
unknown = EXCLUDE
31+
32+
isEditable = fields.Bool(load_default=False, dump_default=False)
33+
34+
2835
class TeamLeaders(Schema):
2936
id: int = fields.Int(required=True)
3037
full_name: Optional[str] = fields.String()
@@ -112,6 +119,7 @@ class ProjectResponse(Schema):
112119
division_directors: Optional[list[IdNamePair]] = fields.List(fields.Nested(IdNamePair), dump_default=[])
113120
project_officers: Optional[list[IdNamePair]] = fields.List(fields.Nested(IdNamePair), dump_default=[])
114121
alternate_project_officers: Optional[list[IdNamePair]] = fields.List(fields.Nested(IdNamePair), dump_default=[])
122+
_meta = fields.Nested(MetaSchema, required=True)
115123

116124
@pre_dump
117125
def extract_metadata(self, data, **kwargs):

backend/ops_api/ops/services/projects.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -425,7 +425,27 @@ def get(self, id: int) -> Project:
425425
The Project instance
426426
"""
427427

428-
project = self.db_session.get(Project, id)
428+
# Eager-load the relationships traversed by the response serializer
429+
# (project_metadata) and by check_project_user_association, to avoid N+1 queries.
430+
stmt = (
431+
select(Project)
432+
.where(Project.id == id)
433+
.options(
434+
selectinload(Project.team_leaders),
435+
selectinload(Project.agreements).selectinload(Agreement.team_members),
436+
selectinload(Project.agreements)
437+
.selectinload(Agreement.budget_line_items)
438+
.selectinload(BudgetLineItem.can)
439+
.selectinload(CAN.portfolio)
440+
.selectinload(Portfolio.division),
441+
selectinload(Project.agreements)
442+
.selectinload(Agreement.budget_line_items)
443+
.selectinload(BudgetLineItem.can)
444+
.selectinload(CAN.portfolio)
445+
.selectinload(Portfolio.team_leaders),
446+
)
447+
)
448+
project = self.db_session.scalar(stmt)
429449
if not project:
430450
raise ResourceNotFoundError("Project", id)
431451
return project

backend/ops_api/tests/ops/project/test_project_authorization.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,3 +500,31 @@ def test_patch_project_no_perms_returns_403(self, no_perms_auth_client, loaded_d
500500
data = {"title": "Should Not Update"}
501501
response = no_perms_auth_client.patch(url_for("api.projects-item", id=unassociated_project.id), json=data)
502502
assert response.status_code == 403
503+
504+
505+
class TestGetProjectMetaIsEditable:
506+
"""GET /projects/<id> returns _meta.isEditable matching the backend authorization rules."""
507+
508+
def test_get_project_unassociated_user_is_not_editable(
509+
self, basic_user_auth_client, loaded_db, unassociated_project
510+
):
511+
response = basic_user_auth_client.get(url_for("api.projects-item", id=unassociated_project.id))
512+
assert response.status_code == 200
513+
assert response.json["_meta"]["isEditable"] is False
514+
515+
def test_get_project_creator_is_editable(self, basic_user_auth_client, loaded_db, project_created_by_basic_user):
516+
response = basic_user_auth_client.get(url_for("api.projects-item", id=project_created_by_basic_user.id))
517+
assert response.status_code == 200
518+
assert response.json["_meta"]["isEditable"] is True
519+
520+
def test_get_project_team_leader_is_editable(
521+
self, basic_user_auth_client, loaded_db, project_with_basic_user_team_leader
522+
):
523+
response = basic_user_auth_client.get(url_for("api.projects-item", id=project_with_basic_user_team_leader.id))
524+
assert response.status_code == 200
525+
assert response.json["_meta"]["isEditable"] is True
526+
527+
def test_get_project_budget_team_is_editable(self, budget_team_auth_client, loaded_db, unassociated_project):
528+
response = budget_team_auth_client.get(url_for("api.projects-item", id=unassociated_project.id))
529+
assert response.status_code == 200
530+
assert response.json["_meta"]["isEditable"] is True

frontend/cypress/e2e/projectDetails.cy.js

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
/// <reference types="cypress" />
22
import { terminalLog, testLogin } from "./utils";
33

4-
beforeEach(() => {
5-
testLogin("system-owner");
6-
cy.visit("/projects/1000");
7-
cy.get("h1", { timeout: 10000 }).should("be.visible");
8-
});
9-
104
afterEach(() => {
115
cy.injectAxe();
126
cy.checkA11y(null, null, terminalLog);
137
});
148

159
describe("Project Details Page", () => {
10+
beforeEach(() => {
11+
testLogin("system-owner");
12+
cy.visit("/projects/1000");
13+
cy.get("h1", { timeout: 10000 }).should("be.visible");
14+
});
15+
1616
it("loads the seeded project details and disabled tab tooltips", () => {
1717
cy.url().should("include", "/projects/1000");
1818
cy.get("h1").should("contain", "Human Services Interoperability Support");
@@ -41,4 +41,72 @@ describe("Project Details Page", () => {
4141
cy.get("[data-cy='alternate-project-officers-tag']").should("contain", "Dave Director");
4242
cy.get("[data-cy='project-team-members-tag']").should("contain", "Amelia Popham");
4343
});
44+
45+
it("enters edit mode and displays the form", () => {
46+
cy.get("[data-cy='project-details-edit-button']").click();
47+
cy.contains("h2", "Edit Project").should("be.visible");
48+
cy.get("input[name='title']").should("be.visible");
49+
cy.get("input[name='short_title']").should("be.visible");
50+
cy.get("textarea[name='description']").should("be.visible");
51+
cy.get("[data-cy='save-btn']").should("be.visible");
52+
cy.get("[data-cy='cancel-button']").should("be.visible");
53+
});
54+
55+
it("edits project details and saves successfully", () => {
56+
const originalShortTitle = "HSS";
57+
const updatedShortTitle = `HSS-${Date.now()}`;
58+
cy.intercept("PATCH", "**/api/v1/projects/1000").as("patchProject");
59+
60+
cy.get("[data-cy='project-details-edit-button']").click();
61+
cy.get("input[name='short_title']").clear().type(updatedShortTitle);
62+
cy.get("[data-cy='save-btn']").click();
63+
64+
cy.wait("@patchProject").then((interception) => {
65+
expect(interception.response.statusCode).to.equal(200);
66+
expect(interception.response.body.id).to.equal(1000);
67+
});
68+
cy.contains("Project Updated").should("be.visible");
69+
cy.get("[data-cy='project-nickname-tag']").should("contain", updatedShortTitle);
70+
71+
// Restore the seeded short_title so later tests (and the seeded fixture assertions
72+
// above) keep passing when the spec reruns against the same database.
73+
cy.get("[data-cy='project-details-edit-button']").click();
74+
cy.get("input[name='short_title']").clear().type(originalShortTitle);
75+
cy.get("[data-cy='save-btn']").click();
76+
cy.wait("@patchProject").its("response.statusCode").should("equal", 200);
77+
});
78+
79+
it("shows an error alert when the save request fails", () => {
80+
cy.intercept("PATCH", "**/api/v1/projects/1000", { statusCode: 500, body: { error: "boom" } }).as(
81+
"patchProjectError"
82+
);
83+
cy.get("[data-cy='project-details-edit-button']").click();
84+
cy.get("input[name='short_title']").clear().type("Will Not Save");
85+
cy.get("[data-cy='save-btn']").click();
86+
cy.wait("@patchProjectError");
87+
cy.contains("Error Updating Project").should("be.visible");
88+
});
89+
90+
it("shows confirmation modal on cancel during edit", () => {
91+
cy.get("[data-cy='project-details-edit-button']").click();
92+
cy.get("[data-cy='cancel-button']").click();
93+
cy.contains("Are you sure you want to cancel editing").should("be.visible");
94+
});
95+
});
96+
97+
describe("Project Details Page — unauthorized user", () => {
98+
beforeEach(() => {
99+
testLogin("basic");
100+
cy.visit("/projects/1000");
101+
cy.get("h1", { timeout: 10000 }).should("be.visible");
102+
});
103+
104+
it("disables the edit button when the user does not have permission", () => {
105+
cy.get("[data-cy='project-details-edit-button']").should("have.attr", "aria-disabled", "true");
106+
cy.get("[data-cy='project-details-edit-button']").should(
107+
"have.attr",
108+
"aria-label",
109+
"You do not have permission to edit this project"
110+
);
111+
});
44112
});

frontend/src/api/opsAPI.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,15 @@ export const opsApi = createApi({
668668
}),
669669
invalidatesTags: ["ResearchProjects"]
670670
}),
671+
updateProject: builder.mutation({
672+
query: ({ id, data }) => ({
673+
url: `/projects/${id}`,
674+
method: "PATCH",
675+
headers: { "Content-Type": "application/json" },
676+
body: data
677+
}),
678+
invalidatesTags: ["ResearchProjects"]
679+
}),
671680
updateBudgetLineItemStatus: builder.mutation({
672681
query: ({ id, status }) => ({
673682
url: `/budget-line-items/${id}`,
@@ -1248,6 +1257,7 @@ export const {
12481257
useGetResearchProjectsQuery,
12491258
useGetResearchProjectsByPortfolioQuery,
12501259
useAddResearchProjectsMutation,
1260+
useUpdateProjectMutation,
12511261
useUpdateBudgetLineItemStatusMutation,
12521262
useGetAgreementTypesQuery,
12531263
useGetProductServiceCodesQuery,

0 commit comments

Comments
 (0)