forked from enviPath/enviPy
Compare commits
4 Commits
a092d4a558
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 72a63b4876 | |||
| 2c2437e3f5 | |||
| 9bc9f86ff1 | |||
| dba6514013 |
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
10
epdb/exceptions.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
class InvalidSMILESException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMolfileException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PackageImportException(Exception):
|
||||||
|
pass
|
||||||
@ -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!"
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"]])
|
||||||
|
|||||||
150
epdb/migrations/0027_alter_compound_aliases_and_more.py
Normal file
150
epdb/migrations/0027_alter_compound_aliases_and_more.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
268
epdb/models.py
268
epdb/models.py
@ -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):
|
||||||
|
|||||||
@ -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
@ -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":
|
||||||
|
|||||||
@ -34,3 +34,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@import "./daisyui-theme.css";
|
@import "./daisyui-theme.css";
|
||||||
|
|
||||||
|
select.select[multiple] {
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
p a {
|
||||||
|
@apply underline;
|
||||||
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
202
static/js/pw.js
202
static/js/pw.js
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
1426
utilities/misc.py
1426
utilities/misc.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user