4 Commits

Author SHA1 Message Date
9bc9f86ff1 [Fix] Pathway SVG Export has Edges again, Fix In/Out Edge coloring (#422)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#422
2026-07-03 07:24:02 +12:00
dba6514013 [Chore] Package Export, Blanks for fields, Styling (#421)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#421
2026-07-02 20:12:26 +12:00
a092d4a558 [Chore] Updated Dockerfile (#418)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#418
2026-06-17 08:33:02 +12:00
2502c020f7 [Fix] Collection of minor Bugfixes (#417)
User feedback from on-prem installation

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#417
2026-06-17 07:56:32 +12:00
38 changed files with 812 additions and 540 deletions

View File

@ -6,18 +6,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
openssh-client \
git \
nodejs \
npm \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install pnpm
RUN npm install -g pnpm
# Install Node 22 + pnpm
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& corepack enable \
&& corepack prepare pnpm@latest --activate \
&& rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"
@ -32,7 +37,7 @@ RUN mkdir -p -m 0700 /root/.ssh \
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
# docker build --ssh default -t envipath/envipy:1.0 .
RUN --mount=type=ssh \
uv sync --locked --extra ms-login --extra pepper-plugin
uv sync --locked --extra pepper-plugin
# Now copy source and do a final sync to install the project itself
# Ensure .dockerignore is reasonable

View File

@ -254,7 +254,14 @@ class Classifier(Plugin):
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
if cls.Config is None:
return None
return cls.Config(**(data or {}))
# remove empty strings a.k.a unset params to not overwrite defaults
cpy = {}
if data is not None:
for k, v in data.items():
if v != "":
cpy[k] = v
return cls.Config(**cpy)
@classmethod
def create(cls, data: dict | None = None):

2
epdb/exceptions.py Normal file
View File

@ -0,0 +1,2 @@
class InvalidSMILESException(Exception):
pass

View File

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

View File

@ -99,11 +99,15 @@ class Command(BaseCommand):
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
new_license.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"]])

View File

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

View File

@ -31,6 +31,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,12 +2364,17 @@ 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):
for ai in self.additional_information.all():
if ai.__class__.__name__ == "OECD301FTimeSeries":
return ai.model_dump(mode="json")
if ai.type == "OECD301FTimeSeries":
return ai.get().model_dump(mode="json")
return None

View File

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

File diff suppressed because one or more lines are too long

View File

@ -65,6 +65,25 @@ def run_both_engines(SMILES, SMIRKS):
def migration(request):
accepted_diffs = [
"bt0055-3469.1",
"bt0056-2685",
"bt0193-4263",
"bt0231-1871.1",
"bt0242-3803",
"bt0254-4224.1",
"bt0254-4224.2",
"bt0291-1129",
"bt0337-3543",
"bt0391-4285",
"bt0402-3576",
"bt0404-3928",
"bt0416-4269",
"bt0432-4254",
"bt0337-4117",
"bt0322-3393",
]
if request.method == "GET":
context = get_base_context(request)
@ -109,11 +128,11 @@ def migration(request):
),
"id": str(r.uuid),
"url": r.url,
"status": res,
"status": res or r.name in accepted_diffs,
}
)
if res:
if res or r.name in accepted_diffs:
success += 1
else:
error += 1
@ -135,7 +154,16 @@ def migration(request):
for r in migration_status["results"]:
r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
if r["name"] in accepted_diffs:
r["status"] = True
migration_status["results"] = sorted(
migration_status["results"], key=lambda x: (x["status"], x["name"])
)
num_success = sum([int(x["status"]) for x in migration_status["results"]])
migration_status["success"] = num_success
migration_status["error"] = migration_status["total"] - num_success
context.update(**migration_status)
return render(request, "migration.html", context)

View File

@ -21,5 +21,11 @@
"django",
"tailwindcss",
"daisyui"
]
],
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@tailwindcss/oxide"
]
}
}

View File

@ -1,3 +1,5 @@
allowBuilds:
'@parcel/watcher': true
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'

View File

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

View File

@ -59,6 +59,9 @@ document.addEventListener("alpine:init", () => {
get isEditMode() {
return this.mode === "edit";
},
get isRequired() {
return (this.schema.required || []).indexOf(this.fieldName) > -1
}
});
// Text widget

View File

