Skip to content

Commit 0439103

Browse files
authored
Merge pull request #800 from superannotateai/FRIDAY-3995
added contributor categories functions
2 parents 2644c2c + dedc079 commit 0439103

File tree

7 files changed

+515
-10
lines changed

7 files changed

+515
-10
lines changed

docs/source/api_reference/api_team.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ Team
1515
.. automethod:: superannotate.SAClient.resume_user_activity
1616
.. automethod:: superannotate.SAClient.get_user_scores
1717
.. automethod:: superannotate.SAClient.set_user_scores
18+
.. automethod:: superannotate.SAClient.set_contributors_categories
19+
.. automethod:: superannotate.SAClient.remove_contributors_categories

src/superannotate/lib/app/interface/sdk_interface.py

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ def list_users(
474474
self,
475475
*,
476476
project: Union[int, str] = None,
477-
include: List[Literal["custom_fields"]] = None,
477+
include: List[Literal["custom_fields", "categories"]] = None,
478478
**filters,
479479
):
480480
"""
@@ -488,7 +488,10 @@ def list_users(
488488
489489
Possible values are
490490
491-
- "custom_fields": Includes custom fields and scores assigned to each user.
491+
- "custom_fields": Includes custom fields and scores assigned to each user.
492+
- "categories": Includes a list of categories assigned to each project contributor.
493+
Note: 'project' parameter must be specified when including 'categories'.
494+
:type include: list of str, optional
492495
493496
:param filters: Specifies filtering criteria, with all conditions combined using logical AND.
494497
@@ -860,6 +863,103 @@ def set_user_scores(
860863
)
861864
logger.info("Scores successfully set.")
862865

866+
def set_contributors_categories(
867+
self,
868+
project: Union[NotEmptyStr, int],
869+
contributors: List[Union[int, str]],
870+
categories: Union[List[str], Literal["*"]],
871+
):
872+
"""
873+
Assign one or more categories to a contributor with an assignable role (Annotator, QA or custom role)
874+
in a Multimodal project. Project Admins are not eligible for category assignments. "*" in the category
875+
list will match all categories defined in the project.
876+
877+
878+
:param project: The name or ID of the project.
879+
:type project: Union[NotEmptyStr, int]
880+
881+
:param contributors: A list of emails or IDs of the contributor.
882+
:type contributors: List[Union[int, str]]
883+
884+
:param categories: A list of category names to assign. Accepts "*" to indicate all available categories in the project.
885+
:type categories: Union[List[str], Literal["*"]]
886+
887+
Request Example:
888+
::
889+
890+
client.set_contributor_categories(
891+
project="product-review-mm",
892+
contributors=["test@superannotate.com","contributor@superannotate.com"],
893+
categories=["Shoes", "T-Shirt"]
894+
)
895+
896+
client.set_contributor_categories(
897+
project="product-review-mm",
898+
contributors=["test@superannotate.com","contributor@superannotate.com"]
899+
categories="*"
900+
)
901+
"""
902+
project = (
903+
self.controller.get_project_by_id(project).data
904+
if isinstance(project, int)
905+
else self.controller.get_project(project)
906+
)
907+
self.controller.check_multimodal_project_categorization(project)
908+
909+
self.controller.work_management.set_remove_contributor_categories(
910+
project=project,
911+
contributors=contributors,
912+
categories=categories,
913+
operation="set",
914+
)
915+
916+
def remove_contributors_categories(
917+
self,
918+
project: Union[NotEmptyStr, int],
919+
contributors: List[Union[int, str]],
920+
categories: Union[List[str], Literal["*"]],
921+
):
922+
"""
923+
Remove one or more categories for a contributor. "*" in the category list will match all categories defined in the project.
924+
925+
:param project: The name or ID of the project.
926+
:type project: Union[NotEmptyStr, int]
927+
928+
:param contributors: A list of emails or IDs of the contributor.
929+
:type contributors: List[Union[int, str]]
930+
931+
:param categories: A list of category names to remove. Accepts "*" to indicate all available categories in the project.
932+
:type categories: Union[List[str], Literal["*"]]
933+
934+
Request Example:
935+
::
936+
937+
client.remove_contributor_categories(
938+
project="product-review-mm",
939+
contributors=["test@superannotate.com","contributor@superannotate.com"],
940+
categories=["Shoes", "T-Shirt", "Jeans"]
941+
)
942+
943+
client.remove_contributor_categories(
944+
project="product-review-mm",
945+
contributors=["test@superannotate.com","contributor@superannotate.com"]
946+
categories="*"
947+
)
948+
"""
949+
project = (
950+
self.controller.get_project_by_id(project).data
951+
if isinstance(project, int)
952+
else self.controller.get_project(project)
953+
)
954+
self.controller.check_multimodal_project_categorization(project)
955+
956+
self.controller.work_management.set_remove_contributor_categories(
957+
project=project,
958+
contributors=contributors,
959+
categories=categories,
960+
operation="remove",
961+
)
962+
863963
def get_component_config(self, project: Union[NotEmptyStr, int], component_id: str):
864964
"""
865965
Retrieves the configuration for a given project and component ID.
@@ -1320,6 +1420,7 @@ def remove_categories(
13201420
)
13211421
self.controller.check_multimodal_project_categorization(project)
13221422

