From b1df989b8d182d1efd62ddf9b627a1e3a16d86db Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Mon, 27 Jul 2026 11:59:27 +0100 Subject: [PATCH 1/2] fix(medcat-trainer): fix ProjectGroup model validation when modelpack, cdb or vocabs are edited --- medcat-trainer/webapp/api/api/admin/models.py | 4 + medcat-trainer/webapp/api/api/models.py | 9 +++ .../webapp/api/api/tests/test_models.py | 73 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/medcat-trainer/webapp/api/api/admin/models.py b/medcat-trainer/webapp/api/api/admin/models.py index 50cae265d..ecc31e4d5 100644 --- a/medcat-trainer/webapp/api/api/admin/models.py +++ b/medcat-trainer/webapp/api/api/admin/models.py @@ -96,6 +96,10 @@ def _set_proj_from_group(self, proj: ProjectAnnotateEntities, group: ProjectGrou proj.project_status = group.project_status proj.concept_db = group.concept_db proj.vocab = group.vocab + proj.model_pack = group.model_pack + proj.deid_model_annotation = group.deid_model_annotation + proj.use_model_service = group.use_model_service + proj.model_service_url = group.model_service_url proj.require_entity_validation = group.require_entity_validation proj.train_model_on_submit = group.train_model_on_submit proj.add_new_entities = group.add_new_entities diff --git a/medcat-trainer/webapp/api/api/models.py b/medcat-trainer/webapp/api/api/models.py index 0b5d0c095..7cf2505da 100644 --- a/medcat-trainer/webapp/api/api/models.py +++ b/medcat-trainer/webapp/api/api/models.py @@ -499,7 +499,16 @@ class Meta: model_service_url = models.CharField(max_length=500, blank=True, null=True, help_text='URL of the remote MedCAT service API (e.g., http://medcat-service:8000)') + def _normalize_model_config(self): + """ModelPack and CDB/Vocab are mutually exclusive configuration options.""" + if self.model_pack_id: + self.concept_db = None + self.vocab = None + elif self.concept_db_id and self.vocab_id: + self.model_pack = None + def save(self, *args, **kwargs): + self._normalize_model_config() # If using remote model service, skip local model validation if not self.use_model_service: if self.model_pack is None and (self.concept_db is None or self.vocab is None): diff --git a/medcat-trainer/webapp/api/api/tests/test_models.py b/medcat-trainer/webapp/api/api/tests/test_models.py index d48a5f786..a0a8ded59 100644 --- a/medcat-trainer/webapp/api/api/tests/test_models.py +++ b/medcat-trainer/webapp/api/api/tests/test_models.py @@ -12,7 +12,9 @@ MetaAnnotation, MetaTask, MetaTaskValue, + ModelPack, ProjectAnnotateEntities, + ProjectGroup, Relation, Vocabulary, cdb_name_validator, @@ -103,6 +105,77 @@ def test_use_model_service_with_url_skips_model_validation(self): proj.save() self.assertIsNotNone(proj.id) + def test_save_with_model_pack_clears_stale_cdb_vocab(self): + mp = ModelPack(name='normalize-mp') + mp.save(skip_load=True) + proj = self._new_project(concept_db=self.cdb, vocab=self.vocab, model_pack=mp) + proj.save() + proj.refresh_from_db() + self.assertEqual(proj.model_pack_id, mp.id) + self.assertIsNone(proj.concept_db_id) + self.assertIsNone(proj.vocab_id) + + def test_save_amended_model_pack_with_stale_cdb_vocab_succeeds(self): + mp_a = ModelPack(name='normalize-mp-a') + mp_a.save(skip_load=True) + mp_b = ModelPack(name='normalize-mp-b') + mp_b.save(skip_load=True) + proj = self._new_project(concept_db=self.cdb, vocab=self.vocab, model_pack=mp_a) + proj.save() + proj.model_pack = mp_b + proj.save() + proj.refresh_from_db() + self.assertEqual(proj.model_pack_id, mp_b.id) + self.assertIsNone(proj.concept_db_id) + self.assertIsNone(proj.vocab_id) + + def test_save_with_cdb_vocab_clears_stale_model_pack(self): + mp = ModelPack(name='normalize-mp-clear') + mp.save(skip_load=True) + proj = self._new_project(model_pack=mp) + proj.save() + proj.concept_db = self.cdb + proj.vocab = self.vocab + proj.save() + proj.refresh_from_db() + self.assertIsNone(proj.model_pack_id) + self.assertEqual(proj.concept_db_id, self.cdb.id) + self.assertEqual(proj.vocab_id, self.vocab.id) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-models') +class ProjectGroupModelConfigValidationTests(TestCase): + @classmethod + def setUpTestData(cls): + cdb = ConceptDB(name='pg_val_cdb', cdb_file='pg_val_cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name='pg_val_vocab', vocab_file='pg_val_vocab.dat') + vocab.save(skip_load=True) + cls.cdb = cdb + cls.vocab = vocab + cls.dataset = create_dataset(name='pg_val_ds', file_name='pg_val_ds.csv') + + def test_save_amended_model_pack_clears_stale_cdb_vocab(self): + mp_a = ModelPack(name='pg-mp-a') + mp_a.save(skip_load=True) + mp_b = ModelPack(name='pg-mp-b') + mp_b.save(skip_load=True) + group = ProjectGroup( + name='pg-switch-model-pack', + dataset=self.dataset, + concept_db=self.cdb, + vocab=self.vocab, + model_pack=mp_a, + cuis='', + ) + group.save() + group.model_pack = mp_b + group.save() + group.refresh_from_db() + self.assertEqual(group.model_pack_id, mp_b.id) + self.assertIsNone(group.concept_db_id) + self.assertIsNone(group.vocab_id) + @override_settings(MEDIA_ROOT='/tmp/mct-tests-models') class AnnotatedEntitySaveUpdatesProjectTests(TestCase): From 92331632473c36f21bb5ec05330ea4ee0faba89d Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Mon, 27 Jul 2026 13:19:25 +0100 Subject: [PATCH 2/2] fix(medcat-trainer): fix backend tests --- .../webapp/api/api/tests/test_models.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_models.py b/medcat-trainer/webapp/api/api/tests/test_models.py index a0a8ded59..4fa7215e2 100644 --- a/medcat-trainer/webapp/api/api/tests/test_models.py +++ b/medcat-trainer/webapp/api/api/tests/test_models.py @@ -129,7 +129,9 @@ def test_save_amended_model_pack_with_stale_cdb_vocab_succeeds(self): self.assertIsNone(proj.concept_db_id) self.assertIsNone(proj.vocab_id) - def test_save_with_cdb_vocab_clears_stale_model_pack(self): + def test_save_prefers_model_pack_when_cdb_vocab_also_set(self): + # When both are present (e.g. stale CDB/Vocab after a ModelPack change), + # ModelPack wins and CDB/Vocab are cleared. mp = ModelPack(name='normalize-mp-clear') mp.save(skip_load=True) proj = self._new_project(model_pack=mp) @@ -138,6 +140,20 @@ def test_save_with_cdb_vocab_clears_stale_model_pack(self): proj.vocab = self.vocab proj.save() proj.refresh_from_db() + self.assertEqual(proj.model_pack_id, mp.id) + self.assertIsNone(proj.concept_db_id) + self.assertIsNone(proj.vocab_id) + + def test_save_with_cdb_vocab_after_clearing_model_pack(self): + mp = ModelPack(name='normalize-mp-switch') + mp.save(skip_load=True) + proj = self._new_project(model_pack=mp) + proj.save() + proj.model_pack = None + proj.concept_db = self.cdb + proj.vocab = self.vocab + proj.save() + proj.refresh_from_db() self.assertIsNone(proj.model_pack_id) self.assertEqual(proj.concept_db_id, self.cdb.id) self.assertEqual(proj.vocab_id, self.vocab.id)