From fc8192fb0d10b940c0432be93afb74c83a727b34 Mon Sep 17 00:00:00 2001 From: jebus Date: Tue, 19 Aug 2025 22:10:18 +1200 Subject: [PATCH] Fix App Domain Bug when a Rule can be applied more than once (#49) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/49 --- epdb/models.py | 160 +++++++++++++++++++++++++++++--------- static/js/pps.js | 5 +- static/js/pw.js | 194 +++++++++++++++++++++++++++++++++++----------- utilities/chem.py | 21 ++--- 4 files changed, 286 insertions(+), 94 deletions(-) diff --git a/epdb/models.py b/epdb/models.py index 55ca1dd9..d72576cb 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -2,6 +2,8 @@ import abc import json import logging import os +import secrets +import hashlib from collections import defaultdict from datetime import datetime, timedelta from typing import Union, List, Optional, Dict, Tuple, Set @@ -58,27 +60,110 @@ class User(AbstractUser): return self.default_setting -class APIToken(models.Model): - hashed_key = models.CharField(max_length=128, unique=True) - user = models.ForeignKey(User, on_delete=models.CASCADE) - 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") +class APIToken(TimeStampedModel): + """ + API authentication token for users. - def is_valid(self): - return not self.expires_at or self.expires_at > timezone.now() + Provides secure token-based authentication with expiration support. + """ + hashed_key = models.CharField( + max_length=128, + unique=True, + help_text="SHA-256 hash of the token key" + ) - @staticmethod - def create_token(user, name="", valid_for=90): - import secrets - raw_token = secrets.token_urlsafe(32) - hashed = make_password(raw_token) - token = APIToken.objects.create(user=user, hashed_key=hashed, name=name, - expires_at=timezone.now() + timedelta(days=valid_for)) - return token, raw_token + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='api_tokens', + help_text="User who owns this token" + ) - def check_token(self, raw_token): - return check_password(raw_token, self.hashed_key) + expires_at = models.DateTimeField( + 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): @@ -1090,16 +1175,15 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin): data = self.kv.get('app_domain_assessment', None) if data: - rule_ids = dict() + rule_ids = defaultdict(list) for e in Edge.objects.filter(start_nodes__in=[self]): 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']: if t['rule']['uuid'] in rule_ids: t['is_predicted'] = True - t['edge'] = rule_ids[t['rule']['uuid']].simple_json() + t['edges'] = rule_ids[t['rule']['uuid']] return data @@ -1141,23 +1225,25 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): if app_domain_data: for t in app_domain_data['assessment']['transformations']: - if 'edge' in t and t['edge']['uuid'] == str(self.uuid): - passes_app_domain = ( - t['local_compatibility'] >= app_domain_data['ad_params']['local_compatibility_threshold'] - ) and ( - t['reliability'] >= app_domain_data['ad_params']['reliability_threshold'] - ) + if 'edges' in t: + for e in t['edges']: + if e['uuid'] == str(self.uuid): + passes_app_domain = ( + t['local_compatibility'] >= app_domain_data['ad_params']['local_compatibility_threshold'] + ) and ( + t['reliability'] >= app_domain_data['ad_params']['reliability_threshold'] + ) - edge_json['app_domain'] = { - 'passes_app_domain': passes_app_domain, - 'local_compatibility': t['local_compatibility'], - 'local_compatibility_threshold': app_domain_data['ad_params']['local_compatibility_threshold'], - 'reliability': t['reliability'], - 'reliability_threshold': app_domain_data['ad_params']['reliability_threshold'], - 'times_triggered': t['times_triggered'], - } + edge_json['app_domain'] = { + 'passes_app_domain': passes_app_domain, + 'local_compatibility': t['local_compatibility'], + 'local_compatibility_threshold': app_domain_data['ad_params']['local_compatibility_threshold'], + 'reliability': t['reliability'], + 'reliability_threshold': app_domain_data['ad_params']['reliability_threshold'], + 'times_triggered': t['times_triggered'], + } - break + break return edge_json diff --git a/static/js/pps.js b/static/js/pps.js index d6f064d4..d2f7971a 100644 --- a/static/js/pps.js +++ b/static/js/pps.js @@ -739,7 +739,10 @@ function handleAssessmentResponse(depict_url, data) { var objLink = null; if (transObj['is_predicted']) { panelName = `Predicted Transformation by ${transObj['rule']['name']}`; - objLink = `${transObj['edge']['name']}` + for (e in transObj['edges']) { + objLink = `${transObj['edges'][e]['name']}` + break; + } } else { panelName = `Potential Transformation by applying ${transObj['rule']['name']}`; objLink = `${transObj['rule']['name']}` diff --git a/static/js/pw.js b/static/js/pw.js index 5e362d12..99c44595 100644 --- a/static/js/pw.js +++ b/static/js/pw.js @@ -1,6 +1,5 @@ console.log("loaded pw.js") - function predictFromNode(url) { $.post("", {node: url}) .done(function (data) { @@ -28,62 +27,165 @@ function draw(pathway, elem) { const horizontalSpacing = 75; // horizontal space between nodes 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)) { depthMap.set(node.depth, 0); } 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); }); } - // 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() { + // Update pseudo node positions first + updatePseudoNodePositions(); + link.attr("x1", d => d.source.x) .attr("y1", d => d.source.y) .attr("x2", d => d.target.x) .attr("y2", d => d.target.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) { 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; + + // 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) { - d.fx = event.x; // Position direkt an Maus anpassen + d.fx = event.x; d.fy = event.y; + + // Update connected pseudo nodes in real-time + if (!d.pseudo) { + updateConnectedPseudoNodes(d); + } } function dragended(event, d) { 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 function nodeClick(event, node, t) { console.log(node); @@ -105,7 +207,7 @@ function draw(pathway, elem) { pop = $(e).attr("aria-describedby") h = $('#' + pop).height(); $('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`) - setTimeout(function () { + setTimeout(function () { var close = setInterval(function () { if (!$(".popover:hover").length // mouse outside popover && !$(e).is(':hover')) { // mouse outside element @@ -140,13 +242,12 @@ function draw(pathway, elem) { }); } - function node_popup(n) { - popupContent = "" + n.name + "
"; + popupContent = "" + n.name + "
"; popupContent += "Depth " + n.depth + "
" 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." + "
" if (n.app_domain['uncovered_functional_groups']) { popupContent += "Compound contains functional groups not covered by the training set
" @@ -154,7 +255,7 @@ function draw(pathway, elem) { } } - popupContent += "
" + popupContent += "
" if (n.scenarios.length > 0) { popupContent += 'Half-lives and related 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; - if(pathway.isIncremental && isLeaf) { + if (pathway.isIncremental && isLeaf) { popupContent += '
Predict from here
'; } @@ -171,25 +272,24 @@ function draw(pathway, elem) { } function edge_popup(e) { - popupContent = "" + e.name + "
"; + popupContent = "" + e.name + "
"; - if(e.app_domain){ + if (e.app_domain) { adcontent = "

"; - 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
"; } - adcontent += "Reliability " + e.app_domain["reliability"].toFixed(2) + " (" + (e.app_domain["reliability"] > e.app_domain["reliability_threshold"] ? ">" : "<") + " Reliability Threshold of " + e.app_domain["reliability_threshold"] + ")
"; - adcontent += "Local Compatibility " + e.app_domain["local_compatibility"].toFixed(2) + " (" + (e.app_domain["local_compatibility"] > e.app_domain["local_compatibility_threshold"] ? ">" : "<") + " Local Compatibility Threshold of " + e.app_domain["local_compatibility_threshold"] + ")
"; + adcontent += "Reliability " + e.app_domain["reliability"].toFixed(2) + " (" + (e.app_domain["reliability"] > e.app_domain["reliability_threshold"] ? ">" : "<") + " Reliability Threshold of " + e.app_domain["reliability_threshold"] + ")
"; + adcontent += "Local Compatibility " + e.app_domain["local_compatibility"].toFixed(2) + " (" + (e.app_domain["local_compatibility"] > e.app_domain["local_compatibility_threshold"] ? ">" : "<") + " Local Compatibility Threshold of " + e.app_domain["local_compatibility_threshold"] + ")
"; adcontent += "

"; + popupContent += adcontent; } - popupContent += adcontent; - popupContent += "
" + popupContent += "
" if (e.reaction_probability) { popupContent += 'Probability:
' + e.reaction_probability.toFixed(3) + '
'; } - if (e.scenarios.length > 0) { popupContent += 'Half-lives and related scenarios:
' for (var s of e.scenarios) { @@ -202,9 +302,9 @@ function draw(pathway, elem) { var clientX; var clientY; - document.addEventListener('mousemove', function(event) { + document.addEventListener('mousemove', function (event) { clientX = event.clientX; - clientY =event.clientY; + clientY = event.clientY; }); const zoomable = d3.select("#zoomable"); @@ -258,8 +358,8 @@ function draw(pathway, elem) { .attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)') // add element to links array - link.each(function(d) { - d.el = this; // attach the DOM element to the data object + link.each(function (d) { + d.el = this; // attach the DOM element to the data object }); pop_add(link, "Reaction", edge_popup); @@ -279,7 +379,7 @@ function draw(pathway, elem) { // Kreise für die Knoten hinzufügen node.append("circle") - // make radius "invisible" + // make radius "invisible" for pseudo nodes .attr("r", d => d.pseudo ? 0.01 : nodeRadius) .style("fill", "#e8e8e8"); @@ -292,9 +392,9 @@ function draw(pathway, elem) { .attr("height", nodeRadius * 2); // add element to nodes array - node.each(function(d) { - d.el = this; // attach the DOM element to the data object + node.each(function (d) { + d.el = this; // attach the DOM element to the data object }); pop_add(node, "Compound", node_popup); -} +} \ No newline at end of file diff --git a/utilities/chem.py b/utilities/chem.py index 395e65b1..6b3f7cea 100644 --- a/utilities/chem.py +++ b/utilities/chem.py @@ -639,20 +639,23 @@ class IndigoUtils(object): environment.add(mappedAtom.index()) 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) + for match in matcher.iterateMatches(query): + if match is not None: - for match in matcher.iterateMatches(query): - if match is not None: + for atom in query.iterateAtoms(): + mappedAtom = match.mapAtom(atom) + if mappedAtom is None or mappedAtom.index() in environment: + continue - for atom in query.iterateAtoms(): - mappedAtom = match.mapAtom(atom) - if mappedAtom is None or mappedAtom.index() in environment: - continue + 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(): if is_reaction: