15 Commits

Author SHA1 Message Date
e09e1fe048 model stats
Some checks failed
CI / test (pull_request) Failing after 28s
API CI / api-tests (pull_request) Failing after 44s
2026-07-26 21:06:46 +02:00
8b2caf3500 auth log for api
Some checks failed
CI / test (pull_request) Failing after 29s
API CI / api-tests (pull_request) Failing after 41s
2026-07-26 20:54:35 +02:00
b9c3618c41 secret packs viz
Some checks failed
CI / test (pull_request) Failing after 30s
API CI / api-tests (pull_request) Failing after 43s
2026-07-25 22:50:20 +02:00
9bf65d4319 ...
Some checks failed
CI / test (pull_request) Failing after 28s
API CI / api-tests (pull_request) Failing after 38s
2026-07-25 22:23:57 +02:00
d72676710a auth log leftovers 2026-07-25 22:13:06 +02:00
aaa87c5f2f Auth log
Some checks failed
API CI / api-tests (pull_request) Failing after 30s
CI / test (pull_request) Failing after 32s
2026-07-23 23:31:07 +02:00
3241bc2648 sync 2026-07-23 23:31:07 +02:00
d5d7779d8e wip 2026-07-23 23:31:07 +02:00
91d27025b9 View Package Perm 2026-07-23 23:31:07 +02:00
a17751be1f Provide proper Error Pages 2026-07-23 23:31:07 +02:00
701bb3dd5f adjusted migration
Initial bayer app

Show Pack Classification

Adjusted docker compose to bayer specifics

Adjusted Dockerfile for Bayer

Adding secret flags to group, add secret pools to packages

Adjusted View for Package creation

Prep configs, added Package Create Modal

wip

More on PES

wip

wip

Wip

minor

PW interactions

API PES

wip

Make Select Widget reflect required

make required generallay available

Update UI if pathway mode is set to build

Added ais

circle adjustments

Initial Zoom, fix AD Creation

wip

auth log, bb4g fix

missing import

Added viz hint if PES is part of reaction

Add Edge check for pes

flip boolean

...

pes

Added extra

...

In / Out Edges Viz, Submitting Button Text

...

Make PES Link clickable

Return proper http response instead of error

Fixed error return, removed unused options

Fix PES Link HTML for other entities

Fixed molfile assignment, adjusted Export

Package Export/Import cycle

highlight Description links

implemented non persistent

Harmonised proposed field in Json output

Added pesLink field to PW Api output

PES Fields in API Output

removed debug

Fix Classification import, Fix PES Deserialization

underline pes link in templates

Fix alter name/desc for node, make /node /edge funcitonal

provide setting link and copy button

Implemented Compound Names / Reaction Names View Option

Unconnected Nodes

Make links thicker, reduce timeout trigger time

Show proposed info in popover

Pathway Build no stereo removal

Include probs in reaction name option viz

Detect clicks outside nodes/edges
2026-07-23 23:31:06 +02:00
7632b3a029 [Fix] All thresholds, Increase Batch Predict Limit, Make "Back" Button on error page trigger a new pageload. (#432)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#432
2026-07-24 09:29:05 +12:00
d657c0285a [Fix] Catch invalid reactions smiles on reaction creation (#431)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#431
2026-07-23 07:45:36 +12:00
f4c198981b [Feature] SPathway to_json outputs Bayes Probabilities (#430)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#430
2026-07-23 07:20:26 +12:00
cdd51fc7aa [Fix] Iuclid export produce only one root substance (#429)
Reviewed-on: enviPath/enviPy#429
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-07-23 06:37:02 +12:00
16 changed files with 335 additions and 48 deletions

View File

@ -131,8 +131,9 @@
id="package-data-pool" id="package-data-pool"
name="package-data-pool" name="package-data-pool"
class="select select-bordered w-full" class="select select-bordered w-full"
:required="isSecret"
> >
<option value="" disabled selected>Select Data Pool</option> <option value="" disabled>Select Data Pool</option>
{% for obj in meta.secret_groups %} {% for obj in meta.secret_groups %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option> <option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endfor %} {% endfor %}

View File

@ -357,6 +357,7 @@ DEFAULT_MODEL_PARAMS = {
DEFAULT_MAX_NUMBER_OF_NODES = 9999 DEFAULT_MAX_NUMBER_OF_NODES = 9999
DEFAULT_MAX_DEPTH = 8 DEFAULT_MAX_DEPTH = 8
DEFAULT_MODEL_THRESHOLD = 0.25 DEFAULT_MODEL_THRESHOLD = 0.25
BATCH_PREDICT_MAX_COMPOUNDS = 150
# Loading Plugins # Loading Plugins
PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True" PLUGINS_ENABLED = os.environ.get("PLUGINS_ENABLED", "False") == "True"

View File

@ -1,3 +1,5 @@
import logging
import msal import msal
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -7,6 +9,9 @@ from django.shortcuts import redirect
from epdb.logic import UserManager, GroupManager from epdb.logic import UserManager, GroupManager
from epdb.models import Group from epdb.models import Group
from epdb.views import get_remote_address
auth_log = logging.getLogger("auth")
def get_msal_app_with_cache(request): def get_msal_app_with_cache(request):
@ -30,6 +35,9 @@ def get_msal_app_with_cache(request):
def entra_login(request): def entra_login(request):
auth_log.info(f"Login request from {get_remote_address(request)}")
msal_app = msal.ConfidentialClientApplication( msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID, client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET, client_credential=s.MS_ENTRA_CLIENT_SECRET,
@ -54,6 +62,10 @@ def entra_callback(request):
# Acquire token using the flow and callback request # Acquire token using the flow and callback request
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET) result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
if "error" in result:
auth_log.error(f"Login attempt by {get_remote_address(request)} failed due to {result['error']}")
return redirect("/")
# Save the token cache to session # Save the token cache to session
if cache.has_state_changed: if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize() request.session["msal_token_cache"] = cache.serialize()
@ -61,7 +73,8 @@ def entra_callback(request):
claims = result["id_token_claims"] claims = result["id_token_claims"]
user_name = claims.get("name") user_name = claims.get("name")
user_email = claims.get("emailaddress", claims.get("email")) # preferred_username is a fallback for 2nd CWID
user_email = claims.get("emailaddress", claims.get("email", claims.get("preferred_username")))
user_oid = claims.get("oid") user_oid = claims.get("oid")
if not all([user_name, user_email, user_oid]): if not all([user_name, user_email, user_oid]):
@ -70,6 +83,7 @@ def entra_callback(request):
# Get implementing class # Get implementing class
User = get_user_model() User = get_user_model()
registered = False
if User.objects.filter(uuid=user_oid).exists(): if User.objects.filter(uuid=user_oid).exists():
u = User.objects.get(uuid=user_oid) u = User.objects.get(uuid=user_oid)
@ -78,8 +92,11 @@ def entra_callback(request):
u.save() u.save()
else: else:
auth_log.info(f"Registering {user_name} with OID {user_oid}")
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True) u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
registered = True
auth_log.info(f"User {user_name} {'(admin) ' if u.is_superuser else ''}with OID {user_oid} successfully logged in as {u.username} from {get_remote_address(request)}")
login(request, u) login(request, u)
# EDIT START # EDIT START
@ -102,10 +119,31 @@ def entra_callback(request):
else: else:
g = Group.objects.get(uuid=id) g = Group.objects.get(uuid=id)
for group_uuid in claims.get("groups", []): sync_groups = list(s.ENTRA_GROUPS.keys()) + list(s.ENTRA_SECRET_GROUPS.keys())
if Group.objects.filter(uuid=group_uuid).exists(): user_groups = claims.get("groups", [])
g = Group.objects.get(uuid=group_uuid)
g.user_member.add(u) for uuid in sync_groups:
if uuid in user_groups:
g = Group.objects.get(uuid=uuid)
if not g.user_member.contains(u):
g.user_member.add(u)
auth_log.info(f"Login Group Sync: Adding {u.username} to Group {g.name} ({ uuid })")
else:
g = Group.objects.get(uuid=uuid)
if g.user_member.contains(u):
g.user_member.remove(u)
auth_log.info(f"Login Group Sync: Removing {u.username} from Group {g.name} ({ uuid })")
if registered:
# #72 make package secret if user is part of a secret group
for id, name in s.ENTRA_SECRET_GROUPS.items():
group = Group.objects.get(uuid=id)
if group.user_member.contains(u):
# User is eligible for secrete
pack = u.default_package
u.data_pool = group
u.classification_level = u.Classification.SECRET
u.save()
# EDIT END # EDIT END

View File

@ -1,3 +1,4 @@
import logging
from collections import defaultdict from collections import defaultdict
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -9,13 +10,11 @@ from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from jwt import InvalidIssuerError
from ninja import Field, Form, Query, Router, Schema from ninja import Field, Form, Query, Router, Schema
from ninja.security import HttpBearer from ninja.security import HttpBearer
from utilities.chem import FormatConverter from utilities.chem import FormatConverter
from utilities.misc import PackageExporter from utilities.misc import PackageExporter
from .logic import ( from .logic import (
EPDBURLParser, EPDBURLParser,
GroupManager, GroupManager,
@ -46,9 +45,12 @@ from .models import (
User, User,
UserPackagePermission, UserPackagePermission,
) )
from .views import delete_with_log, get_remote_address
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
auth_log = logging.getLogger("auth")
def get_cached_jwks(tenant_id: str, force=False) -> Dict: def get_cached_jwks(tenant_id: str, force=False) -> Dict:
"""Get JWKS using Django cache""" """Get JWKS using Django cache"""
@ -116,15 +118,22 @@ def validate_token(token: str) -> dict:
class MSBearerTokenAuth(HttpBearer): class MSBearerTokenAuth(HttpBearer):
def authenticate(self, request, token): def authenticate(self, request, token):
auth_log.info(f"Authentication request by {get_remote_address(request)}")
if token is None: if token is None:
return None return None
claims = validate_token(token) claims = validate_token(token)
if not User.objects.filter(uuid=claims['oid']).exists(): if not User.objects.filter(uuid=claims['oid']).exists():
auth_log.info(f"Authentication request by {get_remote_address(request)} failed!")
return None return None
request.user = User.objects.get(uuid=claims['oid']) user = User.objects.get(uuid=claims['oid'])
request.user = user
auth_log.info(
f"User {user.username} {'(admin) ' if user.is_superuser else ''}with OID {user.uuid} successfully logged in as {user.username} from {get_remote_address(request)}")
return request.user return request.user
@ -558,7 +567,10 @@ def update_package(request, package_uuid, pack: Form[UpdatePackage]):
if pack.hiddenMethod: if pack.hiddenMethod:
if pack.hiddenMethod == "DELETE": if pack.hiddenMethod == "DELETE":
p.delete() if PackageManager.administrable(request.user, p):
delete_with_log(request, p)
else:
raise ValueError("You do not have the rights to delete this Package!")
elif pack.packageDescription is not None: elif pack.packageDescription is not None:
description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip() description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip()
@ -595,7 +607,7 @@ def delete_package(request, package_uuid):
p = PackageManager.get_package_by_id(request.user, package_uuid) p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.administrable(request.user, p): if PackageManager.administrable(request.user, p):
p.delete() delete_with_log(request, p)
return redirect(f"{s.SERVER_URL}/package") return redirect(f"{s.SERVER_URL}/package")
else: else:
raise ValueError("You do not have the rights to delete this Package!") raise ValueError("You do not have the rights to delete this Package!")
@ -881,12 +893,11 @@ def create_package_compound(
if "secret" == classification.lower(): if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET: if p.classification_level != Package.Classification.SECRET:
return 400, {"Cannot create PESs for non-secret packages."} return 400, {"message": "Cannot create secret PESs in non-secret packages."}
if not p.data_pool or not p.data_pool.secret:
return 400, {"message": "Cannot create secret PESs in package without a secret data pool."}
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
return 400, { "messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"}
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription) c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription)
else: else:
@ -1877,7 +1888,7 @@ def delete_pathway(request, package_uuid, pathway_uuid):
p = get_package_for_write(request.user, package_uuid) p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid) pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
pw.delete() delete_with_log(request, pw)
return redirect(f"{p.url}/pathway") return redirect(f"{p.url}/pathway")
except ValueError: except ValueError:
@ -2055,7 +2066,7 @@ def delete_node(request, package_uuid, pathway_uuid, node_uuid):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid) pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
n = Node.objects.get(pathway=pw, uuid=node_uuid) n = Node.objects.get(pathway=pw, uuid=node_uuid)
n.delete() delete_with_log(request, n)
return redirect(f"{pw.url}/node") return redirect(f"{pw.url}/node")
except ValueError: except ValueError:
@ -2215,7 +2226,7 @@ def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid) pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
e = Edge.objects.get(pathway=pw, uuid=edge_uuid) e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
e.delete() delete_with_log(request, e)
return redirect(f"{pw.url}/edge") return redirect(f"{pw.url}/edge")
except ValueError: except ValueError:

View File

@ -35,6 +35,7 @@ from utilities.chem import FormatConverter
from utilities.misc import PackageExporter, PackageImporter from utilities.misc import PackageExporter, PackageImporter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
@ -45,7 +46,7 @@ class EPDBURLParser:
MODEL_PATTERNS = { MODEL_PATTERNS = {
"epdb.User": re.compile(rf"^.*/user/{UUID_PATTERN}"), "epdb.User": re.compile(rf"^.*/user/{UUID_PATTERN}"),
"epdb.Group": re.compile(rf"^.*/group/{UUID_PATTERN}"), "epdb.Group": re.compile(rf"^.*/group/{UUID_PATTERN}"),
"epdb.Package": re.compile(rf"^.*/package/{UUID_PATTERN}"), s.EPDB_PACKAGE_MODEL: re.compile(rf"^.*/package/{UUID_PATTERN}"),
"epdb.Compound": re.compile(rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}"), "epdb.Compound": re.compile(rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}"),
"epdb.CompoundStructure": re.compile( "epdb.CompoundStructure": re.compile(
rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}/structure/{UUID_PATTERN}" rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}/structure/{UUID_PATTERN}"
@ -95,7 +96,7 @@ class EPDBURLParser:
def contains_package_url(self): def contains_package_url(self):
return ( return (
bool(self.MODEL_PATTERNS["epdb.Package"].findall(self.url)) bool(self.MODEL_PATTERNS[s.EPDB_PACKAGE_MODEL].findall(self.url))
and not self.is_package_url() and not self.is_package_url()
) )
@ -123,7 +124,7 @@ class EPDBURLParser:
"epdb.EPModel", "epdb.EPModel",
"epdb.Pathway", "epdb.Pathway",
# 1st level # 1st level
"epdb.Package", s.EPDB_PACKAGE_MODEL,
"epdb.Setting", "epdb.Setting",
"epdb.Group", "epdb.Group",
"epdb.User", "epdb.User",
@ -145,7 +146,7 @@ class EPDBURLParser:
hierarchy_order = [ hierarchy_order = [
# 1st level # 1st level
"epdb.Package", s.EPDB_PACKAGE_MODEL,
"epdb.Setting", "epdb.Setting",
"epdb.Group", "epdb.Group",
"epdb.User", "epdb.User",
@ -316,13 +317,19 @@ class GroupManager(object):
if isinstance(member, Group): if isinstance(member, Group):
if add_or_remove == "add": if add_or_remove == "add":
group.group_member.add(member) group.group_member.add(member)
auth_log.info(f"{caller.username} ({caller.url}) adds {member.name} ({member.url}) to {group.name} ({group.url})")
else: else:
group.group_member.remove(member) group.group_member.remove(member)
auth_log.info(
f"{caller.username} ({caller.url}) removes {member.name} ({member.url}) to {group.name} ({group.url})")
else: else:
if add_or_remove == "add": if add_or_remove == "add":
group.user_member.add(member) group.user_member.add(member)
auth_log.info(f"{caller.username} ({caller.url}) adds {member.username} ({member.url}) to {group.name} ({group.url})")
else: else:
group.user_member.remove(member) group.user_member.remove(member)
auth_log.info(
f"{caller.username} ({caller.url}) adds {member.username} ({member.url}) to {group.name} ({group.url})")
group.save() group.save()
@ -578,9 +585,11 @@ class PackageManager(object):
if isinstance(grantee, User): if isinstance(grantee, User):
perm_cls = UserPackagePermission perm_cls = UserPackagePermission
data["user"] = grantee data["user"] = grantee
grantee_name = grantee.username
else: else:
perm_cls = GroupPackagePermission perm_cls = GroupPackagePermission
data["group"] = grantee data["group"] = grantee
grantee_name = grantee.name
if new_perm is None: if new_perm is None:
qs = perm_cls.objects.filter(**data) qs = perm_cls.objects.filter(**data)
@ -589,11 +598,23 @@ class PackageManager(object):
if qs.count() != 0: if qs.count() != 0:
logger.info(f"Deleting Perm {qs.first()}") logger.info(f"Deleting Perm {qs.first()}")
qs.delete() qs.delete()
auth_log.info(f"{caller.username} ({caller.url}) revokes {grantee_name} ({grantee.url}) all Permissions on {package.name} ({package.url})")
else: else:
logger.debug(f"No Permission object for {perm_cls} with filter {data} found!") logger.debug(f"No Permission object for {perm_cls} with filter {data} found!")
else: else:
old_perm = None
old_perms_qs = perm_cls.objects.filter(**data)
if old_perms_qs.exists():
old_perm = old_perms_qs.first().permission
_ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data) _ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data)
if old_perm is None:
auth_log.info(f"{caller.username} ({caller.url}) grants {grantee_name} ({grantee.url}) '{new_perm}' Permissions on {package.name} ({package.url})")
else:
auth_log.info(f"{caller.username} ({caller.url}) set {grantee_name} ({grantee.url}) Permissions from '{old_perm}' to '{new_perm}' on {package.name} ({package.url})")
@staticmethod @staticmethod
def grant_read(caller: User, package: Package, grantee: Union[User, Group]): def grant_read(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.READ[0]) PackageManager.update_permissions(caller, package, grantee, Permission.READ[0])
@ -1875,12 +1896,51 @@ class SPathway(object):
logger.info("Update done!") logger.info("Update done!")
def compute_bayes_probabilities(self) -> Dict[SEdge, float]:
"""
Computes Bayes-adjusted probabilities for all edges in the pathway
by iterating level by level from depth 0 upwards, keyed on educt depth.
Returns:
A dict mapping each SEdge to its Bayes-adjusted probability.
"""
bayes_probs: Dict[SEdge, float] = {}
# Group edges by their educt depth
edges_by_depth: Dict[int, List[SEdge]] = {}
for edge in self.edges:
d = edge.educts[0].depth
edges_by_depth.setdefault(d, []).append(edge)
for depth in sorted(edges_by_depth.keys()):
for edge in edges_by_depth[depth]:
if depth == 0:
bayes_probs[edge] = edge.probability
else:
predecessor_edges = [e for e in self.edges if edge.educts[0] in e.products]
if not predecessor_edges or not all(
e in bayes_probs for e in predecessor_edges
):
# Predecessor not computed yet (e.g. same-depth product),
# fall back to raw probability
bayes_probs[edge] = edge.probability
else:
predecessor_avg = sum(bayes_probs[e] for e in predecessor_edges) / len(
predecessor_edges
)
bayes_probs[edge] = predecessor_avg * edge.probability
return bayes_probs
def to_json(self): def to_json(self):
nodes = [] nodes = []
edges = [] edges = []
idx_lookup = {} idx_lookup = {}
bayes_probs = self.compute_bayes_probabilities()
for i, smiles in enumerate(self.smiles_to_node): for i, smiles in enumerate(self.smiles_to_node):
n = self.smiles_to_node[smiles] n = self.smiles_to_node[smiles]
idx_lookup[smiles] = i idx_lookup[smiles] = i
@ -1901,6 +1961,7 @@ class SPathway(object):
if edge.probability: if edge.probability:
e["probability"] = edge.probability e["probability"] = edge.probability
e["multiGenProbability"] = bayes_probs[edge]
edges.append(e) edges.append(e)

View File

@ -2815,6 +2815,21 @@ class PackageBasedModel(EPModel):
) )
multigen_eval = models.BooleanField(null=False, blank=False, default=False) multigen_eval = models.BooleanField(null=False, blank=False, default=False)
def statistics(self):
return {
'accuracy': [
self.eval_results["average_accuracy"],
self.eval_results.get("multigen_average_accuracy")],
'precision': [
self.eval_results["average_precision_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_precision_per_threshold", {}).get(f"{self.threshold:.2f}")
],
'recall': [
self.eval_results["average_recall_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_recall_per_threshold", {}).get(f"{self.threshold:.2f}")
],
}
@property @property
def pr_curve(self): def pr_curve(self):
if self.model_status != self.FINISHED: if self.model_status != self.FINISHED:
@ -3014,7 +3029,14 @@ class PackageBasedModel(EPModel):
prec, rec = dict(), dict() prec, rec = dict(), dict()
for t in np.arange(0, 1.05, 0.05): thresholds = list(np.arange(0, 1.05, 0.05))
# Add specific threshold set during object creation if not already present
if np.float64(threshold) not in thresholds:
thresholds.append(np.float64(threshold))
thresholds.sort()
for t in thresholds:
temp_thresholded = (y_pred_filtered >= t).astype(int) temp_thresholded = (y_pred_filtered >= t).astype(int)
prec[f"{t:.2f}"] = precision_score( prec[f"{t:.2f}"] = precision_score(
y_test_filtered, temp_thresholded, zero_division=0 y_test_filtered, temp_thresholded, zero_division=0
@ -3024,7 +3046,14 @@ class PackageBasedModel(EPModel):
return acc, prec, rec return acc, prec, rec
def evaluate_mg(model, pathways: Union[QuerySet["Pathway"] | List["Pathway"]], threshold): def evaluate_mg(model, pathways: Union[QuerySet["Pathway"] | List["Pathway"]], threshold):
thresholds = np.arange(0.1, 1.1, 0.1) thresholds = list(np.arange(0, 1.05, 0.05))
# Add specific threshold set during object creation if not already present
if np.float64(threshold) not in thresholds:
thresholds.append(np.float64(threshold))
thresholds.sort()
logger.info(f"Thresholds: {thresholds}")
precision = {f"{t:.2f}": [] for t in thresholds} precision = {f"{t:.2f}": [] for t in thresholds}
recall = {f"{t:.2f}": [] for t in thresholds} recall = {f"{t:.2f}": [] for t in thresholds}
@ -3050,7 +3079,7 @@ class PackageBasedModel(EPModel):
s = Setting() s = Setting()
s.model = mod s.model = mod
s.model_threshold = thresholds.min() s.model_threshold = 0.0
s.max_depth = 10 s.max_depth = 10
s.max_nodes = 50 s.max_nodes = 50

View File

@ -61,6 +61,7 @@ from .models import (
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
@ -71,6 +72,18 @@ def log_post_params(request):
logger.debug(f"{k}\t{v}") logger.debug(f"{k}\t{v}")
def get_remote_address(request):
remote_address = ""
if request is not None:
remote_address = request.META.get("HTTP_X_FORWARDED_FOR")
if not remote_address:
remote_address = request.META.get("REMOTE_ADDR", "")
return remote_address
def get_error_handler_context(request, for_user=None) -> Dict[str, Any]: def get_error_handler_context(request, for_user=None) -> Dict[str, Any]:
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
@ -147,6 +160,20 @@ def handler500(request):
return render(request, "errors/error.html", context, status=500) return render(request, "errors/error.html", context, status=500)
def delete_with_log(request, obj):
caller = request.user
obj_type = obj.__class__.__name__
try:
obj.delete()
auth_log.info(f"{caller.username} ({caller.url}) deleted {obj_type}: {obj.name} ({obj.url})")
except Exception as e:
logger.info(f"Tried to delete {obj_type}: {obj.name} ({obj.url}) but deletion failed! Exception {e}")
auth_log.info(
f"{caller.username} ({caller.url}) tried to delete {obj_type}: {obj.name} ({obj.url}) but deletion failed!")
raise e
def login(request): def login(request):
context = get_base_context(request) context = get_base_context(request)
@ -205,11 +232,18 @@ def login(request):
if user is not None: if user is not None:
login(request, user) login(request, user)
if user.is_superuser:
auth_log.error(f"admin ({user.username}) login attempt by {get_remote_address(request)} successful")
if next := request.POST.get("next"): if next := request.POST.get("next"):
return redirect(next) return redirect(next)
return redirect(reverse("index")) return redirect(reverse("index"))
else: else:
if _user := User.objects.get(email=email):
if _user.is_superuser:
auth_log.error(f"admin ({_user.username}) login attempt by {get_remote_address(request)} failed")
context["message"] = "Login failed!" context["message"] = "Login failed!"
return render(request, "static/login.html", context) return render(request, "static/login.html", context)
else: else:
@ -390,7 +424,7 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
"external_databases": ExternalDatabase.get_databases(), "external_databases": ExternalDatabase.get_databases(),
"site_id": s.MATOMO_SITE_ID, "site_id": s.MATOMO_SITE_ID,
# EDIT START # EDIT START
"secret_groups": Group.objects.filter(secret=True), "secret_groups": Group.objects.filter(secret=True, user_member=current_user),
# EDIT END # EDIT END
}, },
} }
@ -526,6 +560,7 @@ def batch_predict_pathway(request):
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Batch Predict Pathway" context["title"] = "enviPath - Batch Predict Pathway"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["batch_predict_max_compounds"] = s.BATCH_PREDICT_MAX_COMPOUNDS
return render(request, "batch_predict_pathway.html", context) return render(request, "batch_predict_pathway.html", context)
@ -1281,7 +1316,8 @@ def package(request, package_uuid):
"You cannot delete the default package. If you want to delete this package you have to set another default package first.", "You cannot delete the default package. If you want to delete this package you have to set another default package first.",
) )
logger.debug(current_package.delete()) delete_with_log(request, current_package)
return redirect(s.SERVER_URL + "/package") return redirect(s.SERVER_URL + "/package")
elif hidden == "publish-package": elif hidden == "publish-package":
for g in Group.objects.filter(public=True): for g in Group.objects.filter(public=True):
@ -1949,6 +1985,21 @@ def package_reactions(request, package_uuid):
reaction_name = request.POST.get("reaction-name") reaction_name = request.POST.get("reaction-name")
reaction_description = request.POST.get("reaction-description") reaction_description = request.POST.get("reaction-description")
reaction_smiles = request.POST.get("reaction-smiles") reaction_smiles = request.POST.get("reaction-smiles")
if reaction_smiles is None or reaction_smiles.strip() == "":
return error(
request,
"Reaction SMILES is empty / missing",
"No reaction SMILES provided. Please provide a SMILES for the reaction.",
)
if not FormatConverter.is_valid_smirks(reaction_smiles):
return error(
request,
"Reaction SMILES is invalid",
f"The provided reactions SMILES {reaction_smiles} is invalid",
)
educts = reaction_smiles.split(">>")[0].split(".") educts = reaction_smiles.split(">>")[0].split(".")
products = reaction_smiles.split(">>")[1].split(".") products = reaction_smiles.split(">>")[1].split(".")
@ -2257,7 +2308,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
elif request.method == "POST": elif request.method == "POST":
if hidden := request.POST.get("hidden", None): if hidden := request.POST.get("hidden", None):
if hidden == "delete": if hidden == "delete":
current_pathway.delete() delete_with_log(request, current_pathway)
return redirect(current_package.url + "/pathway") return redirect(current_package.url + "/pathway")
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
@ -2475,7 +2526,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
if hidden := request.POST.get("hidden", None): if hidden := request.POST.get("hidden", None):
if hidden == "delete": if hidden == "delete":
# pre_delete signal will take care of edge deletion # pre_delete signal will take care of edge deletion
current_node.delete() delete_with_log(request, current_node)
return redirect(current_pathway.url) return redirect(current_pathway.url)
else: else:
@ -2629,7 +2680,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
if hidden := request.POST.get("hidden", None): if hidden := request.POST.get("hidden", None):
if hidden == "delete": if hidden == "delete":
current_edge.delete() delete_with_log(request, current_edge)
return redirect(current_pathway.url) return redirect(current_pathway.url)
if "selected-scenarios" in request.POST: if "selected-scenarios" in request.POST:
@ -2991,7 +3042,7 @@ def group(request, group_uuid):
if hidden := request.POST.get("hidden", None): if hidden := request.POST.get("hidden", None):
if hidden == "delete": if hidden == "delete":
current_group.delete() delete_with_log(request, current_group)
return redirect(s.SERVER_URL + "/group") return redirect(s.SERVER_URL + "/group")
else: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()