@ -1,5 +1,37 @@
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) {
fetch("", {
method: "POST",
@ -23,6 +55,7 @@ function predictFromNode(url) {
// elem = 'vizdiv'
function draw(pathway, elem) {
const initialzoom = 2.5
const nodeRadius = 20;
const linkDistance = 100;
const chargeStrength = -200;
@ -63,7 +96,7 @@ function draw(pathway, elem) {
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
}
node.fy = node.depth * levelSpacing + 50;
node.fy = (node.depth + initialzoom + 0.5) * levelSpacing + 50;
depthMap.set(node.depth, depthMap.get(node.depth) + 1);
});
}
@ -577,6 +610,8 @@ function draw(pathway, elem) {
// Apply zoom to the SVG element - this enables wheel zoom
svg.call(zoom);
svg.call(zoom.scaleBy, initialzoom);
// Also apply zoom to container to catch events that might not reach SVG
// This ensures drag-to-pan works even when clicking on empty space
container.call(zoom);
@ -624,6 +659,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) {
@ -640,10 +703,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, 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")
@ -717,14 +811,14 @@ function draw(pathway, elem) {
function serializeSVG(svgElement) {
svgElement.querySelectorAll("line.link").forEach(line => {
svgElement.querySelectorAll("path.link").forEach(line => {
const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth);
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);
line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth);

View File

@ -22,7 +22,7 @@
{% for tpl in action_button_templates %}
{% include tpl %}
{% endfor %}
<li role="separator" class="divider"></li>
<li role="separator" class="divider h-px"></li>
{% endif %}
<li>
<a
@ -74,7 +74,7 @@
Rules</a
>
</li>
<li role="separator" class="divider"></li>
<li role="separator" class="divider h-px"></li>
<li>
<a
class="button"
@ -99,11 +99,16 @@
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li>
<li role="separator" class="divider"></li>
<li role="separator" class="divider h-px"></li>
<li>
<a
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
>
@ -111,7 +116,12 @@
<li>
<a
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
>

View File

@ -5,7 +5,7 @@
{% block action_button %}
<div class="flex items-center gap-2">
{% if meta.can_edit %}
{% if meta.can_edit or not meta.url_contains_package %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -3,14 +3,16 @@
{% block page_title %}Models{% endblock %}
{% block action_button %}
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
New Model
</button>
{% if meta.can_edit or not meta.url_contains_package %}
{% if meta.enabled_features.MODEL_BUILDING %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
New Model
</button>
{% endif %}
{% endif %}
{% endblock action_button %}

View File

@ -3,75 +3,73 @@
{% block page_title %}Packages{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
id="new-package-button"
onclick="document.getElementById('new_package_modal').showModal(); return false;"
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
id="new-package-button"
onclick="document.getElementById('new_package_modal').showModal(); return false;"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-folder-plus-icon lucide-folder-plus"
>
<path d="M12 10v6" />
<path d="M9 13h6" />
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg>
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
Import
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
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-folder-plus-icon lucide-folder-plus"
class="lucide lucide-chevron-down ml-1"
>
<path d="M12 10v6" />
<path d="M9 13h6" />
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
Import
<svg
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-chevron-down ml-1"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2"
>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
Import Package from JSON
</a>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
Import Package from legacy JSON
</a>
</li>
</ul>
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2"
>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
Import Package from JSON
</a>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
Import Package from legacy JSON
</a>
</li>
</ul>
</div>
{% endif %}
</div>
{% endblock action_button %}
{% block action_modals %}

View File

@ -3,7 +3,7 @@
{% block page_title %}Pathways{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
{% if meta.can_edit or not meta.url_contains_package %}
<div class="flex items-center gap-2">
<a
class="btn btn-primary btn-sm"

View File

@ -3,7 +3,7 @@
{% block page_title %}Reactions{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
{% if meta.can_edit or not meta.url_contains_package %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -3,7 +3,7 @@
{% block page_title %}Rules{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
{% if meta.can_edit or not meta.url_contains_package %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -11,7 +11,7 @@
{% endblock action_modals %}
{% block action_button %}
{% if meta.can_edit %}
{% if meta.can_edit or not meta.url_contains_package %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -3,7 +3,7 @@
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
{% if meta.can_edit or not meta.url_contains_package %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -44,6 +44,7 @@
:class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value"
:multiple="multiple"
:required="isRequired"
>
<option value="" :selected="!value">Select...</option>

View File

@ -65,11 +65,11 @@
},
get showMlrr() {
return this.selectedType === 'mlrr';
return this.selectedType === 'ml-relative-reasoning';
},
get showRbrr() {
return this.selectedType === 'rbrr';
return this.selectedType === 'rule-based-relative-reasoning';
},
get showEnviformer() {

View File

@ -203,11 +203,11 @@
id="model-based-prediction-setting-threshold"
name="model-based-prediction-setting-threshold"
class="input input-bordered w-full"
placeholder="0.25"
value="0.25"
type="number"
min="0"
max="1"
step="0.05"
step="any"
/>
</div>

View File

@ -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()"
>
<div class="modal-box">

View File

@ -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()"
>
<div class="modal-box">

View File

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

View File

@ -56,7 +56,9 @@
<ul class="menu bg-base-200 rounded-box">
{% for um in group.user_member.all %}
<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 }}
{% if not um.is_active %}<i>(inactive)</i>{% endif %}</a
>

View File

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

View File

@ -72,6 +72,15 @@
stroke: red;
stroke-width: 3px;
}
.inedge {
stroke: red;
stroke-width: 3px;
}
.outedge {
stroke: green;
stroke-width: 3px;
}
</style>
<script src="{% static 'js/pw.js' %}"></script>
@ -106,7 +115,7 @@
</div>
<!-- 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 />
<div class="collapse-title text-xl font-medium">
Graphical Representation
@ -140,7 +149,7 @@
</div>
<ul
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" %}
</ul>

View File

@ -105,7 +105,7 @@
></iframe>
</div>
<label class="select mb-8 w-full">
<label class="select mb-8 w-full" id="prediction-setting-label">
<span class="label">Predictor</span>
<select id="prediction-setting" name="prediction-setting">
<option disabled>Select a Setting</option>
@ -148,6 +148,21 @@
</div>
{# prettier-ignore-start #}
<script>
// Hide predictor selection and update button text if mode is "build"
function radioChange(event) {
if (event.target.value === "build") {
document.getElementById("prediction-setting-label").hidden = true;
document.getElementById("predict-submit-button").innerText = "Build";
} else {
document.getElementById("prediction-setting-label").hidden = false;
document.getElementById("predict-submit-button").innerText = "Predict";
}
}
const radioButtons = document.querySelectorAll('input[name="predict"]');
radioButtons.forEach(radio => {
radio.addEventListener('change', radioChange);
});
// Helper function to safely get Ketcher instance from iframe
function getKetcherInstance(iframeId) {
const ketcherFrame = document.getElementById(iframeId);
@ -197,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");

View File

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

View File

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

View File

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

View File

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