37 Commits

Author SHA1 Message Date
57f5ce089c Make links thicker, reduce timeout trigger time
Some checks failed
CI / test (pull_request) Failing after 20s
API CI / api-tests (pull_request) Failing after 38s
2026-07-09 20:39:48 +02:00
28e5dbbcf5 Unconnected Nodes 2026-07-09 20:30:27 +02:00
8620f98082 Implemented Compound Names / Reaction Names View Option 2026-07-09 20:18:28 +02:00
4ee37b5c71 provide setting link and copy button 2026-07-09 13:55:10 +02:00
c65be30b6b Fix alter name/desc for node, make /node /edge funcitonal 2026-07-09 13:33:53 +02:00
b5e3c1957b underline pes link in templates 2026-07-08 20:24:53 +02:00
3bb9220b22 Fix Classification import, Fix PES Deserialization 2026-07-08 20:20:36 +02:00
d06ed33196 removed debug 2026-07-08 10:46:45 +02:00
ea672ec91a PES Fields in API Output 2026-07-08 10:46:27 +02:00
dfc1309fd3 Added pesLink field to PW Api output 2026-07-08 10:39:01 +02:00
6e2dbccd6c Harmonised proposed field in Json output 2026-07-08 10:32:05 +02:00
b77fbc9b90 implemented non persistent 2026-07-06 15:44:23 +02:00
ce1d709715 highlight Description links
Some checks failed
CI / test (pull_request) Failing after 18s
API CI / api-tests (pull_request) Failing after 36s
2026-07-06 15:17:38 +02:00
b0004ea66e Package Export/Import cycle 2026-07-06 15:17:16 +02:00
2fbffb4b3b Fixed molfile assignment, adjusted Export 2026-07-03 09:40:47 +02:00
365af14448 Fix PES Link HTML for other entities 2026-07-03 09:29:53 +02:00
8017070de8 Fixed error return, removed unused options 2026-07-02 22:57:30 +02:00
a0ac79a897 Return proper http response instead of error
Some checks failed
API CI / api-tests (pull_request) Failing after 18s
CI / test (pull_request) Failing after 33s
2026-07-02 21:37:19 +02:00
caeec2ab79 Make PES Link clickable
Some checks failed
API CI / api-tests (pull_request) Failing after 17s
CI / test (pull_request) Failing after 37s
2026-07-02 21:25:57 +02:00
3525621281 adjusted migration
Initial bayer app

Show Pack Classification

Adjusted docker compose to bayer specifics

Adjusted Dockerfile for Bayer

Adding secret flags to group, add secret pools to packages

Adjusted View for Package creation

Prep configs, added Package Create Modal

wip

More on PES

wip

wip

Wip

minor

PW interactions

API PES

wip

Make Select Widget reflect required

make required generallay available

Update UI if pathway mode is set to build

Added ais

circle adjustments

Initial Zoom, fix AD Creation

wip

auth log, bb4g fix

missing import

Added viz hint if PES is part of reaction

Add Edge check for pes

flip boolean

...

pes

Added extra

...

In / Out Edges Viz, Submitting Button Text

...
2026-07-02 21:25:57 +02:00
9bc9f86ff1 [Fix] Pathway SVG Export has Edges again, Fix In/Out Edge coloring (#422)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#422
2026-07-03 07:24:02 +12:00
dba6514013 [Chore] Package Export, Blanks for fields, Styling (#421)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#421
2026-07-02 20:12:26 +12:00
a092d4a558 [Chore] Updated Dockerfile (#418)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#418
2026-06-17 08:33:02 +12:00
2502c020f7 [Fix] Collection of minor Bugfixes (#417)
User feedback from on-prem installation

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#417
2026-06-17 07:56:32 +12:00
6ab9180291 [Feature] Vizualize Reaction Circles (#410)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#410
2026-06-11 02:07:49 +12:00
3657d14659 [Fix] Cascade deletion of Reactions to Edges (#409)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#409
2026-06-11 00:24:06 +12:00
ef6091d416 [Fix] Set depth to -1 when adding a Node via Pathway.add_node to be in sync with legacy_api (#408)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#408
2026-06-09 23:55:16 +12:00
14cfc1e4d7 [Feature] Integrate DOI Links, Handle Cycles in Pathway Viz (#407)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#407
2026-06-03 06:00:38 +12:00
868bbf5c05 [Fix] False as multi_step default in legacy API (#406)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#406
2026-05-29 08:12:52 +12:00
be5ee1d1d7 [Fix] Propagate multi_step in Edge.create to Reaction.create to ensure deduplication is working (#405)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#405
2026-05-29 07:39:32 +12:00
20fd949dfd [Fix] Implement legacy PFASConfidence object construction (#404)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#404
2026-05-28 23:57:34 +12:00
c9b643fe6e [Feature] Make JobLog Page Paginated (#403)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#403
2026-05-28 23:01:27 +12:00
1a9f1cf9af [Fix] Legacy API Node Depth Parsing, description validation in Reaction.create (#402)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#402
2026-05-28 09:53:39 +12:00
c7c7e17e43 [Feature] Implement legacy endpoints to enable copy via python client (#400)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#400
2026-05-28 01:41:28 +12:00
674e10c7fa [Fix] Legacy API Package Endpoint, Pepper Unit in Pathway View (#399)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#399
2026-05-27 21:09:17 +12:00
8079b80d57 [Fix] Fix Typo in multi_step assignment (#397)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#397
2026-05-21 09:56:21 +12:00
76e63fda2c [Fix] Fix BART Upload (#396)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#396
2026-05-21 03:16:02 +12:00
60 changed files with 1613102 additions and 1262 deletions

View File

@ -6,18 +6,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
curl \ curl \
openssh-client \ openssh-client \
git \ git \
nodejs \ ca-certificates \
npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install pnpm # Install Node 22 + pnpm
RUN npm install -g pnpm RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& corepack enable \
&& corepack prepare pnpm@latest --activate \
&& rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}" ENV PATH="/root/.local/bin:${PATH}"

View File

@ -0,0 +1,178 @@
import enum
from typing import Optional
from envipy_additional_information import GroupEnum as G
from envipy_additional_information import SubcategoryEnum as S
from envipy_additional_information import (
register,
register_parser_command,
EnviPyModel,
# EnviPyModelParser,
Interval,
UIConfig,
IntervalConfig,
WidgetType,
registry,
)
@register(keyname="compoundlabel", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class CompoundLabel(EnviPyModel):
label: str
class UI:
title = "Compound Label"
label = UIConfig(widget=WidgetType.TEXT, label="Label", order=1)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("compoundlabel")
class CompoundLabelParser:
@staticmethod
def from_string(data: str) -> CompoundLabel:
return CompoundLabel(label=data)
@register(keyname="studywaterstoragecapacity", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class StudyWaterStorageCapacity(EnviPyModel):
capacity: str
class UI:
title = "Study Water Storage Capacity"
capacity = UIConfig(widget=WidgetType.TEXT, label="Study Water Storage Capacity", order=1)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("studywst")
class StudyWaterStorageCapacityParser:
@staticmethod
def from_string(data: str) -> StudyWaterStorageCapacity:
return StudyWaterStorageCapacity(capacity=data)
class ObservationType(enum.Enum):
OBSERVED = "observed"
APPLIED = "applied"
NA = 'NA'
@register(keyname="observation", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class Observation(EnviPyModel):
type: ObservationType
min_value: Optional[float] = None
max_value: Optional[float] = None
class UI:
title = "Observation"
type = UIConfig(widget=WidgetType.SELECT, label="Observed or Applied", order=1)
min_value = UIConfig(widget=WidgetType.NUMBER, label="Min Value", order=2)
max_value = UIConfig(widget=WidgetType.NUMBER, label="Max Value", order=3)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("observation")
class ObservationParser:
@staticmethod
def from_string(data: str) -> Observation:
parts = data.split(";")
observation_type = ObservationType(parts[0])
min_value = None
if parts[1]:
try:
min_value = float(parts[1])
except ValueError:
pass
max_value = None
if parts[2]:
try:
max_value = float(parts[2])
except ValueError:
pass
return Observation(type=observation_type, min_value=min_value, max_value=max_value)
@register(keyname="kinetics", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class Kinetics(EnviPyModel):
dt50: Interval[float]
normalized_dt50: bool
chi2err: Optional[float] = None
t_test: Optional[float] = None
swarc: Optional[float] = None
visual_fit: Optional[int] = None
comment: str
source: str
kinetic_model: str
k1: Optional[float] = None
k2: Optional[float] = None
g: Optional[float] = None
tb: Optional[float] = None
alpha: Optional[float] = None
beta: Optional[float] = None
class UI:
title = "Kinetics"
# Field config
dt50 = IntervalConfig(label="DT50 Range", order=1, unit="d")
normalized_dt50 = UIConfig(widget=WidgetType.CHECKBOX, label="Normalized DT50", order=2)
chi2err = UIConfig(widget=WidgetType.NUMBER, label="Chi2err", order=3)
t_test = UIConfig(widget=WidgetType.NUMBER, label="T-Test", order=4)
swarc = UIConfig(widget=WidgetType.NUMBER, label="SWARC", order=5)
visual_fit = UIConfig(widget=WidgetType.NUMBER, label="Visual Fit", order=6)
comment = UIConfig(widget=WidgetType.TEXTAREA, label="Comments", order=7)
source = UIConfig(widget=WidgetType.TEXT, label="Source", order=8)
kinetic_model = UIConfig(widget=WidgetType.SELECT, label="Kinetic Model", order=9)
k1 = UIConfig(widget=WidgetType.NUMBER, label="K1", order=10)
k2 = UIConfig(widget=WidgetType.NUMBER, label="K2", order=11)
g = UIConfig(widget=WidgetType.NUMBER, label="G", order=12)
tb = UIConfig(widget=WidgetType.NUMBER, label="TB", order=13)
alpha = UIConfig(widget=WidgetType.NUMBER, label="Alpha", order=14)
beta = UIConfig(widget=WidgetType.NUMBER, label="Beta", order=15)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("kineticevaluation")
class KinecticsParser:
@staticmethod
def from_string(data: str) -> Kinetics:
parts = data.split(";")
dt50 = registry.get_parser("interval").from_string(parts[0])
normalized_dt50 = parts[1] == "true"
chi2err = float(parts[2]) if parts[2] else None
t_test = float(parts[3]) if parts[3] else None
swarc = float(parts[4]) if parts[4] else None
visual_fit = int(parts[5]) if parts[5] else None
comment = parts[6]
source = parts[7]
kinetic_model = parts[8]
k1 = float(parts[9]) if parts[9] else None
k2 = float(parts[10]) if parts[10] else None
g = float(parts[11]) if parts[11] else None
tb = float(parts[12]) if parts[12] else None
alpha = float(parts[13]) if parts[13] else None
beta = float(parts[14]) if parts[14] else None
return Kinetics(
dt50=dt50,
normalized_dt50=normalized_dt50,
chi2err=chi2err,
t_test=t_test,
swarc=swarc,
visual_fit=visual_fit,
comment=comment,
source=source,
kinetic_model=kinetic_model,
k1=k1,
k2=k2,
g=g,
tb=tb,
alpha=alpha,
beta=beta,
)
if __name__ == '__main__':
print(KinecticsParser.from_string("187.0 - 187.0;false;;;;;;;AFO;;;;;;"))

View File

@ -1,5 +1,6 @@
import logging import logging
from bayer import additional_information # noqa: F401
from epdb.template_registry import register_template from epdb.template_registry import register_template
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -36,4 +37,4 @@ register_template(
register_template( register_template(
"epdb.objects.node.viz", "epdb.objects.node.viz",
"objects/node_viz.html", "objects/node_viz.html",
) )

View File

@ -185,7 +185,7 @@ class PESStructure(CompoundStructure):
def create( def create(
compound: Compound, compound: Compound,
pes_link: str, pes_link: str,
mol_file: str, molfile: str,
smiles: str, smiles: str,
name: str = None, name: str = None,
description: str = None, description: str = None,
@ -204,7 +204,7 @@ class PESStructure(CompoundStructure):
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
cs.smiles = smiles cs.smiles = smiles
cs.mol_file = mol_file cs.molfile = molfile
cs.pes_link = pes_link cs.pes_link = pes_link
cs.compound = compound cs.compound = compound
@ -232,5 +232,6 @@ class PESStructure(CompoundStructure):
"is_pes": True, "is_pes": True,
"pes_link": self.pes_link, "pes_link": self.pes_link,
# Will overwrite image from Node # Will overwrite image from Node
"image": f"{reverse("depict_pes")}?pesLink={urllib.parse.quote(self.pes_link)}" "image": f"{reverse('depict_pes')}?pesLink={urllib.parse.quote(self.pes_link)}",
"image_type": "png",
} }

View File

@ -3,7 +3,11 @@
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div> <div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ compound_structure.pes_link }}</div> <div class="collapse-content">
<p>
<a href="{{ compound_structure.pes_link }}" class="hover:bg-base-200">{{ compound_structure.pes_link }}</a>
</p>
</div>
</div> </div>
<!-- Image Representation --> <!-- Image Representation -->

View File

@ -3,7 +3,11 @@
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div> <div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ compound.default_structure.pes_link }}</div> <div class="collapse-content">
<p>
<a href="{{ compound.default_structure.pes_link }}" class="hover:bg-base-200">{{ compound.default_structure.pes_link }}</a>
</p>
</div>
</div> </div>
<!-- Image Representation --> <!-- Image Representation -->

View File

@ -3,7 +3,11 @@
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</div> <div class="collapse-title text-xl font-medium">Link to PES</div>
<div class="collapse-content">{{ node.default_node_label.pes_link }}</div> <div class="collapse-content">
<p>
<a href="{{ node.default_node_label.pes_link }}" class="hover:bg-base-200">{{ node.default_node_label.pes_link }}</a>
</p>
</div>
</div> </div>
<!-- Image Representation --> <!-- Image Representation -->

View File

@ -2,14 +2,13 @@ import base64
import requests import requests
from django.conf import settings as s from django.conf import settings as s
from django.core.exceptions import BadRequest from django.http import HttpResponse, HttpResponseBadRequest
from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from bayer.models import PESCompound from bayer.models import PESCompound
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Pathway, Node from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real from epdb.views import _anonymous_or_real, error
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
@ -23,7 +22,11 @@ def create_pes(request, package_uuid):
if request.method == "POST": if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL: if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.") return error(
request,
f'Creation of PESs for package {current_package.name} failed!',
"Creating PESs for internal packages is not allowed.",
)
compound_name = request.POST.get('compound-name') compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description') compound_description = request.POST.get('compound-description')
@ -33,21 +36,25 @@ def create_pes(request, package_uuid):
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link)
except ValueError as e: except ValueError as e:
return BadRequest(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", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest( return HttpResponseBadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data") 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) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
return redirect(pes.url) return redirect(pes.url)
else: else:
return BadRequest("Please provide a PES link.") return HttpResponseBadRequest("Please provide a PES link.")
else: else:
pass pass
@ -61,7 +68,11 @@ def create_pes_node(request, package_uuid, pathway_uuid):
if request.method == "POST": if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL: if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.") return error(
request,
f'Creation of PESs for package {current_package.name} failed!',
"Creating PESs for internal packages is not allowed.",
)
compound_name = request.POST.get('compound-name') compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description') compound_description = request.POST.get('compound-description')
@ -71,18 +82,26 @@ def create_pes_node(request, package_uuid, pathway_uuid):
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link)
except ValueError as e: except ValueError as e:
return BadRequest(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", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest( return HttpResponseBadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data") 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) 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)
if node_qs.exists():
return redirect(current_pathway.url)
n = Node() n = Node()
n.stereo_removed = False n.stereo_removed = False
n.pathway = current_pathway n.pathway = current_pathway
@ -97,7 +116,7 @@ def create_pes_node(request, package_uuid, pathway_uuid):
return redirect(current_pathway.url) return redirect(current_pathway.url)
else: else:
return BadRequest("Please provide a PES link.") return HttpResponseBadRequest("Please provide a PES link.")
else: else:
pass pass
@ -106,6 +125,9 @@ def fetch_pes(request, pes_url) -> dict:
from epauth.views import get_access_token_from_request from epauth.views import get_access_token_from_request
token = get_access_token_from_request(request) token = get_access_token_from_request(request)
if token is None:
token = pes_url.split('/')[-1] == 'dummy'
if token: if token:
for k, v in s.PES_API_MAPPING.items(): for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k): if pes_url.startswith(k):

View File

@ -1,12 +1,13 @@
import enum
import json import json
import logging
import math import math
from datetime import datetime from datetime import datetime
from typing import List from typing import List
import enum
import requests import requests
from django.conf import settings as s from django.conf import settings as s
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType from envipy_additional_information import register, EnviPyModel, UIConfig, WidgetType
from envipy_additional_information import register
from bridge.contracts import Classifier # noqa: I001 from bridge.contracts import Classifier # noqa: I001
from bridge.dto import ( from bridge.dto import (
@ -17,6 +18,9 @@ from bridge.dto import (
TransformationProductPrediction, TransformationProductPrediction,
) # noqa: I001 ) # noqa: I001
logger = logging.getLogger("epdb")
class SamplingAlgorithm(enum.Enum): class SamplingAlgorithm(enum.Enum):
EXACT = "exact" EXACT = "exact"
@ -85,14 +89,13 @@ class BB4G(Classifier):
} }
started = False started = False
retries = 0
while not started and retries < 5:
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None)
while not started:
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None)
logger.info(f"Starting BB4G: {res.status_code}")
if res.status_code == 200: if res.status_code == 200:
started = True started = True
elif res.status_code in [500, 502]: elif res.status_code in [500, 502]:
retries += 1
import time import time
time.sleep(5) time.sleep(5)
else: else:
@ -166,18 +169,30 @@ class BB4G(Classifier):
"cutoff": self.config.cutoff, "cutoff": self.config.cutoff,
} }
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=s.PROXIES or None) retries = 0
while retries < 100:
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data),
proxies=s.PROXIES or None)
resp.raise_for_status() if resp.status_code == 418:
retries += 1
logger.info(f"BB4G predict hit a 418, retrying in 60 seconds")
import time
time.sleep(3)
continue
for substrate, predictions in resp.json().items(): resp.raise_for_status()
preds = {}
for pred in predictions: for substrate, predictions in resp.json().items():
prod = pred["prediction"] preds = {}
prob = math.exp(pred["log_likelihood"])
preds[prod] = prob
result[substrate] = preds for pred in predictions:
prod = pred["prediction"]
prob = math.exp(pred["log_likelihood"])
preds[prod] = prob
result[substrate] = preds
break
return result return result

View File

@ -261,7 +261,6 @@ class Classifier(Plugin):
for k, v in data.items(): for k, v in data.items():
if v != "": if v != "":
cpy[k] = v cpy[k] = v
return cls.Config(**cpy) return cls.Config(**cpy)
@classmethod @classmethod

View File

@ -25,25 +25,14 @@ services:
- ep_bayer_redis_data:/data - ep_bayer_redis_data:/data
biotransformer3: biotransformer3:
image: envipath/biotransformer3:1.0 image: git.envipath.com/envipath/biotransformer3:1.0
container_name: epbiotransformer3 container_name: epbiotransformer3
# web:
# image: envipath/envipy-bayer:1.0
# container_name: epdjango
# ports:
# - "127.0.0.1:8000:8000"
# env_file:
# - .env
# command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
# volumes:
# - ep_bayer_data:/opt/enviPy/
celery_worker: celery_worker:
image: envipath/envipy-bayer:1.0 image: git.envipath.com/envipath/envipy-bayer:1.2
container_name: epcelery container_name: epcelery
env_file: env_file:
- .env.dev - .env
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes: volumes:
- ep_bayer_data:/opt/enviPy/ - ep_bayer_data:/opt/enviPy/

View File

@ -275,6 +275,12 @@ LOGGING = {
"filename": os.path.join(LOG_DIR, "debug.log"), "filename": os.path.join(LOG_DIR, "debug.log"),
"formatter": "simple", "formatter": "simple",
}, },
"auth_file": {
"level": "INFO", # Or higher
"class": "logging.FileHandler",
"filename": os.path.join(LOG_DIR, "auth.log"),
"formatter": "simple",
}
}, },
"loggers": { "loggers": {
# For everything under epdb/ loaded via getlogger(__name__) # For everything under epdb/ loaded via getlogger(__name__)
@ -295,6 +301,11 @@ LOGGING = {
"propagate": True, "propagate": True,
"level": os.environ.get("LOG_LEVEL", "INFO"), "level": os.environ.get("LOG_LEVEL", "INFO"),
}, },
"auth": {
"handlers": ["auth_file"],
"propagate": True,
"level": os.environ.get("LOG_LEVEL", "INFO"),
}
}, },
} }
@ -343,7 +354,7 @@ DEFAULT_MODEL_PARAMS = {
"num_chains": 10, "num_chains": 10,
} }
DEFAULT_MAX_NUMBER_OF_NODES = 50 DEFAULT_MAX_NUMBER_OF_NODES = 9999
DEFAULT_MAX_DEPTH = 8 DEFAULT_MAX_DEPTH = 8
DEFAULT_MODEL_THRESHOLD = 0.25 DEFAULT_MODEL_THRESHOLD = 0.25
@ -491,3 +502,5 @@ BB4G_TENANT_ID = os.environ.get("BB4G_TENANT_ID")
BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID") BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID")
BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET") BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET")
BB4G_SCOPE = os.environ.get("BB4G_SCOPE") BB4G_SCOPE = os.environ.get("BB4G_SCOPE")
os.environ["NO_PROXY"] = "localhost,127.0.0.1,epbiotransformer3"

View File

@ -0,0 +1,26 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from epdb.models import JobLog
from ..pagination import EnhancedPageNumberPagination
from ..schemas import JobLogOutSchema
router = Router()
@router.get("/joblog/", response=EnhancedPageNumberPagination.Output[JobLogOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_joblogs(request):
"""
List all JobLogs from reviewed packages.
"""
current_user = request.user
if current_user.is_superuser:
return JobLog.objects.all().order_by("-created")
else:
return JobLog.objects.filter(user=current_user).order_by("-created")

View File

@ -15,6 +15,7 @@ from .endpoints import (
additional_information, additional_information,
settings, settings,
groups, groups,
joblogs,
) )
# Main router with authentication # Main router with authentication
@ -37,6 +38,7 @@ router.add_router("", structure.router)
router.add_router("", additional_information.router) router.add_router("", additional_information.router)
router.add_router("", settings.router) router.add_router("", settings.router)
router.add_router("", groups.router) router.add_router("", groups.router)
router.add_router("", joblogs.router)
if s.IUCLID_EXPORT_ENABLED: if s.IUCLID_EXPORT_ENABLED:
from epiuclid.api import router as iuclid_router from epiuclid.api import router as iuclid_router

View File

@ -1,7 +1,10 @@
from ninja import FilterSchema, FilterLookup, Schema from datetime import datetime
from typing import Annotated, Optional, List, Dict, Any from typing import Annotated, Optional, List, Dict, Any
from uuid import UUID from uuid import UUID
from django.urls import reverse
from ninja import Field, FilterSchema, FilterLookup, Schema
# Filter schema for query parameters # Filter schema for query parameters
class ReviewStatusFilter(FilterSchema): class ReviewStatusFilter(FilterSchema):
@ -133,3 +136,23 @@ class GroupOutSchema(Schema):
url: str = "" url: str = ""
name: str name: str
description: str description: str
class SimpleUserOutSchema(Schema):
uuid: UUID
url: str
name: str = Field(alias="username")
class JobLogOutSchema(Schema):
user: SimpleUserOutSchema
id: UUID = Field(alias="task_id")
url: str
name: str = Field(alias="job_name")
created: datetime = Field(alias="created")
status: str = Field(alias="status")
done: Optional[datetime] = Field(None, alias="done_at")
@staticmethod
def resolve_url(obj):
return reverse("job detail", kwargs={"job_uuid": obj.task_id})

5
epdb/exceptions.py Normal file
View File

@ -0,0 +1,5 @@
class InvalidSMILESException(Exception):
pass
class PackageImportException(Exception):
pass

View File

@ -439,23 +439,50 @@ class PackageSchema(Schema):
@staticmethod @staticmethod
def resolve_readers(obj: Package): def resolve_readers(obj: Package):
users = User.objects.filter( readers = []
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.READ[0]
).values_list("user", flat=True)
).distinct()
return [{u.id: u.get_name()} for u in users] user_ids = UserPackagePermission.objects.filter(package=obj).values_list("user", flat=True)
users = User.objects.filter(id__in=user_ids).distinct()
for u in users:
readers.append({"id": str(u.url), "identifier": "user", "name": u.get_name()})
group_ids = GroupPackagePermission.objects.filter(package=obj).values_list(
"group", flat=True
)
groups = Group.objects.filter(id__in=group_ids).distinct()
for g in groups:
readers.append({"id": str(g.url), "identifier": "group", "name": g.get_name()})
return readers
@staticmethod @staticmethod
def resolve_writers(obj: Package): def resolve_writers(obj: Package):
users = User.objects.filter( writers = []
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.WRITE[0]
).values_list("user", flat=True)
).distinct()
return [{u.id: u.get_name()} for u in users] user_ids = UserPackagePermission.objects.filter(
package=obj,
permission__in=[UserPackagePermission.WRITE[0], UserPackagePermission.ALL[0]],
).values_list("user", flat=True)
users = User.objects.filter(id__in=user_ids).distinct()
for u in users:
writers.append({"id": str(u.url), "identifier": "user", "name": u.get_name()})
group_ids = GroupPackagePermission.objects.filter(
package=obj, permission=[UserPackagePermission.WRITE[0], UserPackagePermission.ALL[0]]
).values_list("group", flat=True)
groups = Group.objects.filter(id__in=group_ids).distinct()
for g in groups:
writers.append({"id": str(g.url), "identifier": "group", "name": g.get_name()})
return writers
@staticmethod @staticmethod
def resolve_review_comment(obj): def resolve_review_comment(obj):
@ -606,9 +633,14 @@ class CompoundSchema(Schema):
reviewStatus: str = Field(False, alias="review_status") reviewStatus: str = Field(False, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
structures: List["CompoundStructureSchema"] = [] structures: List["CompoundStructureSchema"] = []
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod @staticmethod
def resolve_review_status(obj: CompoundStructure): def resolve_pes_link(obj: Compound):
return getattr(obj.default_structure, "pes_link", None)
@staticmethod
def resolve_review_status(obj: Compound):
return "reviewed" if obj.package.reviewed else "unreviewed" return "reviewed" if obj.package.reviewed else "unreviewed"
@staticmethod @staticmethod
@ -682,6 +714,7 @@ class CompoundStructureSchema(Schema):
reviewStatus: str = Field(None, alias="review_status") reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios") scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
smiles: str = Field(None, alias="smiles") smiles: str = Field(None, alias="smiles")
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod @staticmethod
def resolve_review_status(obj: CompoundStructure): def resolve_review_status(obj: CompoundStructure):
@ -844,6 +877,10 @@ def create_package_compound(
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
return 400, {"Cannot create PESs for non-secret packages."}
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
@ -872,6 +909,27 @@ def delete_compound(request, package_uuid, compound_uuid):
} }
class CreateCompoundStructure(Schema):
smiles: str
name: str | None = None
description: str | None = None
inchi: str | None = None
molfile: str | None = None
@router.post("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure")
def create_package_compound_structure(
request, package_uuid, compound_uuid, structure: Form[CreateCompoundStructure]
):
try:
p = get_package_for_write(request.user, package_uuid)
c = Compound.objects.get(package=p, uuid=compound_uuid)
cs = CompoundStructure.create(c, structure.smiles, structure.name, structure.description)
return redirect(cs.url)
except ValueError as e:
return 400, {"message": str(e)}
@router.delete( @router.delete(
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}" "/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
) )
@ -1398,6 +1456,7 @@ class ScenarioSchema(Schema):
aliases: List[str] = Field([], alias="aliases") aliases: List[str] = Field([], alias="aliases")
collection: Dict["str", List[Dict[str, Any]]] = Field([], alias="collection") collection: Dict["str", List[Dict[str, Any]]] = Field([], alias="collection")
collectionID: Optional[str] = None collectionID: Optional[str] = None
date: str = Field(None, alias="scenario_date")
description: str = Field(None, alias="description") description: str = Field(None, alias="description")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
identifier: str = "scenario" identifier: str = "scenario"
@ -1541,28 +1600,56 @@ def create_package_additional_information(request, package_uuid):
scen = request.POST.get("scenario") scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen) scenario = Scenario.objects.get(package=p, url=scen)
url_parser = EPDBURLParser(request.POST.get("attach_obj")) if request.POST.get("adInfoTypes[]"):
attach_obj = url_parser.get_object() url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"): if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!") raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url): if not attach_obj.url.startswith(p.url):
raise ValueError( raise ValueError(
"Additional Information can only be set to objects stored in the same package!" "Additional Information can only be set to objects stored in the same package!"
) )
types = request.POST.get("adInfoTypes[]", "").split(",") types = request.POST.get("adInfoTypes[]", "").split(",")
for t in types: for t in types:
ai = build_additional_information_from_request(request, t) ai = build_additional_information_from_request(request, t)
AdditionalInformation.create( AdditionalInformation.create(
p, p,
ai, ai,
scenario=scenario, scenario=scenario,
content_object=attach_obj, content_object=attach_obj,
) )
elif request.POST.get("ais"):
import json
parsed_ais = json.loads(request.POST.get("ais"))
for ai_type, ais in parsed_ais.items():
for ai in ais:
attach_obj = None
if ai.get("related"):
url_parser = EPDBURLParser(ai.get("related").get("url"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
AdditionalInformation.create(
p,
AdditionalInformation.from_dict(ai_type, ai),
scenario=scenario,
content_object=attach_obj,
)
# TODO implement additional information endpoint ? # TODO implement additional information endpoint ?
return redirect(f"{scenario.url}") return redirect(f"{scenario.url}")
@ -1606,13 +1693,15 @@ class PathwayNode(Schema):
dt50s: List[Dict[str, str]] = Field([], alias="dt50s") dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate") engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
idcomp: str = Field(None, alias="default_node_label.url") idcomp: str = Field(None, alias="node_label_id")
idreact: str = Field(None, alias="default_node_label.url") idreact: str = Field(None, alias="node_label_id")
image: str = Field(None, alias="image") image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size") imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate") proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate")
smiles: str = Field(None, alias="default_node_label.smiles") smiles: str = Field(None, alias="smiles")
pseudo: bool = Field(False, alias="pseudo")
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod @staticmethod
def resolve_atom_count(obj: Node): def resolve_atom_count(obj: Node):
@ -1625,24 +1714,10 @@ class PathwayNode(Schema):
# TODO # TODO
return [] return []
@staticmethod
def resolve_engineered_intermediate(obj: Node):
# TODO
return False
@staticmethod
def resolve_image(obj: Node):
return f"{obj.default_node_label.url}?image=svg"
@staticmethod @staticmethod
def resolve_image_size(obj: Node): def resolve_image_size(obj: Node):
return 400 return 400
@staticmethod
def resolve_proposed_intermediate(obj: Node):
# TODO
return []
class PathwaySchema(Schema): class PathwaySchema(Schema):
aliases: List[str] = Field([], alias="aliases") aliases: List[str] = Field([], alias="aliases")
@ -1766,6 +1841,29 @@ def create_package_pathway(
return 403, {"message": str(e)} return 403, {"message": str(e)}
@router.post("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def update_pathway(request, package_uuid, pathway_uuid):
try:
p = get_package_for_write(request.user, package_uuid)
if request.POST.get("scenario"):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
scen = Scenario.objects.get(package=p, url=request.POST.get("scenario"))
pw.scenarios.add(scen)
pw.save()
return redirect(f"{pw.url}")
else:
return 400, {"message": "No scenario specified!"}
except ValueError:
return 403, {
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}") @router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def delete_pathway(request, package_uuid, pathway_uuid): def delete_pathway(request, package_uuid, pathway_uuid):
try: try:
@ -1879,30 +1977,42 @@ class CreateNode(Schema):
@router.post( @router.post(
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node", "/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node",
response={200: str | Any, 403: Error}, response={200: str | Any, 400: Error, 403: Error},
) )
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]): def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
try: try:
p = get_package_for_write(request.user, package_uuid) p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid) pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
# TODO Code Dup from bayer.views
if n.pesLink: if n.pesLink:
from bayer.views import fetch_pes from bayer.views import fetch_pes
from bayer.models import PESCompound from bayer.models import PESCompound
try: try:
pes_data = fetch_pes(request, c.pesLink) pes_data = fetch_pes(request, n.pesLink)
except ValueError as e: except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"} return 400, {"message": f"Could not fetch PES data for {n.pesLink}"}
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
return 400, "Cannot create PESs for non-secret packages."
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in 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"} return 400, {
"messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"
}
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription) c = PESCompound.create(p, pes_data, n.nodeName, n.nodeReason)
node_qs = Node.objects.filter(pathway=pw, default_node_label=c.default_structure)
if node_qs.exists():
return redirect(pw.url)
node = Node() node = Node()
node.stereo_removed = False node.stereo_removed = False
@ -2065,6 +2175,10 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
for pr in e.products.split(","): for pr in e.products.split(","):
products.append(Node.objects.get(pathway=pw, url=pr.strip())) products.append(Node.objects.get(pathway=pw, url=pr.strip()))
multi_step = False
if e.multistep and e.multistep.strip() == "true":
multi_step = True
new_e = Edge.create( new_e = Edge.create(
pathway=pw, pathway=pw,
start_nodes=educts, start_nodes=educts,
@ -2072,6 +2186,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
rule=None, rule=None,
name=None, name=None,
description=e.edgeReason, description=e.edgeReason,
multi_step=multi_step,
) )
# Update depths as sideeffect of above operation # Update depths as sideeffect of above operation
@ -2270,3 +2385,31 @@ def get_setting(request, setting_uuid):
return 403, { return 403, {
"message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!" "message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!"
} }
########
# Util #
########
class NonPersistent(Schema):
smiles: str
setting_url: str = Field(..., alias="settingUri")
@router.post("/util", response={200: Any, 403: Error})
def predict(request, np: Form[NonPersistent]):
try:
print(request.user)
print(np.setting_url)
setting = SettingManager.get_setting_by_url(request.user, np.setting_url)
from epdb.logic import SPathway
spw = SPathway(prediction_setting=setting, root_nodes=[np.smiles])
spw.predict()
return spw.to_json()
except ValueError:
return 403, {
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
}

View File

@ -1078,10 +1078,8 @@ class PackageManager(object):
data: Dict[str, Any], data: Dict[str, Any],
owner: User, owner: User,
preserve_uuids=False, preserve_uuids=False,
add_import_timestamp=True,
trust_reviewed=False,
) -> Package: ) -> Package:
importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed) importer = PackageImporter(data, preserve_uuids)
imported_package = importer.do_import() imported_package = importer.do_import()
up = UserPackagePermission() up = UserPackagePermission()
@ -1898,6 +1896,12 @@ class SPathway(object):
"to": to_indices, "to": to_indices,
} }
if edge.rule:
e["rule"] = edge.rule.simple_json()
if edge.probability:
e["probability"] = edge.probability
edges.append(e) edges.append(e)
return { return {

View File

@ -99,11 +99,15 @@ class Command(BaseCommand):
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png" new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
new_license.save() new_license.save()
def import_package(self, data, owner): def import_package(self, data, owner, all_envipath_user_group):
return PackageManager.import_legacy_package( p = PackageManager.import_legacy_package(
data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True
) )
PackageManager.grant_read(owner, p, all_envipath_user_group)
return p
def create_default_setting(self, owner, packages): def create_default_setting(self, owner, packages):
s = SettingManager.create_setting( s = SettingManager.create_setting(
owner, owner,
@ -198,7 +202,7 @@ class Command(BaseCommand):
s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8" s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8"
).read() ).read()
) )
imported_package = self.import_package(package_data, admin) imported_package = self.import_package(package_data, admin, g)
mapping[p.replace(".json", "")] = imported_package mapping[p.replace(".json", "")] = imported_package
setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]]) setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]])

View File

@ -23,6 +23,7 @@ MAPPING = {
"true": HalfLifeModel.SFO, "true": HalfLifeModel.SFO,
"SFO-SFO": HalfLifeModel.SFO_SFO, "SFO-SFO": HalfLifeModel.SFO_SFO,
"DFOP-SFO": HalfLifeModel.DFOP_SFO, "DFOP-SFO": HalfLifeModel.DFOP_SFO,
"other": HalfLifeModel.OTHER,
} }

View File

@ -0,0 +1,48 @@
# Generated by Django 6.0.3 on 2026-06-02 17:18
from django.db import migrations
from envipy_additional_information import DOI
def forward_func(apps, schema_editor):
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
refs = AdditionalInformation.objects.filter(type="Reference")
remaining = []
for ref in refs:
r = ref.data["reference"]
try:
# PubMed IDs are plain ints, try parsing
_ = int(r)
# Nothing to do
except ValueError:
DOMAINS = [
"http://dx.doi.org/",
"https://dx.doi.org/",
"http://doi.org/",
"https://doi.org/",
]
for d in DOMAINS:
r = r.replace(d, "")
if r.startswith("10."):
ref.type = DOI.__name__
ref.data = {"doi": r}
ref.save()
else:
remaining.append(ref)
if len(remaining) > 0:
raise ValueError(f"Could not parse {len(remaining)} references")
class Migration(migrations.Migration):
dependencies = [
("epdb", "0025_auto_20260511_2025"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,150 @@
# Generated by Django 6.0.3 on 2026-07-01 20:59
import django.contrib.postgres.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0026_auto_20260602_1718"),
]
operations = [
migrations.AlterField(
model_name="compound",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="compound",
name="default_structure",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="compound_default_structure",
to="epdb.compoundstructure",
verbose_name="Default Structure",
),
),
migrations.AlterField(
model_name="compound",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="compoundstructure",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="compoundstructure",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="edge",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="edge",
name="edge_label",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="epdb.reaction",
verbose_name="Edge label",
),
),
migrations.AlterField(
model_name="edge",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="node",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="node",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="pathway",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="pathway",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="reaction",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="reaction",
name="medline_references",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(),
blank=True,
null=True,
verbose_name="Medline References",
),
),
migrations.AlterField(
model_name="reaction",
name="rules",
field=models.ManyToManyField(
blank=True, related_name="reaction_rule", to="epdb.rule", verbose_name="Rule"
),
),
migrations.AlterField(
model_name="reaction",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
migrations.AlterField(
model_name="rule",
name="aliases",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
),
),
migrations.AlterField(
model_name="rule",
name="scenarios",
field=models.ManyToManyField(
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
),
),
]

View File

@ -31,6 +31,7 @@ from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property from bridge.contracts import Property
from bridge.dto import RunResult, PropertyPrediction from bridge.dto import RunResult, PropertyPrediction
from epdb.exceptions import InvalidSMILESException
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import ( from utilities.ml import (
ApplicabilityDomainPCA, ApplicabilityDomainPCA,
@ -576,6 +577,10 @@ class ReactionIdentifierMixin(ExternalIdentifierMixin):
def get_uniprot_identifiers(self): def get_uniprot_identifiers(self):
return self.get_external_identifier("UniProt") return self.get_external_identifier("UniProt")
def contains_pes(self):
from bayer.models import PESStructure
return any([isinstance(o, PESStructure) for o in self.educts.all()]) or any(
[isinstance(o, PESStructure) for o in self.products.all()])
############## ##############
# EP Objects # # EP Objects #
@ -632,7 +637,7 @@ class EnviPathModel(TimeStampedModel):
class AliasMixin(models.Model): class AliasMixin(models.Model):
aliases = ArrayField( aliases = ArrayField(
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list models.TextField(blank=False, null=False), verbose_name="Aliases", default=list, blank=True
) )
@transaction.atomic @transaction.atomic
@ -655,7 +660,9 @@ class AliasMixin(models.Model):
class ScenarioMixin(models.Model): class ScenarioMixin(models.Model):
scenarios = models.ManyToManyField("epdb.Scenario", verbose_name="Attached Scenarios") scenarios = models.ManyToManyField(
"epdb.Scenario", verbose_name="Attached Scenarios", blank=True
)
@transaction.atomic @transaction.atomic
def set_scenarios(self, scenarios: List["Scenario"]): def set_scenarios(self, scenarios: List["Scenario"]):
@ -781,7 +788,7 @@ class Compound(
"CompoundStructure", "CompoundStructure",
verbose_name="Default Structure", verbose_name="Default Structure",
related_name="compound_default_structure", related_name="compound_default_structure",
on_delete=models.CASCADE, on_delete=models.SET_NULL,
null=True, null=True,
) )
@ -858,13 +865,17 @@ class Compound(
package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs
) -> "Compound": ) -> "Compound":
if smiles is None or smiles.strip() == "": if smiles is None or smiles.strip() == "":
raise ValueError("SMILES is required") raise InvalidSMILESException("SMILES is required")
smiles = smiles.strip() smiles = smiles.strip()
parsed = FormatConverter.from_smiles(smiles) parsed = FormatConverter.from_smiles(smiles)
if parsed is None: if parsed is None:
raise ValueError("Given SMILES is invalid") raise InvalidSMILESException("Given SMILES is invalid")
if name is not None:
# Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True) standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
@ -876,7 +887,14 @@ class Compound(
# Check if we find a direct match for a given SMILES # Check if we find a direct match for a given SMILES
if qs.exists(): if qs.exists():
return qs.first().compound found_structure = qs.first()
found_compound = found_structure.compound
if name:
found_structure.add_alias(name)
found_compound.add_alias(name)
return found_compound
qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package) qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package)
@ -886,16 +904,19 @@ class Compound(
# Check if we can find the standardized one # Check if we can find the standardized one
if qs.exists(): if qs.exists():
# TODO should we add a structure? # TODO should we add a structure?
return qs.first().compound found_structure = qs.first()
found_compound = found_structure.compound
if name:
found_structure.add_alias(name)
found_compound.add_alias(name)
return found_compound
# Generate Compound # Generate Compound
c = Compound() c = Compound()
c.package = package c.package = package
if name is not None:
# Clean for potential XSS
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "": if name is None or name == "":
name = f"Compound {Compound.objects.filter(package=package).count() + 1}" name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
@ -1146,18 +1167,27 @@ class CompoundStructure(
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
compound: Compound, smiles: str, name: str = None, description: str = None, *args, **kwargs compound: Compound, smiles: str, name: str = None, description: str = None, molfile: str = None, *args, **kwargs
): ):
# Clean for potential XSS
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if CompoundStructure.objects.filter(compound=compound, smiles=smiles).exists(): if CompoundStructure.objects.filter(compound=compound, smiles=smiles).exists():
return CompoundStructure.objects.get(compound=compound, smiles=smiles) found_cs = CompoundStructure.objects.get(compound=compound, smiles=smiles)
if name:
found_cs.add_alias(name)
return found_cs
if compound.pk is None: if compound.pk is None:
raise ValueError("Unpersisted Compound! Persist compound first!") raise ValueError("Unpersisted Compound! Persist compound first!")
cs = CompoundStructure() cs = CompoundStructure()
# Clean for potential XSS
if name is not None: if name is not None:
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() cs.name = name
if description is not None: if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1165,6 +1195,10 @@ class CompoundStructure(
cs.smiles = smiles cs.smiles = smiles
cs.compound = compound cs.compound = compound
# Check if molfile is present and valid
if molfile is not None and FormatConverter.from_molfile(molfile) is not None:
cs.molfile = molfile
if "normalized_structure" in kwargs: if "normalized_structure" in kwargs:
cs.normalized_structure = kwargs["normalized_structure"] cs.normalized_structure = kwargs["normalized_structure"]
@ -1182,6 +1216,8 @@ class CompoundStructure(
@property @property
def as_svg(self, width: int = 800, height: int = 400): def as_svg(self, width: int = 800, height: int = 400):
if self.molfile is not None and self.molfile.strip() != "":
return IndigoUtils.mol_to_svg(self.molfile, width=width, height=height)
return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height) return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height)
@property @property
@ -1379,6 +1415,9 @@ class SimpleAmbitRule(SimpleRule):
if not FormatConverter.is_valid_smirks(smirks): if not FormatConverter.is_valid_smirks(smirks):
raise ValueError(f'SMIRKS "{smirks}" is invalid!') raise ValueError(f'SMIRKS "{smirks}" is invalid!')
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
query = SimpleAmbitRule.objects.filter(package=package, smirks=smirks) query = SimpleAmbitRule.objects.filter(package=package, smirks=smirks)
if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "": if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "":
@ -1390,14 +1429,17 @@ class SimpleAmbitRule(SimpleRule):
if query.exists(): if query.exists():
if query.count() > 1: if query.count() > 1:
logger.error(f"More than one rule matched this one! {query}") logger.error(f"More than one rule matched this one! {query}")
return query.first()
found_rule = query.first()
if name:
found_rule.add_alias(name)
return found_rule
r = SimpleAmbitRule() r = SimpleAmbitRule()
r.package = package r.package = package
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "": if name is None or name == "":
name = f"Rule {Rule.objects.filter(package=package).count() + 1}" name = f"Rule {Rule.objects.filter(package=package).count() + 1}"
@ -1618,10 +1660,15 @@ class Reaction(
products = models.ManyToManyField( products = models.ManyToManyField(
"epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products" "epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products"
) )
rules = models.ManyToManyField("epdb.Rule", verbose_name="Rule", related_name="reaction_rule") rules = models.ManyToManyField(
"epdb.Rule", verbose_name="Rule", related_name="reaction_rule", blank=True
)
multi_step = models.BooleanField(verbose_name="Multistep Reaction") multi_step = models.BooleanField(verbose_name="Multistep Reaction")
medline_references = ArrayField( medline_references = ArrayField(
models.TextField(blank=False, null=False), null=True, verbose_name="Medline References" models.TextField(blank=False, null=False),
null=True,
verbose_name="Medline References",
blank=True,
) )
external_identifiers = GenericRelation("ExternalIdentifier") external_identifiers = GenericRelation("ExternalIdentifier")
@ -1638,8 +1685,13 @@ class Reaction(
educts: Union[List[str], List[CompoundStructure]] = None, educts: Union[List[str], List[CompoundStructure]] = None,
products: Union[List[str], List[CompoundStructure]] = None, products: Union[List[str], List[CompoundStructure]] = None,
rules: Union[Rule | List[Rule]] = None, rules: Union[Rule | List[Rule]] = None,
multi_step: bool = True, multi_step: bool = False,
): ):
# Clean for potential XSS
if name is not None and name.strip() != "":
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
_educts = [] _educts = []
_products = [] _products = []
@ -1694,16 +1746,21 @@ class Reaction(
logger.error( logger.error(
f"Found more than one reaction for given input! {existing_reaction_qs}" f"Found more than one reaction for given input! {existing_reaction_qs}"
) )
return existing_reaction_qs.first()
found_reaction = existing_reaction_qs.first()
if name:
found_reaction.add_alias(name)
return found_reaction
r = Reaction() r = Reaction()
r.package = package r.package = package
# Clean for potential XSS if r is not None:
if name is not None and name.strip() != "": r.name = name
r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None and name.strip() != "": if description is not None and description.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
r.multi_step = multi_step r.multi_step = multi_step
@ -1781,7 +1838,7 @@ class Reaction(
return new_reaction return new_reaction
def smirks(self): def smirks(self):
return f"{'.'.join([cs.smiles for cs in self.educts.all()])}>>{'.'.join([cs.smiles for cs in self.products.all()])}" return f"{'.'.join([cs.smiles for cs in self.educts.all().order_by('-pk')])}>>{'.'.join([cs.smiles for cs in self.products.all().order_by('-pk')])}"
@property @property
def as_svg(self): def as_svg(self):
@ -1894,6 +1951,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
if n not in queue: if n not in queue:
queue.append(n) queue.append(n)
for i in queue:
processed.add(i)
while len(queue): while len(queue):
current = queue.pop() current = queue.pop()
processed.add(current) processed.add(current)
@ -2171,7 +2231,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
smiles: str, smiles: str,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
depth: Optional[int] = 0, depth: Optional[int] = -1,
): ):
return Node.create(self, smiles, depth, name=name, description=description) return Node.create(self, smiles, depth, name=name, description=description)
@ -2200,18 +2260,28 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
depth_map = {} depth_map = {}
depth_map[0] = list() depth_map[0] = list()
processed = set()
for n in self.nodes: data_driven_root_nodes = (
num_parents = in_count[str(n.uuid)] self.node_set.all()
if num_parents == 0: .annotate(prod_cnt=Count("edge_products"), educt_cnt=Count("edge_educts"))
# must be a root node or unconnected node .filter(prod_cnt=0, educt_cnt__gt=0)
if n.depth != 0: .distinct()
n.depth = 0 )
n.save()
# Only root node may have children # Eval QuerySet
if out_count[str(n.uuid)] > 0: root_nodes_by_depth = list(self.root_nodes)
depth_map[0].append(n)
data_driven_root_nodes.update(depth=0)
root_nodes = [n for n in data_driven_root_nodes]
for n in root_nodes_by_depth:
if n not in root_nodes:
if len(n.edge_products.all()) == 0:
root_nodes.append(n)
for n in root_nodes:
depth_map[0].append(n)
# At most depth len(nodes) is possible # At most depth len(nodes) is possible
for i in range(self.nodes.count()): for i in range(self.nodes.count()):
@ -2222,17 +2292,19 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
unique_next_level = set() unique_next_level = set()
for n in level_nodes: for n in level_nodes:
processed.add(n)
for e in self.edges: for e in self.edges:
if n in e.start_nodes.all(): if n in e.start_nodes.all():
for p in e.end_nodes.all(): for p in e.end_nodes.all():
unique_next_level.add(p) if p not in processed:
unique_next_level.add(p)
if len(unique_next_level) > 0: if len(unique_next_level) > 0:
depth_map[i + 1] = list(unique_next_level) depth_map[i + 1] = list(unique_next_level)
for depth, nodes in depth_map.items(): for depth, nodes in depth_map.items():
for n in nodes: for n in nodes:
if n.depth != depth: if n.depth != depth and depth != 0:
n.depth = depth n.depth = depth
n.save() n.save()
@ -2288,7 +2360,12 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"node_label_id": self.default_node_label.url, "node_label_id": self.default_node_label.url,
"image": f"{self.url}?image=svg", "image": f"{self.url}?image=svg",
"image_svg": IndigoUtils.mol_to_svg( "image_svg": IndigoUtils.mol_to_svg(
self.default_node_label.smiles, width=40, height=40 self.default_node_label.molfile
if self.default_node_label.molfile is not None
and self.default_node_label.molfile.strip()
else self.default_node_label.smiles,
width=40,
height=40,
), ),
"image_type": "svg", "image_type": "svg",
"name": self.get_name(), "name": self.get_name(),
@ -2302,6 +2379,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
}, },
"predicted_properties": predicted_properties, "predicted_properties": predicted_properties,
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False), "is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"proposed": self.is_proposed_intermediate(),
"timeseries": self.get_timeseries_data(), "timeseries": self.get_timeseries_data(),
**structure_data, **structure_data,
} }
@ -2342,12 +2420,17 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
@property @property
def as_svg(self): def as_svg(self):
if (
self.default_node_label.molfile is not None
and self.default_node_label.molfile.strip() != ""
):
return IndigoUtils.mol_to_svg(self.default_node_label.molfile)
return IndigoUtils.mol_to_svg(self.default_node_label.smiles) return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
def get_timeseries_data(self): def get_timeseries_data(self):
for ai in self.additional_information.all(): for ai in self.additional_information.all():
if ai.__class__.__name__ == "OECD301FTimeSeries": if ai.type == "OECD301FTimeSeries":
return ai.model_dump(mode="json") return ai.get().model_dump(mode="json")
return None return None
@ -2370,6 +2453,26 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
return data return data
def is_proposed_intermediate(self):
collected = defaultdict(dict)
for ai in self.additional_information.filter(
type__in=["ProposedIntermediate", "TransformationProductImportance", "Confidence"]
, scenario__isnull=False
):
collected[str(ai.scenario.uuid)]["scenarioId"] = ai.scenario.url
collected[str(ai.scenario.uuid)]["scenarioName"] = ai.scenario.name
if ai.type == "ProposedIntermediate":
collected[str(ai.scenario.uuid)]["proposed"] = True
if ai.type == "Confidence":
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level
if ai.type == "TransformationProductImportance":
collected[str(ai.scenario.uuid)]["Transformation product importance"] = ai.get().importance.value
return list(collected.values())
def simple_json(self, include_description=False): def simple_json(self, include_description=False):
res = super().simple_json() res = super().simple_json()
name = res.get("name", None) name = res.get("name", None)
@ -2384,7 +2487,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"epdb.Pathway", verbose_name="belongs to", on_delete=models.CASCADE, db_index=True "epdb.Pathway", verbose_name="belongs to", on_delete=models.CASCADE, db_index=True
) )
edge_label = models.ForeignKey( edge_label = models.ForeignKey(
"epdb.Reaction", verbose_name="Edge label", null=True, on_delete=models.SET_NULL "epdb.Reaction", verbose_name="Edge label", null=True, on_delete=models.CASCADE
) )
start_nodes = models.ManyToManyField( start_nodes = models.ManyToManyField(
"epdb.Node", verbose_name="Start Nodes", related_name="edge_educts" "epdb.Node", verbose_name="Start Nodes", related_name="edge_educts"
@ -2463,6 +2566,8 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
rule: Optional[Rule] = None, rule: Optional[Rule] = None,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
*args,
**kwargs,
): ):
e = Edge() e = Edge()
e.pathway = pathway e.pathway = pathway
@ -2492,7 +2597,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
educts=[n.default_node_label for n in e.start_nodes.all()], educts=[n.default_node_label for n in e.start_nodes.all()],
products=[n.default_node_label for n in e.end_nodes.all()], products=[n.default_node_label for n in e.end_nodes.all()],
rules=rule, rules=rule,
multi_step=False, multi_step=kwargs.get("multi_step", False),
) )
e.edge_label = r e.edge_label = r
@ -4500,18 +4605,22 @@ class AdditionalInformation(models.Model):
return f"{self.scenario.url}/additional-information/{self.uuid}" return f"{self.scenario.url}/additional-information/{self.uuid}"
def get(self) -> "EnviPyModel": @staticmethod
def from_dict(ai_type: str, ai_data: Dict[str, Any]):
from envipy_additional_information import registry from envipy_additional_information import registry
MAPPING = {c.__name__: c for c in registry.list_models().values()} MAPPING = {c.__name__: c for c in registry.list_models().values()}
try: try:
inst = MAPPING[self.type](**self.data) inst = MAPPING[ai_type](**ai_data)
except Exception as e: except Exception as e:
print(f"Error loading {self.type}: {e}") print(f"Error loading {ai_type}: {e}")
raise e raise e
inst.__dict__["uuid"] = str(self.uuid) return inst
def get(self) -> "EnviPyModel":
inst = AdditionalInformation.from_dict(self.type, self.data)
inst.__dict__["uuid"] = str(self.uuid)
return inst return inst
def __str__(self) -> str: def __str__(self) -> str:

View File

@ -19,6 +19,7 @@ from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
from .exceptions import InvalidSMILESException
from .logic import ( from .logic import (
EPDBURLParser, EPDBURLParser,
@ -785,6 +786,11 @@ def models(request):
{"Model": s.SERVER_URL + "/model"}, {"Model": s.SERVER_URL + "/model"},
] ]
context["entity_type"] = "model"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
# Keep model_types for potential modal/action use # Keep model_types for potential modal/action use
context["model_types"] = { context["model_types"] = {
"ML Relative Reasoning": { "ML Relative Reasoning": {
@ -797,12 +803,14 @@ def models(request):
"requires_rule_packages": True, "requires_rule_packages": True,
"requires_data_packages": True, "requires_data_packages": True,
}, },
"EnviFormer": { }
if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = {
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
}, }
}
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
@ -810,6 +818,9 @@ def models(request):
"type": k, "type": k,
"requires_rule_packages": v.requires_rule_packages(), "requires_rule_packages": v.requires_rule_packages(),
"requires_data_packages": v.requires_data_packages(), "requires_data_packages": v.requires_data_packages(),
"additional_parameters": v.Config.__name__.lower()
if v.Config.__name__ != ""
else None,
} }
for k, v in s.PROPERTY_PLUGINS.items(): for k, v in s.PROPERTY_PLUGINS.items():
context["model_types"][v.display()] = { context["model_types"][v.display()] = {
@ -818,12 +829,6 @@ def models(request):
"requires_data_packages": v.requires_data_packages(), "requires_data_packages": v.requires_data_packages(),
} }
# Context for paginated template
context["entity_type"] = "model"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
return render(request, "collections/models_paginated.html", context) return render(request, "collections/models_paginated.html", context)
elif request.method == "POST": elif request.method == "POST":
@ -938,13 +943,12 @@ def package_models(request, package_uuid):
"requires_data_packages": True, "requires_data_packages": True,
}, },
} }
if s.ENVIFORMER_PRESENT: if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = { context["model_types"]["EnviFormer"] = {
"type": "enviformer", "type": "enviformer",
"requires_rule_packages": False, "requires_rule_packages": False,
"requires_data_packages": True, "requires_data_packages": True,
}, }
if s.FLAGS.get("PLUGINS", False): if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
@ -1120,6 +1124,11 @@ def package_model(request, package_uuid, model_uuid):
} }
) )
# Sort data by prob desc
res["pred"] = sorted(
res["pred"], key=lambda x: x["probability"], reverse=True
)
return JsonResponse(res, safe=False) return JsonResponse(res, safe=False)
elif half_life: elif half_life:
@ -1223,9 +1232,7 @@ def package(request, package_uuid):
if request.method == "GET": if request.method == "GET":
if request.GET.get("export", False) == "true": if request.GET.get("export", False) == "true":
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json" filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
pack_json = PackageManager.export_package( pack_json = PackageManager.export_package(current_package)
current_package, include_models=False, include_external_identifiers=False
)
response = JsonResponse(pack_json, content_type="application/json") response = JsonResponse(pack_json, content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{filename}"' response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -2361,7 +2368,13 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_description = request.POST.get("node-description") node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles").strip() node_smiles = request.POST.get("node-smiles").strip()
current_pathway.add_node(node_smiles, name=node_name, description=node_description)
try:
current_pathway.add_node(node_smiles, name=node_name, description=node_description)
except InvalidSMILESException:
return error(
request, "Node creation failed!", f"Given SMILES ({node_smiles}) is invalid"
)
return redirect(current_pathway.url) return redirect(current_pathway.url)
@ -2469,7 +2482,22 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
return JsonResponse({"success": current_node.url}) return JsonResponse({"success": current_node.url})
return HttpResponseBadRequest() new_node_name = request.POST.get("node-name")
new_node_description = request.POST.get("node-description")
if any([new_node_name, new_node_description]):
if new_node_name is not None and new_node_name.strip() != "":
new_node_name = nh3.clean(new_node_name.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
current_node.name = new_node_name
if new_node_description is not None and new_node_description.strip() != "":
new_node_description = nh3.clean(new_node_description.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
current_node.description = new_node_description
current_node.save()
return redirect(current_node.url)
return error(request, "Node update failed!", "No changes were made to the node")
else: else:
return HttpResponseNotAllowed(["GET", "POST"]) return HttpResponseNotAllowed(["GET", "POST"])
@ -3001,9 +3029,15 @@ def settings(request):
new_default = request.POST.get("prediction-setting-new-default", "off") == "on" new_default = request.POST.get("prediction-setting-new-default", "off") == "on"
# min 2, max s.DEFAULT_MAX_NUMBER_OF_NODES # min 2, max s.DEFAULT_MAX_NUMBER_OF_NODES
temp_max_nodes = request.POST.get("prediction-setting-max-nodes")
if temp_max_nodes is None or temp_max_nodes == "" or int(temp_max_nodes) == -1:
temp_max_nodes = s.DEFAULT_MAX_NUMBER_OF_NODES
else:
temp_max_nodes = int(request.POST.get("prediction-setting-max-nodes", 1))
max_nodes = min( max_nodes = min(
max( max(
int(request.POST.get("prediction-setting-max-nodes", 1)), temp_max_nodes,
2, 2,
), ),
s.DEFAULT_MAX_NUMBER_OF_NODES, s.DEFAULT_MAX_NUMBER_OF_NODES,
@ -3024,6 +3058,7 @@ def settings(request):
model_uuid = model_url.split("/")[-1] model_uuid = model_url.split("/")[-1]
params["model"] = EPModel.objects.get(uuid=model_uuid) params["model"] = EPModel.objects.get(uuid=model_uuid)
# TODO Check if removed if request contains "" or not at all
params["model_threshold"] = request.POST.get( params["model_threshold"] = request.POST.get(
"model-based-prediction-setting-threshold", s.DEFAULT_MODEL_THRESHOLD "model-based-prediction-setting-threshold", s.DEFAULT_MODEL_THRESHOLD
) )
@ -3113,12 +3148,21 @@ def jobs(request):
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Jobs": s.SERVER_URL + "/jobs"}, {"Jobs": s.SERVER_URL + "/jobs"},
] ]
if current_user.is_superuser: # if current_user.is_superuser:
context["jobs"] = JobLog.objects.all().order_by("-created") # context["jobs"] = JobLog.objects.all().order_by("-created")
else: # else:
context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created") # context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created")
return render(request, "collections/joblog.html", context) # Context for paginated template
context["entity_type"] = "joblog"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/joblog/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "joblog"
context["list_mode"] = "combined"
return render(request, "collections/joblog_paginated.html", context)
# return render(request, "collections/joblog.html", context)
elif request.method == "POST": elif request.method == "POST":
job_name = request.POST.get("job-name") job_name = request.POST.get("job-name")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

154
fixtures/pes.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -65,6 +65,25 @@ def run_both_engines(SMILES, SMIRKS):
def migration(request): def migration(request):
accepted_diffs = [
"bt0055-3469.1",
"bt0056-2685",
"bt0193-4263",
"bt0231-1871.1",
"bt0242-3803",
"bt0254-4224.1",
"bt0254-4224.2",
"bt0291-1129",
"bt0337-3543",
"bt0391-4285",
"bt0402-3576",
"bt0404-3928",
"bt0416-4269",
"bt0432-4254",
"bt0337-4117",
"bt0322-3393",
]
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
@ -109,11 +128,11 @@ def migration(request):
), ),
"id": str(r.uuid), "id": str(r.uuid),
"url": r.url, "url": r.url,
"status": res, "status": res or r.name in accepted_diffs,
} }
) )
if res: if res or r.name in accepted_diffs:
success += 1 success += 1
else: else:
error += 1 error += 1
@ -135,7 +154,16 @@ def migration(request):
for r in migration_status["results"]: for r in migration_status["results"]:
r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL) r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
if r["name"] in accepted_diffs:
r["status"] = True
migration_status["results"] = sorted(
migration_status["results"], key=lambda x: (x["status"], x["name"])
)
num_success = sum([int(x["status"]) for x in migration_status["results"]])
migration_status["success"] = num_success
migration_status["error"] = migration_status["total"] - num_success
context.update(**migration_status) context.update(**migration_status)
return render(request, "migration.html", context) return render(request, "migration.html", context)

View File

@ -21,5 +21,11 @@
"django", "django",
"tailwindcss", "tailwindcss",
"daisyui" "daisyui"
] ],
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@tailwindcss/oxide"
]
}
} }

View File

@ -1,3 +1,5 @@
allowBuilds:
'@parcel/watcher': true
onlyBuiltDependencies: onlyBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- '@tailwindcss/oxide' - '@tailwindcss/oxide'

View File

@ -34,3 +34,12 @@
} }
@import "./daisyui-theme.css"; @import "./daisyui-theme.css";
select.select[multiple] {
display: block;
white-space: normal;
}
p a {
@apply underline;
}

View File

@ -59,6 +59,9 @@ document.addEventListener("alpine:init", () => {
get isEditMode() { get isEditMode() {
return this.mode === "edit"; return this.mode === "edit";
}, },
get isRequired() {
return (this.schema.required || []).indexOf(this.fieldName) > -1
}
}); });
// Text widget // Text widget
@ -293,6 +296,34 @@ document.addEventListener("alpine:init", () => {
}), }),
); );
// PubMed link widget
Alpine.data(
"doiWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get doiUrl() {
return this.value
? `https://dx.doi.org/${this.value}`
: null;
},
}),
);
// Compound link widget // Compound link widget
Alpine.data( Alpine.data(
"compoundWidget", "compoundWidget",

View File

@ -5,6 +5,125 @@
*/ */
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
const basePagination = (
items,
currentPage,
totalPages,
totalItems,
perPage,
isReviewed,
instanceId
) => ({
items,
currentPage,
totalPages,
totalItems,
perPage,
isReviewed,
instanceId,
get paginatedItems() {
return this.items;
},
get showingStart() {
if (this.totalItems === 0) return 0;
return (this.currentPage - 1) * this.perPage + 1;
},
get showingEnd() {
if (this.totalItems === 0) return 0;
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.fetchPage(this.currentPage + 1);
}
},
prevPage() {
if (this.currentPage > 1) {
this.fetchPage(this.currentPage - 1);
}
},
goToPage(page) {
if (page >= 1 && page <= this.totalPages) {
this.fetchPage(page);
}
},
get pageNumbers() {
const pages = [];
const total = this.totalPages;
const current = this.currentPage;
if (total === 0) {
return pages;
}
if (total <= 7) {
for (let i = 1; i <= total; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
} else {
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
let rangeStart;
let rangeEnd;
if (current <= 4) {
rangeStart = 2;
rangeEnd = 5;
} else if (current >= total - 3) {
rangeStart = total - 4;
rangeEnd = total - 1;
} else {
rangeStart = current - 1;
rangeEnd = current + 1;
}
if (rangeStart > 2) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
}
for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
}
if (rangeEnd < total - 1) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
}
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
}
return pages;
}
});
Alpine.data('paginatedList',
(items, options = {}) => ({
...basePagination(items,1, 0, 0, options.perPage || 50, options.isReviewed || false, options.instanceId || Math.random().toString(36).substring(2, 9),),
init() {
this.fetchPage(1);
},
async fetchPage(page) {
this.totalItems = this.items.length;
this.totalPages = Math.ceil(this.items.length / this.perPage);
this.currentPage = page
const start = page * this.perPage - this.perPage;
const end = page * this.perPage;
return this.items.slice(start, end);
}
}));
Alpine.data('remotePaginatedList', (options = {}) => ({ Alpine.data('remotePaginatedList', (options = {}) => ({
items: [], items: [],
currentPage: 1, currentPage: 1,

View File

@ -1,5 +1,37 @@
console.log("loaded pw.js") console.log("loaded pw.js")
function findPaths(source_idx, links) {
const resultLinks = new Set();
const visited = new Set();
// Helper function for depth-first search
function dfs(current) {
if (visited.has(current)) {
return;
}
if (links.filter(link => link.target.id === current).length === 0) {
return;
}
visited.add(current);
links.forEach(link => {
if (
link.target.id === current &&
!visited.has(link.source.id) &&
link.source.id !== link.target.id // Avoid self-loops
) {
resultLinks.add(link); // Add link to result
dfs(link.source.id);
}
});
visited.delete(current);
}
// Start DFS
dfs(source_idx);
return Array.from(resultLinks);
}
function predictFromNode(url) { function predictFromNode(url) {
fetch("", { fetch("", {
method: "POST", method: "POST",
@ -23,6 +55,7 @@ function predictFromNode(url) {
// elem = 'vizdiv' // elem = 'vizdiv'
function draw(pathway, elem) { function draw(pathway, elem) {
const initialzoom = 2.5
const nodeRadius = 20; const nodeRadius = 20;
const linkDistance = 100; const linkDistance = 100;
const chargeStrength = -200; const chargeStrength = -200;
@ -34,6 +67,9 @@ function draw(pathway, elem) {
const horizontalSpacing = 75; // horizontal space between nodes const horizontalSpacing = 75; // horizontal space between nodes
const depthMap = new Map(); const depthMap = new Map();
// Avoid leaving unconnected Nodes leaving the viewport
nodes.forEach(node => {if (node.depth < 0) node.depth = 0;});
// Sort nodes by depth first to minimize crossings // Sort nodes by depth first to minimize crossings
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth); const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
@ -63,7 +99,7 @@ function draw(pathway, elem) {
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2; node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
} }
node.fy = node.depth * levelSpacing + 50; node.fy = (node.depth + initialzoom + 0.5) * levelSpacing + 50;
depthMap.set(node.depth, depthMap.get(node.depth) + 1); depthMap.set(node.depth, depthMap.get(node.depth) + 1);
}); });
} }
@ -101,12 +137,30 @@ function draw(pathway, elem) {
// Update pseudo node positions first // Update pseudo node positions first
updatePseudoNodePositions(); updatePseudoNodePositions();
link.attr("x1", d => d.source.x) link.attr("d", d => {
.attr("y1", d => d.source.y) // Check if it's a self-loop (source equals target)
.attr("x2", d => d.target.x) if (d.source.id === d.target.id) {
.attr("y2", d => d.target.y); // Create a bezier curve for self-loops
const x = d.source.x;
const y = d.source.y;
const loopRadius = nodeRadius * 2; // Adjust size of the loop
// Create a circular path to the left of the node
return `M ${x},${y - nodeRadius}
C ${x - loopRadius},${y - nodeRadius - loopRadius}
${x - loopRadius},${y + nodeRadius + loopRadius}
${x},${y + nodeRadius}`;
} else {
// Regular straight line for normal edges
return `M ${d.source.x},${d.source.y} L ${d.target.x},${d.target.y}`;
}
});
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => `translate(${d.x},${d.y})`);
linkText
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
} }
function dragstarted(event, d) { function dragstarted(event, d) {
@ -209,7 +263,7 @@ function draw(pathway, elem) {
} }
// Wait before showing popup (ms) // Wait before showing popup (ms)
var popupWaitBeforeShow = 1000; var popupWaitBeforeShow = 500;
// Custom popover element // Custom popover element
let popoverTimeout = null; let popoverTimeout = null;
@ -463,7 +517,7 @@ function draw(pathway, elem) {
// TODO needs to be generic once we store it as AddInf // TODO needs to be generic once we store it as AddInf
for (var s of n.predicted_properties["PepperPrediction"]) { for (var s of n.predicted_properties["PepperPrediction"]) {
if (s["mean"] != null) { if (s["mean"] != null) {
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + "<br>" tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + " days<br>"
} }
} }
} }
@ -563,6 +617,8 @@ function draw(pathway, elem) {
// Apply zoom to the SVG element - this enables wheel zoom // Apply zoom to the SVG element - this enables wheel zoom
svg.call(zoom); svg.call(zoom);
svg.call(zoom.scaleBy, initialzoom);
// Also apply zoom to container to catch events that might not reach SVG // Also apply zoom to container to catch events that might not reach SVG
// This ensures drag-to-pan works even when clicking on empty space // This ensures drag-to-pan works even when clicking on empty space
container.call(zoom); container.call(zoom);
@ -581,11 +637,11 @@ function draw(pathway, elem) {
for (idx in parents) { for (idx in parents) {
p = nodes[parents[idx]] p = nodes[parents[idx]]
// console.log(p.depth) // console.log(p.depth)
if (p.depth >= n.depth) { // if (p.depth >= n.depth) {
// keep the .5 steps for pseudo nodes // // keep the .5 steps for pseudo nodes
n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1); // n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1);
// console.log("Adjusting", orig_depth, Math.floor(p.depth + 1)); // // console.log("Adjusting", orig_depth, Math.floor(p.depth + 1));
} // }
} }
}); });
@ -598,43 +654,68 @@ function draw(pathway, elem) {
.on("tick", ticked); .on("tick", ticked);
// Kanten zeichnen // Kanten zeichnen
const link = zoomable.append("g") const linkGroup = zoomable.append("g")
.selectAll("line") .selectAll("g")
.data(links) .data(links)
.enter().append("line") .enter()
// Check if target is pseudo and draw marker only if not pseudo .append("g")
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link") .attr("class", "link-group");
.attr("marker-end", d => d.target.pseudo ? '' : d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)')
const link = linkGroup
.append("path")
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)")
.attr("fill", "none")
// Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
.attr("marker-end", d => {
if (d.target.pseudo) return '';
if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)';
}
)
.attr("fill", "none")
.on("click", function(event, d) { .on("click", function(event, d) {
const wasHighlighted = d3.select(this).classed("highlighted"); const wasHighlighted = d3.select(this).classed("highlighted");
d3.selectAll("path").classed("highlighted", false);
d3.selectAll("line").classed("highlighted", false);
if (!wasHighlighted) { if (!wasHighlighted) {
const toHighlight = []; const toHighlight = [];
toHighlight.push(d.el); toHighlight.push(d.el);
if (d.source.pseudo || d.target.pseudo) { if (d.source.pseudo || d.target.pseudo) {
if (d.target.pseudo) { if (d.target.pseudo) {
d3.selectAll("line").each(e => { d3.selectAll("path").each(e => {
if (e !== undefined && e.source.id === d.target.id) { if (e !== undefined && e.source.id === d.target.id) {
toHighlight.push(e.el); toHighlight.push(e.el);
}
});
} else {
d3.selectAll("line").each(e => {
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
toHighlight.push(e.el);
}
});
} }
});
} else {
d3.selectAll("path").each(e => {
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
toHighlight.push(e.el);
}
});
} }
}
for (const e of toHighlight) { for (const e of toHighlight) {
d3.select(e).classed("highlighted", true); d3.select(e).classed("highlighted", true);
} }
} }
}) });
const linkText = linkGroup
.append("text")
.attr("class", "link-label")
.attr("text-anchor", "middle")
.attr("dy", -6)
.style("font-size", "4px")
.style("pointer-events", "none")
.style("display", "none")
.text(d => d.name)
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
// add element to links array // add element to links array
link.each(function (d) { link.each(function (d) {
@ -651,16 +732,41 @@ function draw(pathway, elem) {
.call(d3.drag() .call(d3.drag()
.on("start", dragstarted) .on("start", dragstarted)
.on("drag", dragged) .on("drag", dragged)
.on("end", dragended)) .on("end", dragended)
)
.on("click", function (event, d) { .on("click", function (event, d) {
const wasHighlighted = d3.select(this).select("circle").classed("highlighted"); const wasHighlighted = d3.select(this).select("circle").classed("highlighted");
d3.selectAll('circle.highlighted').classed('highlighted', false);
d3.selectAll("path").classed("inedge", false);
d3.selectAll("path").classed("outedge", false);
d3.selectAll('circle.highlighted').classed('highlighted', false); if (!wasHighlighted) {
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
if (!wasHighlighted) { const inEdges = findPaths(d.id, links);
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted")); const outEdges = []
// Colorize out edges green
for (const l of links) {
if (l.source.id === d.id) {
outEdges.push(l);
if (l.target.pseudo) {
for (const l2 of links) {
if (l.target.id === l2.source.id) {
outEdges.push(l2);
}
}
}
}
} }
})
for (const e of inEdges) {
d3.select(e.el).classed("inedge", true);
}
for (const e of outEdges) {
d3.select(e.el).classed("outedge", true);
}
}
});
// Kreise für die Knoten hinzufügen // Kreise für die Knoten hinzufügen
node.append("circle") node.append("circle")
@ -734,14 +840,14 @@ function draw(pathway, elem) {
function serializeSVG(svgElement) { function serializeSVG(svgElement) {
svgElement.querySelectorAll("line.link").forEach(line => { svgElement.querySelectorAll("path.link").forEach(line => {
const style = getComputedStyle(line); const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke); line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth); line.setAttribute("stroke-width", style.strokeWidth);
line.setAttribute("fill", style.fill); line.setAttribute("fill", style.fill);
}); });
svgElement.querySelectorAll("line.link_no_arrow").forEach(line => { svgElement.querySelectorAll("path.link_no_arrow").forEach(line => {
const style = getComputedStyle(line); const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke); line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth); line.setAttribute("stroke-width", style.strokeWidth);

View File

@ -0,0 +1,102 @@
{# Partial for paginated list content - expects to be inside a remotePaginatedList Alpine.js context #}
{# Variables: empty_text (string), show_review_badge (bool), always_show_badge (bool) #}
{% load envipytags %}
{# Loading state #}
<div
x-show="isLoading"
class="mx-auto flex h-32 w-32 items-center justify-center"
>
{% include "components/loading-spinner.html" %}
</div>
{# Error state #}
<div
x-show="!isLoading && error"
class="alert alert-error/50 text-sm"
x-text="error"
></div>
{# Content #}
<template x-if="!isLoading && !error">
<div>
{# Empty state #}
<div
x-show="totalItems === 0"
class="text-base-content/70 py-8 text-center"
>
<p>No {{ empty_text|default:"items" }} found.</p>
</div>
{# Items list #}
<ul class="menu bg-base-100 rounded-box w-full" x-show="totalItems > 0">
<table class="table-zebra table">
<thead>
<tr>
<th>User</th>
<th>ID</th>
<th>Name</th>
<th>Status</th>
<th>Queued At</th>
<th>Done At</th>
</tr>
</thead>
<tbody>
<template x-for="obj in paginatedItems" :key="obj.url">
<tr>
<td>
<a :href="obj.user.url"><span x-text="obj.user.name"></span></a>
</td>
<td>
<a :href="obj.url"><span x-text="obj.id"></span></a>
</td>
<td><span x-text="obj.name"></span></td>
<td><span x-text="obj.status"></span></td>
<td><span x-text="obj.created"></span></td>
<td><span x-text="obj.done"></span></td>
</tr>
</template>
</tbody>
</table>
</ul>
{# Pagination controls #}
<div
x-show="totalPages > 1"
class="mt-4 flex items-center justify-between px-2"
>
<span class="text-base-content/70 text-sm">
Showing <span x-text="showingStart"></span>-<span
x-text="showingEnd"
></span>
of <span x-text="totalItems"></span>
</span>
<div class="join">
<button
class="join-item btn btn-sm"
:disabled="currentPage === 1"
@click="prevPage()"
>
«
</button>
<template x-for="item in pageNumbers" :key="item.key">
<button
class="join-item btn btn-sm"
:class="{ 'btn-active': item.page === currentPage }"
:disabled="item.isEllipsis"
@click="!item.isEllipsis && goToPage(item.page)"
x-text="item.page"
></button>
</template>
<button
class="join-item btn btn-sm"
:disabled="currentPage === totalPages"
@click="nextPage()"
>
»
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,13 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Jobs{% endblock %}
{% block action_button %}
{% endblock action_button %}
{% block action_modals %}
{% endblock action_modals %}
{% block description %}
<p>List of Jobs submitted.</p>
{% endblock description %}

View File

@ -3,73 +3,73 @@
{% block page_title %}Packages{% endblock %} {% block page_title %}Packages{% endblock %}
{% block action_button %} {% block action_button %}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
id="new-package-button" id="new-package-button"
onclick="document.getElementById('new_package_modal').showModal(); return false;" onclick="document.getElementById('new_package_modal').showModal(); return false;"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-folder-plus-icon lucide-folder-plus"
> >
<path d="M12 10v6" />
<path d="M9 13h6" />
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg>
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
Import
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="16"
height="24" height="16"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
class="lucide lucide-folder-plus-icon lucide-folder-plus" class="lucide lucide-chevron-down ml-1"
> >
<path d="M12 10v6" /> <path d="m6 9 6 6 6-6" />
<path d="M9 13h6" />
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg> </svg>
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
Import
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down ml-1"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2"
>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
Import Package from JSON
</a>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
Import Package from legacy JSON
</a>
</li>
</ul>
</div> </div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2"
>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
Import Package from JSON
</a>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
Import Package from legacy JSON
</a>
</li>
</ul>
</div> </div>
</div>
{% endblock action_button %} {% endblock action_button %}
{% block action_modals %} {% block action_modals %}

View File

@ -37,7 +37,11 @@
perPage: {{ per_page|default:50 }} perPage: {{ per_page|default:50 }}
})" })"
> >
{% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %} {% if entity_type == 'joblog' %}
{% include "collections/_joblog_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
{% else %}
{% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
{% endif %}
</div> </div>
{% else %} {% else %}
{# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #} {# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #}

View File

@ -11,7 +11,7 @@
{% endblock action_modals %} {% endblock action_modals %}
{% block action_button %} {% block action_button %}
{% if meta.can_edit or not meta.url_contains_package %} {% if meta.can_edit or not meta.url_contains_package %}
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"

View File

@ -123,6 +123,17 @@
</div> </div>
</template> </template>
<!-- DOI link widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'doi-link'"
>
<div
x-data="doiWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/doi_link_widget.html" %}
</div>
</template>
<!-- Compound link widget --> <!-- Compound link widget -->
<template <template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"

View File

@ -0,0 +1,69 @@
{# DOI link widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: display as link -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value && doiUrl">
<a
:href="doiUrl"
class="link link-primary"
target="_blank"
x-text="value"
></a>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="text"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="DOI e.g. 10.1016/j.jhazmat.2016.08.036"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -44,6 +44,7 @@
:class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }" :class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value" x-model="value"
:multiple="multiple" :multiple="multiple"
:required="isRequired"
> >
<option value="" :selected="!value">Select...</option> <option value="" :selected="!value">Select...</option>

View File

@ -65,11 +65,11 @@
}, },
get showMlrr() { get showMlrr() {
return this.selectedType === 'mlrr'; return this.selectedType === 'ml-relative-reasoning';
}, },
get showRbrr() { get showRbrr() {
return this.selectedType === 'rbrr'; return this.selectedType === 'rule-based-relative-reasoning';
}, },
get showEnviformer() { get showEnviformer() {

View File

@ -5,6 +5,7 @@
x-data="{ x-data="{
isSubmitting: false, isSubmitting: false,
reactionImageUrl: '', reactionImageUrl: '',
pes: false,
reset() { reset() {
this.isSubmitting = false; this.isSubmitting = false;
@ -15,20 +16,30 @@
const substratesSelect = document.getElementById('add_pathway_edge_substrates'); const substratesSelect = document.getElementById('add_pathway_edge_substrates');
const productsSelect = document.getElementById('add_pathway_edge_products'); const productsSelect = document.getElementById('add_pathway_edge_products');
const pesLinks = [];
const substrates = []; const substrates = [];
for (const option of substratesSelect.selectedOptions) { for (const option of substratesSelect.selectedOptions) {
substrates.push(option.dataset.smiles); substrates.push(option.dataset.smiles);
if (option.dataset.pes === 'true') {
pesLinks.push(option.dataset.pes);
}
} }
const products = []; const products = [];
for (const option of productsSelect.selectedOptions) { for (const option of productsSelect.selectedOptions) {
products.push(option.dataset.smiles); products.push(option.dataset.smiles);
if (option.dataset.pes === 'true') {
pesLinks.push(option.dataset.pes);
}
} }
if (substrates.length > 0 && products.length > 0) { if (substrates.length > 0 && products.length > 0) {
const reaction = substrates.join('.') + '>>' + products.join('.'); const reaction = substrates.join('.') + '>>' + products.join('.');
this.reactionImageUrl = '{% url "depict" %}?smirks=' + encodeURIComponent(reaction); this.reactionImageUrl = '{% url "depict" %}?smirks=' + encodeURIComponent(reaction);
this.pes = pesLinks.length > 0;
} else { } else {
this.pes = false;
this.reactionImageUrl = ''; this.reactionImageUrl = '';
} }
}, },
@ -106,6 +117,7 @@
{% for n in pathway.nodes %} {% for n in pathway.nodes %}
<option <option
data-smiles="{{ n.default_node_label.smiles }}" data-smiles="{{ n.default_node_label.smiles }}"
data-pes="{% if n.default_node_label.pes_link %}true{% else %}false{% endif %}"
value="{{ n.url }}" value="{{ n.url }}"
> >
{{ n.default_node_label.name|safe }} {{ n.default_node_label.name|safe }}
@ -132,6 +144,7 @@
{% for n in pathway.nodes %} {% for n in pathway.nodes %}
<option <option
data-smiles="{{ n.default_node_label.smiles }}" data-smiles="{{ n.default_node_label.smiles }}"
data-pes="{% if n.default_node_label.pes_link %}true{% else %}false{% endif %}"
value="{{ n.url }}" value="{{ n.url }}"
> >
{{ n.default_node_label.name|safe }} {{ n.default_node_label.name|safe }}
@ -144,6 +157,9 @@
<div class="mb-3" x-show="reactionImageUrl" x-cloak> <div class="mb-3" x-show="reactionImageUrl" x-cloak>
<img :src="reactionImageUrl" class="w-full" alt="Reaction preview" /> <img :src="reactionImageUrl" class="w-full" alt="Reaction preview" />
<div x-show="pes">
<span class='alert alert-info alert-soft'>The reaction contains a partially elucidated structure!</br>For visualization the representative structure is used. The pathway itself will show the actual partially elucidated structure.</span>
</div>
</div> </div>
</form> </form>
</div> </div>

View File

@ -5,8 +5,7 @@
class="modal" class="modal"
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
@modal-opened.window=" @modal-opened.window="
const links = d3.selectAll('line.highlighted'); const links = d3.selectAll('path.highlighted');
console.log(links);
if (!links.empty()) { if (!links.empty()) {
const el = links.node(); const el = links.node();
const selectElement = document.getElementById('delete_pathway_edge_edges'); const selectElement = document.getElementById('delete_pathway_edge_edges');
@ -18,9 +17,7 @@
break; break;
} }
} }
selectElement.dispatchEvent(new Event('change')); selectElement.dispatchEvent(new Event('change'));
} }
" "
@close="reset()" @close="reset()"

View File

@ -6,17 +6,14 @@
x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })"
@modal-opened.window=" @modal-opened.window="
const el = d3.select('circle.highlighted').node(); const el = d3.select('circle.highlighted').node();
if (el !== null) { if (el !== null) {
const selectElement = document.getElementById('delete_pathway_node_nodes'); const selectElement = document.getElementById('delete_pathway_node_nodes');
for (let option of selectElement.options) { for (let option of selectElement.options) {
if (option.value === el.__data__.url) { if (option.value === el.__data__.url) {
option.selected = true; option.selected = true;
break; break;
} }
} }
selectElement.dispatchEvent(new Event('change')); selectElement.dispatchEvent(new Event('change'));
} }
" "

View File

@ -82,6 +82,9 @@
<div class="collapse-content"> <div class="collapse-content">
<div class="flex justify-center">{{ edge.edge_label.as_svg|safe }}</div> <div class="flex justify-center">{{ edge.edge_label.as_svg|safe }}</div>
</div> </div>
{% if edge.edge_label.contains_pes %}
<span class='alert alert-info alert-soft'>The reaction contains a partially elucidated structure!</br>For visualization the representative structure is used. The pathway itself will show the actual partially elucidated structure.</span>
{% endif %}
</div> </div>
<!-- Reaction Description --> <!-- Reaction Description -->
@ -92,7 +95,7 @@
<div class="flex flex-wrap items-center justify-center gap-4"> <div class="flex flex-wrap items-center justify-center gap-4">
{% for educt in edge.start_nodes.all %} {% for educt in edge.start_nodes.all %}
<a href="{{ educt.url }}" class="btn btn-outline btn-sm" <a href="{{ educt.url }}" class="btn btn-outline btn-sm"
>{{ educt.name }}</a >{{ educt.get_name }}</a
> >
{% endfor %} {% endfor %}
<svg <svg
@ -112,7 +115,7 @@
</svg> </svg>
{% for product in edge.end_nodes.all %} {% for product in edge.end_nodes.all %}
<a href="{{ product.url }}" class="btn btn-outline btn-sm" <a href="{{ product.url }}" class="btn btn-outline btn-sm"
>{{ product.name }}</a >{{ product.get_name }}</a
> >
{% endfor %} {% endfor %}
</div> </div>

View File

@ -56,7 +56,9 @@
<ul class="menu bg-base-200 rounded-box"> <ul class="menu bg-base-200 rounded-box">
{% for um in group.user_member.all %} {% for um in group.user_member.all %}
<li> <li>
<a href="{{ um.url }}" class="hover:bg-base-300" <a
href="{% if user.is_superuser %}{{ um.url }}{% else %}{{ "#" }}{% endif %}"
class="hover:bg-base-300"
>{{ um.username }} >{{ um.username }}
{% if not um.is_active %}<i>(inactive)</i>{% endif %}</a {% if not um.is_active %}<i>(inactive)</i>{% endif %}</a
> >

View File

@ -55,6 +55,15 @@
</div> </div>
</div> </div>
<!-- Description -->
{% if node.description and node.description != "no description" %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Description</div>
<div class="collapse-content">{{ node.description }}</div>
</div>
{% endif %}
{% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %} {% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %}
{% for tpl in viz_templates %} {% for tpl in viz_templates %}

View File

@ -21,6 +21,7 @@
.link { .link {
stroke: #999; stroke: #999;
stroke-opacity: 0.6; stroke-opacity: 0.6;
stroke-width: 1.5px;
/* marker-end: url(#arrow); */ /* marker-end: url(#arrow); */
} }
@ -72,6 +73,15 @@
stroke: red; stroke: red;
stroke-width: 3px; stroke-width: 3px;
} }
.inedge {
stroke: red;
stroke-width: 3px;
}
.outedge {
stroke: green;
stroke-width: 3px;
}
</style> </style>
<script src="{% static 'js/pw.js' %}"></script> <script src="{% static 'js/pw.js' %}"></script>
@ -111,7 +121,7 @@
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
Graphical Representation Graphical Representation
</div> </div>
<div class="collapse-content "> <div class="collapse-content">
<div class="bg-base-100 mb-2 rounded-lg p-2"> <div class="bg-base-100 mb-2 rounded-lg p-2">
<div class="navbar bg-base-100 rounded-lg"> <div class="navbar bg-base-100 rounded-lg">
<div class="flex-1"> <div class="flex-1">
@ -168,6 +178,52 @@
tabindex="0" tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2" class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2"
> >
<li>
<a id="compound-names-toggle-button" class="cursor-pointer">
<svg
id="compound-names-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
Compound Names
</a>
</li>
<li>
<a id="reaction-names-toggle-button" class="cursor-pointer">
<svg
id="reaction-names-icon"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-eye"
>
<path
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
Reaction Names
</a>
</li>
{% if pathway.setting.model.app_domain %} {% if pathway.setting.model.app_domain %}
<li> <li>
<a id="app-domain-toggle-button" class="cursor-pointer"> <a id="app-domain-toggle-button" class="cursor-pointer">
@ -193,6 +249,7 @@
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if 1 == 0 %}
<li> <li>
<a id="timeseries-toggle-button" class="cursor-pointer"> <a id="timeseries-toggle-button" class="cursor-pointer">
<svg <svg
@ -243,6 +300,7 @@
Show Predicted Properties Show Predicted Properties
</a> </a>
</li> </li>
{% endif %}
</ul> </ul>
</div> </div>
</div> </div>
@ -367,6 +425,18 @@
> >
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" /> <path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker> </marker>
<marker
id="curve-arrow"
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
<marker <marker
id="doublearrow" id="doublearrow"
viewBox="0 0 20 30" viewBox="0 0 20 30"
@ -474,6 +544,10 @@
{{ pathway.d3_json|json_script:"pathway" }} {{ pathway.d3_json|json_script:"pathway" }}
<script> <script>
// Global switch for compound names view
var compoundNamesViewEnabled = false;
// Gloabl switch for reaction names view
var reactionNamesViewEnabled = false;
// Global switch for app domain view // Global switch for app domain view
var appDomainViewEnabled = false; var appDomainViewEnabled = false;
// Global switch for timeseries view // Global switch for timeseries view
@ -509,6 +583,67 @@
descContent.innerHTML = newDesc; descContent.innerHTML = newDesc;
} }
const compoundNamesBtn = document.getElementById("compound-names-toggle-button");
if (compoundNamesBtn) {
compoundNamesBtn.addEventListener("click", function () {
compoundNamesViewEnabled = !compoundNamesViewEnabled;
const icon = document.getElementById("compound-names-icon");
if (compoundNamesViewEnabled) {
// Change to eye-off icon
icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
nodes.forEach((x) => {
if (x.name) {
d3.select(x.el)
.append("text")
.text(d => d.name)
.attr("text-anchor", "middle")
.attr("y", -20)
.style("font-size", "4px");
}
});
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
nodes.forEach((x) => {
d3.select(x.el)
.select("text")
.remove()
})
}
});
}
const reactionNamesBtn = document.getElementById("reaction-names-toggle-button");
if (reactionNamesBtn) {
reactionNamesBtn.addEventListener("click", function () {
reactionNamesViewEnabled = !reactionNamesViewEnabled;
const icon = document.getElementById("reaction-names-icon");
if (reactionNamesViewEnabled) {
// Change to eye-off icon
icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
d3.selectAll(".link-label")
.style("display", null);
} else {
// Change back to eye icon
icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
d3.selectAll(".link-label")
.style("display", "none");
}
});
}
// App domain toggle // App domain toggle
const appDomainBtn = document.getElementById("app-domain-toggle-button"); const appDomainBtn = document.getElementById("app-domain-toggle-button");
if (appDomainBtn) { if (appDomainBtn) {

View File

@ -79,6 +79,9 @@
<div class="collapse-content"> <div class="collapse-content">
<div class="flex justify-center">{{ reaction.as_svg|safe }}</div> <div class="flex justify-center">{{ reaction.as_svg|safe }}</div>
</div> </div>
{% if reaction.contains_pes %}
<span class='alert alert-info alert-soft'>The reaction contains a partially elucidated structure!</br>For visualization the representative structure is used. The pathway itself will show the actual partially elucidated structure.</span>
{% endif %}
</div> </div>
<!-- Reaction Description --> <!-- Reaction Description -->

View File

@ -24,6 +24,76 @@
<td>Setting Name</td> <td>Setting Name</td>
<td>{{ setting_to_render.name }}</td> <td>{{ setting_to_render.name }}</td>
</tr> </tr>
<tr>
<td>Setting URL</td>
<td>
<a href="{{ setting_to_render.url }}" class="link link-primary">{{ setting_to_render.url }}</a>
<div
x-data="{
value: '{{ setting_to_render.url }}',
copied: false,
async copy() {
await navigator.clipboard.writeText(this.value)
this.copied = true
setTimeout(() => {
this.copied = false
}, 1500)
}
}"
class="join"
>
<button
type="button"
class="btn btn-ghost"
:data-tip="copied ? 'Copied!' : 'Copy'"
@click="copy"
aria-label="Copy to clipboard"
>
<svg
x-show="!copied"
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2"
/>
<rect
width="12"
height="12"
x="8"
y="8"
rx="2"
ry="2"
/>
</svg>
<svg
x-show="copied"
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</button>
</div>
</td>
</tr>
{% if setting_to_render.description %} {% if setting_to_render.description %}
<tr> <tr>
<td>Setting Description</td> <td>Setting Description</td>

View File

@ -105,7 +105,7 @@
></iframe> ></iframe>
</div> </div>
<label class="select mb-8 w-full"> <label class="select mb-8 w-full" id="prediction-setting-label">
<span class="label">Predictor</span> <span class="label">Predictor</span>
<select id="prediction-setting" name="prediction-setting"> <select id="prediction-setting" name="prediction-setting">
<option disabled>Select a Setting</option> <option disabled>Select a Setting</option>
@ -148,6 +148,21 @@
</div> </div>
{# prettier-ignore-start #} {# prettier-ignore-start #}
<script> <script>
// Hide predictor selection and update button text if mode is "build"
function radioChange(event) {
if (event.target.value === "build") {
document.getElementById("prediction-setting-label").hidden = true;
document.getElementById("predict-submit-button").innerText = "Build";
} else {
document.getElementById("prediction-setting-label").hidden = false;
document.getElementById("predict-submit-button").innerText = "Predict";
}
}
const radioButtons = document.querySelectorAll('input[name="predict"]');
radioButtons.forEach(radio => {
radio.addEventListener('change', radioChange);
});
// Helper function to safely get Ketcher instance from iframe // Helper function to safely get Ketcher instance from iframe
function getKetcherInstance(iframeId) { function getKetcherInstance(iframeId) {
const ketcherFrame = document.getElementById(iframeId); const ketcherFrame = document.getElementById(iframeId);
@ -197,7 +212,13 @@
e.preventDefault(); e.preventDefault();
const button = this; const button = this;
button.disabled = true; button.disabled = true;
button.textContent = "Predicting...";
// Set text depending on mode
if (document.getElementById("predict-submit-button").innerText === "Build") {
button.textContent = "Building...";
} else {
button.textContent = "Predicting...";
}
// Get SMILES from either input or Ketcher // Get SMILES from either input or Ketcher
const smilesInput = document.getElementById("predict-smiles"); const smilesInput = document.getElementById("predict-smiles");

View File

@ -110,8 +110,6 @@
<div <div
class="text-base-content/50 flex items-center justify-center space-x-6 text-sm" class="text-base-content/50 flex items-center justify-center space-x-6 text-sm"
> >
<a href="/legal" class="link link-hover">Legal</a>
<span class="text-base-content/30"></span>
<a href="/terms" class="link link-hover">Terms of Use</a> <a href="/terms" class="link link-hover">Terms of Use</a>
<span class="text-base-content/30"></span> <span class="text-base-content/30"></span>
<a href="/privacy" class="link link-hover">Privacy Policy</a> <a href="/privacy" class="link link-hover">Privacy Policy</a>

View File

@ -1,5 +1,7 @@
from django.conf import settings as s from django.conf import settings as s
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, CompoundStructure from epdb.models import Compound, User, CompoundStructure
@ -33,13 +35,13 @@ class CompoundTest(TestCase):
self.assertEqual(c.description, "No Desc") self.assertEqual(c.description, "No Desc")
def test_missing_smiles(self): def test_missing_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc")
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc")
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc") _ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
def test_smiles_are_trimmed(self): def test_smiles_are_trimmed(self):
@ -96,7 +98,7 @@ class CompoundTest(TestCase):
self.assertEqual(len(self.package.compounds), 1) self.assertEqual(len(self.package.compounds), 1)
def test_wrong_smiles(self): def test_wrong_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Compound.create( _ = Compound.create(
self.package, self.package,
smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F", smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F",

View File

@ -1,6 +1,7 @@
from django.conf import settings as s from django.conf import settings as s
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, Reaction, Rule from epdb.models import Compound, User, Reaction, Rule
@ -163,7 +164,7 @@ class ReactionTest(TestCase):
self.assertEqual(len(self.package.reactions), 1) self.assertEqual(len(self.package.reactions), 1)
def test_wrong_smiles(self): def test_wrong_smiles(self):
with self.assertRaises(ValueError): with self.assertRaises(InvalidSMILESException):
_ = Reaction.create( _ = Reaction.create(
package=self.package, package=self.package,
name="Eawag BBD reaction r0001", name="Eawag BBD reaction r0001",

View File

@ -46,6 +46,14 @@ class ModelViewTest(TestCase):
) )
expected = [ expected = [
{
"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",
"name": "bt0001-3568",
},
},
{ {
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]], "products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]],
"probability": 0.25, "probability": 0.25,
@ -62,14 +70,6 @@ class ModelViewTest(TestCase):
"name": "bt0243-4301", "name": "bt0243-4301",
}, },
}, },
{
"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",
"name": "bt0001-3568",
},
},
] ]
actual = response.json()["pred"] actual = response.json()["pred"]

View File

@ -125,6 +125,7 @@ from envipy_additional_information.parsers import (
LiquidMatrixSourceParser, LiquidMatrixSourceParser,
OxygenUptakeRateParser, OxygenUptakeRateParser,
InitiatingOrganismParser, InitiatingOrganismParser,
PFASConfidenceParser,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -141,7 +142,7 @@ def get_parameter(request, paramname):
res = request.POST.get(paramname) res = request.POST.get(paramname)
if res is not None and res.strip() != "": if res is not None and res.strip() != "":
return res return res
return ValueError("Not all parameters are set!") raise ValueError("Not all parameters are set!")
def get_parameter_or_empty_string(request, paramname): def get_parameter_or_empty_string(request, paramname):
@ -673,7 +674,8 @@ def build_additional_information_from_request(request, type_):
elif type_ == "studywst": elif type_ == "studywst":
# study_wst_cond = get_parameter(request, "studywstcond") # study_wst_cond = get_parameter(request, "studywstcond")
raise ValueError("studywstcond is not yet implemented") raise ValueError("studywstcond is not yet implemented")
elif type_ == "pfasconfidence":
return PFASConfidenceParser.from_string(get_parameter(request, "level"))
else: else:
raise ValueError(f"No corresponding AdditionalInformation for {type_} found!") raise ValueError(f"No corresponding AdditionalInformation for {type_} found!")

File diff suppressed because it is too large Load Diff

2
uv.lock generated
View File

@ -894,7 +894,7 @@ provides-extras = ["ms-login", "dev", "pepper-plugin"]
[[package]] [[package]]
name = "envipy-additional-information" name = "envipy-additional-information"
version = "0.4.2" version = "0.4.2"
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#676dae1c5678539beac637b87e49b9dadfdfd85a" } source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#ad825570480bbe2f1a35c04923fede756c450751" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]