4 Commits

Author SHA1 Message Date
7e9f194425 wip
Some checks failed
API CI / api-tests (pull_request) Failing after 25s
CI / test (pull_request) Failing after 25s
2026-07-19 23:25:36 +02:00
3409a17ab8 View Package Perm
Some checks failed
API CI / api-tests (pull_request) Failing after 29s
CI / test (pull_request) Failing after 45s
2026-07-17 10:52:03 +02:00
230026147e Provide proper Error Pages
Some checks failed
CI / test (pull_request) Failing after 30s
API CI / api-tests (pull_request) Failing after 44s
2026-07-17 10:34:06 +02:00
e5e0dcee3b adjusted migration
Some checks failed
API CI / api-tests (pull_request) Failing after 30s
CI / test (pull_request) Failing after 33s
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-16 22:26:23 +02:00
44 changed files with 182 additions and 1606 deletions

View File

@ -1,62 +0,0 @@
name: Build Docker Image
# Trigger when a PR to main/develop is completed.
on:
pull_request:
branches:
- main
- develop
types:
- closed
jobs:
build-and-push:
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
steps:
# Fetch the repository content for the Docker build context.
- name: Checkout repository
uses: actions/checkout@v4
# Enable Buildx for BuildKit features (incl. SSH mount support).
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Authenticate against the container registry before pushing images.
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.envipath.com
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Generate image tags/labels:
# - PRs targeting main get "latest" and "main-sha"
# - PRs targeting develop get "dev" and "dev-sha"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.envipath.com/envipath/envipy
tags: |
type=raw,value=latest,enable=${{ github.event.pull_request.base.ref == 'main' }}
type=sha,prefix=main-,enable=${{ github.event.pull_request.base.ref == 'main' }}
type=raw,value=dev,enable=${{ github.event.pull_request.base.ref == 'develop' }}
type=sha,prefix=dev-,enable=${{ github.event.pull_request.base.ref == 'develop' }}
# Load SSH key so Docker can pull private git+ssh dependencies during build.
- name: Setup SSH for private git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
# Build and push the production image; forward SSH agent without registry cache reuse.
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
ssh: default
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -60,10 +60,6 @@ COPY tests tests
COPY utilities utilities
COPY manage.py .
# Used to run migrations etc
COPY entrypoint.sh entrypoint.sh
RUN chmod +x entrypoint.sh
# Install frontend deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
@ -85,7 +81,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxext6 \
libfontconfig1 \
nano \
openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash django
@ -107,5 +102,4 @@ USER django
EXPOSE 8000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "8"]
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

View File

@ -5,11 +5,11 @@
class="modal"
x-data="{
isSubmitting: false,
packageClassification: '',
packageClassification: null,
reset() {
this.isSubmitting = false;
this.packageClassification = '';
this.packageClassification = null;
},
setFormData(data) {
@ -114,7 +114,7 @@
x-model="packageClassification"
required
>
<option value="" disabled>Select Classification</option>
<option value="null" disabled selected>Select Classification</option>
<option value="0">Internal</option>
<option value="10">Restricted</option>
<option value="20">Secret</option>
@ -131,9 +131,8 @@
id="package-data-pool"
name="package-data-pool"
class="select select-bordered w-full"
:required="isSecret"
>
<option value="" disabled>Select Data Pool</option>
<option value="" disabled selected>Select Data Pool</option>
{% for obj in meta.secret_groups %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endfor %}

View File

@ -8,7 +8,7 @@ from django.shortcuts import redirect
from bayer.models import PESCompound
from epdb.logic import PackageManager
from epdb.models import Pathway, Node, Group
from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real, error
from utilities.decorators import package_permission_required
@ -18,23 +18,6 @@ Package = s.GET_PACKAGE_MODEL()
logger = logging.getLogger(__name__)
def has_secret_group(user):
"""
Determines if the specified user belongs to any secret group.
This function checks whether the given user is a member of any group
that is marked as secret.
Args:
user: The user for whom the check is performed.
Returns:
bool: True if the user belongs to at least one secret group,
False otherwise.
"""
return Group.objects.filter(secret=True, user_member=user).exists()
@package_permission_required()
def create_pes(request, package_uuid):
current_user = _anonymous_or_real(request)
@ -55,7 +38,7 @@ def create_pes(request, package_uuid):
if pes_link:
try:
pes_data = fetch_pes(request, pes_link, current_user)
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return error(
request,
@ -115,7 +98,7 @@ def create_pes_node(request, package_uuid, pathway_uuid):
if pes_link:
try:
pes_data = fetch_pes(request, pes_link, current_user)
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return error(
request,
@ -174,82 +157,53 @@ def create_pes_node(request, package_uuid, pathway_uuid):
return HttpResponseNotAllowed(["POST"])
def get_application_token(prod: bool) -> str:
scope = f"{s.PROD_PES_SCOPE if prod else s.NON_PROD_PES_SCOPE}/.default"
def fetch_pes(request, pes_url) -> dict:
from epauth.views import get_access_token_from_request
token = get_access_token_from_request(request)
url = f"https://login.microsoftonline.com/{s.MS_TENANT_ID}/oauth2/v2.0/token"
data = {
"grant_type": "client_credentials",
"client_id": s.MS_ENTRA_CLIENT_ID,
"client_secret": s.MS_ENTRA_CLIENT_SECRET,
"scope": scope,
}
if token is None:
token = pes_url.split('/')[-1] == 'dummy'
try:
response = requests.post(url, data=data)
response.raise_for_status()
return response.json()["access_token"]
except requests.exceptions.HTTPError as e:
logger.error(f"Could not fetch application token: {e}")
raise ValueError(f"Could not fetch application token!")
if token:
for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k):
pes_id = pes_url.split('/')[-1]
def fetch_pes(request, pes_url, user) -> dict:
for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k):
prod = "cropkey-np" not in pes_url
pes_id = pes_url.split('/')[-1]
if pes_id == 'dummy':
import json
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
res_data["pes_url"] = pes_url
return res_data
else:
headers = {
"accept": "*/*",
"authorization": "Bearer " + get_application_token(prod),
}
# Restrict request if user is not part of any secret group
if not has_secret_group(user):
headers["app-classification-level-restriction"] = "restrict-pes-secret-structure-access"
params = {"pes_reg_entity_corporate_id": pes_id}
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
try:
res.raise_for_status()
pes_data = res.json()
# Handle missing response
if "detail" in pes_data and "The following PES Reg Entities Corporate Ids could not be found" in pes_data["detail"]:
raise ValueError(f"PES with id {pes_id} not found")
# Ensure we have a entity
if len(pes_data) == 0:
raise ValueError(f"PES with id {pes_id} not found")
res_data = pes_data[0]
if pes_id == 'dummy':
import json
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
res_data["pes_url"] = pes_url
return res_data
else:
headers = {"Authorization": f"Bearer {token['access_token']}"}
params = {"pes_reg_entity_corporate_id": pes_id}
except requests.exceptions.HTTPError as e:
raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
raise ValueError(f"Unknown URL {pes_url}")
try:
res.raise_for_status()
pes_data = res.json()
if len(pes_data) == 0:
raise ValueError(f"PES with id {pes_id} not found")
res_data = pes_data[0]
res_data["pes_url"] = pes_url
return res_data
except requests.exceptions.HTTPError as e:
raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
else:
raise ValueError(f"Unknown URL {pes_url}")
else:
raise ValueError("Could not fetch access token from request.")
def visualize_pes(request):
pes_link = request.GET.get('pesLink')
if pes_link:
pes_data = fetch_pes(request, pes_link, request.user)
pes_data = fetch_pes(request, pes_link)
representations = pes_data.get('representations')

View File

@ -1,5 +1,4 @@
import enum
from typing import Any, Dict
from abc import ABC, abstractmethod
from envipy_additional_information import EnviPyModel
@ -70,12 +69,6 @@ class Plugin(ABC):
class Property(Plugin):
def parameters(self) -> Dict[str, Any]:
"""
Returns the parameters of the PropertyPlugin.
"""
return {}
@classmethod
@abstractmethod
def requires_rule_packages(cls) -> bool:
@ -307,12 +300,6 @@ class Classifier(Plugin):
"""
pass
def parameters(self) -> Dict[str, Any]:
"""
Returns the parameters of the ClassifierPlugin.
"""
return {}
@abstractmethod
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
"""

View File

@ -1,9 +0,0 @@
#!/bin/bash
set -e
if [ "${SKIP_DJANGO_SETUP:-false}" != "true" ]; then
python manage.py migrate --no-input
python manage.py collectstatic --no-input
fi
exec "$@"

View File

@ -357,7 +357,6 @@ 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, Scenario, Node
from ..dal import get_scenario_for_read, get_scenario_for_write, get_package_for_write
from epdb.models import AdditionalInformation
from ..dal import get_scenario_for_read, get_scenario_for_write
logger = logging.getLogger(__name__)
@ -58,61 +58,6 @@ 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

@ -56,7 +56,7 @@ def get_pathway_for_iuclid_export(user, pathway_uuid: UUID) -> PathwayExportDTO:
ai_for_node = []
scenario_entries: list[PathwayScenarioDTO] = []
for scenario in sorted(node.get_scenarios(), key=lambda item: item.pk):
for scenario in sorted(node.scenarios.all(), key=lambda item: item.pk):
ai_for_scenario = list(scenario.get_additional_information(direct_only=True))
ai_for_node.extend(ai_for_scenario)
scenario_entries.append(

View File

@ -1,5 +1,3 @@
import logging
import msal
from django.conf import settings as s
from django.contrib.auth import get_user_model
@ -9,9 +7,6 @@ from django.shortcuts import redirect
from epdb.logic import UserManager, GroupManager
from epdb.models import Group
from epdb.views import get_remote_address, error
auth_log = logging.getLogger("auth")
def get_msal_app_with_cache(request):
@ -35,9 +30,6 @@ def get_msal_app_with_cache(request):
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,
@ -62,28 +54,14 @@ 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()
claims = result["id_token_claims"]
if claims.get("roles") is None or claims.get("roles") == [] or "envipath_registered_user" not in claims.get("roles"):
auth_log.error(f"Login attempt by {get_remote_address(request)} failed due to missing role")
return error(
request,
"Login Failed",
"The user is not authenticated. A reason for this might be a missing assignment to the respective enviPath group.",
403,
)
user_name = claims.get("name")
# preferred_username is a fallback for 2nd CWID
user_email = claims.get("emailaddress", claims.get("email", claims.get("preferred_username")))
user_email = claims.get("emailaddress", claims.get("email"))
user_oid = claims.get("oid")
if not all([user_name, user_email, user_oid]):
@ -92,7 +70,6 @@ def entra_callback(request):
# Get implementing class
User = get_user_model()
registered = False
if User.objects.filter(uuid=user_oid).exists():
u = User.objects.get(uuid=user_oid)
@ -101,11 +78,8 @@ 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)
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)
# EDIT START
@ -128,31 +102,10 @@ def entra_callback(request):
else:
g = Group.objects.get(uuid=id)
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 })")
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
pack.data_pool = group
pack.classification_level = pack.Classification.SECRET
pack.save()
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)
# EDIT END

