Skip to content

Commit f8f5467

Browse files
authored
Merge pull request #5656 from HHS/OPS-4523/service-req-type
feat(agreements): require service_requirement_type for Contract and AA agreements
2 parents 745a555 + 17cc2bd commit f8f5467

15 files changed

Lines changed: 349 additions & 11 deletions

backend/ops_api/ops/resources/agreements.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ def _update(id: int, message_prefix: str, meta: OpsEventHandler, partial: bool =
407407

408408
data["agreement_cls"] = AGREEMENT_TYPE_TO_CLASS_MAPPING.get(old_agreement.agreement_type)
409409

410-
agreement, status_code = service.update(old_agreement.id, data)
410+
agreement, status_code = service.update(old_agreement.id, data, partial=partial)
411411

412412
response_schema = AGREEMENT_ITEM_TYPE_TO_RESPONSE_MAPPING.get(agreement.agreement_type)()
413413
agreement_dict = response_schema.dump(agreement)

backend/ops_api/ops/services/agreements.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
from ops_api.ops.utils.events import OpsEventHandler
5050
from ops_api.ops.validation.agreement_validator import AgreementValidator
5151
from ops_api.ops.validation.awarded_agreement_validator import AwardedAgreementValidator
52+
from ops_api.ops.validation.context import ValidationContext
53+
from ops_api.ops.validation.rules.agreement import ServiceRequirementTypeRule
5254

5355

5456
@dataclass
@@ -145,6 +147,16 @@ def create(self, create_request: dict[str, Any]) -> tuple[Agreement, dict[str, A
145147
ValidationError: If validation fails (e.g., invalid services_component_ref)
146148
ResourceNotFoundError: If referenced entities don't exist (e.g., invalid can_id)
147149
"""
150+
# Validate service_requirement_type for Contract and AA agreements
151+
ServiceRequirementTypeRule().validate(
152+
None,
153+
ValidationContext(
154+
user=get_current_user(),
155+
updated_fields=create_request,
156+
db_session=self.db_session,
157+
),
158+
)
159+
148160
# STEP 0: Extract nested entity data from request
149161
budget_line_items_data = create_request.pop("budget_line_items", [])
150162
services_components_data = create_request.pop("services_components", [])
@@ -316,7 +328,7 @@ def _create_budget_line_items(
316328

317329
return bli_count
318330

319-
def update(self, id: int, updated_fields: dict[str, Any]) -> tuple[Agreement, int]:
331+
def update(self, id: int, updated_fields: dict[str, Any], partial: bool = True) -> tuple[Agreement, int]:
320332
"""
321333
Update an existing agreement
322334
"""
@@ -329,7 +341,7 @@ def update(self, id: int, updated_fields: dict[str, Any]) -> tuple[Agreement, in
329341
else:
330342
validator = AgreementValidator()
331343

332-
validator.validate(agreement, user, updated_fields, self.db_session)
344+
validator.validate(agreement, user, updated_fields, self.db_session, metadata={"full_update": not partial})
333345

334346
agreement_cls = updated_fields.get("agreement_cls")
335347
del updated_fields["agreement_cls"]

backend/ops_api/ops/validation/agreement_validator.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def _get_default_validators(self) -> List[ValidationRule]:
3939
ProcurementShopChangeRule,
4040
ResearchMetadataRule,
4141
ResourceExistsRule,
42+
ServiceRequirementTypeRule,
4243
)
4344

4445
return [
@@ -47,9 +48,17 @@ def _get_default_validators(self) -> List[ValidationRule]:
4748
AgreementTypeImmutableRule(),
4849
ProcurementShopChangeRule(),
4950
ResearchMetadataRule(),
51+
ServiceRequirementTypeRule(),
5052
]
5153

52-
def validate(self, agreement: Agreement, user: User, updated_fields: Dict[str, Any], db_session: Session) -> None:
54+
def validate(
55+
self,
56+
agreement: Agreement,
57+
user: User,
58+
updated_fields: Dict[str, Any],
59+
db_session: Session,
60+
metadata: Dict[str, Any] = None,
61+
) -> None:
5362
"""
5463
Execute all validation rules in sequence.
5564
@@ -58,14 +67,17 @@ def validate(self, agreement: Agreement, user: User, updated_fields: Dict[str, A
5867
user: The user making the request
5968
updated_fields: Dictionary of fields being updated
6069
db_session: Database session for queries
70+
metadata: Optional metadata for validation context
6171
6272
Raises:
6373
ValidationError: If any validation fails
6474
ResourceNotFoundError: If resource not found
6575
AuthorizationError: If authorization fails
6676
"""
6777
# Create validation context
68-
context = ValidationContext(user=user, updated_fields=updated_fields, db_session=db_session)
78+
context = ValidationContext(
79+
user=user, updated_fields=updated_fields, db_session=db_session, metadata=metadata or {}
80+
)
6981

7082
# Execute all validators
7183
for validator in self.validators:

backend/ops_api/ops/validation/rules/agreement.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Concrete validation rules for agreement updates."""
22

3-
from models import Agreement, BudgetLineItemStatus, ResearchMethodology, SpecialTopic
3+
from models import Agreement, AgreementType, BudgetLineItemStatus, ResearchMethodology, SpecialTopic
44
from ops_api.ops.services.ops_service import (
55
AuthorizationError,
66
ResourceNotFoundError,
@@ -125,3 +125,37 @@ def validate(self, agreement: Agreement, context: ValidationContext) -> None:
125125

126126
if invalid_ids:
127127
raise ValidationError({"special_topics": [f"Special Topic IDs do not exist: {invalid_ids}"]})
128+
129+
130+
class ServiceRequirementTypeRule(ValidationRule):
131+
"""Validates that Contract and AA agreements have service_requirement_type set."""
132+
133+
@property
134+
def name(self) -> str:
135+
return "Service Requirement Type Required"
136+
137+
def validate(self, agreement: Agreement, context: ValidationContext) -> None:
138+
updated_fields = context.updated_fields
139+
140+
agreement_type = updated_fields.get("agreement_type") or (agreement.agreement_type if agreement else None)
141+
142+
if agreement_type not in (AgreementType.CONTRACT, AgreementType.AA):
143+
return
144+
145+
if "service_requirement_type" in updated_fields:
146+
if updated_fields["service_requirement_type"] is None:
147+
raise ValidationError(
148+
{"service_requirement_type": "Service Requirement Type is required for Contract and AA agreements."}
149+
)
150+
return
151+
152+
if agreement is None:
153+
raise ValidationError(
154+
{"service_requirement_type": "Service Requirement Type is required for Contract and AA agreements."}
155+
)
156+
157+
if agreement.service_requirement_type is None:
158+
if context.metadata.get("full_update"):
159+
raise ValidationError(
160+
{"service_requirement_type": "Service Requirement Type is required for Contract and AA agreements."}
161+
)

backend/ops_api/tests/ops/agreement/test_agreement.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,12 +1387,25 @@ def test_post_iaa_agreement(auth_client, loaded_db, app_ctx):
13871387
assert response.status_code == 200
13881388

13891389

1390+
def test_agreements_post_contract_without_service_requirement_type_returns_400(auth_client, loaded_db, app_ctx):
1391+
response = auth_client.post(
1392+
url_for("api.agreements-group"),
1393+
json={
1394+
"agreement_type": AgreementType.CONTRACT.name,
1395+
"name": "Test Contract (missing SRT)",
1396+
},
1397+
)
1398+
assert response.status_code == 400
1399+
assert "service_requirement_type" in response.json["errors"]
1400+
1401+
13901402
def test_agreements_post(auth_client, loaded_db, app_ctx):
13911403
response = auth_client.post(
13921404
url_for("api.agreements-group"),
13931405
json={
13941406
"agreement_type": AgreementType.CONTRACT.name,
13951407
"name": "Test Contract (for post)",
1408+
"service_requirement_type": "SEVERABLE",
13961409
},
13971410
)
13981411
assert response.status_code == 201
@@ -1471,6 +1484,7 @@ def test_agreements_post_contract_with_vendor(
14711484
"project_id": test_project.id,
14721485
"awarding_entity_id": 2,
14731486
"contract_type": "FIRM_FIXED_PRICE",
1487+
"service_requirement_type": "SEVERABLE",
14741488
"research_methodologies": [
14751489
{
14761490
"id": 1,

backend/ops_api/tests/ops/budget_line_items/test_budget_line_item.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,7 @@ def test_budget_line_item_validation_create_invalid(auth_client, app, test_can,
815815
"agreement_type": "CONTRACT",
816816
"agreement_reason": "NEW_REQ",
817817
"name": "TEST: Agreement for BLI Validation",
818+
"service_requirement_type": "SEVERABLE",
818819
"team_members": [
819820
{
820821
"id": 520,
@@ -862,6 +863,7 @@ def test_budget_line_item_validation_patch_to_invalid(auth_client, app, test_can
862863
"agreement_type": "CONTRACT",
863864
"agreement_reason": "NEW_REQ",
864865
"name": "TEST: Agreement for BLI Validation",
866+
"service_requirement_type": "SEVERABLE",
865867
"team_members": [
866868
{
867869
"id": 520,
@@ -919,6 +921,7 @@ def test_budget_line_item_validation_patch_to_zero_or_negative_amount(
919921
"agreement_type": "CONTRACT",
920922
"agreement_reason": "NEW_REQ",
921923
"name": "TEST: Agreement for BLI Validation",
924+
"service_requirement_type": "SEVERABLE",
922925
"description": "Description",
923926
"awarding_entity_id": 2,
924927
"product_service_code_id": 1,
@@ -973,6 +976,7 @@ def test_budget_line_item_validation_patch_to_invalid_date(auth_client, app, tes
973976
"agreement_type": "CONTRACT",
974977
"agreement_reason": "NEW_REQ",
975978
"name": "TEST: Agreement for BLI Validation",
979+
"service_requirement_type": "SEVERABLE",
976980
"description": "Description",
977981
"awarding_entity_id": 2,
978982
"product_service_code_id": 1,

backend/ops_api/tests/ops/features/test_edit_agreement_metadata.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def test_contract(loaded_db, test_project, test_admin_user, test_vendor):
4141
name="Feature Test Contract",
4242
contract_number="CT0999",
4343
contract_type=ContractType.FIRM_FIXED_PRICE,
44+
service_requirement_type=ServiceRequirementType.SEVERABLE,
4445
agreement_type=AgreementType.CONTRACT,
4546
project_id=test_project.id,
4647
project_officer_id=test_admin_user.id,
@@ -243,6 +244,7 @@ def test_contract_with_division_director(loaded_db, test_project):
243244
contract_number="CT0111",
244245
contract_type=ContractType.FIRM_FIXED_PRICE,
245246
agreement_type=AgreementType.CONTRACT,
247+
service_requirement_type=ServiceRequirementType.SEVERABLE,
246248
project_id=test_project.id,
247249
)
248250
loaded_db.add(contract_agreement)

backend/ops_api/tests/ops/history/test_history.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def test_history_expanded_with_web_client(auth_client, loaded_db, test_user, tes
135135
"name": "Contract123",
136136
"description": "History Test Description",
137137
"product_service_code_id": 1,
138+
"service_requirement_type": "SEVERABLE",
138139
"project_officer_id": test_user.id,
139140
"team_members": [
140141
{

backend/ops_api/tests/ops/services/test_agreements.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ def test_immediate_change_with_all_draft_and_update_fees(service):
7979
assert result is None
8080

8181

82-
def test_create_with_bad_research_methodologies(service):
82+
@patch("ops_api.ops.services.agreements.get_current_user")
83+
def test_create_with_bad_research_methodologies(mock_get_user, service):
84+
mock_get_user.return_value = MagicMock(id=1)
85+
8386
create_request = {
8487
"agreement_cls": ContractAgreement,
8588
"name": "Test Agreement",
@@ -608,6 +611,10 @@ def test_no_award_type_filter_returns_all(self, loaded_db, app_ctx):
608611
class TestAgreementsAtomicCreation:
609612
"""Test suite for atomic agreement creation with nested entities"""
610613

614+
@pytest.fixture(autouse=True)
615+
def _mock_current_user(self, monkeypatch):
616+
monkeypatch.setattr("ops_api.ops.services.agreements.get_current_user", lambda: MagicMock(id=1))
617+
611618
def test_create_agreement_with_budget_line_items_without_services_component_ref(self, loaded_db, app_ctx):
612619
"""Test that budget line items can be created without services_component_ref (backward compatibility)"""
613620
service = AgreementsService(loaded_db)
@@ -761,6 +768,10 @@ def test_create_agreement_with_default_numeric_sc_refs(self, loaded_db, app_ctx)
761768
class TestAgreementsAtomicCreationRollback:
762769
"""Test suite for transaction rollback behavior in atomic agreement creation"""
763770

771+
@pytest.fixture(autouse=True)
772+
def _mock_current_user(self, monkeypatch):
773+
monkeypatch.setattr("ops_api.ops.services.agreements.get_current_user", lambda: MagicMock(id=1))
774+
764775
def test_invalid_can_id_causes_complete_rollback(self, loaded_db, app_ctx):
765776
"""Test that invalid CAN ID causes complete rollback - no agreement or BLIs created"""
766777
from ops_api.ops.services.ops_service import ResourceNotFoundError
@@ -1097,6 +1108,10 @@ def test_first_bli_failure_prevents_subsequent_bli_creation(self, loaded_db, app
10971108
class TestAgreementsDuplicateNameHandling:
10981109
"""Test suite for duplicate agreement name validation"""
10991110

1111+
@pytest.fixture(autouse=True)
1112+
def _mock_current_user(self, monkeypatch):
1113+
monkeypatch.setattr("ops_api.ops.services.agreements.get_current_user", lambda: MagicMock(id=1))
1114+
11001115
def test_create_agreement_with_duplicate_name_raises_validation_error(self, loaded_db, app_ctx):
11011116
"""Test that creating an agreement with a duplicate name (same type) raises ValidationError"""
11021117
service = AgreementsService(loaded_db)

0 commit comments

Comments
 (0)