"""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.
|
"""
|
import logging
|
|
from core.feature_flags import flag_set
|
from core.mixins import GetParentObjectMixin
|
from core.utils.common import load_func
|
from django.conf import settings
|
from django.urls import reverse
|
from django.utils.decorators import method_decorator
|
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from organizations.models import Organization, OrganizationMember
|
from organizations.serializers import (
|
OrganizationIdSerializer,
|
OrganizationInviteSerializer,
|
OrganizationMemberListParamsSerializer,
|
OrganizationMemberListSerializer,
|
OrganizationMemberSerializer,
|
OrganizationSerializer,
|
)
|
from projects.models import Project
|
from rest_framework import generics, status
|
from rest_framework.exceptions import NotFound, PermissionDenied
|
from rest_framework.generics import get_object_or_404
|
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.response import Response
|
from rest_framework.settings import api_settings
|
from rest_framework.views import APIView
|
from tasks.models import Annotation
|
from users.models import User
|
|
from label_studio.core.permissions import ViewClassPermission, all_permissions
|
from label_studio.core.utils.params import bool_from_request
|
|
logger = logging.getLogger(__name__)
|
|
HasObjectPermission = load_func(settings.MEMBER_PERM)
|
|
|
@method_decorator(
|
name='get',
|
decorator=extend_schema(
|
tags=['Organizations'],
|
summary='List your organizations',
|
description="""
|
Return a list of the organizations you've created or that you have access to.
|
""",
|
extensions={
|
'x-fern-sdk-group-name': 'organizations',
|
'x-fern-sdk-method-name': 'list',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
class OrganizationListAPI(generics.ListCreateAPIView):
|
queryset = Organization.objects.all()
|
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
permission_required = ViewClassPermission(
|
GET=all_permissions.organizations_view,
|
PUT=all_permissions.organizations_change,
|
POST=all_permissions.organizations_create,
|
PATCH=all_permissions.organizations_change,
|
DELETE=all_permissions.organizations_change,
|
)
|
serializer_class = OrganizationIdSerializer
|
|
def filter_queryset(self, queryset):
|
return queryset.filter(
|
organizationmember__in=self.request.user.om_through.filter(deleted_at__isnull=True)
|
).distinct()
|
|
def get(self, request, *args, **kwargs):
|
return super(OrganizationListAPI, self).get(request, *args, **kwargs)
|
|
@extend_schema(exclude=True)
|
def post(self, request, *args, **kwargs):
|
return super(OrganizationListAPI, self).post(request, *args, **kwargs)
|
|
|
class OrganizationMemberListPagination(PageNumberPagination):
|
page_size = 20
|
page_size_query_param = 'page_size'
|
|
def get_page_size(self, request):
|
# emulate "unlimited" page_size
|
if (
|
self.page_size_query_param in request.query_params
|
and request.query_params[self.page_size_query_param] == '-1'
|
):
|
return 1000000
|
return super().get_page_size(request)
|
|
|
@method_decorator(
|
name='get',
|
decorator=extend_schema(
|
tags=['Organizations'],
|
summary='Get organization members list',
|
description='Retrieve a list of the organization members and their IDs.',
|
extensions={
|
'x-fern-sdk-group-name': ['organizations', 'members'],
|
'x-fern-sdk-method-name': 'list',
|
'x-fern-audiences': ['public'],
|
'x-fern-pagination': {
|
'offset': '$request.page',
|
'results': '$response.results',
|
},
|
},
|
),
|
)
|
class OrganizationMemberListAPI(generics.ListAPIView):
|
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
permission_required = ViewClassPermission(
|
GET=all_permissions.organizations_view,
|
PUT=all_permissions.organizations_change,
|
PATCH=all_permissions.organizations_change,
|
DELETE=all_permissions.organizations_change,
|
)
|
serializer_class = OrganizationMemberListSerializer
|
pagination_class = OrganizationMemberListPagination
|
|
def _get_created_projects_map(self):
|
members = self.paginate_queryset(self.filter_queryset(self.get_queryset()))
|
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)
|
.values('created_by_id', 'id', 'title')
|
.distinct()
|
)
|
projects_map = {}
|
for project in projects:
|
projects_map.setdefault(project['created_by_id'], []).append(
|
{
|
'id': project['id'],
|
'title': project['title'],
|
}
|
)
|
return projects_map
|
|
def _get_contributed_to_projects_map(self):
|
members = self.paginate_queryset(self.filter_queryset(self.get_queryset()))
|
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
|
)
|
annotations = (
|
Annotation.objects.filter(completed_by__in=list(user_ids), project__in=list(org_project_ids))
|
.values('completed_by', 'project_id')
|
.distinct()
|
)
|
project_ids = [annotation['project_id'] for annotation in annotations]
|
projects_map = Project.objects.in_bulk(id_list=project_ids, field_name='id')
|
|
contributed_to_projects_map = {}
|
for annotation in annotations:
|
project = projects_map[annotation['project_id']]
|
contributed_to_projects_map.setdefault(annotation['completed_by'], []).append(
|
{
|
'id': project.id,
|
'title': project.title,
|
}
|
)
|
return contributed_to_projects_map
|
|
def get_serializer_context(self):
|
context = super().get_serializer_context()
|
contributed_to_projects = bool_from_request(self.request.GET, 'contributed_to_projects', False)
|
return {
|
'contributed_to_projects': contributed_to_projects,
|
'created_projects_map': self._get_created_projects_map() if contributed_to_projects else None,
|
'contributed_to_projects_map': self._get_contributed_to_projects_map()
|
if contributed_to_projects
|
else None,
|
**context,
|
}
|
|
def get_queryset(self):
|
org = generics.get_object_or_404(self.request.user.organizations, pk=self.kwargs[self.lookup_field])
|
if flag_set('fix_backend_dev_3134_exclude_deactivated_users', self.request.user):
|
serializer = OrganizationMemberListParamsSerializer(data=self.request.GET)
|
serializer.is_valid(raise_exception=True)
|
active = serializer.validated_data.get('active')
|
|
# return only active users (exclude DISABLED and NOT_ACTIVATED)
|
if active:
|
return org.active_members.prefetch_related('user__om_through').order_by('user__username')
|
|
# organization page to show all members
|
return org.members.prefetch_related('user__om_through').order_by('user__username')
|
else:
|
return org.members.prefetch_related('user__om_through').order_by('user__username')
|
|
|
@method_decorator(
|
name='get',
|
decorator=extend_schema(
|
tags=['Organizations'],
|
summary='Get organization member details',
|
description='Get organization member details by user ID.',
|
parameters=[
|
OpenApiParameter(
|
name='user_pk',
|
type=OpenApiTypes.INT,
|
location='path',
|
description='A unique integer value identifying the user to get organization details for.',
|
),
|
],
|
responses={200: OrganizationMemberSerializer()},
|
extensions={
|
'x-fern-sdk-group-name': ['organizations', 'members'],
|
'x-fern-sdk-method-name': 'get',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
@method_decorator(
|
name='delete',
|
decorator=extend_schema(
|
tags=['Organizations'],
|
summary='Soft delete an organization member',
|
description='Soft delete a member from the organization.',
|
parameters=[
|
OpenApiParameter(
|
name='user_pk',
|
type=OpenApiTypes.INT,
|
location='path',
|
description='A unique integer value identifying the user to be deleted from the organization.',
|
),
|
],
|
responses={
|
204: OpenApiResponse(description='Member deleted successfully.'),
|
405: OpenApiResponse(description='User cannot soft delete self.'),
|
404: OpenApiResponse(description='Member not found'),
|
403: OpenApiResponse(description='You can delete members only for your current active organization'),
|
},
|
extensions={
|
'x-fern-sdk-group-name': ['organizations', 'members'],
|
'x-fern-sdk-method-name': 'delete',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
class OrganizationMemberDetailAPI(GetParentObjectMixin, generics.RetrieveDestroyAPIView):
|
permission_required = ViewClassPermission(
|
GET=all_permissions.organizations_view,
|
DELETE=all_permissions.organizations_change,
|
)
|
parent_queryset = Organization.objects.all()
|
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
serializer_class = OrganizationMemberSerializer
|
http_method_names = ['delete', 'get']
|
|
@property
|
def permission_classes(self):
|
if self.request.method == 'DELETE':
|
return [IsAuthenticated, HasObjectPermission]
|
return api_settings.DEFAULT_PERMISSION_CLASSES
|
|
def get_queryset(self):
|
return OrganizationMember.objects.filter(organization=self.parent_object)
|
|
def get_serializer_context(self):
|
return {
|
**super().get_serializer_context(),
|
'organization': self.parent_object,
|
}
|
|
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)
|
self.check_object_permissions(request, member)
|
serializer = self.get_serializer(member)
|
return Response(serializer.data)
|
|
def delete(self, request, pk=None, user_pk=None):
|
org = self.parent_object
|
if org != request.user.active_organization:
|
raise PermissionDenied('You can delete members only for your current active organization')
|
|
user = get_object_or_404(User, pk=user_pk)
|
member = get_object_or_404(OrganizationMember, user=user, organization=org)
|
if member.deleted_at is not None:
|
raise NotFound('Member not found')
|
|
if member.user_id == request.user.id:
|
return Response({'detail': 'User cannot soft delete self'}, status=status.HTTP_405_METHOD_NOT_ALLOWED)
|
|
member.soft_delete()
|
return Response(status=204) # 204 No Content is a common HTTP status for successful delete requests
|
|
|
@method_decorator(
|
name='get',
|
decorator=extend_schema(
|
tags=['Organizations'],
|
summary='Get organization settings',
|
description='Retrieve the settings for a specific organization by ID.',
|
extensions={
|
'x-fern-sdk-group-name': 'organizations',
|
'x-fern-sdk-method-name': 'get',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
@method_decorator(
|
name='patch',
|
decorator=extend_schema(
|
tags=['Organizations'],
|
summary='Update organization settings',
|
description='Update the settings for a specific organization by ID.',
|
extensions={
|
'x-fern-sdk-group-name': 'organizations',
|
'x-fern-sdk-method-name': 'update',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
class OrganizationAPI(generics.RetrieveUpdateAPIView):
|
|
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
queryset = Organization.objects.all()
|
permission_required = all_permissions.organizations_change
|
serializer_class = OrganizationSerializer
|
|
redirect_route = 'organizations-dashboard'
|
redirect_kwarg = 'pk'
|
|
def get(self, request, *args, **kwargs):
|
return super(OrganizationAPI, self).get(request, *args, **kwargs)
|
|
def patch(self, request, *args, **kwargs):
|
return super(OrganizationAPI, self).patch(request, *args, **kwargs)
|
|
@extend_schema(exclude=True)
|
def put(self, request, *args, **kwargs):
|
return super(OrganizationAPI, self).put(request, *args, **kwargs)
|
|
|
@method_decorator(
|
name='get',
|
decorator=extend_schema(
|
tags=['Invites'],
|
summary='Get organization invite link',
|
description='Get a link to use to invite a new member to an organization in Label Studio Enterprise.',
|
responses={200: OrganizationInviteSerializer()},
|
extensions={
|
'x-fern-sdk-group-name': 'organizations',
|
'x-fern-sdk-method-name': 'get_invite',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
class OrganizationInviteAPI(generics.RetrieveAPIView):
|
parser_classes = (JSONParser,)
|
queryset = Organization.objects.all()
|
permission_required = all_permissions.organizations_invite
|
|
def get(self, request, *args, **kwargs):
|
org = request.user.active_organization
|
invite_url = '{}?token={}'.format(reverse('user-signup'), org.token)
|
if hasattr(settings, 'FORCE_SCRIPT_NAME') and settings.FORCE_SCRIPT_NAME:
|
invite_url = invite_url.replace(settings.FORCE_SCRIPT_NAME, '', 1)
|
serializer = OrganizationInviteSerializer(data={'invite_url': invite_url, 'token': org.token})
|
serializer.is_valid()
|
return Response(serializer.data, status=200)
|
|
|
@method_decorator(
|
name='post',
|
decorator=extend_schema(
|
tags=['Invites'],
|
summary='Reset organization token',
|
description='Reset the token used in the invitation link to invite someone to an organization.',
|
responses={200: OrganizationInviteSerializer()},
|
extensions={
|
'x-fern-sdk-group-name': 'organizations',
|
'x-fern-sdk-method-name': 'reset_token',
|
'x-fern-audiences': ['public'],
|
},
|
),
|
)
|
class OrganizationResetTokenAPI(APIView):
|
permission_required = all_permissions.organizations_invite
|
parser_classes = (JSONParser,)
|
|
def post(self, request, *args, **kwargs):
|
org = request.user.active_organization
|
org.reset_token()
|
logger.debug(f'New token for organization {org.pk} is {org.token}')
|
invite_url = '{}?token={}'.format(reverse('user-signup'), org.token)
|
serializer = OrganizationInviteSerializer(data={'invite_url': invite_url, 'token': org.token})
|
serializer.is_valid()
|
return Response(serializer.data, status=201)
|