Skip to content
Open
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
28 changes: 27 additions & 1 deletion medcat-trainer/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Emitted by the core app; plugins connect receivers (in `AppConfig.ready()`):
| `annotation_created` / `annotation_updated` / `annotation_deleted` | annotation row change | `annotation`, `project`, `document`, (`user`) |
| `project_group_created` / `project_group_updated` | project group change | `project_group` |
| `user_oidc_resolved` | after OIDC user resolution | `user`, `id_token`, `created` |
| `model_pack_imported` | after `import_model_pack()` succeeds | `model_pack`, `user`, `description`, `source_uri` |

Receivers should be cheap and must not assume they can block the core flow —
exceptions are logged and swallowed.
Expand Down Expand Up @@ -140,6 +141,30 @@ register_route({"path": "/ee/adj", "component": "Adjudication"})
`route`/`href`/`path` must be relative paths or `http(s)` URLs; other schemes
raise `ValueError` at registration time.

### Model import

Plugins that pull model packs from an external registry (e.g. MedCATtery) should
use the stable helper rather than re-implementing upload/unpack:

```python
from api.model_import import ImportModelPackError, import_model_pack

try:
model_pack = import_model_pack(
"/tmp/snomed_v3.zip",
name="snomed-v3",
user=request.user,
description="Imported from MedCATtery",
source_uri="https://medcattery.example/models/snomed/3",
)
except ImportModelPackError as exc:
...
```

`import_model_pack` creates a `ModelPack` (and linked `ConceptDB` / `Vocabulary`
via the normal `ModelPack.save()` path) and emits `model_pack_imported`.
Annotation projects reference `model_pack.id`, not the CDB directly.

## Backend API endpoints — required auth pattern

Plugin URLs are mounted on the **same origin** as the core app under
Expand Down Expand Up @@ -197,7 +222,8 @@ def adjudication_summary(request, project_id):
`registerPlugin({ routes: [...] })`) or described via `register_route` for the
bootstrap payload.
- **UI slots** let a build-time plugin inject components at named slots, e.g.
`home:after-projects`, `project-admin:tabs`, `train-annotations:sidebar`:
`home:after-projects`, `project-admin:tabs`, `project-admin:modelpacks`,
`train-annotations:sidebar`:

```ts
import { registerPlugin } from "@/plugins/registry";
Expand Down
8 changes: 8 additions & 0 deletions medcat-trainer/webapp/api/api/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@
#: kwargs: ``user`` (User), ``id_token`` (dict), ``created`` (bool).
user_oidc_resolved = Signal()

#: Sent after :func:`api.model_import.import_model_pack` successfully registers
#: a model pack. The linked ``concept_db`` and ``vocab`` are available on the
#: ``model_pack`` itself.
#: kwargs: ``model_pack`` (ModelPack), ``user`` (User or None),
#: ``description`` (str or None), ``source_uri`` (str or None).
model_pack_imported = Signal()


# ---------------------------------------------------------------------------
# Signal dispatch (plugin-isolating)
Expand Down Expand Up @@ -290,6 +297,7 @@ def clear_registries() -> None:
"project_group_created",
"project_group_updated",
"user_oidc_resolved",
"model_pack_imported",
"dispatch",
"register_permission_hook",
"get_permission_hooks",
Expand Down
45 changes: 45 additions & 0 deletions medcat-trainer/webapp/api/api/model_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import os
from datetime import datetime, timezone

from django.core.files import File

from api.extensions import dispatch, model_pack_imported
from api.models import ModelPack


class ImportModelPackError(Exception):
"""Raised when a model pack cannot be registered from a source archive."""


def import_model_pack(src_zip, *, name, user=None, description=None, source_uri=None):
"""Register a model pack zip already on disk as a new ModelPack.

Copies ``src_zip`` into ``MEDIA_ROOT``, unpacks/links CDB/Vocab via
``ModelPack.save()``, and emits ``model_pack_imported``.
"""
stripped_name = (name or '').strip()
if not stripped_name:
raise ImportModelPackError('Model pack name is required.')

if ModelPack.objects.filter(name=stripped_name).exists():
raise ImportModelPackError(f'A model pack named "{stripped_name}" already exists.')

if not os.path.isfile(src_zip):
raise ImportModelPackError(f'Model pack archive not found: {src_zip}')

stamp = datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')
dest_name = f'modelpacks/{stripped_name}-{stamp}.zip'

model_pack = ModelPack(name=stripped_name, last_modified_by=user)
with open(src_zip, 'rb') as fh:
model_pack.model_pack.save(dest_name, File(fh), save=False)
model_pack.save()

dispatch(
model_pack_imported,
model_pack=model_pack,
user=user,
description=description,
source_uri=source_uri,
)
return model_pack
62 changes: 62 additions & 0 deletions medcat-trainer/webapp/api/api/tests/test_model_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import os
import tempfile
from unittest.mock import patch

from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings

from api.extensions import model_pack_imported
from api.model_import import ImportModelPackError, import_model_pack
from api.models import ConceptDB, ModelPack


@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
class ImportModelPackTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='importer', password='pass')
self.src_zip = os.path.join(settings.MEDIA_ROOT, 'incoming.zip')
with open(self.src_zip, 'wb') as fh:
fh.write(b'fake-model-pack-zip')

def test_rejects_empty_name(self):
with self.assertRaises(ImportModelPackError):
import_model_pack(self.src_zip, name=' ')

def test_rejects_duplicate_name(self):
existing = ModelPack(name='snomed-v1')
existing.save(skip_load=True)
with self.assertRaises(ImportModelPackError):
import_model_pack(self.src_zip, name='snomed-v1')

@patch('api.model_import.dispatch')
def test_import_from_path_registers_model_pack(self, mock_dispatch):
from django.db import models as django_models

def fake_save(self, *args, **kwargs):
"""Stand in for ModelPack.save() without unpacking a real archive."""
cdb = ConceptDB(name=f'{self.name}_CDB', cdb_file='imported/cdb.dat')
cdb.save(skip_load=True)
self.concept_db = cdb
django_models.Model.save(self)

with patch.object(ModelPack, 'save', autospec=True, side_effect=fake_save):
model_pack = import_model_pack(
self.src_zip,
name='medcattery-snomed',
user=self.user,
description='Pulled from MedCATtery',
source_uri='https://medcattery.example/models/snomed/3',
)

self.assertEqual(model_pack.name, 'medcattery-snomed')
self.assertEqual(model_pack.last_modified_by, self.user)
self.assertTrue(model_pack.model_pack.name.startswith('modelpacks/medcattery-snomed-'))
self.assertTrue(os.path.exists(model_pack.model_pack.path))
mock_dispatch.assert_called_once()
signal, kwargs = mock_dispatch.call_args.args[0], mock_dispatch.call_args.kwargs
self.assertIs(signal, model_pack_imported)
self.assertEqual(kwargs['model_pack'], model_pack)
self.assertEqual(kwargs['user'], self.user)
self.assertEqual(kwargs['description'], 'Pulled from MedCATtery')
self.assertEqual(kwargs['source_uri'], 'https://medcattery.example/models/snomed/3')
1 change: 1 addition & 0 deletions medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@

<!-- Model Packs Tab -->
<div v-if="activeTab === 'modelpacks'" class="tab-content admin-section">
<plugin-slot name="project-admin:modelpacks" />
<model-packs-list
v-if="!showModelPackForm && !editingModelPack"
:model-packs="modelPacks"
Expand Down
Loading