diff --git a/api/urls.py b/api/urls.py index daa8886f..b39fb833 100644 --- a/api/urls.py +++ b/api/urls.py @@ -87,6 +87,7 @@ router.register(r"job_file_transfer", viewer_views.JobFileTransferView, basename='job_file_transfer') router.register(r"job_callback", viewer_views.JobCallBackView, basename='job_callback') router.register(r"job_config", viewer_views.JobConfigView, basename='job_config') +router.register(r"job_override", viewer_views.JobOverrideView, basename='job_override') from rest_framework_swagger.renderers import OpenAPIRenderer, SwaggerUIRenderer from rest_framework.decorators import api_view, renderer_classes diff --git a/viewer/download_structures.py b/viewer/download_structures.py index c99adfea..ba801aa9 100644 --- a/viewer/download_structures.py +++ b/viewer/download_structures.py @@ -463,11 +463,12 @@ def _create_structures_zip(target, errors += _molecule_files_zip(zip_contents, ziparchive, combined_sdf_file, error_file) - # Add combined_sdf_file to the archive. - combined_sdf_file_exists = os.path.isfile(combined_sdf_file) - - if zip_contents['molecules']['single_sdf_file'] is True \ - and combined_sdf_file_exists: + # Add combined_sdf_file to the archive? + if ( + zip_contents['molecules']['single_sdf_file'] is True + and combined_sdf_file + and os.path.isfile(combined_sdf_file) + ): ziparchive.write( combined_sdf_file, os.path.join(_ZIP_FILEPATHS['single_sdf_file'], diff --git a/viewer/migrations/0032_add_job_override_table.py b/viewer/migrations/0032_add_job_override_table.py new file mode 100644 index 00000000..a6ec2252 --- /dev/null +++ b/viewer/migrations/0032_add_job_override_table.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.14 on 2023-05-31 13:44 + +from django.conf import settings +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('viewer', '0031_fix_JobFileTransfer_sub_path'), + ] + + operations = [ + migrations.CreateModel( + name='JobOverride', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('override', models.JSONField(encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('author', models.ForeignKey(help_text='The user that uploaded the override', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'viewer_joboverride', + }, + ), + ] diff --git a/viewer/models.py b/viewer/models.py index 40330f04..02232f9f 100644 --- a/viewer/models.py +++ b/viewer/models.py @@ -1223,6 +1223,16 @@ def job_has_finished(self): """ return self.job_status in [JobRequest.SUCCESS, JobRequest.FAILURE, 'LOST'] + +class JobOverride(models.Model): + override = models.JSONField(encoder=DjangoJSONEncoder) + author = models.ForeignKey(User, null=True, on_delete=models.SET_NULL, + help_text="The user that uploaded the override") + + class Meta: + db_table = 'viewer_joboverride' + + class Squonk2Org(models.Model): """Django model to store Squonk2 Organisations (UUIDs) and the Account Servers they belong to. Managed by the Squonk2Agent class. diff --git a/viewer/serializers.py b/viewer/serializers.py index e18eac92..ab1ee4a6 100644 --- a/viewer/serializers.py +++ b/viewer/serializers.py @@ -28,7 +28,8 @@ MoleculeTag, SessionProjectTag, JobFileTransfer, - JobRequest + JobRequest, + JobOverride ) from viewer.utils import get_https_host @@ -789,3 +790,14 @@ class Meta: fields = ("job_status", "state_transition_time") # End of Serializers for Squonk Jobs + +class JobOverrideReadSerializer(serializers.ModelSerializer): + class Meta: + model = JobOverride + fields = '__all__' + + +class JobOverrideWriteSerializer(serializers.ModelSerializer): + class Meta: + model = JobOverride + fields = ('override',) diff --git a/viewer/squonk/day-1-job-override.json b/viewer/squonk/day-1-job-override.json new file mode 100644 index 00000000..49837644 --- /dev/null +++ b/viewer/squonk/day-1-job-override.json @@ -0,0 +1,191 @@ +{ + "global": { + "job_dir": "fragalysis-jobs/{username}/{job_name}-{timestamp}", + "protein_id": "{selected_protein}" + }, + "precompilation_ignore": ["job_dir", "protein_id"], + "fragalysis-jobs": [ + { + "job_collection": "fragmenstein", + "job_name": "fragmenstein-combine", + "job_version": "1.0.0", + "inputs": { + "fragments": { + "type": "array", + "uniqueItems": true, + "format": "chemical/x-mdl-molfile", + "items": { + "from": "lhs", + "enum": "{inputs_dir}/{target}-{item}.mol", + "enumNames": "{item}" + } + }, + "protein": { + "type": "string", + "format": "chemical/x-pdb", + "from": "lhs", + "enum": "{inputs_dir}/{target}-{item}_apo-desolv.pdb", + "enumNames": "{item}" + } + }, + "options": { + "outfile": { + "default": "{job_dir}/merged.sdf", + "ui:widget": "hidden" + }, + "count": { + "type": "integer", + "default": 5 + }, + "smilesFieldName": { + "type": "string", + "default": "original SMILES", + "ui:widget": "hidden" + }, + "fragIdField": { + "type": "string", + "default": "_Name", + "ui:widget": "hidden" + }, + "proteinFieldName": { + "type": "string", + "default": "ref_pdb", + "ui:widget": "hidden" + }, + "proteinFieldValue": { + "type": "string", + "default": "{protein_id}", + "ui:widget": "hidden" + } + }, + "outputs": { + "outputs": { "type": "string" } + }, + "results": ["Merged molecules"] + }, + + { + "job_collection": "fragmenstein", + "job_name": "fragmenstein-combine-multi-scoring", + "job_version": "1.0.0", + "inputs": { + "fragments": { + "type": "array", + "uniqueItems": true, + "format": "chemical/x-mdl-molfile", + "items": { + "from": "lhs", + "enum": "{inputs_dir}/{target}-{item}.mol", + "enumNames": "{item}" + } + }, + "protein": { + "type": "string", + "format": "chemical/x-pdb", + "from": "lhs", + "enum": "{inputs_dir}/{target}-{item}_apo-desolv.pdb", + "enumNames": "{item}" + } + }, + "options": { + "outfile": { + "default": "{job_dir}/merged.sdf", + "ui:widget": "hidden" + }, + "count": { + "type": "integer", + "default": 5 + }, + "smilesFieldName": { + "type": "string", + "default": "original SMILES", + "ui:widget": "hidden" + }, + "fragIdField": { + "type": "string", + "default": "_Name", + "ui:widget": "hidden" + }, + "proteinFieldName": { + "type": "string", + "default": "ref_pdb", + "ui:widget": "hidden" + }, + "proteinFieldValue": { + "type": "string", + "value": "{protein_id}", + "ui:widget": "hidden" + } + }, + "outputs": { + "outputs": { "type": "string" } + }, + "results": ["Merged molecules"] + }, + + { + "job_collection": "fragmenstein", + "job_name": "fragmenstein-place-string", + "job_version": "1.0.0", + "inputs": { + "fragments": { + "type": "array", + "uniqueItems": true, + "format": "chemical/x-mdl-molfile", + "items": { + "from": "lhs", + "enum": "{inputs_dir}/{target}-{item}.mol", + "enumNames": "{item}" + } + }, + "protein": { + "type": "string", + "format": "chemical/x-pdb", + "from": "lhs", + "enum": "{inputs_dir}/{target}-{item}_apo-desolv.pdb", + "enumNames": "{item}" + } + }, + "options": { + "outfile": { + "default": "{job_dir}/merged.sdf", + "ui:widget": "hidden" + }, + "count": { + "type": "integer", + "default": 5 + }, + "smilesFieldName": { + "type": "string", + "default": "original SMILES", + "ui:widget": "hidden" + }, + "smiles": { + "items": { + "ui:widget": "textarea" + } + }, + "fragIdField": { + "type": "string", + "default": "_Name", + "ui:widget": "hidden" + }, + "proteinFieldName": { + "type": "string", + "default": "ref_pdb", + "ui:widget": "hidden" + }, + "proteinFieldValue": { + "type": "string", + "value": "{protein_id}", + "ui:widget": "hidden" + } + }, + "outputs": { + "outputs": { "type": "string" } + }, + "results": ["Merged molecules"] + } + ] + } + \ No newline at end of file diff --git a/viewer/views.py b/viewer/views.py index db66df15..52a6cc63 100644 --- a/viewer/views.py +++ b/viewer/views.py @@ -60,7 +60,8 @@ SessionProjectTag, DownloadLinks, JobRequest, - JobFileTransfer + JobFileTransfer, + JobOverride ) from viewer import filters from viewer.squonk2_agent import Squonk2AgentRv, Squonk2Agent, get_squonk2_agent @@ -133,6 +134,8 @@ JobRequestWriteSerializer, JobCallBackReadSerializer, JobCallBackWriteSerializer, + JobOverrideReadSerializer, + JobOverrideWriteSerializer, ProjectSerializer, ) @@ -2990,6 +2993,12 @@ def list(self, request): def create(self, request): """Method to handle POST request """ + # Only authenticated users can transfer files to sqonk + user = self.request.user + if not user.is_authenticated: + content = {'Only authenticated users can download structures'} + return Response(content, status=status.HTTP_403_FORBIDDEN) + logger.info('+ DownloadStructures.post') # Clear up old existing files @@ -3292,7 +3301,8 @@ def create(self, request): class JobConfigView(viewsets.ReadOnlyModelViewSet): - """Django view that calls Squonk to get a requested job configuration + """Django view that calls Squonk to get a requested job configuration. + The caller must provide a collection, name and version for a Job to be retrieved. Methods ------- @@ -3300,17 +3310,13 @@ class JobConfigView(viewsets.ReadOnlyModelViewSet): - GET: Get job config url: - api/job_config + api/job_config get params: - - squonk_job: name of the squonk job requested - - Returns: job details. - - example input for get + - job_collection: The collection of the squonk job + - job_name: The name of the squonk job + - job_version: The version of the squonk job - .. code-block:: - - /api/job_config/?squonk_job_name=run_smina + Returns: job details. """ def list(self, request): """Method to handle GET request @@ -3326,13 +3332,18 @@ def list(self, request): # Can't use this method if the squonk variables are not set! sqa_rv = _SQ2A.configured() - if sqa_rv.success: - content = {f'The Squonk2 Agent is not configured ({sqa_rv.msg}'} + if not sqa_rv.success: + content = {f'The Squonk2 Agent is not configured ({sqa_rv.msg})'} return Response(content, status=status.HTTP_403_FORBIDDEN) job_collection = request.query_params.get('job_collection', None) job_name = request.query_params.get('job_name', None) job_version = request.query_params.get('job_version', None) + # User must provide collection, name and version + if not job_collection or not job_name or not job_version: + content = {'Please provide job_collection, job_name and job_version'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + content = get_squonk_job_config(request, job_collection=job_collection, job_name=job_name, @@ -3341,6 +3352,52 @@ def list(self, request): return Response(content) +class JobOverrideView(viewsets.ModelViewSet): + queryset = JobOverride.objects.all().order_by('-id') + + def get_serializer_class(self): + if self.request.method in ['GET']: + return JobOverrideReadSerializer + return JobOverrideWriteSerializer + + def create(self, request): + logger.info('+ JobOverride.post') + # Only authenticated users can transfer files to sqonk + user = self.request.user + if not user.is_authenticated: + content = {'Only authenticated users can provide Job overrides'} + return Response(content, status=status.HTTP_403_FORBIDDEN) + + # Override is expected to be a JSON string, + # but protect against format issues + override = request.data['override'] + try: + override_dict = json.loads(override) + except ValueError: + content = {'error': 'The override is not valid JSON'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + # We could use a schema but that's comlex. + # For now let's just insist on some key fields in the provided override: - + # - global + # - fragalysis-jobs + if "global" not in override_dict: + content = {'error': 'The override does not contain a "global" key'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + if "fragalysis-jobs" not in override_dict: + content = {'error': 'The override does not contain a "fragalysis-jobs" key'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + if type(override_dict["fragalysis-jobs"]) != list: + content = {'error': 'The override "fragalysis-jobs" key is not a list'} + return Response(content, status=status.HTTP_400_BAD_REQUEST) + + job_override = JobOverride() + job_override.override = override_dict + job_override.author = user + job_override.save() + + return Response({"id": job_override.id}) + + class JobRequestView(APIView): """ Operational Django view to set up/retrieve information about tags relating to Session Projects