Skip to content
47 changes: 32 additions & 15 deletions backend/ops_api/ops/validation/rules/procurement_tracker_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,27 @@ def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: Va

class NoUpdatingCompletedProcurementStepRule(ValidationRule):
"""
Validates that completed procurement tracker steps cannot be updated.
Validates that completed procurement tracker steps cannot be updated,
except when the only field being updated is the notes.
"""

# Fields that are still editable after a step has been completed.
EDITABLE_AFTER_COMPLETION = frozenset({"notes"})

@property
def name(self) -> str:
return "No Updating Completed Procurement Step"

def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: ValidationContext) -> None:
if procurement_tracker_step.status == ProcurementTrackerStepStatus.COMPLETED:
raise ValidationError({"status": "Cannot update a procurement tracker step that is already completed."})
if procurement_tracker_step.status != ProcurementTrackerStepStatus.COMPLETED:
return

# Allow notes-only edits on a completed step; block any other field change.
updated_field_names = set(context.updated_fields.keys())
if updated_field_names.issubset(self.EDITABLE_AFTER_COMPLETION):
return

raise ValidationError({"status": "Cannot update a procurement tracker step that is already completed."})


class AcquisitionPlanningRequiredFieldsRule(ValidationRule):
Expand All @@ -140,20 +151,26 @@ def name(self) -> str:
return "Required Fields Check"

def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: ValidationContext) -> None:
if not is_procurement_tracker_step_updated_to_complete(context):
return

updated_fields = context.updated_fields
acquisition_planning = ["notes", "task_completed_by", "date_completed"]
required_acquisition_planning_fields = ["task_completed_by", "date_completed"]
acquisition_planning_field_found = [field for field in acquisition_planning if field in updated_fields]
if acquisition_planning_field_found:
missing_fields = [field for field in required_acquisition_planning_fields if field not in updated_fields]
if missing_fields:
raise ValidationError(
{
field: f"{field} is required when updating procurement tracker step with acquisition planning package provided."
for field in missing_fields
}
)
mapping = {
"task_completed_by": "acquisition_planning_task_completed_by",
"date_completed": "acquisition_planning_date_completed",
}
required_fields = ["task_completed_by", "date_completed"]
missing_fields = [field for field in required_fields if field not in updated_fields]
final_missing_fields = [
field for field in missing_fields if getattr(procurement_tracker_step, mapping[field], None) is None
]
if final_missing_fields:
raise ValidationError(
{
field: f"{field} is required when completing acquisition planning step."
for field in final_missing_fields
}
)


class NoFutureCompletionDateUpdateValidationRule(ValidationRule):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def test_validate_updating_procurement_tracker_step_with_valid_status(): ...

@scenario(
"validate_procurement_tracker_steps.feature",
"When no presolicitation package is sent to proc shop, the request is valid with unfilled request",
Comment thread
rajohnson90 marked this conversation as resolved.
"Cannot complete acquisition planning step without required fields",
)
def test_validate_updating_procurement_tracker_step_without_presolicitation_package(): ...

Expand All @@ -113,6 +113,13 @@ def test_validate_updating_procurement_tracker_step_without_presolicitation_pack
def test_cannot_update_completed_procurement_tracker_step(): ...


@scenario(
"validate_procurement_tracker_steps.feature",
"Can update only the notes on a completed procurement tracker step",
)
def test_can_update_notes_only_on_completed_procurement_tracker_step(): ...