View File

@ -1,4 +1,3 @@
import logging
from collections import defaultdict
from typing import Any, Dict, List, Optional
@ -10,11 +9,13 @@ from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from jwt import InvalidIssuerError
from ninja import Field, Form, Query, Router, Schema
from ninja.security import HttpBearer
from utilities.chem import FormatConverter
from utilities.misc import PackageExporter
from .logic import (
EPDBURLParser,
GroupManager,
@ -45,12 +46,9 @@ from .models import (
User,
UserPackagePermission,
)
from .views import delete_with_log, get_remote_address
Package = s.GET_PACKAGE_MODEL()
auth_log = logging.getLogger("auth")
def get_cached_jwks(tenant_id: str, force=False) -> Dict:
"""Get JWKS using Django cache"""
@ -72,10 +70,6 @@ def get_cached_jwks(tenant_id: str, force=False) -> Dict:
return jwks
def get_package_for_read(user, package_uuid):
return PackageManager.get_package_by_id(user, package_uuid)
def get_package_for_write(user, package_uuid):
p = PackageManager.get_package_by_id(user, package_uuid)
if not PackageManager.writable(user, p):
@ -122,22 +116,15 @@ def validate_token(token: str) -> dict:
class MSBearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
auth_log.info(f"Authentication request by {get_remote_address(request)}")
if token is None:
return None
claims = validate_token(token)
if not User.objects.filter(uuid=claims['oid']).exists():
auth_log.info(f"Authentication request by {get_remote_address(request)} failed!")
return None
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)}")
request.user = User.objects.get(uuid=claims['oid'])
return request.user
@ -571,10 +558,7 @@ def update_package(request, package_uuid, pack: Form[UpdatePackage]):
if pack.hiddenMethod:
if pack.hiddenMethod == "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!")
p.delete()
elif pack.packageDescription is not None:
description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip()
@ -611,7 +595,7 @@ def delete_package(request, package_uuid):
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.administrable(request.user, p):
delete_with_log(request, p)
p.delete()
return redirect(f"{s.SERVER_URL}/package")
else:
raise ValueError("You do not have the rights to delete this Package!")
@ -889,7 +873,7 @@ def create_package_compound(
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, c.pesLink, request.user)
pes_data = fetch_pes(request, c.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"}
@ -897,11 +881,12 @@ def create_package_compound(
if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
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."}
return 400, {"Cannot create PESs for non-secret packages."}
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:
@ -1892,7 +1877,7 @@ def delete_pathway(request, package_uuid, pathway_uuid):
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
delete_with_log(request, pw)
pw.delete()
return redirect(f"{p.url}/pathway")
except ValueError:
@ -2014,7 +1999,7 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, n.pesLink, request.user)
pes_data = fetch_pes(request, n.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {n.pesLink}"}
@ -2070,7 +2055,7 @@ def delete_node(request, package_uuid, pathway_uuid, node_uuid):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
n = Node.objects.get(pathway=pw, uuid=node_uuid)
delete_with_log(request, n)
n.delete()
return redirect(f"{pw.url}/node")
except ValueError:
@ -2230,7 +2215,7 @@ def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
delete_with_log(request, e)
e.delete()
return redirect(f"{pw.url}/edge")
except ValueError:
@ -2434,42 +2419,3 @@ def predict(request, np: Form[NonPersistent]):
return 403, {
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
}
##########
# Export #
##########
class PackageExportInSchema(Schema):
package_uuid: str
additional_information_types: List[str] | None = None
@router.get("/export", response={200: Any, 403: Error})
def export(request, q: Query[PackageExportInSchema]):
try:
p = get_package_for_read(request.user, q.package_uuid)
from envipy_additional_information import registry
from utilities.misc import PathwayExporter
ai_types = []
if q.additional_information_types is not None:
for ai_type in q.additional_information_types:
if registry.get_model(ai_type) is None:
return 400, {
"message": f"Exporting Package with id {q.package_uuid} failed as {ai_type} is not a valid additional information type!"
}
ai_types.append(ai_type)
exporter = PathwayExporter(p, add_infs_to_export=ai_types)
res = exporter.do_export()
filename = f"{p.get_name().replace(' ', '_')}_{p.uuid}.tsv"
response = HttpResponse(res, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ValueError:
return 403, {
"message": f"Exporting Package with id {q.package_uuid} failed due to insufficient rights!"
}

View File

@ -35,7 +35,6 @@ 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()
@ -46,7 +45,7 @@ class EPDBURLParser:
MODEL_PATTERNS = {
"epdb.User": re.compile(rf"^.*/user/{UUID_PATTERN}"),
"epdb.Group": re.compile(rf"^.*/group/{UUID_PATTERN}"),
s.EPDB_PACKAGE_MODEL: re.compile(rf"^.*/package/{UUID_PATTERN}"),
"epdb.Package": 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}"
@ -96,7 +95,7 @@ class EPDBURLParser:
def contains_package_url(self):
return (
bool(self.MODEL_PATTERNS[s.EPDB_PACKAGE_MODEL].findall(self.url))
bool(self.MODEL_PATTERNS["epdb.Package"].findall(self.url))
and not self.is_package_url()
)
@ -124,7 +123,7 @@ class EPDBURLParser:
"epdb.EPModel",
"epdb.Pathway",
# 1st level
s.EPDB_PACKAGE_MODEL,
"epdb.Package",
"epdb.Setting",
"epdb.Group",
"epdb.User",
@ -146,7 +145,7 @@ class EPDBURLParser:
hierarchy_order = [
# 1st level
s.EPDB_PACKAGE_MODEL,
"epdb.Package",
"epdb.Setting",
"epdb.Group",
"epdb.User",
@ -317,19 +316,13 @@ 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}) from {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}) removes {member.username} ({member.url}) from {group.name} ({group.url})")
group.save()
@ -585,11 +578,9 @@ class PackageManager(object):
if isinstance(grantee, User):
perm_cls = UserPackagePermission
data["user"] = grantee
grantee_name = grantee.username
else:
perm_cls = GroupPackagePermission
data["group"] = grantee
grantee_name = grantee.name
if new_perm is None:
qs = perm_cls.objects.filter(**data)
@ -598,23 +589,11 @@ 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)
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])
@ -1896,51 +1875,12 @@ 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
@ -1961,7 +1901,6 @@ class SPathway(object):
if edge.probability:
e["probability"] = edge.probability
e["multiGenProbability"] = bayes_probs[edge]
edges.append(e)

