diff --git a/epdb/exceptions.py b/epdb/exceptions.py
new file mode 100644
index 00000000..f278cb4d
--- /dev/null
+++ b/epdb/exceptions.py
@@ -0,0 +1,2 @@
+class InvalidSMILESException(Exception):
+ pass
diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py
index b9a9d491..1f7219b9 100644
--- a/epdb/legacy_api.py
+++ b/epdb/legacy_api.py
@@ -1610,13 +1610,13 @@ class PathwayNode(Schema):
dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url")
- idcomp: str = Field(None, alias="default_node_label.url")
- idreact: str = Field(None, alias="default_node_label.url")
+ idcomp: str = Field(None, alias="node_label_id")
+ idreact: str = Field(None, alias="node_label_id")
image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name")
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")
@staticmethod
diff --git a/epdb/management/commands/bootstrap.py b/epdb/management/commands/bootstrap.py
index ccc02d14..f145126f 100644
--- a/epdb/management/commands/bootstrap.py
+++ b/epdb/management/commands/bootstrap.py
@@ -99,11 +99,15 @@ class Command(BaseCommand):
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
new_license.save()
- def import_package(self, data, owner):
- return PackageManager.import_legacy_package(
+ def import_package(self, data, owner, all_envipath_user_group):
+ p = PackageManager.import_legacy_package(
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):
s = SettingManager.create_setting(
owner,
@@ -198,7 +202,7 @@ class Command(BaseCommand):
s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8"
).read()
)
- imported_package = self.import_package(package_data, admin)
+ imported_package = self.import_package(package_data, admin, g)
mapping[p.replace(".json", "")] = imported_package
setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]])
diff --git a/epdb/migrations/0027_alter_compound_aliases_and_more.py b/epdb/migrations/0027_alter_compound_aliases_and_more.py
new file mode 100644
index 00000000..73c7a02f
--- /dev/null
+++ b/epdb/migrations/0027_alter_compound_aliases_and_more.py
@@ -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"
+ ),
+ ),
+ ]
diff --git a/epdb/models.py b/epdb/models.py
index 74dca2cc..0353872f 100644
--- a/epdb/models.py
+++ b/epdb/models.py
@@ -31,6 +31,7 @@ from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property
from bridge.dto import RunResult, PropertyPrediction
+from epdb.exceptions import InvalidSMILESException
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import (
ApplicabilityDomainPCA,
@@ -631,7 +632,7 @@ class EnviPathModel(TimeStampedModel):
class AliasMixin(models.Model):
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
@@ -654,7 +655,9 @@ class AliasMixin(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
def set_scenarios(self, scenarios: List["Scenario"]):
@@ -780,7 +783,7 @@ class Compound(
"CompoundStructure",
verbose_name="Default Structure",
related_name="compound_default_structure",
- on_delete=models.CASCADE,
+ on_delete=models.SET_NULL,
null=True,
)
@@ -857,13 +860,13 @@ class Compound(
package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs
) -> "Compound":
if smiles is None or smiles.strip() == "":
- raise ValueError("SMILES is required")
+ raise InvalidSMILESException("SMILES is required")
smiles = smiles.strip()
parsed = FormatConverter.from_smiles(smiles)
if parsed is None:
- raise ValueError("Given SMILES is invalid")
+ raise InvalidSMILESException("Given SMILES is invalid")
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
@@ -1174,6 +1177,8 @@ class CompoundStructure(
@property
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)
@property
@@ -1610,10 +1615,15 @@ class Reaction(
products = models.ManyToManyField(
"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")
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")
@@ -1630,7 +1640,7 @@ class Reaction(
educts: Union[List[str], List[CompoundStructure]] = None,
products: Union[List[str], List[CompoundStructure]] = None,
rules: Union[Rule | List[Rule]] = None,
- multi_step: bool = True,
+ multi_step: bool = False,
):
_educts = []
_products = []
@@ -2197,7 +2207,25 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
depth_map[0] = list()
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)
# At most depth len(nodes) is possible
@@ -2277,7 +2305,12 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"node_label_id": self.default_node_label.url,
"image": f"{self.url}?image=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",
"name": self.get_name(),
@@ -2331,6 +2364,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
@property
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)
def get_timeseries_data(self):
diff --git a/epdb/views.py b/epdb/views.py
index 169fd86d..e0daf802 100644
--- a/epdb/views.py
+++ b/epdb/views.py
@@ -19,6 +19,7 @@ from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required
+from .exceptions import InvalidSMILESException
from .logic import (
EPDBURLParser,
@@ -754,6 +755,11 @@ def models(request):
{"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
context["model_types"] = {
"ML Relative Reasoning": {
@@ -766,19 +772,26 @@ def models(request):
"requires_rule_packages": True,
"requires_data_packages": True,
},
- "EnviFormer": {
- "type": "enviformer",
- "requires_rule_packages": False,
- "requires_data_packages": True,
- },
}
+ if s.ENVIFORMER_PRESENT:
+ context["model_types"]["EnviFormer"] = (
+ {
+ "type": "enviformer",
+ "requires_rule_packages": False,
+ "requires_data_packages": True,
+ },
+ )
+
if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v.display()] = {
"type": k,
"requires_rule_packages": v.requires_rule_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():
context["model_types"][v.display()] = {
@@ -787,12 +800,6 @@ def models(request):
"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)
elif request.method == "POST":
@@ -906,12 +913,13 @@ def package_models(request, package_uuid):
"requires_rule_packages": True,
"requires_data_packages": True,
},
- "EnviFormer": {
+ }
+ if s.ENVIFORMER_PRESENT:
+ context["model_types"]["EnviFormer"] = {
"type": "enviformer",
"requires_rule_packages": False,
"requires_data_packages": True,
- },
- }
+ }
if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items():
@@ -1087,6 +1095,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)
elif half_life:
@@ -1190,9 +1203,7 @@ def package(request, package_uuid):
if request.method == "GET":
if request.GET.get("export", False) == "true":
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
- pack_json = PackageManager.export_package(
- current_package, include_models=False, include_external_identifiers=False
- )
+ pack_json = PackageManager.export_package(current_package)
response = JsonResponse(pack_json, content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
@@ -2328,7 +2339,13 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles").strip()
- current_pathway.add_node(node_smiles, name=node_name, description=node_description)
+
+ try:
+ current_pathway.add_node(node_smiles, 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)
diff --git a/migration/views.py b/migration/views.py
index 0e47ea7b..eaafd612 100644
--- a/migration/views.py
+++ b/migration/views.py
@@ -81,6 +81,7 @@ def migration(request):
"bt0416-4269",
"bt0432-4254",
"bt0337-4117",
+ "bt0322-3393",
]
if request.method == "GET":
diff --git a/static/css/input.css b/static/css/input.css
index 98ad1678..10ed39fa 100644
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -34,3 +34,8 @@
}
@import "./daisyui-theme.css";
+
+select.select[multiple] {
+ display: block;
+ white-space: normal;
+}
diff --git a/static/js/pw.js b/static/js/pw.js
index 5ad81262..da214d97 100644
--- a/static/js/pw.js
+++ b/static/js/pw.js
@@ -1,5 +1,31 @@
console.log("loaded pw.js")
+function findPaths(source_idx, target_idx, links) {
+ const resultLinks = new Set();
+ const visited = new Set();
+ // Helper function for depth-first search
+ function dfs(current) {
+ if (current === target_idx) {
+ 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); // Convert Set to Array for result
+}
+
function predictFromNode(url) {
fetch("", {
method: "POST",
@@ -627,6 +653,34 @@ function draw(pathway, elem) {
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)';
})
.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);
+ }
+ }
+ });
// add element to links array
link.each(function (d) {
@@ -643,10 +697,41 @@ function draw(pathway, elem) {
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
- .on("end", dragended))
+ .on("end", dragended)
+ )
.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"));
- })
+ const inEdges = findPaths(d.id, 3, 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
node.append("circle")
diff --git a/templates/actions/objects/pathway.html b/templates/actions/objects/pathway.html
index 85e4164b..91c861d6 100644
--- a/templates/actions/objects/pathway.html
+++ b/templates/actions/objects/pathway.html
@@ -22,7 +22,7 @@
{% for tpl in action_button_templates %}
{% include tpl %}
{% endfor %}
-
+
{% endif %}
-
+
Set Aliases
-
+
Delete Compound
@@ -111,7 +116,12 @@
Delete Reaction
diff --git a/templates/modals/objects/delete_pathway_edge_modal.html b/templates/modals/objects/delete_pathway_edge_modal.html
index 82ad8f4f..04f38d35 100644
--- a/templates/modals/objects/delete_pathway_edge_modal.html
+++ b/templates/modals/objects/delete_pathway_edge_modal.html
@@ -4,6 +4,22 @@
id="delete_pathway_edge_modal"
class="modal"
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()"
>
diff --git a/templates/modals/objects/delete_pathway_node_modal.html b/templates/modals/objects/delete_pathway_node_modal.html
index 41767e8f..1ac06f2f 100644
--- a/templates/modals/objects/delete_pathway_node_modal.html
+++ b/templates/modals/objects/delete_pathway_node_modal.html
@@ -4,6 +4,19 @@
id="delete_pathway_node_modal"
class="modal"
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()"
>
diff --git a/templates/objects/edge.html b/templates/objects/edge.html
index f9d93d04..f346e45b 100644
--- a/templates/objects/edge.html
+++ b/templates/objects/edge.html
@@ -92,7 +92,7 @@
{% for educt in edge.start_nodes.all %}
{{ educt.name }}{{ educt.get_name }}
{% endfor %}
diff --git a/templates/objects/group.html b/templates/objects/group.html
index c689ce7f..5aadb80c 100644
--- a/templates/objects/group.html
+++ b/templates/objects/group.html
@@ -56,7 +56,9 @@
+ {% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %}
+
+ {% for tpl in viz_templates %}
+ {% include tpl %}
+ {% endfor %}
+
diff --git a/templates/objects/pathway.html b/templates/objects/pathway.html
index 6b46e6fb..9cd9cf58 100644
--- a/templates/objects/pathway.html
+++ b/templates/objects/pathway.html
@@ -72,6 +72,15 @@
stroke: red;
stroke-width: 3px;
}
+
+ .inedge {
+ stroke: red;
+ stroke-width: 3px;
+ }
+ .outedge {
+ stroke: green;
+ stroke-width: 3px;
+ }
@@ -106,7 +115,7 @@
-
+
Graphical Representation
@@ -140,7 +149,7 @@
diff --git a/templates/predict_pathway.html b/templates/predict_pathway.html
index d0cb1ba6..d3413ca4 100644
--- a/templates/predict_pathway.html
+++ b/templates/predict_pathway.html
@@ -212,7 +212,13 @@
e.preventDefault();
const button = this;
button.disabled = true;
- button.textContent = "Predicting...";
+
+ // Set text depending on mode
+ if (document.getElementById("predict-submit-button").innerText === "Build") {
+ button.textContent = "Building...";
+ } else {
+ button.textContent = "Predicting...";
+ }
// Get SMILES from either input or Ketcher
const smilesInput = document.getElementById("predict-smiles");
diff --git a/tests/test_compound_model.py b/tests/test_compound_model.py
index 83a7113f..575b463b 100644
--- a/tests/test_compound_model.py
+++ b/tests/test_compound_model.py
@@ -1,5 +1,7 @@
from django.conf import settings as s
from django.test import TestCase, override_settings
+
+from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager
from epdb.models import Compound, User, CompoundStructure
@@ -33,13 +35,13 @@ class CompoundTest(TestCase):
self.assertEqual(c.description, "No Desc")
def test_missing_smiles(self):
- with self.assertRaises(ValueError):
+ with self.assertRaises(InvalidSMILESException):
_ = 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")
- with self.assertRaises(ValueError):
+ with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
def test_smiles_are_trimmed(self):
@@ -96,7 +98,7 @@ class CompoundTest(TestCase):
self.assertEqual(len(self.package.compounds), 1)
def test_wrong_smiles(self):
- with self.assertRaises(ValueError):
+ with self.assertRaises(InvalidSMILESException):
_ = Compound.create(
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",
diff --git a/tests/test_reaction_model.py b/tests/test_reaction_model.py
index bc501cd5..96b40965 100644
--- a/tests/test_reaction_model.py
+++ b/tests/test_reaction_model.py
@@ -1,6 +1,7 @@
from django.conf import settings as s
from django.test import TestCase, override_settings
+from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager
from epdb.models import Compound, User, Reaction, Rule
@@ -163,7 +164,7 @@ class ReactionTest(TestCase):
self.assertEqual(len(self.package.reactions), 1)
def test_wrong_smiles(self):
- with self.assertRaises(ValueError):
+ with self.assertRaises(InvalidSMILESException):
_ = Reaction.create(
package=self.package,
name="Eawag BBD reaction r0001",
diff --git a/tests/views/test_model_views.py b/tests/views/test_model_views.py
index 12cee202..dabf18c2 100644
--- a/tests/views/test_model_views.py
+++ b/tests/views/test_model_views.py
@@ -46,6 +46,14 @@ class ModelViewTest(TestCase):
)
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"]],
"probability": 0.25,
@@ -62,14 +70,6 @@ class ModelViewTest(TestCase):
"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"]
diff --git a/utilities/misc.py b/utilities/misc.py
index 6083c831..e1a84dec 100644
--- a/utilities/misc.py
+++ b/utilities/misc.py
@@ -6,12 +6,15 @@ import logging
import uuid
from collections import defaultdict
from datetime import datetime
-from typing import Any, Dict, List, TYPE_CHECKING
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
from django.conf import settings as s
from django.db import transaction
+from ninja import Schema
+from pydantic import HttpUrl
from epdb.models import (
+ AdditionalInformation,
Compound,
CompoundStructure,
Edge,
@@ -28,7 +31,6 @@ from epdb.models import (
Rule,
RuleBasedRelativeReasoning,
Scenario,
- SequentialRule,
Setting,
SimpleAmbitRule,
SimpleRDKitRule,
@@ -43,408 +45,235 @@ if TYPE_CHECKING:
from epdb.logic import SPathway
-class PackageExporter:
- def __init__(
- self,
- package: Package,
- include_models: bool = False,
- include_external_identifiers: bool = True,
- ):
- self._raw_package = package
- self.include_modes = include_models
- self.include_external_identifiers = include_external_identifiers
+class LicenseExportSchema(Schema):
+ cc_string: str
+ link: HttpUrl
+ image_link: HttpUrl
- def do_export(self):
- return PackageExporter._export_package_as_json(
- self._raw_package, self.include_modes, self.include_external_identifiers
- )
+
+##############
+# RefSchemas #
+##############
+class RefExportSchema(Schema):
+ uuid: str
+ url: str
@staticmethod
- def _export_package_as_json(
- package: Package, include_models: bool = False, include_external_identifiers: bool = True
- ) -> Dict[str, Any]:
+ def resolve_uuid(obj):
+ value = obj.get("uuid") if isinstance(obj, dict) else obj.uuid
+ return str(value)
+
+
+class RefCompoundExportSchema(RefExportSchema): ...
+
+
+class RefCompoundStructureExportSchema(RefExportSchema): ...
+
+
+class RefReactionExportSchema(RefExportSchema): ...
+
+
+class RefRuleExportSchema(RefExportSchema): ...
+
+
+class RefNodeExportSchema(RefExportSchema): ...
+
+
+class RefEdgeExportSchema(RefExportSchema): ...
+
+
+class RefPathwayExportSchema(RefExportSchema): ...
+
+
+class RefScenarioExportSchema(RefExportSchema): ...
+
+
+############
+# Compound #
+############
+class CompoundExportSchema(RefCompoundExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ default_structure: RefCompoundStructureExportSchema
+ structures: List["CompoundStructureExportSchema"]
+ scenarios: List[RefScenarioExportSchema]
+
+
+class CompoundStructureExportSchema(RefCompoundStructureExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ smiles: str
+ molfile: Optional[str]
+ normalized_structure: bool
+ scenarios: List[RefScenarioExportSchema]
+
+
+############
+# Reaction #
+############
+class ReactionExportSchema(RefReactionExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ educts: List[RefCompoundStructureExportSchema]
+ products: List[RefCompoundStructureExportSchema]
+ rules: List[RefRuleExportSchema]
+ multi_step: bool
+ medline_references: List[str] | None
+ scenarios: List[RefScenarioExportSchema]
+
+
+#########
+# Rules #
+#########
+class RuleExportSchema(RefRuleExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ smirks: str
+ reactant_filter_smarts: Optional[str]
+ product_filter_smarts: Optional[str]
+ scenarios: List[RefScenarioExportSchema]
+
+
+class ParallelRuleExportSchema(RefRuleExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ simple_rules: List[RefRuleExportSchema]
+ scenarios: List[RefScenarioExportSchema]
+
+
+###########################
+# Pathway / Nodes / Edges #
+###########################
+class NodeExportSchema(RefNodeExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ default_node_label: RefCompoundStructureExportSchema
+ node_labels: List[RefCompoundStructureExportSchema]
+ depth: int
+ stereo_removed: bool
+ scenarios: List[RefScenarioExportSchema]
+
+
+class EdgeExportSchema(RefEdgeExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ edge_label: RefReactionExportSchema
+ start_nodes: List[RefNodeExportSchema]
+ end_nodes: List[RefNodeExportSchema]
+ scenarios: List[RefScenarioExportSchema]
+
+
+class PathwayExportSchema(RefPathwayExportSchema):
+ name: str
+ description: str
+ aliases: List[str]
+ predicted: bool
+ nodes: List[NodeExportSchema]
+ edges: List[EdgeExportSchema]
+ scenarios: List[RefScenarioExportSchema]
+
+
+class ScenarioExportSchema(RefScenarioExportSchema):
+ name: str
+ description: str
+ scenario_date: str
+ scenario_type: str
+
+
+class AdditionalInformationExportSchema(RefExportSchema):
+ type: str
+ data: dict
+ scenario: RefScenarioExportSchema | None = None
+ attach_object: RefExportSchema | None = None
+
+ @staticmethod
+ def resolve_attach_object(obj):
+ if isinstance(obj, dict):
+ if obj.get("attach_object") is None:
+ return None
+ return RefExportSchema.model_validate(obj["attach_object"])
+ return obj.content_object
+
+
+###########
+# Package #
+###########
+class PackageExportSchema(Schema):
+ name: str
+ description: str
+ uuid: str
+ reviewed: bool
+ license: LicenseExportSchema | None
+ compounds: List[CompoundExportSchema]
+ reactions: List[ReactionExportSchema]
+ simple_rules: List[RuleExportSchema]
+ composite_rules: List[ParallelRuleExportSchema]
+ pathways: List[PathwayExportSchema]
+ scenarios: List[ScenarioExportSchema]
+ additional_information: List[AdditionalInformationExportSchema]
+
+ @staticmethod
+ def resolve_uuid(obj):
+ value = obj.get("uuid") if isinstance(obj, dict) else obj.uuid
+ return str(value)
+
+ @staticmethod
+ def resolve_simple_rules(obj):
+ if isinstance(obj, dict):
+ result = []
+ for r in obj.get("simple_rules", []):
+ result.append(RuleExportSchema.model_validate(r))
+ return result
+ return SimpleAmbitRule.objects.filter(package=obj)
+
+ @staticmethod
+ def resolve_composite_rules(obj):
+ if isinstance(obj, dict):
+ result = []
+ for r in obj.get("composite_rules", []):
+ result.append(ParallelRuleExportSchema.model_validate(r))
+ return result
+ return ParallelRule.objects.filter(package=obj)
+
+ @staticmethod
+ def resolve_additional_information(obj):
+ if isinstance(obj, dict):
+ result = []
+ for ai in obj.get("additional_information", []):
+ result.append(AdditionalInformationExportSchema.model_validate(ai))
+ return result
+ return AdditionalInformation.objects.filter(package=obj)
+
+
+class PackageExporter:
+ def __init__(self, package: Package):
+ self._raw_package = package
+
+ def do_export(self):
+ return PackageExporter._export_package_as_json(self._raw_package)
+
+ @staticmethod
+ def _export_package_as_json(package: Package) -> Dict[str, Any]:
"""
Dumps a Package and all its related objects as JSON.
Args:
package: The Package instance to dump
- include_models: Whether to include EPModel objects
- include_external_identifiers: Whether to include external identifiers
Returns:
Dict containing the complete package data as JSON-serializable structure
"""
- def serialize_base_object(
- obj, include_aliases: bool = True, include_scenarios: bool = True
- ) -> Dict[str, Any]:
- """Serialize common EnviPathModel fields"""
- base_dict = {
- "uuid": str(obj.uuid),
- "name": obj.name,
- "description": obj.description,
- "url": obj.url,
- "kv": obj.kv,
- }
+ data = PackageExportSchema.from_orm(package)
- # Add aliases if the object has them
- if include_aliases and hasattr(obj, "aliases"):
- base_dict["aliases"] = obj.aliases
-
- # Add scenarios if the object has them
- if include_scenarios and hasattr(obj, "scenarios"):
- base_dict["scenarios"] = [
- {"uuid": str(s.uuid), "url": s.url} for s in obj.scenarios.all()
- ]
-
- return base_dict
-
- def serialize_external_identifiers(obj) -> List[Dict[str, Any]]:
- """Serialize external identifiers for an object"""
- if not include_external_identifiers or not hasattr(obj, "external_identifiers"):
- return []
-
- identifiers = []
- for ext_id in obj.external_identifiers.all():
- identifier_dict = {
- "uuid": str(ext_id.uuid),
- "database": {
- "uuid": str(ext_id.database.uuid),
- "name": ext_id.database.name,
- "base_url": ext_id.database.base_url,
- },
- "identifier_value": ext_id.identifier_value,
- "url": ext_id.url,
- "is_primary": ext_id.is_primary,
- }
- identifiers.append(identifier_dict)
- return identifiers
-
- # Start with the package itself
- result = serialize_base_object(package, include_aliases=True, include_scenarios=True)
- result["reviewed"] = package.reviewed
-
- # # Add license information
- # if package.license:
- # result['license'] = {
- # 'uuid': str(package.license.uuid),
- # 'name': package.license.name,
- # 'link': package.license.link,
- # 'image_link': package.license.image_link
- # }
- # else:
- # result['license'] = None
-
- # Initialize collections
- result.update(
- {
- "compounds": [],
- "structures": [],
- "rules": {"simple_rules": [], "parallel_rules": [], "sequential_rules": []},
- "reactions": [],
- "pathways": [],
- "nodes": [],
- "edges": [],
- "scenarios": [],
- "models": [],
- }
- )
-
- print(f"Exporting package: {package.name}")
-
- # Export compounds
- print("Exporting compounds...")
- for compound in package.compounds.prefetch_related("default_structure").order_by("url"):
- compound_dict = serialize_base_object(
- compound, include_aliases=True, include_scenarios=True
- )
-
- if compound.default_structure:
- compound_dict["default_structure"] = {
- "uuid": str(compound.default_structure.uuid),
- "url": compound.default_structure.url,
- }
- else:
- compound_dict["default_structure"] = None
-
- compound_dict["external_identifiers"] = serialize_external_identifiers(compound)
- result["compounds"].append(compound_dict)
-
- # Export compound structures
- print("Exporting compound structures...")
- compound_structures = (
- CompoundStructure.objects.filter(compound__package=package)
- .select_related("compound")
- .order_by("url")
- )
-
- for structure in compound_structures:
- structure_dict = serialize_base_object(
- structure, include_aliases=True, include_scenarios=True
- )
- structure_dict.update(
- {
- "compound": {
- "uuid": str(structure.compound.uuid),
- "url": structure.compound.url,
- },
- "smiles": structure.smiles,
- "canonical_smiles": structure.canonical_smiles,
- "inchikey": structure.inchikey,
- "normalized_structure": structure.normalized_structure,
- "external_identifiers": serialize_external_identifiers(structure),
- }
- )
- result["structures"].append(structure_dict)
-
- # Export rules
- print("Exporting rules...")
-
- # Simple rules (including SimpleAmbitRule and SimpleRDKitRule)
- for rule in SimpleRule.objects.filter(package=package).order_by("url"):
- rule_dict = serialize_base_object(rule, include_aliases=True, include_scenarios=True)
-
- # Add specific fields for SimpleAmbitRule
- if isinstance(rule, SimpleAmbitRule):
- rule_dict.update(
- {
- "rule_type": "SimpleAmbitRule",
- "smirks": rule.smirks,
- "reactant_filter_smarts": rule.reactant_filter_smarts or "",
- "product_filter_smarts": rule.product_filter_smarts or "",
- }
- )
- elif isinstance(rule, SimpleRDKitRule):
- rule_dict.update(
- {"rule_type": "SimpleRDKitRule", "reaction_smarts": rule.reaction_smarts}
- )
- else:
- rule_dict["rule_type"] = "SimpleRule"
-
- result["rules"]["simple_rules"].append(rule_dict)
-
- # Parallel rules
- for rule in (
- ParallelRule.objects.filter(package=package)
- .prefetch_related("simple_rules")
- .order_by("url")
- ):
- rule_dict = serialize_base_object(rule, include_aliases=True, include_scenarios=True)
- rule_dict["rule_type"] = "ParallelRule"
- rule_dict["simple_rules"] = [
- {"uuid": str(sr.uuid), "url": sr.url} for sr in rule.simple_rules.all()
- ]
- result["rules"]["parallel_rules"].append(rule_dict)
-
- # Sequential rules
- for rule in (
- SequentialRule.objects.filter(package=package)
- .prefetch_related("simple_rules")
- .order_by("url")
- ):
- rule_dict = serialize_base_object(rule, include_aliases=True, include_scenarios=True)
- rule_dict["rule_type"] = "SequentialRule"
- rule_dict["simple_rules"] = [
- {
- "uuid": str(sr.uuid),
- "url": sr.url,
- "order_index": sr.sequentialruleordering_set.get(
- sequential_rule=rule
- ).order_index,
- }
- for sr in rule.simple_rules.all()
- ]
- result["rules"]["sequential_rules"].append(rule_dict)
-
- # Export reactions
- print("Exporting reactions...")
- for reaction in package.reactions.prefetch_related("educts", "products", "rules").order_by(
- "url"
- ):
- reaction_dict = serialize_base_object(
- reaction, include_aliases=True, include_scenarios=True
- )
- reaction_dict.update(
- {
- "educts": [{"uuid": str(e.uuid), "url": e.url} for e in reaction.educts.all()],
- "products": [
- {"uuid": str(p.uuid), "url": p.url} for p in reaction.products.all()
- ],
- "rules": [{"uuid": str(r.uuid), "url": r.url} for r in reaction.rules.all()],
- "multi_step": reaction.multi_step,
- "medline_references": reaction.medline_references,
- "external_identifiers": serialize_external_identifiers(reaction),
- }
- )
- result["reactions"].append(reaction_dict)
-
- # Export pathways
- print("Exporting pathways...")
- for pathway in package.pathways.order_by("url"):
- pathway_dict = serialize_base_object(
- pathway, include_aliases=True, include_scenarios=True
- )
-
- # Add setting reference if exists
- if hasattr(pathway, "setting") and pathway.setting:
- pathway_dict["setting"] = {
- "uuid": str(pathway.setting.uuid),
- "url": pathway.setting.url,
- }
- else:
- pathway_dict["setting"] = None
-
- result["pathways"].append(pathway_dict)
-
- # Export nodes
- print("Exporting nodes...")
- pathway_nodes = (
- Node.objects.filter(pathway__package=package)
- .select_related("pathway", "default_node_label")
- .prefetch_related("node_labels", "out_edges")
- .order_by("url")
- )
-
- for node in pathway_nodes:
- node_dict = serialize_base_object(node, include_aliases=True, include_scenarios=True)
- node_dict.update(
- {
- "pathway": {"uuid": str(node.pathway.uuid), "url": node.pathway.url},
- "default_node_label": {
- "uuid": str(node.default_node_label.uuid),
- "url": node.default_node_label.url,
- },
- "node_labels": [
- {"uuid": str(label.uuid), "url": label.url}
- for label in node.node_labels.all()
- ],
- "out_edges": [
- {"uuid": str(edge.uuid), "url": edge.url} for edge in node.out_edges.all()
- ],
- "depth": node.depth,
- }
- )
- result["nodes"].append(node_dict)
-
- # Export edges
- print("Exporting edges...")
- pathway_edges = (
- Edge.objects.filter(pathway__package=package)
- .select_related("pathway", "edge_label")
- .prefetch_related("start_nodes", "end_nodes")
- .order_by("url")
- )
-
- for edge in pathway_edges:
- edge_dict = serialize_base_object(edge, include_aliases=True, include_scenarios=True)
- edge_dict.update(
- {
- "pathway": {"uuid": str(edge.pathway.uuid), "url": edge.pathway.url},
- "edge_label": {"uuid": str(edge.edge_label.uuid), "url": edge.edge_label.url},
- "start_nodes": [
- {"uuid": str(node.uuid), "url": node.url} for node in edge.start_nodes.all()
- ],
- "end_nodes": [
- {"uuid": str(node.uuid), "url": node.url} for node in edge.end_nodes.all()
- ],
- }
- )
- result["edges"].append(edge_dict)
-
- # Export scenarios
- print("Exporting scenarios...")
- for scenario in package.scenarios.order_by("url"):
- scenario_dict = serialize_base_object(
- scenario, include_aliases=False, include_scenarios=False
- )
- scenario_dict.update(
- {
- "scenario_date": scenario.scenario_date,
- "scenario_type": scenario.scenario_type,
- "parent": {"uuid": str(scenario.parent.uuid), "url": scenario.parent.url}
- if scenario.parent
- else None,
- "additional_information": scenario.additional_information,
- }
- )
- result["scenarios"].append(scenario_dict)
-
- # Export models
- if include_models:
- print("Exporting models...")
- package_models = (
- package.models.select_related("app_domain")
- .prefetch_related("rule_packages", "data_packages", "eval_packages")
- .order_by("url")
- )
-
- for model in package_models:
- model_dict = serialize_base_object(
- model, include_aliases=True, include_scenarios=False
- )
-
- # Common fields for PackageBasedModel
- if hasattr(model, "rule_packages"):
- model_dict.update(
- {
- "rule_packages": [
- {"uuid": str(p.uuid), "url": p.url}
- for p in model.rule_packages.all()
- ],
- "data_packages": [
- {"uuid": str(p.uuid), "url": p.url}
- for p in model.data_packages.all()
- ],
- "eval_packages": [
- {"uuid": str(p.uuid), "url": p.url}
- for p in model.eval_packages.all()
- ],
- "threshold": model.threshold,
- "eval_results": model.eval_results,
- "model_status": model.model_status,
- }
- )
-
- if model.app_domain:
- model_dict["app_domain"] = {
- "uuid": str(model.app_domain.uuid),
- "url": model.app_domain.url,
- }
- else:
- model_dict["app_domain"] = None
-
- # Specific fields for different model types
- if isinstance(model, RuleBasedRelativeReasoning):
- model_dict.update(
- {
- "model_type": "RuleBasedRelativeReasoning",
- "min_count": model.min_count,
- "max_count": model.max_count,
- }
- )
- elif isinstance(model, MLRelativeReasoning):
- model_dict["model_type"] = "MLRelativeReasoning"
- elif isinstance(model, EnviFormer):
- model_dict["model_type"] = "EnviFormer"
- else:
- model_dict["model_type"] = "EPModel"
-
- result["models"].append(model_dict)
-
- print(f"Export completed for package: {package.name}")
- print(f"- Compounds: {len(result['compounds'])}")
- print(f"- Structures: {len(result['structures'])}")
- print(f"- Simple rules: {len(result['rules']['simple_rules'])}")
- print(f"- Parallel rules: {len(result['rules']['parallel_rules'])}")
- print(f"- Sequential rules: {len(result['rules']['sequential_rules'])}")
- print(f"- Reactions: {len(result['reactions'])}")
- print(f"- Pathways: {len(result['pathways'])}")
- print(f"- Nodes: {len(result['nodes'])}")
- print(f"- Edges: {len(result['edges'])}")
- print(f"- Scenarios: {len(result['scenarios'])}")
- print(f"- Models: {len(result['models'])}")
-
- return result
+ return data.model_dump(mode="json")
class PackageImporter: