diff --git a/medcat-trainer/docs/plugins.md b/medcat-trainer/docs/plugins.md index 4e55bcbe5..1b1978c30 100644 --- a/medcat-trainer/docs/plugins.md +++ b/medcat-trainer/docs/plugins.md @@ -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. @@ -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 @@ -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"; diff --git a/medcat-trainer/webapp/api/api/extensions.py b/medcat-trainer/webapp/api/api/extensions.py index 148ff917d..4785eff27 100644 --- a/medcat-trainer/webapp/api/api/extensions.py +++ b/medcat-trainer/webapp/api/api/extensions.py @@ -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) @@ -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", diff --git a/medcat-trainer/webapp/api/api/model_import.py b/medcat-trainer/webapp/api/api/model_import.py new file mode 100644 index 000000000..1406c2bae --- /dev/null +++ b/medcat-trainer/webapp/api/api/model_import.py @@ -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 diff --git a/medcat-trainer/webapp/api/api/tests/test_model_import.py b/medcat-trainer/webapp/api/api/tests/test_model_import.py new file mode 100644 index 000000000..6d9ac5edd --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_model_import.py @@ -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') diff --git a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue index 66393090f..ed7028beb 100644 --- a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue +++ b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue @@ -463,6 +463,7 @@
+