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
6 changes: 4 additions & 2 deletions medcat-trainer/docs/meta_annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Examples:

## Configure meta tasks

Create and manage tasks in Django admin (`/admin`):
Create and manage task definitions in Django admin (`/admin`):

1. Create **Meta Task Values** (the allowed label options).
2. Create a **Meta Task**:
Expand All @@ -21,7 +21,9 @@ Create and manage tasks in Django admin (`/admin`):
- default value (optional)
- description
- ordering
3. Attach selected tasks to your project (`Project annotate entities`).
3. Attach selected tasks to your project from **Project Admin** (`/project-admin`)
using the **Meta Annotation Tasks** multi-select (also available for project groups).
Django admin (`Project annotate entities`) can still be used for the same attachment.

## Model-pack predictions vs manual labels

Expand Down
6 changes: 6 additions & 0 deletions medcat-trainer/docs/project_admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ Pick one of:
| **Irrelevant available** | Shows irrelevant action in annotation toolbar. |
| **Entity annotation comments** | Enables free-text comments per annotation. |

### Meta Annotation & Relation Tasks
| Field | Description |
|------------------------------|-----------------------------------------------------------------------------|
| **Meta Annotation Tasks** | Multi-select of Meta Tasks (for example Temporality, Assertion, Experiencer) shown in the annotator sidebar for Correct/Alternative concepts. Define task values in Django admin (`/admin`) first, or upload a model pack that includes MetaCAT models. Selecting a model pack with linked MetaCAT models pre-fills matching tasks. |
| **Relation Tasks** | Multi-select of relation types available between annotated concepts in the Relation tab. |