View File

@ -120,13 +120,6 @@ class PathwayMapper:
) )
bundle.reference_substances.append(ref_sub) bundle.reference_substances.append(ref_sub)
sub = IUCLIDSubstanceData(
uuid=sub_uuid,
name=compound.name,
reference_substance_uuid=ref_sub_uuid,
)
bundle.substances.append(sub)
if not export.compounds: if not export.compounds:
return bundle return bundle
@ -145,6 +138,16 @@ class PathwayMapper:
if not root_compound_pks: if not root_compound_pks:
return bundle return bundle
for root_pk in root_compound_pks:
root_sub_uuid, root_ref_uuid = seen_compounds[root_pk]
bundle.substances.append(
IUCLIDSubstanceData(
uuid=root_sub_uuid,
name=compound_names[root_pk],
reference_substance_uuid=root_ref_uuid,
)
)
edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]] = [] edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]] = []
for edge in sorted(export.edges, key=lambda item: str(item.edge_uuid)): for edge in sorted(export.edges, key=lambda item: str(item.edge_uuid)):
parent_compound_pks = sorted( parent_compound_pks = sorted(

View File

@ -70,8 +70,7 @@ class IUCLIDExportAPITest(TestCase):
names = zf.namelist() names = zf.namelist()
self.assertIn("manifest.xml", names) self.assertIn("manifest.xml", names)
i6d_files = [n for n in names if n.endswith(".i6d")] i6d_files = [n for n in names if n.endswith(".i6d")]
# 2 substances + 2 ref substances + 1 ESR = 5 i6d files self.assertEqual(len(i6d_files), 4)
self.assertEqual(len(i6d_files), 5)
def test_anonymous_returns_401(self): def test_anonymous_returns_401(self):
self.client.logout() self.client.logout()

View File

@ -7,6 +7,11 @@ from uuid import uuid4
from django.test import SimpleTestCase, tag from django.test import SimpleTestCase, tag
from epapi.v1.interfaces.iuclid.dto import (
PathwayCompoundDTO,
PathwayEdgeDTO,
PathwayExportDTO,
)
from epiuclid.serializers.i6z import I6ZSerializer from epiuclid.serializers.i6z import I6ZSerializer
from epiuclid.serializers.pathway_mapper import ( from epiuclid.serializers.pathway_mapper import (
IUCLIDDocumentBundle, IUCLIDDocumentBundle,
@ -14,9 +19,24 @@ from epiuclid.serializers.pathway_mapper import (
IUCLIDReferenceSubstanceData, IUCLIDReferenceSubstanceData,
IUCLIDSubstanceData, IUCLIDSubstanceData,
IUCLIDTransformationProductEntry, IUCLIDTransformationProductEntry,
PathwayMapper,
) )
def _unlinked_documents(manifest_xml: str) -> list[tuple[str | None, str]]:
ns = "http://iuclid6.echa.europa.eu/namespaces/manifest/v1"
root = ET.fromstring(manifest_xml)
base = root.findtext(f"{{{ns}}}base-document-uuid")
linked_targets: set[str | None] = {base}
docs: dict[str, str | None] = {}
for doc in root.findall(f".//{{{ns}}}document"):
uuid = doc.findtext(f"{{{ns}}}uuid")
docs[uuid] = doc.findtext(f"{{{ns}}}type")
for link in doc.findall(f"{{{ns}}}links/{{{ns}}}link"):
linked_targets.add(link.findtext(f"{{{ns}}}ref-uuid"))
return [(doc_type, uuid) for uuid, doc_type in docs.items() if uuid not in linked_targets]
def _make_bundle() -> IUCLIDDocumentBundle: def _make_bundle() -> IUCLIDDocumentBundle:
ref_uuid = uuid4() ref_uuid = uuid4()
sub_uuid = uuid4() sub_uuid = uuid4()
@ -197,3 +217,29 @@ class I6ZSerializerTest(SimpleTestCase):
} }
self.assertIn(parent_ref_key, reference_links) self.assertIn(parent_ref_key, reference_links)
self.assertIn(product_ref_key, reference_links) self.assertIn(product_ref_key, reference_links)
def test_multi_compound_pathway_has_no_unlinked_documents(self):
compounds = [
PathwayCompoundDTO(pk=1, name="Root", smiles="c1ccccc1"),
PathwayCompoundDTO(pk=2, name="P1", smiles="CCO"),
PathwayCompoundDTO(pk=3, name="P2", smiles="CCN"),
PathwayCompoundDTO(pk=4, name="P3", smiles="CCC"),
]
export = PathwayExportDTO(
pathway_uuid=uuid4(),
pathway_name="Regression Pathway",
compounds=compounds,
edges=[
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[2]),
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[1], end_compound_pks=[3]),
PathwayEdgeDTO(edge_uuid=uuid4(), start_compound_pks=[2], end_compound_pks=[4]),
],
root_compound_pks=[1],
)
bundle = PathwayMapper().map(export)
data = I6ZSerializer().serialize(bundle)
with zipfile.ZipFile(io.BytesIO(data)) as zf:
manifest_xml = zf.read("manifest.xml").decode("utf-8")
self.assertEqual(_unlinked_documents(manifest_xml), [])

