Skip to content
Open
34 changes: 29 additions & 5 deletions label_studio/organizations/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from django.conf import settings
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from organizations.models import Organization, OrganizationMember
Expand Down Expand Up @@ -100,6 +101,14 @@ def get_page_size(self, request):
tags=['Organizations'],
summary='Get organization members list',
description='Retrieve a list of the organization members and their IDs.',
parameters=[
OpenApiParameter(
name='contributed_to_projects',
type=OpenApiTypes.BOOL,
location='query',
description='Whether to include projects created and contributed to by the members.',
),
],
extensions={
'x-fern-sdk-group-name': ['organizations', 'members'],
'x-fern-sdk-method-name': 'list',
Expand All @@ -122,8 +131,12 @@ class OrganizationMemberListAPI(generics.ListAPIView):
serializer_class = OrganizationMemberListSerializer
pagination_class = OrganizationMemberListPagination

@cached_property
def paginated_members(self):
return self.paginate_queryset(self.filter_queryset(self.get_queryset()))

def _get_created_projects_map(self):
members = self.paginate_queryset(self.filter_queryset(self.get_queryset()))
members = self.paginated_members
user_ids = [member.user_id for member in members]
projects = (
Project.objects.filter(created_by_id__in=user_ids, organization=self.request.user.active_organization)
Expand All @@ -141,7 +154,7 @@ def _get_created_projects_map(self):
return projects_map

def _get_contributed_to_projects_map(self):
members = self.paginate_queryset(self.filter_queryset(self.get_queryset()))
members = self.paginated_members
user_ids = [member.user_id for member in members]
org_project_ids = Project.objects.filter(organization=self.request.user.active_organization).values_list(
'id', flat=True
Expand Down Expand Up @@ -193,6 +206,11 @@ def get_queryset(self):
else:
return org.members.prefetch_related('user__om_through').order_by('user__username')

def list(self, request, *args, **kwargs):
page = self.paginated_members # Using cached property to avoid multiple queries
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)


@method_decorator(
name='get',
Expand All @@ -207,6 +225,12 @@ def get_queryset(self):
location='path',
description='A unique integer value identifying the user to get organization details for.',
),
OpenApiParameter(
name='contributed_to_projects',
type=OpenApiTypes.BOOL,
location='query',
description='Whether to include projects created and contributed to by the member.',
),
],
responses={200: OrganizationMemberSerializer()},
extensions={
Expand Down Expand Up @@ -260,18 +284,18 @@ def permission_classes(self):
return api_settings.DEFAULT_PERMISSION_CLASSES

def get_queryset(self):
return OrganizationMember.objects.filter(organization=self.parent_object)
return OrganizationMember.objects.filter(organization=self.parent_object).select_related('user')

def get_serializer_context(self):
return {
**super().get_serializer_context(),
'organization': self.parent_object,
'contributed_to_projects': bool_from_request(self.request.GET, 'contributed_to_projects', False),
}

def get(self, request, pk, user_pk):
queryset = self.get_queryset()
user = get_object_or_404(User, pk=user_pk)
member = get_object_or_404(queryset, user=user)
member = get_object_or_404(queryset, user=user_pk)
self.check_object_permissions(request, member)
serializer = self.get_serializer(member)
return Response(serializer.data)
Expand Down
87 changes: 80 additions & 7 deletions label_studio/organizations/serializers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""This file and its contents are licensed under the Apache License 2.0. Please see the included NOTICE for copyright information and LICENSE for a copy of the license.
"""

from typing import TypedDict

from drf_dynamic_fields import DynamicFieldsMixin
from drf_spectacular.utils import extend_schema_serializer
from organizations.models import Organization, OrganizationMember
from projects.models import Project
from rest_framework import serializers
from tasks.models import Annotation
from users.serializers import UserSerializer


Expand All @@ -21,25 +26,37 @@ class Meta:

# =========================================
# OrganizationMemberListAPI
# OrganizationMemberDetailAPI
# =========================================


class ProjectInfo(TypedDict):
id: int
title: str


class OrganizationMemberListParamsSerializer(serializers.Serializer):
active = serializers.BooleanField(required=False, default=False)
contributed_to_projects = serializers.BooleanField(required=False, default=False)


@extend_schema_serializer(
deprecate_fields=[
'created_projects',
'contributed_to_projects',
]
)
class UserOrganizationMemberListSerializer(UserSerializer):
created_projects = serializers.SerializerMethodField(read_only=True)
contributed_to_projects = serializers.SerializerMethodField(read_only=True)

def get_created_projects(self, user):
def get_created_projects(self, user) -> list[ProjectInfo] | None:
if not self.context.get('contributed_to_projects', False):
return None
created_projects_map = self.context.get('created_projects_map', {})
return created_projects_map.get(user.id, [])

def get_contributed_to_projects(self, user):
def get_contributed_to_projects(self, user) -> list[ProjectInfo] | None:
if not self.context.get('contributed_to_projects', False):
return None
contributed_to_projects_map = self.context.get('contributed_to_projects_map', {})
Expand All @@ -51,30 +68,86 @@ class Meta(UserSerializer.Meta):

class OrganizationMemberListSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
user = UserOrganizationMemberListSerializer()
created_projects = serializers.SerializerMethodField(read_only=True)
contributed_to_projects = serializers.SerializerMethodField(read_only=True)

class Meta:
model = OrganizationMember
fields = ['id', 'organization', 'user']
fields = ['id', 'organization', 'user', 'created_projects', 'contributed_to_projects']

def get_created_projects(self, member) -> list[ProjectInfo] | None:
if not self.context.get('contributed_to_projects', False):
return None
created_projects_map = self.context.get('created_projects_map', {})
return created_projects_map.get(member.user.id, [])

# =========================================
def get_contributed_to_projects(self, member) -> list[ProjectInfo] | None:
if not self.context.get('contributed_to_projects', False):
return None
contributed_to_projects_map = self.context.get('contributed_to_projects_map', {})
return contributed_to_projects_map.get(member.user.id, [])


class OrganizationMemberSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
annotations_count = serializers.SerializerMethodField(read_only=True)
contributed_projects_count = serializers.SerializerMethodField(read_only=True)
created_projects = serializers.SerializerMethodField(read_only=True)
contributed_to_projects = serializers.SerializerMethodField(read_only=True)

def get_annotations_count(self, member):
def get_annotations_count(self, member) -> int:
org = self.context.get('organization')
return member.user.annotations.filter(project__organization=org).count()

def get_contributed_projects_count(self, member):
def get_contributed_projects_count(self, member) -> int:
org = self.context.get('organization')
return member.user.annotations.filter(project__organization=org).values('project').distinct().count()

def get_created_projects(self, member) -> list[ProjectInfo] | None:
if not self.context.get('contributed_to_projects', False):
return None
organization = self.context.get('organization')
projects = Project.objects.filter(created_by=member.user, organization=organization).values('id', 'title')
projects = projects[:100] # Limit to 100 projects
return [
{
'id': project['id'],
'title': project['title'],
}
for project in projects
]

def get_contributed_to_projects(self, member) -> list[ProjectInfo] | None:
if not self.context.get('contributed_to_projects', False):
return None
organization = self.context.get('organization')
annotations = (
Annotation.objects.filter(completed_by=member.user, project__organization=organization)
.values('project__id', 'project__title')
.distinct()
)
annotations = annotations[:100] # Limit to 100 projects
return [
{
'id': annotation['project__id'],
'title': annotation['project__title'],
}
for annotation in annotations
]

class Meta:
model = OrganizationMember
fields = ['user', 'organization', 'contributed_projects_count', 'annotations_count', 'created_at']
fields = [
'user',
'organization',
'contributed_projects_count',
'annotations_count',
'created_at',
'created_projects',
'contributed_to_projects',
]


# =========================================


class OrganizationInviteSerializer(serializers.Serializer):
Expand Down
40 changes: 37 additions & 3 deletions label_studio/organizations/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,19 @@ def test_list_organization_members(self):
assert owner['user']['id'] == self.owner.id
assert owner['user']['created_projects'] is None
assert owner['user']['contributed_to_projects'] is None
assert owner['contributed_to_projects'] is None

user_1 = response.json()['results'][1]
assert user_1['user']['id'] == self.user_1.id
assert user_1['user']['created_projects'] is None
assert user_1['user']['contributed_to_projects'] is None
assert user_1['contributed_to_projects'] is None

user_2 = response.json()['results'][2]
assert user_2['user']['id'] == self.user_2.id
assert user_2['user']['created_projects'] is None
assert user_2['user']['contributed_to_projects'] is None
assert user_2['contributed_to_projects'] is None

def test_list_with_contributed_to_projects(self):
project_1 = ProjectFactory(created_by=self.user_1, organization=self.organization)
Expand All @@ -55,36 +58,67 @@ def test_list_with_contributed_to_projects(self):
assert response.status_code == 200
assert len(response.json()['results']) == 3

owner = response.json()['results'][0]['user']
owner = response.json()['results'][0]
assert owner['user']['created_projects'] == []
assert owner['created_projects'] == []
assert owner['user']['contributed_to_projects'] == [
{
'id': project_2.id,
'title': project_2.title,
}
]
assert owner['contributed_to_projects'] == [
{
'id': project_2.id,
'title': project_2.title,
}
]

user_1 = response.json()['results'][1]['user']
user_1 = response.json()['results'][1]
assert user_1['user']['contributed_to_projects'] == [
{
'id': project_1.id,
'title': project_1.title,
}
]
assert user_1['contributed_to_projects'] == [
{
'id': project_1.id,
'title': project_1.title,
}
]
assert user_1['user']['created_projects'] == [
{
'id': project_1.id,
'title': project_1.title,
}
]
assert user_1['created_projects'] == [
{
'id': project_1.id,
'title': project_1.title,
}
]

user_2 = response.json()['results'][2]['user']
user_2 = response.json()['results'][2]
assert user_2['user']['contributed_to_projects'] == [
{
'id': project_2.id,
'title': project_2.title,
}
]
assert user_2['contributed_to_projects'] == [
{
'id': project_2.id,
'title': project_2.title,
}
]
assert user_2['user']['created_projects'] == [
{
'id': project_2.id,
'title': project_2.title,
}
]
assert user_2['created_projects'] == [
{
'id': project_2.id,
Expand Down
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ dependencies = [
"tldextract (>=5.1.3)",
"uuid-utils (>=0.11.0,<1.0.0)",
## HumanSignal repo dependencies :start
"label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/04ae19229ddcd36ee7de2194023af2dcb9c2a110.zip",
"label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/9c4849589314c7288665cc48d83f9a5124eda65c.zip",
## HumanSignal repo dependencies :end
]

Expand Down
Loading