Skip to content

Commit 55d08ae

Browse files
committed
feat: opt-in preservation of imported annotation timestamps
Add a LABEL_STUDIO_PRESERVE_IMPORT_TIMESTAMPS env var (default false) that changes ImportStorage.add_task to preserve created_at / updated_at values supplied in the source annotation JSON, instead of overwriting them with now() via auto_now_add / auto_now on the Annotation model. Motivation: importing historical or migrated annotation data through a storage sync (S3, GCS, Azure, local files) silently destroys the real annotation timestamps — the audit trail, time-based analytics, and any downstream reporting keyed on annotation time. Users importing legacy data currently have no way to preserve those values. Implementation: - Extract the existing suppress_autotime helper out of core/old_ls_migration.py (a legacy module new code shouldn't couple to) and into core/utils/db.py alongside the other database utilities. No behavior change to the helper itself; old_ls_migration.py now imports it from the new location. - Add PRESERVE_IMPORT_TIMESTAMPS to core/settings/base.py, sourced from the LABEL_STUDIO_PRESERVE_IMPORT_TIMESTAMPS env var. - In io_storages/base_models.py, add a private _ImportAnnotationSerializer subclass that makes created_at and updated_at writable. When the setting is on, ImportStorage.add_task uses this subclass, pre-fills any missing timestamps with now() to avoid NULL writes, and wraps serializer.save() in suppress_autotime(Annotation, [...]) so auto_now_add / auto_now don't clobber the values at the model layer. When the setting is off, the code path is unchanged — same serializer, same save, same validation and duplicate-detection behavior. Adds tests covering the three code paths: setting on + timestamped source → preserved; setting off + timestamped source → default overwrite behavior (regression guard); setting on + missing source timestamps → falls back to now(), never NULL. Closes #9797
1 parent 145611c commit 55d08ae

5 files changed

Lines changed: 178 additions & 21 deletions

File tree

label_studio/core/old_ls_migration.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import contextlib
21
import datetime
32
import io
43
import json
54
import os
65
import pathlib
76

7+
from core.utils.db import suppress_autotime
88
from core.utils.io import get_data_dir
99
from core.utils.params import get_env
1010
from data_import.models import FileUpload
@@ -18,24 +18,6 @@
1818
from tasks.models import Annotation, Prediction, Task
1919

2020

21-
@contextlib.contextmanager
22-
def suppress_autotime(model, fields):
23-
"""allow to keep original created_at value for auto_now_add=True field"""
24-
_original_values = {}
25-
for field in model._meta.local_fields:
26-
if field.name in fields:
27-
_original_values[field.name] = {'auto_now': field.auto_now, 'auto_now_add': field.auto_now_add}
28-
field.auto_now = False
29-
field.auto_now_add = False
30-
try:
31-
yield
32-
finally:
33-
for field in model._meta.local_fields:
34-
if field.name in fields:
35-
field.auto_now = _original_values[field.name]['auto_now']
36-
field.auto_now_add = _original_values[field.name]['auto_now_add']
37-
38-
3921
def _migrate_tasks(project_path, project):
4022
"""Migrate tasks from json file to database objects"""
4123
tasks_path = project_path / 'tasks.json'

label_studio/core/settings/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,10 @@
586586
IMPORT_BATCH_SIZE = int(get_env('IMPORT_BATCH_SIZE', 500))
587587
# Batch size for processing prediction imports to avoid memory issues with large datasets
588588
PREDICTION_IMPORT_BATCH_SIZE = int(get_env('PREDICTION_IMPORT_BATCH_SIZE', 500))
589+
# When True, ImportStorage.add_task preserves created_at / updated_at supplied by the
590+
# source annotation JSON instead of overwriting them with now(). Missing timestamps
591+
# still fall back to now(). Default False preserves current behavior.
592+
PRESERVE_IMPORT_TIMESTAMPS = get_bool_env('LABEL_STUDIO_PRESERVE_IMPORT_TIMESTAMPS', False)
589593
PROJECT_TITLE_MIN_LEN = 3
590594
PROJECT_TITLE_MAX_LEN = 50
591595
LOGIN_REDIRECT_URL = '/'

label_studio/core/utils/db.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import itertools
23
import logging
34
import time
@@ -201,3 +202,27 @@ def signal_clear_column_presence_cache(**_kwargs):
201202
so that the next migration can introspect the new column using has_column_cached()."""
202203
logger.debug('Clearing column presence cache in post_migrate signal')
203204
_column_presence_cache.clear()
205+
206+
207+
@contextlib.contextmanager
208+
def suppress_autotime(model, fields):
209+
"""Temporarily disable auto_now / auto_now_add on the named model fields.
210+
211+
Use as a context manager around a .save() call when you need to persist
212+
caller-supplied timestamps on fields that are otherwise stamped
213+
automatically by Django (e.g., importing rows with historical
214+
created_at / updated_at values).
215+
"""
216+
_original_values = {}
217+
for field in model._meta.local_fields:
218+
if field.name in fields:
219+
_original_values[field.name] = {'auto_now': field.auto_now, 'auto_now_add': field.auto_now_add}
220+
field.auto_now = False
221+
field.auto_now_add = False
222+
try:
223+
yield
224+
finally:
225+
for field in model._meta.local_fields:
226+
if field.name in fields:
227+
field.auto_now = _original_values[field.name]['auto_now']
228+
field.auto_now_add = _original_values[field.name]['auto_now_add']

label_studio/io_storages/base_models.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from core.feature_flags import flag_set
2121
from core.redis import redis_connected, start_job_async_or_sync
2222
from core.utils.common import load_func
23+
from core.utils.db import suppress_autotime
2324
from core.utils.iterators import iterate_queryset
2425
from data_export.serializers import ExportDataSerializer
2526
from django.conf import settings
@@ -31,6 +32,7 @@
3132
from django.utils.translation import gettext_lazy as _
3233
from fsm.functions import backfill_fsm_states_for_tasks
3334
from io_storages.utils import StorageObject, get_all_uris_via_regex, get_uri_via_regex, parse_bucket_uri
35+
from rest_framework import serializers
3436
from rest_framework.exceptions import ValidationError
3537
from rq.job import Job
3638
from tasks.models import Annotation, Task
@@ -43,6 +45,20 @@
4345
logger = logging.getLogger(__name__)
4446

4547

48+
class _ImportAnnotationSerializer(AnnotationSerializer):
49+
"""Variant of AnnotationSerializer used by ImportStorage.add_task when
50+
settings.PRESERVE_IMPORT_TIMESTAMPS is True. Overrides created_at and
51+
updated_at so caller-supplied timestamps in the source JSON are not
52+
silently dropped as read-only fields. The serializer.save() call must
53+
still be wrapped in suppress_autotime(Annotation, ['created_at',
54+
'updated_at']) so that Django's auto_now / auto_now_add flags don't
55+
overwrite the values at the model layer.
56+
"""
57+
58+
created_at = serializers.DateTimeField(required=False)
59+
updated_at = serializers.DateTimeField(required=False)
60+
61+
4662
class StorageInfo(models.Model):
4763
"""
4864
StorageInfo helps to understand storage status and progress
@@ -544,14 +560,30 @@ def add_task(cls, project, maximum_annotations, max_inner_id, storage, link_obje
544560

545561
# add annotations
546562
logger.debug(f'Create {len(annotations)} annotations for task={task}')
563+
preserve_timestamps = settings.PRESERVE_IMPORT_TIMESTAMPS
564+
now_ts = timezone.now() if preserve_timestamps else None
547565
for annotation in annotations:
548566
annotation['task'] = task.id
549567
annotation['project'] = project.id
550-
annotation_ser = AnnotationSerializer(data=annotations, many=True)
568+
if preserve_timestamps:
569+
# Fill in "now" for any missing timestamp so that
570+
# suppress_autotime never writes a NULL value.
571+
# Caller-supplied values (when present) win.
572+
annotation.setdefault('created_at', now_ts)
573+
annotation.setdefault('updated_at', now_ts)
574+
575+
if preserve_timestamps:
576+
annotation_ser = _ImportAnnotationSerializer(data=annotations, many=True)
577+
else:
578+
annotation_ser = AnnotationSerializer(data=annotations, many=True)
551579

552580
# Always validate annotations, but control error handling based on FF
553581
if annotation_ser.is_valid():
554-
annotation_ser.save()
582+
if preserve_timestamps:
583+
with suppress_autotime(Annotation, ['created_at', 'updated_at']):
584+
annotation_ser.save()
585+
else:
586+
annotation_ser.save()
555587
else:
556588
# Log validation errors but don't save invalid annotations
557589
logger.error(f'Invalid annotations for task {task.id}: {annotation_ser.errors}')
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Coverage for LABEL_STUDIO_PRESERVE_IMPORT_TIMESTAMPS opt-in behavior in
2+
ImportStorage.add_task. See core/settings/base.py for the setting and
3+
io_storages/base_models.py for the code path.
4+
"""
5+
import json
6+
from datetime import datetime, timezone as dt_timezone
7+
8+
import boto3
9+
import pytest
10+
from django.test import override_settings
11+
from django.utils import timezone
12+
from io_storages.models import S3ImportStorage
13+
from moto import mock_s3
14+
from projects.tests.factories import ProjectFactory
15+
from tasks.models import Annotation
16+
from tests.conftest import set_feature_flag_envvar # noqa: F401
17+
18+
19+
LABEL_CONFIG = """
20+
<View>
21+
<Text name="text" value="$text"/>
22+
<Choices name="sentiment" toName="text">
23+
<Choice value="positive"/>
24+
<Choice value="negative"/>
25+
</Choices>
26+
</View>
27+
"""
28+
29+
30+
def _annotation_result():
31+
return [
32+
{
33+
'from_name': 'sentiment',
34+
'to_name': 'text',
35+
'type': 'choices',
36+
'value': {'choices': ['positive']},
37+
}
38+
]
39+
40+
41+
TASK_WITH_TIMESTAMPED_ANNOTATION = {
42+
'data': {'text': 'hello'},
43+
'annotations': [
44+
{
45+
'result': _annotation_result(),
46+
'created_at': '2024-06-01T12:00:00Z',
47+
'updated_at': '2024-06-01T12:05:00Z',
48+
}
49+
],
50+
}
51+
52+
TASK_WITHOUT_TIMESTAMPS = {
53+
'data': {'text': 'hello'},
54+
'annotations': [{'result': _annotation_result()}],
55+
}
56+
57+
58+
def _sync_via_s3(project, task_json, bucket_name='pytest-preserve-timestamps'):
59+
"""Push a task JSON to a mocked S3 bucket and sync it into the project."""
60+
with mock_s3():
61+
s3 = boto3.client('s3', region_name='us-east-1')
62+
s3.create_bucket(Bucket=bucket_name)
63+
s3.put_object(Bucket=bucket_name, Key='task.json', Body=json.dumps([task_json]))
64+
storage = S3ImportStorage(
65+
project=project,
66+
bucket=bucket_name,
67+
aws_access_key_id='example',
68+
aws_secret_access_key='example',
69+
use_blob_urls=False,
70+
)
71+
storage.save()
72+
storage.sync()
73+
74+
75+
@pytest.mark.django_db
76+
class TestPreserveImportTimestamps:
77+
@pytest.fixture
78+
def project(self):
79+
return ProjectFactory(label_config=LABEL_CONFIG)
80+
81+
@override_settings(PRESERVE_IMPORT_TIMESTAMPS=True)
82+
def test_source_timestamps_preserved_when_enabled(self, project, set_feature_flag_envvar):
83+
"""With the setting on, created_at/updated_at from the source JSON survive the save."""
84+
_sync_via_s3(project, TASK_WITH_TIMESTAMPED_ANNOTATION)
85+
86+
ann = Annotation.objects.get(project=project)
87+
assert ann.created_at == datetime(2024, 6, 1, 12, 0, tzinfo=dt_timezone.utc)
88+
assert ann.updated_at == datetime(2024, 6, 1, 12, 5, tzinfo=dt_timezone.utc)
89+
90+
def test_source_timestamps_ignored_by_default(self, project, set_feature_flag_envvar):
91+
"""Default (setting off): auto_now_add / auto_now overwrite the source values
92+
with the sync time. Confirms we haven't accidentally changed default behavior.
93+
"""
94+
before = timezone.now()
95+
_sync_via_s3(project, TASK_WITH_TIMESTAMPED_ANNOTATION)
96+
97+
ann = Annotation.objects.get(project=project)
98+
# Source JSON said 2024-06-01; expected now(), not that value.
99+
assert ann.created_at >= before
100+
assert ann.updated_at >= before
101+
102+
@override_settings(PRESERVE_IMPORT_TIMESTAMPS=True)
103+
def test_missing_source_timestamps_fall_back_to_now(self, project, set_feature_flag_envvar):
104+
"""With the setting on but no timestamps in the source JSON, both fields fall
105+
back to now() rather than being left NULL by suppress_autotime.
106+
"""
107+
before = timezone.now()
108+
_sync_via_s3(project, TASK_WITHOUT_TIMESTAMPS)
109+
110+
ann = Annotation.objects.get(project=project)
111+
assert ann.created_at is not None
112+
assert ann.updated_at is not None
113+
assert ann.created_at >= before
114+
assert ann.updated_at >= before

0 commit comments

Comments
 (0)