forked from enviPath/enviPy
Compare commits
1 Commits
develop-ba
...
e5e0dcee3b
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e0dcee3b |
@ -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 }}
|
||||
@ -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"]
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
{% block action_modals %}
|
||||
{% include "modals/objects/edit_package_modal.html" %}
|
||||
{% include "modals/objects/view_package_permissions_modal.html" %}
|
||||
{% include "modals/objects/edit_package_permissions_modal.html" %}
|
||||
{% include "modals/objects/publish_package_modal.html" %}
|
||||
{% include "modals/objects/set_license_modal.html" %}
|
||||
|
||||
196
bayer/views.py
196
bayer/views.py
@ -1,40 +1,19 @@
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django.conf import settings as s
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
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
|
||||
|
||||
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,43 +34,29 @@ 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,
|
||||
"Could not fetch PES",
|
||||
f"Could not fetch PES data for {pes_link}"
|
||||
)
|
||||
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}")
|
||||
|
||||
classification = pes_data.get("classificationLevel", "")
|
||||
if "secret" == classification.lower():
|
||||
|
||||
if current_package.classification_level != Package.Classification.SECRET:
|
||||
return error(
|
||||
request,
|
||||
"Classification Mismatch!",
|
||||
"Cannot create secret PESs in non-secret packages."
|
||||
)
|
||||
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
|
||||
|
||||
if not current_package.data_pool or not current_package.data_pool.secret:
|
||||
logger.info(f"The current package does not have a secret data pool.")
|
||||
return error(
|
||||
request,
|
||||
"The current package does not have a secret data pool.",
|
||||
"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[current_package.data_pool.name] not in data_pools:
|
||||
return HttpResponseBadRequest(
|
||||
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
|
||||
|
||||
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
|
||||
|
||||
return redirect(pes.url)
|
||||
else:
|
||||
return error(
|
||||
request,
|
||||
"No PES link received",
|
||||
"Please provide a PES link."
|
||||
)
|
||||
return HttpResponseBadRequest("Please provide a PES link.")
|
||||
else:
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
pass
|
||||
|
||||
|
||||
@package_permission_required()
|
||||
@ -115,39 +80,25 @@ 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,
|
||||
"Could not fetch PES",
|
||||
f"Could not fetch PES data for {pes_link}"
|
||||
)
|
||||
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}")
|
||||
|
||||
classification = pes_data.get("classificationLevel", "")
|
||||
if "secret" == classification.lower():
|
||||
|
||||
if current_package.classification_level != Package.Classification.SECRET:
|
||||
return error(
|
||||
request,
|
||||
"Classification Mismatch!",
|
||||
"Cannot create secret PESs in non-secret packages."
|
||||
)
|
||||
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
|
||||
|
||||
if not current_package.data_pool or not current_package.data_pool.secret:
|
||||
logger.info(f"The current package does not have a secret data pool.")
|
||||
return error(
|
||||
request,
|
||||
"The current package does not have a secret data pool.",
|
||||
"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[current_package.data_pool.name] not in data_pools:
|
||||
return HttpResponseBadRequest(
|
||||
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
|
||||
|
||||
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
|
||||
|
||||
node_qs = Node.objects.filter(
|
||||
pathway=current_pathway,
|
||||
default_node_label=pes.default_structure
|
||||
)
|
||||
|
||||
node_qs = Node.objects.filter(pathway=current_pathway, default_node_label=pes.default_structure)
|
||||
if node_qs.exists():
|
||||
return redirect(current_pathway.url)
|
||||
|
||||
@ -165,91 +116,58 @@ def create_pes_node(request, package_uuid, pathway_uuid):
|
||||
return redirect(current_pathway.url)
|
||||
|
||||
else:
|
||||
return error(
|
||||
request,
|
||||
"No PES link received",
|
||||
"Please provide a PES link."
|
||||
)
|
||||
return HttpResponseBadRequest("Please provide a PES link.")
|
||||
else:
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
pass
|
||||
|
||||
|
||||
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_ENTRA_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')
|
||||
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
@ -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 "$@"
|
||||
@ -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"
|
||||
@ -445,8 +444,6 @@ if MS_ENTRA_ENABLED:
|
||||
MS_ENTRA_AUTHORITY = f"https://login.microsoftonline.com/{MS_ENTRA_TENANT_ID}"
|
||||
MS_ENTRA_REDIRECT_URI = os.environ["MS_REDIRECT_URI"]
|
||||
MS_ENTRA_SCOPES = os.environ.get("MS_SCOPES", "").split(",")
|
||||
NON_PROD_PES_SCOPE = os.environ.get("NON_PROD_PES_SCOPE")
|
||||
PROD_PES_SCOPE = os.environ.get("PROD_PES_SCOPE")
|
||||
|
||||
# Site ID 10 -> beta.envipath.org
|
||||
MATOMO_SITE_ID = os.environ.get("MATOMO_SITE_ID", "10")
|
||||
|
||||
@ -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(...)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
@ -1720,7 +1705,7 @@ class PathwayNode(Schema):
|
||||
image: str = Field(None, alias="image")
|
||||
imageSize: int = Field(None, alias="image_size")
|
||||
name: str = Field(None, alias="name")
|
||||
proposed: List[Dict[str, Any]] = []
|
||||
proposed: List[Dict[str, str]] = []
|
||||
smiles: str = Field(None, alias="smiles")
|
||||
pseudo: bool = Field(False, alias="pseudo")
|
||||
pesLink: str | None = Field(None, alias="pes_link")
|
||||
@ -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:
|
||||
@ -1990,7 +1975,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
|
||||
|
||||
|
||||
class CreateNode(Schema):
|
||||
nodeAsSmiles: str | None = None
|
||||
nodeAsSmiles: str
|
||||
nodeAsMolFile: str | None = None
|
||||
nodeName: str | None = None
|
||||
nodeReason: str | None = None
|
||||
@ -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}"}
|
||||
|
||||
@ -2022,10 +2007,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
|
||||
if "secret" == classification.lower():
|
||||
|
||||
if p.classification_level != Package.Classification.SECRET:
|
||||
return 400, {"message": "Cannot create secret PESs in non-secret packages."}
|
||||
return 400, "Cannot create PESs for 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, n.nodeName, n.nodeReason)
|
||||
|
||||
@ -2070,7 +2059,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 +2219,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 +2423,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!"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
126
epdb/models.py
126
epdb/models.py
@ -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.
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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), [])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,8 +23,6 @@
|
||||
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
@ -34,7 +31,6 @@
|
||||
<i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a
|
||||
>
|
||||
</li>
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
@ -52,13 +48,3 @@
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if not meta.can_edit %}
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('view_package_permissions_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-user"></i> View Permissions</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
@ -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>
|
||||
@ -1,147 +0,0 @@
|
||||
{% load static %}
|
||||
<!-- Edit Package Permissions -->
|
||||
<dialog
|
||||
id="view_package_permissions_modal"
|
||||
class="modal"
|
||||
x-data="{}"
|
||||
>
|
||||
<div class="modal-box max-w-2xl">
|
||||
<!-- Header -->
|
||||
<h3 class="text-lg font-bold">Current Permissions</h3>
|
||||
|
||||
<!-- Close button (X) -->
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="py-4">
|
||||
<p class="mb-4">
|
||||
Current permissions for this package.
|
||||
</p>
|
||||
|
||||
<!-- User Permissions -->
|
||||
{% if user_permissions %}
|
||||
<div class="divider">User Permissions</div>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-12 gap-2 items-center">
|
||||
<div class="col-span-5 truncate"></div>
|
||||
<div class="col-span-2 text-center">Read</div>
|
||||
<div class="col-span-2 text-center">Write</div>
|
||||
<div class="col-span-2 text-center">Owner</div>
|
||||
<div class="col-span-1"></div>
|
||||
</div>
|
||||
{% for up in user_permissions %}
|
||||
<div class="grid grid-cols-12 gap-2 items-center">
|
||||
<div class="col-span-5 truncate">
|
||||
{{ up.user.username }}
|
||||
{% if not up.user.is_active %}<i>(inactive)</i>{% endif %}
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="read"
|
||||
id="read_{{ up.user.uuid }}"
|
||||
class="checkbox"
|
||||
{% if up.has_read %}checked{% endif %}
|
||||
onclick="return false;"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="write"
|
||||
id="write_{{ up.user.uuid }}"
|
||||
class="checkbox"
|
||||
{% if up.has_write %}checked{% endif %}
|
||||
onclick="return false;"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="owner"
|
||||
id="owner_{{ up.user.uuid }}"
|
||||
class="checkbox"
|
||||
{% if up.has_all %}checked{% endif %}
|
||||
onclick="return false;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Group Permissions -->
|
||||
{% if group_permissions %}
|
||||
<div class="divider">Group Permissions</div>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-12 gap-2 items-center">
|
||||
<div class="col-span-5 truncate"></div>
|
||||
<div class="col-span-2 text-center">Read</div>
|
||||
<div class="col-span-2 text-center">Write</div>
|
||||
<div class="col-span-2 text-center">Owner</div>
|
||||
<div class="col-span-1"></div>
|
||||
</div>
|
||||
{% for gp in group_permissions %}
|
||||
|
||||
<div class="grid grid-cols-12 gap-2 items-center">
|
||||
<div class="col-span-5 truncate">
|
||||
{{ gp.group.name|safe }}
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="read"
|
||||
id="read_{{ gp.group.uuid }}"
|
||||
class="checkbox"
|
||||
{% if gp.has_read %}checked{% endif %}
|
||||
onclick="return false;"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="write"
|
||||
id="write_{{ gp.group.uuid }}"
|
||||
class="checkbox"
|
||||
{% if gp.has_write %}checked{% endif %}
|
||||
onclick="return false;"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="owner"
|
||||
id="owner_{{ gp.group.uuid }}"
|
||||
class="checkbox"
|
||||
{% if gp.has_all %}checked{% endif %}
|
||||
onclick="return false;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick="this.closest('dialog').close()"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
@ -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 %}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
@ -271,7 +269,7 @@ class PackageExportSchema(Schema):
|
||||
@staticmethod
|
||||
def resolve_classification_level(obj):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("classification_level", "Internal")
|
||||
return obj["classification_level"]
|
||||
return obj.Classification(obj.classification_level).name
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user