4 Commits

Author SHA1 Message Date
72a63b4876 [Feature] Sync Features from Client Repo (#425)
- New PackageImporter
- Fix persisting Molfile on Structure creation
- Add NonPersistent Prediction
- Pathway View Options
- Edit Node Fix

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#425
2026-07-16 07:50:46 +12: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
9bc9f86ff1 [Fix] Pathway SVG Export has Edges again, Fix In/Out Edge coloring (#422)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#422
2026-07-03 07:24:02 +12:00
dba6514013 [Chore] Package Export, Blanks for fields, Styling (#421)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#421
2026-07-02 20:12:26 +12:00
31 changed files with 1808 additions and 1104 deletions

View File

@ -117,25 +117,28 @@ class APIPermissionTestBase(TestCase):
# Create test compounds in each package # Create test compounds in each package
cls.reviewed_compound = Compound.create( 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.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.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.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.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.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_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( return Compound.create(
package, package,
smiles, smiles,
f"Reviewed Compound {idx:03d}", name=f"Reviewed Compound {idx:03d}",
"Compound for pagination tests", description="Compound for pagination tests",
) )
@classmethod @classmethod
@ -305,8 +305,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
return Compound.create( return Compound.create(
package, package,
smiles, smiles,
f"Draft Compound {idx:03d}", name=f"Draft Compound {idx:03d}",
"Compound for pagination tests", description="Compound for pagination tests",
) )

View File

@ -87,6 +87,11 @@ def get_access_token_from_request(request, scopes=None):
""" """
Get an access token from the request using MSAL token cache. Get an access token from the request using MSAL token cache.
""" """
# Check if auth via Access Token
if request.headers.get("Authorization"):
return {"access_token": request.headers.get("Authorization").split(" ")[1]}
if scopes is None: if scopes is None:
scopes = s.MS_ENTRA_SCOPES scopes = s.MS_ENTRA_SCOPES

10
epdb/exceptions.py Normal file
View File

@ -0,0 +1,10 @@
class InvalidSMILESException(Exception):
pass
class InvalidMolfileException(Exception):
pass
class PackageImportException(Exception):
pass

View File

@ -791,6 +791,7 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
class CreateCompound(Schema): class CreateCompound(Schema):
compoundSmiles: str compoundSmiles: str
compoundMolFile: str | None = None
compoundName: str | None = None compoundName: str | None = None
compoundDescription: str | None = None compoundDescription: str | None = None
inchi: str | None = None inchi: str | None = None
@ -804,9 +805,13 @@ def create_package_compound(
): ):
try: try:
p = get_package_for_write(request.user, package_uuid) p = get_package_for_write(request.user, package_uuid)
# inchi is not used atm
c = Compound.create( 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) return redirect(c.url)
except ValueError as e: except ValueError as e:
@ -1610,13 +1615,13 @@ class PathwayNode(Schema):
dt50s: List[Dict[str, str]] = Field([], alias="dt50s") dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate") engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
idcomp: str = Field(None, alias="default_node_label.url") idcomp: str = Field(None, alias="node_label_id")
idreact: str = Field(None, alias="default_node_label.url") idreact: str = Field(None, alias="node_label_id")
image: str = Field(None, alias="image") image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size") imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate") proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate")
smiles: str = Field(None, alias="default_node_label.smiles") smiles: str = Field(None, alias="smiles")
pseudo: bool = Field(False, alias="pseudo") pseudo: bool = Field(False, alias="pseudo")
@staticmethod @staticmethod
@ -1630,24 +1635,10 @@ class PathwayNode(Schema):
# TODO # TODO
return [] return []
@staticmethod
def resolve_engineered_intermediate(obj: Node):
# TODO
return False
@staticmethod
def resolve_image(obj: Node):
return f"{obj.default_node_label.url}?image=svg"
@staticmethod @staticmethod
def resolve_image_size(obj: Node): def resolve_image_size(obj: Node):
return 400 return 400
@staticmethod
def resolve_proposed_intermediate(obj: Node):
# TODO
return []
class PathwaySchema(Schema): class PathwaySchema(Schema):
aliases: List[str] = Field([], alias="aliases") aliases: List[str] = Field([], alias="aliases")
@ -1899,6 +1890,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
class CreateNode(Schema): class CreateNode(Schema):
nodeAsSmiles: str nodeAsSmiles: str
nodeAsMolFile: str | None = None
nodeName: str | None = None nodeName: str | None = None
nodeReason: str | None = None nodeReason: str | None = None
nodeDepth: str | None = None nodeDepth: str | None = None
@ -1918,7 +1910,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
else: else:
node_depth = -1 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) return redirect(n.url)
except ValueError: except ValueError:
@ -2269,3 +2268,26 @@ def get_setting(request, setting_uuid):
return 403, { return 403, {
"message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!" "message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!"
} }
########
# Util #
########
class NonPersistent(Schema):
smiles: str
setting_url: str = Field(..., alias="settingUri")
@router.post("/util", response={200: Any, 403: Error})
def predict(request, np: Form[NonPersistent]):
try:
from epdb.logic import SPathway
setting = SettingManager.get_setting_by_url(request.user, np.setting_url)
spw = SPathway(prediction_setting=setting, root_nodes=[np.smiles])
spw.predict()
return spw.to_json()
except ValueError:
return 403, {
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
}

View File

@ -1011,7 +1011,7 @@ class PackageManager(object):
add_import_timestamp=True, add_import_timestamp=True,
trust_reviewed=False, trust_reviewed=False,
) -> Package: ) -> Package:
importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed) importer = PackageImporter(data, preserve_uuids)
imported_package = importer.do_import() imported_package = importer.do_import()
up = UserPackagePermission() up = UserPackagePermission()
@ -1828,6 +1828,11 @@ class SPathway(object):
"to": to_indices, "to": to_indices,
} }
if edge.rule:
e["rule"] = edge.rule.simple_json()
if edge.probability:
e["probability"] = edge.probability
edges.append(e) edges.append(e)
return { return {

View File

@ -99,11 +99,15 @@ class Command(BaseCommand):
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png" new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
new_license.save() new_license.save()
def import_package(self, data, owner): def import_package(self, data, owner, all_envipath_user_group):
return PackageManager.import_legacy_package( p = PackageManager.import_legacy_package(
data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True
) )
PackageManager.grant_read(owner, p, all_envipath_user_group)
return p
def create_default_setting(self, owner, packages): def create_default_setting(self, owner, packages):
s = SettingManager.create_setting( s = SettingManager.create_setting(
owner, owner,
@ -198,7 +202,7 @@ class Command(BaseCommand):
s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8" s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8"
).read() ).read()
) )
imported_package = self.import_package(package_data, admin) imported_package = self.import_package(package_data, admin, g)
mapping[p.replace(".json", "")] = imported_package mapping[p.replace(".json", "")] = imported_package
setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]]) setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]])

View File

