From dba651401341eaf7480e69401c2085007a7c6098 Mon Sep 17 00:00:00 2001 From: jebus Date: Thu, 2 Jul 2026 20:12:26 +1200 Subject: [PATCH] [Chore] Package Export, Blanks for fields, Styling (#421) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/421 --- epdb/exceptions.py | 2 + epdb/legacy_api.py | 6 +- epdb/management/commands/bootstrap.py | 10 +- .../0027_alter_compound_aliases_and_more.py | 150 +++++ epdb/models.py | 58 +- epdb/views.py | 53 +- migration/views.py | 1 + static/css/input.css | 5 + static/js/pw.js | 89 ++- templates/actions/objects/pathway.html | 20 +- .../objects/delete_pathway_edge_modal.html | 16 + .../objects/delete_pathway_node_modal.html | 13 + templates/objects/edge.html | 4 +- templates/objects/group.html | 4 +- templates/objects/node.html | 7 + templates/objects/pathway.html | 13 +- templates/predict_pathway.html | 8 +- tests/test_compound_model.py | 10 +- tests/test_reaction_model.py | 3 +- tests/views/test_model_views.py | 16 +- utilities/misc.py | 609 +++++++----------- 21 files changed, 647 insertions(+), 450 deletions(-) create mode 100644 epdb/exceptions.py create mode 100644 epdb/migrations/0027_alter_compound_aliases_and_more.py 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()" > + {% 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: