Fix App Domain Bug when a Rule can be applied more than once (#49)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#49
This commit is contained in:
2025-08-19 22:10:18 +12:00
parent c3c1d4f5cf
commit fc8192fb0d
4 changed files with 286 additions and 94 deletions

View File

@ -2,6 +2,8 @@ import abc
import json import json
import logging import logging
import os import os
import secrets
import hashlib
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Union, List, Optional, Dict, Tuple, Set from typing import Union, List, Optional, Dict, Tuple, Set
@ -58,27 +60,110 @@ class User(AbstractUser):
return self.default_setting return self.default_setting
class APIToken(models.Model): class APIToken(TimeStampedModel):
hashed_key = models.CharField(max_length=128, unique=True) """
user = models.ForeignKey(User, on_delete=models.CASCADE) API authentication token for users.
created = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True, default=timezone.now() + timedelta(days=90))
name = models.CharField(max_length=100, blank=True, help_text="Optional name for the token")
def is_valid(self): Provides secure token-based authentication with expiration support.
return not self.expires_at or self.expires_at > timezone.now() """
hashed_key = models.CharField(
max_length=128,
unique=True,
help_text="SHA-256 hash of the token key"
)
@staticmethod user = models.ForeignKey(
def create_token(user, name="", valid_for=90): User,
import secrets on_delete=models.CASCADE,
raw_token = secrets.token_urlsafe(32) related_name='api_tokens',
hashed = make_password(raw_token) help_text="User who owns this token"
token = APIToken.objects.create(user=user, hashed_key=hashed, name=name, )
expires_at=timezone.now() + timedelta(days=valid_for))
return token, raw_token
def check_token(self, raw_token): expires_at = models.DateTimeField(
return check_password(raw_token, self.hashed_key) null=True,
blank=True,
help_text="Token expiration time (null for no expiration)"
)
name = models.CharField(
max_length=100,
help_text="Descriptive name for this token"
)
is_active = models.BooleanField(
default=True,
help_text="Whether this token is active"
)
class Meta:
db_table = 'epdb_api_token'
verbose_name = 'API Token'
verbose_name_plural = 'API Tokens'
ordering = ['-created']
def __str__(self) -> str:
return f"{self.name} ({self.user.username})"
def is_valid(self) -> bool:
"""Check if token is valid and not expired."""
if not self.is_active:
return False
if self.expires_at and timezone.now() > self.expires_at:
return False
return True
@classmethod
def create_token(cls, user: User, name: str, expires_days: Optional[int] = None) -> Tuple['APIToken', str]:
"""
Create a new API token for a user.
Args:
user: User to create token for
name: Descriptive name for the token
expires_days: Number of days until expiration (None for no expiration)
Returns:
Tuple of (token_instance, raw_key)
"""
raw_key = secrets.token_urlsafe(32)
hashed_key = hashlib.sha256(raw_key.encode()).hexdigest()
expires_at = None
if expires_days:
expires_at = timezone.now() + timezone.timedelta(days=expires_days)
token = cls.objects.create(
user=user,
name=name,
hashed_key=hashed_key,
expires_at=expires_at
)
return token, raw_key
@classmethod
def authenticate(cls, raw_key: str) -> Optional[User]:
"""
Authenticate a user using an API token.
Args:
raw_key: Raw token key
Returns:
User if token is valid, None otherwise
"""
hashed_key = hashlib.sha256(raw_key.encode()).hexdigest()
try:
token = cls.objects.select_related('user').get(hashed_key=hashed_key)
if token.is_valid():
return token.user
except cls.DoesNotExist:
pass
return None
class Group(TimeStampedModel): class Group(TimeStampedModel):
@ -1090,16 +1175,15 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
data = self.kv.get('app_domain_assessment', None) data = self.kv.get('app_domain_assessment', None)
if data: if data:
rule_ids = dict() rule_ids = defaultdict(list)
for e in Edge.objects.filter(start_nodes__in=[self]): for e in Edge.objects.filter(start_nodes__in=[self]):
for r in e.edge_label.rules.all(): for r in e.edge_label.rules.all():
rule_ids[str(r.uuid)] = e rule_ids[str(r.uuid)].append(e.simple_json())
for t in data['assessment']['transformations']: for t in data['assessment']['transformations']:
if t['rule']['uuid'] in rule_ids: if t['rule']['uuid'] in rule_ids:
t['is_predicted'] = True t['is_predicted'] = True
t['edge'] = rule_ids[t['rule']['uuid']].simple_json() t['edges'] = rule_ids[t['rule']['uuid']]
return data return data
@ -1141,7 +1225,9 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
if app_domain_data: if app_domain_data:
for t in app_domain_data['assessment']['transformations']: for t in app_domain_data['assessment']['transformations']:
if 'edge' in t and t['edge']['uuid'] == str(self.uuid): if 'edges' in t:
for e in t['edges']:
if e['uuid'] == str(self.uuid):
passes_app_domain = ( passes_app_domain = (
t['local_compatibility'] >= app_domain_data['ad_params']['local_compatibility_threshold'] t['local_compatibility'] >= app_domain_data['ad_params']['local_compatibility_threshold']
) and ( ) and (

View File

@ -739,7 +739,10 @@ function handleAssessmentResponse(depict_url, data) {
var objLink = null; var objLink = null;
if (transObj['is_predicted']) { if (transObj['is_predicted']) {
panelName = `Predicted Transformation by ${transObj['rule']['name']}`; panelName = `Predicted Transformation by ${transObj['rule']['name']}`;
objLink = `<a class='list-group-item' href="${transObj['edge']['url']}">${transObj['edge']['name']}</a>` for (e in transObj['edges']) {
objLink = `<a class='list-group-item' href="${transObj['edges'][e]['url']}">${transObj['edges'][e]['name']}</a>`
break;
}
} else { } else {
panelName = `Potential Transformation by applying ${transObj['rule']['name']}`; panelName = `Potential Transformation by applying ${transObj['rule']['name']}`;
objLink = `<a class='list-group-item' href="${transObj['rule']['url']}">${transObj['rule']['name']}</a>` objLink = `<a class='list-group-item' href="${transObj['rule']['url']}">${transObj['rule']['name']}</a>`

View File

@ -1,6 +1,5 @@
console.log("loaded pw.js") console.log("loaded pw.js")
function predictFromNode(url) { function predictFromNode(url) {
$.post("", {node: url}) $.post("", {node: url})
.done(function (data) { .done(function (data) {
@ -28,61 +27,164 @@ function draw(pathway, elem) {
const horizontalSpacing = 75; // horizontal space between nodes const horizontalSpacing = 75; // horizontal space between nodes
const depthMap = new Map(); const depthMap = new Map();
nodes.forEach(node => { // Sort nodes by depth first to minimize crossings
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
sortedNodes.forEach(node => {
if (!depthMap.has(node.depth)) { if (!depthMap.has(node.depth)) {
depthMap.set(node.depth, 0); depthMap.set(node.depth, 0);
} }
const nodesInLevel = nodes.filter(n => n.depth === node.depth).length; const nodesInLevel = nodes.filter(n => n.depth === node.depth).length;
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
node.fy = node.depth * levelSpacing + 50;
// For pseudo nodes, try to position them to minimize crossings
if (node.pseudo) {
const parentLinks = links.filter(l => l.target.id === node.id);
const childLinks = links.filter(l => l.source.id === node.id);
if (parentLinks.length > 0 && childLinks.length > 0) {
const parentX = parentLinks[0].source.x || (width / 2);
const childrenX = childLinks.map(l => l.target.x || (width / 2));
const avgChildX = childrenX.reduce((sum, x) => sum + x, 0) / childrenX.length;
// Position pseudo node between parent and average child position
node.fx = (parentX + avgChildX) / 2;
} else {
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
}
} else {
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
}
node.fy = node.depth * levelSpacing + 50;
depthMap.set(node.depth, depthMap.get(node.depth) + 1); depthMap.set(node.depth, depthMap.get(node.depth) + 1);
}); });
} }
// Funktion für das Update der Positionen // Function to update pseudo node positions based on connected nodes
function updatePseudoNodePositions() {
nodes.forEach(node => {
if (node.pseudo && !node.isDragging) { // Don't auto-update if being dragged
const parentLinks = links.filter(l => l.target.id === node.id);
const childLinks = links.filter(l => l.source.id === node.id);
if (parentLinks.length > 0 && childLinks.length > 0) {
const parent = parentLinks[0].source;
const children = childLinks.map(l => l.target);
// Calculate optimal position to minimize crossing
const parentX = parent.x;
const parentY = parent.y;
const childrenX = children.map(c => c.x);
const childrenY = children.map(c => c.y);
const avgChildX = d3.mean(childrenX);
const avgChildY = d3.mean(childrenY);
// Position pseudo node between parent and average child position
node.fx = (parentX + avgChildX) / 2;
node.fy = (parentY + avgChildY) / 2; // Allow vertical movement too
}
}
});
}
// Enhanced ticked function
function ticked() { function ticked() {
// Update pseudo node positions first
updatePseudoNodePositions();
link.attr("x1", d => d.source.x) link.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y) .attr("y1", d => d.source.y)
.attr("x2", d => d.target.x) .attr("x2", d => d.target.x)
.attr("y2", d => d.target.y); .attr("y2", d => d.target.y);
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => `translate(${d.x},${d.y})`);
nodes.forEach(n => {
if (n.pseudo) {
// Alle Kinder dieses Pseudonodes finden
const childLinks = links.filter(l => l.source.id === n.id);
const childNodes = childLinks.map(l => l.target);
if (childNodes.length > 0) {
// Durchschnitt der Kinderpositionen berechnen
const avgX = d3.mean(childNodes, d => d.x);
const avgY = d3.mean(childNodes, d => d.y);
n.fx = avgX;
// keep level as is
n.fy = n.y;
}
}
});
//simulation.alpha(0.3).restart();
} }
function dragstarted(event, d) { function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart(); if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; // Setzt die Fixierung auf die aktuelle Position d.fx = d.x;
d.fy = d.y; d.fy = d.y;
// Mark if this node is being dragged
d.isDragging = true;
// If dragging a non-pseudo node, mark connected pseudo nodes for update
if (!d.pseudo) {
markConnectedPseudoNodes(d);
}
} }
function dragged(event, d) { function dragged(event, d) {
d.fx = event.x; // Position direkt an Maus anpassen d.fx = event.x;
d.fy = event.y; d.fy = event.y;
// Update connected pseudo nodes in real-time
if (!d.pseudo) {
updateConnectedPseudoNodes(d);
}
} }
function dragended(event, d) { function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0); if (!event.active) simulation.alphaTarget(0);
// Knoten bleibt an der neuen Position und wird nicht zurückgezogen
// Mark that dragging has ended
d.isDragging = false;
// Final update of connected pseudo nodes
if (!d.pseudo) {
updateConnectedPseudoNodes(d);
} }
}
// Helper function to mark connected pseudo nodes
function markConnectedPseudoNodes(draggedNode) {
// Find pseudo nodes connected to this node
const connectedPseudos = new Set();
// Check as parent of pseudo nodes
links.filter(l => l.source.id === draggedNode.id && l.target.pseudo)
.forEach(l => connectedPseudos.add(l.target));
// Check as child of pseudo nodes
links.filter(l => l.target.id === draggedNode.id && l.source.pseudo)
.forEach(l => connectedPseudos.add(l.source));
return connectedPseudos;
}
// Helper function to update connected pseudo nodes
function updateConnectedPseudoNodes(draggedNode) {
const connectedPseudos = markConnectedPseudoNodes(draggedNode);
connectedPseudos.forEach(pseudoNode => {
if (!pseudoNode.isDragging) { // Don't update if pseudo node is being dragged
const parentLinks = links.filter(l => l.target.id === pseudoNode.id);
const childLinks = links.filter(l => l.source.id === pseudoNode.id);
if (parentLinks.length > 0 && childLinks.length > 0) {
const parent = parentLinks[0].source;
const children = childLinks.map(l => l.target);
const parentX = parent.fx || parent.x;
const parentY = parent.fy || parent.y;
const childrenX = children.map(c => c.fx || c.x);
const childrenY = children.map(c => c.fy || c.y);
const avgChildX = d3.mean(childrenX);
const avgChildY = d3.mean(childrenY);
// Update pseudo node position - allow both X and Y movement
pseudoNode.fx = (parentX + avgChildX) / 2;
pseudoNode.fy = (parentY + avgChildY) / 2;
}
}
});
// Restart simulation with lower alpha to smooth the transition
simulation.alpha(0.1).restart();
}
// t -> ref to "this" from d3 // t -> ref to "this" from d3
function nodeClick(event, node, t) { function nodeClick(event, node, t) {
@ -140,13 +242,12 @@ function draw(pathway, elem) {
}); });
} }
function node_popup(n) { function node_popup(n) {
popupContent = "<a href='" + n.url +"'>" + n.name + "</a><br>"; popupContent = "<a href='" + n.url + "'>" + n.name + "</a><br>";
popupContent += "Depth " + n.depth + "<br>" popupContent += "Depth " + n.depth + "<br>"
if (appDomainViewEnabled) { if (appDomainViewEnabled) {
if(n.app_domain != null) { if (n.app_domain != null) {
popupContent += "This compound is " + (n.app_domain['inside_app_domain'] ? "inside" : "outside") + " the Applicability Domain derived from the chemical (PCA) space constructed using the training data." + "<br>" popupContent += "This compound is " + (n.app_domain['inside_app_domain'] ? "inside" : "outside") + " the Applicability Domain derived from the chemical (PCA) space constructed using the training data." + "<br>"
if (n.app_domain['uncovered_functional_groups']) { if (n.app_domain['uncovered_functional_groups']) {
popupContent += "Compound contains functional groups not covered by the training set <br>" popupContent += "Compound contains functional groups not covered by the training set <br>"
@ -154,7 +255,7 @@ function draw(pathway, elem) {
} }
} }
popupContent += "<img src='" + n.image + "' width='"+ 20 * nodeRadius +"'><br>" popupContent += "<img src='" + n.image + "' width='" + 20 * nodeRadius + "'><br>"
if (n.scenarios.length > 0) { if (n.scenarios.length > 0) {
popupContent += '<b>Half-lives and related scenarios:</b><br>' popupContent += '<b>Half-lives and related scenarios:</b><br>'
for (var s of n.scenarios) { for (var s of n.scenarios) {
@ -163,7 +264,7 @@ function draw(pathway, elem) {
} }
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0; var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
if(pathway.isIncremental && isLeaf) { if (pathway.isIncremental && isLeaf) {
popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>'; popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
} }
@ -171,25 +272,24 @@ function draw(pathway, elem) {
} }
function edge_popup(e) { function edge_popup(e) {
popupContent = "<a href='" + e.url +"'>" + e.name + "</a><br>"; popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br>";
if(e.app_domain){ if (e.app_domain) {
adcontent = "<p>"; adcontent = "<p>";
if(e.app_domain["times_triggered"]) { if (e.app_domain["times_triggered"]) {
adcontent += "This rule triggered " + e.app_domain["times_triggered"] + " times in the training set<br>"; adcontent += "This rule triggered " + e.app_domain["times_triggered"] + " times in the training set<br>";
} }
adcontent += "Reliability " + e.app_domain["reliability"].toFixed(2) + " (" + (e.app_domain["reliability"] > e.app_domain["reliability_threshold"] ? "&gt" : "&lt") + " Reliability Threshold of " + e.app_domain["reliability_threshold"] + ")<br>"; adcontent += "Reliability " + e.app_domain["reliability"].toFixed(2) + " (" + (e.app_domain["reliability"] > e.app_domain["reliability_threshold"] ? "&gt" : "&lt") + " Reliability Threshold of " + e.app_domain["reliability_threshold"] + ")<br>";
adcontent += "Local Compatibility " + e.app_domain["local_compatibility"].toFixed(2) + " (" + (e.app_domain["local_compatibility"] > e.app_domain["local_compatibility_threshold"] ? "&gt" : "&lt") + " Local Compatibility Threshold of " + e.app_domain["local_compatibility_threshold"] + ")<br>"; adcontent += "Local Compatibility " + e.app_domain["local_compatibility"].toFixed(2) + " (" + (e.app_domain["local_compatibility"] > e.app_domain["local_compatibility_threshold"] ? "&gt" : "&lt") + " Local Compatibility Threshold of " + e.app_domain["local_compatibility_threshold"] + ")<br>";
adcontent += "</p>"; adcontent += "</p>";
}
popupContent += adcontent; popupContent += adcontent;
}
popupContent += "<img src='" + e.image + "' width='"+ 20 * nodeRadius +"'><br>" popupContent += "<img src='" + e.image + "' width='" + 20 * nodeRadius + "'><br>"
if (e.reaction_probability) { if (e.reaction_probability) {
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>'; popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
} }
if (e.scenarios.length > 0) { if (e.scenarios.length > 0) {
popupContent += '<b>Half-lives and related scenarios:</b><br>' popupContent += '<b>Half-lives and related scenarios:</b><br>'
for (var s of e.scenarios) { for (var s of e.scenarios) {
@ -202,9 +302,9 @@ function draw(pathway, elem) {
var clientX; var clientX;
var clientY; var clientY;
document.addEventListener('mousemove', function(event) { document.addEventListener('mousemove', function (event) {
clientX = event.clientX; clientX = event.clientX;
clientY =event.clientY; clientY = event.clientY;
}); });
const zoomable = d3.select("#zoomable"); const zoomable = d3.select("#zoomable");
@ -258,7 +358,7 @@ function draw(pathway, elem) {
.attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)') .attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)')
// add element to links array // add element to links array
link.each(function(d) { link.each(function (d) {
d.el = this; // attach the DOM element to the data object d.el = this; // attach the DOM element to the data object
}); });
@ -279,7 +379,7 @@ function draw(pathway, elem) {
// Kreise für die Knoten hinzufügen // Kreise für die Knoten hinzufügen
node.append("circle") node.append("circle")
// make radius "invisible" // make radius "invisible" for pseudo nodes
.attr("r", d => d.pseudo ? 0.01 : nodeRadius) .attr("r", d => d.pseudo ? 0.01 : nodeRadius)
.style("fill", "#e8e8e8"); .style("fill", "#e8e8e8");
@ -292,7 +392,7 @@ function draw(pathway, elem) {
.attr("height", nodeRadius * 2); .attr("height", nodeRadius * 2);
// add element to nodes array // add element to nodes array
node.each(function(d) { node.each(function (d) {
d.el = this; // attach the DOM element to the data object d.el = this; // attach the DOM element to the data object
}); });

View File

@ -639,7 +639,7 @@ class IndigoUtils(object):
environment.add(mappedAtom.index()) environment.add(mappedAtom.index())
for k, v in functional_groups.items(): for k, v in functional_groups.items():
try:
sanitized = IndigoUtils.sanitize_functional_group(k) sanitized = IndigoUtils.sanitize_functional_group(k)
query = indigo.loadSmarts(sanitized) query = indigo.loadSmarts(sanitized)
@ -654,6 +654,9 @@ class IndigoUtils(object):
counts[mappedAtom.index()] = max(v, counts[mappedAtom.index()]) counts[mappedAtom.index()] = max(v, counts[mappedAtom.index()])
except IndigoException as e:
logger.debug(f'Colorizing failed due to {e}')
for k, v in counts.items(): for k, v in counts.items():
if is_reaction: if is_reaction:
color = "128, 0, 128" color = "128, 0, 128"