View File

@ -44,25 +44,20 @@ class Command(BaseCommand):
"EPModel",
"ApplicabilityDomain",
"EnzymeLink",
"AdditionalInformation",
]
for model in MODELS:
obj_cls = apps.get_model("epdb", model)
update_fields = {"url": Replace(F("url"), Value(options["old"]), Value(options["new"]))}
if hasattr(obj_cls, "description"):
update_fields["description"] = Replace(
F("description"), Value(options["old"]), Value(options["new"])
)
obj_cls.objects.update(
url=Replace(F("url"), Value(options["old"]), Value(options["new"]))
)
if issubclass(obj_cls, EnviPathModel):
update_fields["kv"] = Cast(
Replace(
Cast(F("kv"), output_field=TextField()),
Value(options["old"]),
Value(options["new"]),
),
output_field=JSONField(),
obj_cls.objects.update(
kv=Cast(
Replace(
Cast(F("kv"), output_field=TextField()),
Value(options["old"]),
Value(options["new"]),
),
output_field=JSONField(),
)
)
obj_cls.objects.update(**update_fields)

View File

@ -1,97 +0,0 @@
import logging
from django.core.management.base import BaseCommand
from django.db import transaction
from uuid import uuid4
from epdb.models import Package, ReactionExplanation
from utilities.chem import FormatConverter
from django.utils import timezone
logger = logging.getLogger(__name__)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--rule-package",
action="append",
default=["32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"],
type=str,
help="UUID to process. Can be specified multiple times.",
)
parser.add_argument(
"--reaction-package",
action="append",
default=[
"32de3cf4-e3e6-4168-956e-32fa5ddb0ce1", # BBD
"f05e38d8-e9b4-4c3e-b0d8-9ab29966eccf", # Sediment
"521c547a-fd2a-491c-ad5b-7eaa1577fb65", # Sludge
"5882df9c-dae1-4d80-a40e-db4724271456", # Soil
"87a49584-d937-482c-9c33-25928dcb02a8", # PFAS
],
type=str,
help="UUID to process. Can be specified multiple times.",
)
parser.add_argument(
"--dry-run",
default=False,
action="store_true",
help="Perform dry run",
)
@transaction.atomic
def handle(self, *args, **options):
RUN_UUID = uuid4()
RUN_START = timezone.now()
rule_packages = Package.objects.filter(uuid__in=options["rule_package"])
reaction_packages = Package.objects.filter(uuid__in=options["reaction_package"])
rules = []
for rule_package in rule_packages:
rules.extend(rule_package.get_applicable_rules())
reactions = []
for reaction_package in reaction_packages:
reactions.extend(reaction_package.reactions)
logger.debug(f"Collected {len(rules)} rules and {len(reactions)} reactions.")
for i, reaction in enumerate(reactions):
logger.debug(f"Reaction {i} / {len(reactions)}")
for j, rule in enumerate(rules):
reactants, products = reaction.smirks().split(">>")
if len(reactants.split(".")) > 1:
logger.debug(f"Skipping reaction {reaction.uuid} as it has multiple reactants.")
break
products = products.split(".")
# Run reaction with rule
rule_products = rule.apply(reactants)
# Check if products match (in both directions if extras are not allowed)
for product_set in rule_products:
covered, exact = FormatConverter.smiles_covered_by(
products,
product_set.product_set,
standardize=True,
canonicalize_tautomers=True,
return_exact_match=True,
)
if covered and not options["dry-run"]:
logger.debug(f"Reaction {reaction.uuid} explained by rule {rule.uuid}")
re = ReactionExplanation()
re.run_uuid = RUN_UUID
re.run_start = RUN_START
re.reaction = reaction
re.rule = rule
re.exact = exact
re.save()
# Its explained, if there are more sets skip them
break

View File

@ -1,113 +0,0 @@
# Generated by Django 6.0.3 on 2026-08-12 09:02
from django.conf import settings as s
from django.db import migrations
from envipy_additional_information import Likelihood, RuleLikelihood
NEW_RULE = {
"parent": "bt0005",
"name": "bt0005-3667",
"description": "vic-unsubstituted Aromatic > vic-Dihydroxyaromatic",
"smirks": "[#8:7]([H])-[#6:1]([H])-1-[#6:2]=[#6:3]-[#6:4]=[#6:5]-[#6:6]([H])-1-[#8:8]([H])>>[#8:7]([H])-[#6:1]=1-[#6:2]=[#6:3]-[#6:4]=[#6:5]-[#6:6]=1-[#8:8]([H])",
"scenario_name": "bt0005-3667 aerobic likelihood",
"scenario_aerobic_likelihood": RuleLikelihood(likelihood=Likelihood.LIKELY),
}
RULE_FIXES = {
"bt0005-4282": "[c:1]([H])1:[c:2]([H]):[#6,#7;a:3]:[c:4]:[c:5]:[c:6]1>>[c:1]([#8])1:[c:2]([#8]):[#6,#7;a:3]:[c:4]:[c:5]:[c:6]1",
"bt0014-4215": "[c:1]([H])1[c:8][#6,#7;a:7][c:6][c:5][c:4]1[#8;!$([OH]c:[#6,#7;a:7]([OH])):9]([H])>>[#8:9]([H])[c:4]1:[c:5]:[c:6]:[#6,#7;a:7]:[c:8]:[c:1]1[#8]([H])",
# "bt0063-3938": "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([H:3])(=[#1,#6:7]).[#6;A:2]=O",
# CN1C=NC2=C1C(=O)N(C)C(=O)N2 not working anymore with bt0063-3938 if change above is applied
"bt0063-3938": "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O",
"bt0068-3564": "[#7:4]!@-[#6:2](!@-[#7:1])=[O:5]>>[#7:4]-[#6:2](-[O+0H1])=[O:5].[#7H1:1]",
"bt0180-2844": "[H][C:2]([#6:5]([H])([H])([H]))([#1,#6:4])!@-[#6:1]([H])([H])-[#6:3](-[#8-:8])=[O:6]>>[#6:5]([H])([H])([H])\\[#6:2](-[#1,#6:4])=[#6H:1]\\[#6:3](-[#8-:8])=[O:6]",
"bt0181-1278": "[#8-:1]-[#6:2](=[O:11])-[#6:7]=[#6:8]-[#6:3](-[H])=[#6:5](-Cl)-[#6:6](-[#8-:10])=[O:9]>>[O+0H1:10]-[#6:6](=[O:9])-[#6:5]=[#6:3]-1-[O+0:1]-[#6:2](=[O:11])-[#6:7]=[#6:8]-1",
"bt0298-3335": "[#6:1][N+:2]#[C:3]>>[#6:1]-[#7H2:2]-[#6:3]=O",
"bt0322-3393": "[H:10]\\[#6:6](=[#6:9](/[#6:1]([H])([H])([H]))-[#6:11]-[#6:12]-[#6:13]=[#6:14])-[#6:5](-[#16:7])=[O:8]>>[H:10]\\[#6:6](-[#6:5](-[#16:7])=[O:8])=[#6:9](\\[#6:11]-[#6:12]-[#6:13]=[#6:14])-[#6:1]-[#6](-[#8-])=O",
"bt0343-2675": "[#8-]-[#6](=O)-[c:1]1[c:6][cH:7][c:8](-[#7H2,#8H1:9])[cH:10][c:11]1>>[#8H][c:1]1[c:6][c:7][c:8]([*:9])[c:10][c:11]1",
"bt0350-3319": "[#6:6][#7:3][#6;!R:2]=[#7;!R:1][#6:5]>>[#6:5][#7:1][#6:2]=O.[#6:6][#7:3]", # Trig before 5 -> all of them shouldn't
"bt0374-4081": "[cH:4]1[c:16][c:15][c:14][c:13][c:3]1[#7,#8:2][c:1]1[c:8][c:9][c:10][c:11][c:12]1>>[#7,#8:2]-[c:1]1[c:12][c:11][c:10][c:9][c:8]1[c:13]1[c:14][c:15][c:16][c:4](-[#8])[c:3]1-[#8]",
"bt0378-3188": "[#8-:7][c:1]1[c:6]([#7+]([#8-])=O)[c:5][c:4]([#7+:9]([#8-])=O)[c:3][c:2]1([#7+:8]([#8-])=O)>>[#8+0:7]=[#6:1]1-[#6:6]-[#6:5]-[#6:4]([#7+:9]([#8-])=O)-[#6:3]-[#6:2]1([#7+:8]([#8-])=O)",
"bt0379-3190": "[#9,#17,#35,#53]-[#6:1](-[H])-1-[#6:5]-,=[#6:6]-[#6:7]-,=[#6:8]-[#6:2](-[H])-1-[#9,#17,#35,#53]>>[#6:6]~1-[#6:7]~[#6:8]-[#6:2]=[#6:1]-[#6:5]~1",
"bt0393-3367": "[#6:5]-[#6:1](-[#7:2](-[H])(-[H]))=[S+:3]-[#8-:6]>>[#6:5]-[#6:1](=[#7H1:2])-[S+0:3](=[O])-[#8+0H1:6]",
}
def forward_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
pkg_class = s.EPDB_PACKAGE_MODEL
if len(pkg_class.split(".")) != 2:
raise ValueError(
f"EPDB_PACKAGE_MODEL must be of the form 'app_label.model_name', got {pkg_class}"
)
app_label, model_name = pkg_class.split(".")
Package = apps.get_model(app_label, model_name)
SimpleAmbitRule = apps.get_model("epdb", "SimpleAmbitRule")
ParallelRule = apps.get_model("epdb", "ParallelRule")
Scenario = apps.get_model("epdb", "Scenario")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
simple_ambit_rule_ct = ContentType.objects.get_for_model(SimpleAmbitRule)
if Package.objects.filter(name="EAWAG-BBD").exists():
p = Package.objects.get(name="EAWAG-BBD")
if not SimpleAmbitRule.objects.filter(package=p, name=NEW_RULE["name"]).exists():
# Create Missing Rule
new_sr = SimpleAmbitRule()
new_sr.polymorphic_ctype = simple_ambit_rule_ct
new_sr.package = p
new_sr.name = NEW_RULE["name"]
new_sr.description = NEW_RULE["description"]
new_sr.smirks = NEW_RULE["smirks"]
new_sr.save()
new_sr.url = "{}/simple-ambit-rule/{}".format(new_sr.package.url, new_sr.uuid)
new_sr.save()
# Add likelihood
new_scen = Scenario()
new_scen.package = p
new_scen.name = NEW_RULE["scenario_name"]
new_scen.save()
new_scen.url = "{}/scenario/{}".format(new_scen.package.url, new_scen.uuid)
new_scen.save()
ai = NEW_RULE["scenario_aerobic_likelihood"]
new_add_inf = AdditionalInformation()
new_add_inf.package = p
new_add_inf.type = ai.__class__.__name__
new_add_inf.data = ai.model_dump(mode="json")
new_add_inf.scenario = new_scen
new_add_inf.save()
new_add_inf.url = "{}/additional-information/{}".format(
new_add_inf.scenario.url, new_add_inf.uuid
)
new_add_inf.save()
# Link Scenario
new_sr.scenarios.add(new_scen)
# Link to bt0005
pr = ParallelRule.objects.get(package=p, name="bt0005")
pr.simple_rules.add(new_sr)
# Update others
for rule_name, smirks in RULE_FIXES.items():
sr = SimpleAmbitRule.objects.get(package=p, name=rule_name)
sr.smirks = smirks
sr.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0027_alter_compound_aliases_and_more"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -1,63 +0,0 @@
# Generated by Django 6.0.3 on 2026-08-13 09:58
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0028_auto_20260812_0902"),
]
operations = [
migrations.CreateModel(
name="ReactionExplanation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now, editable=False, verbose_name="created"
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now, editable=False, verbose_name="modified"
),
),
("run_uuid", models.UUIDField()),
("run_start", models.DateTimeField()),
("exact", models.BooleanField(default=False)),
(
"reaction",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="epdb.reaction"
),
),
(
"rule",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="epdb.rule"),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="reaction",
name="explained_by",
field=models.ManyToManyField(
related_name="explained_reactions",
through="epdb.ReactionExplanation",
to="epdb.rule",
),
),
]