### Concept Filtering
| Field | Description |
|------------------------------|-----------------------------------------------------------------------------|
Expand Down
103 changes: 103 additions & 0 deletions medcat-trainer/webapp/api/api/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
ConceptDB,
Document,
Entity,
MetaTask,
ProjectAnnotateEntities,
Relation,
Vocabulary,
)
from ._helpers import (
Expand Down Expand Up @@ -333,6 +335,23 @@ def test_clone_default_name_when_unspecified(self):
self.assertEqual(resp.status_code, 201)
self.assertEqual(resp.json()['name'], 'clone-proj (Clone)')

def test_clone_copies_tasks_and_relations(self):
task = MetaTask.objects.create(name='Temporality')
relation = Relation.objects.create(label='is associated with')
self.project.tasks.add(task)
self.project.relations.add(relation)

client = _auth_client(self.member)
resp = client.post(
f'/api/project-admin/projects/{self.project.id}/clone/',
{'name': 'clone-with-tasks'},
format='json',
)
self.assertEqual(resp.status_code, 201, msg=resp.content)
clone = ProjectAnnotateEntities.objects.get(name='clone-with-tasks')
self.assertIn(task, clone.tasks.all())
self.assertIn(relation, clone.relations.all())

def test_clone_returns_404_for_unknown_project(self):
client = _auth_client(self.member)
resp = client.post('/api/project-admin/projects/99999/clone/', {}, format='json')
Expand All @@ -348,6 +367,90 @@ def test_outsider_cannot_clone(self):
self.assertEqual(resp.status_code, 403)


@override_settings(MEDIA_ROOT='/tmp/mct-tests-views')
class ProjectAdminMetaTaskSelectionTests(TestCase):
def setUp(self):
self.user = create_user(username='task-admin')
self.client = _auth_client(self.user)
self.base = create_basic_project(name='task-base')
self.task = MetaTask.objects.create(name='Assertion')
self.relation = Relation.objects.create(label='causes')

def test_create_persists_tasks_and_relations(self):
resp = self.client.post(
'/api/project-admin/projects/create/',
{
'name': 'with-meta-tasks',
'dataset': self.base.dataset_id,
'concept_db': self.base.concept_db_id,
'vocab': self.base.vocab_id,
'cuis': '',
'members': [self.user.id],
'cdb_search_filter': [self.base.concept_db_id],
'tasks': [self.task.id],
'relations': [self.relation.id],
},
format='json',
)
self.assertEqual(resp.status_code, 201, msg=resp.content)
project = ProjectAnnotateEntities.objects.get(name='with-meta-tasks')
self.assertIn(self.task, project.tasks.all())
self.assertIn(self.relation, project.relations.all())
self.assertEqual(resp.json()['tasks'], [self.task.id])
self.assertEqual(resp.json()['relations'], [self.relation.id])

def test_update_persists_tasks_and_relations(self):
project = create_basic_project(name='update-tasks-proj')
project.members.add(self.user)
other_task = MetaTask.objects.create(name='Experiencer')

resp = self.client.put(
f'/api/project-admin/projects/{project.id}/',
{
'name': project.name,
'dataset': project.dataset_id,
'concept_db': project.concept_db_id,
'vocab': project.vocab_id,
'cuis': '',
'members': [self.user.id],
'cdb_search_filter': [project.concept_db_id],
'tasks': [self.task.id, other_task.id],
'relations': [self.relation.id],
},
format='json',
)
self.assertEqual(resp.status_code, 200, msg=resp.content)
project.refresh_from_db()
self.assertEqual(set(project.tasks.values_list('id', flat=True)), {self.task.id, other_task.id})
self.assertEqual(list(project.relations.values_list('id', flat=True)), [self.relation.id])

def test_update_can_clear_tasks_and_relations(self):
project = create_basic_project(name='clear-tasks-proj')
project.members.add(self.user)
project.tasks.add(self.task)
project.relations.add(self.relation)

resp = self.client.put(
f'/api/project-admin/projects/{project.id}/',
{
'name': project.name,
'dataset': project.dataset_id,
'concept_db': project.concept_db_id,
'vocab': project.vocab_id,
'cuis': '',
'members': [self.user.id],
'cdb_search_filter': [project.concept_db_id],
'tasks': [],
'relations': [],
},
format='json',
)
self.assertEqual(resp.status_code, 200, msg=resp.content)
project.refresh_from_db()
self.assertEqual(project.tasks.count(), 0)
self.assertEqual(project.relations.count(), 0)


@override_settings(MEDIA_ROOT='/tmp/mct-tests-views')
class ModelLoadedTests(TestCase):
def setUp(self):
Expand Down
94 changes: 50 additions & 44 deletions medcat-trainer/webapp/api/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,34 @@

logger = logging.getLogger(__name__)


def _parse_m2m_ids(data, key):
"""Parse a many-to-many ID list from JSON or multipart form data."""
try:
value = data.get(key) if hasattr(data, 'get') else data[key]
except (KeyError, TypeError):
return []

if isinstance(value, list):
raw = value
elif hasattr(data, 'getlist'):
raw = data.getlist(key)
elif value in (None, ''):
return []
else:
raw = [value]

ids = []
for item in raw:
if item in (None, ''):
continue
try:
ids.append(int(item))
except (TypeError, ValueError):
logger.warning('Skipping non-integer %s value: %r', key, item)
return ids


# Get the basic version of MedCAT
cat = None

Expand Down Expand Up @@ -1315,24 +1343,17 @@ def project_admin_detail(request, project_id):
data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data)

# Extract many-to-many fields - handle both JSON (list) and FormData (getlist)
cdb_search_filter_ids = []
try:
cdb_search_filter_ids = [int(x) for x in request.data['cdb_search_filter'] if x]
except (ValueError, TypeError) as e:
logger.warning(f"Error parsing cdb_search_filter: {e}")
cdb_search_filter_ids = []

members_ids = []
try:
members_ids = [int(x) for x in request.data['members'] if x]
except (ValueError, TypeError) as e:
logger.warning(f"Error parsing members: {e}")
members_ids = []
cdb_search_filter_ids = _parse_m2m_ids(request.data, 'cdb_search_filter')
members_ids = _parse_m2m_ids(request.data, 'members')
task_ids = _parse_m2m_ids(request.data, 'tasks')
relation_ids = _parse_m2m_ids(request.data, 'relations')