View File

@ -31,7 +31,7 @@ class PathwayMapperTest(SimpleTestCase):
) )
bundle = PathwayMapper().map(export) bundle = PathwayMapper().map(export)
self.assertEqual(len(bundle.substances), 2) self.assertEqual(len(bundle.substances), 1)
self.assertEqual(len(bundle.reference_substances), 2) self.assertEqual(len(bundle.reference_substances), 2)
self.assertEqual(len(bundle.endpoint_study_records), 1) self.assertEqual(len(bundle.endpoint_study_records), 1)
@ -49,8 +49,7 @@ class PathwayMapperTest(SimpleTestCase):
) )
bundle = PathwayMapper().map(export) bundle = PathwayMapper().map(export)
# 2 unique compounds -> 2 substances, 2 ref substances self.assertEqual(len(bundle.substances), 1)
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.reference_substances), 2) self.assertEqual(len(bundle.reference_substances), 2)
# One endpoint study record per pathway # One endpoint study record per pathway
self.assertEqual(len(bundle.endpoint_study_records), 1) self.assertEqual(len(bundle.endpoint_study_records), 1)

View File

@ -15,6 +15,7 @@
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a <i class="glyphicon glyphicon-user"></i> Edit Permissions</a
> >
</li> </li>
{% if meta.current_package.get_classification_level_display != "Secret" %}
<li> <li>
<a <a
role="button" role="button"
@ -23,6 +24,7 @@
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a <i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a
> >
</li> </li>
{% endif %}
{% endif %} {% endif %}
<li> <li>
<a <a