@ -0,0 +1,150 @@
# Generated by Django 6.0.3 on 2026-07-01 20:59
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0026_auto_20260602_1718"),
]
operations = [
migrations.AlterField(
model_name="compound",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="compound",
name="default_structure",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="compound_default_structure",
to="epdb.compoundstructure",
verbose_name="Default Structure",
),
),
migrations.AlterField(
model_name="compound",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="compoundstructure",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="compoundstructure",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="edge",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="edge",
name="edge_label",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="epdb.reaction",
verbose_name="Edge label",
),
),
migrations.AlterField(
model_name="edge",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="node",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="node",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="pathway",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="pathway",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="reaction",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="reaction",
name="medline_references",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
blank=True,
null=True,
verbose_name="Medline References",
),
),
migrations.AlterField(
model_name="reaction",
name="rules",
field=models.ManyToManyField(
blank=True, related_name="reaction_rule", to="epdb.rule", verbose_name="Rule"
),
),
migrations.AlterField(
model_name="reaction",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="rule",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="rule",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
]

View File

@ -31,6 +31,10 @@ from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property from bridge.contracts import Property
from bridge.dto import RunResult, PropertyPrediction from bridge.dto import RunResult, PropertyPrediction
from epdb.exceptions import (
InvalidMolfileException,
InvalidSMILESException,
)
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import ( from utilities.ml import (
ApplicabilityDomainPCA, ApplicabilityDomainPCA,
@ -631,7 +635,7 @@ class EnviPathModel(TimeStampedModel):
class AliasMixin(models.Model): class AliasMixin(models.Model):
aliases = ArrayField( aliases = ArrayField(
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list models.TextField(blank=False, null=False), verbose_name="Aliases", default=list, blank=True
) )
@transaction.atomic @transaction.atomic
@ -654,7 +658,9 @@ class AliasMixin(models.Model):
class ScenarioMixin(models.Model): class ScenarioMixin(models.Model):
scenarios = models.ManyToManyField("epdb.Scenario", verbose_name="Attached Scenarios") scenarios = models.ManyToManyField(
"epdb.Scenario", verbose_name="Attached Scenarios", blank=True
)
@transaction.atomic @transaction.atomic
def set_scenarios(self, scenarios: List["Scenario"]): def set_scenarios(self, scenarios: List["Scenario"]):
@ -780,12 +786,19 @@ class Compound(
"CompoundStructure", "CompoundStructure",
verbose_name="Default Structure", verbose_name="Default Structure",
related_name="compound_default_structure", related_name="compound_default_structure",
on_delete=models.CASCADE, on_delete=models.SET_NULL,
null=True, null=True,
) )
external_identifiers = GenericRelation("ExternalIdentifier") 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 @property
def structures(self) -> QuerySet: def structures(self) -> QuerySet:
return CompoundStructure.objects.filter(compound=self) return CompoundStructure.objects.filter(compound=self)
@ -854,16 +867,32 @@ class Compound(
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( 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": ) -> "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() == "": if smiles is None or smiles.strip() == "":
raise ValueError("SMILES is required") raise InvalidSMILESException("SMILES is required")
smiles = smiles.strip() smiles = smiles.strip()
parsed = FormatConverter.from_smiles(smiles) parsed = FormatConverter.from_smiles(smiles)
if parsed is None: if parsed is None:
raise ValueError("Given SMILES is invalid") raise InvalidSMILESException("Given SMILES is invalid")
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True) standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
@ -875,11 +904,16 @@ class Compound(
if CompoundStructure.objects.filter( if CompoundStructure.objects.filter(
smiles=standardized_smiles, compound__package=package smiles=standardized_smiles, compound__package=package
).exists(): ).exists():
# TODO should we add a structure? found_c = CompoundStructure.objects.get(
return CompoundStructure.objects.get(
smiles=standardized_smiles, compound__package=package smiles=standardized_smiles, compound__package=package
).compound ).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 # Generate Compound
c = Compound() c = Compound()
c.package = package c.package = package
@ -911,7 +945,12 @@ class Compound(
) )
cs = CompoundStructure.create( 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 c.default_structure = cs
@ -924,11 +963,22 @@ class Compound(
self, self,
smiles: str, smiles: str,
name: str = None, name: str = None,
molfile: str = None,
description: str = None, description: str = None,
default_structure: bool = False, default_structure: bool = False,
*args, *args,
**kwargs, **kwargs,
) -> "CompoundStructure": ) -> "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 == "": if smiles is None or smiles == "":
raise ValueError("SMILES is required") raise ValueError("SMILES is required")
@ -948,16 +998,28 @@ class Compound(
) )
if is_standardized: 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 # Check if we find a direct match for a given SMILES and/or its standardized SMILES
if CompoundStructure.objects.filter( if CompoundStructure.objects.filter(smiles=smiles, compound__package=self.package).exists():
smiles__in=smiles, compound__package=self.package found_cs = CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
).exists():
return CompoundStructure.objects.get(smiles__in=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( 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: if default_structure:
@ -1138,10 +1200,35 @@ class CompoundStructure(
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( 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(): 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: if compound.pk is None:
raise ValueError("Unpersisted Compound! Persist compound first!") raise ValueError("Unpersisted Compound! Persist compound first!")
@ -1154,8 +1241,12 @@ class CompoundStructure(
if description is not None: if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
cs.smiles = smiles
cs.compound = compound cs.compound = compound
cs.smiles = smiles
# Check if molfile is present and valid
if molfile is not None and molfile.strip() != "":
cs.molfile = molfile
if "normalized_structure" in kwargs: if "normalized_structure" in kwargs:
cs.normalized_structure = kwargs["normalized_structure"] cs.normalized_structure = kwargs["normalized_structure"]
@ -1174,6 +1265,8 @@ class CompoundStructure(
@property @property
def as_svg(self, width: int = 800, height: int = 400): def as_svg(self, width: int = 800, height: int = 400):
if self.molfile is not None and self.molfile.strip() != "":
return IndigoUtils.mol_to_svg(self.molfile, width=width, height=height)
return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height) return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height)
@property @property
@ -1610,10 +1703,15 @@ class Reaction(
products = models.ManyToManyField( products = models.ManyToManyField(
"epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products" "epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products"
) )
rules = models.ManyToManyField("epdb.Rule", verbose_name="Rule", related_name="reaction_rule") rules = models.ManyToManyField(
"epdb.Rule", verbose_name="Rule", related_name="reaction_rule", blank=True
)
multi_step = models.BooleanField(verbose_name="Multistep Reaction") multi_step = models.BooleanField(verbose_name="Multistep Reaction")
medline_references = ArrayField( medline_references = ArrayField(
models.TextField(blank=False, null=False), null=True, verbose_name="Medline References" models.TextField(blank=False, null=False),
null=True,
verbose_name="Medline References",
blank=True,
) )
external_identifiers = GenericRelation("ExternalIdentifier") external_identifiers = GenericRelation("ExternalIdentifier")
@ -1630,7 +1728,7 @@ class Reaction(
educts: Union[List[str], List[CompoundStructure]] = None, educts: Union[List[str], List[CompoundStructure]] = None,
products: Union[List[str], List[CompoundStructure]] = None, products: Union[List[str], List[CompoundStructure]] = None,
rules: Union[Rule | List[Rule]] = None, rules: Union[Rule | List[Rule]] = None,
multi_step: bool = True, multi_step: bool = False,
): ):
_educts = [] _educts = []
_products = [] _products = []
@ -1929,6 +2027,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
# add links start -> pseudo # add links start -> pseudo
new_link = { new_link = {
"name": link["name"], "name": link["name"],
"plain_name": link["plain_name"],
"id": link["id"], "id": link["id"],
"url": link["url"], "url": link["url"],
"image": link["image"], "image": link["image"],
@ -1938,6 +2037,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
"source": node_url_to_idx[link["start_node_urls"][0]], "source": node_url_to_idx[link["start_node_urls"][0]],
"target": pseudo_idx, "target": pseudo_idx,
"app_domain": link.get("app_domain", None), "app_domain": link.get("app_domain", None),
"to_pseudo": True,
} }
adjusted_links.append(new_link) adjusted_links.append(new_link)
@ -1945,6 +2045,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
for target in link["end_node_urls"]: for target in link["end_node_urls"]:
new_link = { new_link = {
"name": link["name"], "name": link["name"],
"plain_name": link["plain_name"],
"id": link["id"], "id": link["id"],
"url": link["url"], "url": link["url"],
"image": link["image"], "image": link["image"],
@ -1955,6 +2056,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
"target": node_url_to_idx[target], "target": node_url_to_idx[target],
"app_domain": link.get("app_domain", None), "app_domain": link.get("app_domain", None),
"multi_step": link["multi_step"], "multi_step": link["multi_step"],
"from_pseudo": True,
} }
adjusted_links.append(new_link) adjusted_links.append(new_link)
@ -2164,11 +2266,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
def add_node( def add_node(
self, self,
smiles: str, smiles: str,
name: Optional[str] = None, molfile: str | None = None,
description: Optional[str] = None, name: str | None = None,
depth: Optional[int] = -1, 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 @transaction.atomic
def add_edge( def add_edge(
@ -2197,7 +2300,25 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
depth_map[0] = list() depth_map[0] = list()
processed = set() processed = set()
for n in self.root_nodes: data_driven_root_nodes = (
self.node_set.all()
.annotate(prod_cnt=Count("edge_products"), educt_cnt=Count("edge_educts"))
.filter(prod_cnt=0, educt_cnt__gt=0)
.distinct()
)
# Eval QuerySet
root_nodes_by_depth = list(self.root_nodes)
data_driven_root_nodes.update(depth=0)
root_nodes = [n for n in data_driven_root_nodes]
for n in root_nodes_by_depth:
if n not in root_nodes:
if len(n.edge_products.all()) == 0:
root_nodes.append(n)
for n in root_nodes:
depth_map[0].append(n) depth_map[0].append(n)
# At most depth len(nodes) is possible # At most depth len(nodes) is possible
@ -2246,17 +2367,19 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
def _url(self): def _url(self):
return "{}/node/{}".format(self.pathway.url, self.uuid) return "{}/node/{}".format(self.pathway.url, self.uuid)
def get_name(self): def get_name(self, include_suffix=True):
non_generic_name = True non_generic_name = True
if self.name == "no name": if self.name is None or self.name == "no name":
non_generic_name = False non_generic_name = False
return ( if non_generic_name:
self.name return self.name
if non_generic_name else:
else f"{self.default_node_label.name} (taken from underlying structure)" if include_suffix:
) return f"{self.default_node_label.name} (taken from underlying structure)"
else:
return self.default_node_label.name
def d3_json(self): def d3_json(self):
app_domain_data = self.get_app_domain_assessment_data() app_domain_data = self.get_app_domain_assessment_data()
@ -2277,10 +2400,16 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"node_label_id": self.default_node_label.url, "node_label_id": self.default_node_label.url,
"image": f"{self.url}?image=svg", "image": f"{self.url}?image=svg",
"image_svg": IndigoUtils.mol_to_svg( "image_svg": IndigoUtils.mol_to_svg(
self.default_node_label.smiles, width=40, height=40 self.default_node_label.molfile
if self.default_node_label.molfile is not None
and self.default_node_label.molfile.strip()
else self.default_node_label.smiles,
width=40,
height=40,
), ),
"image_type": "svg", "image_type": "svg",
"name": self.get_name(), "name": self.get_name(),
"plain_name": self.get_name(include_suffix=False),
"smiles": self.default_node_label.smiles, "smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
"app_domain": { "app_domain": {
@ -2303,34 +2432,54 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
pathway: "Pathway", pathway: "Pathway",
smiles: str, smiles: str,
depth: int, depth: int,
name: Optional[str] = None, molfile: str | None = None,
description: Optional[str] = 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 stereo_removed = False
if pathway.predicted and FormatConverter.has_stereo(smiles): if pathway.predicted and FormatConverter.has_stereo(smiles):
smiles = FormatConverter.standardize(smiles, remove_stereo=True) smiles = FormatConverter.standardize(smiles, remove_stereo=True)
stereo_removed = 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(): structure = c.get_structure_by_smiles(smiles)
return Node.objects.get(pathway=pathway, default_node_label=c.default_structure)
if Node.objects.filter(pathway=pathway, default_node_label=structure).exists():
return Node.objects.get(pathway=pathway, default_node_label=structure)
n = Node() n = Node()
n.stereo_removed = stereo_removed n.stereo_removed = stereo_removed
n.pathway = pathway n.pathway = pathway
n.depth = depth n.depth = depth
n.default_node_label = c.default_structure n.default_node_label = structure
n.save() n.save()
n.node_labels.add(c.default_structure) n.node_labels.add(structure)
n.save() n.save()
return n return n
@property @property
def as_svg(self): def as_svg(self):
if (
self.default_node_label.molfile is not None
and self.default_node_label.molfile.strip() != ""
):
return IndigoUtils.mol_to_svg(self.default_node_label.molfile)
return IndigoUtils.mol_to_svg(self.default_node_label.smiles) return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
def get_timeseries_data(self): def get_timeseries_data(self):
@ -2367,6 +2516,28 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return res return res
def is_proposed_intermediate(self):
collected = defaultdict(dict)
for ai in self.additional_information.filter(
type__in=["ProposedIntermediate", "TransformationProductImportance", "Confidence"],
scenario__isnull=False,
):
collected[str(ai.scenario.uuid)]["scenarioId"] = ai.scenario.url
collected[str(ai.scenario.uuid)]["scenarioName"] = ai.scenario.name
if ai.type == "ProposedIntermediate":
collected[str(ai.scenario.uuid)]["proposed"] = True
if ai.type == "Confidence":
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level
if ai.type == "TransformationProductImportance":
collected[str(ai.scenario.uuid)]["Transformation product importance"] = (
ai.get().importance.value
)
return list(collected.values())
class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin): class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
pathway = models.ForeignKey( pathway = models.ForeignKey(
@ -2388,6 +2559,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
def d3_json(self): def d3_json(self):
edge_json = { edge_json = {
"name": self.get_name(), "name": self.get_name(),
"plain_name": self.get_name(include_suffix=False),
"id": self.url, "id": self.url,
"url": self.url, "url": self.url,
"image": self.url + "?image=svg", "image": self.url + "?image=svg",
@ -2502,17 +2674,19 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return res return res
def get_name(self): def get_name(self, include_suffix=True):
non_generic_name = True non_generic_name = True
if self.name == "no name": if self.name == "no name":
non_generic_name = False non_generic_name = False
return ( if non_generic_name:
self.name return self.name
if non_generic_name else:
else f"{self.edge_label.name} (taken from underlying reaction)" if include_suffix:
) return f"{self.edge_label.name} (taken from underlying reaction)"
else:
return self.edge_label.name
class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin): class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin):

View File

@ -19,6 +19,7 @@ from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
from .exceptions import InvalidMolfileException, InvalidSMILESException
from .logic import ( from .logic import (
EPDBURLParser, EPDBURLParser,
@ -754,6 +755,11 @@ def models(request):
{"Model": s.SERVER_URL + "/model"}, {"Model": s.SERVER_URL + "/model"},
] ]
context["entity_type"] = "model"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
# Keep model_types for potential modal/action use # Keep model_types for potential modal/action use
context["model_types"] = { context["model_types"] = {
"ML Relative Reasoning": { "ML Relative Reasoning": {
@ -766,11 +772,13 @@ def models(request):
"requires_rule_packages": True, "requires_rule_packages": True,
"requires_data_packages": True, "requires_data_packages": True,
}, },
"EnviFormer": { }
if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = {
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
},
} }
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
@ -779,6 +787,9 @@ def models(request):
"type": k, "type": k,
"requires_rule_packages": v.requires_rule_packages(), "requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v.requires_data_packages(), "requires_data_packages": v.requires_data_packages(),
"additional_parameters": v.Config.__name__.lower()
if v.Config.__name__ != ""
else None,
} }
for k, v in s.PROPERTY_PLUGINS.items(): for k, v in s.PROPERTY_PLUGINS.items():
context["model_types"][v.display()] = { context["model_types"][v.display()] = {
@ -787,12 +798,6 @@ def models(request):
"requires_data_packages": v.requires_data_packages(), "requires_data_packages": v.requires_data_packages(),
} }
# Context for paginated template
context["entity_type"] = "model"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
return render(request, "collections/models_paginated.html", context) return render(request, "collections/models_paginated.html", context)
elif request.method == "POST": elif request.method == "POST":
@ -906,11 +911,12 @@ def package_models(request, package_uuid):
"requires_rule_packages": True, "requires_rule_packages": True,
"requires_data_packages": True, "requires_data_packages": True,
}, },
"EnviFormer": { }
if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = {
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
},
} }
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
@ -1087,6 +1093,11 @@ def package_model(request, package_uuid, model_uuid):
} }
) )
# Sort data by prob desc
res["pred"] = sorted(
res["pred"], key=lambda x: x["probability"], reverse=True
)
return JsonResponse(res, safe=False) return JsonResponse(res, safe=False)
elif half_life: elif half_life:
@ -1190,9 +1201,7 @@ def package(request, package_uuid):
if request.method == "GET": if request.method == "GET":
if request.GET.get("export", False) == "true": if request.GET.get("export", False) == "true":
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json" filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
pack_json = PackageManager.export_package( pack_json = PackageManager.export_package(current_package)
current_package, include_models=False, include_external_identifiers=False
)
response = JsonResponse(pack_json, content_type="application/json") response = JsonResponse(pack_json, content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -1375,12 +1384,18 @@ def package_compounds(request, package_uuid):
elif request.method == "POST": elif request.method == "POST":
compound_name = request.POST.get("compound-name") compound_name = request.POST.get("compound-name")
compound_smiles = request.POST.get("compound-smiles") compound_smiles = request.POST.get("compound-smiles")
compound_molfile = request.POST.get("compound-molfile")
compound_description = request.POST.get("compound-description") compound_description = request.POST.get("compound-description")
try: try:
c = Compound.create( 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)) raise BadRequest(str(e))
return redirect(c.url) return redirect(c.url)
@ -1506,11 +1521,15 @@ def package_compound_structures(request, package_uuid, compound_uuid):
elif request.method == "POST": elif request.method == "POST":
structure_name = request.POST.get("structure-name") structure_name = request.POST.get("structure-name")
structure_smiles = request.POST.get("structure-smiles") structure_smiles = request.POST.get("structure-smiles")
structure_molfile = request.POST.get("structure-molfile")
structure_description = request.POST.get("structure-description") structure_description = request.POST.get("structure-description")
try: try:
cs = current_compound.add_structure( cs = current_compound.add_structure(
structure_smiles, structure_name, structure_description structure_smiles,
molfile=structure_molfile,
name=structure_name,
description=structure_description,
) )
except ValueError: except ValueError:
return error( return error(
@ -2081,12 +2100,14 @@ def package_pathways(request, package_uuid):
else: else:
prediction_setting = current_user.prediction_settings() prediction_setting = current_user.prediction_settings()
is_predict_mode = pw_mode in {"predict", "incremental"}
pw = Pathway.create( pw = Pathway.create(
current_package, current_package,
stand_smiles, stand_smiles if is_predict_mode else smiles,
name=name, name=name,
description=description, description=description,
predicted=pw_mode in {"predict", "incremental"}, predicted=is_predict_mode,
) )
# set mode # set mode
@ -2328,7 +2349,16 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_description = request.POST.get("node-description") node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles").strip() node_smiles = request.POST.get("node-smiles").strip()
current_pathway.add_node(node_smiles, name=node_name, description=node_description) node_molfile = request.POST.get("node-molfile").strip()
try:
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"
)
return redirect(current_pathway.url) return redirect(current_pathway.url)
@ -2436,7 +2466,26 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
return JsonResponse({"success": current_node.url}) return JsonResponse({"success": current_node.url})
return HttpResponseBadRequest() new_node_name = request.POST.get("node-name")
new_node_description = request.POST.get("node-description")
if any([new_node_name, new_node_description]):
if new_node_name is not None and new_node_name.strip() != "":
new_node_name = nh3.clean(new_node_name.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
current_node.name = new_node_name
if new_node_description is not None and new_node_description.strip() != "":
new_node_description = nh3.clean(
new_node_description.strip(), tags=s.ALLOWED_HTML_TAGS
).strip()
current_node.description = new_node_description
current_node.save()
return redirect(current_node.url)
return error(request, "Node update failed!", "No changes were made to the node")
else: else:
return HttpResponseNotAllowed(["GET", "POST"]) return HttpResponseNotAllowed(["GET", "POST"])

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -81,6 +81,7 @@ def migration(request):
"bt0416-4269", "bt0416-4269",
"bt0432-4254", "bt0432-4254",
"bt0337-4117", "bt0337-4117",
"bt0322-3393",
] ]
if request.method == "GET": if request.method == "GET":

View File

@ -34,3 +34,12 @@
} }
@import "./daisyui-theme.css"; @import "./daisyui-theme.css";
select.select[multiple] {
display: block;
white-space: normal;
}
p a {
@apply underline;
}

View File

@ -5,6 +5,126 @@
*/ */
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
const basePagination = (
items,
currentPage,
totalPages,
totalItems,
perPage,
isReviewed,
instanceId
) => ({
items,
currentPage,
totalPages,
totalItems,
perPage,
isReviewed,
instanceId,
get paginatedItems() {
return this.items;
},
get showingStart() {
if (this.totalItems === 0) return 0;
return (this.currentPage - 1) * this.perPage + 1;
},
get showingEnd() {
if (this.totalItems === 0) return 0;
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.fetchPage(this.currentPage + 1);
}
},
prevPage() {
if (this.currentPage > 1) {
this.fetchPage(this.currentPage - 1);
}
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.fetchPage(page);
}
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
if (total === 0) {
return pages;
}
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
} else {
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
let rangeStart;
let rangeEnd;
if (current <= 4) {
rangeStart = 2;
rangeEnd = 5;
} else if (current >= total - 3) {
rangeStart = total - 4;
rangeEnd = total - 1;
} else {
rangeStart = current - 1;
rangeEnd = current + 1;
}
if (rangeStart > 2) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
}
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
if (rangeEnd < total - 1) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
}
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
}
return pages;
}
});
Alpine.data('paginatedList',
(items, options = {}) => ({
...basePagination(items,1, 0, 0, options.perPage || 50, options.isReviewed || false, options.instanceId || Math.random().toString(36).substring(2, 9),),
init() {
this.fetchPage(1);
},
async fetchPage(page) {
this.totalItems = this.items.length;
this.totalPages = Math.ceil(this.items.length / this.perPage);
this.currentPage = page
const start = page * this.perPage - this.perPage;
const end = page * this.perPage;
return this.items.slice(start, end);
}
})
);
Alpine.data('remotePaginatedList', (options = {}) => ({ Alpine.data('remotePaginatedList', (options = {}) => ({
items: [], items: [],
currentPage: 1, currentPage: 1,

View File

@ -1,5 +1,37 @@
console.log("loaded pw.js") console.log("loaded pw.js")
function findPaths(source_idx, links) {
const resultLinks = new Set();
const visited = new Set();
// Helper function for depth-first search
function dfs(current) {
if (visited.has(current)) {
return;
}
if (links.filter(link => link.target.id === current).length === 0) {
return;
}
visited.add(current);
links.forEach(link => {
if (
link.target.id === current &&
!visited.has(link.source.id) &&
link.source.id !== link.target.id // Avoid self-loops
) {
resultLinks.add(link); // Add link to result
dfs(link.source.id);
}
});
visited.delete(current);
}
// Start DFS
dfs(source_idx);
return Array.from(resultLinks);
}
function predictFromNode(url) { function predictFromNode(url) {
fetch("", { fetch("", {
method: "POST", method: "POST",
@ -35,6 +67,9 @@ function draw(pathway, elem) {
const horizontalSpacing = 75; // horizontal space between nodes const horizontalSpacing = 75; // horizontal space between nodes
const depthMap = new Map(); const depthMap = new Map();
// Avoid leaving unconnected Nodes leaving the viewport
nodes.forEach(node => {if (node.depth < 0) node.depth = 0;});
// Sort nodes by depth first to minimize crossings // Sort nodes by depth first to minimize crossings
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth); const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
@ -122,6 +157,11 @@ function draw(pathway, elem) {
}); });
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => `translate(${d.x},${d.y})`);
linkText
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
} }
function dragstarted(event, d) { function dragstarted(event, d) {
@ -224,7 +264,7 @@ function draw(pathway, elem) {
} }
// Wait before showing popup (ms) // Wait before showing popup (ms)
var popupWaitBeforeShow = 1000; var popupWaitBeforeShow = 750;
// Custom popover element // Custom popover element
let popoverTimeout = null; let popoverTimeout = null;
@ -456,7 +496,7 @@ function draw(pathway, elem) {
if (n.stereo_removed) { if (n.stereo_removed) {
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>"; popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
} }
popupContent += "<a href='" + n.url + "'>" + n.name + "</a><br>"; popupContent += "<a href='" + n.url + "'>" + n.plain_name + "</a><br>";
popupContent += "Depth " + n.depth + "<br>" popupContent += "Depth " + n.depth + "<br>"
if (appDomainViewEnabled) { if (appDomainViewEnabled) {
@ -496,6 +536,21 @@ function draw(pathway, elem) {
popupContent += '<b>Half-lives and related scenarios:</b><br>' popupContent += '<b>Half-lives and related scenarios:</b><br>'
for (var s of n.scenarios) { for (var s of n.scenarios) {
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>"; popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
for (var prop in n.proposed) {
if (n.proposed[prop].scenarioId === s.url) {
if("proposed" in n.proposed[prop]){
popupContent += "This compound is a proposed intermediate" + "<br>";
}
if("Confidence" in n.proposed[prop]){
popupContent += "Confidence: " + n.proposed[prop]["Confidence"] + "<br>";
}
if("Transformation product importance" in n.proposed[prop]){
popupContent += "Transformation product importance: " + n.proposed[prop]["Transformation product importance"] + "<br>";
}
}
}
popupContent += "<br>";
} }
} }
@ -508,7 +563,7 @@ function draw(pathway, elem) {
} }
function edge_popup(e) { function edge_popup(e) {
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>"; popupContent = "<a href='" + e.url + "'>" + e.plain_name + "</a><br><br>";
if (e.reaction.rules) { if (e.reaction.rules) {
for (var rule of e.reaction.rules) { for (var rule of e.reaction.rules) {
@ -559,7 +614,7 @@ function draw(pathway, elem) {
// Add background rectangle FIRST to enable pan/zoom on empty space // Add background rectangle FIRST to enable pan/zoom on empty space
// This must be inserted before zoomable group so it's behind everything // This must be inserted before zoomable group so it's behind everything
svg.insert("rect", "#zoomable") const rect = svg.insert("rect", "#zoomable")
.attr("x", 0) .attr("x", 0)
.attr("y", 0) .attr("y", 0)
.attr("width", width) .attr("width", width)
@ -568,6 +623,30 @@ function draw(pathway, elem) {
.attr("pointer-events", "all") .attr("pointer-events", "all")
.style("cursor", "grab"); .style("cursor", "grab");
// Click on empty SVG space (not on a node or link)
rect.on("click", function(event) {
// Only treat it as a background click if the direct target is the rect itself
// (clicks on nodes/links bubble up but originate from different elements)
const target = event.target;
const isNode = target.closest && (
target.closest("circle") !== null ||
target.closest("image") !== null ||
target.closest(".node") !== null
);
const isLink = target.closest && (
target.closest("path.link") !== null ||
target.closest("path.link_no_arrow") !== null
);
if (!isNode && !isLink) {
// console.log("Clicked outside a node or link");
// Clear all highlights
d3.selectAll("circle").classed("highlighted", false);
d3.selectAll("path").classed("highlighted", false);
d3.selectAll("path").classed("inedge", false);
d3.selectAll("path").classed("outedge", false);
}
});
// Zoom Funktion aktivieren // Zoom Funktion aktivieren
const zoom = d3.zoom() const zoom = d3.zoom()
.scaleExtent([0.5, 5]) .scaleExtent([0.5, 5])
@ -614,19 +693,83 @@ function draw(pathway, elem) {
.force("center", d3.forceCenter(width / 2, height / 4)) .force("center", d3.forceCenter(width / 2, height / 4))
.on("tick", ticked); .on("tick", ticked);
// Kanten zeichnen // Draw Edges
const link = zoomable.append("g") const linkGroup = zoomable.append("g")
.selectAll("path") .selectAll("g")
.data(links) .data(links)
.enter().append("path") .enter()
.append("g")
.attr("class", "link-group");
const link = linkGroup
.append("path")
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)")
.attr("fill", "none")
// Check if target is pseudo and draw marker only if not pseudo // Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link") .attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => { .attr("marker-end", d => {
if (d.target.pseudo) return ''; if (d.target.pseudo) return '';
if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)'; return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)';
}) }
)
.attr("fill", "none") .attr("fill", "none")
.on("click", function(event, d) {
const wasHighlighted = d3.select(this).classed("highlighted");
d3.selectAll("path").classed("highlighted", false);
if (!wasHighlighted) {
const toHighlight = [];
toHighlight.push(d.el);
if (d.source.pseudo || d.target.pseudo) {
if (d.target.pseudo) {
d3.selectAll("path").each(e => {
if (e !== undefined && e.source.id === d.target.id) {
toHighlight.push(e.el);
}
});
} else {
d3.selectAll("path").each(e => {
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
toHighlight.push(e.el);
}
});
}
}
for (const e of toHighlight) {
d3.select(e).classed("highlighted", true);
}
}
});
function reaction_name(d) {
const to_pseudo = d?.to_pseudo || false;
if (to_pseudo) {
return "";
}
var suffix = "";
if ("reaction_probability" in d && d["reaction_probability"] !== null) {
suffix = " (p= " + d["reaction_probability"].toFixed(2) + ")"
}
return d.plain_name + suffix;
}
const linkText = linkGroup
.append("text")
.attr("class", "link-label")
.attr("text-anchor", "middle")
.attr("dy", -6)
.style("font-size", "4px")
.style("pointer-events", "none")
.style("display", "none")
.text(d => reaction_name(d))
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
// add element to links array // add element to links array
link.each(function (d) { link.each(function (d) {
@ -635,7 +778,7 @@ function draw(pathway, elem) {
pop_add(link, "Reaction", edge_popup); pop_add(link, "Reaction", edge_popup);
// Knoten zeichnen // Draw Nodes
const node = zoomable.append("g") const node = zoomable.append("g")
.selectAll("g") .selectAll("g")
.data(nodes) .data(nodes)
@ -643,10 +786,41 @@ function draw(pathway, elem) {
.call(d3.drag() .call(d3.drag()
.on("start", dragstarted) .on("start", dragstarted)
.on("drag", dragged) .on("drag", dragged)
.on("end", dragended)) .on("end", dragended)
)
.on("click", function (event, d) { .on("click", function (event, d) {
const wasHighlighted = d3.select(this).select("circle").classed("highlighted");
d3.selectAll('circle.highlighted').classed('highlighted', false);
d3.selectAll("path").classed("inedge", false);
d3.selectAll("path").classed("outedge", false);
if (!wasHighlighted) {
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted")); d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
}) const inEdges = findPaths(d.id, links);
const outEdges = []
// Colorize out edges green
for (const l of links) {
if (l.source.id === d.id) {
outEdges.push(l);
if (l.target.pseudo) {
for (const l2 of links) {
if (l.target.id === l2.source.id) {
outEdges.push(l2);
}
}
}
}
}
for (const e of inEdges) {
d3.select(e.el).classed("inedge", true);
}
for (const e of outEdges) {
d3.select(e.el).classed("outedge", true);
}
}
});
// Kreise für die Knoten hinzufügen // Kreise für die Knoten hinzufügen
node.append("circle") node.append("circle")
@ -720,14 +894,14 @@ function draw(pathway, elem) {
function serializeSVG(svgElement) { function serializeSVG(svgElement) {
svgElement.querySelectorAll("line.link").forEach(line => { svgElement.querySelectorAll("path.link").forEach(line => {
const style = getComputedStyle(line); const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke); line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth); line.setAttribute("stroke-width", style.strokeWidth);
line.setAttribute("fill", style.fill); line.setAttribute("fill", style.fill);
}); });
svgElement.querySelectorAll("line.link_no_arrow").forEach(line => { svgElement.querySelectorAll("path.link_no_arrow").forEach(line => {
const style = getComputedStyle(line); const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke); line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth); line.setAttribute("stroke-width", style.strokeWidth);

View File

@ -22,7 +22,7 @@
{% for tpl in action_button_templates %} {% for tpl in action_button_templates %}
{% include tpl %} {% include tpl %}
{% endfor %} {% endfor %}
<li role="separator" class="divider"></li> <li role="separator" class="divider h-px"></li>
{% endif %} {% endif %}
<li> <li>
<a <a
@ -74,7 +74,7 @@
Rules</a Rules</a
> >
</li> </li>
<li role="separator" class="divider"></li> <li role="separator" class="divider h-px"></li>
<li> <li>
<a <a
class="button" class="button"
@ -99,11 +99,16 @@
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a <i class="glyphicon glyphicon-plus"></i> Set Aliases</a
> >
</li> </li>
<li role="separator" class="divider"></li> <li role="separator" class="divider h-px"></li>
<li> <li>
<a <a
class="button" class="button"
onclick="document.getElementById('delete_pathway_node_modal').showModal(); return false;" onclick="
const modal = document.getElementById('delete_pathway_node_modal');
modal.showModal();
window.dispatchEvent(new Event('modal-opened'));
return false;
"
> >
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a <i class="glyphicon glyphicon-trash"></i> Delete Compound</a
> >
@ -111,7 +116,12 @@
<li> <li>
<a <a
class="button" class="button"
onclick="document.getElementById('delete_pathway_edge_modal').showModal(); return false;" onclick="
const modal = document.getElementById('delete_pathway_edge_modal');
modal.showModal();
window.dispatchEvent(new Event('modal-opened'));
return false;
"
> >
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a <i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
> >

View File

@ -4,6 +4,22 @@
id="delete_pathway_edge_modal" id="delete_pathway_edge_modal"
class="modal" class="modal"
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
@modal-opened.window="
const links = d3.selectAll('path.highlighted');
if (!links.empty()) {
const el = links.node();
const selectElement = document.getElementById('delete_pathway_edge_edges');
console.log(el);
console.log(el.__data__);
for (let option of selectElement.options) {
if (option.value === el.__data__.url) {
option.selected = true;
break;
}
}
selectElement.dispatchEvent(new Event('change'));
}
"
@close="reset()" @close="reset()"
> >
<div class="modal-box"> <div class="modal-box">

View File

@ -4,6 +4,19 @@
id="delete_pathway_node_modal" id="delete_pathway_node_modal"
class="modal" class="modal"
x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })"
@modal-opened.window="
const el = d3.select('circle.highlighted').node();
if (el !== null) {
const selectElement = document.getElementById('delete_pathway_node_nodes');
for (let option of selectElement.options) {
if (option.value === el.__data__.url) {
option.selected = true;
break;
}
}
selectElement.dispatchEvent(new Event('change'));
}
"
@close="reset()" @close="reset()"
> >
<div class="modal-box"> <div class="modal-box">

View File

@ -92,7 +92,7 @@
<div class="flex flex-wrap items-center justify-center gap-4"> <div class="flex flex-wrap items-center justify-center gap-4">
{% for educt in edge.start_nodes.all %} {% for educt in edge.start_nodes.all %}
<a href="{{ educt.url }}" class="btn btn-outline btn-sm" <a href="{{ educt.url }}" class="btn btn-outline btn-sm"
>{{ educt.name }}</a >{{ educt.get_name }}</a
> >
{% endfor %} {% endfor %}
<svg <svg
@ -112,7 +112,7 @@
</svg> </svg>
{% for product in edge.end_nodes.all %} {% for product in edge.end_nodes.all %}
<a href="{{ product.url }}" class="btn btn-outline btn-sm" <a href="{{ product.url }}" class="btn btn-outline btn-sm"
>{{ product.name }}</a >{{ product.get_name }}</a
> >
{% endfor %} {% endfor %}
</div> </div>

View File

@ -56,7 +56,9 @@
<ul class="menu bg-base-200 rounded-box"> <ul class="menu bg-base-200 rounded-box">
{% for um in group.user_member.all %} {% for um in group.user_member.all %}
<li> <li>
<a href="{{ um.url }}" class="hover:bg-base-300" <a
href="{% if user.is_superuser %}{{ um.url }}{% else %}{{ "#" }}{% endif %}"
class="hover:bg-base-300"
>{{ um.username }} >{{ um.username }}
{% if not um.is_active %}<i>(inactive)</i>{% endif %}</a {% if not um.is_active %}<i>(inactive)</i>{% endif %}</a
> >

View File

@ -1,4 +1,5 @@
{% extends "framework_modern.html" %} {% extends "framework_modern.html" %}
{% load envipytags %}
{% block content %} {% block content %}
@ -54,6 +55,20 @@
</div> </div>
</div> </div>
<!-- Description -->
{% if node.description and node.description != "no description" %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Description</div>
<div class="collapse-content">{{ node.description }}</div>
</div>
{% endif %}
{% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %}
{% for tpl in viz_templates %}
{% include tpl %}
{% endfor %}
<!-- Image Representation --> <!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />

View File

@ -21,12 +21,13 @@
.link { .link {
stroke: #999; stroke: #999;
stroke-opacity: 0.6; stroke-opacity: 0.6;
/* marker-end: url(#arrow); */ stroke-width: 1.5px;
} }
.link_no_arrow { .link_no_arrow {
stroke: #999; stroke: #999;
stroke-opacity: 0.6; stroke-opacity: 0.6;
stroke-width: 1.5px;
} }
.node image { .node image {
@ -72,6 +73,15 @@
stroke: red; stroke: red;
stroke-width: 3px; stroke-width: 3px;
} }
.inedge {
stroke: red;
stroke-width: 3px;
}
.outedge {
stroke: green;
stroke-width: 3px;
}
</style> </style>
<script src="{% static 'js/pw.js' %}"></script> <script src="{% static 'js/pw.js' %}"></script>
@ -106,7 +116,7 @@
</div> </div>
<!-- Graphical Representation --> <!-- Graphical Representation -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse overflow-y-auto">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
Graphical Representation Graphical Representation
@ -140,7 +150,7 @@
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2" class="dropdown-content menu bg-base-100 rounded-box z-50 w-96 p-2"
> >
{% include "actions/objects/pathway.html" %} {% include "actions/objects/pathway.html" %}
</ul> </ul>
@ -168,6 +178,52 @@
tabindex="0" tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2" class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2"
> >
<li>
<a id="compound-names-toggle-button" class="cursor-pointer">
<svg
id="compound-names-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
Compound Names
</a>
</li>
<li>
<a id="reaction-names-toggle-button" class="cursor-pointer">
<svg
id="reaction-names-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
Reaction Names
</a>
</li>
{% if pathway.setting.model.app_domain %} {% if pathway.setting.model.app_domain %}
<li> <li>
<a id="app-domain-toggle-button" class="cursor-pointer"> <a id="app-domain-toggle-button" class="cursor-pointer">
@ -486,6 +542,10 @@
{{ pathway.d3_json|json_script:"pathway" }} {{ pathway.d3_json|json_script:"pathway" }}
<script> <script>
// Global switch for compound names view
var compoundNamesViewEnabled = false;
// Gloabl switch for reaction names view
var reactionNamesViewEnabled = false;
// Global switch for app domain view // Global switch for app domain view
var appDomainViewEnabled = false; var appDomainViewEnabled = false;
// Global switch for timeseries view // Global switch for timeseries view
@ -521,6 +581,58 @@
descContent.innerHTML = newDesc; descContent.innerHTML = newDesc;
} }
const compoundNamesBtn = document.getElementById(
"compound-names-toggle-button",
);
if (compoundNamesBtn) {
compoundNamesBtn.addEventListener("click", function () {
compoundNamesViewEnabled = !compoundNamesViewEnabled;
const icon = document.getElementById("compound-names-icon");
if (compoundNamesViewEnabled) {
// Change to eye-off icon
icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
nodes.forEach((x) => {
if (x.name) {
d3.select(x.el)
.append("text")
.text((d) => d.plain_name)
.attr("text-anchor", "middle")
.attr("y", -20)
.style("font-size", "4px");
}
});
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
nodes.forEach((x) => {
d3.select(x.el).select("text").remove();
});
}
});
}
const reactionNamesBtn = document.getElementById(
"reaction-names-toggle-button",
);
if (reactionNamesBtn) {
reactionNamesBtn.addEventListener("click", function () {
reactionNamesViewEnabled = !reactionNamesViewEnabled;
const icon = document.getElementById("reaction-names-icon");
if (reactionNamesViewEnabled) {
// Change to eye-off icon
icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
d3.selectAll(".link-label").style("display", null);
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
d3.selectAll(".link-label").style("display", "none");
}
});
}
// App domain toggle // App domain toggle
const appDomainBtn = document.getElementById("app-domain-toggle-button"); const appDomainBtn = document.getElementById("app-domain-toggle-button");
if (appDomainBtn) { if (appDomainBtn) {

View File

@ -212,7 +212,13 @@
e.preventDefault(); e.preventDefault();
const button = this; const button = this;
button.disabled = true; button.disabled = true;
// Set text depending on mode
if (document.getElementById("predict-submit-button").innerText === "Build") {
button.textContent = "Building...";
} else {
button.textContent = "Predicting..."; button.textContent = "Predicting...";
}
// Get SMILES from either input or Ketcher // Get SMILES from either input or Ketcher
const smilesInput = document.getElementById("predict-smiles"); const smilesInput = document.getElementById("predict-smiles");

View File

@ -110,8 +110,6 @@
<div <div
class="text-base-content/50 flex items-center justify-center space-x-6 text-sm" class="text-base-content/50 flex items-center justify-center space-x-6 text-sm"
> >
<a href="/legal" class="link link-hover">Legal</a>
<span class="text-base-content/30"></span>
<a href="/terms" class="link link-hover">Terms of Use</a> <a href="/terms" class="link link-hover">Terms of Use</a>
<span class="text-base-content/30"></span> <span class="text-base-content/30"></span>
<a href="/privacy" class="link link-hover">Privacy Policy</a> <a href="/privacy" class="link link-hover">Privacy Policy</a>

View File

@ -1,5 +1,7 @@
from django.conf import settings as s from django.conf import settings as s
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException, InvalidMolfileException
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, CompoundStructure from epdb.models import Compound, User, CompoundStructure
@ -17,6 +19,10 @@ class CompoundTest(TestCase):
cls.user = User.objects.get(username="anonymous") cls.user = User.objects.get(username="anonymous")
cls.package = PackageManager.create_package(cls.user, "Anon Test Package", "No Desc") 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): def test_smoke(self):
c = Compound.create( c = Compound.create(
self.package, self.package,
@ -33,13 +39,13 @@ class CompoundTest(TestCase):
self.assertEqual(c.description, "No Desc") self.assertEqual(c.description, "No Desc")
def test_missing_smiles(self): def test_missing_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc")
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc")
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
def test_smiles_are_trimmed(self): def test_smiles_are_trimmed(self):
@ -96,7 +102,7 @@ class CompoundTest(TestCase):
self.assertEqual(len(self.package.compounds), 1) self.assertEqual(len(self.package.compounds), 1)
def test_wrong_smiles(self): def test_wrong_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create( _ = Compound.create(
self.package, self.package,
smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F", smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F",
@ -188,3 +194,153 @@ class CompoundTest(TestCase):
c1.set_default_structure(c2) c1.set_default_structure(c2)
self.assertNotEqual(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

@ -1,6 +1,7 @@
from django.conf import settings as s from django.conf import settings as s
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, Reaction, Rule from epdb.models import Compound, User, Reaction, Rule
@ -163,7 +164,7 @@ class ReactionTest(TestCase):
self.assertEqual(len(self.package.reactions), 1) self.assertEqual(len(self.package.reactions), 1)
def test_wrong_smiles(self): def test_wrong_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Reaction.create( _ = Reaction.create(
package=self.package, package=self.package,
name="Eawag BBD reaction r0001", name="Eawag BBD reaction r0001",

View File

@ -11,6 +11,9 @@ from epdb.models import Compound, Scenario, ExternalDatabase
class CompoundViewTest(TestCase): class CompoundViewTest(TestCase):
fixtures = ["test_fixtures_incl_model.jsonl.gz"] 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 @classmethod
def setUpClass(cls): def setUpClass(cls):
super(CompoundViewTest, cls).setUpClass() super(CompoundViewTest, cls).setUpClass()
@ -396,3 +399,108 @@ class CompoundViewTest(TestCase):
c = Compound.objects.get(url=compound_url) c = Compound.objects.get(url=compound_url)
self.assertEqual(len(c.aliases), 0) 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)

View File

@ -46,6 +46,14 @@ class ModelViewTest(TestCase):
) )
expected = [ expected = [
{
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
"probability": 0.75,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
"name": "bt0001-3568",
},
},
{ {
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]], "products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]],
"probability": 0.25, "probability": 0.25,
@ -62,14 +70,6 @@ class ModelViewTest(TestCase):
"name": "bt0243-4301", "name": "bt0243-4301",
}, },
}, },
{
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
"probability": 0.75,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
"name": "bt0001-3568",
},
},
] ]
actual = response.json()["pred"] actual = response.json()["pred"]

View File

@ -72,8 +72,10 @@ class PackageViewTest(TestCase):
def test_import_package(self): def test_import_package(self):
file = SimpleUploadedFile( file = SimpleUploadedFile(
"Fixture_Package.json", "EAWAG-BBD_32de3cf4-e3e6-4168-956e-32fa5ddb0ce1.json",
open(s.FIXTURE_DIRS[0] / "Fixture_Package.json", "rb").read(), open(
s.FIXTURE_DIRS[0] / "EAWAG-BBD_32de3cf4-e3e6-4168-956e-32fa5ddb0ce1.json", "rb"
).read(),
content_type="application/json", content_type="application/json",
) )
@ -86,10 +88,11 @@ class PackageViewTest(TestCase):
p = Package.objects.get(url=package_url) p = Package.objects.get(url=package_url)
self.assertEqual(p.pathways.count(), 22) self.assertEqual(p.pathways.count(), 219)
self.assertEqual(p.rules.count(), 45) self.assertEqual(p.rules.count(), 498)
self.assertEqual(p.compounds.count(), 223) self.assertEqual(p.compounds.count(), 1399)
self.assertEqual(p.reactions.count(), 212) self.assertEqual(p.reactions.count(), 1480)
self.assertEqual(p.scenarios.count(), 1914)
upp = UserPackagePermission.objects.get(package=p, user=self.user1) upp = UserPackagePermission.objects.get(package=p, user=self.user1)
self.assertEqual(upp.permission, Permission.ALL[0]) self.assertEqual(upp.permission, Permission.ALL[0])

File diff suppressed because it is too large Load Diff