From 21525ae4a27bbc874b72a9403d636418c312cdd4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 11:05:28 +0000 Subject: [PATCH] feat(medcat-trainer): run a model over a project dataset Expose the project-list Run action as POST /api/run-model/ and add a client method so a configured project can be annotated by its model without using the UI. Background runs use the existing doc_prep worker; small datasets can run inside the request. Co-authored-by: tomolopolis --- medcat-trainer/client/README.md | 4 + medcat-trainer/client/mctclient.py | 236 +++++++++++++++ medcat-trainer/client/tests/test_mctclient.py | 230 ++++++++++++++- medcat-trainer/docs/advanced_usage.md | 25 ++ medcat-trainer/docs/client.md | 14 + medcat-trainer/docs/project_admin.md | 4 +- .../webapp/api/api/tests/test_run_model.py | 271 ++++++++++++++++++ .../webapp/api/api/tests/test_views.py | 4 +- medcat-trainer/webapp/api/api/utils.py | 93 ++++-- medcat-trainer/webapp/api/api/views.py | 245 +++++++++++++--- medcat-trainer/webapp/api/core/urls.py | 3 + 11 files changed, 1058 insertions(+), 71 deletions(-) create mode 100644 medcat-trainer/webapp/api/api/tests/test_run_model.py diff --git a/medcat-trainer/client/README.md b/medcat-trainer/client/README.md index cfebcc7d9..0efb1fba3 100644 --- a/medcat-trainer/client/README.md +++ b/medcat-trainer/client/README.md @@ -101,6 +101,10 @@ session = MedCATTrainerSession( - `get_projects()` - `get_datasets()` - `get_project_annos(projects)` +- `run_model(project, background=True, wait=False, timeout=None)` +- `get_model_run(project)` +- `stop_model_run(project)` +- `get_model_runs()` Each method returns the corresponding object or a list of objects. diff --git a/medcat-trainer/client/mctclient.py b/medcat-trainer/client/mctclient.py index e38eb2841..9c2401777 100644 --- a/medcat-trainer/client/mctclient.py +++ b/medcat-trainer/client/mctclient.py @@ -239,6 +239,28 @@ def __str__(self): return f'{self.id} : {self.name} \t {self.description} \t {self.dataset}' +@dataclass +class MCTModelRun: + """A run of a project's model over that project's dataset. + + ``status`` is one of ``queued``, ``running``, ``complete``, ``idle``, + ``failed``, or ``stopped``. ``idle`` means no run is in progress and some + documents are still unprepared. ``complete`` means every document has + either been prepared, annotated, or submitted. + """ + project_id: Union[str, int] = None + job_id: Optional[int] = None + status: Optional[str] = None + dataset_len: Optional[int] = None + prepared_docs: Optional[int] = None + documents_queued: Optional[int] = None + documents_remaining: Optional[int] = None + already_running: bool = False + last_error: Optional[str] = None + + def __str__(self): + return f'{self.project_id} : {self.status} \t job {self.job_id}' + @dataclass class MCTProjectGroup(MCTObj): @@ -360,6 +382,12 @@ class MedCATTrainerSession: To download annotations for a project: >>> projects = session.get_projects() >>> annotations = session.get_project_annos(projects[0]) + + Once a project exists, run its model over the project dataset. Background + runs are picked up by the MedCATTrainer ``process_tasks`` worker. Pass + ``background=False`` to run inside the request, or ``wait=True`` to poll + until the worker finishes. + >>> run = session.run_model(project, wait=True, timeout=3600) """ def __init__(self, server=None, username=None, password=None, keycloak_settings=None, use_oidc: bool = False): @@ -1021,6 +1049,214 @@ def upload_projects_export(self, projects: Dict[str, Any], else: raise MCTUtilsException(f'Failed to upload projects export: {resp.text}') + def _resolve_project_id(self, project: Union[MCTProject, int, str]) -> int: + """Return a project id from a wrapper, an id, or a unique project name.""" + if isinstance(project, MCTProject): + if project.id is None: + if not project.name: + raise MCTUtilsException('Project id is required to run the model') + project = project.name + else: + return int(project.id) + if isinstance(project, int) or (isinstance(project, str) and str(project).isdigit()): + return int(project) + if isinstance(project, str): + matches = [item for item in self.get_projects() if item.name == project] + if len(matches) != 1: + if matches: + raise MCTUtilsException( + f'Multiple projects named {project} were found; pass a project id' + ) + raise MCTUtilsException(f'Project not found in MedCATTrainer instance: {project}') + return int(matches[0].id) + raise MCTUtilsException(f'Unsupported project reference: {project}') + + def _response_payload(self, resp) -> Dict[str, Any]: + data = None + json_fn = getattr(resp, 'json', None) + if callable(json_fn): + try: + data = json_fn() + except Exception: + data = None + if isinstance(data, dict): + return data + try: + parsed = json.loads(getattr(resp, 'text', None) or '{}') + except Exception as exc: + raise MCTUtilsException('Failed to parse model run response', exc) + if isinstance(parsed, dict): + return parsed + return {} + + def _model_run_from_payload(self, project_id, payload: Dict[str, Any]) -> MCTModelRun: + return MCTModelRun( + project_id=payload.get('project_id', payload.get('proj_id', project_id)), + job_id=payload.get('bg_job_id', payload.get('job_id')), + status=payload.get('status'), + dataset_len=payload.get('dataset_len'), + prepared_docs=payload.get('prepd_docs_len', payload.get('prepared_docs')), + documents_queued=payload.get('documents_queued'), + documents_remaining=payload.get('documents_remaining'), + already_running=bool(payload.get('already_running', False)), + last_error=payload.get('last_error'), + ) + + def run_model(self, project: Union[MCTProject, int, str], + background: bool = True, + wait: bool = False, + timeout: Optional[float] = None, + poll_interval: float = 2.0) -> MCTModelRun: + """Run the project's model over its dataset. + + This is the project-list Run action. With ``background=True`` (the default) + the server queues a ``doc_prep`` task and returns once it is accepted. + The MedCATTrainer worker (``manage.py process_tasks``) performs the run. + + ``wait=True`` polls until that task leaves the queue. ``timeout`` is the + number of seconds to wait before raising ``MCTUtilsException``. + + ``background=False`` runs the model inside the HTTP request. Use it for + small datasets when a background worker is unavailable. ``timeout`` is + then the HTTP timeout. + + Args: + project (Union[MCTProject, int, str]): Project wrapper, id, or unique name. + background (bool): Queue the run for the worker. Defaults to True. + wait (bool): Poll until the queued run finishes. Defaults to False. + timeout (float, optional): Wait limit in seconds. Defaults to None. + poll_interval (float): Seconds between status polls. Defaults to 2. + + Raises: + MCTUtilsException: If the run cannot be started, fails, or times out. + + Returns: + MCTModelRun: The queued, finished, or in-progress model run. + """ + self.ensure_token_fresh() + project_id = self._resolve_project_id(project) + payload = {'project_id': project_id, 'background': background} + post_kwargs = {'json': payload, 'headers': self.headers} + if not background and timeout is not None: + post_kwargs['timeout'] = timeout + resp = requests.post(f'{self.server}/api/run-model/', **post_kwargs) + if not (200 <= resp.status_code < 300): + raise MCTUtilsException( + f'Failed to run model for project {project_id}', + f'HTTP {resp.status_code}: {getattr(resp, "text", "")[:500]}', + ) + run = self._model_run_from_payload(project_id, self._response_payload(resp)) + if background and wait: + finished = self._wait_for_model_run(project_id, timeout, poll_interval) + if finished.job_id is None: + finished.job_id = run.job_id + if finished.documents_queued is None: + finished.documents_queued = run.documents_queued + finished.already_running = run.already_running + return finished + return run + + def get_model_run(self, project: Union[MCTProject, int, str]) -> MCTModelRun: + """Return the current model-run status for a project. + + Args: + project (Union[MCTProject, int, str]): Project wrapper, id, or unique name. + + Raises: + MCTUtilsException: If the status request fails. + + Returns: + MCTModelRun: Current dataset progress and run status. + """ + self.ensure_token_fresh() + project_id = self._resolve_project_id(project) + resp = requests.get(f'{self.server}/api/run-model/{project_id}/', headers=self.headers) + if not (200 <= resp.status_code < 300): + raise MCTUtilsException( + f'Failed to get model run status for project {project_id}', + f'HTTP {resp.status_code}: {getattr(resp, "text", "")[:500]}', + ) + return self._model_run_from_payload(project_id, self._response_payload(resp)) + + def stop_model_run(self, project: Union[MCTProject, int, str]) -> MCTModelRun: + """Stop a queued model run for a project. + + Args: + project (Union[MCTProject, int, str]): Project wrapper, id, or unique name. + + Raises: + MCTUtilsException: If there is no queued run to stop, or the request fails. + + Returns: + MCTModelRun: The stopped run. + """ + self.ensure_token_fresh() + project_id = self._resolve_project_id(project) + resp = requests.delete(f'{self.server}/api/run-model/{project_id}/', headers=self.headers) + if not (200 <= resp.status_code < 300): + raise MCTUtilsException( + f'Failed to stop model run for project {project_id}', + f'HTTP {resp.status_code}: {getattr(resp, "text", "")[:500]}', + ) + payload = self._response_payload(resp) + if payload: + run = self._model_run_from_payload(project_id, payload) + if run.status is None: + run.status = 'stopped' + return run + return MCTModelRun(project_id=project_id, status='stopped') + + def get_model_runs(self) -> Dict[str, List[Dict[str, Any]]]: + """List background model runs known to the server. + + Returns: + Dict[str, List[Dict[str, Any]]]: ``running`` and ``complete`` lists. + Each item has ``project_id`` and ``user_id``. + + Raises: + MCTUtilsException: If the list request fails. + """ + self.ensure_token_fresh() + resp = requests.get(f'{self.server}/api/run-model/', headers=self.headers) + if not (200 <= resp.status_code < 300): + raise MCTUtilsException( + 'Failed to list model runs', + f'HTTP {resp.status_code}: {getattr(resp, "text", "")[:500]}', + ) + payload = self._response_payload(resp) + + def _normalise(tasks): + return [ + { + 'project_id': task.get('project', task.get('project_id')), + 'user_id': task.get('user_id'), + } + for task in tasks or [] + ] + + return { + 'running': _normalise(payload.get('running_tasks')), + 'complete': _normalise(payload.get('comp_tasks')), + } + + def _wait_for_model_run(self, project_id: int, timeout: Optional[float], poll_interval: float) -> MCTModelRun: + started = time.monotonic() + while True: + current = self.get_model_run(project_id) + if current.status == 'failed': + raise MCTUtilsException( + f'Model run failed for project {project_id}', + current.last_error, + ) + if current.status not in ('queued', 'running'): + return current + if timeout is not None and (time.monotonic() - started) > timeout: + raise MCTUtilsException( + f'Timed out waiting for model run on project {project_id}. ' + f'The MedCATTrainer process_tasks worker must be running.' + ) + time.sleep(poll_interval) + def __str__(self) -> str: return f'{self.server} \t {self.username} \t {self.password}' diff --git a/medcat-trainer/client/tests/test_mctclient.py b/medcat-trainer/client/tests/test_mctclient.py index 128264a87..69a292b32 100644 --- a/medcat-trainer/client/tests/test_mctclient.py +++ b/medcat-trainer/client/tests/test_mctclient.py @@ -5,7 +5,7 @@ from mctclient import ( MedCATTrainerSession, KeycloakSettings, MCTUtilsException, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, - MCTUser, MCTProject, MCTProjectGroup + MCTUser, MCTProject, MCTProjectGroup, MCTModelRun ) class TestMCTClient(unittest.TestCase): @@ -992,5 +992,233 @@ def test_get_project_groups(self, mock_get, mock_post): mock_get.assert_called_with('http://localhost/api/project-groups/', headers=session.headers) +def _auth_post(url, *args, **kwargs): + if url.endswith('/api/api-token-auth/'): + return MagicMock(status_code=200, text='{"token": "abc"}') + return None + + +class TestRunModel(unittest.TestCase): + + def _session(self, mock_post): + mock_post.side_effect = _auth_post + return MedCATTrainerSession(server='http://localhost', username='u', password='p') + + @patch('mctclient.requests.post') + def test_run_model_queues_background_job(self, mock_post): + def post_side_effect(url, *args, **kwargs): + auth = _auth_post(url) + if auth is not None: + return auth + self.assertTrue(url.endswith('/api/run-model/')) + self.assertEqual(kwargs['json'], {'project_id': 3, 'background': True}) + self.assertEqual(kwargs['headers']['Authorization'], 'Token abc') + return MagicMock( + status_code=200, + text='', + json=lambda: { + 'bg_job_id': 12, + 'project_id': 3, + 'documents_queued': 4, + 'status': 'queued', + 'already_running': False, + }, + ) + + mock_post.side_effect = post_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + run = session.run_model(MCTProject(id=3, name='Demo')) + self.assertIsInstance(run, MCTModelRun) + self.assertEqual(run.job_id, 12) + self.assertEqual(run.status, 'queued') + self.assertEqual(run.documents_queued, 4) + self.assertEqual(run.project_id, 3) + self.assertFalse(run.already_running) + + @patch('mctclient.requests.post') + def test_run_model_accepts_project_id_and_sync_flag(self, mock_post): + def post_side_effect(url, *args, **kwargs): + auth = _auth_post(url) + if auth is not None: + return auth + self.assertEqual(kwargs['json']['project_id'], 8) + self.assertFalse(kwargs['json']['background']) + self.assertEqual(kwargs['timeout'], 15) + return MagicMock( + status_code=200, + json=lambda: { + 'project_id': 8, + 'status': 'complete', + 'dataset_len': 2, + 'prepd_docs_len': 2, + 'documents_queued': 2, + 'documents_prepared': 2, + }, + ) + + mock_post.side_effect = post_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + run = session.run_model(8, background=False, timeout=15) + self.assertEqual(run.status, 'complete') + self.assertEqual(run.dataset_len, 2) + self.assertEqual(run.prepared_docs, 2) + + @patch('mctclient.requests.get') + @patch('mctclient.requests.post') + def test_run_model_resolves_project_name(self, mock_post, mock_get): + mock_post.side_effect = _auth_post + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + + def post_side_effect(url, *args, **kwargs): + auth = _auth_post(url) + if auth is not None: + return auth + self.assertEqual(kwargs['json']['project_id'], 9) + return MagicMock(status_code=200, json=lambda: {'bg_job_id': 1, 'project_id': 9, 'status': 'queued'}) + + mock_post.side_effect = post_side_effect + mock_get.return_value = MagicMock( + status_code=200, + text=json.dumps({'results': [{ + 'id': 9, + 'name': 'Cardio', + 'description': '', + 'cuis': '', + 'create_time': '2021-01-01T00:00:00Z', + 'last_modified': '2021-01-01T00:00:00Z', + 'annotation_classification': False, + 'project_locked': False, + 'project_status': 'A', + 'deid_model_annotation': False, + 'validated_documents': [], + 'dataset': 2, + 'concept_db': None, + 'vocab': None, + 'members': [], + 'tasks': [], + 'relations': [], + }]}), + ) + run = session.run_model('Cardio') + self.assertEqual(run.project_id, 9) + self.assertEqual(run.job_id, 1) + + @patch('mctclient.requests.post') + def test_run_model_raises_on_api_error(self, mock_post): + def post_side_effect(url, *args, **kwargs): + auth = _auth_post(url) + if auth is not None: + return auth + return MagicMock(status_code=403, text='{"error": "forbidden"}') + + mock_post.side_effect = post_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + with self.assertRaises(MCTUtilsException) as raised: + session.run_model(3) + self.assertIn('403', str(raised.exception)) + + @patch('mctclient.time.sleep', return_value=None) + @patch('mctclient.requests.get') + @patch('mctclient.requests.post') + def test_run_model_waits_until_complete(self, mock_post, mock_get, _sleep): + def post_side_effect(url, *args, **kwargs): + auth = _auth_post(url) + if auth is not None: + return auth + return MagicMock(status_code=200, json=lambda: { + 'bg_job_id': 5, 'project_id': 3, 'status': 'queued', 'documents_queued': 2, + }) + + statuses = [ + {'status': 'running', 'bg_job_id': 5, 'project_id': 3, 'dataset_len': 2, 'prepd_docs_len': 1}, + {'status': 'complete', 'bg_job_id': None, 'project_id': 3, 'dataset_len': 2, 'prepd_docs_len': 2, + 'documents_remaining': 0}, + ] + + def get_side_effect(url, *args, **kwargs): + self.assertTrue(url.endswith('/api/run-model/3/')) + return MagicMock(status_code=200, json=lambda payload=statuses.pop(0): payload) + + mock_post.side_effect = post_side_effect + mock_get.side_effect = get_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + run = session.run_model(3, wait=True, timeout=30, poll_interval=0) + self.assertEqual(run.status, 'complete') + self.assertEqual(run.job_id, 5) + self.assertEqual(run.prepared_docs, 2) + self.assertEqual(run.documents_queued, 2) + self.assertEqual(mock_get.call_count, 2) + + @patch('mctclient.time.sleep', return_value=None) + @patch('mctclient.time.monotonic', side_effect=[0, 0, 10, 10]) + @patch('mctclient.requests.get') + @patch('mctclient.requests.post') + def test_run_model_wait_times_out(self, mock_post, mock_get, _monotonic, _sleep): + def post_side_effect(url, *args, **kwargs): + auth = _auth_post(url) + if auth is not None: + return auth + return MagicMock(status_code=200, json=lambda: {'bg_job_id': 5, 'project_id': 3, 'status': 'queued'}) + + mock_post.side_effect = post_side_effect + mock_get.return_value = MagicMock( + status_code=200, + json=lambda: {'status': 'running', 'bg_job_id': 5, 'project_id': 3}, + ) + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + with self.assertRaises(MCTUtilsException) as raised: + session.run_model(3, wait=True, timeout=5, poll_interval=0) + self.assertIn('process_tasks', str(raised.exception)) + + @patch('mctclient.requests.get') + @patch('mctclient.requests.post') + def test_get_model_run_and_list(self, mock_post, mock_get): + mock_post.return_value = MagicMock(status_code=200, text='{"token": "abc"}') + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + + def get_side_effect(url, *args, **kwargs): + if url.endswith('/api/run-model/3/'): + return MagicMock(status_code=200, json=lambda: { + 'status': 'idle', 'project_id': 3, 'dataset_len': 4, 'prepd_docs_len': 1, + 'documents_remaining': 3, + }) + if url.endswith('/api/run-model/'): + return MagicMock(status_code=200, json=lambda: { + 'running_tasks': [{'project': 3, 'user_id': 1}], + 'comp_tasks': [{'project': 4, 'user_id': 2}], + }) + return MagicMock(status_code=404, text='') + + mock_get.side_effect = get_side_effect + status = session.get_model_run(MCTProject(id='3', name='Demo')) + self.assertEqual(status.status, 'idle') + self.assertEqual(status.prepared_docs, 1) + self.assertEqual(status.documents_remaining, 3) + listed = session.get_model_runs() + self.assertEqual(listed['running'], [{'project_id': 3, 'user_id': 1}]) + self.assertEqual(listed['complete'], [{'project_id': 4, 'user_id': 2}]) + + @patch('mctclient.requests.delete') + @patch('mctclient.requests.post') + def test_stop_model_run(self, mock_post, mock_delete): + mock_post.return_value = MagicMock(status_code=200, text='{"token": "abc"}') + mock_delete.return_value = MagicMock( + status_code=200, + json=lambda: {'project_id': 3, 'status': 'stopped', 'message': 'Successfully stopped running response'}, + ) + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + stopped = session.stop_model_run(3) + self.assertEqual(stopped.status, 'stopped') + mock_delete.assert_called_once() + self.assertTrue(mock_delete.call_args.args[0].endswith('/api/run-model/3/')) + + @patch('mctclient.requests.post') + def test_run_model_requires_an_id_or_name(self, mock_post): + mock_post.return_value = MagicMock(status_code=200, text='{"token": "abc"}') + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + with self.assertRaises(MCTUtilsException): + session.run_model(MCTProject()) + + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/medcat-trainer/docs/advanced_usage.md b/medcat-trainer/docs/advanced_usage.md index f1c2687e8..85d7e500f 100644 --- a/medcat-trainer/docs/advanced_usage.md +++ b/medcat-trainer/docs/advanced_usage.md @@ -38,6 +38,31 @@ Modern project-admin UI uses dedicated endpoints: - `POST /api/project-admin/projects//clone/` - `POST /api/project-admin/projects//reset/` +## Run a model over a project dataset + +Once a project has a dataset and a model (model pack, CDB + vocab, or a remote +model service), run that model to create the initial annotations: + +- `POST /api/run-model/` with `{"project_id": }` queues the run. The + `process_tasks` worker performs it. The response includes `bg_job_id`, + `documents_queued`, and `status` (`queued`). +- `POST /api/run-model/` with `{"project_id": , "background": false}` runs + the model in the request and returns `status: complete`. +- `GET /api/run-model//` returns `dataset_len`, `prepd_docs_len`, + `documents_remaining`, and `status` (`running`, `complete`, `idle`, or `failed`). +- `DELETE /api/run-model//` stops a queued run. +- `GET /api/run-model/` lists running and completed background runs. + +`/api/prepare-documents-bg/` and `/api/prep-docs-bg-tasks/` are the same +handlers. The project list Run button uses those paths. + +The caller must be allowed to administer the project (staff, a project member, +or a project-group administrator). Documents that already have annotations, +have been submitted, or have already been prepared are left unchanged. Resetting +a project clears that state, so a later run includes those documents again. + +The Python client wraps this as `session.run_model(project)`. + ## Metrics APIs - `GET/POST /api/metrics-job/` (list jobs / submit new report) diff --git a/medcat-trainer/docs/client.md b/medcat-trainer/docs/client.md index 78b7b0b8c..19e77e6c8 100644 --- a/medcat-trainer/docs/client.md +++ b/medcat-trainer/docs/client.md @@ -50,6 +50,11 @@ project = session.create_project( dataset=ds, modelpack=model_packs[0], ) + +# Run the project model over the dataset. Background runs need the +# MedCATTrainer process_tasks worker. background=False runs inside the request. +run = session.run_model(project, wait=True, timeout=3600) +print(run.status, run.prepared_docs, run.dataset_len) ``` ## Common methods @@ -70,9 +75,18 @@ project = session.create_project( - `get_datasets()` - `get_project_annos(projects)` - `upload_projects_export(...)` +- `run_model(project, background=True, wait=False, timeout=None)` +- `get_model_run(project)` +- `stop_model_run(project)` +- `get_model_runs()` ## Notes - `create_project` expects **either** `modelpack` **or** `concept_db + vocab`. - Wrapper objects (`MCTDataset`, `MCTConceptDB`, etc.) can often be passed by object or resolved by name. +- `run_model` accepts a project object, id, or unique name. It runs the + configured model over dataset documents that are still unprepared. + Submitted documents, documents that already have annotations, and documents + that have already been prepared are left unchanged. `wait=True` polls until + the background worker finishes. diff --git a/medcat-trainer/docs/project_admin.md b/medcat-trainer/docs/project_admin.md index eaab994bd..40151aba6 100644 --- a/medcat-trainer/docs/project_admin.md +++ b/medcat-trainer/docs/project_admin.md @@ -113,7 +113,9 @@ Upload CSV or XLSX with at least: From the home **Projects** table: - Open and annotate a project. -- Run document preparation in the background. +- Run document preparation in the background. The same run is available from + the API (`POST /api/run-model/`) and the Python client (`session.run_model`) + once the project exists. - View model-loaded state and clear model cache. - Save current model state. - Select compatible projects and submit a metrics report. diff --git a/medcat-trainer/webapp/api/api/tests/test_run_model.py b/medcat-trainer/webapp/api/api/tests/test_run_model.py new file mode 100644 index 000000000..1c1391bf7 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_run_model.py @@ -0,0 +1,271 @@ +"""Tests for running a project model over its dataset. + +The project-list Run action and ``POST /api/run-model/`` both queue +``prep_docs``. ``background: false`` runs the same code in the request. +""" + +import json +from unittest.mock import MagicMock, patch + +from background_task.models import Task +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from ..models import AnnotatedEntity, Document, Entity, ProjectAnnotateEntities +from ..utils import run_project_model +from ._helpers import create_basic_project, create_document, create_user + + +MEDIA_ROOT = '/tmp/mct-tests-run-model' + + +class _Ent: + def __init__(self, text, cui, start, end, acc=0.9): + self.text = text + self.cui = cui + self.start_char_index = start + self.end_char_index = end + self.context_similarity = acc + + +class _Doc: + def __init__(self, ents): + self.linked_ents = list(ents) + + +def _fake_cat(): + def predict(text): + if 'asthma' in text: + start = text.index('asthma') + return _Doc([_Ent('asthma', 'C0004096', start, start + len('asthma'))]) + return _Doc([]) + + cat = MagicMock(side_effect=predict) + cat.config.components.linking.filters.cuis_exclude = set() + cat.config.components.linking.similarity_threshold = 0.3 + return cat + + +def _auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class RunModelApiTests(TestCase): + def setUp(self): + self.user = create_user(username='runner', is_staff=True) + self.client = _auth_client(self.user) + self.project = create_basic_project(name='run-model-proj') + self.project.cuis = 'C0004096' + self.project.save() + self.pending = create_document(self.project, name='pending', text='asthma is present') + self.annotated = create_document(self.project, name='annotated', text='asthma already marked') + self.validated = create_document(self.project, name='validated', text='asthma was submitted') + self.prepared = create_document(self.project, name='prepared', text='asthma was prepared') + entity = Entity.objects.create(label='C999') + AnnotatedEntity.objects.create( + user=self.user, project=self.project, document=self.annotated, + entity=entity, value='asthma', start_ind=0, end_ind=6, acc=1.0, + ) + self.project.validated_documents.add(self.validated) + self.project.prepared_documents.add(self.prepared) + + def _queued_doc_ids(self): + task = Task.objects.get(queue='doc_prep') + return json.loads(task.task_params)[0][1], task + + def test_background_run_queues_only_unprepared_documents(self): + resp = self.client.post( + '/api/run-model/', + {'project_id': self.project.id}, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + body = resp.json() + self.assertEqual(body['status'], 'queued') + self.assertEqual(body['documents_queued'], 1) + self.assertFalse(body['already_running']) + self.assertIsNotNone(body['bg_job_id']) + + doc_ids, task = self._queued_doc_ids() + self.assertEqual(doc_ids, [self.pending.id]) + self.assertEqual(task.id, body['bg_job_id']) + + # The project-list Run button posts to the original path. + again = self.client.post( + '/api/prepare-documents-bg/', + {'project_id': self.project.id}, + format='json', + ) + self.assertEqual(again.status_code, 200, msg=again.content) + self.assertTrue(again.json()['already_running']) + self.assertEqual(again.json()['bg_job_id'], task.id) + self.assertEqual(Task.objects.filter(queue='doc_prep').count(), 1) + + def test_legacy_task_list_includes_the_queued_run(self): + self.client.post('/api/run-model/', {'project_id': self.project.id}, format='json') + listed = self.client.get('/api/prep-docs-bg-tasks/') + self.assertEqual(listed.status_code, 200, msg=listed.content) + running = listed.json()['running_tasks'] + self.assertEqual(len(running), 1) + self.assertEqual(running[0]['project'], self.project.id) + self.assertEqual(running[0]['user_id'], self.user.id) + + same = self.client.get('/api/run-model/') + self.assertEqual(same.json()['running_tasks'], running) + + @patch('api.utils.get_medcat') + def test_worker_function_stores_predictions(self, mock_get_medcat): + mock_get_medcat.return_value = _fake_cat() + self.client.post('/api/run-model/', {'project_id': self.project.id}, format='json') + doc_ids, _task = self._queued_doc_ids() + + processed = run_project_model(self.project.id, doc_ids, self.user.id) + self.assertEqual(processed, 1) + + ann = AnnotatedEntity.objects.get(document=self.pending, project=self.project) + self.assertEqual(ann.entity.label, 'C0004096') + self.assertEqual(ann.value, 'asthma') + self.assertFalse(ann.deleted) + self.project.refresh_from_db() + prepared_ids = set(self.project.prepared_documents.values_list('id', flat=True)) + self.assertIn(self.pending.id, prepared_ids) + self.assertFalse( + AnnotatedEntity.objects.filter(document=self.validated, project=self.project).exists() + ) + self.assertEqual( + AnnotatedEntity.objects.filter(document=self.annotated, project=self.project).count(), + 1, + ) + + @patch('api.utils.get_medcat') + def test_sync_run_finishes_inside_the_request(self, mock_get_medcat): + cat = _fake_cat() + mock_get_medcat.return_value = cat + resp = self.client.post( + '/api/run-model/', + {'project_id': str(self.project.id), 'background': False}, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + body = resp.json() + self.assertEqual(body['status'], 'complete') + self.assertEqual(body['documents_queued'], 1) + self.assertEqual(body['documents_prepared'], 1) + self.assertEqual(body['documents_remaining'], 0) + self.assertFalse(Task.objects.filter(queue='doc_prep').exists()) + self.assertEqual(cat.config.components.linking.filters.cuis, {'C0004096'}) + self.assertTrue( + AnnotatedEntity.objects.filter(document=self.pending, entity__label='C0004096').exists() + ) + + status = self.client.get(f'/api/run-model/{self.project.id}/') + self.assertEqual(status.status_code, 200, msg=status.content) + self.assertEqual(status.json()['status'], 'complete') + self.assertEqual(status.json()['proj_id'], self.project.id) + self.assertEqual(status.json()['dataset_len'], 4) + + def test_string_false_runs_synchronously(self): + with patch('api.utils.get_medcat', return_value=_fake_cat()): + resp = self.client.post( + '/api/prepare-documents-bg/', + {'project_id': self.project.id, 'background': 'false'}, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + self.assertEqual(resp.json()['status'], 'complete') + self.assertFalse(Task.objects.filter(queue='doc_prep').exists()) + + def test_stop_removes_the_queued_task(self): + started = self.client.post('/api/run-model/', {'project_id': self.project.id}, format='json') + self.assertEqual(started.status_code, 200) + status = self.client.get(f'/api/prep-docs-bg-tasks/{self.project.id}/') + self.assertEqual(status.json()['status'], 'running') + self.assertEqual(status.json()['bg_job_id'], started.json()['bg_job_id']) + + stopped = self.client.delete(f'/api/run-model/{self.project.id}/') + self.assertEqual(stopped.status_code, 200, msg=stopped.content) + self.assertEqual(stopped.json()['status'], 'stopped') + self.assertFalse(Task.objects.filter(queue='doc_prep').exists()) + + missing = self.client.delete(f'/api/run-model/{self.project.id}/') + self.assertEqual(missing.status_code, 400) + + idle = self.client.get(f'/api/run-model/{self.project.id}/') + self.assertEqual(idle.json()['status'], 'idle') + self.assertEqual(idle.json()['documents_remaining'], 1) + + def test_anonymous_and_non_members_cannot_start_a_run(self): + anon = APIClient() + denied = anon.post('/api/run-model/', {'project_id': self.project.id}, format='json') + self.assertIn(denied.status_code, (401, 403)) + + outsider = create_user(username='outsider') + resp = _auth_client(outsider).post( + '/api/run-model/', {'project_id': self.project.id}, format='json', + ) + self.assertEqual(resp.status_code, 403) + + member = create_user(username='member') + self.project.members.add(member) + allowed = _auth_client(member).post( + '/api/run-model/', {'project_id': self.project.id}, format='json', + ) + self.assertEqual(allowed.status_code, 200, msg=allowed.content) + + def test_missing_project_is_404(self): + resp = self.client.post('/api/run-model/', {'project_id': 999999}, format='json') + self.assertEqual(resp.status_code, 404) + missing = self.client.get('/api/run-model/999999/') + self.assertEqual(missing.status_code, 404) + no_id = self.client.post('/api/run-model/', {}, format='json') + self.assertEqual(no_id.status_code, 400) + + @patch('api.utils.call_remote_model_service') + def test_remote_model_service_run(self, mock_remote): + remote = ProjectAnnotateEntities.objects.create( + name='remote-run-proj', + dataset=self.project.dataset, + use_model_service=True, + model_service_url='http://medcat-service:8000', + cuis='', + ) + # The shared dataset's other documents are already annotated, validated, or prepared + # on self.project only. This project has its own annotation state, so every dataset + # document is eligible. + mock_remote.return_value = _Doc([_Ent('asthma', 'C0004096', 0, 6)]) + resp = self.client.post( + '/api/run-model/', + {'project_id': remote.id, 'background': 0}, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + self.assertEqual(resp.json()['documents_prepared'], Document.objects.filter(dataset=remote.dataset).count()) + self.assertGreater(mock_remote.call_count, 0) + self.assertTrue(AnnotatedEntity.objects.filter(project=remote, entity__label='C0004096').exists()) + + @patch('api.utils.get_medcat') + def test_sync_run_with_nothing_left_does_not_load_the_model(self, mock_get_medcat): + self.project.prepared_documents.add(self.pending) + resp = self.client.post( + '/api/run-model/', + {'project_id': self.project.id, 'background': False}, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + self.assertEqual(resp.json()['documents_queued'], 0) + self.assertEqual(resp.json()['status'], 'complete') + mock_get_medcat.assert_not_called() + + @patch('api.utils.get_medcat', side_effect=RuntimeError('model failed to load')) + def test_sync_run_reports_model_errors(self, _mock_get_medcat): + resp = self.client.post( + '/api/run-model/', + {'project_id': self.project.id, 'background': False}, + format='json', + ) + self.assertEqual(resp.status_code, 500, msg=resp.content) + self.assertEqual(resp.json()['status'], 'failed') + self.assertIn('model failed to load', resp.json()['message']) diff --git a/medcat-trainer/webapp/api/api/tests/test_views.py b/medcat-trainer/webapp/api/api/tests/test_views.py index 4e358e935..14d0e5f02 100644 --- a/medcat-trainer/webapp/api/api/tests/test_views.py +++ b/medcat-trainer/webapp/api/api/tests/test_views.py @@ -197,9 +197,9 @@ def setUp(self): for i in range(2): create_document(self.project, name=f'd-{i}', text=f't-{i}') - def test_returns_400_for_unknown_project(self): + def test_returns_404_for_unknown_project(self): resp = self.client.get('/api/prep-docs-bg-tasks/999999/') - self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.status_code, 404) def test_returns_doc_counts_for_known_project(self): resp = self.client.get(f'/api/prep-docs-bg-tasks/{self.project.id}/') diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index 5f7ff39fc..2f1b93eb4 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -419,25 +419,55 @@ def train_medcat(cat, project, document): cat.config.components.linking.filters.cuis_exclude = set() cat.config.components.linking.filters.cuis_exclude.add(cui) -@background(schedule=1, queue='doc_prep') -def prep_docs(project_id: List[int], doc_ids: List[int], user_id: int): - user = User.objects.get(id=user_id) - project = ProjectAnnotateEntities.objects.get(id=project_id) - docs = Document.objects.filter(id__in=doc_ids) - - # Get CUI filters +def project_cui_filters(project) -> set: + """CUIs configured on the project, from the text list and the optional JSON file.""" cuis = set() - if project.cuis is not None and project.cuis: - cuis = set([str(cui).strip() for cui in project.cuis.split(",")]) - if project.cuis_file is not None and project.cuis_file: + if project.cuis: + cuis = {str(cui).strip() for cui in project.cuis.split(',') if str(cui).strip()} + if project.cuis_file: try: - cuis.update(json.load(open(project.cuis_file.path))) + with open(project.cuis_file.path) as cui_file: + cuis.update(json.load(cui_file)) except FileNotFoundError: logger.warning('Missing CUI filter file for project %s', project.id) + return cuis + + +def documents_for_model_run(project): + """Documents the project model still needs to be run over. + + A document is ready for a model run when it belongs to the project dataset, + has no annotations yet, has not been submitted, and has not already been + prepared. Submitted documents and documents that already have annotations + are left unchanged. + """ + annotated_ids = AnnotatedEntity.objects.filter(project=project).values('document_id') + validated_ids = project.validated_documents.values('id') + prepared_ids = project.prepared_documents.values('id') + return ( + Document.objects.filter(dataset=project.dataset) + .exclude(id__in=annotated_ids) + .exclude(id__in=validated_ids) + .exclude(id__in=prepared_ids) + .order_by('id') + ) + + +def run_model_over_documents(project, user, docs) -> int: + """Run the project model over ``docs`` and store the predictions. + + Each document is committed on its own, then marked prepared. Returns the + number of documents processed. + """ + docs = list(docs) + if not docs: + logger.info('No documents left to prepare for project %s', project.id) + return 0 + + cuis = project_cui_filters(project) if project.use_model_service: - # Use remote model service - logger.info('Using remote model service in bg process for project: %s', project.id) + logger.info('Using remote model service for project: %s', project.id) filters = SimpleFilters(cuis=cuis) for doc in docs: logger.info('Running remote MedCAT service for project %s:%s over doc: %s', @@ -455,20 +485,15 @@ def prep_docs(project_id: List[int], doc_ids: List[int], user_id: int): existing_annotations=anns) project.prepared_documents.add(doc) else: - # Use local medcat model - logger.info('Loading CAT object in bg process for project: %s', project.id) + logger.info('Loading CAT object for project: %s', project.id) cat = get_medcat(project=project, addons=["meta_cat"]) - - # Set CAT filters cat.config.components.linking.filters.cuis = cuis + deid = DeIdModel(cat) if project.deid_model_annotation else None for doc in docs: - logger.info('Running MedCAT model for project %s:%s over doc: %s', project.id, project.name, doc.id) - if not project.deid_model_annotation: - spacy_doc = cat(doc.text) - else: - deid = DeIdModel(cat) - spacy_doc = deid(doc.text) + logger.info('Running MedCAT model for project %s:%s over doc: %s', + project.id, project.name, doc.id) + spacy_doc = deid(doc.text) if deid is not None else cat(doc.text) anns = AnnotatedEntity.objects.filter(document=doc).filter(project=project) with transaction.atomic(): add_annotations(spacy_doc=spacy_doc, @@ -478,9 +503,27 @@ def prep_docs(project_id: List[int], doc_ids: List[int], user_id: int): cat=cat, existing_annotations=anns) project.prepared_documents.add(doc) + project.save() - logger.info('Prepared all docs for project: %s, docs processed: %s', - project.id, project.prepared_documents) + logger.info('Prepared documents for project %s, docs processed: %s', project.id, len(docs)) + return len(docs) + + +def run_project_model(project_id: int, doc_ids: List[int], user_id: int) -> int: + """Load the project and user, then run the model over ``doc_ids``. + + This is the body of the ``doc_prep`` background task. Calling it directly + runs the model in the current process. + """ + user = User.objects.get(id=user_id) + project = ProjectAnnotateEntities.objects.get(id=project_id) + docs = Document.objects.filter(id__in=doc_ids).order_by('id') + return run_model_over_documents(project, user, docs) + + +@background(schedule=1, queue='doc_prep') +def prep_docs(project_id: int, doc_ids: List[int], user_id: int): + run_project_model(project_id, doc_ids, user_id) @lru_cache(maxsize=8) diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index 25abe9596..0829ca837 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -1,3 +1,4 @@ +import json import logging import os from smtplib import SMTPException @@ -33,7 +34,15 @@ from .permissions import * from .serializers import * from .solr_utils import collections_available, search_collection, ensure_concept_searchable -from .utils import add_annotations, remove_annotations, train_medcat, create_annotation, prep_docs +from .utils import ( + add_annotations, + create_annotation, + documents_for_model_run, + prep_docs, + remove_annotations, + run_project_model, + train_medcat, +) logger = logging.getLogger(__name__) tracer = trace.get_tracer("medcat-trainer") @@ -369,59 +378,211 @@ def prepare_documents(request): return Response({'message': 'Documents prepared successfully'}) -@api_view(http_method_names=['POST']) +def _request_flag(value, default=True): + """Interpret a JSON or form boolean. A missing value keeps ``default``.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in ('1', 'true', 't', 'yes', 'y') + return default + + +def _task_positional_args(task): + """Positional args stored on a django-background-task row, or None.""" + try: + parsed = json.loads(task.task_params) + except (TypeError, ValueError): + return None + if isinstance(parsed, (list, tuple)) and parsed and isinstance(parsed[0], (list, tuple)): + return list(parsed[0]) + return None + + +def _project_id_from_task(task): + args = _task_positional_args(task) + if not args: + return None + try: + return int(args[0]) + except (TypeError, ValueError): + return None + + +def _doc_prep_tasks_for_project(manager, project_id): + project_id = int(project_id) + return [ + task for task in manager.filter(queue='doc_prep').order_by('id') + if _project_id_from_task(task) == project_id + ] + + +def _task_summary(task): + args = _task_positional_args(task) or [] + user_id = args[2] if len(args) > 2 else None + return {'project': _project_id_from_task(task), 'user_id': user_id} + + +def _list_doc_prep_tasks(): + running_tasks = [_task_summary(task) for task in Task.objects.filter(queue='doc_prep').order_by('id') + if _project_id_from_task(task) is not None] + complete_tasks = [_task_summary(task) for task in CompletedTask.objects.filter(queue='doc_prep').order_by('id') + if _project_id_from_task(task) is not None] + return Response({'running_tasks': running_tasks, 'comp_tasks': complete_tasks}) + + +def _model_run_status(project): + """Progress of a model run, plus whether one is currently queued.""" + dataset_len = Document.objects.filter(dataset_id=project.dataset_id).count() if project.dataset_id else 0 + prepared_len = project.prepared_documents.count() + remaining = documents_for_model_run(project).count() if project.dataset_id else 0 + running = _doc_prep_tasks_for_project(Task.objects, project.id) + payload = { + 'proj_id': project.id, + 'project_id': project.id, + 'dataset_len': dataset_len, + 'prepd_docs_len': prepared_len, + 'documents_remaining': remaining, + 'bg_job_id': None, + 'last_error': None, + } + if running: + payload['status'] = 'running' + payload['bg_job_id'] = running[-1].id + return payload + + completed = _doc_prep_tasks_for_project(CompletedTask.objects, project.id) + latest = completed[-1] if completed else None + if remaining == 0: + payload['status'] = 'complete' + elif latest is not None and getattr(latest, 'failed_at', None): + payload['status'] = 'failed' + payload['last_error'] = getattr(latest, 'last_error', None) or None + else: + payload['status'] = 'idle' + return payload + + +def _load_project_for_model_run(project_id): + try: + return ProjectAnnotateEntities.objects.get(id=int(project_id)), None + except (ObjectDoesNotExist, TypeError, ValueError): + return None, Response({'message': 'No Project found for the given ID'}, status=404) + + +@api_view(http_method_names=['POST', 'GET']) +@permission_classes([permissions.IsAuthenticated]) def prepare_documents_bg(request): - user = request.user - # Get project id - p_id = request.data['project_id'] - project = ProjectAnnotateEntities.objects.get(id=p_id) - docs = Document.objects.filter(dataset=project.dataset) + """Run the project model over its dataset, or list background runs. + + POST queues the same background document preparation as the project list + Run action. Pass ``background: false`` to run inside this request. + + Documents that already have annotations, that have been submitted, or that + have already been prepared are left unchanged. + + GET returns the running and completed ``doc_prep`` tasks. + """ + if request.method == 'GET': + return _list_doc_prep_tasks() + + if 'project_id' not in request.data: + return HttpResponseBadRequest('project_id is required') - # Get docs that have no AnnotatedEntities - d_ids = [d.id for d in docs if len(AnnotatedEntity.objects.filter(document=d).filter(project=project)) == 0 or - d in project.validated_documents.all()] + project, error = _load_project_for_model_run(request.data.get('project_id')) + if error is not None: + return error + if not is_project_admin(request.user, project): + return Response( + {'error': 'You do not have permission to run the model for this project'}, + status=403, + ) + if project.dataset_id is None: + return HttpResponseBadRequest('Project has no dataset') + + background = _request_flag(request.data.get('background', True), default=True) + doc_ids = list(documents_for_model_run(project).values_list('id', flat=True)) + + if background: + existing = _doc_prep_tasks_for_project(Task.objects, project.id) + if existing: + task = existing[-1] + args = _task_positional_args(task) or [] + queued = len(args[1]) if len(args) > 1 and isinstance(args[1], list) else len(doc_ids) + return Response({ + 'bg_job_id': task.id, + 'project_id': project.id, + 'documents_queued': queued, + 'status': 'running', + 'already_running': True, + 'message': 'Model run already in progress', + }) + job = prep_docs(project.id, doc_ids, request.user.id) + logger.info('Queued model run %s for project %s over %s documents', job.id, project.id, len(doc_ids)) + return Response({ + 'bg_job_id': job.id, + 'project_id': project.id, + 'documents_queued': len(doc_ids), + 'status': 'queued', + 'already_running': False, + 'message': 'Model run queued', + }) - # execute model infer in bg - job = prep_docs(p_id, d_ids, user.id) - return Response({'bg_job_id': job.id}) + try: + processed = run_project_model(project.id, doc_ids, request.user.id) + except Exception as exc: + logger.warning('Error running model for project %s', project.id, exc_info=exc) + return Response({ + 'message': exc.args[0] if exc.args else 'Internal Server Error', + 'description': exc.args[1] if len(exc.args) > 1 else '', + 'project_id': project.id, + 'status': 'failed', + }, status=500) + + payload = _model_run_status(project) + payload.update({ + 'documents_queued': len(doc_ids), + 'documents_prepared': processed, + 'already_running': False, + 'message': 'Model run finished', + }) + return Response(payload) @api_view(http_method_names=['GET']) +@permission_classes([permissions.IsAuthenticated]) def prepare_docs_bg_tasks(_): - running_doc_prep_tasks = Task.objects.filter(queue='doc_prep') - completed_doc_prep_tasks = CompletedTask.objects.filter(queue='doc_prep') - - def transform_task_params(task_params_str): - task_params = json.loads(task_params_str)[0] - return { - 'project': task_params[0], - 'user_id': task_params[2] - } - running_tasks = [transform_task_params(task.task_params) for task in running_doc_prep_tasks] - complete_tasks = [transform_task_params(task.task_params) for task in completed_doc_prep_tasks] - return Response({'running_tasks': running_tasks, 'comp_tasks': complete_tasks}) + return _list_doc_prep_tasks() @api_view(http_method_names=['GET', 'DELETE']) +@permission_classes([permissions.IsAuthenticated]) def prepare_docs_bg_task(request, proj_id): + """Status of a project model run, or cancel the queued run.""" + project, error = _load_project_for_model_run(proj_id) + if error is not None: + return error + if request.method == 'GET': - # state of bg running process as determined by prepared docs - try: - proj = ProjectAnnotateEntities.objects.get(id=proj_id) - prepd_docs_count = proj.prepared_documents.count() - ds_total_count = Document.objects.filter( - dataset=ProjectAnnotateEntities.objects.get(id=proj_id).dataset.id).count() - return Response({'proj_id': proj_id, 'dataset_len': ds_total_count, 'prepd_docs_len': prepd_docs_count}) - except ObjectDoesNotExist: - return HttpResponseBadRequest('No Project found for the given ID') - else: - running_doc_prep_tasks = {json.loads(task.task_params)[0][0]: task.id - for task in Task.objects.filter(queue='doc_prep')} - if proj_id in running_doc_prep_tasks: - Task.objects.filter(id=running_doc_prep_tasks[proj_id]).delete() - return Response("Successfully stopped running response") - else: - return HttpResponseBadRequest('Could not find running BG Process to stop') + return Response(_model_run_status(project)) + + if not is_project_admin(request.user, project): + return Response( + {'error': 'You do not have permission to stop the model run for this project'}, + status=403, + ) + running = _doc_prep_tasks_for_project(Task.objects, project.id) + if running: + Task.objects.filter(id__in=[task.id for task in running]).delete() + return Response({ + 'message': 'Successfully stopped running response', + 'project_id': project.id, + 'status': 'stopped', + }) + return HttpResponseBadRequest('Could not find running BG Process to stop') @api_view(http_method_names=['POST']) diff --git a/medcat-trainer/webapp/api/core/urls.py b/medcat-trainer/webapp/api/core/urls.py index 045c7e225..ff0642d74 100644 --- a/medcat-trainer/webapp/api/core/urls.py +++ b/medcat-trainer/webapp/api/core/urls.py @@ -35,6 +35,9 @@ path('api/prepare-documents-bg/', api.views.prepare_documents_bg), path('api/prep-docs-bg-tasks/', api.views.prepare_docs_bg_tasks), path('api/prep-docs-bg-tasks//', api.views.prepare_docs_bg_task), + # Same handlers as the project-list Run action, under a stable name. + path('api/run-model/', api.views.prepare_documents_bg), + path('api/run-model//', api.views.prepare_docs_bg_task), path('api/api-token-auth/', auth_views.obtain_auth_token), path('admin/', admin.site.urls), path('api/api-auth/', include('rest_framework.urls', namespace='rest_framework')),