# Set many-to-many fields to the extracted IDs (or empty list)
# This satisfies serializer validation, then we'll set them properly after save
data['members'] = members_ids if members_ids else []
data['cdb_search_filter'] = cdb_search_filter_ids if cdb_search_filter_ids else []
data['members'] = members_ids
data['cdb_search_filter'] = cdb_search_filter_ids
data['tasks'] = task_ids
data['relations'] = relation_ids

# Convert string booleans to actual booleans
boolean_fields = ['project_locked', 'annotation_classification', 'require_entity_validation',
Expand All @@ -1351,6 +1372,8 @@ def project_admin_detail(request, project_id):
# Handle many-to-many fields manually after saving
project.cdb_search_filter.set(cdb_search_filter_ids)
project.members.set(members_ids)
project.tasks.set(task_ids)
project.relations.set(relation_ids)
return Response(ProjectAnnotateEntitiesSerializer(project).data)
except Exception as e:
logger.error(f"Error saving project {project_id}: {e}", exc_info=e)
Expand All @@ -1372,33 +1395,10 @@ def project_admin_create(request):
"""
# Handle both JSON and FormData
# Extract many-to-many fields - handle both JSON (list) and FormData (getlist)
cdb_search_filter_ids = []
try:
if isinstance(request.data.get('cdb_search_filter'), list):
# JSON request - already a list
cdb_search_filter_ids = [int(x) for x in request.data['cdb_search_filter'] if x]
elif hasattr(request.data, 'getlist'):
# FormData request - use getlist()
cdb_filter_list = request.data.getlist('cdb_search_filter')
if cdb_filter_list:
cdb_search_filter_ids = [int(x) for x in cdb_filter_list if x and str(x).strip()]
except (ValueError, TypeError) as e:
logger.warning(f"Error parsing cdb_search_filter: {e}")
cdb_search_filter_ids = []

members_ids = []
try:
if isinstance(request.data.get('members'), list):
# JSON request - already a list
members_ids = [int(x) for x in request.data['members'] if x]
elif hasattr(request.data, 'getlist'):
# FormData request - use getlist()
members_list = request.data.getlist('members')
if members_list:
members_ids = [int(x) for x in members_list if x and str(x).strip()]
except (ValueError, TypeError) as e:
logger.warning(f"Error parsing members: {e}")
members_ids = []
cdb_search_filter_ids = _parse_m2m_ids(request.data, 'cdb_search_filter')
members_ids = _parse_m2m_ids(request.data, 'members')
task_ids = _parse_m2m_ids(request.data, 'tasks')
relation_ids = _parse_m2m_ids(request.data, 'relations')

# Build data dict - use the actual member IDs we extracted, or empty list if none
# The serializer will validate with these, then we'll set them properly after save
Expand All @@ -1409,15 +1409,19 @@ def project_admin_create(request):

# Set many-to-many fields to the extracted IDs (or empty list)
# This satisfies serializer validation, then we'll set them properly after save
data['members'] = members_ids if members_ids else []
data['cdb_search_filter'] = cdb_search_filter_ids if cdb_search_filter_ids else []
data['members'] = members_ids
data['cdb_search_filter'] = cdb_search_filter_ids
data['tasks'] = task_ids
data['relations'] = relation_ids

serializer = ProjectAnnotateEntitiesSerializer(data=data)
if serializer.is_valid():
project = serializer.save()
# Handle many-to-many fields manually after saving
project.cdb_search_filter.set(cdb_search_filter_ids)
project.members.set(members_ids)
project.tasks.set(task_ids)
project.relations.set(relation_ids)
# Add the creator as a member if not already included
if request.user not in project.members.all():
project.members.add(request.user)
Expand Down Expand Up @@ -1462,6 +1466,8 @@ def project_admin_clone(request, project_id):
project_copy.cdb_search_filter.add(c)
for t in project.tasks.all():
project_copy.tasks.add(t)
for r in project.relations.all():
project_copy.relations.add(r)

project_copy.save()
serializer = ProjectAnnotateEntitiesSerializer(project_copy)
Expand Down
Loading
Loading