11 Commits

Author SHA1 Message Date
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
21 changed files with 626 additions and 45 deletions

View File

@ -357,6 +357,7 @@ DEFAULT_MODEL_PARAMS = {
DEFAULT_MAX_NUMBER_OF_NODES = 9999
DEFAULT_MAX_DEPTH = 8
DEFAULT_MODEL_THRESHOLD = 0.25
BATCH_PREDICT_MAX_COMPOUNDS = 150
# Loading Plugins
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 epapi.utils.schema_transformers import build_rjsf_output
from epapi.utils.validation_errors import handle_validation_error
from epdb.models import AdditionalInformation
from ..dal import get_scenario_for_read, get_scenario_for_write
from epdb.models import AdditionalInformation, Scenario, Node
from ..dal import get_scenario_for_read, get_scenario_for_write, get_package_for_write
logger = logging.getLogger(__name__)
@ -58,6 +58,61 @@ def list_scenario_info(request, scenario_uuid: UUID):
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}/")
def add_scenario_info(
request, scenario_uuid: UUID, model_name: str, payload: Dict[str, Any] = Body(...)

View File

@ -1,3 +1,5 @@
import logging
import msal
from django.conf import settings as s
from django.contrib.auth import get_user_model
@ -8,6 +10,7 @@ from django.shortcuts import redirect
from epdb.logic import UserManager, GroupManager
from epdb.models import Group
auth_log = logging.getLogger("auth")
def get_msal_app_with_cache(request):
"""
@ -28,8 +31,21 @@ def get_msal_app_with_cache(request):
return msal_app, cache
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 entra_login(request):
auth_log.info(f"Login request from {get_remote_address(request)}")
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
@ -54,6 +70,10 @@ def entra_callback(request):
# Acquire token using the flow and callback request
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
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
@ -61,7 +81,8 @@ def entra_callback(request):
claims = result["id_token_claims"]
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")
if not all([user_name, user_email, user_oid]):
@ -78,8 +99,10 @@ def entra_callback(request):
u.save()
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)
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)
# EDIT START
@ -102,10 +125,20 @@ def entra_callback(request):
else:
g = Group.objects.get(uuid=id)
for group_uuid in claims.get("groups", []):
if Group.objects.filter(uuid=group_uuid).exists():
g = Group.objects.get(uuid=group_uuid)
g.user_member.add(u)
sync_groups = list(s.ENTRA_GROUPS.keys()) + list(s.ENTRA_SECRET_GROUPS.keys())
user_groups = claims.get("groups", [])
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 })")
# EDIT END

View File

@ -881,12 +881,11 @@ def create_package_compound(
if "secret" == classification.lower():
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)
else:

View File

@ -35,6 +35,7 @@ from utilities.chem import FormatConverter
from utilities.misc import PackageExporter, PackageImporter
logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL()
@ -45,7 +46,7 @@ class EPDBURLParser:
MODEL_PATTERNS = {
"epdb.User": re.compile(rf"^.*/user/{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.CompoundStructure": re.compile(
rf"^.*/package/{UUID_PATTERN}/compound/{UUID_PATTERN}/structure/{UUID_PATTERN}"
@ -95,7 +96,7 @@ class EPDBURLParser:
def contains_package_url(self):
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()
)
@ -123,7 +124,7 @@ class EPDBURLParser:
"epdb.EPModel",
"epdb.Pathway",
# 1st level
"epdb.Package",
s.EPDB_PACKAGE_MODEL,
"epdb.Setting",
"epdb.Group",
"epdb.User",
@ -145,7 +146,7 @@ class EPDBURLParser:
hierarchy_order = [
# 1st level
"epdb.Package",
s.EPDB_PACKAGE_MODEL,
"epdb.Setting",
"epdb.Group",
"epdb.User",
@ -316,13 +317,19 @@ class GroupManager(object):
if isinstance(member, Group):
if add_or_remove == "add":
group.group_member.add(member)
auth_log.info(f"{caller.username} ({caller.url}) adds {member.name} ({member.url}) to {group.name} ({group.url})")
else:
group.group_member.remove(member)
auth_log.info(
f"{caller.username} ({caller.url}) removes {member.name} ({member.url}) to {group.name} ({group.url})")
else:
if add_or_remove == "add":
group.user_member.add(member)
auth_log.info(f"{caller.username} ({caller.url}) adds {member.username} ({member.url}) to {group.name} ({group.url})")
else:
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()
@ -589,11 +596,25 @@ class PackageManager(object):
if qs.count() != 0:
logger.info(f"Deleting Perm {qs.first()}")
qs.delete()
auth_log.info(f"{caller.username} ({caller.url}) revokes {grantee.name} ({grantee.url}) all Permissions on {package.name} ({package.url})")
else:
logger.debug(f"No Permission object for {perm_cls} with filter {data} found!")
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)
grantee_name = grantee.username if isinstance(grantee, User) else grantee.name
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
def grant_read(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.READ[0])
@ -1875,12 +1896,51 @@ class SPathway(object):
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):
nodes = []
edges = []
idx_lookup = {}
bayes_probs = self.compute_bayes_probabilities()
for i, smiles in enumerate(self.smiles_to_node):
n = self.smiles_to_node[smiles]
idx_lookup[smiles] = i
@ -1901,6 +1961,7 @@ class SPathway(object):
if edge.probability:
e["probability"] = edge.probability
e["multiGenProbability"] = bayes_probs[edge]
edges.append(e)

View File

@ -2466,7 +2466,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"name": self.get_name(),
"plain_name": self.get_name(include_suffix=False),
"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": {
"inside_app_domain": app_domain_data["assessment"]["inside_app_domain"]
if app_domain_data
@ -2594,6 +2594,15 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
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):
pathway = models.ForeignKey(
@ -3005,7 +3014,14 @@ class PackageBasedModel(EPModel):
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)
prec[f"{t:.2f}"] = precision_score(
y_test_filtered, temp_thresholded, zero_division=0
@ -3015,7 +3031,14 @@ class PackageBasedModel(EPModel):
return acc, prec, rec
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}
recall = {f"{t:.2f}": [] for t in thresholds}
@ -3041,7 +3064,7 @@ class PackageBasedModel(EPModel):
s = Setting()
s.model = mod
s.model_threshold = thresholds.min()
s.model_threshold = 0.0
s.max_depth = 10
s.max_nodes = 50

View File

@ -61,6 +61,7 @@ from .models import (
)
logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL()
@ -147,6 +148,19 @@ def handler500(request):
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()
except Exception as e:
logger.info(f"Tried to delete {obj_type}: {obj.name} ({obj.url}) but deletion failed! Exception {e}")
auth_log.error(
f"{caller.username} ({caller.url}) tried to delete {obj_type}: {obj.name} ({obj.url}) but deletion failed!")
raise e
def login(request):
context = get_base_context(request)
@ -526,6 +540,7 @@ def batch_predict_pathway(request):
context = get_base_context(request)
context["title"] = "enviPath - Batch Predict Pathway"
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)
@ -1281,7 +1296,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.",
)
logger.debug(current_package.delete())
delete_with_log(request, current_package)
return redirect(s.SERVER_URL + "/package")
elif hidden == "publish-package":
for g in Group.objects.filter(public=True):
@ -1949,6 +1965,21 @@ def package_reactions(request, package_uuid):
reaction_name = request.POST.get("reaction-name")
reaction_description = request.POST.get("reaction-description")
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(".")
products = reaction_smiles.split(">>")[1].split(".")
@ -2257,7 +2288,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
elif request.method == "POST":
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
current_pathway.delete()
delete_with_log(request, current_pathway)
return redirect(current_package.url + "/pathway")
else:
return HttpResponseBadRequest()
@ -2475,7 +2506,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
# pre_delete signal will take care of edge deletion
current_node.delete()
delete_with_log(request, current_node)
return redirect(current_pathway.url)
else:
@ -2629,7 +2660,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
current_edge.delete()
delete_with_log(request, current_edge)
return redirect(current_pathway.url)
if "selected-scenarios" in request.POST:
@ -2991,7 +3022,7 @@ def group(request, group_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
current_group.delete()
delete_with_log(request, current_group)
return redirect(s.SERVER_URL + "/group")
else:
return HttpResponseBadRequest()

View File

@ -120,13 +120,6 @@ class PathwayMapper:
)
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:
return bundle
@ -145,6 +138,16 @@ class PathwayMapper:
if not root_compound_pks:
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, ...]]] = []
for edge in sorted(export.edges, key=lambda item: str(item.edge_uuid)):
parent_compound_pks = sorted(

View File

@ -70,8 +70,7 @@ class IUCLIDExportAPITest(TestCase):
names = zf.namelist()
self.assertIn("manifest.xml", names)
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), 5)
self.assertEqual(len(i6d_files), 4)
def test_anonymous_returns_401(self):
self.client.logout()

View File

@ -7,6 +7,11 @@ from uuid import uuid4
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.pathway_mapper import (
IUCLIDDocumentBundle,
@ -14,9 +19,24 @@ from epiuclid.serializers.pathway_mapper import (
IUCLIDReferenceSubstanceData,
IUCLIDSubstanceData,
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:
ref_uuid = uuid4()
sub_uuid = uuid4()
@ -197,3 +217,29 @@ class I6ZSerializerTest(SimpleTestCase):
}
self.assertIn(parent_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)
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.substances), 1)
self.assertEqual(len(bundle.reference_substances), 2)
self.assertEqual(len(bundle.endpoint_study_records), 1)
@ -49,8 +49,7 @@ class PathwayMapperTest(SimpleTestCase):
)
bundle = PathwayMapper().map(export)
# 2 unique compounds -> 2 substances, 2 ref substances
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.substances), 1)
self.assertEqual(len(bundle.reference_substances), 2)
# One endpoint study record per pathway
self.assertEqual(len(bundle.endpoint_study_records), 1)

View File

@ -186,6 +186,40 @@ window.AdditionalInformationApi = {
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
* @param {string} scenarioUuid - UUID of the scenario

View File

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

View File

@ -83,6 +83,19 @@
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a
>
</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>
<a
role="button"

View File

@ -10,7 +10,7 @@
<li>
<a
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
>

View File

@ -37,7 +37,8 @@
class="text-xs text-base-content/50 border-t border-base-300 pt-3"
>
<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>
@ -195,8 +196,7 @@
// Function to populate table from CSV data
function populateTableFromCSV(csvData) {
const lines = csvData.trim().split("\n");
const maxRows = 30;
const maxRows = Number("{{ batch_predict_max_compounds|default:150 }}");
// Clear existing table
clearTable();

View File

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

View File

@ -1,7 +1,7 @@
{% load static %}
<!-- Add Additional Information -->
<dialog
id="add_additional_information_modal"
id="add_scenario_additional_information_modal"
class="modal"
x-data="{
isSubmitting: false,
@ -98,7 +98,7 @@
);
// 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();
} catch (err) {
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

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

View File

@ -7,7 +7,7 @@
{% block action_modals %}
{% 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/generic_delete_modal.html" %}
{% endblock action_modals %}