View File

@ -37,7 +37,8 @@
class="text-xs text-base-content/50 border-t border-base-300 pt-3" class="text-xs text-base-content/50 border-t border-base-300 pt-3"
> >
<strong>Format:</strong> First column = SMILES, Second column = <strong>Format:</strong> First column = SMILES, Second column =
Name (headers optional) • Maximum 30 rows Name (headers optional) • Maximum
{{ batch_predict_max_compoundss|default:150 }} rows
</div> </div>
</div> </div>
</div> </div>
@ -195,8 +196,7 @@
// Function to populate table from CSV data // Function to populate table from CSV data
function populateTableFromCSV(csvData) { function populateTableFromCSV(csvData) {
const lines = csvData.trim().split("\n"); const lines = csvData.trim().split("\n");
const maxRows = 30; const maxRows = Number("{{ batch_predict_max_compounds|default:150 }}");
// Clear existing table // Clear existing table
clearTable(); clearTable();

View File

@ -105,6 +105,11 @@
<img src="{% static 'images/restricted_mid.png' %}" width="200"> <img src="{% static 'images/restricted_mid.png' %}" width="200">
{% elif meta.url_contains_package and meta.current_package.get_classification_level_display == "Secret" %} {% elif meta.url_contains_package and meta.current_package.get_classification_level_display == "Secret" %}
<img src="{% static 'images/secret_mid.png' %}" width="120"> <img src="{% static 'images/secret_mid.png' %}" width="120">
{% elif not meta.url_contains_package and meta.user.default_package.get_classification_level_display == "Restricted" %}
<img src="{% static 'images/restricted_mid.png' %}" width="200">
{% elif not meta.url_contains_package and meta.user.default_package.get_classification_level_display == "Secret" %}
<img src="{% static 'images/secret_mid.png' %}" width="200">
{% endif %} {% endif %}
{% if not public_mode %} {% if not public_mode %}
<a id="search-trigger" role="button" class="cursor-pointer"> <a id="search-trigger" role="button" class="cursor-pointer">

View File

@ -51,7 +51,10 @@
</svg> </svg>
Go Home Go Home
</a> </a>
<button onclick="window.history.back()" class="btn btn-outline"> <button
onclick="window.location.href = document.referrer"
class="btn btn-outline"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5" class="mr-2 h-5 w-5"

View File

@ -313,6 +313,44 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Model Statistics Panel -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Model Statistics for threshold {{ model.threshold }}</div>
<div class="collapse-content">
<div class="flex justify-center">
<div
id="model-stats"
class="overflow-x-auto rounded-box shadow-md bg-base-100"
>
<table class="table table-fixed w-full">
<thead class="text-base">
<tr>
<th class="w-1/5">Metric</th>
<th>Single Gen Value</th>
{% if model.multigen_eval %}
<th>Multi Gen Value</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for metric, value in model.statistics.items %}
<tr>
<td>{{ metric|upper }}</td>
<td>{{ value.0|floatformat:3 }}</td>
{% if model.multigen_eval %}
<td>{{ value.1|floatformat:3 }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %} {% endif %}
<script> <script>
function makeChart(selector, data) { function makeChart(selector, data) {