From 4220c5ac1fecea61e1f4d47a07fe3009bcb0ed13 Mon Sep 17 00:00:00 2001 From: Manu Sharma Date: Tue, 13 Oct 2026 21:23:26 -0700 Subject: [PATCH] Add user group and member organization filters to workspace performance reports Client.get_workspace_performance_metric() and Client.get_workspace_labeler_performance() take user_group_ids and member_organization_ids, which the REST API now accepts on both workspace endpoints. Rows of the three performance tables carry member_organization_id and member_organization_name, and a workspace series echoes member_organization_ids and user_group_ids. Iterating a performance table now raises PerformanceTableChangedError when a page reports a different total from the first page. Under a user group filter, group membership is worked out for every page, so a member joining or leaving mid-read shifts the rows; before, the SDK returned them without saying so. Co-Authored-By: Claude Opus 5.5 --- docs/labelbox/performance.rst | 2 +- libs/labelbox/src/labelbox/__init__.py | 1 + libs/labelbox/src/labelbox/client.py | 78 ++++- .../src/labelbox/schema/performance.py | 86 +++++- libs/labelbox/src/labelbox/schema/project.py | 6 +- .../tests/unit/schema/test_performance.py | 269 +++++++++++++++++- 6 files changed, 421 insertions(+), 21 deletions(-) diff --git a/docs/labelbox/performance.rst b/docs/labelbox/performance.rst index b86d5fe98..70c6ff347 100644 --- a/docs/labelbox/performance.rst +++ b/docs/labelbox/performance.rst @@ -2,5 +2,5 @@ Performance =============================================================================================== .. automodule:: labelbox.schema.performance - :members: PerformanceMetric, PerformanceMetricSeries, PerformanceTimeBucket, PerformanceScoreBucket, PerformanceHandlingTime, ProjectLabelerPerformance, ProjectReviewerPerformance, WorkspaceLabelerPerformance, PerformanceReportDownload, PerformanceRows, PerformanceInterval, PerformanceDeletedLabels, PerformanceMemberType + :members: PerformanceMetric, PerformanceMetricSeries, PerformanceTimeBucket, PerformanceScoreBucket, PerformanceHandlingTime, ProjectLabelerPerformance, ProjectReviewerPerformance, WorkspaceLabelerPerformance, PerformanceReportDownload, PerformanceRows, PerformanceTableChangedError, PerformanceInterval, PerformanceDeletedLabels, PerformanceMemberType :show-inheritance: diff --git a/libs/labelbox/src/labelbox/__init__.py b/libs/labelbox/src/labelbox/__init__.py index b0cb502b4..0ea4ec4ef 100644 --- a/libs/labelbox/src/labelbox/__init__.py +++ b/libs/labelbox/src/labelbox/__init__.py @@ -111,6 +111,7 @@ PerformanceReportDownload, PerformanceRows, PerformanceScoreBucket, + PerformanceTableChangedError, PerformanceTimeBucket, ProjectLabelerPerformance, ProjectReviewerPerformance, diff --git a/libs/labelbox/src/labelbox/client.py b/libs/labelbox/src/labelbox/client.py index 83801cdbc..df1989dea 100644 --- a/libs/labelbox/src/labelbox/client.py +++ b/libs/labelbox/src/labelbox/client.py @@ -2365,6 +2365,8 @@ def get_workspace_performance_metric( Union[_performance.PerformanceMemberType, str] ] = None, owner_organization_ids: Optional[List[str]] = None, + member_organization_ids: Optional[List[str]] = None, + user_group_ids: Optional[List[str]] = None, ) -> _performance.PerformanceMetricSeries: """Returns one performance metric across the workspace. @@ -2398,10 +2400,24 @@ def get_workspace_performance_metric( owner_organization_ids (Optional[List[str]]): Count only projects owned by these organizations. Use it to separate the projects you own from the ones shared with you. - - ``member_type`` and ``owner_organization_ids`` report on other + member_organization_ids (Optional[List[str]]): Count only work by + members whose account belongs to one of these organizations, + for example a workforce provider's. Up to 100. + user_group_ids (Optional[List[str]]): Count only work by the + current members of these user groups of your organization. Up + to 25 groups, with at most 5,000 members between them. The + groups count only if you have the organization-wide + permission to view user groups, and a restricted API key has + it too. Without it no group adds anyone, and every value is + zero; no error is raised. A group of another organization + adds no one either. + + ``member_type``, ``owner_organization_ids``, + ``member_organization_ids`` and ``user_group_ids`` report on other members, so they need permission to view all performance in every - project the report covers. + project the report covers. Filters narrow each other: only work that + matches all of them is counted, and if none does, every value is + zero. It is never the unfiltered report. Returns: PerformanceMetricSeries: The metric as a value per interval. @@ -2412,7 +2428,8 @@ def get_workspace_performance_metric( lbox.exceptions.AuthorizationError: You may not view performance across the organization, or not everyone's in a project the report covers. - lbox.exceptions.InvalidQueryError: A parameter is not valid. + lbox.exceptions.InvalidQueryError: A parameter is not valid, or + the user groups have more than 5,000 members between them. ValueError: A filter was given as an empty list. Pass ``None`` to leave a filter off; an empty list is not read as one. @@ -2426,6 +2443,11 @@ def get_workspace_performance_metric( >>> series = client.get_workspace_performance_metric( >>> "labels_created", "2026-01-01", "2026-01-31", >>> member_type="external") + >>> + >>> # The same, for the members of one user group. + >>> series = client.get_workspace_performance_metric( + >>> "labels_created", "2026-01-01", "2026-01-31", + >>> user_group_ids=[group.id]) """ return _performance.get_workspace_metric( self, @@ -2440,6 +2462,8 @@ def get_workspace_performance_metric( use_cache=use_cache, member_type=member_type, owner_organization_ids=owner_organization_ids, + member_organization_ids=member_organization_ids, + user_group_ids=user_group_ids, ) def get_workspace_labeler_performance( @@ -2460,6 +2484,8 @@ def get_workspace_labeler_performance( descending: bool = False, page_size: int = 50, use_cache: bool = True, + member_organization_ids: Optional[List[str]] = None, + user_group_ids: Optional[List[str]] = None, ) -> _performance.PerformanceRows[_performance.WorkspaceLabelerPerformance]: """Returns per-labeler totals across the workspace. @@ -2467,8 +2493,9 @@ def get_workspace_labeler_performance( labeler on each project they worked on in the period. It covers the projects your organization owns and the ones other organizations share with it. Each row says which organization owns the project - (``project_organization_id``) and whether the labeler is one of your - own members (``member_type``). + (``project_organization_id``), whether the labeler is one of your + own members (``member_type``), and which organization the labeler's + account belongs to (``member_organization_id``). Args: start_date (date, datetime or str): First day of the period, in @@ -2490,14 +2517,31 @@ def get_workspace_labeler_performance( projects owned by these organizations. sort_by (Optional[str]): A column to sort by, named as the row's attribute, for example ``"labels_created"``. Without it, rows - are ordered by project, then labeler. Sorting by email is not - offered. + are ordered by project, then labeler. Sorting by email or by + member organization is not offered. descending (bool): Sort from the highest value down. page_size (int): Rows fetched per request, at most 100. use_cache (bool): Pass False to read the report again instead of using rows read within the last few minutes. Leave it on - while reading a large table, so that every page comes from - the same rows. + while reading a large table, so that the pages come from the + same rows while those are kept; see :class:`PerformanceRows`. + member_organization_ids (Optional[List[str]]): Return only + members whose account belongs to one of these organizations, + for example a workforce provider's. Up to 100. + user_group_ids (Optional[List[str]]): Return only the current + members of these user groups of your organization. Up to 25 + groups, with at most 5,000 members between them. The groups + count only if you have the organization-wide permission to + view user groups, and a restricted API key has it too. + Without it no group adds anyone, and no rows are returned; no + error is raised. A group of another organization adds no one + either. Who is in the groups is worked out for each page; see + :class:`PerformanceRows`. + + Filters narrow each other: a row is returned only if it matches all + of them. If you may view only your own performance, you get your own + rows, and with ``user_group_ids`` only if you are in one of the + groups. Returns: PerformanceRows[WorkspaceLabelerPerformance]: One row per labeler @@ -2509,7 +2553,8 @@ def get_workspace_labeler_performance( you can see. lbox.exceptions.AuthorizationError: You may not view performance across the organization. - lbox.exceptions.InvalidQueryError: A parameter is not valid. + lbox.exceptions.InvalidQueryError: A parameter is not valid, or + the user groups have more than 5,000 members between them. ValueError: A filter was given as an empty list. Pass ``None`` to leave a filter off; an empty list is not read as one. @@ -2519,6 +2564,15 @@ def get_workspace_labeler_performance( >>> print(rows.total) >>> for row in rows: >>> print(row.project_name, row.email, row.labels_created) + >>> + >>> # Totals by vendor organization. Names are not unique and + >>> # can be masked, so the totals are kept by ID. + >>> from collections import Counter + >>> labels, names = Counter(), {} + >>> for row in client.get_workspace_labeler_performance( + >>> "2026-01-01", "2026-01-31", member_type="external"): + >>> labels[row.member_organization_id] += row.labels_created + >>> names[row.member_organization_id] = row.member_organization_name """ return _performance.get_workspace_labelers( self, @@ -2534,6 +2588,8 @@ def get_workspace_labeler_performance( descending=descending, page_size=page_size, use_cache=use_cache, + member_organization_ids=member_organization_ids, + user_group_ids=user_group_ids, ) def get_task_by_id(self, task_id: str) -> Union[Task, DataUpsertTask]: diff --git a/libs/labelbox/src/labelbox/schema/performance.py b/libs/labelbox/src/labelbox/schema/performance.py index 868bef2bb..7360729d0 100644 --- a/libs/labelbox/src/labelbox/schema/performance.py +++ b/libs/labelbox/src/labelbox/schema/performance.py @@ -188,6 +188,10 @@ class PerformanceMetricSeries(_CamelCaseMixin): narrowed by owning organization, the projects of those owners that are not counted because you may not view everyone's performance in them. Empty when none were left out. + member_organization_ids (Optional[List[str]]): The member + organizations a workspace series was narrowed to, if any. + user_group_ids (Optional[List[str]]): The user groups a workspace + series was narrowed to, if any, as they were sent. """ metric: str @@ -204,6 +208,8 @@ class PerformanceMetricSeries(_CamelCaseMixin): owner_organization_ids: Optional[List[str]] = None member_type: Optional[str] = None skipped_project_ids: Optional[List[str]] = None + member_organization_ids: Optional[List[str]] = None + user_group_ids: Optional[List[str]] = None class ProjectLabelerPerformance(_CamelCaseMixin): @@ -221,6 +227,14 @@ class ProjectLabelerPerformance(_CamelCaseMixin): organization, ``"external"`` for a member of another one (see :class:`PerformanceMemberType`). ``None`` if the account no longer exists. + member_organization_id (Optional[str]): The organization the + member's account belongs to: yours, or another one, such as a + workforce provider's or, if you are a workforce provider, a + customer's. It is the same whoever asks. ``None`` if the account + no longer exists. + member_organization_name (Optional[str]): That organization's name, + or ``org.name.`` if it is masked for you. ``None`` if the + organization can no longer be found. labels_created (int) labels_skipped (int) member_total_time (float) @@ -244,6 +258,8 @@ class ProjectLabelerPerformance(_CamelCaseMixin): project_id: str email: str member_type: Optional[str] = None + member_organization_id: Optional[str] = None + member_organization_name: Optional[str] = None labels_created: int labels_skipped: int member_total_time: float @@ -278,6 +294,14 @@ class ProjectReviewerPerformance(_CamelCaseMixin): organization, ``"external"`` for a member of another one (see :class:`PerformanceMemberType`). ``None`` if the account no longer exists. + member_organization_id (Optional[str]): The organization the + member's account belongs to: yours, or another one, such as a + workforce provider's or, if you are a workforce provider, a + customer's. It is the same whoever asks. ``None`` if the account + no longer exists. + member_organization_name (Optional[str]): That organization's name, + or ``org.name.`` if it is masked for you. ``None`` if the + organization can no longer be found. labels_reviewed (int) labels_reworked (int) member_total_time (float) @@ -294,6 +318,8 @@ class ProjectReviewerPerformance(_CamelCaseMixin): project_id: str email: str member_type: Optional[str] = None + member_organization_id: Optional[str] = None + member_organization_name: Optional[str] = None labels_reviewed: int labels_reworked: int member_total_time: float @@ -324,6 +350,14 @@ class WorkspaceLabelerPerformance(_CamelCaseMixin): organization, ``"external"`` for a member of another one (see :class:`PerformanceMemberType`). ``None`` if the account no longer exists. + member_organization_id (Optional[str]): The organization the + member's account belongs to: yours, or another one, such as a + workforce provider's or, if you are a workforce provider, a + customer's. It is the same whoever asks. ``None`` if the account + no longer exists. + member_organization_name (Optional[str]): That organization's name, + or ``org.name.`` if it is masked for you. ``None`` if the + organization can no longer be found. project_id (str) project_name (Optional[str]): ``None`` for a project that can no longer be found. Its figures are still counted. @@ -353,6 +387,8 @@ class WorkspaceLabelerPerformance(_CamelCaseMixin): user_id: str email: str member_type: Optional[str] = None + member_organization_id: Optional[str] = None + member_organization_name: Optional[str] = None project_id: str project_name: Optional[str] = None project_organization_id: Optional[str] = None @@ -397,6 +433,14 @@ class PerformanceReportDownload(_CamelCaseMixin): generated_at: datetime +class PerformanceTableChangedError(LabelboxError): + """A table changed while its pages were being read. + + Raised while iterating :class:`PerformanceRows` when a page reports a + different ``total`` from the first page. Iterate the table again. + """ + + Row = TypeVar("Row", bound=_CamelCaseMixin) @@ -405,8 +449,23 @@ class PerformanceRows(Generic[Row]): Iterating fetches the table a page at a time, so a large table is never held in one response. Nothing is requested until the rows, ``total`` or - ``cached_at`` are read. The rows come in a fixed order, so no row is - repeated or missed between pages. + ``cached_at`` are read. The rows come in a fixed order. + + Each page is a separate request, so the pages fit together only if the + table does not change between them. A workspace table read with the + cache on is cut from a reading of the report that is kept for five + minutes from when it was taken, possibly by an earlier request, so its + pages agree while that reading lasts. A very large table is not kept, + and an iteration that outlasts the reading continues from a new one. A + project table is read a page at a time, and on any table, who is in the + groups of a ``user_group_ids`` filter is worked out again for every + page. If the figures or a group's members change between pages, a row + can be missed or repeated. + + When a page reports a different ``total`` from the first page, iterating + raises :class:`PerformanceTableChangedError`: read the table again. A + change that leaves the number of rows as it was cannot be seen this way, + such as one member joining a group as another with as many rows leaves. Iterating a second time reads the table again from its first page, and ``total`` and ``cached_at`` then describe that reading. @@ -456,13 +515,22 @@ def __iter__(self) -> Iterator[Row]: number = 1 seen = 0 + total: Optional[int] = None while True: page = self._page(number) + if total is None: + total = int(page["total"]) + elif int(page["total"]) != total: + raise PerformanceTableChangedError( + f"The table changed while it was read: page 1 counted " + f"{total} rows and page {number} counts {page['total']}. " + "Read it again." + ) rows = page["data"] for row in rows: yield self._row_type.model_validate(row) seen += len(rows) - if not rows or seen >= int(page["total"]): + if not rows or seen >= total: return number += 1 @@ -799,6 +867,8 @@ def get_workspace_metric( use_cache: bool = True, member_type: Optional[Union[PerformanceMemberType, str]] = None, owner_organization_ids: Optional[Iterable[Any]] = None, + member_organization_ids: Optional[Iterable[Any]] = None, + user_group_ids: Optional[Iterable[Any]] = None, ) -> PerformanceMetricSeries: body = _get( client, @@ -813,6 +883,10 @@ def get_workspace_metric( ownerOrganizationIds=_to_ids( "owner_organization_ids", owner_organization_ids ), + memberOrganizationIds=_to_ids( + "member_organization_ids", member_organization_ids + ), + userGroupIds=_to_ids("user_group_ids", user_group_ids), useCache=None if use_cache else False, ) return PerformanceMetricSeries.model_validate(body) @@ -834,6 +908,8 @@ def get_workspace_labelers( descending: bool = False, page_size: int = _DEFAULT_PAGE_SIZE, use_cache: bool = True, + member_organization_ids: Optional[Iterable[Any]] = None, + user_group_ids: Optional[Iterable[Any]] = None, ) -> PerformanceRows[WorkspaceLabelerPerformance]: return _table( client, @@ -852,5 +928,9 @@ def get_workspace_labelers( ownerOrganizationIds=_to_ids( "owner_organization_ids", owner_organization_ids ), + memberOrganizationIds=_to_ids( + "member_organization_ids", member_organization_ids + ), + userGroupIds=_to_ids("user_group_ids", user_group_ids), useCache=None if use_cache else False, ) diff --git a/libs/labelbox/src/labelbox/schema/project.py b/libs/labelbox/src/labelbox/schema/project.py index 83eea92d3..c39433cf4 100644 --- a/libs/labelbox/src/labelbox/schema/project.py +++ b/libs/labelbox/src/labelbox/schema/project.py @@ -1949,7 +1949,8 @@ def get_labeler_performance( request can cover at most 366 days. user_ids (Optional[List[str]]): Return only these users. user_group_ids (Optional[List[str]]): Return only the current - members of these user groups. + members of these user groups. Who is in the groups is worked + out for each page; see :class:`PerformanceRows`. batch_ids (Optional[List[str]]): Count only these batches. deleted_labels (PerformanceDeletedLabels or str): Whether labels deleted since are counted. Included by default. @@ -2023,7 +2024,8 @@ def get_reviewer_performance( request can cover at most 366 days. user_ids (Optional[List[str]]): Return only these users. user_group_ids (Optional[List[str]]): Return only the current - members of these user groups. + members of these user groups. Who is in the groups is worked + out for each page; see :class:`PerformanceRows`. batch_ids (Optional[List[str]]): Count only these batches. deleted_labels (PerformanceDeletedLabels or str): Whether labels deleted since are counted. Included by default. diff --git a/libs/labelbox/tests/unit/schema/test_performance.py b/libs/labelbox/tests/unit/schema/test_performance.py index 3075957df..5182721d5 100644 --- a/libs/labelbox/tests/unit/schema/test_performance.py +++ b/libs/labelbox/tests/unit/schema/test_performance.py @@ -23,6 +23,7 @@ PerformanceInterval, PerformanceMemberType, PerformanceScoreBucket, + PerformanceTableChangedError, PerformanceTimeBucket, Project, ) @@ -432,7 +433,14 @@ def test_refuses_an_empty_filter_instead_of_reporting_on_everyone( client.connection.get.assert_not_called() @pytest.mark.parametrize( - "argument", ["project_ids", "owner_organization_ids", "user_ids"] + "argument", + [ + "project_ids", + "owner_organization_ids", + "user_ids", + "member_organization_ids", + "user_group_ids", + ], ) def test_refuses_an_empty_workspace_filter_too(self, client, argument): with pytest.raises(ValueError, match=f"{argument} is empty"): @@ -573,6 +581,30 @@ def test_stops_if_a_page_comes_back_empty(self, client, project): assert [row.user_id for row in rows] == ["user-1"] assert client.connection.get.call_count == 2 + def test_says_when_the_table_changed_between_pages(self, client, project): + # A member joined while the table was read: user-1 moves to page 2. + client.connection.get.side_effect = [ + response(table([labeler("user-1"), labeler("user-2")], total=4)), + response( + table([labeler("user-1"), labeler("user-2")], total=5, page=2) + ), + ] + + rows = Project.get_labeler_performance( + project, "2026-01-01", "2026-01-07", page_size=2 + ) + read = [] + with pytest.raises( + PerformanceTableChangedError, + match="page 1 counted 4 rows and page 2 counts 5", + ): + for row in rows: + read.append(row.user_id) + + # The first page was returned before the change could be seen. + assert read == ["user-1", "user-2"] + assert client.connection.get.call_count == 2 + def test_reads_the_total_and_cache_time_from_the_first_page_once( self, client, project ): @@ -867,14 +899,120 @@ def test_narrows_a_metric_by_member_type_and_owning_organization( assert series.owner_organization_ids == ["org-2"] assert series.skipped_project_ids == ["project-9"] - def test_sends_neither_narrowing_unless_asked(self, client): + def test_sends_no_narrowing_unless_asked(self, client): client.get_workspace_performance_metric( "labels_created", "2026-01-01", "2026-01-07" ) + client.connection.get.return_value = response(workspace_table([], 0)) + list( + client.get_workspace_labeler_performance("2026-01-01", "2026-01-07") + ) + + for call in (0, 1): + _, params = sent(client, call) + assert "memberType" not in params + assert "ownerOrganizationIds" not in params + assert "memberOrganizationIds" not in params + assert "userGroupIds" not in params + + def test_narrows_a_metric_by_user_group_and_member_organization( + self, client + ): + client.connection.get.return_value = response( + { + **{k: v for k, v in SERIES.items() if k != "projectId"}, + "organizationId": "org-1", + "projectIds": None, + "ownerOrganizationIds": None, + "memberOrganizationIds": ["org-2", "org-3"], + "memberType": None, + "userGroupIds": ["group-a", "GROUP-B"], + "skippedProjectIds": [], + } + ) + + series = client.get_workspace_performance_metric( + "labels_created", + "2026-01-01", + "2026-01-07", + member_organization_ids=["org-2", SimpleNamespace(uid="org-3")], + # UserGroup is a pydantic model whose ID is `id`, not `uid`. + user_group_ids=[ + UserGroup(client=client, id="group-a", name="Vendor A"), + "GROUP-B", + ], + ) _, params = sent(client) - assert "memberType" not in params - assert "ownerOrganizationIds" not in params + assert params["memberOrganizationIds"] == "org-2,org-3" + assert params["userGroupIds"] == "group-a,GROUP-B" + assert series.member_organization_ids == ["org-2", "org-3"] + # Echoed as sent. + assert series.user_group_ids == ["group-a", "GROUP-B"] + + def test_says_when_a_group_filtered_table_changed_between_pages( + self, client + ): + # Group membership is worked out for every page; one member left. + client.connection.get.side_effect = [ + response( + workspace_table( + [ + workspace_labeler("user-1", "project-1"), + workspace_labeler("user-2", "project-1"), + ], + total=3, + ) + ), + response(workspace_table([], total=2, page=2)), + ] + + rows = client.get_workspace_labeler_performance( + "2026-01-01", "2026-01-07", user_group_ids=["group-a"], page_size=2 + ) + + with pytest.raises(PerformanceTableChangedError, match="Read it again"): + list(rows) + + def test_reads_a_metric_from_an_api_that_does_not_echo_them(self, client): + client.connection.get.return_value = response( + { + **{k: v for k, v in SERIES.items() if k != "projectId"}, + "organizationId": "org-1", + } + ) + + series = client.get_workspace_performance_metric( + "labels_created", "2026-01-01", "2026-01-07" + ) + + assert series.member_organization_ids is None + assert series.user_group_ids is None + + def test_reports_too_many_group_members_as_an_invalid_query(self, client): + client.connection.get.return_value = response( + { + "statusCode": 400, + "error": "Bad Request", + "message": "The selected user groups have 5200 members; a " + "performance report can be filtered by at most 5000", + "details": [ + { + "parameter": "userGroupIds", + "message": "selects 5200 members; at most 5000 can " + "be reported on", + } + ], + }, + status=400, + ) + + with pytest.raises(InvalidQueryError, match="userGroupIds selects"): + list( + client.get_workspace_labeler_performance( + "2026-01-01", "2026-01-07", user_group_ids=["group-a"] + ) + ) def test_returns_a_row_per_labeler_and_project(self, client): client.connection.get.return_value = response( @@ -978,6 +1116,39 @@ def test_filters_and_sorts_the_workspace_table(self, client): assert params["order"] == "desc" assert params["useCache"] == "false" + def test_narrows_the_workspace_table_by_user_group_and_member_organization( + self, client + ): + client.connection.get.return_value = response( + workspace_table( + [ + workspace_labeler( + "user-1", + "project-1", + memberType="external", + memberOrganizationId="org-2", + memberOrganizationName="Vendor org", + ) + ], + 1, + ) + ) + + (row,) = client.get_workspace_labeler_performance( + "2026-01-01", + "2026-01-07", + member_organization_ids=["org-2"], + user_group_ids=[ + UserGroup(client=client, id="group-a", name="Vendor A") + ], + ) + + _, params = sent(client) + assert params["memberOrganizationIds"] == "org-2" + assert params["userGroupIds"] == "group-a" + assert row.member_organization_id == "org-2" + assert row.member_organization_name == "Vendor org" + def test_reads_a_row_whose_project_can_no_longer_be_found(self, client): client.connection.get.return_value = response( workspace_table( @@ -1062,6 +1233,96 @@ def test_reads_a_reply_from_an_api_that_does_not_say(self, client, project): ) assert row.member_type is None + assert row.member_organization_id is None + assert row.member_organization_name is None + + +class TestMemberOrganization: + def test_says_whose_organization_each_member_belongs_to( + self, client, project + ): + client.connection.get.side_effect = [ + response( + table( + [ + { + **labeler("user-1"), + "memberType": "internal", + "memberOrganizationId": "org-1", + "memberOrganizationName": "Org one", + }, + { + **labeler("user-2"), + "memberType": "external", + "memberOrganizationId": "org-2", + # Masked for a caller who may not see it. + "memberOrganizationName": "org.name.org-2", + }, + ], + total=2, + ) + ), + response( + table( + [ + { + **REVIEWER, + "memberType": "external", + "memberOrganizationId": "org-2", + "memberOrganizationName": "Vendor org", + } + ], + total=1, + ) + ), + ] + + labelers = list( + Project.get_labeler_performance(project, "2026-01-01", "2026-01-07") + ) + reviewers = list( + Project.get_reviewer_performance( + project, "2026-01-01", "2026-01-07" + ) + ) + + assert [ + (row.member_organization_id, row.member_organization_name) + for row in labelers + ] == [("org-1", "Org one"), ("org-2", "org.name.org-2")] + assert reviewers[0].member_organization_id == "org-2" + assert reviewers[0].member_organization_name == "Vendor org" + + def test_reads_a_member_whose_account_or_organization_is_gone(self, client): + client.connection.get.return_value = response( + workspace_table( + [ + workspace_labeler( + "user-gone", + "project-1", + memberType=None, + memberOrganizationId=None, + memberOrganizationName=None, + ), + workspace_labeler( + "user-2", + "project-1", + memberOrganizationId="org-gone", + memberOrganizationName=None, + ), + ], + 2, + ) + ) + + gone, orphaned = client.get_workspace_labeler_performance( + "2026-01-01", "2026-01-07" + ) + + assert gone.member_organization_id is None + assert gone.member_organization_name is None + assert orphaned.member_organization_id == "org-gone" + assert orphaned.member_organization_name is None class TestRetries: