diff --git a/label_studio/core/settings/base.py b/label_studio/core/settings/base.py index 358eedd26493..a30074e6553f 100644 --- a/label_studio/core/settings/base.py +++ b/label_studio/core/settings/base.py @@ -899,3 +899,7 @@ def collect_versions_dummy(**kwargs): # Base FSM (Finite State Machine) Configuration for Label Studio FSM_CACHE_TTL = 300 # Cache TTL in seconds (5 minutes) + +# Used for async migrations. In LSE this is set to a real queue name, including here so we +# can use settings.SERVICE_QUEUE_NAME in async migrations in LSO +SERVICE_QUEUE_NAME = '' diff --git a/label_studio/data_manager/migrations/0017_update_agreement_selected_to_nested_structure.py b/label_studio/data_manager/migrations/0017_update_agreement_selected_to_nested_structure.py new file mode 100644 index 000000000000..3a6aa5eff53e --- /dev/null +++ b/label_studio/data_manager/migrations/0017_update_agreement_selected_to_nested_structure.py @@ -0,0 +1,103 @@ +from django.db import migrations, connection +from copy import deepcopy +from django.apps import apps as django_apps +from django.conf import settings +from core.models import AsyncMigrationStatus +from core.redis import start_job_async_or_sync +from core.utils.iterators import iterate_queryset +import logging + +migration_name = '0017_update_agreement_selected_to_nested_structure' + +logger = logging.getLogger(__name__) + + +def forward_migration(): + """ + Migrates views that have agreement_selected populated to the new structure + + Old structure: + 'agreement_selected': { + 'annotators': List[int] + 'models': List[str] + 'ground_truth': bool + } + + New structure: + 'agreement_selected': { + 'annotators': { + 'all': bool + 'ids': List[int] + }, + 'models': { + 'all': bool + 'ids': List[str] + }, + 'ground_truth': bool + } + """ + migration, created = AsyncMigrationStatus.objects.get_or_create( + name=migration_name, + defaults={'status': AsyncMigrationStatus.STATUS_STARTED} + ) + if not created: + return # already in progress or done + + # Look up models at runtime inside the worker process + View = django_apps.get_model('data_manager', 'View') + + # Iterate using values() to avoid loading full model instances + # Fetch only the fields we need, filtering to views that have 'agreement_selected' in data + qs = ( + View.objects + .filter(data__has_key='agreement_selected') + .filter(data__agreement_selected__isnull=False) + .values('id', 'data') + ) + + updated = 0 + for row in qs: + view_id = row['id'] + data = row.get('data') or {} + + new_data = deepcopy(data) + # Always use the new nested structure + new_data['agreement_selected'] = { + 'annotators': {'all': True, 'ids': []}, + 'models': {'all': True, 'ids': []}, + 'ground_truth': False + } + + # Update only the JSON field via update(); do not load model instance or call save() + View.objects.filter(id=view_id).update(data=new_data) + logger.info(f'Updated View {view_id} agreement selected to default all annotators + all models') + updated += 1 + + if updated: + logger.info(f'{migration_name} Updated {updated} View rows') + + migration.status = AsyncMigrationStatus.STATUS_FINISHED + migration.save(update_fields=['status']) + +def forwards(apps, schema_editor): + start_job_async_or_sync(forward_migration, queue_name=settings.SERVICE_QUEUE_NAME) + + +def backwards(apps, schema_editor): + # Irreversible: we cannot reconstruct the previous annotator lists safely + pass + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ('data_manager', '0016_migrate_agreement_selected_annotators_to_unique') + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] + + + diff --git a/web/libs/datamanager/src/components/CellViews/AgreementSelected.jsx b/web/libs/datamanager/src/components/CellViews/AgreementSelected.jsx index 46cc8f2fdd89..3beb08426cf4 100644 --- a/web/libs/datamanager/src/components/CellViews/AgreementSelected.jsx +++ b/web/libs/datamanager/src/components/CellViews/AgreementSelected.jsx @@ -34,13 +34,17 @@ export const AgreementSelected = (cell) => { AgreementSelected.userSelectable = false; -AgreementSelected.HeaderCell = ({ agreementFilters, onSave }) => { +AgreementSelected.HeaderCell = ({ agreementFilters, onSave, onClose }) => { const sdk = useSDK(); const [content, setContent] = useState(null); useEffect(() => { - sdk.invoke("AgreementSelectedHeaderClick", { agreementFilters, onSave }, (jsx) => setContent(jsx)); + sdk.invoke("AgreementSelectedHeaderClick", { agreementFilters, onSave, onClose }, (jsx) => setContent(jsx)); }, []); return content; }; + +AgreementSelected.style = { + minWidth: 210, +}; diff --git a/web/libs/datamanager/src/components/Common/Table/TableHead/TableHead.jsx b/web/libs/datamanager/src/components/Common/Table/TableHead/TableHead.jsx index f1d2cc71d330..da01b2b92e95 100644 --- a/web/libs/datamanager/src/components/Common/Table/TableHead/TableHead.jsx +++ b/web/libs/datamanager/src/components/Common/Table/TableHead/TableHead.jsx @@ -73,8 +73,14 @@ const AgreementSelectedWrapper = observer(({ column, children }) => { const selectedView = root.viewsStore.selected; const agreementFilters = selectedView.agreement_selected; const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + const closeHandler = () => { + ref.current?.close(); + setIsOpen(false); + }; const onSave = (agreementFilters) => { selectedView.setAgreementFilters(agreementFilters); + closeHandler(); return selectedView.save(); }; const onToggle = (isOpen) => { @@ -82,9 +88,15 @@ const AgreementSelectedWrapper = observer(({ column, children }) => { }; return ( + ) : ( <> ) diff --git a/web/libs/datamanager/src/stores/AppStore.js b/web/libs/datamanager/src/stores/AppStore.js index dc73fc478224..86e390ed7f0a 100644 --- a/web/libs/datamanager/src/stores/AppStore.js +++ b/web/libs/datamanager/src/stores/AppStore.js @@ -711,6 +711,8 @@ export const AppStore = types invokeAction: flow(function* (actionId, options = {}) { const view = self.currentView ?? {}; + const viewReloaded = view; + let projectFetched = self.project; const needsLock = self.availableActions.findIndex((a) => a.id === actionId) >= 0; @@ -749,7 +751,13 @@ export const AppStore = types } if (actionCallback instanceof Function) { - return actionCallback(actionParams, view); + const result = yield actionCallback(actionParams, view); + self.SDK.invoke("actionDialogOkComplete", actionId, { + result, + view: viewReloaded, + project: projectFetched, + }); + return result; } const requestParams = { @@ -774,17 +782,28 @@ export const AppStore = types if (result.reload) { self.SDK.reload(); + self.SDK.invoke("actionDialogOkComplete", actionId, { + result, + view: viewReloaded, + project: projectFetched, + }); return; } if (options.reload !== false) { yield view.reload(); - self.fetchProject(); + yield self.fetchProject(); + projectFetched = self.project; view.clearSelection(); } view?.unlock?.(); + self.SDK.invoke("actionDialogOkComplete", actionId, { + result, + view: viewReloaded, + project: projectFetched, + }); return result; }), diff --git a/web/libs/datamanager/src/stores/Tabs/tab.js b/web/libs/datamanager/src/stores/Tabs/tab.js index fc3a23d0bd3c..036a1aacb05f 100644 --- a/web/libs/datamanager/src/stores/Tabs/tab.js +++ b/web/libs/datamanager/src/stores/Tabs/tab.js @@ -422,11 +422,21 @@ export const Tab = types self.save(); }, - setAgreementFilters({ ground_truth = false, annotators = [], models = [] }) { + setAgreementFilters({ + ground_truth = false, + annotators = { all: true, ids: [] }, + models = { all: true, ids: [] }, + }) { self.agreement_selected = { ground_truth, - annotators, - models, + annotators: { + all: annotators.all, + ids: annotators.ids, + }, + models: { + all: models.all, + ids: models.ids, + }, }; },