From 2c2437e3f589123ae3afaa99f0de4d1c8ce1a7a6 Mon Sep 17 00:00:00 2001 From: jebus Date: Wed, 15 Jul 2026 23:56:24 +1200 Subject: [PATCH] [Feature] Generate Compounds by Molfile (#423) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/423 --- epapi/tests/v1/test_api_permissions.py | 17 +- epapi/tests/v1/test_contract_get_entities.py | 8 +- epdb/exceptions.py | 4 + epdb/legacy_api.py | 19 ++- epdb/models.py | 148 +++++++++++++++--- epdb/views.py | 23 ++- tests/test_compound_model.py | 156 ++++++++++++++++++- tests/views/test_compound_views.py | 108 +++++++++++++ 8 files changed, 439 insertions(+), 44 deletions(-) diff --git a/epapi/tests/v1/test_api_permissions.py b/epapi/tests/v1/test_api_permissions.py index 57de6215..e3b884c2 100644 --- a/epapi/tests/v1/test_api_permissions.py +++ b/epapi/tests/v1/test_api_permissions.py @@ -117,25 +117,28 @@ class APIPermissionTestBase(TestCase): # Create test compounds in each package cls.reviewed_compound = Compound.create( - cls.reviewed_package, "C", "Reviewed Compound", "Test compound" + cls.reviewed_package, "C", name="Reviewed Compound", description="Test compound" ) cls.owned_compound = Compound.create( - cls.unreviewed_package_owned, "CC", "Owned Compound", "Test compound" + cls.unreviewed_package_owned, "CC", name="Owned Compound", description="Test compound" ) cls.read_compound = Compound.create( - cls.unreviewed_package_read, "CCC", "Read Compound", "Test compound" + cls.unreviewed_package_read, "CCC", name="Read Compound", description="Test compound" ) cls.write_compound = Compound.create( - cls.unreviewed_package_write, "CCCC", "Write Compound", "Test compound" + cls.unreviewed_package_write, "CCCC", name="Write Compound", description="Test compound" ) cls.all_compound = Compound.create( - cls.unreviewed_package_all, "CCCCC", "All Compound", "Test compound" + cls.unreviewed_package_all, "CCCCC", name="All Compound", description="Test compound" ) cls.no_access_compound = Compound.create( - cls.unreviewed_package_no_access, "CCCCCC", "No Access Compound", "Test compound" + cls.unreviewed_package_no_access, + "CCCCCC", + name="No Access Compound", + description="Test compound", ) cls.group_compound = Compound.create( - cls.group_package, "CCCCCCC", "Group Compound", "Test compound" + cls.group_package, "CCCCCCC", name="Group Compound", description="Test compound" ) diff --git a/epapi/tests/v1/test_contract_get_entities.py b/epapi/tests/v1/test_contract_get_entities.py index 0ec515f8..40a81b23 100644 --- a/epapi/tests/v1/test_contract_get_entities.py +++ b/epapi/tests/v1/test_contract_get_entities.py @@ -294,8 +294,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase): return Compound.create( package, smiles, - f"Reviewed Compound {idx:03d}", - "Compound for pagination tests", + name=f"Reviewed Compound {idx:03d}", + description="Compound for pagination tests", ) @classmethod @@ -305,8 +305,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase): return Compound.create( package, smiles, - f"Draft Compound {idx:03d}", - "Compound for pagination tests", + name=f"Draft Compound {idx:03d}", + description="Compound for pagination tests", ) diff --git a/epdb/exceptions.py b/epdb/exceptions.py index f278cb4d..42cdcef1 100644 --- a/epdb/exceptions.py +++ b/epdb/exceptions.py @@ -1,2 +1,6 @@ class InvalidSMILESException(Exception): pass + + +class InvalidMolfileException(Exception): + pass diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py index 1f7219b9..89e8ae58 100644 --- a/epdb/legacy_api.py +++ b/epdb/legacy_api.py @@ -791,6 +791,7 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu class CreateCompound(Schema): compoundSmiles: str + compoundMolFile: str | None = None compoundName: str | None = None compoundDescription: str | None = None inchi: str | None = None @@ -804,9 +805,13 @@ def create_package_compound( ): try: p = get_package_for_write(request.user, package_uuid) - # inchi is not used atm c = Compound.create( - p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi + p, + c.compoundSmiles, + molfile=c.compoundMolFile, + name=c.compoundName, + description=c.compoundDescription, + inchi=c.inchi, ) return redirect(c.url) except ValueError as e: @@ -1899,6 +1904,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid): class CreateNode(Schema): nodeAsSmiles: str + nodeAsMolFile: str | None = None nodeName: str | None = None nodeReason: str | None = None nodeDepth: str | None = None @@ -1918,7 +1924,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]): else: node_depth = -1 - n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason) + n = Node.create( + pw, + n.nodeAsSmiles, + node_depth, + molfile=n.nodeAsMolFile, + name=n.nodeName, + description=n.nodeReason, + ) return redirect(n.url) except ValueError: diff --git a/epdb/models.py b/epdb/models.py index 0353872f..599255a1 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -31,7 +31,10 @@ from sklearn.model_selection import ShuffleSplit from bridge.contracts import Property from bridge.dto import RunResult, PropertyPrediction -from epdb.exceptions import InvalidSMILESException +from epdb.exceptions import ( + InvalidMolfileException, + InvalidSMILESException, +) from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet from utilities.ml import ( ApplicabilityDomainPCA, @@ -789,6 +792,13 @@ class Compound( external_identifiers = GenericRelation("ExternalIdentifier") + def get_structure_by_smiles(self, smiles: str) -> "CompoundStructure": + for struct in self.structures.all(): + if struct.smiles == smiles: + return struct + + raise ValueError(f"No structure with SMILES {smiles} found for {self.get_name()}") + @property def structures(self) -> QuerySet: return CompoundStructure.objects.filter(compound=self) @@ -857,8 +867,24 @@ class Compound( @staticmethod @transaction.atomic def create( - package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs + package: "Package", + smiles: str, + molfile: str | None = None, + name: str | None = None, + description: str | None = None, + *args, + **kwargs, ) -> "Compound": + # Molfile has precendence over SMILES + if molfile is not None and molfile.strip() != "": + mol = FormatConverter.from_molfile(molfile) + + if mol is None: + raise InvalidMolfileException("Given molfile is invalid") + else: + # Overwrite SMILES from molfile + smiles = FormatConverter.to_smiles(mol) + if smiles is None or smiles.strip() == "": raise InvalidSMILESException("SMILES is required") @@ -878,11 +904,16 @@ class Compound( if CompoundStructure.objects.filter( smiles=standardized_smiles, compound__package=package ).exists(): - # TODO should we add a structure? - return CompoundStructure.objects.get( + found_c = CompoundStructure.objects.get( smiles=standardized_smiles, compound__package=package ).compound + # As a direct match wasn't found add a new structure + # TODO return newly created structure + _ = found_c.add_structure(smiles, molfile=molfile, name=name, description=description) + + return found_c + # Generate Compound c = Compound() c.package = package @@ -914,7 +945,12 @@ class Compound( ) cs = CompoundStructure.create( - c, smiles, name=name, description=description, normalized_structure=is_standardized + c, + smiles, + molfile=molfile, + name=name, + description=description, + normalized_structure=is_standardized, ) c.default_structure = cs @@ -927,11 +963,22 @@ class Compound( self, smiles: str, name: str = None, + molfile: str = None, description: str = None, default_structure: bool = False, *args, **kwargs, ) -> "CompoundStructure": + # Molfile has precendence over SMILES + if molfile is not None and molfile.strip() != "": + mol = FormatConverter.from_molfile(molfile) + + if mol is None: + raise InvalidMolfileException("Given molfile is invalid") + else: + # Overwrite SMILES from molfile + smiles = FormatConverter.to_smiles(mol) + if smiles is None or smiles == "": raise ValueError("SMILES is required") @@ -951,16 +998,28 @@ class Compound( ) if is_standardized: - CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package) + CompoundStructure.objects.get(smiles=smiles, compound__package=self.package) # Check if we find a direct match for a given SMILES and/or its standardized SMILES - if CompoundStructure.objects.filter( - smiles__in=smiles, compound__package=self.package - ).exists(): - return CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package) + if CompoundStructure.objects.filter(smiles=smiles, compound__package=self.package).exists(): + found_cs = CompoundStructure.objects.get(smiles=smiles, compound__package=self.package) + + if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""): + logger.info( + f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty" + ) + found_cs.molfile = molfile + found_cs.save() + + return found_cs cs = CompoundStructure.create( - self, smiles, name=name, description=description, normalized_structure=is_standardized + self, + smiles, + name=name, + molfile=molfile, + description=description, + normalized_structure=is_standardized, ) if default_structure: @@ -1141,10 +1200,35 @@ class CompoundStructure( @staticmethod @transaction.atomic def create( - compound: Compound, smiles: str, name: str = None, description: str = None, *args, **kwargs + compound: Compound, + smiles: str, + molfile: str = None, + name: str = None, + description: str = None, + *args, + **kwargs, ): + # Molfile has precendence over SMILES + if molfile is not None and molfile.strip() != "": + mol = FormatConverter.from_molfile(molfile) + + if mol is None: + raise InvalidMolfileException("Given molfile is invalid") + else: + # Overwrite SMILES from molfile + smiles = FormatConverter.to_smiles(mol) + if CompoundStructure.objects.filter(compound=compound, smiles=smiles).exists(): - return CompoundStructure.objects.get(compound=compound, smiles=smiles) + found_cs = CompoundStructure.objects.get(smiles=smiles, compound=compound) + + if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""): + logger.info( + f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty" + ) + found_cs.molfile = molfile + found_cs.save() + + return found_cs if compound.pk is None: raise ValueError("Unpersisted Compound! Persist compound first!") @@ -2174,11 +2258,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix def add_node( self, smiles: str, - name: Optional[str] = None, - description: Optional[str] = None, - depth: Optional[int] = -1, + molfile: str | None = None, + name: str | None = None, + description: str | None = None, + depth: int = -1, ): - return Node.create(self, smiles, depth, name=name, description=description) + return Node.create(self, smiles, depth, molfile=molfile, name=name, description=description) @transaction.atomic def add_edge( @@ -2336,28 +2421,43 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) pathway: "Pathway", smiles: str, depth: int, - name: Optional[str] = None, - description: Optional[str] = None, + molfile: str | None = None, + name: str | None = None, + description: str | None = None, ): + # Molfile has precendence over SMILES + if molfile is not None and molfile.strip() != "": + mol = FormatConverter.from_molfile(molfile) + + if mol is None: + raise InvalidMolfileException("Given molfile is invalid") + else: + # Overwrite SMILES from molfile + smiles = FormatConverter.to_smiles(mol) + stereo_removed = False if pathway.predicted and FormatConverter.has_stereo(smiles): smiles = FormatConverter.standardize(smiles, remove_stereo=True) stereo_removed = True - c = Compound.create(pathway.package, smiles, name=name, description=description) + c = Compound.create( + pathway.package, smiles, molfile=molfile, name=name, description=description + ) - if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists(): - return Node.objects.get(pathway=pathway, default_node_label=c.default_structure) + structure = c.get_structure_by_smiles(smiles) + + if Node.objects.filter(pathway=pathway, default_node_label=structure).exists(): + return Node.objects.get(pathway=pathway, default_node_label=structure) n = Node() n.stereo_removed = stereo_removed n.pathway = pathway n.depth = depth - n.default_node_label = c.default_structure + n.default_node_label = structure n.save() - n.node_labels.add(c.default_structure) + n.node_labels.add(structure) n.save() return n diff --git a/epdb/views.py b/epdb/views.py index e0daf802..b42abe31 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -19,7 +19,7 @@ from sentry_sdk import capture_exception from utilities.chem import FormatConverter, IndigoUtils from utilities.decorators import package_permission_required -from .exceptions import InvalidSMILESException +from .exceptions import InvalidMolfileException, InvalidSMILESException from .logic import ( EPDBURLParser, @@ -1386,12 +1386,18 @@ def package_compounds(request, package_uuid): elif request.method == "POST": compound_name = request.POST.get("compound-name") compound_smiles = request.POST.get("compound-smiles") + compound_molfile = request.POST.get("compound-molfile") compound_description = request.POST.get("compound-description") + try: c = Compound.create( - current_package, compound_smiles, compound_name, compound_description + current_package, + compound_smiles, + molfile=compound_molfile, + name=compound_name, + description=compound_description, ) - except ValueError as e: + except (InvalidSMILESException, InvalidMolfileException) as e: raise BadRequest(str(e)) return redirect(c.url) @@ -1517,11 +1523,15 @@ def package_compound_structures(request, package_uuid, compound_uuid): elif request.method == "POST": structure_name = request.POST.get("structure-name") structure_smiles = request.POST.get("structure-smiles") + structure_molfile = request.POST.get("structure-molfile") structure_description = request.POST.get("structure-description") try: cs = current_compound.add_structure( - structure_smiles, structure_name, structure_description + structure_smiles, + molfile=structure_molfile, + name=structure_name, + description=structure_description, ) except ValueError: return error( @@ -2339,9 +2349,12 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid): node_description = request.POST.get("node-description") node_smiles = request.POST.get("node-smiles").strip() + node_molfile = request.POST.get("node-molfile").strip() try: - current_pathway.add_node(node_smiles, name=node_name, description=node_description) + current_pathway.add_node( + node_smiles, molfile=node_molfile, name=node_name, description=node_description + ) except InvalidSMILESException: return error( request, "Node creation failed!", f"Given SMILES ({node_smiles}) is invalid" diff --git a/tests/test_compound_model.py b/tests/test_compound_model.py index 575b463b..5f9e65d4 100644 --- a/tests/test_compound_model.py +++ b/tests/test_compound_model.py @@ -1,7 +1,7 @@ from django.conf import settings as s from django.test import TestCase, override_settings -from epdb.exceptions import InvalidSMILESException +from epdb.exceptions import InvalidSMILESException, InvalidMolfileException from epdb.logic import PackageManager from epdb.models import Compound, User, CompoundStructure @@ -19,6 +19,10 @@ class CompoundTest(TestCase): cls.user = User.objects.get(username="anonymous") cls.package = PackageManager.create_package(cls.user, "Anon Test Package", "No Desc") + # A valid V2000 molfile for 4-Nitrobenzoic acid (O=C(O)C1=CC=C([N+](=O)[O-])C=C1) + cls.VALID_MOLFILE = """\n Mrv2211 01012500002D\n\n 12 12 0 0 0 0 999 V2000\n 1.4289 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.0625 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 1.2375 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 0.8250 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.8875 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0\n 0.0000 -3.3000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0\n 1.4289 -3.3000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 2 0 0 0 0\n 2 3 1 0 0 0 0\n 3 4 2 0 0 0 0\n 4 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 1 1 0 0 0 0\n 2 7 1 0 0 0 0\n 7 8 2 0 0 0 0\n 7 9 1 0 0 0 0\n 5 10 1 0 0 0 0\n 10 11 1 0 0 0 0\n 10 12 2 0 0 0 0\nM CHG 2 10 1 11 -1\nM END\n""" + cls.INVALID_MOLFILE = "this is not a valid molfile" + def test_smoke(self): c = Compound.create( self.package, @@ -190,3 +194,153 @@ class CompoundTest(TestCase): c1.set_default_structure(c2) self.assertNotEqual(default_structure, c2) + + def test_create_structure_from_molfile(self): + c = Compound.create( + self.package, + smiles="PLACEHOLDER", + molfile=self.VALID_MOLFILE, + name="Molfile Compound", + description="Created from molfile", + ) + + cs = CompoundStructure.create( + compound=c, + smiles="PLACEHOLDER", + molfile=self.VALID_MOLFILE, + name="Molfile Structure", + description="Structure from molfile", + ) + + # The SMILES must have been derived from the molfile, not the placeholder + self.assertNotEqual(cs.smiles, "PLACEHOLDER") + self.assertIsNotNone(cs.smiles) + self.assertTrue(len(cs.smiles) > 0) + + def test_create_structure_from_molfile_stores_molfile(self): + c = Compound.create( + self.package, + smiles="c1c(C(=O)O)ccc([N+]([O-])=O)c1", + name="Molfile Store Test", + description="No Desc", + ) + + cs = c.default_structure + + # O=C(O)C1=CC=C([N+](=O)[O-])C=C1 will be overwritten with + # c1c(C(=O)O)ccc([N+]([O-])=O)c1 and molfile will be set + # on the existing structure + _ = CompoundStructure.create( + compound=c, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + molfile=self.VALID_MOLFILE, + name="Structure with Molfile", + description="No Desc", + ) + + # Fetch fresh from DB to confirm persistence + cs_db = CompoundStructure.objects.get(pk=cs.pk) + self.assertIsNotNone(cs_db.molfile) + self.assertNotEqual(cs_db.molfile.strip(), "") + + def test_create_structure_from_invalid_molfile_raises(self): + c = Compound.create( + self.package, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + name="Invalid Molfile Test", + description="No Desc", + ) + + with self.assertRaises(InvalidMolfileException): + CompoundStructure.create( + compound=c, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + molfile=self.INVALID_MOLFILE, + name="Bad Structure", + description="No Desc", + ) + + def test_molfile_takes_precedence_over_smiles(self): + """When both molfile and smiles are supplied, molfile must win.""" + c = Compound.create( + self.package, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + name="Precedence Test", + description="No Desc", + ) + + cs = CompoundStructure.create( + compound=c, + smiles="C", # intentionally wrong / different SMILES + molfile=self.VALID_MOLFILE, + name="Molfile Wins", + description="No Desc", + ) + + # SMILES should be derived from molfile, not the supplied "C" + self.assertNotEqual(cs.smiles, "C") + + def test_empty_molfile_falls_back_to_smiles(self): + """An empty or whitespace-only molfile should be ignored and SMILES used instead.""" + c = Compound.create( + self.package, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + name="Empty Molfile Test", + description="No Desc", + ) + + cs = CompoundStructure.create( + compound=c, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + molfile=" ", # whitespace only – should be ignored + name="Fallback to SMILES", + description="No Desc", + ) + + self.assertEqual(cs.smiles, "O=C(O)C1=CC=C([N+](=O)[O-])C=C1") + + def test_none_molfile_falls_back_to_smiles(self): + """None as molfile should be ignored and SMILES used instead.""" + c = Compound.create( + self.package, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + name="None Molfile Test", + description="No Desc", + ) + + cs = CompoundStructure.create( + compound=c, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + molfile=None, + name="Fallback None Molfile", + description="No Desc", + ) + + self.assertEqual(cs.smiles, "O=C(O)C1=CC=C([N+](=O)[O-])C=C1") + + def test_molfile_deduplication(self): + """Creating a structure twice from the same molfile should return the existing object.""" + c = Compound.create( + self.package, + smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1", + name="Molfile Dedup Test", + description="No Desc", + ) + + cs1 = CompoundStructure.create( + compound=c, + smiles="PLACEHOLDER", + molfile=self.VALID_MOLFILE, + name="Molfile Structure", + description="No Desc", + ) + + cs2 = CompoundStructure.create( + compound=c, + smiles="PLACEHOLDER", + molfile=self.VALID_MOLFILE, + name="Molfile Structure", + description="No Desc", + ) + + self.assertEqual(cs1.pk, cs2.pk) diff --git a/tests/views/test_compound_views.py b/tests/views/test_compound_views.py index 2f6c98e0..36004712 100644 --- a/tests/views/test_compound_views.py +++ b/tests/views/test_compound_views.py @@ -11,6 +11,9 @@ from epdb.models import Compound, Scenario, ExternalDatabase class CompoundViewTest(TestCase): fixtures = ["test_fixtures_incl_model.jsonl.gz"] + # A valid V2000 molfile for 4-Nitrobenzoic acid (O=C(O)C1=CC=C([N+](=O)[O-])C=C1) + VALID_MOLFILE = """\n Mrv2211 01012500002D\n\n 12 12 0 0 0 0 999 V2000\n 1.4289 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.0625 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 1.2375 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 0.8250 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.8875 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0\n 0.0000 -3.3000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0\n 1.4289 -3.3000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 2 0 0 0 0\n 2 3 1 0 0 0 0\n 3 4 2 0 0 0 0\n 4 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 1 1 0 0 0 0\n 2 7 1 0 0 0 0\n 7 8 2 0 0 0 0\n 7 9 1 0 0 0 0\n 5 10 1 0 0 0 0\n 10 11 1 0 0 0 0\n 10 12 2 0 0 0 0\nM CHG 2 10 1 11 -1\nM END\n""" + @classmethod def setUpClass(cls): super(CompoundViewTest, cls).setUpClass() @@ -396,3 +399,108 @@ class CompoundViewTest(TestCase): c = Compound.objects.get(url=compound_url) self.assertEqual(len(c.aliases), 0) + + def test_create_compound_via_molfile(self): + """POSTing a valid molfile without a SMILES should create a compound successfully.""" + response = self.client.post( + reverse("compounds"), + { + "compound-name": "4-Nitrobenzoic acid", + "compound-description": "Created from molfile", + "compound-molfile": self.VALID_MOLFILE, + }, + ) + + self.assertEqual(response.status_code, 302) + compound_url = response.url + + c = Compound.objects.get(url=compound_url) + + self.assertEqual(c.name, "4-Nitrobenzoic acid") + self.assertEqual(c.description, "Created from molfile") + # SMILES should have been extracted from molfile, so it must be non-empty + self.assertIsNotNone(c.default_structure.smiles) + self.assertNotEqual(c.default_structure.smiles.strip(), "") + + def test_create_compound_molfile_takes_precedence_over_smiles(self): + """When both molfile and SMILES are submitted, the molfile should win.""" + response = self.client.post( + reverse("compounds"), + { + "compound-name": "4-Nitrobenzoic acid", + "compound-description": "Molfile precedence test", + "compound-smiles": "C", # intentionally wrong/different + "compound-molfile": self.VALID_MOLFILE, + }, + ) + + self.assertEqual(response.status_code, 302) + compound_url = response.url + + c = Compound.objects.get(url=compound_url) + + # The resulting SMILES must NOT be the dummy "C" supplied via compound-smiles + self.assertNotEqual(c.default_structure.smiles, "C") + + def test_create_compound_via_invalid_molfile_returns_error(self): + """POSTing an invalid molfile should not create a compound and should return an error response.""" + initial_count = self.user1_default_package.compounds.count() + + response = self.client.post( + reverse("compounds"), + { + "compound-name": "Bad Molfile Compound", + "compound-description": "Should fail", + "compound-molfile": "this is not a molfile at all", + }, + ) + + # The view should signal an error (non-2xx or a redirect to an error page, not 302 to a new compound) + self.assertNotEqual(response.status_code, 302) + # No new compound should have been created + self.assertEqual(self.user1_default_package.compounds.count(), initial_count) + + def test_create_compound_via_empty_molfile_falls_back_to_smiles(self): + """Submitting an empty molfile with a valid SMILES should fall back to SMILES.""" + response = self.client.post( + reverse("compounds"), + { + "compound-name": "Fallback SMILES Compound", + "compound-description": "Empty molfile fallback", + "compound-smiles": "C(CCl)Cl", + "compound-molfile": " ", # whitespace only + }, + ) + + self.assertEqual(response.status_code, 302) + compound_url = response.url + + c = Compound.objects.get(url=compound_url) + self.assertEqual(c.default_structure.smiles, "C(CCl)Cl") + + def test_create_compound_via_molfile_deduplication(self): + """Submitting the same molfile twice should return the existing compound.""" + response1 = self.client.post( + reverse("compounds"), + { + "compound-name": "4-Nitrobenzoic acid", + "compound-description": "Molfile dedup test", + "compound-molfile": self.VALID_MOLFILE, + }, + ) + + self.assertEqual(response1.status_code, 302) + compound_url_1 = response1.url + + response2 = self.client.post( + reverse("compounds"), + { + "compound-name": "4-Nitrobenzoic acid", + "compound-description": "Molfile dedup test", + "compound-molfile": self.VALID_MOLFILE, + }, + ) + + self.assertEqual(response2.status_code, 302) + self.assertEqual(response2.url, compound_url_1) + self.assertEqual(self.user1_default_package.compounds.count(), 1)