2 Commits

Author SHA1 Message Date
1d2bb85c90 adjusted migration
Some checks failed
CI / test (pull_request) Failing after 17s
API CI / api-tests (pull_request) Failing after 29s
Initial bayer app

Show Pack Classification

Adjusted docker compose to bayer specifics

Adjusted Dockerfile for Bayer

Adding secret flags to group, add secret pools to packages

Adjusted View for Package creation

Prep configs, added Package Create Modal

wip

More on PES

wip

wip

Wip

minor

PW interactions

API PES

wip

Make Select Widget reflect required

make required generallay available

Update UI if pathway mode is set to build

Added ais

circle adjustments

Initial Zoom, fix AD Creation

wip

auth log, bb4g fix

missing import

Added viz hint if PES is part of reaction

Add Edge check for pes

flip boolean

...

pes

Added extra

...

In / Out Edges Viz, Submitting Button Text

...

Make PES Link clickable

Return proper http response instead of error

Fixed error return, removed unused options

Fix PES Link HTML for other entities

Fixed molfile assignment, adjusted Export

Package Export/Import cycle

highlight Description links

implemented non persistent

Harmonised proposed field in Json output

Added pesLink field to PW Api output

PES Fields in API Output

removed debug

Fix Classification import, Fix PES Deserialization

underline pes link in templates

Fix alter name/desc for node, make /node /edge funcitonal

provide setting link and copy button

Implemented Compound Names / Reaction Names View Option

Unconnected Nodes

Make links thicker, reduce timeout trigger time

Show proposed info in popover

Pathway Build no stereo removal

Include probs in reaction name option viz

Detect clicks outside nodes/edges
2026-07-15 15:31:37 +02:00
2c2437e3f5 [Feature] Generate Compounds by Molfile (#423)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#423
2026-07-15 23:56:24 +12:00
8 changed files with 436 additions and 46 deletions

View File

@ -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"
)

View File

@ -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",
)

View File

@ -1,5 +1,11 @@
class InvalidSMILESException(Exception):
pass
class InvalidMolfileException(Exception):
pass
class PackageImportException(Exception):
pass
pass

View File

@ -850,6 +850,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
@ -889,7 +890,12 @@ def create_package_compound(
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription)
else:
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:
@ -1969,6 +1975,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
@ -2030,7 +2037,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
else:
node_depth = -1
node = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
node = Node.create(
pw,
n.nodeAsSmiles,
node_depth,
molfile=n.nodeAsMolFile,
name=n.nodeName,
description=n.nodeReason,
)
return redirect(node.url)
except ValueError:

View File

@ -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,
@ -794,6 +797,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)
@ -862,8 +872,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")
@ -896,20 +922,19 @@ class Compound(
return found_compound
qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package)
if subclasses:
qs = qs.not_instance_of(*subclasses)
# Check if we can find the standardized one
if qs.exists():
# TODO should we add a structure?
# ->
found_structure = qs.first()
found_compound = found_structure.compound
# We've only found the normalized one, create the very structure
new_structure = found_compound.add_structure(smiles, molfile=molfile, name=name, description=description)
if name:
found_structure.add_alias(name)
found_compound.add_alias(name)
return found_compound
@ -941,7 +966,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
@ -954,11 +984,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")
@ -978,16 +1019,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:
@ -1168,8 +1221,24 @@ class CompoundStructure(
@staticmethod
@transaction.atomic
def create(
compound: Compound, smiles: str, name: str = None, description: str = None, molfile: 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)
# Clean for potential XSS
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1180,6 +1249,13 @@ class CompoundStructure(
if name:
found_cs.add_alias(name)
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:
@ -1197,7 +1273,7 @@ class CompoundStructure(
cs.compound = compound
# Check if molfile is present and valid
if molfile is not None and FormatConverter.from_molfile(molfile) is not None:
if molfile is not None and molfile.strip() != "":
cs.molfile = molfile
if "normalized_structure" in kwargs:
@ -2234,11 +2310,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(
@ -2400,28 +2477,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

View File

@ -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,
@ -1415,12 +1415,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)
@ -1546,11 +1552,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(
@ -2370,9 +2380,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"

View File

@ -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)

View File

@ -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)