Skip to content
Merged
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
10 changes: 9 additions & 1 deletion label_studio/tasks/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,18 @@ def prefetch(self, queryset):
)

def get_retrieve_serializer_context(self, request):
"""Build serializer context for task retrieval.

The resolve_uri parameter controls whether storage URLs (e.g., s3://bucket/file.jpg)
are converted to proxy URLs (/tasks/<id>/resolve/?fileuri=...). This is useful for:
- resolve_uri=True (default): URLs are proxied through Label Studio for security
- resolve_uri=False: Original storage URLs are preserved, useful for debugging
or when users need to see the actual source paths in task preview
"""
fields = ['drafts', 'predictions', 'annotations']

return {
'resolve_uri': True,
'resolve_uri': bool_from_request(request.GET, 'resolve_uri', True),
'predictions': 'predictions' in fields,
'annotations': 'annotations' in fields,
'drafts': 'drafts' in fields,
Expand Down
113 changes: 113 additions & 0 deletions label_studio/tasks/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest.mock import patch

from organizations.tests.factories import OrganizationFactory
from projects.tests.factories import ProjectFactory
from rest_framework.test import APITestCase
Expand Down Expand Up @@ -123,3 +125,114 @@ def test_create_task_with_project_id_succeeds(self):
response_data = response.json()
assert response_data['project'] == self.project.id
assert response_data['data'] == {'text': 'test task'}


class TestTaskAPIResolveUri(APITestCase):
"""Tests for resolve_uri query parameter in task detail endpoint.

The resolve_uri parameter controls whether storage URLs (e.g., s3://bucket/file.jpg)
are converted to proxy URLs. This is useful for debugging and viewing original
source paths in task preview.
"""

@classmethod
def setUpTestData(cls):
cls.organization = OrganizationFactory()
cls.project = ProjectFactory(organization=cls.organization)
cls.user = cls.organization.created_by

def test_get_task_resolve_uri_default_true(self):
"""Test that resolve_uri defaults to True when not specified.

This test validates:
- Creating a task with a storage-like URL in data
- Fetching the task without resolve_uri parameter
- Verifying that Task.resolve_uri method is called (default behavior)

Critical validation: By default, URLs should be resolved for security,
preventing direct exposure of storage credentials.
"""
task = TaskFactory(project=self.project, data={'image': 's3://bucket/image.jpg'})
self.client.force_authenticate(user=self.user)

# Patch resolve_uri to track if it's called
with patch.object(task.__class__, 'resolve_uri', return_value={'image': '/resolved/url'}) as mock_resolve:
response = self.client.get(f'/api/tasks/{task.id}/')

assert response.status_code == 200
# resolve_uri should be called by default
mock_resolve.assert_called_once()

def test_get_task_resolve_uri_explicit_true(self):
"""Test that resolve_uri=true explicitly enables URL resolution.

This test validates:
- Creating a task with a storage-like URL in data
- Fetching the task with resolve_uri=true
- Verifying that Task.resolve_uri method is called

Critical validation: Explicit resolve_uri=true should resolve URLs.
"""
task = TaskFactory(project=self.project, data={'image': 's3://bucket/image.jpg'})
self.client.force_authenticate(user=self.user)

with patch.object(task.__class__, 'resolve_uri', return_value={'image': '/resolved/url'}) as mock_resolve:
response = self.client.get(f'/api/tasks/{task.id}/?resolve_uri=true')

assert response.status_code == 200
mock_resolve.assert_called_once()

def test_get_task_resolve_uri_false_preserves_original_urls(self):
"""Test that resolve_uri=false preserves original storage URLs.

This test validates:
- Creating a task with a storage-like URL in data
- Fetching the task with resolve_uri=false
- Verifying that Task.resolve_uri method is NOT called
- Original URL is preserved in the response

Critical validation: When resolve_uri=false, users should see original
storage URLs (e.g., s3://bucket/file.jpg) for debugging purposes.
"""
original_url = 's3://my-bucket/path/to/image.jpg'
task = TaskFactory(project=self.project, data={'image': original_url, 'text': 'test'})
self.client.force_authenticate(user=self.user)

with patch.object(task.__class__, 'resolve_uri') as mock_resolve:
response = self.client.get(f'/api/tasks/{task.id}/?resolve_uri=false')

assert response.status_code == 200
# resolve_uri should NOT be called when resolve_uri=false
mock_resolve.assert_not_called()
# Original URL should be preserved
assert response.json()['data']['image'] == original_url
assert response.json()['data']['text'] == 'test'

def test_get_task_resolve_uri_false_with_multiple_url_fields(self):
"""Test resolve_uri=false with multiple URL fields in task data.

This test validates:
- Creating a task with multiple storage URLs
- Fetching with resolve_uri=false
- All original URLs are preserved

Critical validation: All URL fields should preserve their original values.
"""
task_data = {
'image_1': 's3://bucket-1/image1.jpg',
'image_2': 'gs://bucket-2/image2.png',
'audio': 'azure-blob://container/audio.mp3',
'text': 'Plain text field',
}
task = TaskFactory(project=self.project, data=task_data)
self.client.force_authenticate(user=self.user)

response = self.client.get(f'/api/tasks/{task.id}/?resolve_uri=false')

assert response.status_code == 200
response_data = response.json()['data']
# All original URLs should be preserved
assert response_data['image_1'] == 's3://bucket-1/image1.jpg'
assert response_data['image_2'] == 'gs://bucket-2/image2.png'
assert response_data['audio'] == 'azure-blob://container/audio.mp3'
assert response_data['text'] == 'Plain text field'
7 changes: 5 additions & 2 deletions web/libs/datamanager/src/components/Common/Table/Table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,14 @@ export const Table = observer(
predictions: out?.predictions,
};

const onTaskLoad = async () => {
const onTaskLoad = async (options = {}) => {
if (isFF(FF_LOPS_E_3) && type === "DE") {
return new Promise((resolve) => resolve(out));
}
const response = await api.task({ taskID: out.id });
const response = await api.task({
taskID: out.id,
resolve_uri: options.resolveUri ?? false,
});

return response ?? {};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
background: var(--color-neutral-surface-inset);
border-radius: var(--corner-radius-small);
overflow: auto;
max-height: 600px;

// Fill parent container for consistent modal height
height: 100%;
box-shadow: inset 0 2px 8px rgba(var(--color-neutral-shadow-raw) / 12%);
border: 1px solid var(--color-neutral-border);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
flex-direction: column;
gap: var(--spacing-base);
width: 100%;

// Fixed height prevents modal from resizing when switching Code/Interactive views
// that leads to accidental clicks outside and unexpecteddd modal closing
height: 600px;
}

.viewContent {
Expand Down
Loading
Loading