16 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
ac8df05913 [Feature] Attach AdditionalInformation to Nodes (#428)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#428
2026-07-20 09:32:28 +12:00
27 changed files with 953 additions and 84 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

@ -4,6 +4,7 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/edit_package_modal.html" %} {% include "modals/objects/edit_package_modal.html" %}
{% include "modals/objects/view_package_permissions_modal.html" %}
{% include "modals/objects/edit_package_permissions_modal.html" %} {% include "modals/objects/edit_package_permissions_modal.html" %}
{% include "modals/objects/publish_package_modal.html" %} {% include "modals/objects/publish_package_modal.html" %}
{% include "modals/objects/set_license_modal.html" %} {% include "modals/objects/set_license_modal.html" %}

View File

@ -1,8 +1,9 @@
import base64 import base64
import logging
import requests import requests
from django.conf import settings as s from django.conf import settings as s
from django.http import HttpResponse, HttpResponseBadRequest from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
from django.shortcuts import redirect from django.shortcuts import redirect
from bayer.models import PESCompound from bayer.models import PESCompound
@ -14,6 +15,9 @@ from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
logger = logging.getLogger(__name__)
@package_permission_required() @package_permission_required()
def create_pes(request, package_uuid): def create_pes(request, package_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
@ -36,27 +40,41 @@ def create_pes(request, package_uuid):
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link)
except ValueError as e: except ValueError as e:
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}") return error(
request,
"Could not fetch PES",
f"Could not fetch PES data for {pes_link}"
)
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET: if current_package.classification_level != Package.Classification.SECRET:
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.") return error(
request,
"Classification Mismatch!",
"Cannot create secret PESs in non-secret packages."
)
data_pools = pes_data.get("dataPools") if not current_package.data_pool or not current_package.data_pool.secret:
if data_pools: logger.info(f"The current package does not have a secret data pool.")
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: return error(
return HttpResponseBadRequest( request,
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data") "The current package does not have a secret data pool.",
"Cannot create secret PESs in package without a secret data pool."
)
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
return redirect(pes.url) return redirect(pes.url)
else: else:
return HttpResponseBadRequest("Please provide a PES link.") return error(
request,
"No PES link received",
"Please provide a PES link."
)
else: else:
pass return HttpResponseNotAllowed(["POST"])
@package_permission_required() @package_permission_required()
@ -82,23 +100,37 @@ def create_pes_node(request, package_uuid, pathway_uuid):
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link)
except ValueError as e: except ValueError as e:
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}") return error(
request,
"Could not fetch PES",
f"Could not fetch PES data for {pes_link}"
)
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET: if current_package.classification_level != Package.Classification.SECRET:
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.") return error(
request,
"Classification Mismatch!",
"Cannot create secret PESs in non-secret packages."
)
data_pools = pes_data.get("dataPools") if not current_package.data_pool or not current_package.data_pool.secret:
if data_pools: logger.info(f"The current package does not have a secret data pool.")
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: return error(
return HttpResponseBadRequest( request,
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data") "The current package does not have a secret data pool.",
"Cannot create secret PESs in package without a secret data pool."
)
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
node_qs = Node.objects.filter(pathway=current_pathway, default_node_label=pes.default_structure) node_qs = Node.objects.filter(
pathway=current_pathway,
default_node_label=pes.default_structure
)
if node_qs.exists(): if node_qs.exists():
return redirect(current_pathway.url) return redirect(current_pathway.url)
@ -116,9 +148,13 @@ def create_pes_node(request, package_uuid, pathway_uuid):
return redirect(current_pathway.url) return redirect(current_pathway.url)
else: else:
return HttpResponseBadRequest("Please provide a PES link.") return error(
request,
"No PES link received",
"Please provide a PES link."
)
else: else:
pass return HttpResponseNotAllowed(["POST"])
def fetch_pes(request, pes_url) -> dict: def fetch_pes(request, pes_url) -> dict:

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

@ -9,8 +9,8 @@ from envipy_additional_information import registry
from envipy_additional_information.groups import GroupEnum from envipy_additional_information.groups import GroupEnum
from epapi.utils.schema_transformers import build_rjsf_output from epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error from epapi.utils.validation_errors import handle_validation_error
from epdb.models import AdditionalInformation from epdb.models import AdditionalInformation, Scenario, Node
from ..dal import get_scenario_for_read, get_scenario_for_write from ..dal import get_scenario_for_read, get_scenario_for_write, get_package_for_write
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -58,6 +58,61 @@ def list_scenario_info(request, scenario_uuid: UUID):
return result return result
@router.post("/information/{model_name}/")
def add_object_info(request, model_name: str, payload: Dict[str, Any] = Body(...)):
from epdb.views import EPDBURLParser
cls = registry.get_model(model_name.lower())
if not cls:
raise HttpError(404, f"Unknown model: {model_name}")
try:
instance = cls(**payload) # Pydantic validates
except ValidationError as e:
handle_validation_error(e)
if "attach_obj_url" in payload:
url_parser = EPDBURLParser(payload["attach_obj_url"])
if url_parser.contains_package_url():
package = get_package_for_write(request.user, url_parser.get_objects()[0].uuid)
attach_obj = url_parser.get_object()
if "scenario_uuid" in payload:
scenario = get_scenario_for_read(request.user, payload["scenario_uuid"])
else:
scenario = Scenario.create(
package,
name=f"Scenario {Scenario.objects.filter(package=package).count() + 1}",
description="no description",
scenario_date=None,
scenario_type=None,
additional_information=[],
)
if isinstance(attach_obj, Node):
ai = add_info_to_node(package, instance, scenario, attach_obj)
else:
raise HttpError(404, f"Bad request - Not implemented for {type(attach_obj)}!")
return {"status": "created", "uuid": ai.uuid}
raise HttpError(404, "Bad request!")
def add_info_to_node(package, add_inf, scenario, node):
ai = AdditionalInformation.create(
package,
add_inf,
scenario=scenario,
content_object=node,
)
node.pathway.scenarios.add(scenario)
return ai
@router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/") @router.post("/scenario/{uuid:scenario_uuid}/information/{model_name}/")
def add_scenario_info( def add_scenario_info(
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...) request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)

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:
@ -1705,7 +1716,7 @@ class PathwayNode(Schema):
image: str = Field(None, alias="image") image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size") imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = [] proposed: List[Dict[str, Any]] = []
smiles: str = Field(None, alias="smiles") smiles: str = Field(None, alias="smiles")
pseudo: bool = Field(False, alias="pseudo") pseudo: bool = Field(False, alias="pseudo")
pesLink: str | None = Field(None, alias="pes_link") pesLink: str | None = Field(None, alias="pes_link")
@ -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:
@ -1975,7 +1986,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
class CreateNode(Schema): class CreateNode(Schema):
nodeAsSmiles: str nodeAsSmiles: str | None = None
nodeAsMolFile: str | None = None nodeAsMolFile: str | None = None
nodeName: str | None = None nodeName: str | None = None
nodeReason: str | None = None nodeReason: str | None = None
@ -2007,14 +2018,10 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
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."}
data_pools = pes_data.get("dataPools") if not p.data_pool or not p.data_pool.secret:
if data_pools: return 400, {"message": "Cannot create secret PESs in package without a secret data pool."}
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, n.nodeName, n.nodeReason) c = PESCompound.create(p, pes_data, n.nodeName, n.nodeReason)
@ -2059,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:
@ -2219,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