1423+
categories_to_remove = None
13231424
query = EmptyQuery()
13241425
if categories == "*":
13251426
query &= Filter("id", [0], OperatorEnum.GT)
@@ -1335,14 +1436,13 @@ def remove_categories(
13351436
else:
13361437
raise AppException("Categories should be a list of strings or '*'.")
13371438

1338-
response = (
1339-
self.controller.service_provider.work_management.remove_project_categories(
1439+
if categories_to_remove:
1440+
response = self.controller.service_provider.work_management.remove_project_categories(
13401441
project_id=project.id, query=query
13411442
)
1342-
)
1343-
logger.info(
1344-
f"{len(response.data)} categories successfully removed from the project."
1345-
)
1443+
logger.info(
1444+
f"{len(response.data)} categories successfully removed from the project."
1445+
)
13461446

13471447
def create_folder(self, project: NotEmptyStr, folder_name: NotEmptyStr):
13481448
"""

src/superannotate/lib/core/entities/work_managament.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,12 @@ def json(self, **kwargs):
129129
class WMProjectUserEntity(TimedBaseModel):
130130
id: Optional[int]
131131
team_id: Optional[int]
132-
role: int
132+
role: Optional[int]
133133
email: Optional[str]
134134
state: Optional[WMUserStateEnum]
135135
custom_fields: Optional[dict] = Field(dict(), alias="customField")
136136
permissions: Optional[dict]
137+
categories: Optional[list[dict]]
137138

138139
class Config:
139140
extra = Extra.ignore

src/superannotate/lib/core/serviceproviders.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,17 @@ def create_score(
228228
def delete_score(self, score_id: int) -> ServiceResponse:
229229
raise NotImplementedError
230230

231+
@abstractmethod
232+
def set_remove_contributor_categories(
233+
self,
234+
project_id: int,
235+
contributor_ids: List[int],
236+
category_ids: List[int],
237+
operation: Literal["set", "remove"],
238+
chunk_size=100,
239+
) -> list[dict]:
240+
raise NotImplementedError
241+
231242

232243
class BaseProjectService(SuperannotateServiceProvider):
233244
@abstractmethod

src/superannotate/lib/infrastructure/controller.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@
6868
from typing_extensions import Unpack
6969

7070

71+
logger = logging.getLogger("sa")
72+
73+
7174
def build_condition(**kwargs) -> Condition:
7275
condition = Condition.get_empty_condition()
7376
if any(kwargs.values()):
@@ -177,7 +180,10 @@ def set_custom_field_value(
177180
)
178181

179182
def list_users(
180-
self, include: List[Literal["custom_fields"]] = None, project=None, **filters
183+
self,
184+
include: List[Literal["custom_fields", "categories"]] = None,
185+
project=None,
186+
**filters,
181187
):
182188
context = {"team_id": self.service_provider.client.team_id}
183189
if project:
@@ -205,6 +211,10 @@ def list_users(
205211
]
206212
)
207213
query = chain.handle(filters, EmptyQuery())
214+
215+
if project and include and "categories" in include:
216+
query &= Join("categories")
217+
208218
if include and "custom_fields" in include:
209219
response = self.service_provider.work_management.list_users(
210220
query,
@@ -401,6 +411,76 @@ def set_user_scores(
401411
res.res_error = "Please provide valid score values."
402412
res.raise_for_status()
403413

414+
def set_remove_contributor_categories(
415+
self,
416+
project: ProjectEntity,
417+
contributors: List[Union[int, str]],
418+
categories: Union[List[str], Literal["*"]],
419+
operation: Literal["set", "remove"],
420+
):
421+
if categories and contributors:
422+
all_categories = (
423+
self.service_provider.work_management.list_project_categories(
424+
project_id=project.id, entity=ProjectCategoryEntity # noqa
425+
).data
426+
)
427+
if categories == "*":
428+
category_ids = [c.id for c in all_categories]
429+
else:
430+
categories = [c.lower() for c in categories]
431+
category_ids = [
432+
c.id for c in all_categories if c.name.lower() in categories
433+
]
434+
435+
if isinstance(contributors[0], str):
436+
project_contributors = self.list_users(
437+
project=project, email__in=contributors
438+
)
439+
elif isinstance(contributors[0], int):
440+
project_contributors = self.list_users(
441+
project=project, id__in=contributors
442+
)
443+
else:
444+
raise AppException("Contributors not found.")
445+
446+
if len(project_contributors) < len(contributors):
447+
raise AppException("Contributors not found.")
448+
449+
contributor_ids = [
450+
c.id
451+
for c in project_contributors
452+
if c.role != 3 # exclude Project Admins
453+
]
454+
455+
if category_ids and contributor_ids:
456+
response = self.service_provider.work_management.set_remove_contributor_categories(
457+
project_id=project.id,
458+
contributor_ids=contributor_ids,
459+
category_ids=category_ids,
460+
operation=operation,
461+
)
462+
463+
success_processed = 0
464+
for contributor in response:
465+
contributor_category_ids = [
466+
category["id"] for category in contributor["categories"]
467+
]
468+
if operation == "set":
469+
if set(category_ids).issubset(contributor_category_ids):
470+
success_processed += len(category_ids)
471+
else:
472+
if not set(category_ids).intersection(contributor_category_ids):
473+
success_processed += len(category_ids)
474+
475+
if success_processed / len(contributor_ids) == len(category_ids):
476+
action_for_log = (
477+
"added to" if operation == "set" else "removed from"
478+
)
479+
logger.info(
480+
f"{len(category_ids)} categories successfully {action_for_log} "
481+
f"{len(contributor_ids)} contributors."
482+
)
483+
404484

405485
class ProjectManager(BaseManager):
406486
def __init__(self, service_provider: ServiceProvider, team: TeamEntity):

src/superannotate/lib/infrastructure/services/work_management.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from lib.core.entities.work_managament import WMUserEntity
1515
from lib.core.enums import CustomFieldEntityEnum
1616
from lib.core.exceptions import AppException
17+
from lib.core.jsx_conditions import EmptyQuery
1718
from lib.core.jsx_conditions import Filter
1819
from lib.core.jsx_conditions import OperatorEnum
1920
from lib.core.jsx_conditions import Query
@@ -71,6 +72,7 @@ class WorkManagementService(BaseWorkManagementService):
7172
URL_SEARCH_PROJECT_USERS = "projectusers/search"
7273
URL_SEARCH_PROJECTS = "projects/search"
7374
URL_RESUME_PAUSE_USER = "teams/editprojectsusers"
75+
URL_CONTRIBUTORS_CATEGORIES = "customentities/edit"
7476

7577
@staticmethod
7678
def _generate_context(**kwargs):
@@ -475,3 +477,46 @@ def delete_score(self, score_id: int) -> ServiceResponse:
475477
),
476478
},
477479
)
480+
481+
def set_remove_contributor_categories(
482+
self,
483+
project_id: int,
484+
contributor_ids: List[int],
485+
category_ids: List[int],
486+
operation: Literal["set", "remove"],
487+
chunk_size=100,
488+
) -> List[dict]:
489+
params = {
490+
"entity": "Contributor",
491+
"parentEntity": "Project",
492+
}
493+
if operation == "set":
494+
params["action"] = "addcontributorcategory"
495+
else:
496+
params["action"] = "removecontributorcategory"
497+
498+
from lib.infrastructure.utils import divide_to_chunks
499+
500+
success_contributors = []
501+
502+
for chunk in divide_to_chunks(contributor_ids, chunk_size):
503+
body_query = EmptyQuery()
504+
body_query &= Filter("id", chunk, OperatorEnum.IN)
505+
response = self.client.request(
506+
url=self.URL_CONTRIBUTORS_CATEGORIES,
507+
method="post",
508+
params=params,
509+
data={
510+
"query": body_query.body_builder(),
511+
"body": {"categories": [{"id": i} for i in category_ids]},
512+
},
513+
headers={
514+
"x-sa-entity-context": self._generate_context(
515+
team_id=self.client.team_id, project_id=project_id
516+
),
517+
},
518+
)
519+
response.raise_for_status()
520+
success_contributors.extend(response.data["data"])
521+
522+
return success_contributors

0 commit comments

Comments
 (0)