Skip to content

Commit 4b96769

Browse files
fix: UTC-445: Remove allow_skip functionality in LSO (#9047)
Co-authored-by: Caitlin <[email protected]> Co-authored-by: mcanu <[email protected]>
1 parent 5bb5182 commit 4b96769

File tree

19 files changed

+279
-211
lines changed

19 files changed

+279
-211
lines changed

docs/source/guide/skip.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ You can see which tasks have been skipped using the **Cancelled** column in the
2727
!!! error Enterprise
2828
Task skipping, and how tasks can be skipped, is highly configurable in Label Studio Enterprise and Starter Cloud. For more information, see [the Enterprise documentation](https://docs.humansignal.com/guide/skip).
2929

30-
To make tasks unskippable, you can include a special key in the JSON task definition that you import to your project.
31-
3230
</div>
3331

3432
<div class="enterprise-only">
@@ -47,8 +45,6 @@ Whether annotators can skip tasks, and what should happen to skipped tasks, is c
4745

4846
While you can disallow skipping entirely from the project settings, if you want to have specific tasks be unskippable, you will need to configure that by adding a special key as part of the JSON task definition that you import to your project.
4947

50-
</div>
51-
5248
## Individual unskippable tasks
5349

5450
To make an individual task unskippable, specify `"allow_skip": false` as part of the [JSON task definition](tasks#Basic-Label-Studio-JSON-format) that you import to your project.
@@ -71,8 +67,6 @@ For example, the following JSON snippet would result in one skippable task and o
7167
]
7268
```
7369

74-
<div class="enterprise-only">
75-
7670
!!! note
7771
Managers, Admins, and Owners can still skip these tasks. Only Annotators and Reviewers cannot skip tasks that have been marked unskippable using this method.
7872

@@ -82,13 +76,5 @@ For example, the following JSON snippet would result in one skippable task and o
8276

8377
</div>
8478

85-
<div class="opensource-only">
86-
87-
!!! info Tip
88-
Use the **Allow Skip** column to see which tasks have skipping disabled and filter for unskippable tasks.
89-
90-
</div>
91-
92-
9379

9480

label_studio/data_export/serializers.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""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.
2-
"""
1+
"""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."""
2+
33
from core.label_config import replace_task_data_undefined_with_config_field
44
from core.utils.common import load_func
55
from data_export.models import DataExport
@@ -168,8 +168,7 @@ class TaskFilterOptionsSerializer(serializers.Serializer):
168168
choices=ONLY_OR_EXCLUDE_CHOICE,
169169
allow_null=True,
170170
required=False,
171-
help_text='`only` - include all finished tasks (is_labeled = true)<br>'
172-
'`exclude` - exclude all finished tasks',
171+
help_text='`only` - include all finished tasks (is_labeled = true)<br>`exclude` - exclude all finished tasks',
173172
)
174173
annotated = serializers.ChoiceField(
175174
choices=ONLY_OR_EXCLUDE_CHOICE,

label_studio/data_manager/serializers.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""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.
2-
"""
1+
"""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."""
2+
33
import os
44

55
import ujson as json
@@ -117,7 +117,7 @@ def _build_filter_tree(filter_obj):
117117
# Add child filter if exists (only one level of nesting)
118118
child_filters = filter_obj.children.all()
119119
if child_filters:
120-
child = child_filters[0] # Only support one child
120+
child = child_filters[0] # Only support one child
121121
child_item = {
122122
'filter': child.column,
123123
'operator': child.operator,
@@ -228,7 +228,6 @@ def _create_filters(filter_group, filters_data):
228228
"""
229229

230230
def _create_recursive(data, parent=None, index=None):
231-
232231
# Extract nested children early (if any) and remove them from payload
233232
child_filter = data.pop('child_filter', None)
234233

@@ -466,7 +465,7 @@ class DataManagerTaskSerializer(TaskSerializer):
466465
class Meta:
467466
model = Task
468467
ref_name = 'data_manager_task_serializer'
469-
exclude = ('precomputed_agreement',)
468+
exclude = ('precomputed_agreement', 'allow_skip')
470469
expandable_fields = {'annotations': (AnnotationSerializer, {'many': True})}
471470

472471
def to_representation(self, obj):
@@ -483,10 +482,6 @@ def to_representation(self, obj):
483482
and flag_set('fflag_feat_fit_710_fsm_state_fields', user=user)
484483
):
485484
ret.pop('state', None)
486-
# Ensure allow_skip is always present in the response, even if None
487-
# This is important for frontend logic that checks allow_skip !== false
488-
if 'allow_skip' not in ret:
489-
ret['allow_skip'] = obj.allow_skip
490485
return ret
491486

492487
def _pretty_results(self, task, field, unique=False):

label_studio/io_storages/base_models.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""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.
2-
"""
1+
"""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."""
2+
33
import base64
44
import concurrent.futures
55
import itertools
@@ -250,8 +250,7 @@ def health_check(self):
250250
)
251251
self.save(update_fields=['status', 'traceback'])
252252
logger.info(
253-
f'Storage {self} status moved to `failed` '
254-
f'because the job {self.last_sync_job} has too old ping time'
253+
f'Storage {self} status moved to `failed` because the job {self.last_sync_job} has too old ping time'
255254
)
256255

257256
def job_health_check(self):
@@ -276,7 +275,7 @@ def job_health_check(self):
276275
'This typically occurs due to an out-of-memory (OOM) error.'
277276
)
278277
self.save(update_fields=['status', 'traceback'])
279-
logger.info(f'Storage {self} status moved to `failed` ' f'because of the failed job {self.last_sync_job}')
278+
logger.info(f'Storage {self} status moved to `failed` because of the failed job {self.last_sync_job}')
280279

281280
# job is not found in redis (maybe deleted while redeploy), storage status is still active
282281
elif job_status == 'not found':
@@ -288,9 +287,7 @@ def job_health_check(self):
288287
'or workers reloaded unexpectedly.'
289288
)
290289
self.save(update_fields=['status', 'traceback'])
291-
logger.info(
292-
f'Storage {self} status moved to `failed` ' f'because the job {self.last_sync_job} was not found'
293-
)
290+
logger.info(f'Storage {self} status moved to `failed` because the job {self.last_sync_job} was not found')
294291

295292

296293
class Storage(StorageInfo):
@@ -449,14 +446,14 @@ def add_task(cls, project, maximum_annotations, max_inner_id, storage, link_obje
449446
link_kwargs = asdict(link_object)
450447
data = link_kwargs.pop('task_data', None)
451448

452-
allow_skip = data.get('allow_skip', None)
449+
allow_skip = data.get('allow_skip', True)
453450

454451
# predictions
455452
predictions = data.get('predictions') or []
456453
if predictions:
457454
if 'data' not in data:
458455
raise ValueError(
459-
'If you use "predictions" field in the task, ' 'you must put "data" field in the task too'
456+
'If you use "predictions" field in the task, you must put "data" field in the task too'
460457
)
461458

462459
# annotations
@@ -465,7 +462,7 @@ def add_task(cls, project, maximum_annotations, max_inner_id, storage, link_obje
465462
if annotations:
466463
if 'data' not in data:
467464
raise ValueError(
468-
'If you use "annotations" field in the task, ' 'you must put "data" field in the task too'
465+
'If you use "annotations" field in the task, you must put "data" field in the task too'
469466
)
470467
cancelled_annotations = len([a for a in annotations if a.get('was_cancelled', False)])
471468

@@ -486,7 +483,7 @@ def add_task(cls, project, maximum_annotations, max_inner_id, storage, link_obje
486483
total_annotations=len(annotations) - cancelled_annotations,
487484
cancelled_annotations=cancelled_annotations,
488485
inner_id=max_inner_id,
489-
allow_skip=(allow_skip if allow_skip is not None else True),
486+
allow_skip=allow_skip,
490487
)
491488
# Save with skip_fsm flag to bypass FSM during bulk import
492489
task.save(skip_fsm=True)
@@ -889,7 +886,6 @@ class Meta:
889886

890887

891888
class ImportStorageLink(models.Model):
892-
893889
task = models.OneToOneField('tasks.Task', on_delete=models.CASCADE, related_name='%(app_label)s_%(class)s')
894890
key = models.TextField(_('key'), null=False, help_text='External link key')
895891

@@ -919,7 +915,6 @@ class Meta:
919915

920916

921917
class ExportStorageLink(models.Model):
922-
923918
annotation = models.ForeignKey(
924919
'tasks.Annotation', on_delete=models.CASCADE, related_name='%(app_label)s_%(class)s'
925920
)

label_studio/tasks/api.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""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.
2-
"""
1+
"""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."""
2+
33
import logging
44

55
from core.feature_flags import flag_set
@@ -603,7 +603,7 @@ def perform_create(self, ser):
603603
was_cancelled_data = self.request.data.get('was_cancelled', False)
604604
is_skipping = was_cancelled_get or was_cancelled_data
605605

606-
if is_skipping and not task.allow_skip:
606+
if is_skipping and not task.can_be_skipped():
607607
raise ValidationError({'detail': 'This task cannot be skipped.'})
608608

609609
# updates history

label_studio/tasks/mixins.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ def after_bulk_delete_actions(tasks_ids, project):
3636
def get_rejected_query(self):
3737
pass
3838

39+
def can_be_skipped(self) -> bool:
40+
return True
41+
3942

4043
class AnnotationMixin:
4144
def has_permission(self, user: 'User') -> bool: # noqa: F821

label_studio/tasks/serializers.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
"""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.
2-
"""
1+
"""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."""
2+
33
import logging
44

55
import ujson as json
@@ -213,7 +213,7 @@ def to_representation(self, instance):
213213

214214
class Meta:
215215
model = Task
216-
exclude = ('precomputed_agreement',)
216+
exclude = ('precomputed_agreement', 'allow_skip')
217217

218218

219219
class BaseTaskSerializer(FlexFieldsModelSerializer):
@@ -266,16 +266,11 @@ def to_representation(self, instance):
266266
data = instance.data
267267
replace_task_data_undefined_with_config_field(data, project)
268268

269-
ret = super().to_representation(instance)
270-
# Ensure allow_skip is always present in the response, even if None
271-
# This is important for frontend logic that checks allow_skip !== false
272-
if 'allow_skip' not in ret:
273-
ret['allow_skip'] = instance.allow_skip
274-
return ret
269+
return super().to_representation(instance)
275270

276271
class Meta:
277272
model = Task
278-
exclude = ('precomputed_agreement',)
273+
exclude = ('precomputed_agreement', 'allow_skip')
279274

280275

281276
class BaseTaskSerializerBulk(serializers.ListSerializer):
@@ -439,7 +434,6 @@ def create(self, validated_data):
439434

440435
# to be sure we add tasks with annotations at the same time
441436
with transaction.atomic():
442-
443437
# extract annotations, predictions, drafts, reviews, etc
444438
# all these lists will be grouped by tasks, e.g.:
445439
# task_annotations = [ [a1, a2], [a3, a4, a5], ... ]
@@ -537,7 +531,7 @@ def add_predictions(self, task_predictions):
537531
prediction_score = float(prediction_score)
538532
except ValueError:
539533
logger.error(
540-
"Can't upload prediction score: should be in float format." 'Fallback to score=None'
534+
"Can't upload prediction score: should be in float format.Fallback to score=None"
541535
)
542536
prediction_score = None
543537

@@ -683,7 +677,7 @@ def add_tasks(self, task_annotations, task_predictions, validated_tasks):
683677
total_predictions=len(task_predictions[i]),
684678
total_annotations=total_annotations,
685679
cancelled_annotations=cancelled_annotations,
686-
allow_skip=task.get('allow_skip', True), # Default to True for backward compatibility
680+
allow_skip=task.get('allow_skip', True),
687681
)
688682
db_tasks.append(t)
689683

@@ -807,7 +801,6 @@ class Meta:
807801

808802

809803
class TaskWithAnnotationsAndPredictionsAndDraftsSerializer(TaskSerializer):
810-
811804
predictions = serializers.SerializerMethodField(default=[], read_only=True)
812805
annotations = serializers.SerializerMethodField(default=[], read_only=True)
813806
drafts = serializers.SerializerMethodField(default=[], read_only=True)

label_studio/tasks/tests/test_api.py

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from organizations.tests.factories import OrganizationFactory
22
from projects.tests.factories import ProjectFactory
33
from rest_framework.test import APITestCase
4-
from tasks.models import Task
54
from tasks.tests.factories import TaskFactory
65

76

@@ -51,7 +50,6 @@ def test_get_task(self):
5150
'comment_count': 0,
5251
'last_comment_updated_at': None,
5352
'unresolved_comment_count': 0,
54-
'allow_skip': True,
5553
}
5654

5755
def test_patch_task(self):
@@ -94,7 +92,6 @@ def test_patch_task(self):
9492
'comment_count': 0,
9593
'last_comment_updated_at': None,
9694
'unresolved_comment_count': 0,
97-
'allow_skip': True,
9895
}
9996

10097
def test_create_task_without_project_id_fails(self):
@@ -126,67 +123,3 @@ def test_create_task_with_project_id_succeeds(self):
126123
response_data = response.json()
127124
assert response_data['project'] == self.project.id
128125
assert response_data['data'] == {'text': 'test task'}
129-
130-
def test_get_task_includes_allow_skip(self):
131-
"""Test that GET task API includes allow_skip field"""
132-
task = TaskFactory(project=self.project, allow_skip=False)
133-
134-
self.client.force_authenticate(user=self.user)
135-
response = self.client.get(f'/api/tasks/{task.id}/')
136-
assert response.status_code == 200
137-
response_data = response.json()
138-
assert 'allow_skip' in response_data
139-
assert response_data['allow_skip'] is False
140-
141-
def test_create_task_with_allow_skip(self):
142-
"""Test that creating a task with allow_skip field succeeds"""
143-
payload = {
144-
'project': self.project.id,
145-
'data': {'text': 'test task'},
146-
'meta': {},
147-
'allow_skip': False,
148-
}
149-
150-
self.client.force_authenticate(user=self.user)
151-
response = self.client.post('/api/tasks/', data=payload, format='json')
152-
153-
assert response.status_code == 201
154-
response_data = response.json()
155-
assert response_data['allow_skip'] is False
156-
task = Task.objects.get(id=response_data['id'])
157-
assert task.allow_skip is False
158-
159-
def test_skip_unskippable_task_fails(self):
160-
"""Test that skipping a task with allow_skip=False fails"""
161-
task = TaskFactory(project=self.project, allow_skip=False)
162-
163-
self.client.force_authenticate(user=self.user)
164-
response = self.client.post(
165-
f'/api/tasks/{task.id}/annotations/', data={'result': [], 'was_cancelled': True}, format='json'
166-
)
167-
168-
assert response.status_code == 400
169-
response_data = response.json()
170-
assert 'cannot be skipped' in str(response_data).lower()
171-
172-
def test_skip_skippable_task_succeeds(self):
173-
"""Test that skipping a task with allow_skip=True succeeds"""
174-
task = TaskFactory(project=self.project, allow_skip=True)
175-
176-
self.client.force_authenticate(user=self.user)
177-
response = self.client.post(
178-
f'/api/tasks/{task.id}/annotations/', data={'result': [], 'was_cancelled': True}, format='json'
179-
)
180-
181-
assert response.status_code == 201
182-
183-
def test_skip_task_with_default_allow_skip_succeeds(self):
184-
"""Test that skipping a task without explicit allow_skip (defaults to True) succeeds"""
185-
task = TaskFactory(project=self.project) # allow_skip defaults to True
186-
187-
self.client.force_authenticate(user=self.user)
188-
response = self.client.post(
189-
f'/api/tasks/{task.id}/annotations/', data={'result': [], 'was_cancelled': True}, format='json'
190-
)
191-
192-
assert response.status_code == 201

web/libs/datamanager/src/sdk/lsf-sdk.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,10 +805,11 @@ export class LSFWrapper {
805805
// Manager roles that can force-skip unskippable tasks (OW=Owner, AD=Admin, MA=Manager)
806806
const MANAGER_ROLES = ["OW", "AD", "MA"];
807807
const task = this.task;
808-
const taskAllowSkip = task?.allow_skip !== false;
808+
const isEnterprise = window.APP_SETTINGS?.billing?.enterprise;
809+
const skipDisabled = isEnterprise ? task?.allow_skip === false : false;
809810
const userRole = window.APP_SETTINGS?.user?.role;
810811
const hasForceSkipPermission = MANAGER_ROLES.includes(userRole);
811-
const canSkip = taskAllowSkip || hasForceSkipPermission;
812+
const canSkip = !skipDisabled || hasForceSkipPermission;
812813
if (!canSkip) {
813814
console.warn("Task cannot be skipped: allow_skip is false and user lacks manager role");
814815
this.showOperationToast(400, null, "This task cannot be skipped", { error: "Task cannot be skipped" });

0 commit comments

Comments
 (0)