forked from enviPath/enviPy
[Feature] Generate Compounds by Molfile (#423)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch> Reviewed-on: enviPath/enviPy#423
This commit is contained in:
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,2 +1,6 @@
|
||||
class InvalidSMILESException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMolfileException(Exception):
|
||||
pass
|
||||
|
||||
@ -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:
|
||||
|
||||
148
epdb/models.py
148
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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user