@scenario(
"validate_procurement_tracker_steps.feature",
"Validate Procurement Tracker Step exists",
Expand Down Expand Up @@ -590,6 +597,11 @@ def have_valid_completed_procurement_step(context):
context["request_body"] = data


@when("I have a notes-only update for the completed step")
def have_notes_only_update_for_completed_step(context):
context["request_body"] = {"notes": "Adding a note after completion."}


@when("I have a procurement step with a non-existent user in the task_completed_by step")
def have_procurement_step_with_nonexistent_user(context):
data = {
Expand Down Expand Up @@ -936,6 +948,17 @@ def check_invalid_status_error_message(context, setup_and_teardown):
assert response.status_code == 400


@then("I should get a message that the notes were updated successfully")
def check_notes_only_update_success(context, loaded_db, setup_and_teardown):
response = context["response_patch"]
assert response.status_code == 200, response.get_json()

json_data = response.get_json()
assert json_data["notes"] == "Adding a note after completion."
# The step should remain completed after a notes-only update.
assert json_data["status"] == ProcurementTrackerStepStatus.COMPLETED.name


@then("I should get a resource not found error")
def check_resource_not_found_error_message(context, setup_and_teardown):
response = context["response_patch"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Feature: Validate Procurement Tracker Steps

Then I should get a validation error

Scenario: When no presolicitation package is sent to proc shop, the request is valid with unfilled request
Scenario: Cannot complete acquisition planning step without required fields
Comment thread
rajohnson90 marked this conversation as resolved.
Given I am logged in as an OPS user
And I have a Contract Agreement with OPS user as a team member
And I have a procurement tracker
Expand All @@ -77,7 +77,7 @@ Scenario: When no presolicitation package is sent to proc shop, the request is v
When I have a procurement step with no presolicitation package sent to procurement shop
And I submit a procurement step update

Then I should get a message that it was successful and my procurement tracker has moved onto the next step
Then I should get a validation error

Scenario: Cannot update completed procurement tracker step
Given I am logged in as an OPS user
Expand All @@ -90,6 +90,17 @@ Scenario: Cannot update completed procurement tracker step

Then I should get a validation error

Scenario: Can update only the notes on a completed procurement tracker step
Given I am logged in as an OPS user
And I have a Contract Agreement with OPS user as a team member
And I have a procurement tracker with a completed step 1
And I am working with acquisition planning procurement tracker step

When I have a notes-only update for the completed step
And I submit a procurement step update

Then I should get a message that the notes were updated successfully

Scenario: Validate Procurement Tracker Step exists
Given I am logged in as an OPS user
And I have a Contract Agreement with OPS user as a team member
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,8 @@ def test_update_procurement_tracker_step_creates_update_tracker_event(auth_clien
select(func.count()).select_from(OpsEvent).where(OpsEvent.event_type == OpsEventType.UPDATE_PROCUREMENT_TRACKER)
)

# Complete the first step
update_data = {"status": "COMPLETED"}
# Complete the first step (acquisition planning step requires date_completed and task_completed_by)
update_data = {"status": "COMPLETED", "date_completed": date.today().isoformat(), "task_completed_by": 503}
response = auth_client.patch(f"/api/v1/procurement-tracker-steps/{first_step.id}", json=update_data)
assert response.status_code == 200

Expand Down
6 changes: 3 additions & 3 deletions frontend/cypress/e2e/procurementTracker.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ describe("Procurement Tracker Step 2", () => {
if (isPending) {
cy.get("#users-combobox-input").should("be.disabled");
cy.get("#step-2-date-completed").should("be.disabled");
cy.get("#notes").should("be.disabled");
cy.get("#notes").should("not.be.disabled");
cy.get("#step-2-draft-solicitation-date").should("be.disabled");
cy.get('[data-cy="cancel-button"]').should("be.disabled");
cy.get('[data-cy="continue-btn"]').should("be.disabled");
Expand Down Expand Up @@ -635,7 +635,7 @@ describe("Procurement Tracker Step 3: Solicitation", () => {
if (isPending) {
cy.get("#users-combobox-input").should("be.disabled");
cy.get("#step-3-date-completed").should("be.disabled");
cy.get("#notes").should("be.disabled");
cy.get("#notes").should("not.be.disabled");
cy.get('[data-cy="cancel-button"]').should("be.disabled");
cy.get('[data-cy="continue-btn"]').should("be.disabled");
}
Expand Down Expand Up @@ -932,7 +932,7 @@ describe("Procurement Tracker Step 5: Pre-Award", () => {
if (isPending) {
cy.get("#users-combobox-input").should("be.disabled");
cy.get("#step-5-date-completed").should("be.disabled");
cy.get("#notes").should("be.disabled");
cy.get("#notes").should("not.be.disabled");
cy.get('[data-cy="cancel-button"]').should("be.disabled");
cy.get('[data-cy="continue-btn"]').should("be.disabled");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,15 @@ export const ProcurementTrackerPreAwardApprovalStatus = {
DECLINED: "DECLINED",
CANCELLED: "CANCELLED"
};

/**
* Maximum number of characters allowed in a procurement tracker step's notes field.
* @type {number}
*/
export const STEP_NOTES_MAX_LENGTH = 750;

/**
* Shared inline style for the notes TextArea rendered in each procurement tracker step.
* @type {{ height: string, minWidth: string }}
*/
export const STEP_NOTES_TEXTAREA_STYLE = { height: "8.5rem", minWidth: "30rem" };
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import DatePicker from "../../../UI/USWDS/DatePicker";
import suite from "./suite";
import { useUpdateProcurementTrackerStepMutation } from "../../../../api/opsAPI";
import useAlert from "../../../../hooks/use-alert.hooks";
import useSaveNotes from "../useSaveNotes";

/**
* @typedef {import("../../../../types/ProcurementTrackerTypes").ProcurementTrackerPreAwardStep} ProcurementTrackerPreAwardStep
Expand All @@ -21,7 +22,6 @@ export default function useProcurementTrackerStepFive(stepFiveData, handleSetCom
const [selectedUser, setSelectedUser] = React.useState(/** @type {SafeUser | undefined} */ (undefined));
const [targetCompletionDate, setTargetCompletionDate] = React.useState("");
const [step5DateCompleted, setStep5DateCompleted] = React.useState("");
const [step5Notes, setStep5Notes] = React.useState("");
const [showModal, setShowModal] = React.useState(false);
const [modalProps, setModalProps] = React.useState({
heading: "",
Expand Down Expand Up @@ -49,6 +49,13 @@ export default function useProcurementTrackerStepFive(stepFiveData, handleSetCom

let validatorRes = suite.get();

const {
notes: step5Notes,
setNotes: setStep5Notes,
resetNotes: resetStep5Notes,
handleSaveNotes
} = useSaveNotes(patchStepFive, stepFiveData?.notes, setAlert);

/**
* Handles the submission of the target completion date for step five, updating the procurement tracker step with the new date.
* @param {number} stepId - The ID of the procurement tracker step being updated.
Expand Down Expand Up @@ -119,7 +126,7 @@ export default function useProcurementTrackerStepFive(stepFiveData, handleSetCom
setSelectedUser(undefined);
setTargetCompletionDate("");
setStep5DateCompleted("");
setStep5Notes("");
resetStep5Notes(stepFiveData?.notes ?? "");
};

const cancelModalStep5 = () => {
Expand All @@ -136,6 +143,7 @@ export default function useProcurementTrackerStepFive(stepFiveData, handleSetCom

return {
cancelStepFive,
handleSaveNotes,
isPreAwardComplete,
setIsPreAwardComplete,
selectedUser,
Expand All @@ -149,6 +157,7 @@ export default function useProcurementTrackerStepFive(stepFiveData, handleSetCom
step5TargetCompletionDateLabel,
step5Notes,
setStep5Notes,
resetStep5Notes,
step5NotesLabel,
runValidate,
validatorRes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("useProcurementTrackerStepFive", () => {
expect(result.current.selectedUser).toBeUndefined();
expect(result.current.targetCompletionDate).toBe("");
expect(result.current.step5DateCompleted).toBe("");
expect(result.current.step5Notes).toBe("");
expect(result.current.step5Notes).toBe("Pre-award approval received"); // Notes initialize from existing stepData.notes
});

it("returns all setter functions", () => {
Expand Down Expand Up @@ -554,7 +554,7 @@ describe("useProcurementTrackerStepFive", () => {
expect(result.current.selectedUser).toBeUndefined();
expect(result.current.targetCompletionDate).toBe("");
expect(result.current.step5DateCompleted).toBe("");
expect(result.current.step5Notes).toBe("");
expect(result.current.step5Notes).toBe(mockStepFiveData.notes);
});
});

Expand Down Expand Up @@ -606,7 +606,7 @@ describe("useProcurementTrackerStepFive", () => {
});

expect(result.current.isPreAwardComplete).toBe(false);
expect(result.current.step5Notes).toBe("");
expect(result.current.step5Notes).toBe(mockStepFiveData.notes);
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useNavigate } from "react-router-dom";
import { getLocalISODate } from "../../../../helpers/utils";
import TextArea from "../../../UI/Form/TextArea";
import ConfirmationModal from "../../../UI/Modals/ConfirmationModal";
import TermTag from "../../../UI/Term/TermTag";
import Tooltip from "../../../UI/USWDS/Tooltip/Tooltip";
import UsersComboBox from "../../UsersComboBox";
import useProcurementTrackerStepFive from "./ProcurementTrackerStepFive.hooks";
import StepNotesEditor from "../StepNotesEditor/StepNotesEditor";
import StepNotesForm from "../StepNotesForm/StepNotesForm";
import { faCircleCheck } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { PROCUREMENT_STEP_STATUS, ProcurementTrackerPreAwardApprovalStatus } from "../ProcurementTracker.constants";
Expand Down Expand Up @@ -57,6 +58,7 @@ const ProcurementTrackerStepFive = ({
setStep5DateCompleted,
step5Notes,
setStep5Notes,
resetStep5Notes,
step5NotesLabel,
runValidate,
validatorRes,
Expand All @@ -68,6 +70,7 @@ const ProcurementTrackerStepFive = ({
setShowModal,
modalProps,
cancelModalStep5,
handleSaveNotes,
handleStepFiveComplete
} = useProcurementTrackerStepFive(stepFiveData, handleSetCompletedStepNumber);

Expand Down Expand Up @@ -352,14 +355,11 @@ const ProcurementTrackerStepFive = ({
isDisabled={isPreAwardFieldsDisabled}
/>
</div>
<TextArea
name="notes"
label="Notes (optional)"
className="margin-top-2"
maxLength={750}
value={step5Notes}
onChange={/** @param {any} _ @param {any} value */ (_, value) => setStep5Notes(value)}
isDisabled={isPreAwardFieldsDisabled}
<StepNotesForm
notes={step5Notes}
setNotes={setStep5Notes}
onSave={() => handleSaveNotes(stepFiveData?.id)}
isDisabled={isDisabled}
/>

<div className="margin-top-2 display-flex flex-justify-end">
Expand Down Expand Up @@ -433,7 +433,16 @@ const ProcurementTrackerStepFive = ({
/>
<div className="width-full">
<dt className="margin-0 text-base-dark margin-top-3 font-12px">Notes</dt>
<dd className="margin-0 margin-top-1">{step5NotesLabel || "None"}</dd>
<StepNotesEditor
notes={step5Notes}
setNotes={setStep5Notes}
resetNotes={resetStep5Notes}
notesLabel={step5NotesLabel}
savedNotes={stepFiveData?.notes}
stepId={stepFiveData?.id}
onSave={handleSaveNotes}
isDisabled={isDisabled}
/>
</div>
</dl>
</div>
Expand Down
Loading