Skip to content

Commit 6734ffc

Browse files
committed
feat(lab-4291): resolve comments, remove deleted users from count
1 parent cb2d249 commit 6734ffc

File tree

6 files changed

+94
-105
lines changed

6 files changed

+94
-105
lines changed

recipes/copy_workflow_between_projects.ipynb

Lines changed: 6 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,7 @@
8989
"skip"
9090
]
9191
},
92-
"outputs": [
93-
{
94-
"name": "stdout",
95-
"output_type": "stream",
96-
"text": [
97-
"Source project: cmn8z1aoz004rme4i4zx8c81w\n"
98-
]
99-
}
100-
],
92+
"outputs": [],
10193
"source": [
10294
"json_interface = {\n",
10395
" \"jobs\": {\n",
@@ -168,18 +160,7 @@
168160
"skip"
169161
]
170162
},
171-
"outputs": [
172-
{
173-
"name": "stdout",
174-
"output_type": "stream",
175-
"text": [
176-
"Source workflow steps:\n",
177-
" - {'assignees': [{'email': 'test+edouard@kili-technology.com', 'id': 'user-2'}, {'email': 'labeler1@example.com', 'id': 'cmn8yvbve000lme4i2yxa252z'}, {'email': 'labeler2@example.com', 'id': 'cmn8yvbzi000tme4ifga589w7'}, {'email': 'reviewer1@example.com', 'id': 'cmn8yvc2h0011me4iajuh8v1r'}], 'type': 'DEFAULT', 'name': 'Labeling Renamed', 'id': 'cmn8z1apd004tme4i9bvbfwrv'}\n",
178-
" - {'assignees': [{'email': 'test+edouard@kili-technology.com', 'id': 'user-2'}, {'email': 'reviewer1@example.com', 'id': 'cmn8yvc2h0011me4iajuh8v1r'}], 'type': 'REVIEW', 'name': 'Review', 'id': 'cmn8z1apn004ume4i8hwm0kce'}\n",
179-
" - {'assignees': [{'email': 'test+edouard@kili-technology.com', 'id': 'user-2'}, {'email': 'test+max@kili-technology.com', 'id': 'user-7'}, {'email': 'test+queues@kili-technology.com', 'id': 'user-9'}, {'email': 'test+github@kili-technology.com', 'id': 'user-6'}, {'email': 'reviewer1@example.com', 'id': 'cmn8yvc2h0011me4iajuh8v1r'}], 'type': 'REVIEW', 'name': 'Review 2', 'id': 'cmn8z1e77005tme4ia3r05pk2'}\n"
180-
]
181-
}
182-
],
163+
"outputs": [],
183164
"source": [
184165
"from kili.use_cases.project_workflow import ProjectWorkflowUseCases # noqa: F401\n",
185166
"\n",
@@ -239,15 +220,7 @@
239220
"skip"
240221
]
241222
},
242-
"outputs": [
243-
{
244-
"name": "stdout",
245-
"output_type": "stream",
246-
"text": [
247-
"Destination project: cmn8z1hbg005yme4i3e6sc8mv\n"
248-
]
249-
}
250-
],
223+
"outputs": [],
251224
"source": [
252225
"destination_project_id = kili.create_project(\n",
253226
" input_type=\"IMAGE\",\n",
@@ -303,15 +276,7 @@
303276
"cell_type": "code",
304277
"execution_count": null,
305278
"metadata": {},
306-
"outputs": [
307-
{
308-
"name": "stdout",
309-
"output_type": "stream",
310-
"text": [
311-
"{'enforceStepSeparation': True, 'steps': [{'id': 'cmn8z1hbw0060me4iek7parym'}, {'id': 'cmn8z1kio0070me4i7nrta6fc'}, {'id': 'cmn8z1kiu0071me4ie9kg0tn9'}]}\n"
312-
]
313-
}
314-
],
279+
"outputs": [],
315280
"source": [
316281
"result = kili.copy_workflow_from_project(\n",
317282
" destination_project_id=destination_project_id,\n",
@@ -338,18 +303,7 @@
338303
"cell_type": "code",
339304
"execution_count": null,
340305
"metadata": {},
341-
"outputs": [
342-
{
343-
"name": "stdout",
344-
"output_type": "stream",
345-
"text": [
346-
"Destination workflow steps after copy:\n",
347-
" - {'name': 'Labeling Renamed', 'type': 'DEFAULT', 'consensusCoverage': 50, 'numberOfExpectedLabelsForConsensus': 2, 'stepCoverage': None, 'sendBackStepId': None}\n",
348-
" - {'name': 'Review', 'type': 'REVIEW', 'consensusCoverage': None, 'numberOfExpectedLabelsForConsensus': None, 'stepCoverage': None, 'sendBackStepId': None}\n",
349-
" - {'name': 'Review 2', 'type': 'REVIEW', 'consensusCoverage': None, 'numberOfExpectedLabelsForConsensus': None, 'stepCoverage': 80, 'sendBackStepId': 'cmn8z1hbw0060me4iek7parym'}\n"
350-
]
351-
}
352-
],
306+
"outputs": [],
353307
"source": [
354308
"dest_steps = kili.get_steps(\n",
355309
" project_id=destination_project_id,\n",
@@ -393,18 +347,7 @@
393347
"skip"
394348
]
395349
},
396-
"outputs": [
397-
{
398-
"data": {
399-
"text/plain": [
400-
"'cmn8z0wln0044me4icsblhtja'"
401-
]
402-
},
403-
"execution_count": null,
404-
"metadata": {},
405-
"output_type": "execute_result"
406-
}
407-
],
350+
"outputs": [],
408351
"source": [
409352
"kili.delete_project(source_project_id)\n",
410353
"kili.delete_project(destination_project_id)"

src/kili/adapters/kili_api_gateway/project_workflow/mappers.py

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,39 @@ def project_input_mapper(data: ProjectWorkflowDataKiliAPIGatewayInput) -> dict:
1010
return {
1111
"enforceStepSeparation": data.enforce_step_separation,
1212
"steps": {
13-
"creates": [
14-
update_step_mapper(step, for_copy=data.for_copy) for step in data.create_steps
15-
]
13+
"creates": [update_step_mapper(step, data.null_fields) for step in data.create_steps]
1614
if data.create_steps
1715
else [],
18-
"updates": [
19-
update_step_mapper(step, for_copy=data.for_copy) for step in data.update_steps
20-
]
16+
"updates": [update_step_mapper(step, data.null_fields) for step in data.update_steps]
2117
if data.update_steps
2218
else [],
23-
"deletes": data.delete_steps if data.delete_steps else [],
19+
"deletes": data.delete_steps or [],
2420
},
2521
}
2622

2723

2824
def update_step_mapper(
29-
data: WorkflowStepCreate | WorkflowStepUpdate, for_copy: bool = False
25+
data: WorkflowStepCreate | WorkflowStepUpdate,
26+
null_fields: frozenset[str] = frozenset(),
3027
) -> dict:
31-
"""Build the GraphQL create StepData variable to be sent in an operation."""
32-
## In copy worklow use case, we want to copy as well
33-
# consensusCoverage and numberOfExpectedLabelsForConsensus properties, even if they are None
28+
"""Build the GraphQL StepData variable to be sent in an operation.
3429
35-
step = {
36-
"id": data.get("id"),
37-
"name": data.get("name"),
38-
"consensusCoverage": data.get("consensus_coverage"),
39-
"numberOfExpectedLabelsForConsensus": data.get("number_of_expected_labels_for_consensus"),
40-
"stepCoverage": data.get("step_coverage"),
41-
"type": data.get("type"),
42-
"assignees": data.get("assignees"),
43-
"sendBackStepId": data.get("send_back_step_id"),
30+
A field is included when its value is not None, or when its GQL name appears in
31+
null_fields (meaning the caller explicitly wants to send null to clear that field).
32+
Fields absent from the TypedDict are never included.
33+
"""
34+
mapping = {
35+
"id": "id",
36+
"name": "name",
37+
"consensusCoverage": "consensus_coverage",
38+
"numberOfExpectedLabelsForConsensus": "number_of_expected_labels_for_consensus",
39+
"stepCoverage": "step_coverage",
40+
"type": "type",
41+
"assignees": "assignees",
42+
"sendBackStepId": "send_back_step_id",
43+
}
44+
return {
45+
gql_key: data[py_key]
46+
for gql_key, py_key in mapping.items()
47+
if py_key in data and (data[py_key] is not None or gql_key in null_fields)
4448
}
45-
if for_copy:
46-
special_keys = ["consensusCoverage", "numberOfExpectedLabelsForConsensus"]
47-
return {k: v for k, v in step.items() if v is not None or k in special_keys}
48-
return {k: v for k, v in step.items() if v is not None}

src/kili/adapters/kili_api_gateway/project_workflow/operations_mixin.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Mixin extending Kili API Gateway class with Projects related operations."""
2+
23
import warnings
34

45
from kili.adapters.kili_api_gateway.base import BaseOperationMixin
@@ -62,12 +63,12 @@ def get_steps(self, project_id: str, fields: ListOrTuple[str]) -> list[dict]:
6263

6364
def count_activated_project_users(self, project_id: str) -> int:
6465
"""Count project users with ACTIVATED status."""
65-
where = ProjectUserWhere(project_id=project_id, status="ACTIVATED")
66+
where = ProjectUserWhere(project_id=project_id, status="ACTIVATED", deleted=False)
6667
return ProjectUserQuery(self.graphql_client, self.http_client).count(where)
6768

6869
def list_activated_project_users(self, project_id: str) -> list[dict]:
6970
"""List project users with ACTIVATED status, returning role and user id."""
70-
where = ProjectUserWhere(project_id=project_id, status="ACTIVATED")
71+
where = ProjectUserWhere(project_id=project_id, status="ACTIVATED", deleted=False)
7172
return list(
7273
ProjectUserQuery(self.graphql_client, self.http_client)(
7374
where=where,
@@ -81,7 +82,7 @@ def add_reviewers_to_step(
8182
) -> list[str]:
8283
"""Add reviewers to a specific step."""
8384
existing_members = ProjectUserQuery(self.graphql_client, self.http_client)(
84-
where=ProjectUserWhere(project_id=project_id, status="ACTIVATED"),
85+
where=ProjectUserWhere(project_id=project_id, status="ACTIVATED", deleted=False),
8586
fields=["role", "user.email", "user.id", "activated"],
8687
options=QueryOptions(None),
8788
)

src/kili/adapters/kili_api_gateway/project_workflow/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ class ProjectWorkflowDataKiliAPIGatewayInput:
1414
create_steps: Optional[list[WorkflowStepCreate]]
1515
update_steps: Optional[list[WorkflowStepUpdate]]
1616
delete_steps: Optional[list[str]]
17-
for_copy: bool = False
17+
null_fields: frozenset[str] = frozenset()

src/kili/use_cases/project_workflow/__init__.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,8 @@ def copy_workflow_from_project(
107107
# 3. Validate destination has no labels
108108
self._validate_destination_has_no_labels(destination_project_id)
109109

110-
# 3b. Validate destination has enough labelers for consensus on the first step
111-
self._validate_consensus_labelers(destination_project_id, source_steps[0])
110+
# 3b. Validate destination has enough labelers for the most demanding consensus step
111+
self._validate_consensus_labelers(destination_project_id, source_steps)
112112

113113
# 4. Get existing destination steps and activated users
114114
dest_steps = self._get_destination_steps(destination_project_id)
@@ -168,7 +168,7 @@ def copy_workflow_from_project(
168168
create_steps=steps_to_create or None,
169169
update_steps=update_steps or None,
170170
delete_steps=delete_steps,
171-
for_copy=True,
171+
null_fields=frozenset({"consensusCoverage", "numberOfExpectedLabelsForConsensus"}),
172172
),
173173
)
174174

@@ -188,10 +188,18 @@ def copy_workflow_from_project(
188188
return result
189189

190190
def _validate_consensus_labelers(
191-
self, destination_project_id: ProjectId, first_source_step: dict[str, object]
191+
self, destination_project_id: ProjectId, source_steps: list[dict[str, object]]
192192
) -> None:
193-
"""Validate the destination has enough activated labelers for the source consensus setting."""
194-
required = cast("int", first_source_step.get("numberOfExpectedLabelsForConsensus"))
193+
"""Validate the destination has enough activated labelers for every consensus step."""
194+
required = max(
195+
(
196+
cast("int", step.get("numberOfExpectedLabelsForConsensus"))
197+
for step in source_steps
198+
if step.get("numberOfExpectedLabelsForConsensus")
199+
),
200+
default=0,
201+
)
202+
195203
if not required:
196204
return
197205
activated_count = self._kili_api_gateway.count_activated_project_users(
@@ -306,17 +314,14 @@ def _make_create_step(step: dict[str, object], assignees: list[str]) -> Workflow
306314

307315
def _build_step_update(dest_step_id: str, source_step: dict[str, object]) -> WorkflowStepUpdate:
308316
"""Build a WorkflowStepUpdate for the first dest step based on source step properties."""
309-
update: WorkflowStepUpdate = {
317+
return {
310318
"id": dest_step_id,
311319
"name": cast("str", source_step["name"]),
320+
"consensus_coverage": cast("int | None", source_step.get("consensusCoverage")),
321+
"number_of_expected_labels_for_consensus": cast(
322+
"int | None", source_step.get("numberOfExpectedLabelsForConsensus")
323+
),
312324
}
313-
if source_step.get("consensusCoverage") is not None:
314-
update["consensus_coverage"] = cast("int", source_step["consensusCoverage"])
315-
if source_step.get("numberOfExpectedLabelsForConsensus") is not None:
316-
update["number_of_expected_labels_for_consensus"] = cast(
317-
"int", source_step["numberOfExpectedLabelsForConsensus"]
318-
)
319-
return update
320325

321326

322327
def _build_send_back_updates(

tests/unit/use_cases/project_workflow/test_copy_workflow.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,46 @@ def test_raises_when_destination_has_too_few_labelers(self, use_cases, mock_gate
205205
destination_project_id=dest_id,
206206
)
207207

208+
def test_raises_when_later_step_requires_more_labelers(self, use_cases, mock_gateway):
209+
"""Test that validation checks all steps, not just the first one."""
210+
source_id = ProjectId("source-project")
211+
dest_id = ProjectId("dest-project")
212+
213+
# First step: no consensus; second step: requires 5 labelers
214+
steps = [
215+
{
216+
"id": "source-step-1",
217+
"name": "Labeling",
218+
"type": "DEFAULT",
219+
"consensusCoverage": None,
220+
"numberOfExpectedLabelsForConsensus": None,
221+
"stepCoverage": None,
222+
"sendBackStepId": None,
223+
},
224+
{
225+
"id": "source-step-2",
226+
"name": "Second Pass",
227+
"type": "DEFAULT",
228+
"consensusCoverage": 80,
229+
"numberOfExpectedLabelsForConsensus": 5,
230+
"stepCoverage": None,
231+
"sendBackStepId": None,
232+
},
233+
]
234+
mock_gateway.get_steps.return_value = steps
235+
mock_gateway.get_project.side_effect = [
236+
{"enforceStepSeparation": None},
237+
{"workflowVersion": "V2"},
238+
]
239+
mock_gateway.count_labels.return_value = 0
240+
mock_gateway.count_activated_project_users.return_value = 3 # fewer than 5
241+
242+
with pytest.raises(ValueError, match="3 activated labeler"):
243+
use_cases.copy_workflow_from_project(
244+
source_project_id=source_id,
245+
destination_project_id=dest_id,
246+
)
247+
208248
def test_skips_consensus_check_when_not_set(self, use_cases, mock_gateway):
209249
"""Test consensus labeler check is skipped when numberOfExpectedLabelsForConsensus is None."""
210250
source_id = ProjectId("source-project")

0 commit comments

Comments
 (0)