Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions base_tier_validation/models/tier_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ def _get_tier_validation_model_names(self):
help="Bypassed (auto validated), if previous tier was validated "
"by same reviewer",
)
exclude_requester = fields.Boolean(
string="Four-eyes Principle",
default=False,
help="When set, the user who requested the validation is removed "
"from this tier's reviewer pool. Anyone else in the configured "
"group can validate -- except the requester themselves. Without "
"this flag, a requester who happens to be in the same group as "
"the configured reviewers can auto-validate their own request. "
"Only meaningful for review_type='group'; the form hides this "
"field for the other review types.",
)

@api.onchange("review_type")
def onchange_review_type(self):
Expand Down
24 changes: 18 additions & 6 deletions base_tier_validation/models/tier_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ def _can_review_value(self):

@api.model
def _get_reviewer_fields(self):
return ["reviewer_id", "reviewer_group_id", "reviewer_group_id.user_ids"]
return [
"reviewer_id",
"reviewer_group_id",
"reviewer_group_id.user_ids",
"definition_id.exclude_requester",
"requested_by",
]

@api.depends(lambda self: self._get_reviewer_fields())
def _compute_reviewer_ids(self):
Expand All @@ -154,24 +160,30 @@ def _compute_todo_by(self):
rec.todo_by = todo_by

def _get_reviewers(self):
reviewers = self.env["res.users"]
if self.reviewer_id or self.reviewer_group_id.user_ids:
return self.reviewer_id + self.reviewer_group_id.user_ids
if self.reviewer_field_id:
reviewers = self.reviewer_id + self.reviewer_group_id.user_ids
elif self.reviewer_field_id:
resource = self.env[self.model].browse(self.res_id)
reviewer_field = getattr(resource, self.reviewer_field_id.name, False)
if reviewer_field:
if reviewer_field._name == "res.groups":
return reviewer_field.user_ids
reviewers = reviewer_field.user_ids
elif reviewer_field._name == "res.users":
return reviewer_field
reviewers = reviewer_field
else:
raise ValidationError(
self.env._(
"Validation reviewer field "
"should be of the appropriate type"
)
)
return self.env["res.users"]
# Four-eyes: drop the requester from the reviewer pool if the
# definition opted in. Done as a post-filter so it applies
# uniformly across individual / group / field review types.
if self.definition_id.exclude_requester and self.requested_by:
reviewers -= self.requested_by
return reviewers

def _notify_pending_status(self, review_ids):
"""Method to call and reuse abstract notification method"""
Expand Down
68 changes: 68 additions & 0 deletions base_tier_validation/tests/test_tier_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,74 @@ def test_03_request_validation_approved(self):
record.validate_tier()
self.assertEqual(record.validation_status, "validated")

def test_exclude_requester_drops_requester_from_group(self):
"""With ``exclude_requester=True`` on a group review type, the
requester is removed from the tier's reviewer pool so they
cannot auto-validate their own request -- enforcing a
four-eyes principle without custom Python."""
# Build a group containing both users and a tier definition
# that points at it. test_user_1 will be the requester; the
# group also includes test_user_1, so without the four-eyes
# flag they'd be in their own review's reviewer_ids.
group = self.env["res.groups"].create(
{
"name": "Four-eyes Reviewers",
"user_ids": [
Command.link(self.test_user_1.id),
Command.link(self.test_user_2.id),
],
}
)
# Use the existing definition so we don't get extra reviews on
# tier_definition_1's individual reviewer.
self.tier_definition.write(
{
"review_type": "group",
"reviewer_id": False,
"reviewer_group_id": group.id,
"exclude_requester": True,
}
)
record = self.test_model.create({"test_field": 1.0})
record.with_user(self.test_user_1).request_validation()
review = record.review_ids.filtered(
lambda r: r.definition_id == self.tier_definition
)
# The requester (test_user_1) is excluded; only test_user_2
# remains as a valid reviewer.
self.assertIn(self.test_user_2, review.reviewer_ids)
self.assertNotIn(self.test_user_1, review.reviewer_ids)
# The requester therefore cannot self-validate.
self.assertFalse(record.with_user(self.test_user_1).can_review)
self.assertTrue(record.with_user(self.test_user_2).can_review)

def test_exclude_requester_off_keeps_legacy_behavior(self):
"""Without the flag, a requester who happens to be a member of
the configured reviewer group is included in reviewer_ids and
can auto-validate -- which is the legacy behaviour and must
keep working for backwards compatibility."""
group = self.env["res.groups"].create(
{
"name": "Open Reviewers",
"user_ids": [Command.link(self.test_user_1.id)],
}
)
self.tier_definition.write(
{
"review_type": "group",
"reviewer_id": False,
"reviewer_group_id": group.id,
# exclude_requester left at its default of False
}
)
record = self.test_model.create({"test_field": 1.0})
record.with_user(self.test_user_1).request_validation()
review = record.review_ids.filtered(
lambda r: r.definition_id == self.tier_definition
)
self.assertIn(self.test_user_1, review.reviewer_ids)
self.assertTrue(record.with_user(self.test_user_1).can_review)

def test_04_request_validation_rejected(self):
"""Request validation, rejection and reset."""
self.assertFalse(self.test_record.review_ids)
Expand Down
4 changes: 4 additions & 0 deletions base_tier_validation/views/tier_definition_view.xml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
<span
>Bypass, if previous tier was validated by same reviewer</span>
</div>
<field
name="exclude_requester"
invisible="review_type != 'group'"
/>
</group>
</group>
<notebook>
Expand Down
Loading