@ -2466,7 +2466,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"name": self.get_name(), "name": self.get_name(),
"plain_name": self.get_name(include_suffix=False), "plain_name": self.get_name(include_suffix=False),
"smiles": self.default_node_label.smiles, "smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.get_scenarios()],
"app_domain": { "app_domain": {
"inside_app_domain": app_domain_data["assessment"]["inside_app_domain"] "inside_app_domain": app_domain_data["assessment"]["inside_app_domain"]
if app_domain_data if app_domain_data
@ -2594,6 +2594,15 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return list(collected.values()) return list(collected.values())
def get_scenarios(self):
qs = self.scenarios.all()
qs |= Scenario.objects.filter(
id__in=self.additional_information.filter(scenario__isnull=False)
.values_list("scenario", flat=True)
.distinct()
)
return qs.distinct()
class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin): class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
pathway = models.ForeignKey( pathway = models.ForeignKey(
@ -2806,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:
@ -3005,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
@ -3015,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}
@ -3041,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

@ -186,6 +186,40 @@ window.AdditionalInformationApi = {
return this._handleResponse(response, "createItem"); return this._handleResponse(response, "createItem");
}, },
/**
* Create new additional information and attach it to an object.
* @param {string} modelName - Name/type of the additional information model
* @param {Object} data - Data for the new item
* @param {string} attachObjectUrl - UUID of the object this data should be attached to
* @param {string} scenarioUuid - UUID of the scenario
* @returns {Promise<{status: string, uuid: string}>}
*/
async createItemOnNonScenarioObject(modelName, data, attachObjectUrl, scenarioUuid) {
const sanitizedData = this.sanitizePayload(data);
this._log("createItemOnNonScenarioObject", { modelName, data: sanitizedData, attachObjectUrl, scenarioUuid });
sanitizedData.attach_obj_url = attachObjectUrl;
if (scenarioUuid) {
sanitizedData.scenario_uuid = scenarioUuid;
}
// Normalize model name to lowercase
const normalizedName = modelName.toLowerCase();
const response = await fetch(
`/api/v1/information/${normalizedName}/`,
{
method: "POST",
headers: this._buildHeaders(),
body: JSON.stringify(sanitizedData),
},
);
return this._handleResponse(response, "createItemOnNonScenarioObject");
},
/** /**
* Delete additional information from a scenario * Delete additional information from a scenario
* @param {string} scenarioUuid - UUID of the scenario * @param {string} scenarioUuid - UUID of the scenario

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,8 @@
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a <i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a
> >
</li> </li>
{% endif %}
{% endif %}
<li> <li>
<a <a
role="button" role="button"
@ -31,6 +34,7 @@
<i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a <i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a
> >
</li> </li>
{% if meta.can_edit %}
<li> <li>
<a <a
role="button" role="button"
@ -48,3 +52,13 @@
> >
</li> </li>
{% endif %} {% endif %}
{% if not meta.can_edit %}
<li>
<a
role="button"
onclick="document.getElementById('view_package_permissions_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-user"></i> View Permissions</a
>
</li>
{% endif %}

View File

@ -83,6 +83,19 @@
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a <i class="glyphicon glyphicon-edit"></i> Edit Pathway</a
> >
</li> </li>
<li>
<a
class="button"
onclick="
const modal = document.getElementById('edit_pathway_node_modal');
modal.showModal();
window.dispatchEvent(new Event('modal-opened'));
return false;
"
>
<i class="glyphicon glyphicon-edit"></i> Edit Compound</a
>
</li>
<li> <li>
<a <a
role="button" role="button"

View File

@ -10,7 +10,7 @@
<li> <li>
<a <a
class="button" class="button"
onclick="document.getElementById('add_additional_information_modal').showModal(); return false;" onclick="document.getElementById('add_scenario_additional_information_modal').showModal(); return false;"
> >
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a <i class="glyphicon glyphicon-trash"></i> Add Additional Information</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

@ -1,7 +1,7 @@
{% load static %} {% load static %}
<!-- Add Additional Information --> <!-- Add Additional Information -->
<dialog <dialog
id="add_additional_information_modal" id="add_scenario_additional_information_modal"
class="modal" class="modal"
x-data="{ x-data="{
isSubmitting: false, isSubmitting: false,
@ -98,7 +98,7 @@
); );
// Close modal and reload page to show new item // Close modal and reload page to show new item
document.getElementById('add_additional_information_modal').close(); document.getElementById('add_scenario_additional_information_modal').close();
window.location.reload(); window.location.reload();
} catch (err) { } catch (err) {
if (err.isValidationError && err.fieldErrors) { if (err.isValidationError && err.fieldErrors) {

View File

@ -0,0 +1,276 @@
{% load static %}
<!-- Add Additional Information -->
<dialog
id="edit_pathway_node_modal"
class="modal"
x-data="{
isSubmitting: false,
selectedType: '',
selectedScenario: '',
selectedNode: '',
schemas: {},
loadingSchemas: false,
error: null,
formData: null, // Store reference to form data
formRenderKey: 0, // Counter to force form re-render
allowedTypes: ['halflife', 'halflifews', 'proposedintermediate', 'transformationproductimportance', 'confidence'],
scenarios: [],
scenariosLoaded: false,
// Get sorted unique schema names for dropdown, excluding already-added types
get sortedSchemaNames() {
const names = Object.keys(this.schemas);
// Remove duplicates, exclude existing types, and sort alphabetically by display title
const unique = [...new Set(names)];
const available = unique.filter(name =>
this.allowedTypes.includes(name)
);
return available.sort((a, b) => {
const titleA = (this.schemas[a]?.schema?.['x-title'] || a).toLowerCase();
const titleB = (this.schemas[b]?.schema?.['x-title'] || b).toLowerCase();
return titleA.localeCompare(titleB);
});
},
async init() {
// Watch for selectedType changes
this.$watch('selectedType', (value) => {
// Reset formData when type changes and increment key to force re-render
this.formData = null;
this.formRenderKey++;
// Clear previous errors
this.error = null;
Alpine.store('validationErrors').clearErrors(); // No context - clears all
});
// Load schemas and existing items
try {
this.loadingSchemas = true;
const [schemasRes, scenarioRes] = await Promise.all([
fetch('/api/v1/information/schema/'),
fetch('{% url "package scenario list" meta.current_package.uuid %}', { headers: {'Accept': 'application/json' }}),
]);
if (!schemasRes.ok) throw new Error('Failed to load schemas');
if (!scenarioRes.ok) throw new Error('Failed to load scenarios');
this.schemas = await schemasRes.json();
this.scenarios = await scenarioRes.json();
// Get unique existing types (normalize to lowercase)
} catch (err) {
this.error = err.message;
} finally {
this.loadingSchemas = false;
}
},
reset() {
this.isSubmitting = false;
this.selectedType = '';
this.error = null;
this.formData = null;
this.selectedScenario = '';
Alpine.store('validationErrors').clearErrors(); // No context - clears all
},
setFormData(data) {
// Fired from schemaRenderer
this.formData = data;
},
async submit() {
if (!this.selectedType) return;
const payload = window.AdditionalInformationApi.sanitizePayload(this.formData || {});
// Validate that form has data
if (!payload || Object.keys(payload).length === 0) {
// proposedintermediate is parameterless
if (!this.selectedType === 'proposedintermediate') {
this.error = 'Please fill in at least one field';
return;
}
}
this.isSubmitting = true;
this.error = null;
try {
// TODO
await window.AdditionalInformationApi.createItemOnNonScenarioObject(
this.selectedType,
payload,
this.selectedNode,
this.selectedScenario
);
// Close modal and reload page to show new item
document.getElementById('edit_pathway_node_modal').close();
window.location.reload();
} catch (err) {
if (err.isValidationError && err.fieldErrors) {
// No context for add modal - simple flat errors
Alpine.store('validationErrors').setErrors(err.fieldErrors);
this.error = err.message || 'Please correct the errors in the form';
} else {
this.error = err.message || 'An error occurred. Please try again.';
}
} finally {
this.isSubmitting = false;
}
}
}"
@close="reset()"
@form-data-ready="setFormData($event.detail)"
@modal-opened.window="
const el = d3.select('circle.highlighted').node();
if (el !== null) {
const selectElement = document.getElementById('edit_pathway_nodes_node');
for (let option of selectElement.options) {
if (option.value === el.__data__.url) {
option.selected = true;
break;
}
}
selectElement.dispatchEvent(new Event('change'));
}
"
>
<div class="modal-box max-w-2xl">
<!-- Header -->
<h3 class="text-lg font-bold">Edit Compound</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<!-- Loading state -->
<template x-if="loadingSchemas">
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-md"></span>
</div>
</template>
<!-- Error state -->
<template x-if="error">
<div class="alert alert-error mb-4">
<span x-text="error"></span>
</div>
</template>
<div class="form-control">
<p>
If no Scenario is selected a new Scenario will be created and attached
to this Pathway
</p>
<label class="label" for="edit_pathway_nodes_node">
<span class="label-text">Select Node</span>
</label>
<select
id="edit_pathway_nodes_node"
name="node"
class="select select-bordered w-full"
x-model="selectedNode"
>
{% for n in pathway.nodes %}
<option value="{{ n.url }}">
{{ n.default_node_label.name|safe }}
</option>
{% endfor %}
</select>
<label class="label" for="scenario-select">
<span class="label-text">Scenarios</span>
</label>
<select
id="scenario-select"
name="selected-scenarios"
class="select select-bordered w-full"
x-model="selectedScenario"
>
<option value="" selected disabled>Select Scenario</option>
<template x-for="scenario in scenarios" :key="scenario.url">
<option :value="scenario.url" x-text="scenario.name"></option>
</template>
</select>
</div>
<!-- Schema selection -->
<template x-if="!loadingSchemas">
<div>
<div class="form-control mb-4">
<label class="label" for="select-additional-information-type">
<span class="label-text">Select the type to add</span>
</label>
<select
id="select-additional-information-type"
class="select select-bordered w-full"
x-model="selectedType"
>
<option value="" selected disabled>Select the type to add</option>
<template x-for="name in sortedSchemaNames" :key="name">
<option
:value="name"
x-text="(schemas[name].schema && (schemas[name].schema['x-title'] || schemas[name].schema.title)) || name"
></option>
</template>
</select>
</div>
<!-- Form renderer for selected type -->
<!-- Use unique key per type to force re-render -->
<template x-for="renderKey in [formRenderKey]" :key="renderKey">
<div x-show="selectedType && schemas[selectedType]">
<div
x-data="schemaRenderer({
rjsf: schemas[selectedType],
mode: 'edit'
// No context - single form, backward compatible
})"
x-init="await init(); $dispatch('form-data-ready', data)"
>
{% include "components/schema_form.html" %}
</div>
</div>
</template>
</div>
</template>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit()"
:disabled="isSubmitting || loadingSchemas || !selectedType || !selectedNode"
>
<span x-show="!isSubmitting">Add</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Adding...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -0,0 +1,147 @@
{% load static %}
<!-- Edit Package Permissions -->
<dialog
id="view_package_permissions_modal"
class="modal"
x-data="{}"
>
<div class="modal-box max-w-2xl">
<!-- Header -->
<h3 class="text-lg font-bold">Current Permissions</h3>
<!-- Close button (X) -->
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">
</button>
</form>
<!-- Body -->
<div class="py-4">
<p class="mb-4">
Current permissions for this package.
</p>
<!-- User Permissions -->
{% if user_permissions %}
<div class="divider">User Permissions</div>
<div class="space-y-2">
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-5 truncate"></div>
<div class="col-span-2 text-center">Read</div>
<div class="col-span-2 text-center">Write</div>
<div class="col-span-2 text-center">Owner</div>
<div class="col-span-1"></div>
</div>
{% for up in user_permissions %}
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-5 truncate">
{{ up.user.username }}
{% if not up.user.is_active %}<i>(inactive)</i>{% endif %}
</div>
<div class="col-span-2 text-center">
<input
type="checkbox"
name="read"
id="read_{{ up.user.uuid }}"
class="checkbox"
{% if up.has_read %}checked{% endif %}
onclick="return false;"
/>
</div>
<div class="col-span-2 text-center">
<input
type="checkbox"
name="write"
id="write_{{ up.user.uuid }}"
class="checkbox"
{% if up.has_write %}checked{% endif %}
onclick="return false;"
/>
</div>
<div class="col-span-2 text-center">
<input
type="checkbox"
name="owner"
id="owner_{{ up.user.uuid }}"
class="checkbox"
{% if up.has_all %}checked{% endif %}
onclick="return false;"
/>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Group Permissions -->
{% if group_permissions %}
<div class="divider">Group Permissions</div>
<div class="space-y-2">
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-5 truncate"></div>
<div class="col-span-2 text-center">Read</div>
<div class="col-span-2 text-center">Write</div>
<div class="col-span-2 text-center">Owner</div>
<div class="col-span-1"></div>
</div>
{% for gp in group_permissions %}
<div class="grid grid-cols-12 gap-2 items-center">
<div class="col-span-5 truncate">
{{ gp.group.name|safe }}
</div>
<div class="col-span-2 text-center">
<input
type="checkbox"
name="read"
id="read_{{ gp.group.uuid }}"
class="checkbox"
{% if gp.has_read %}checked{% endif %}
onclick="return false;"
/>
</div>
<div class="col-span-2 text-center">
<input
type="checkbox"
name="write"
id="write_{{ gp.group.uuid }}"
class="checkbox"
{% if gp.has_write %}checked{% endif %}
onclick="return false;"
/>
</div>
<div class="col-span-2 text-center">
<input
type="checkbox"
name="owner"
id="owner_{{ gp.group.uuid }}"
class="checkbox"
{% if gp.has_all %}checked{% endif %}
onclick="return false;"
/>
</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
>
Close
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

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

View File

@ -4,6 +4,8 @@
{% block content %} {% block content %}
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://d3js.org/d3.v7.min.js"></script>
<script src="{% static 'js/api/additional-information.js' %}"></script>
<style> <style>
#vizdiv { #vizdiv {
width: 100%; width: 100%;
@ -97,6 +99,7 @@
{% include "modals/objects/identify_missing_rules_modal.html" %} {% include "modals/objects/identify_missing_rules_modal.html" %}
{% include "modals/objects/generic_copy_object_modal.html" %} {% include "modals/objects/generic_copy_object_modal.html" %}
{% include "modals/objects/edit_pathway_modal.html" %} {% include "modals/objects/edit_pathway_modal.html" %}
{% include "modals/objects/edit_pathway_node_modal.html" %}
{% include "modals/objects/generic_set_aliases_modal.html" %} {% include "modals/objects/generic_set_aliases_modal.html" %}
{% include "modals/objects/generic_set_scenario_modal.html" %} {% include "modals/objects/generic_set_scenario_modal.html" %}
{% include "modals/objects/delete_pathway_node_modal.html" %} {% include "modals/objects/delete_pathway_node_modal.html" %}

View File

@ -7,7 +7,7 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/edit_scenario_modal.html" %} {% include "modals/objects/edit_scenario_modal.html" %}
{% include "modals/objects/add_additional_information_modal.html" %} {% include "modals/objects/add_scenario_additional_information_modal.html" %}
{% include "modals/objects/update_scenario_additional_information_modal.html" %} {% include "modals/objects/update_scenario_additional_information_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}