Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 219 additions & 2 deletions label_studio/projects/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -37,6 +38,7 @@
ProjectReimportSerializer,
ProjectSerializer,
ProjectSummarySerializer,
ProjectContributorSerializer,
)
from rest_framework import filters, generics, status
from rest_framework.exceptions import NotFound
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 [email protected] successfully added to project',
'user_id': 123,
'email': '[email protected]'
}
}
]
),
200: OpenApiResponse(
description='Contributor successfully removed from project',
examples=[
{
'application/json': {
'message': 'User [email protected] successfully removed from project',
'user_id': 123,
'email': '[email protected]'
}
}
]
),
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)}')
7 changes: 7 additions & 0 deletions label_studio/projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)')
2 changes: 2 additions & 0 deletions label_studio/projects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
path('<int:pk>/sample-task/', api.ProjectSampleTask.as_view(), name='project-sample-task'),
# List available model versions
path('<int:pk>/model-versions/', api.ProjectModelVersions.as_view(), name='project-model-versions'),
# List project contributors
path('<int:pk>/contributors/', api.ProjectContributorListAPI.as_view(), name='project-contributors-list'),
]

_api_urlpatterns_templates = [
Expand Down
3 changes: 3 additions & 0 deletions web/apps/labelstudio/src/config/ApiConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion web/apps/labelstudio/src/pages/Home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -81,14 +83,18 @@ export const HomePage: Page = () => {
<section className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<Typography variant="headline" size="small">
Welcome 👋
Welcome👋
</Typography>
<Typography size="small" className="text-neutral-content-subtler">
Let's get you started.
</Typography>
</div>
<div className="flex justify-start gap-4">
{actions.map((action) => {
if (action.type === "createProject" && user?.user_type === "Contributor") {
return null;
}

return (
<Button
key={action.title}
Expand Down
8 changes: 7 additions & 1 deletion web/apps/labelstudio/src/pages/Projects/Projects.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Oneof } from "../../components/Oneof/Oneof";
import { Spinner } from "../../components/Spinner/Spinner";
import { ApiContext } from "../../providers/ApiProvider";
import { useContextProps } from "../../providers/RoutesProvider";
import { useCurrentUser } from "../../providers/CurrentUser";
import { Block, Elem } from "../../utils/bem";
import { CreateProject } from "../CreateProject/CreateProject";
import { DataManagerPage } from "../DataManager/DataManager";
Expand All @@ -22,6 +23,7 @@ const getCurrentPage = () => {

export const ProjectsPage = () => {
const api = React.useContext(ApiContext);
const { user } = useCurrentUser();
const abortController = useAbortController();
const [projectsList, setProjectsList] = React.useState([]);
const [networkState, setNetworkState] = React.useState(null);
Expand Down Expand Up @@ -110,7 +112,11 @@ export const ProjectsPage = () => {
React.useEffect(() => {
// there is a nice page with Create button when list is empty
// so don't show the context button in that case
setContextProps({ openModal, showButton: projectsList.length > 0 });
if (user?.user_type === "Contributor") {
setContextProps({ openModal, showButton: false });
} else {
setContextProps({ openModal, showButton: projectsList.length > 0 });
}
}, [projectsList.length]);

return (
Expand Down
Loading
Loading