Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from django.db import migrations
from copy import deepcopy
from django.apps import apps as django_apps
from core.models import AsyncMigrationStatus
from core.redis import start_job_async_or_sync
import logging

migration_name = '0017_update_agreement_selected_to_nested_structure'

logger = logging.getLogger(__name__)


def forward_migration():
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')
Annotation = django_apps.get_model('tasks', 'Annotation')

# Cache unique annotators per project_id to avoid repetitive queries
project_to_unique_annotators = {}

# Iterate using values() to avoid loading full model instances
# Fetch only the fields we need
qs = View.objects.all().values('id', 'project_id', 'data')

updated = 0
for row in qs:
view_id = row['id']
project_id = row['project_id']
data = row.get('data') or {}

agreement = data.get('agreement_selected')
if not isinstance(agreement, dict):
continue

# Handle both old flat structure and new nested structure
existing_annotators = agreement.get('annotators', None)
if existing_annotators is None:
continue

# Check if using old flat structure (list) or new nested structure (dict)
if isinstance(existing_annotators, dict):
# New structure: annotators = { all: bool, ids: [] }
existing_annotator_ids = existing_annotators.get('ids', [])
else:
# Old structure: annotators = []
existing_annotator_ids = existing_annotators

# Compute unique annotators for this project (once per project)
if project_id not in project_to_unique_annotators:
unique_ids = set(
Annotation.objects
.filter(project_id=project_id, completed_by_id__isnull=False)
.values_list('completed_by_id', flat=True)
.distinct()
)
# Normalize to unique ints
project_to_unique_annotators[project_id] = unique_ids

new_annotators = project_to_unique_annotators[project_id]

# If no change, skip update
old_set = {int(a) for a in (existing_annotator_ids or [])}
if new_annotators == old_set:
continue

new_data = deepcopy(data)
# Always use the new nested structure
new_data['agreement_selected']['annotators'] = {
'all': isinstance(existing_annotators, dict) and existing_annotators.get('all', False),
'ids': list(new_annotators)
}

# 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 annotators to {list(new_annotators)}')
logger.info(f'Old annotator length: {len(old_set)}, new annotator length: {len(new_annotators)}')
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='low')


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),
]



Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,30 @@ 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) => {
setIsOpen(isOpen);
};
return (
<Dropdown.Trigger
ref={ref}
content={
isOpen ? (
<AgreementSelected.HeaderCell agreementFilters={agreementFilters} onSave={onSave} align="left" />
<AgreementSelected.HeaderCell
agreementFilters={agreementFilters}
onSave={onSave}
align="left"
onClose={closeHandler}
/>
) : (
<></>
)
Expand Down
16 changes: 13 additions & 3 deletions web/libs/datamanager/src/stores/Tabs/tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
},

Expand Down
1 change: 0 additions & 1 deletion web/libs/editor/src/tags/Custom.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import React from "react";
import { destroy, types, getRoot } from "mobx-state-tree";
import { observer } from "mobx-react";
import { EnterpriseBadge } from "@humansignal/ui";
import Registry from "../core/Registry";
import ControlBase from "./control/Base";
import ClassificationBase from "./control/ClassificationBase";

Expand Down
Loading