diff --git a/label_studio/projects/api.py b/label_studio/projects/api.py index e70c51c7f892..fe896e243862 100644 --- a/label_studio/projects/api.py +++ b/label_studio/projects/api.py @@ -15,7 +15,7 @@ from data_manager.functions import filters_ordering_selected_items_exist, get_prepared_queryset from django.conf import settings from django.db import IntegrityError -from django.db.models import F +from django.db.models import Case, When, IntegerField, F, Q,Value, OuterRef, Subquery, Exists from django.http import Http404 from django.utils.decorators import method_decorator from django_filters import CharFilter, FilterSet @@ -27,7 +27,8 @@ from projects.functions.next_task import get_next_task from projects.functions.stream_history import get_label_stream_history from projects.functions.utils import recalculate_created_annotations_and_labels_from_scratch -from projects.models import Project, ProjectImport, ProjectManager, ProjectReimport, ProjectSummary +from projects.models import Project, ProjectImport, ProjectManager, ProjectReimport, ProjectSummary, ProjectMember +from users.models import User from projects.serializers import ( GetFieldsSerializer, ProjectCountsSerializer, @@ -37,6 +38,7 @@ ProjectReimportSerializer, ProjectSerializer, ProjectSummarySerializer, + ProjectContributorSerializer, ) from rest_framework import filters, generics, status from rest_framework.exceptions import NotFound @@ -247,6 +249,15 @@ def get_queryset(self): projects = Project.objects.filter(organization=self.request.user.active_organization).order_by( F('pinned_at').desc(nulls_last=True), '-created_at' ) + + # Special handling for Contributor users - only show projects they are members of + if self.request.user.user_type == "Contributor": + projects = projects.filter( + id__in=ProjectMember.objects.filter( + user=self.request.user.id + ).values_list('project_id', flat=True) + ) + if filter in ['pinned_only', 'exclude_pinned']: projects = projects.filter(pinned_at__isnull=filter == 'exclude_pinned') return ProjectManager.with_counts_annotate(projects, fields=fields).prefetch_related('members', 'created_by') @@ -904,3 +915,209 @@ def delete(self, request, *args, **kwargs): count = project.delete_predictions(model_version=model_version) return Response(data=count) + + +@method_decorator( + name='get', + decorator=extend_schema( + tags=['Projects'], + summary='List project contributors', + description='Get all users with user_type "Contributor", left join with projects_projectmember table, return user_id, email and joined status with pagination', + parameters=[ + OpenApiParameter( + name='pk', + type=OpenApiTypes.INT, + location='path', + description='Project ID to check if user is joined', + required=True, + ), + OpenApiParameter( + name='keyword', + type=OpenApiTypes.STR, + location='query', + description='Keyword to filter contributors by email (case-insensitive)', + required=False, + ), + ], + extensions={ + 'x-fern-sdk-group-name': 'projects', + 'x-fern-sdk-method-name': 'list_contributors', + 'x-fern-audiences': ['public'], + }, + ), +) +@method_decorator( + name='post', + decorator=extend_schema( + tags=['Projects'], + summary='Add or remove project contributor', + description='Add or remove a user with user_type "Contributor" to/from a project', + parameters=[ + OpenApiParameter( + name='pk', + type=OpenApiTypes.INT, + location='path', + description='Project ID', + required=True, + ), + ], + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'action': { + 'type': 'string', + 'enum': ['add', 'remove'], + 'description': 'Action to perform: add or remove contributor' + }, + 'email': { + 'type': 'string', + 'format': 'email', + 'description': 'Email address of the contributor' + } + }, + 'required': ['action', 'email'] + } + }, + responses={ + 201: OpenApiResponse( + description='Contributor successfully added to project', + examples=[ + { + 'application/json': { + 'message': 'User example@email.com successfully added to project', + 'user_id': 123, + 'email': 'example@email.com' + } + } + ] + ), + 200: OpenApiResponse( + description='Contributor successfully removed from project', + examples=[ + { + 'application/json': { + 'message': 'User example@email.com successfully removed from project', + 'user_id': 123, + 'email': 'example@email.com' + } + } + ] + ), + 400: OpenApiResponse( + description='Bad request - validation error', + examples=[ + { + 'application/json': { + 'detail': 'Both action and email are required' + } + } + ] + ) + }, + extensions={ + 'x-fern-sdk-group-name': 'projects', + 'x-fern-sdk-method-name': 'add_remove_contributor', + 'x-fern-audiences': ['public'], + }, + ), +) +class ProjectContributorListAPI(generics.ListCreateAPIView): + """API to get all users with user_type 'Contributor' and their project membership status with pagination""" + serializer_class = ProjectContributorSerializer + permission_required = ViewClassPermission( + GET=all_permissions.projects_view, + POST=all_permissions.projects_change, + ) + pagination_class = ProjectListPagination + + def get_queryset(self): + # Get project_id from URL path parameter (pk) + project_id = self.kwargs.get('pk') + if not project_id: + raise RestValidationError('Project ID is required') + + # Get keyword parameter for email filtering + keyword = self.request.query_params.get('keyword', '').strip() + + # Get all users with user_type "Contributor" + contributors = User.objects.filter(user_type='Contributor') + + # Apply keyword filter if provided + if keyword: + contributors = contributors.filter(email__icontains=keyword) + + # Left join with projects_projectmember table to check if user is joined to the project + joined_subquery = ProjectMember.objects.filter( + user=OuterRef('pk'), + project_id=project_id + ) + + contributors = contributors.annotate( + joined=Case( + When(Exists(joined_subquery), then=Value(1)), + default=Value(0), + output_field=IntegerField() + ) + ) + + # Order by email ascending and distinct + return contributors.values('id', 'email', 'joined').order_by('email').distinct() + + def post(self, request, *args, **kwargs): + """Handle add/remove contributor operations""" + action = request.data.get('action') + email = request.data.get('email') + project_id = self.kwargs.get('pk') + + if not action or not email: + raise RestValidationError('Both action and email are required') + + if action not in ['add', 'remove']: + raise RestValidationError('Action must be either "add" or "remove"') + + try: + # Get user by email + try: + user = User.objects.get(email=email, user_type='Contributor') + except User.DoesNotExist: + raise RestValidationError(f'User with email {email} not found or is not a Contributor') + + if action == 'add': + # Check if user is already a member + if ProjectMember.objects.filter(user=user, project_id=project_id).exists(): + raise RestValidationError(f'User {email} is already a member of this project') + + # Add user to project + ProjectMember.objects.create( + user=user, + project_id=project_id, + enabled=True + ) + + return Response({ + 'message': f'User {email} successfully added to project', + 'user_id': user.id, + 'email': email + }, status=status.HTTP_201_CREATED) + + elif action == 'remove': + # Check if user is a member + try: + project_member = ProjectMember.objects.get(user=user, project_id=project_id) + project_member.delete() + + return Response({ + 'message': f'User {email} successfully removed from project', + 'user_id': user.id, + 'email': email + }, status=status.HTTP_200_OK) + + except ProjectMember.DoesNotExist: + raise RestValidationError(f'User {email} is not a member of this project') + + except RestValidationError: + raise + except Exception as e: + logger.error(f'Error in ProjectContributorListAPI.post: {str(e)}') + raise RestValidationError(f'An error occurred while processing the request: {str(e)}') diff --git a/label_studio/projects/serializers.py b/label_studio/projects/serializers.py index 49e80847d093..b34819e03afa 100644 --- a/label_studio/projects/serializers.py +++ b/label_studio/projects/serializers.py @@ -409,3 +409,10 @@ def validate_include(self, value): def validate_filter(self, value): if value in ['all', 'pinned_only', 'exclude_pinned']: return value + + +class ProjectContributorSerializer(serializers.Serializer): + """Serializer for project contributors with user_id, email and joined fields""" + user_id = serializers.IntegerField(source='id', help_text='User ID') + email = serializers.EmailField(help_text='User email address') + joined = serializers.IntegerField(help_text='Whether user is joined to the project (1) or not (0)') diff --git a/label_studio/projects/urls.py b/label_studio/projects/urls.py index 0db84b70ee4d..8162a82df8dd 100644 --- a/label_studio/projects/urls.py +++ b/label_studio/projects/urls.py @@ -45,6 +45,8 @@ path('/sample-task/', api.ProjectSampleTask.as_view(), name='project-sample-task'), # List available model versions path('/model-versions/', api.ProjectModelVersions.as_view(), name='project-model-versions'), + # List project contributors + path('/contributors/', api.ProjectContributorListAPI.as_view(), name='project-contributors-list'), ] _api_urlpatterns_templates = [ diff --git a/web/apps/labelstudio/src/config/ApiConfig.js b/web/apps/labelstudio/src/config/ApiConfig.js index 62c8f95cb572..7d94a924e1bb 100644 --- a/web/apps/labelstudio/src/config/ApiConfig.js +++ b/web/apps/labelstudio/src/config/ApiConfig.js @@ -23,6 +23,9 @@ export const API_CONFIG = { createProject: "POST:/projects", deleteProject: "DELETE:/projects/:pk", projectResetCache: "POST:/projects/:pk/summary/reset", + projectContributors: "/projects/:pk/contributors", + addProjectContributor: "POST:/projects/:pk/contributors", + removeProjectContributor: "POST:/projects/:pk/contributors", // Presigning presignUrlForTask: "/../tasks/:taskID/presign", diff --git a/web/apps/labelstudio/src/pages/Home/HomePage.tsx b/web/apps/labelstudio/src/pages/Home/HomePage.tsx index 5ac087ddcb82..58c00980e001 100644 --- a/web/apps/labelstudio/src/pages/Home/HomePage.tsx +++ b/web/apps/labelstudio/src/pages/Home/HomePage.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import { HeidiTips } from "../../components/HeidiTips/HeidiTips"; import { useAPI } from "../../providers/ApiProvider"; +import { useCurrentUser } from "../../providers/CurrentUser"; import { CreateProject } from "../CreateProject/CreateProject"; import { InviteLink } from "../Organization/PeoplePage/InviteLink"; import type { Page } from "../types/Page"; @@ -51,6 +52,7 @@ type Action = (typeof actions)[number]["type"]; export const HomePage: Page = () => { const api = useAPI(); + const { user } = useCurrentUser(); const [creationDialogOpen, setCreationDialogOpen] = useState(false); const [invitationOpen, setInvitationOpen] = useState(false); const { data, isFetching, isSuccess, isError } = useQuery({ @@ -81,7 +83,7 @@ export const HomePage: Page = () => {
- Welcome đź‘‹ + Welcomeđź‘‹ Let's get you started. @@ -89,6 +91,10 @@ export const HomePage: Page = () => {
{actions.map((action) => { + if (action.type === "createProject" && user?.user_type === "Contributor") { + return null; + } + return (