View File

@ -1,37 +0,0 @@
# Generated by Django 6.0.3 on 2026-08-14 07:41
from django.db import migrations
def forward_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
models = {}
for c in ContentType.objects.all():
try:
models[(c.app_label, c.model)] = apps.get_model(c.app_label, c.model)
except Exception:
pass
for ai in AdditionalInformation.objects.all():
if ai.url is None:
if ai.content_type is None:
ai.url = "{}/additional-information/{}".format(ai.scenario.url, ai.uuid)
else:
model = models[(ai.content_type.app_label, ai.content_type.model)]
obj = model.objects.get(pk=ai.object_id)
ai.url = "{}/additional-information/{}".format(obj.url, ai.uuid)
ai.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0029_reactionexplanation_reaction_explained_by"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -859,15 +859,9 @@ class Compound(
@property
def related_reactions(self):
return (
(
Reaction.objects.filter(package=self.package, educts__in=[self.default_structure])
| Reaction.objects.filter(
package=self.package, products__in=[self.default_structure]
)
)
.distinct()
.order_by("name")
)
Reaction.objects.filter(package=self.package, educts__in=[self.default_structure])
| Reaction.objects.filter(package=self.package, products__in=[self.default_structure])
).order_by("name")
@property
def related_nodes(self):
@ -1741,14 +1735,6 @@ class SequentialRuleOrdering(models.Model):
order_index = models.IntegerField(null=False, blank=False)
class ReactionExplanation(TimeStampedModel):
run_uuid = models.UUIDField(null=False, blank=False)
run_start = models.DateTimeField(null=False, blank=False)
reaction = models.ForeignKey("epdb.Reaction", on_delete=models.CASCADE)
rule = models.ForeignKey("epdb.Rule", on_delete=models.CASCADE)
exact = models.BooleanField(default=False)
class Reaction(
EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin, AdditionalInformationMixin
):
@ -1774,12 +1760,6 @@ class Reaction(
external_identifiers = GenericRelation("ExternalIdentifier")
explained_by = models.ManyToManyField(
"epdb.Rule",
through="ReactionExplanation",
related_name="explained_reactions",
)
def _url(self):
return "{}/reaction/{}".format(self.package.url, self.uuid)
@ -2193,7 +2173,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
row += [cs.smiles, cs.get_name(), n.depth]
edges = self.edges.filter(end_nodes=n)
edges = self.edges.filter(end_nodes__in=[n])
if len(edges):
for e in edges:
_row = row.copy()
@ -2486,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.get_scenarios()],
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
"app_domain": {
"inside_app_domain": app_domain_data["assessment"]["inside_app_domain"]
if app_domain_data
@ -2605,7 +2585,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
collected[str(ai.scenario.uuid)]["proposed"] = True
if ai.type == "Confidence":
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level.value
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level
if ai.type == "TransformationProductImportance":
collected[str(ai.scenario.uuid)]["Transformation product importance"] = (
@ -2614,15 +2594,6 @@ 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(
@ -2878,58 +2849,6 @@ class PackageBasedModel(EPModel):
return res
def parameters(self):
params = {
"Model Evaluation Threshold": f"{self.threshold:.2f}",
"Multi Gen Evaluation": "Yes" if self.multigen_eval else "No",
}
if self.app_domain:
params["Applicability Domain Num Neighbors"] = f"{self.app_domain.num_neighbours:.2f}"
params["Applicability Domain Reliability Threshold"] = (
f"{self.app_domain.reliability_threshold:.2f}"
)
params["Applicability Domain Local Compatibility Threshold"] = (
f"{self.app_domain.local_compatibilty_threshold:.2f}"
)
return params
def statistics(self):
from sklearn.metrics import auc
recall = list(self.eval_results["average_recall_per_threshold"].values())
precision = list(self.eval_results["average_precision_per_threshold"].values())
mg_recall = list(
self.eval_results.get("multigen_average_recall_per_threshold", {}).values()
)
mg_precision = list(
self.eval_results.get("multigen_average_precision_per_threshold", {}).values()
)
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}"
),
],
"Area under PR Curve": [
auc(recall, precision),
auc(mg_recall, mg_precision) if self.multigen_eval else None,
],
}
@cached_property
def applicable_rules(self) -> List["Rule"]:
"""
@ -3086,14 +3005,7 @@ class PackageBasedModel(EPModel):
prec, rec = dict(), dict()
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:
for t in np.arange(0, 1.05, 0.05):
temp_thresholded = (y_pred_filtered >= t).astype(int)
prec[f"{t:.2f}"] = precision_score(
y_test_filtered, temp_thresholded, zero_division=0
@ -3103,12 +3015,7 @@ class PackageBasedModel(EPModel):
return acc, prec, rec
def evaluate_mg(model, pathways: Union[QuerySet["Pathway"] | List["Pathway"]], threshold):
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()
thresholds = np.arange(0.1, 1.1, 0.1)
precision = {f"{t:.2f}": [] for t in thresholds}
recall = {f"{t:.2f}": [] for t in thresholds}
@ -3134,7 +3041,7 @@ class PackageBasedModel(EPModel):
s = Setting()
s.model = mod
s.model_threshold = 0.0
s.model_threshold = thresholds.min()
s.max_depth = 10
s.max_nodes = 50
@ -3158,17 +3065,14 @@ class PackageBasedModel(EPModel):
for t in thresholds:
for true, pred in zip(pathways, pred_pathways):
acc, pre, rec = multigen_eval(true, pred, t)
if f"{t:.2f}" == f"{threshold:.2f}":
mg_acc += acc
if abs(t - threshold) < 0.01:
mg_acc = acc
precision[f"{t:.2f}"].append(pre)
recall[f"{t:.2f}"].append(rec)
avg_mg_acc = mg_acc / len(root_compounds)
precision = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in precision.items()}
recall = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in recall.items()}
return avg_mg_acc, precision, recall
return mg_acc, precision, recall
# If there are eval packages perform single generation evaluation on them instead of random splits
if self.eval_packages.count() > 0:
@ -4300,9 +4204,6 @@ class ClassifierPluginModel(PackageBasedModel):
instance = impl(conf)
return instance
def parameters(self):
return self.instance().parameters()
def build_dataset(self):
"""
Required by general model contract but actual implementation resides in plugin.
@ -4523,9 +4424,6 @@ class PropertyPluginModel(PackageBasedModel):
instance = impl()
return instance
def parameters(self):
return self.instance().parameters()
def build_dataset(self):
"""
Required by general model contract but actual implementation resides in plugin.

View File

@ -477,7 +477,8 @@ def batch_predict(
limit=None,
setting_overrides={
"max_nodes": num_tps,
"model_threshold": 0.0,
"max_depth": num_tps,
"model_threshold": 0.001,
},
)

View File

@ -61,7 +61,6 @@ from .models import (
)
logger = logging.getLogger(__name__)
auth_log = logging.getLogger("auth")
Package = s.GET_PACKAGE_MODEL()
@ -72,18 +71,6 @@ def log_post_params(request):
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]:
current_user = _anonymous_or_real(request)
@ -160,20 +147,6 @@ 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()
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):
context = get_base_context(request)
@ -232,18 +205,11 @@ def login(request):
if user is not None:
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"):
return redirect(next)
return redirect(reverse("index"))
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!"
return render(request, "static/login.html", context)
else:
@ -424,7 +390,7 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
"external_databases": ExternalDatabase.get_databases(),
"site_id": s.MATOMO_SITE_ID,
# EDIT START
"secret_groups": Group.objects.filter(secret=True, user_member=current_user),
"secret_groups": Group.objects.filter(secret=True),
# EDIT END
},
}
@ -560,7 +526,6 @@ 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)
@ -1145,23 +1110,19 @@ def package_model(request, package_uuid, model_uuid):
for pr in pred_res:
if len(pr) > 0:
products = []
for prod_set in pr.product_sets:
logger.debug(f"Checking {prod_set}")
products.append(tuple([x for x in prod_set]))
products = list(set(products))
for prod in products:
res["pred"].append(
{
"products": list(prod),
"probability": pr.probability,
"btrule": {k: getattr(pr.rule, k) for k in ["url", "name"]}
if pr.rule is not None
else None,
}
)
res["pred"].append(
{
"products": list(set(products)),
"probability": pr.probability,
"btrule": {k: getattr(pr.rule, k) for k in ["url", "name"]}
if pr.rule is not None
else None,
}
)
# Sort data by prob desc
res["pred"] = sorted(
@ -1320,8 +1281,7 @@ 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.",
)
delete_with_log(request, current_package)
logger.debug(current_package.delete())
return redirect(s.SERVER_URL + "/package")
elif hidden == "publish-package":
for g in Group.objects.filter(public=True):
@ -1989,21 +1949,6 @@ 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(".")
@ -2312,7 +2257,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
elif request.method == "POST":
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
delete_with_log(request, current_pathway)
current_pathway.delete()
return redirect(current_package.url + "/pathway")
else:
return HttpResponseBadRequest()
@ -2530,7 +2475,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
delete_with_log(request, current_node)
current_node.delete()
return redirect(current_pathway.url)
else:
@ -2684,7 +2629,7 @@ def package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
delete_with_log(request, current_edge)
current_edge.delete()
return redirect(current_pathway.url)
if "selected-scenarios" in request.POST:
@ -3046,7 +2991,7 @@ def group(request, group_uuid):
if hidden := request.POST.get("hidden", None):
if hidden == "delete":
delete_with_log(request, current_group)
current_group.delete()
return redirect(s.SERVER_URL + "/group")
else:
return HttpResponseBadRequest()

View File

@ -120,6 +120,13 @@ 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
@ -138,16 +145,6 @@ 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(
@ -351,8 +348,7 @@ class PathwayMapper:
props = SoilPropertiesData()
for ai_obj in ai_list:
ai = ai_obj.get()
for ai in ai_list:
if isinstance(ai, SoilTexture1) and props.soil_type is None:
props.soil_type = ai.type.value
elif isinstance(ai, SoilTexture2):

View File

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

View File

@ -7,11 +7,6 @@ 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,
@ -19,24 +14,9 @@ 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()
@ -217,29 +197,3 @@ 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), 1)
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.reference_substances), 2)
self.assertEqual(len(bundle.endpoint_study_records), 1)
@ -49,7 +49,8 @@ class PathwayMapperTest(SimpleTestCase):
)
bundle = PathwayMapper().map(export)
self.assertEqual(len(bundle.substances), 1)
# 2 unique compounds -> 2 substances, 2 ref substances
self.assertEqual(len(bundle.substances), 2)
self.assertEqual(len(bundle.reference_substances), 2)
# One endpoint study record per pathway
self.assertEqual(len(bundle.endpoint_study_records), 1)

View File

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

View File

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

View File

@ -37,8 +37,7 @@
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
{{ batch_predict_max_compoundss|default:150 }} rows
Name (headers optional) • Maximum 30 rows
</div>
</div>
</div>
@ -196,7 +195,8 @@
// Function to populate table from CSV data
function populateTableFromCSV(csvData) {
const lines = csvData.trim().split("\n");
const maxRows = Number("{{ batch_predict_max_compounds|default:150 }}");
const maxRows = 30;
// Clear existing table
clearTable();

View File

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

View File

@ -51,10 +51,7 @@
</svg>
Go Home
</a>
<button
onclick="window.location.href = document.referrer"
class="btn btn-outline"
>
<button onclick="window.history.back()" 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_scenario_additional_information_modal"
id="add_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_scenario_additional_information_modal').close();
document.getElementById('add_additional_information_modal').close();
window.location.reload();
} catch (err) {
if (err.isValidationError && err.fieldErrors) {

View File

@ -1,276 +0,0 @@
{% 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

@ -117,39 +117,6 @@
</div>
</div>
{% endif %}
{% if model.parameters %}
<!-- Model Parameters Panel -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Model Parameters</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-3/5">Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for param, value in model.parameters.items %}
<tr>
<td>{{ param }}</td>
<td>{{ value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block usemodel %}

View File

@ -109,12 +109,12 @@
res += "<th scope='row'>" + cnt + "</th>";
res +=
"<th scope='row'>" +
data[transformation]["products"].join(", ") +
data[transformation]["products"][0].join(", ") +
"</th>";
res +=
"<th scope='row'>" +
"<img width='400' src='{% url 'depict' %}?smiles=" +
encodeURIComponent(data[transformation]["products"].join(".")) +
encodeURIComponent(data[transformation]["products"][0].join(".")) +
"'></th>";
res +=
"<th scope='row'>" +
@ -313,44 +313,6 @@
</div>
</div>
{% 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 %}
<script>
function makeChart(selector, data) {

View File

@ -139,7 +139,7 @@
</tr>
<tr>
<td>Model</td>
<td>{{ half_lifes.0.model.value }}</td>
<td>{{ half_lifes.0.model }}</td>
</tr>
</tbody>
</table>

View File

@ -4,8 +4,6 @@
{% 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%;
@ -99,7 +97,6 @@
{% 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" %}
@ -537,7 +534,7 @@
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Setting</div>
<div class="collapse-content">
{% with setting_to_render=pathway.setting_with_overrides can_be_default=False %}
{% with setting_to_render=pathway.setting can_be_default=False %}
{% include "objects/setting_template.html" %}
{% endwith %}
</div>

View File

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

View File

@ -95,23 +95,21 @@
</div>
<!-- Other Prediction Settings -->
{% if meta.available_settings|length > 1 %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
Other Prediction Settings
</div>
<div class="collapse-content space-y-3">
{% for setting in meta.available_settings %}
{% if setting != user.default_setting %}
{% with setting_to_render=setting can_be_default=True %}
{% include "objects/setting_template.html" %}
{% endwith %}
{% endif %}
{% endfor %}
</div>
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
Other Prediction Settings
</div>
{% endif %}
<div class="collapse-content space-y-3">
{% for setting in meta.available_settings %}
{% if setting != user.default_setting %}
{% with setting_to_render=setting can_be_default=True %}
{% include "objects/setting_template.html" %}
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div>
</div>
<script>

View File

@ -58,7 +58,7 @@ class MultiGenTest(TestCase):
self.assertEqual(
pw.setting_with_overrides.max_depth,
5,
f"{num_tps} (this is an override for this particular pathway)",
)
self.assertEqual(
pw.setting_with_overrides.max_nodes,

View File

@ -47,7 +47,7 @@ class ModelViewTest(TestCase):
expected = [
{
"products": ["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"],
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
"probability": 0.75,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
@ -55,7 +55,7 @@ class ModelViewTest(TestCase):
},
},
{
"products": ["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"],
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]],
"probability": 0.25,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/0e6e9290-b658-4450-b291-3ec19fa19206",
@ -63,7 +63,7 @@ class ModelViewTest(TestCase):
},
},
{
"products": ["CCNC(=O)C1=CC(CO)=CC=C1", "CC=O"],
"products": [["CCNC(=O)C1=CC(CO)=CC=C1", "CC=O"]],
"probability": 0.0,
"btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/27a3a353-0b66-4228-bd16-e407949e90df",

View File

@ -68,8 +68,6 @@ class PredictionResult(object):
class FormatConverter(object):
tautomer_enumerator = rdMolStandardize.TautomerEnumerator()
@staticmethod
def mass(smiles):
return Descriptors.MolWt(FormatConverter.from_smiles(smiles))
@ -242,9 +240,8 @@ class FormatConverter(object):
Chem.RemoveStereochemistry(res_mol)
if canonicalize_tautomers:
tautomers = FormatConverter.tautomer_enumerator.Enumerate(res_mol)
if len(tautomers) >= 1:
res_mol = FormatConverter.tautomer_enumerator.PickCanonical(tautomers)
te = rdMolStandardize.TautomerEnumerator() # idem
res_mol = te.Canonicalize(res_mol)
return Chem.MolToSmiles(res_mol, kekuleSmiles=True)
@ -392,7 +389,7 @@ class FormatConverter(object):
prods.append(p)
except ValueError as e:
logger.debug(f"Sanitizing and converting failed:\n{e}")
logger.error(f"Sanitizing and converting failed:\n{e}")
continue
if len(prods):
@ -400,8 +397,7 @@ class FormatConverter(object):
pss.add(ps)
except Exception as e:
logger.debug(f"Applying {smirks} on {smiles} failed:\n{e}")
pass
logger.error(f"Applying {smirks} on {smiles} failed:\n{e}")
return list(pss)
@ -448,7 +444,6 @@ class FormatConverter(object):
r_smiles: List[str],
standardize: bool = True,
canonicalize_tautomers: bool = True,
return_exact_match: bool = False,
) -> bool:
"""
Check if all SMILES in the left list are covered by (contained in) the right list.
@ -487,10 +482,8 @@ class FormatConverter(object):
if standardize:
for smi in l_smiles:
try:
smi = FormatConverter.canonicalize(
FormatConverter.standardize(
smi, remove_stereo=True, canonicalize_tautomers=canonicalize_tautomers
)
smi = FormatConverter.standardize(
smi, remove_stereo=True, canonicalize_tautomers=canonicalize_tautomers
)
except Exception:
# :shrug:
@ -504,12 +497,8 @@ class FormatConverter(object):
if standardize:
for smi in r_smiles:
try:
smi = FormatConverter.canonicalize(
FormatConverter.standardize(
smi,
remove_stereo=True,
canonicalize_tautomers=canonicalize_tautomers,
)
smi = FormatConverter.standardize(
smi, remove_stereo=True, canonicalize_tautomers=canonicalize_tautomers
)
except Exception:
# :shrug:
@ -518,11 +507,8 @@ class FormatConverter(object):
standardized_r_smiles.append(smi)
else:
standardized_r_smiles = r_smiles
if not return_exact_match:
return len(set(standardized_l_smiles).difference(set(standardized_r_smiles))) == 0
return len(set(standardized_l_smiles).difference(set(standardized_r_smiles))) == 0, set(
standardized_l_smiles
) == set(standardized_r_smiles)
return len(set(standardized_l_smiles).difference(set(standardized_r_smiles))) == 0
class Standardizer(ABC):

View File

@ -4,13 +4,11 @@ import hmac
import json
import logging
import uuid
from abc import ABC, abstractmethod
from collections import defaultdict
from datetime import datetime
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Type
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from django.conf import settings as s
from envipy_additional_information import EnviPyModel, UIConfig
from ninja import Schema
from pydantic import HttpUrl, ValidationError
@ -158,7 +156,7 @@ class EnzymeExportSchema(RefEnzymeExportSchema):
class EnzymeRuleExportSchema(RefRuleExportSchema):
enzymes: List[EnzymeExportSchema] = []
enzymes: List[EnzymeExportSchema] | None = None
@staticmethod
def resolve_enzymes(obj):
@ -321,20 +319,15 @@ class PackageExportSchema(Schema):
return AdditionalInformation.objects.filter(package=obj)
class Exporter(ABC):
class PackageExporter:
def __init__(self, package: Package):
self._raw_package = package
def do_export(self):
return self._export()
return PackageExporter._export_package_as_json(self._raw_package)
@abstractmethod
def _export(self):
pass
class PackageExporter(Exporter):
def _export(self) -> Dict[str, Any]:
@staticmethod
def _export_package_as_json(package: Package) -> Dict[str, Any]:
"""
Dumps a Package and all its related objects as JSON.
@ -345,126 +338,11 @@ class PackageExporter(Exporter):
Dict containing the complete package data as JSON-serializable structure
"""
data = PackageExportSchema.from_orm(self._raw_package)
data = PackageExportSchema.from_orm(package)
return data.model_dump(mode="json")
class PathwayExporter(Exporter):
def __init__(self, package: Package, add_infs_to_export: List[str] = []):
super().__init__(package)
self._add_infs_to_export = add_infs_to_export
def _flatten_additional_information(self, ai: AdditionalInformation) -> dict[str, Any]:
model_cls: Type[EnviPyModel] = type(ai.get())
def _flatten(d: dict, parent_key: str = "") -> dict[str, Any]:
items: dict[str, Any] = {}
for key, value in d.items():
new_key = f"{parent_key}__{key.lower()}" if parent_key else key.lower()
if isinstance(value, dict):
items.update(_flatten(value, new_key))
else:
items[new_key] = value
return items
flat = _flatten(ai.data, ai.type)
ui_class = getattr(model_cls, "UI", None)
if ui_class is None:
return flat
for f in model_cls.model_fields:
ui_info = getattr(ui_class, f, None)
if not isinstance(ui_info, UIConfig) or ui_info.unit is None:
continue
flat[f"{model_cls.__name__}__{f}__unit"] = ui_info.unit
return flat
def _export(self):
from io import StringIO
from csv import DictWriter
rows = []
for pw in self._raw_package.pathways.all():
for n in pw.nodes:
for scen in pw.scenarios.all():
row = {
"pathway_name": pw.name,
"pathway_id": str(pw.url),
"node_depth": n.depth,
"compound_id": str(n.default_node_label.compound.url),
"pubchem_ID": n.default_node_label.pubchem_compound_id,
"compound_name": n.default_node_label.compound.name,
"compound_smiles": n.default_node_label.smiles,
"scenario_id": str(scen.url),
"scenario_name": scen.name,
"scenario_type": scen.scenario_type,
"scenario_description": scen.description,
}
if self._add_infs_to_export:
ai_qs = AdditionalInformation.objects.filter(
scenario=scen, type__in=self._add_infs_to_export
)
else:
ai_qs = AdditionalInformation.objects.filter(scenario=scen)
for ai in ai_qs:
if ai.type == "ProposedIntermediate" and ai.content_object == n:
row.update({"proposed_intermediate": True})
elif ai.type == "SpikeCompound":
spike = {"SpikeCompound__url": ai.get().url}
try:
struc = CompoundStructure.objects.get(
compound__package=self._raw_package, url=ai.get().url
)
spike["SpikeCompound__smiles"] = struc.smiles
except Exception:
spike["SpikeCompound__smiles"] = None
row.update(**spike)
else:
row.update(self._flatten_additional_information(ai))
rows.append(row)
# Get all header fields
all_header_fields = set()
for row in rows:
all_header_fields.update(row.keys())
# Per request the CSV should start with these fields
header = [
"pathway_name",
"pathway_id",
"node_depth",
"compound_id",
"pubchem_ID",
"compound_name",
"compound_smiles",
"scenario_id",
"scenario_name",
"scenario_type",
"scenario_description",
]
# User remaining fields and place them after the predefined values in a sorted manner
remainder = sorted(list(all_header_fields.difference(set(header))))
header.extend(remainder)
buffer = StringIO()
writer = DictWriter(buffer, fieldnames=header, delimiter="\t")
writer.writeheader()
writer.writerows(rows)
buffer.seek(0)
return buffer.getvalue()
class PackageImporter:
def __init__(self, package: Dict[str, Any], preserve_uuids: bool = False):
self.preserve_uuids = preserve_uuids

View File

@ -238,11 +238,9 @@ class RuleBasedDataset(Dataset):
):
if feat_funcs is None:
feat_funcs = [FormatConverter.maccs]
_structures = set()
_structures = set() # Get all the structures
for r in reactions:
_structures.update(r.educts.all())
if not educts_only:
_structures.update(r.products.all())
@ -284,14 +282,17 @@ class RuleBasedDataset(Dataset):
if key not in triggered:
continue
if FormatConverter.smiles_covered_by(
[prod.smiles for prod in r.products.all()],
list(triggered[key]),
standardize=True,
canonicalize_tautomers=True,
):
# standardize products from reactions for comparison
standardized_products = []
for cs in r.products.all():
smi = cs.smiles
try:
smi = FormatConverter.standardize(smi, remove_stereo=True)
except Exception:
logger.debug(f"Standardizing SMILES failed for {smi}")
standardized_products.append(smi)
if len(set(standardized_products).difference(triggered[key])) == 0:
observed.add(key)
feat_columns = []
for feat_func in feat_funcs:
if isinstance(feat_func, Descriptor):
@ -300,7 +301,6 @@ class RuleBasedDataset(Dataset):
feats = feat_func(compounds[0].smiles)
start_i = len(feat_columns)
feat_columns.extend([f"feature_{start_i + i}" for i, _ in enumerate(feats)])
ds_columns = (
["structure_id"]
+ feat_columns
@ -334,9 +334,7 @@ class RuleBasedDataset(Dataset):
obs.append(None)
else:
obs.append(0)
rows.append([str(comp.uuid)] + feats + trig + obs)
ds = RuleBasedDataset(len(applicable_rules), ds_columns, data=rows)
return ds
@ -682,13 +680,9 @@ class RelativeReasoning:
def predict(self, X):
res = np.zeros((len(X), (self.end_index + 1 - self.start_index)))
immutable_res = np.zeros((len(X), (self.end_index + 1 - self.start_index)))
# Loop through all instances
for inst_idx, inst in enumerate(X):
for i, t in enumerate(inst[self.start_index : self.end_index + 1]):
immutable_res[inst_idx][i] = t
# Loop through all "triggered" features
for i, t in enumerate(inst[self.start_index : self.end_index + 1]):
# Set label
@ -702,7 +696,7 @@ class RelativeReasoning:
if i2 in self.winmap.get(i, []):
# if thatat rule also triggered, it dominated the current
# set label to 0
if immutable_res[inst_idx][i2]:
if X[inst_idx][i2]:
res[inst_idx][i] = 0
return res