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 += "