3 Commits

Author SHA1 Message Date
86b456748b Show Pack Classification 2026-04-14 12:00:17 +02:00
9afa122abe Initial bayer app 2026-04-14 12:00:17 +02:00
fb073438db adjusted migration 2026-04-14 12:00:17 +02:00
103 changed files with 1504 additions and 1615751 deletions

View File

@ -6,23 +6,18 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
openssh-client \
git \
ca-certificates \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install Node 22 + 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/*
# Install pnpm
RUN npm install -g pnpm
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}"
@ -35,21 +30,17 @@ RUN mkdir -p -m 0700 /root/.ssh \
&& ssh-keyscan git.envipath.com >> /root/.ssh/known_hosts
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
# docker build --ssh default -t envipath/envipy-bayer:1.0 .
# docker build --ssh default -t envipath/envipy:1.0 .
RUN --mount=type=ssh \
uv sync --locked --extra ms-login --extra pepper-plugin
# Now copy source and do a final sync to install the project itself
# Ensure .dockerignore is reasonable
COPY bb4g bb4g
COPY biotransformer biotransformer
COPY bayer bayer
COPY bridge bridge
COPY envipath envipath
COPY epapi epapi
COPY epauth epauth
COPY epdb epdb
COPY epiuclid epiuclid
COPY fixtures fixtures
COPY migration migration
COPY pepper pepper

View File

@ -1,178 +0,0 @@
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,19 +1,3 @@
from django.contrib import admin
# Register your models here.
from .models import (
PESCompound,
PESStructure
)
class PESCompoundAdmin(admin.ModelAdmin):
pass
class PESStructureAdmin(admin.ModelAdmin):
pass
admin.site.register(PESCompound, PESCompoundAdmin)
admin.site.register(PESStructure, PESStructureAdmin)

View File

@ -1,40 +0,0 @@
import logging
from bayer import additional_information # noqa: F401
from epdb.template_registry import register_template
logger = logging.getLogger(__name__)
# PES Create
register_template(
"epdb.actions.collections.compound",
"actions/collections/new_pes.html",
)
register_template(
"modals.collections.compound",
"modals/collections/new_pes_modal.html",
)
register_template(
"epdb.actions.objects.pathway.add",
"actions/objects/pathway_add_pes.html",
)
register_template(
"epdb.modals.objects.pathway.add",
"modals/objects/add_pathway_pes_node_modal.html"
)
# PES Viz
register_template(
"epdb.objects.compound.viz",
"objects/compound_viz.html",
)
register_template(
"epdb.objects.compound_structure.viz",
"objects/compound_structure_viz.html",
)
register_template(
"epdb.objects.node.viz",
"objects/node_viz.html",
)

View File

@ -1,41 +0,0 @@
# Generated by Django 6.0.3 on 2026-04-17 21:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bayer', '0002_initial'),
('epdb', '0023_alter_compoundstructure_options_and_more'),
]
operations = [
migrations.CreateModel(
name='PESCompound',
fields=[
('compound_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.compound')),
],
options={
'abstract': False,
},
bases=('epdb.compound',),
),
migrations.CreateModel(
name='PESStructure',
fields=[
('compoundstructure_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='epdb.compoundstructure')),
('pes_link', models.URLField(verbose_name='PES Link')),
],
options={
'abstract': False,
},
bases=('epdb.compoundstructure',),
),
migrations.AddField(
model_name='package',
name='data_pool',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.group', verbose_name='Data pool'),
),
]

View File

@ -1,21 +1,16 @@
from typing import List
import urllib.parse
import nh3
from django.conf import settings as s
from django.db import models, transaction
from django.db import models
from django.db.models import QuerySet
from django.urls import reverse
from epdb.models import (
EnviPathModel,
Compound,
CompoundStructure,
ParallelRule,
SequentialRule,
SimpleAmbitRule,
SimpleRDKitRule,
)
from utilities.chem import FormatConverter
class Package(EnviPathModel):
@ -34,9 +29,6 @@ class Package(EnviPathModel):
default=Classification.RESTRICTED,
)
data_pool = models.ForeignKey("epdb.Group", on_delete=models.SET_NULL, blank=True, null=True,
verbose_name="Data pool", default=None)
def delete(self, *args, **kwargs):
# explicitly handle related Rules
for r in self.rules.all():
@ -100,138 +92,4 @@ class Package(EnviPathModel):
return rules
class Meta:
db_table = "epdb_package"
class PESCompound(Compound):
@staticmethod
@transaction.atomic
def create(
package: "Package", pes_data: dict, name: str = None, description: str = None, *args, **kwargs
) -> "Compound":
pes_url = pes_data["pes_url"]
# Check if we find a direct match for a given pes_link
if PESStructure.objects.filter(pes_link=pes_url, compound__package=package).exists():
# Due to normalization we might end up in having multiple structures
# All of them point to the same compound -> pick any
return PESStructure.objects.filter(pes_link=pes_url, compound__package=package).first().compound
# Generate Compound
c = PESCompound()
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 == "":
name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
c.name = name
# We have a default here only set the value if it carries some payload
if description is not None and description.strip() != "":
c.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
c.save()
molfile = pes_data.get("representativeStructures", [{}])[0].get("ctab")
if molfile is None:
raise ValueError("PES data does not contain a valid mol file!")
smiles = FormatConverter.to_smiles(FormatConverter.from_molfile(molfile))
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
is_standardized = standardized_smiles == smiles
if not is_standardized:
_ = PESStructure.create(
c,
pes_url,
molfile,
standardized_smiles,
name="Normalized structure of {}".format(name),
description="{} (in its normalized form)".format(description),
normalized_structure=True,
)
cs = PESStructure.create(
c,
pes_url,
molfile,
smiles,
name=name,
description=description,
normalized_structure=is_standardized
)
c.default_structure = cs
c.save()
return c
class PESStructure(CompoundStructure):
pes_link = models.URLField(blank=False, null=False, verbose_name="PES Link")
@staticmethod
@transaction.atomic
def create(
compound: Compound,
pes_link: str,
molfile: str,
smiles: str,
name: str = None,
description: str = None,
*args,
**kwargs
):
if compound.pk is None:
raise ValueError("Unpersisted Compound! Persist compound first!")
cs = PESStructure()
# Clean for potential XSS
if name is not None:
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
cs.smiles = smiles
cs.molfile = molfile
cs.pes_link = pes_link
cs.compound = compound
if "normalized_structure" in kwargs:
cs.normalized_structure = kwargs["normalized_structure"]
cs.save()
return cs
@transaction.atomic
def add_structure(
self,
smiles: str,
name: str = None,
description: str = None,
default_structure: bool = False,
*args,
**kwargs,
) -> "CompoundStructure":
raise ValueError("Not supported!")
def d3_json(self):
return {
"is_pes": True,
"pes_link": self.pes_link,
# Will overwrite image from Node
"image": f"{reverse('depict_pes')}?pesLink={urllib.parse.quote(self.pes_link)}",
"image_type": "png",
}
db_table = "epdb_package"

View File

@ -1,9 +0,0 @@
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_pes_modal').showModal(); return false;"
>
New PES
</button>
{% endif %}

View File

@ -1,8 +0,0 @@
<li>
<a
class="button"
onclick="document.getElementById('add_pathway_pes_node_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Add PES</a
>
</li>

View File

@ -1,175 +0,0 @@
{% load static %}
<dialog
id="new_package_modal"
class="modal"
x-data="{
isSubmitting: false,
packageClassification: null,
reset() {
this.isSubmitting = false;
this.packageClassification = null;
},
setFormData(data) {
this.formData = data;
},
get isSecret() {
return this.packageClassification === '20';
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New Package</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new_package_form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
<!-- Name -->
<div class="form-control mb-3">
<label class="label" for="package-name">
<span class="label-text">Name</span>
</label>
<input
id="package-name"
class="input input-bordered w-full"
name="package-name"
placeholder="Name"
required
/>
</div>
<!-- Description -->
<div class="form-control mb-3">
<label class="label" for="package-description">
<span class="label-text">Description</span>
</label>
<input
id="package-description"
type="text"
class="input input-bordered w-full"
placeholder="Description..."
name="package-description"
/>
</div>
<!-- Classification Level -->
<div class="form-control mb-3">
<label class="label" for="package-classification">
<span class="label-text">Package Classification</span>
</label>
<select
id="package-classification"
name="package-classification"
class="select select-bordered w-full"
x-model="packageClassification"
required
>
<option value="null" disabled selected>Select Classification</option>
<option value="0">Internal</option>
<option value="10">Restricted</option>
<option value="20">Secret</option>
</select>
</div>
<!-- Secret Groups -->
<div class="form-control mb-3" x-show="isSecret" x-cloak>
<label class="label" for="package-data-pool">
<span class="label-text">Data Pool for SECRET Package</span>
</label>
<p>Only users with this role can be granted access to this package</p>
<select
id="package-data-pool"
name="package-data-pool"
class="select select-bordered w-full"
>
<option value="" disabled selected>Select Data Pool</option>
{% for obj in meta.secret_groups %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endfor %}
</select>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new_package_form')"
:disabled="isSubmitting || !selectedType || loadingSchemas"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -1,174 +0,0 @@
{% load static %}
<dialog
id="new_pes_modal"
class="modal"
x-data="{
isSubmitting: false,
pesLink: null,
pesVizHtml: '',
reset() {
this.isSubmitting = false;
},
get isPESSet() {
console.log(this.pesLink);
return this.pesLink !== null;
},
updatePesViz() {
if (!this.isPESSet) {
this.pesVizHtml = '';
return;
}
const img = new Image();
img.src = '{% url 'depict_pes' %}?pesLink=' + encodeURIComponent(this.pesLink);
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.onload = () => {
this.pesVizHtml = img.outerHTML;
};
img.onerror = () => {
this.pesVizHtml = `
<div class='alert alert-error' role='alert'>
<h4 class='alert-heading'>Could not render PES!</h4>
<p>Could not render PES - Do you have access?</p>
</div>`;
};
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New PES</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new-pes-modal-form"
accept-charset="UTF-8"
action="{% url 'create pes' meta.current_package.uuid %}"
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="compound-name">
<span class="label-text">Name</span>
</label>
<input
id="compound-name"
class="input input-bordered w-full"
name="compound-name"
placeholder="Name"
required
/>
</div>
<div class="form-control mb-3">
<label class="label" for="compound-description">
<span class="label-text">Description</span>
</label>
<input
id="compound-description"
class="input input-bordered w-full"
name="compound-description"
placeholder="Description"
/>
</div>
<div class="form-control mb-3">
<label class="label" for="pes-link">
<span class="label-text">Link to PES</span>
</label>
<input
id="pes-link"
name="pes-link"
type="text"
class="input input-bordered w-full"
placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126"
x-model="pesLink"
@input="updatePesViz()"
required
/>
</div>
<div id="pes-viz" class="mb-3" x-html="pesVizHtml"></div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new-pes-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -1,174 +0,0 @@
{% load static %}
<dialog
id="add_pathway_pes_node_modal"
class="modal"
x-data="{
isSubmitting: false,
pesLink: null,
pesVizHtml: '',
reset() {
this.isSubmitting = false;
},
get isPESSet() {
console.log(this.pesLink);
return this.pesLink !== null;
},
updatePesViz() {
if (!this.isPESSet) {
this.pesVizHtml = '';
return;
}
const img = new Image();
img.src = '{% url 'depict_pes' %}?pesLink=' + encodeURIComponent(this.pesLink);
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'cover';
img.onload = () => {
this.pesVizHtml = img.outerHTML;
};
img.onerror = () => {
this.pesVizHtml = `
<div class='alert alert-error' role='alert'>
<h4 class='alert-heading'>Could not render PES!</h4>
<p>Could not render PES - Do you have access?</p>
</div>`;
};
},
submit(formId) {
const form = document.getElementById(formId);
// Remove previously injected inputs
form.querySelectorAll('.dynamic-param').forEach(el => el.remove());
// Add values from dynamic form into the html form
if (this.formData) {
Object.entries(this.formData).forEach(([key, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
input.classList.add('dynamic-param');
form.appendChild(input);
});
}
if (form && form.checkValidity()) {
this.isSubmitting = true;
form.submit();
} else if (form) {
form.reportValidity();
}
}
}"
@close="reset()"
>
<div class="modal-box max-w-3xl">
<!-- Header -->
<h3 class="text-lg font-bold">New PES</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="new-pes-node-modal-form"
accept-charset="UTF-8"
action="{% url 'create pes node' current_object.package.uuid current_object.uuid %}"
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="compound-name">
<span class="label-text">Name</span>
</label>
<input
id="compound-name"
class="input input-bordered w-full"
name="compound-name"
placeholder="Name"
required
/>
</div>
<div class="form-control mb-3">
<label class="label" for="compound-description">
<span class="label-text">Description</span>
</label>
<input
id="compound-description"
class="input input-bordered w-full"
name="compound-description"
placeholder="Description"
/>
</div>
<div class="form-control mb-3">
<label class="label" for="pes-link">
<span class="label-text">Link to PES</span>
</label>
<input
id="pes-link"
name="pes-link"
type="text"
class="input input-bordered w-full"
placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126"
x-model="pesLink"
@input="updatePesViz()"
required
/>
</div>
<div id="pes-viz" class="mb-3" x-html="pesVizHtml"></div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('new-pes-node-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Submit</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Creating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -1,23 +0,0 @@
{% if compound_structure.pes_link %}
<!-- PES -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</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>
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
<div class="collapse-content">
<div class="flex justify-center">
<img src='{% url 'depict_pes' %}?pesLink={{ compound_structure.pes_link|urlencode }}'/>
</div>
</div>
</div>
{% endif %}

View File

@ -1,23 +0,0 @@
{% if compound.default_structure.pes_link %}
<!-- PES -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</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>
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
<div class="collapse-content">
<div class="flex justify-center">
<img src='{% url 'depict_pes' %}?pesLink={{ compound.default_structure.pes_link|urlencode }}'/>
</div>
</div>
</div>
{% endif %}

View File

@ -1,23 +0,0 @@
{% if node.default_node_label.pes_link %}
<!-- PES -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Link to PES</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>
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">PES Image Representation</div>
<div class="collapse-content">
<div class="flex justify-center">
<img src='{% url 'depict_pes' %}?pesLink={{ node.default_node_label.pes_link|urlencode }}'/>
</div>
</div>
</div>
{% endif %}

View File

@ -1,5 +1,5 @@
{% extends "framework_modern.html" %}
{% load static %}
{% block content %}
{% block action_modals %}
@ -16,7 +16,7 @@
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ package.name }} {% if meta.url_contains_package and meta.current_package.get_classification_level_display == "Restricted" %}<img src="{% static 'images/restricted_mid.png' %}" width="100">{% elif meta.url_contains_package and meta.current_package.get_classification_level_display == "Secret" %}<img src="{% static 'images/secret_mid.png' %}" width="60">{% endif %}</h2>
<h2 class="card-title text-2xl">{{ package.name }} - ({{ package.get_classification_level_display }})</h2>
<div id="actionsButton" class="dropdown dropdown-e nd hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg

View File

@ -1,154 +0,0 @@
{% extends "static/login_base.html" %}
{% load static %}
{% block title %}enviPath - Sign In{% endblock %}
{% block extra_styles %}
<style>
/* Tab styling */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
input[type="radio"].tab-radio {
display: none;
}
.tab-label {
cursor: pointer;
padding: 0.75rem 1.5rem;
border-bottom: 2px solid transparent;
transition: all 0.3s ease;
}
.tab-label:hover {
background-color: rgba(0, 0, 0, 0.05);
}
input[type="radio"].tab-radio:checked + .tab-label {
border-bottom-color: #3b82f6;
font-weight: 600;
}
</style>
{% endblock %}
{% block content %}
<div>
<img src="{% static 'images/bayer-logo.svg' %}">
</div>
<div class="flex flex-col space-y-4 ...">
<div><p></p></div>
<div><p></p></div>
</div>
<!-- Tab Navigation -->
<div class="border-base-300 mb-6 border-b" hidden>
<div class="flex justify-start">
<input
type="radio"
name="auth-tab"
id="tab-sso"
class="tab-radio"
checked
/>
<label for="tab-sso" class="tab-label">SSO</label>
<input
type="radio"
name="auth-tab"
id="tab-signin"
class="tab-radio"
/>
<label for="tab-signin" class="tab-label">Local User</label>
</div>
</div>
<!-- SSO Tab -->
<div id="content-sso" class="tab-content active">
<button role="link" onclick="window.location.href='/entra/login'" name="sso" class="btn btn-primary w-full">
Login with Microsoft
</button>
</div>
<!-- Sign In Tab -->
<div id="content-signin" class="tab-content">
<form method="post" action="{% url 'login' %}" class="space-y-4">
{% csrf_token %}
<input type="hidden" name="login" value="true" />
<div class="form-control">
<label class="label" for="username">
<span class="label-text">Account</span>
</label>
<input
type="text"
id="username"
name="username"
placeholder="Username or Email"
class="input input-bordered w-full"
required
autocomplete="username"
/>
</div>
<div class="form-control">
<label class="label" for="passwordinput">
<span class="label-text">Password</span>
</label>
<input
type="password"
id="passwordinput"
name="password"
placeholder="••••••••"
class="input input-bordered w-full"
required
autocomplete="current-password"
/>
</div>
<div class="text-right">
<a href="{% url 'password_reset' %}" class="link link-primary text-sm"
>Forgot password?</a
>
</div>
<input type="hidden" name="next" value="{{ next }}" />
<button type="submit" name="signin" class="btn btn-primary w-full">
Sign In
</button>
</form>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Tab switching functionality
document.querySelectorAll('input[name="auth-tab"]').forEach((radio) => {
radio.addEventListener("change", function () {
// Hide all content
document.querySelectorAll(".tab-content").forEach((content) => {
content.classList.remove("active");
});
// Show selected content
const contentId = "content-" + this.id.replace("tab-", "");
document.getElementById(contentId).classList.add("active");
});
});
// Check for hash in URL to auto-select tab
window.addEventListener("DOMContentLoaded", function () {
const hash = window.location.hash.substring(1); // Remove the # symbol
if (hash === "signup" || hash === "signin") {
const tabRadio = document.getElementById("tab-" + hash);
if (tabRadio) {
tabRadio.checked = true;
// Trigger change event to show correct content
tabRadio.dispatchEvent(new Event("change"));
}
}
});
</script>
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -1,19 +0,0 @@
from django.urls import re_path
from . import views as v
UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
urlpatterns = [
re_path(r"^depict_pes$", v.visualize_pes, name="depict_pes"),
re_path(
rf"^package/(?P<package_uuid>{UUID})/pes$",
v.create_pes,
name="create pes",
),
re_path(
rf"^package/(?P<package_uuid>{UUID})/pathway/(?P<pathway_uuid>{UUID})/pes$",
v.create_pes_node,
name="create pes node",
),
]

View File

@ -1,177 +1,3 @@
import base64
from django.shortcuts import render
import requests
from django.conf import settings as s
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from bayer.models import PESCompound
from epdb.logic import PackageManager
from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real, error
from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL()
@package_permission_required()
def create_pes(request, package_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL:
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_description = request.POST.get('compound-description')
pes_link = request.POST.get('pes-link')
if pes_link:
try:
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return HttpResponseBadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
return redirect(pes.url)
else:
return HttpResponseBadRequest("Please provide a PES link.")
else:
pass
@package_permission_required()
def create_pes_node(request, package_uuid, pathway_uuid):
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
current_pathway = Pathway.objects.get(package=current_package, uuid=pathway_uuid)
if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL:
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_description = request.POST.get('compound-description')
pes_link = request.POST.get('pes-link')
if pes_link:
try:
pes_data = fetch_pes(request, pes_link)
except ValueError as e:
return HttpResponseBadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return HttpResponseBadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return HttpResponseBadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
node_qs = Node.objects.filter(pathway=current_pathway, default_node_label=pes.default_structure)
if node_qs.exists():
return redirect(current_pathway.url)
n = Node()
n.stereo_removed = False
n.pathway = current_pathway
n.depth = 0
n.default_node_label = pes.default_structure
n.save()
n.node_labels.add(pes.default_structure)
n.save()
return redirect(current_pathway.url)
else:
return HttpResponseBadRequest("Please provide a PES link.")
else:
pass
def fetch_pes(request, pes_url) -> dict:
from epauth.views import get_access_token_from_request
token = get_access_token_from_request(request)
if token is None:
token = pes_url.split('/')[-1] == 'dummy'
if token:
for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k):
pes_id = pes_url.split('/')[-1]
if pes_id == 'dummy':
import json
res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json"))
res_data["pes_url"] = pes_url
return res_data
else:
headers = {"Authorization": f"Bearer {token['access_token']}"}
params = {"pes_reg_entity_corporate_id": pes_id}
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
try:
res.raise_for_status()
pes_data = res.json()
if len(pes_data) == 0:
raise ValueError(f"PES with id {pes_id} not found")
res_data = pes_data[0]
res_data["pes_url"] = pes_url
return res_data
except requests.exceptions.HTTPError as e:
raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
else:
raise ValueError(f"Unknown URL {pes_url}")
else:
raise ValueError("Could not fetch access token from request.")
def visualize_pes(request):
pes_link = request.GET.get('pesLink')
if pes_link:
pes_data = fetch_pes(request, pes_link)
representations = pes_data.get('representations')
for rep in representations:
if rep.get('type') == 'color':
image_data = base64.b64decode(rep.get('base64').replace("data:image/png;base64,", ""))
return HttpResponse(image_data, content_type="image/png")
# Create your views here.

View File

@ -1,198 +0,0 @@
import enum
import json
import logging
import math
from datetime import datetime
from typing import List
import requests
from django.conf import settings as s
from envipy_additional_information import register, EnviPyModel, UIConfig, WidgetType
from bridge.contracts import Classifier # noqa: I001
from bridge.dto import (
BuildResult,
EnviPyDTO,
EvaluationResult,
RunResult,
TransformationProductPrediction,
) # noqa: I001
logger = logging.getLogger("epdb")
class SamplingAlgorithm(enum.Enum):
EXACT = "exact"
@register("bb4gconfig")
class BB4GConfig(EnviPyModel):
sampling_algorithm: SamplingAlgorithm = SamplingAlgorithm.EXACT
cutoff: int = -5
class UI:
title = "BB4G Configuration"
sampling_algorithm = UIConfig(
widget=WidgetType.SELECT,
label="BB4G Sampling Algorithm",
order=1,
placeholder="If unset defaults to 'exact'"
)
cutoff = UIConfig(
widget=WidgetType.NUMBER,
label="BB4G Cutoff",
order=2,
placeholder="If unset defaults to -5"
)
# Once stable these will be exposed by enviPy-plugins lib
class BB4G(Classifier):
Config = BB4GConfig
def __init__(self, config: BB4GConfig | None = None):
super().__init__(config)
self.url = f"{s.BB4G_URL}"
self.token = self.acquire_token()
self.header = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
def acquire_token(self):
BB4G_TENANT_ID = s.BB4G_TENANT_ID
BB4G_CLIENT_ID = s.BB4G_CLIENT_ID
BB4G_CLIENT_SECRET = s.BB4G_CLIENT_SECRET
BB4G_SCOPE = s.BB4G_SCOPE
BB4G_TOKEN_URL = f"https://login.microsoftonline.com/{BB4G_TENANT_ID}/oauth2/v2.0/token"
payload = {
"client_id": BB4G_CLIENT_ID,
"client_secret": BB4G_CLIENT_SECRET,
"scope": BB4G_SCOPE,
"grant_type": "client_credentials"
}
# No Proxy required, URL is whitelisted
res = requests.post(BB4G_TOKEN_URL, data=payload)
res.raise_for_status()
return res.json()["access_token"]
def start(self):
header = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
started = False
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:
started = True
elif res.status_code in [500, 502]:
import time
time.sleep(5)
else:
raise ValueError(f"Unexpected status code: {res.status_code}")
@classmethod
def requires_rule_packages(cls) -> bool:
return False
@classmethod
def requires_data_packages(cls) -> bool:
return False
@classmethod
def identifier(cls) -> str:
return "bb4g"
@classmethod
def name(cls) -> str:
return "BB4G Template Free Model"
@classmethod
def display(cls) -> str:
return "BB4G Template Free Model"
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
return
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
# Ensure Service is running
self.start()
smiles = [c.smiles for c in eP.get_compounds()]
preds = self._post(smiles)
results = []
for substrate in preds.keys():
results.append(
TransformationProductPrediction(
substrate=substrate,
products=preds[substrate],
)
)
return RunResult(
producer=eP.get_context().url,
description=f"Generated at {datetime.now()}",
result=results,
)
def evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
pass
def build_and_evaluate(self, eP: EnviPyDTO, *args, **kwargs) -> EvaluationResult:
pass
def _post(self, smiles: List[str]) -> dict[str, dict[str, float]]:
header = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
result = {}
for smi in smiles:
data = {
"smiles": smi,
"sampling_alg": self.config.sampling_algorithm.value,
"cutoff": self.config.cutoff,
}
retries = 0
while retries < 100:
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data),
proxies=s.PROXIES or None)
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
resp.raise_for_status()
for substrate, predictions in resp.json().items():
preds = {}
for pred in predictions:
prod = pred["prediction"]
prob = math.exp(pred["log_likelihood"])
preds[prod] = prob
result[substrate] = preds
break
return result

View File

@ -254,14 +254,7 @@ class Classifier(Plugin):
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
if cls.Config is None:
return None
# remove empty strings a.k.a unset params to not overwrite defaults
cpy = {}
if data is not None:
for k, v in data.items():
if v != "":
cpy[k] = v
return cls.Config(**cpy)
return cls.Config(**(data or {}))
@classmethod
def create(cls, data: dict | None = None):

View File

@ -1,43 +1,26 @@
services:
db:
image: postgres:18
container_name: eppostgres
container_name: envipath-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: envipath
ports:
- "5432:5432"
volumes:
- ep_bayer_postgres_data:/var/lib/postgresql
- postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: epredis
container_name: envipath-redis
ports:
- "6379:6379"
volumes:
- ep_bayer_redis_data:/data
biotransformer3:
image: git.envipath.com/envipath/biotransformer3:1.0
container_name: epbiotransformer3
celery_worker:
image: git.envipath.com/envipath/envipy-bayer:1.2
container_name: epcelery
env_file:
- .env
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes:
- ep_bayer_data:/opt/enviPy/
volumes:
ep_bayer_postgres_data:
ep_bayer_redis_data:
ep_bayer_data:
postgres_data:

View File

@ -7,7 +7,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- ep_bayer_postgres_data:/var/lib/postgresql
- ep_postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres" ]
interval: 5s
@ -18,14 +18,14 @@ services:
image: redis:7-alpine
container_name: epredis
volumes:
- ep_bayer_redis_data:/data
- ep_redis_data:/data
biotransformer3:
image: envipath/biotransformer3:1.0
container_name: epbiotransformer3
web:
image: envipath/envipy-bayer:1.0
image: envipath/envipy:1.0
container_name: epdjango
ports:
- "127.0.0.1:8000:8000"
@ -33,18 +33,18 @@ services:
- .env
command: gunicorn envipath.wsgi:application --bind 0.0.0.0:8000 --workers 3
volumes:
- ep_bayer_data:/opt/enviPy/
- ep_data:/opt/enviPy/
celery_worker:
image: envipath/envipy-bayer:1.0
image: envipath/envipy:1.0
container_name: epcelery
env_file:
- .env
command: celery -A envipath worker --concurrency=6 -Q model,predict,background --pool threads
volumes:
- ep_bayer_data:/opt/enviPy/
- ep_data:/opt/enviPy/
volumes:
ep_bayer_postgres_data:
ep_bayer_redis_data:
ep_bayer_data:
ep_postgres_data:
ep_redis_data:
ep_data:

View File

@ -9,7 +9,7 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import json
import os
from pathlib import Path
@ -20,7 +20,7 @@ from sklearn.tree import DecisionTreeClassifier
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env.dev")
ENV_PATH = os.environ.get("ENV_PATH", BASE_DIR / ".env")
print(f"Loading env from {ENV_PATH}")
load_dotenv(ENV_PATH, override=False)
@ -143,12 +143,6 @@ if os.environ.get("USE_TEMPLATE_DB", False) == "True":
"TEMPLATE": os.environ["TEMPLATE_DB"],
}
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
@ -275,12 +269,6 @@ LOGGING = {
"filename": os.path.join(LOG_DIR, "debug.log"),
"formatter": "simple",
},
"auth_file": {
"level": "INFO", # Or higher
"class": "logging.FileHandler",
"filename": os.path.join(LOG_DIR, "auth.log"),
"formatter": "simple",
}
},
"loggers": {
# For everything under epdb/ loaded via getlogger(__name__)
@ -301,11 +289,6 @@ LOGGING = {
"propagate": True,
"level": os.environ.get("LOG_LEVEL", "INFO"),
},
"auth": {
"handlers": ["auth_file"],
"propagate": True,
"level": os.environ.get("LOG_LEVEL", "INFO"),
}
},
}
@ -354,7 +337,7 @@ DEFAULT_MODEL_PARAMS = {
"num_chains": 10,
}
DEFAULT_MAX_NUMBER_OF_NODES = 9999
DEFAULT_MAX_NUMBER_OF_NODES = 50
DEFAULT_MAX_DEPTH = 8
DEFAULT_MODEL_THRESHOLD = 0.25
@ -459,48 +442,3 @@ BIOTRANSFORMER_ENABLED = os.environ.get("BIOTRANSFORMER_ENABLED", "False") == "T
FLAGS["BIOTRANSFORMER"] = BIOTRANSFORMER_ENABLED
if BIOTRANSFORMER_ENABLED:
BIOTRANSFORMER_URL = os.environ.get("BIOTRANSFORMER_URL", None)
# PES
PES_API_MAPPING = os.environ.get("PES_API_MAPPING", None)
if PES_API_MAPPING:
import json
PES_API_MAPPING = json.loads(PES_API_MAPPING)
else:
PES_API_MAPPING = {}
# Entra Groups
ENTRA_GROUPS = os.environ.get("ENTRA_GROUPS", None)
if ENTRA_GROUPS:
import json
ENTRA_GROUPS = json.loads(ENTRA_GROUPS)
else:
ENTRA_GROUPS = {}
ENTRA_SECRET_GROUPS = os.environ.get("ENTRA_SECRET_GROUPS", None)
if ENTRA_SECRET_GROUPS:
import json
ENTRA_SECRET_GROUPS = json.loads(ENTRA_SECRET_GROUPS)
else:
ENTRA_SECRET_GROUPS = {}
# PES Data Pools vs Entra Mapping
DATA_POOL_MAPPING = os.environ.get("DATA_POOL_MAPPING", None)
if DATA_POOL_MAPPING:
import json
DATA_POOL_MAPPING = json.loads(DATA_POOL_MAPPING)
else:
DATA_POOL_MAPPING = {}
PROXIES = {}
if os.environ.get("HTTP_PROXY"):
PROXIES["http"] = os.environ.get("HTTP_PROXY")
PROXIES["https"] = os.environ.get("HTTPS_PROXY")
# BB4g
BB4G_URL = os.environ.get("BB4G_URL")
BB4G_TENANT_ID = os.environ.get("BB4G_TENANT_ID")
BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID")
BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET")
BB4G_SCOPE = os.environ.get("BB4G_SCOPE")
os.environ["NO_PROXY"] = "localhost,127.0.0.1,epbiotransformer3"

View File

@ -40,9 +40,6 @@ if "migration" in s.INSTALLED_APPS:
if s.MS_ENTRA_ENABLED:
urlpatterns.append(path(f"{PATH_PREFIX}", include("epauth.urls")))
if s.TENANT != "public":
urlpatterns.append(path(f"{PATH_PREFIX}", include(f"{s.TENANT}.urls")))
# Custom error handlers
handler400 = "epdb.views.handler400"
handler403 = "epdb.views.handler403"

View File

@ -1,23 +0,0 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from epdb.logic import GroupManager
from ..pagination import EnhancedPageNumberPagination
from ..schemas import GroupOutSchema
router = Router()
@router.get("/groups/", response=EnhancedPageNumberPagination.Output[GroupOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_groups(request):
"""
List all groups the user has access to.
"""
user = request.user
return GroupManager.get_groups(user)

View File

@ -1,26 +0,0 @@
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,9 +15,9 @@ router = Router()
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_settings(request):
def list_all_pathways(request):
"""
List all settings the user has access to.
List all pathways from reviewed packages.
"""
user = request.user
return SettingManager.get_all_settings(user)

View File

@ -1,7 +1,6 @@
from ninja import Router
from ninja.security import SessionAuth
from envipath import settings as s
from .auth import BearerTokenAuth
from .endpoints import (
packages,
@ -14,9 +13,8 @@ from .endpoints import (
structure,
additional_information,
settings,
groups,
joblogs,
)
from envipath import settings as s
# Main router with authentication
router = Router(
@ -37,8 +35,6 @@ router.add_router("", models.router)
router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)
router.add_router("", groups.router)
router.add_router("", joblogs.router)
if s.IUCLID_EXPORT_ENABLED:
from epiuclid.api import router as iuclid_router

View File

@ -1,10 +1,7 @@
from datetime import datetime
from ninja import FilterSchema, FilterLookup, Schema
from typing import Annotated, Optional, List, Dict, Any
from uuid import UUID
from django.urls import reverse
from ninja import Field, FilterSchema, FilterLookup, Schema
# Filter schema for query parameters
class ReviewStatusFilter(FilterSchema):
@ -129,30 +126,3 @@ class SettingOutSchema(Schema):
url: str = ""
name: str
description: str
class GroupOutSchema(Schema):
uuid: UUID
url: str = ""
name: 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})

View File

@ -5,5 +5,4 @@ from . import views
urlpatterns = [
path("entra/login/", views.entra_login, name="entra_login"),
path("auth/redirect/", views.entra_callback, name="entra_callback"),
path("auth/token/", views.get_token, name="get_token"),
]

View File

@ -1,32 +1,10 @@
import msal
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.http import HttpResponse
from django.shortcuts import redirect
from django.contrib.auth import get_user_model
from epdb.logic import UserManager, GroupManager
from epdb.models import Group
def get_msal_app_with_cache(request):
"""
Create MSAL app with session-based token cache.
"""
cache = msal.SerializableTokenCache()
# Load cache from session if it exists
if request.session.get("msal_token_cache"):
cache.deserialize(request.session["msal_token_cache"])
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
authority=s.MS_ENTRA_AUTHORITY,
token_cache=cache,
)
return msal_app, cache
from epdb.logic import UserManager
def entra_login(request):
@ -45,7 +23,11 @@ def entra_login(request):
def entra_callback(request):
msal_app, cache = get_msal_app_with_cache(request)
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
authority=s.MS_ENTRA_AUTHORITY,
)
flow = request.session.pop("msal_auth_flow", None)
if not flow:
@ -54,18 +36,11 @@ def entra_callback(request):
# Acquire token using the flow and callback request
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
# Save the token cache to session
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
claims = result["id_token_claims"]
user_name = claims.get("name")
user_email = claims.get("emailaddress", claims.get("email"))
user_oid = claims.get("oid")
if not all([user_name, user_email, user_oid]):
raise ValueError("Missing required claims in ID token")
user_name = claims["name"]
user_email = claims["emailaddress"]
user_oid = claims["oid"]
# Get implementing class
User = get_user_model()
@ -82,89 +57,4 @@ def entra_callback(request):
login(request, u)
# EDIT START
# Ensure groups exists in eP
for id, name in s.ENTRA_SECRET_GROUPS.items():
if not Group.objects.filter(uuid=id).exists():
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
uuid=id)
else:
g = Group.objects.get(uuid=id)
# Ensure its secret
g.secret = True
g.save()
for id, name in s.ENTRA_GROUPS.items():
if not Group.objects.filter(uuid=id).exists():
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
uuid=id)
else:
g = Group.objects.get(uuid=id)
for group_uuid in claims.get("groups", []):
if Group.objects.filter(uuid=group_uuid).exists():
g = Group.objects.get(uuid=group_uuid)
g.user_member.add(u)
# EDIT END
return redirect(s.SERVER_URL) # Handle errors
def get_access_token_from_request(request, scopes=None):
"""
Get an access token from the request using MSAL token cache.
"""
# Check if auth via Access Token
if request.headers.get("Authorization"):
return {"access_token": request.headers.get("Authorization").split(" ")[1]}
if scopes is None:
scopes = s.MS_ENTRA_SCOPES
# Get user from request (must be authenticated)
if not request.user.is_authenticated:
return None
# Create MSAL app with persistent cache
msal_app, cache = get_msal_app_with_cache(request)
# Try to get accounts from cache
accounts = msal_app.get_accounts()
if not accounts:
return None
# Find the account that matches the current user
user_account = None
for account in accounts:
if account.get("local_account_id") == str(request.user.uuid):
user_account = account
break
# If no matching account found, use the first available account
if not user_account and accounts:
user_account = accounts[0]
if not user_account:
return None
# Try to acquire token silently from cache
result = msal_app.acquire_token_silent(scopes=scopes, account=user_account)
# Save cache changes back to session
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
if result and "access_token" in result:
return result
return None
def get_token(request):
token = get_access_token_from_request(request)
msg = f"{token}"
return HttpResponse(msg, content_type='text/plain')
return redirect("/") # Handle errors

View File

@ -1,8 +1,5 @@
import logging
from django.conf import settings as s
from django.contrib import admin
from django.contrib import messages
from .models import (
AdditionalInformation,
@ -32,8 +29,6 @@ from .models import (
Package = s.GET_PACKAGE_MODEL()
logger = logging.getLogger(__name__)
class AdditionalInformationAdmin(admin.ModelAdmin):
pass
@ -50,113 +45,6 @@ class UserAdmin(admin.ModelAdmin):
"date_joined",
]
actions = ["send_welcome_mail", "send_affiliation_mail"]
@admin.action(description="Send welcome mail")
def send_welcome_mail(self, request, queryset):
from django.core.mail import EmailMultiAlternatives
tpl = """Hello {username},
Your account has been successfully activated.
To log in, please visit
https://envipath.org/password_reset/
and request a new password.
If you have any questions or feedback, feel free to visit our community forum at
https://community.envipath.org/.
You do not need to register again for the forum - you can log in using your enviPath account by clicking "Log In" and then "Log in with enviPath."
Best regards,
The enviPath Team"""
users = []
for user in queryset:
if user.is_active:
logger.info(f"{user.username} already active - not sending mail again")
continue
try:
msg = EmailMultiAlternatives(
"Your enviPath Account Is Now Active",
tpl.format(username=user.username),
"admin@envipath.org",
[user.email],
bcc=["admin@envipath.org"],
)
msg.send(fail_silently=False)
user.is_active = True
user.password = "ASDF"
user.save()
users.append(user)
logger.info(f"{user.username} -> {user.email} mail sent")
except Exception as e:
logger.info(f"Error sending mail to {user.username}: {e}")
self.message_user(
request, f"Sent welcome mail to {[u.email for u in users]}", messages.SUCCESS
)
@admin.action(description="Send affiliation mail")
def send_affiliation_mail(self, request, queryset):
from django.core.mail import EmailMultiAlternatives
tpl = """Dear {username},
Thank you for your interest in enviPath!
Please note that the public enviPath system is intended for non-commercial use only.
We see that you registered using the email address {email}.
If possible, we kindly ask you to register using an official email address that reflects your affiliation (e.g., a university, NGO, or research organization).
If you would like us to update your account, simply reply to this email and let us know which address we should use.
We will then change it in our system, and you will receive a password reset email at the new address.
If you are registering with a company email address and are interested in commercial use, you are very welcome to book a meeting with us so we can discuss how we can best support you.
To book a meeting, please visit https://envipath.com/book
If changing to an affiliation email address is not possible, please contact us at registration@envipath.org
Best regards,
enviPath team"""
users = []
for user in queryset:
if user.is_active or user.contacted:
logger.info(
f"{user.username} already active or already contacted - not sending mail again"
)
continue
try:
msg = EmailMultiAlternatives(
"Regarding your enviPath registration",
tpl.format(username=user.username, email=user.email),
"admin@envipath.org",
[user.email],
bcc=["admin@envipath.org"],
)
msg.send(fail_silently=False)
user.contacted = True
user.save()
users.append(user)
logger.info(f"{user.username} -> {user.email} affiliation mail sent")
except Exception as e:
logger.info(f"Error sending mail to {user.username}: {e}")
self.message_user(
request, f"Sent affiliation mail to {[u.email for u in users]}", messages.SUCCESS
)
class UserPackagePermissionAdmin(admin.ModelAdmin):
pass

View File

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

View File

@ -1,20 +1,17 @@
from collections import defaultdict
from typing import Any, Dict, List, Optional
import jwt
import nh3
import requests
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from jwt import InvalidIssuerError
from ninja import Field, Form, Query, Router, Schema
from ninja.security import HttpBearer
from ninja.security import SessionAuth
from utilities.chem import FormatConverter
from utilities.misc import PackageExporter
from .logic import (
EPDBURLParser,
GroupManager,
@ -49,26 +46,6 @@ from .models import (
Package = s.GET_PACKAGE_MODEL()
def get_cached_jwks(tenant_id: str, force=False) -> Dict:
"""Get JWKS using Django cache"""
cache_key = f"jwks_{tenant_id}"
jwks = cache.get(cache_key)
if jwks is None or force:
# Cache miss, fetch new keys
jwks_uri = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
response = requests.get(jwks_uri)
response.raise_for_status()
jwks = response.json()
# Cache for 1 hour (3600 seconds)
cache.set(cache_key, jwks, 3600)
return jwks
def get_package_for_write(user, package_uuid):
p = PackageManager.get_package_by_id(user, package_uuid)
if not PackageManager.writable(user, p):
@ -82,52 +59,7 @@ def _anonymous_or_real(request):
return get_user_model().objects.get(username="anonymous")
def validate_token(token: str) -> dict:
TENANT_ID = s.MS_ENTRA_TENANT_ID
CLIENT_ID = s.MS_ENTRA_CLIENT_ID
jwks = get_cached_jwks(TENANT_ID)
header = jwt.get_unverified_header(token)
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(
next(k for k in jwks["keys"] if k["kid"] == header["kid"])
)
# Handle V1 and V2 tokens
try:
claims = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience=[CLIENT_ID, f"api://{CLIENT_ID}"],
issuer=[
f"https://sts.windows.net/{TENANT_ID}/",
f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"
]
)
except Exception as e:
raise ValueError(f"Token verification failed! - {e}")
return claims
class MSBearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
if token is None:
return None
claims = validate_token(token)
if not User.objects.filter(uuid=claims['oid']).exists():
return None
request.user = User.objects.get(uuid=claims['oid'])
return request.user
router = Router(auth=MSBearerTokenAuth())
router = Router(auth=SessionAuth(csrf=False))
class Error(Schema):
@ -221,6 +153,21 @@ class SimpleModel(SimpleObject):
identifier: str = "relative-reasoning"
################
# Login/Logout #
################
@router.post("/", response={200: SimpleUser, 403: Error}, auth=None)
def login(request, loginusername: Form[str], loginpassword: Form[str]):
from django.contrib.auth import authenticate, login
email = User.objects.get(username=loginusername).email
user = authenticate(username=email, password=loginpassword)
if user:
login(request, user)
return user
else:
return 403, {"message": "Invalid username and/or password"}
########
# User #
@ -439,50 +386,23 @@ class PackageSchema(Schema):
@staticmethod
def resolve_readers(obj: Package):
readers = []
users = User.objects.filter(
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.READ[0]
).values_list("user", flat=True)
).distinct()
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
return [{u.id: u.get_name()} for u in users]
@staticmethod
def resolve_writers(obj: Package):
writers = []
users = User.objects.filter(
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.WRITE[0]
).values_list("user", flat=True)
).distinct()
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
return [{u.id: u.get_name()} for u in users]
@staticmethod
def resolve_review_comment(obj):
@ -633,14 +553,9 @@ class CompoundSchema(Schema):
reviewStatus: str = Field(False, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
structures: List["CompoundStructureSchema"] = []
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod
def resolve_pes_link(obj: Compound):
return getattr(obj.default_structure, "pes_link", None)
@staticmethod
def resolve_review_status(obj: Compound):
def resolve_review_status(obj: CompoundStructure):
return "reviewed" if obj.package.reviewed else "unreviewed"
@staticmethod
@ -714,7 +629,6 @@ class CompoundStructureSchema(Schema):
reviewStatus: str = Field(None, alias="review_status")
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
smiles: str = Field(None, alias="smiles")
pesLink: str | None = Field(None, alias="pes_link")
@staticmethod
def resolve_review_status(obj: CompoundStructure):
@ -853,7 +767,6 @@ class CreateCompound(Schema):
compoundName: str | None = None
compoundDescription: str | None = None
inchi: str | None = None
pesLink: str | None = None
@router.post("/package/{uuid:package_uuid}/compound")
@ -865,32 +778,9 @@ def create_package_compound(
try:
p = get_package_for_write(request.user, package_uuid)
# inchi is not used atm
if c.pesLink is not None:
from bayer.views import fetch_pes
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, c.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"}
classification = pes_data.get("classificationLevel", "")
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")
if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
return 400, { "messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"}
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription)
else:
c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
)
c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
)
return redirect(c.url)
except ValueError as e:
return 400, {"message": str(e)}
@ -909,27 +799,6 @@ 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(
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
)
@ -1456,7 +1325,6 @@ class ScenarioSchema(Schema):
aliases: List[str] = Field([], alias="aliases")
collection: Dict["str", List[Dict[str, Any]]] = Field([], alias="collection")
collectionID: Optional[str] = None
date: str = Field(None, alias="scenario_date")
description: str = Field(None, alias="description")
id: str = Field(None, alias="url")
identifier: str = "scenario"
@ -1600,56 +1468,28 @@ def create_package_additional_information(request, package_uuid):
scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen)
if request.POST.get("adInfoTypes[]"):
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
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"):
raise ValueError("Can't attach additional information to this 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!"
)
if not attach_obj.url.startswith(p.url):
raise ValueError(
"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:
ai = build_additional_information_from_request(request, t)
for t in types:
ai = build_additional_information_from_request(request, t)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
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,
)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
content_object=attach_obj,
)
# TODO implement additional information endpoint ?
return redirect(f"{scenario.url}")
@ -1693,15 +1533,13 @@ class PathwayNode(Schema):
dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
id: str = Field(None, alias="url")
idcomp: str = Field(None, alias="node_label_id")
idreact: str = Field(None, alias="node_label_id")
idcomp: str = Field(None, alias="default_node_label.url")
idreact: str = Field(None, alias="default_node_label.url")
image: str = Field(None, alias="image")
imageSize: int = Field(None, alias="image_size")
name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate")
smiles: str = Field(None, alias="smiles")
pseudo: bool = Field(False, alias="pseudo")
pesLink: str | None = Field(None, alias="pes_link")
smiles: str = Field(None, alias="default_node_label.smiles")
@staticmethod
def resolve_atom_count(obj: Node):
@ -1714,10 +1552,24 @@ class PathwayNode(Schema):
# TODO
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
def resolve_image_size(obj: Node):
return 400
@staticmethod
def resolve_proposed_intermediate(obj: Node):
# TODO
return []
class PathwaySchema(Schema):
aliases: List[str] = Field([], alias="aliases")
@ -1841,29 +1693,6 @@ def create_package_pathway(
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}")
def delete_pathway(request, package_uuid, pathway_uuid):
try:
@ -1972,67 +1801,25 @@ class CreateNode(Schema):
nodeName: str | None = None
nodeReason: str | None = None
nodeDepth: str | None = None
pesLink: str | None = None
@router.post(
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node",
response={200: str | Any, 400: Error, 403: Error},
response={200: str | Any, 403: Error},
)
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
try:
p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
# TODO Code Dup from bayer.views
if n.pesLink:
from bayer.views import fetch_pes
from bayer.models import PESCompound
try:
pes_data = fetch_pes(request, n.pesLink)
except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {n.pesLink}"}
classification = pes_data.get("classificationLevel", "")
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")
if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
return 400, {
"messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"
}
c = PESCompound.create(p, pes_data, n.nodeName, n.nodeReason)
node_qs = Node.objects.filter(pathway=pw, default_node_label=c.default_structure)
if node_qs.exists():
return redirect(pw.url)
node = Node()
node.stereo_removed = False
node.pathway = pw
node.depth = 0
node.default_node_label = c.default_structure
node.save()
node.node_labels.add(c.default_structure)
node.save()
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
node_depth = int(n.nodeDepth)
else:
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
node_depth = int(n.nodeDepth)
else:
node_depth = -1
node_depth = -1
node = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
return redirect(node.url)
return redirect(n.url)
except ValueError:
return 403, {"message": "Adding node failed!"}
@ -2142,16 +1929,13 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
educts = []
products = []
subclasses = CompoundStructure.__subclasses__()
if e.edgeAsSmirks:
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
stand_ed = FormatConverter.standardize(ed, remove_stereo=True)
educts.append(
Node.objects.get(
pathway=pw,
default_node_label=CompoundStructure.objects.not_instance_of(*subclasses).
get(
default_node_label=CompoundStructure.objects.get(
compound__package=p, smiles=stand_ed
).compound.default_structure,
)
@ -2162,8 +1946,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
products.append(
Node.objects.get(
pathway=pw,
default_node_label=CompoundStructure.objects.not_instance_of(*subclasses).
get(
default_node_label=CompoundStructure.objects.get(
compound__package=p, smiles=stand_pr
).compound.default_structure,
)
@ -2175,10 +1958,6 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
for pr in e.products.split(","):
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(
pathway=pw,
start_nodes=educts,
@ -2186,15 +1965,11 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
rule=None,
name=None,
description=e.edgeReason,
multi_step=multi_step,
)
# Update depths as sideeffect of above operation
pw.update_depths()
return redirect(new_e.url)
except ValueError:
return 403, {"message": "Adding Edge failed!"}
return 403, {"message": "Adding node failed!"}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
@ -2385,31 +2160,3 @@ def get_setting(request, setting_uuid):
return 403, {
"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

@ -7,7 +7,6 @@ import nh3
from django.conf import settings as s
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import QuerySet
from pydantic import ValidationError
from epdb.models import (
@ -265,12 +264,8 @@ class GroupManager(object):
return bool(re.findall(GroupManager.group_pattern, url))
@staticmethod
def create_group(current_user, name, description, *args, **kwargs):
def create_group(current_user, name, description):
g = Group()
if "uuid" in kwargs:
g.uuid = kwargs["uuid"]
# Clean for potential XSS
g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -346,17 +341,52 @@ class PackageManager(object):
@staticmethod
def readable(user, package):
return (
PackageManager.has_package_permission(user, package, "read") | package.reviewed is True
)
if (
UserPackagePermission.objects.filter(package=package, user=user).exists()
or GroupPackagePermission.objects.filter(
package=package, group__in=GroupManager.get_groups(user)
)
or package.reviewed is True
or user.is_superuser
):
return True
return False
@staticmethod
def writable(user, package):
return PackageManager.has_package_permission(user, package, "write")
if (
UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.WRITE[0]
).exists()
or GroupPackagePermission.objects.filter(
package=package,
group__in=GroupManager.get_groups(user),
permission=Permission.WRITE[0],
).exists()
or UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.ALL[0]
).exists()
or user.is_superuser
):
return True
return False
@staticmethod
def administrable(user, package):
return PackageManager.has_package_permission(user, package, "all")
if (
UserPackagePermission.objects.filter(
package=package, user=user, permission=Permission.ALL[0]
).exists()
or GroupPackagePermission.objects.filter(
package=package,
group__in=GroupManager.get_groups(user),
permission=Permission.ALL[0],
).exists()
or user.is_superuser
):
return True
return False
@staticmethod
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
@ -365,14 +395,6 @@ class PackageManager(object):
groups = GroupManager.get_groups(user)
# EDIT START
if package.classification_level == Package.Classification.SECRET:
if package.data_pool not in groups:
return False
# EDIT END
perms = {"all": ["all"], "write": ["all", "write"], "read": ["all", "write", "read"]}
valid_perms = perms.get(permission)
@ -415,7 +437,6 @@ class PackageManager(object):
try:
p = Package.objects.get(uuid=package_id)
if PackageManager.readable(user, p):
p = PackageManager.check_package_classification(user, p)
return p
else:
# FIXME: use custom exception to be translatable to 403 in API
@ -425,37 +446,6 @@ class PackageManager(object):
except Package.DoesNotExist:
raise ValueError("Package with ID {} does not exist!".format(package_id))
# EDIT START
@staticmethod
def check_package_classification(user, pack: Package):
if pack.classification_level == Package.Classification.SECRET:
if pack.data_pool.user_member.filter(id=user.id).exists():
return pack
raise ValueError("Package is secret and not accessible to user!")
else:
return pack
@staticmethod
def check_package_classifications(user, package_qs: QuerySet[Package]):
non_secret = package_qs.exclude(classification_level=Package.Classification.SECRET)
secret = package_qs.filter(classification_level=Package.Classification.SECRET)
# TODO we should be able to do via the db
accessible_secret = []
for s_package in secret:
if s_package.data_pool.user_member.filter(id=user.id).exists():
accessible_secret.append(s_package.pk)
# Cannot combine a unique query with a non-unique query -> we have to call distinct
return Package.objects.filter(pk__in=accessible_secret).distinct() | non_secret.distinct()
# EDIT END
@staticmethod
def get_all_readable_packages(user, include_reviewed=False):
# UserPermission only exists if at least read is granted...
@ -480,13 +470,7 @@ class PackageManager(object):
# remove package if user is owner and package is reviewed e.g. admin
qs = qs.filter(reviewed=False)
qs = qs.distinct()
# EDIT START
qs = PackageManager.check_package_classifications(user, qs)
# EDIT END
return qs
return qs.distinct()
@staticmethod
def get_all_writeable_packages(user):
@ -530,13 +514,11 @@ class PackageManager(object):
qs = qs.filter(reviewed=False)
qs = qs.distinct()
return qs.distinct()
# EDIT START
qs = PackageManager.check_package_classifications(user, qs)
# EDIT END
return qs
@staticmethod
def get_packages():
return Package.objects.all()
@staticmethod
@transaction.atomic
@ -641,25 +623,6 @@ class PackageManager(object):
else:
pack.reviewed = False
# EDIT START
if data.get("classification"):
if data["classification"] == "INTERNAL":
pack.classification = Package.Classification.RESTRICTED
elif data["classification"] == "RESTRICTED":
pack.classification = Package.Classification.RESTRICTED
elif data["classification"] == "SECRET":
pack.classification = Package.Classification.SECRET
if not "datapool" in data:
raise ValueError("Missing datapool in package")
g = Group.objects.get(uuid=data["datapool"].split('/')[-1])
pack.data_pool = g
else:
raise ValueError(f"Invalid classification {data['classification']}")
# EDIT END
pack.description = data["description"]
pack.save()
@ -745,13 +708,7 @@ class PackageManager(object):
default_structure = None
for structure in compound["structures"]:
if structure.get("pesLink"):
from bayer.models import PESStructure
struc = PESStructure()
struc.pes_link = structure["pesLink"]
else:
struc = CompoundStructure()
struc = CompoundStructure()
# struc.object_url = Command.get_id(structure, keep_ids)
struc.compound = comp
struc.uuid = UUID(structure["id"].split("/")[-1]) if keep_ids else uuid4()
@ -759,10 +716,6 @@ class PackageManager(object):
struc.description = structure["description"]
struc.aliases = structure.get("aliases", [])
struc.smiles = structure["smiles"]
if structure.get("molfile"):
struc.molfile = structure["molfile"]
struc.save()
for scen in structure["scenarios"]:
@ -1065,9 +1018,52 @@ class PackageManager(object):
print("Fixing Node depths...")
total_pws = Pathway.objects.filter(package=pack).count()
for p, pw in enumerate(Pathway.objects.filter(package=pack)):
pw.update_depths()
in_count = defaultdict(lambda: 0)
out_count = defaultdict(lambda: 0)
for e in pw.edges:
# TODO check if this will remain
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
root_nodes = []
for n in pw.nodes:
num_parents = in_count[str(n.uuid)]
if num_parents == 0:
# must be a root node or unconnected node
if n.depth != 0:
n.depth = 0
n.save()
# Only root node may have children
if out_count[str(n.uuid)] > 0:
root_nodes.append(n)
levels = [root_nodes]
seen = set()
# Do a bfs to determine depths starting with level 0 a.k.a. root nodes
for i, level_nodes in enumerate(levels):
new_level = []
for n in level_nodes:
for e in n.out_edges.all():
for prod in e.end_nodes.all():
if str(prod.uuid) not in seen:
old_depth = prod.depth
if old_depth != i + 1:
prod.depth = i + 1
prod.save()
new_level.append(prod)
seen.add(str(n.uuid))
if new_level:
levels.append(new_level)
print(f"{p + 1}/{total_pws} fixed.", end="\r")
return pack
@ -1078,8 +1074,10 @@ class PackageManager(object):
data: Dict[str, Any],
owner: User,
preserve_uuids=False,
add_import_timestamp=True,
trust_reviewed=False,
) -> Package:
importer = PackageImporter(data, preserve_uuids)
importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed)
imported_package = importer.do_import()
up = UserPackagePermission()
@ -1896,12 +1894,6 @@ class SPathway(object):
"to": to_indices,
}
if edge.rule:
e["rule"] = edge.rule.simple_json()
if edge.probability:
e["probability"] = edge.probability
edges.append(e)
return {

View File

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

View File

@ -1,54 +0,0 @@
# Generated by Django 6.0.3 on 2026-04-21 11:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0022_alter_classifierpluginmodel_data_packages_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="compoundstructure",
options={},
),
migrations.AlterModelOptions(
name="epmodel",
options={},
),
migrations.AlterModelOptions(
name="parallelrule",
options={},
),
migrations.AlterModelOptions(
name="rule",
options={},
),
migrations.AlterModelOptions(
name="sequentialrule",
options={},
),
migrations.AlterModelOptions(
name="simpleambitrule",
options={},
),
migrations.AlterModelOptions(
name="simplerdkitrule",
options={},
),
migrations.AlterModelOptions(
name="simplerule",
options={},
),
migrations.AddField(
model_name="compoundstructure",
name="molfile",
field=models.TextField(blank=True, null=True, verbose_name="Molfile"),
),
migrations.AddField(
model_name="group",
name="secret",
field=models.BooleanField(default=False, verbose_name="Secret Group"),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 6.0.3 on 2026-04-21 19:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0023_alter_compoundstructure_options_and_more"),
]
operations = [
migrations.AddField(
model_name="user",
name="contacted",
field=models.BooleanField(blank=True, null=True),
),
]

View File

@ -1,57 +0,0 @@
# Generated by Django 6.0.3 on 2026-05-11 20:25
from django.db import migrations
from envipy_additional_information import HalfLife, HalfLifeModel, HalfLifeWS
MAPPING = {
"": HalfLifeModel.OTHER,
"HS-SFO": HalfLifeModel.HS_SFO,
"FOMC": HalfLifeModel.FOMC,
"FOTC": HalfLifeModel.DFOP,
"FMOC": HalfLifeModel.FOMC,
"DFOP": HalfLifeModel.DFOP,
"SFO + SFO": HalfLifeModel.SFO_SFO,
"FOMC-SFO": HalfLifeModel.FOMC_SFO,
"first order kinetics": HalfLifeModel.SFO,
"SFO²": HalfLifeModel.SFO,
"HS": HalfLifeModel.HS,
"top down": HalfLifeModel.OTHER,
"SFO": HalfLifeModel.SFO,
"First Order": HalfLifeModel.SFO,
"SFO/SFO": HalfLifeModel.SFO_SFO,
"FOMC + SFO": HalfLifeModel.FOMC_SFO,
"true": HalfLifeModel.SFO,
"SFO-SFO": HalfLifeModel.SFO_SFO,
"DFOP-SFO": HalfLifeModel.DFOP_SFO,
"other": HalfLifeModel.OTHER,
}
def forward_func(apps, schema_editor):
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
hls = AdditionalInformation.objects.filter(type="HalfLife")
for hl in hls:
data = hl.data
data["model"] = MAPPING[data["model"]].value
hl.data = HalfLife(**data).model_dump(mode="json")
hl.save()
hlws = AdditionalInformation.objects.filter(type="HalfLifeWS")
for hl in hlws:
data = hl.data
data["model"] = MAPPING[data["model"]].value
hl.data = HalfLifeWS(**data).model_dump(mode="json")
hl.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0024_user_contacted"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -1,48 +0,0 @@
# 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

@ -1,150 +0,0 @@
# 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,7 +31,6 @@ from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property
from bridge.dto import RunResult, PropertyPrediction
from epdb.exceptions import InvalidSMILESException
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import (
ApplicabilityDomainPCA,
@ -76,7 +75,6 @@ class User(AbstractUser):
blank=False,
)
is_reviewer = models.BooleanField(default=False)
contacted = models.BooleanField(null=True, blank=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
@ -205,7 +203,6 @@ class Group(TimeStampedModel):
name = models.TextField(blank=False, null=False, verbose_name="Group name")
owner = models.ForeignKey("User", verbose_name="Group Owner", on_delete=models.CASCADE)
public = models.BooleanField(verbose_name="Public Group", default=False)
secret = models.BooleanField(verbose_name="Secret Group", default=False)
description = models.TextField(
blank=False, null=False, verbose_name="Descriptions", default="no description"
)
@ -577,10 +574,6 @@ class ReactionIdentifierMixin(ExternalIdentifierMixin):
def get_uniprot_identifiers(self):
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 #
@ -637,7 +630,7 @@ class EnviPathModel(TimeStampedModel):
class AliasMixin(models.Model):
aliases = ArrayField(
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list, blank=True
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list
)
@transaction.atomic
@ -660,9 +653,7 @@ class AliasMixin(models.Model):
class ScenarioMixin(models.Model):
scenarios = models.ManyToManyField(
"epdb.Scenario", verbose_name="Attached Scenarios", blank=True
)
scenarios = models.ManyToManyField("epdb.Scenario", verbose_name="Attached Scenarios")
@transaction.atomic
def set_scenarios(self, scenarios: List["Scenario"]):
@ -788,7 +779,7 @@ class Compound(
"CompoundStructure",
verbose_name="Default Structure",
related_name="compound_default_structure",
on_delete=models.SET_NULL,
on_delete=models.CASCADE,
null=True,
)
@ -865,58 +856,37 @@ class Compound(
package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs
) -> "Compound":
if smiles is None or smiles.strip() == "":
raise InvalidSMILESException("SMILES is required")
raise ValueError("SMILES is required")
smiles = smiles.strip()
parsed = FormatConverter.from_smiles(smiles)
if parsed is None:
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()
raise ValueError("Given SMILES is invalid")
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
subclasses = CompoundStructure.__subclasses__()
qs = CompoundStructure.objects.filter(smiles=smiles, compound__package=package)
if subclasses:
qs = qs.not_instance_of(*subclasses)
# Check if we find a direct match for a given SMILES
if qs.exists():
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)
if subclasses:
qs = qs.not_instance_of(*subclasses)
if CompoundStructure.objects.filter(smiles=smiles, compound__package=package).exists():
return CompoundStructure.objects.get(smiles=smiles, compound__package=package).compound
# Check if we can find the standardized one
if qs.exists():
if CompoundStructure.objects.filter(
smiles=standardized_smiles, compound__package=package
).exists():
# TODO should we add a structure?
found_structure = qs.first()
found_compound = found_structure.compound
if name:
found_structure.add_alias(name)
found_compound.add_alias(name)
return found_compound
return CompoundStructure.objects.get(
smiles=standardized_smiles, compound__package=package
).compound
# Generate Compound
c = Compound()
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 == "":
name = f"Compound {Compound.objects.filter(package=package).count() + 1}"
@ -1142,7 +1112,6 @@ class CompoundStructure(
canonical_smiles = models.TextField(blank=False, null=False, verbose_name="Canonical SMILES")
inchikey = models.TextField(max_length=27, blank=False, null=False, verbose_name="InChIKey")
normalized_structure = models.BooleanField(null=False, blank=False, default=False)
molfile = models.TextField(blank=True, null=True, verbose_name="Molfile")
external_identifiers = GenericRelation("ExternalIdentifier")
@ -1167,27 +1136,18 @@ class CompoundStructure(
@staticmethod
@transaction.atomic
def create(
compound: Compound, smiles: str, name: str = None, description: str = None, molfile: str = None, *args, **kwargs
compound: Compound, smiles: str, name: str = None, description: 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():
found_cs = CompoundStructure.objects.get(compound=compound, smiles=smiles)
if name:
found_cs.add_alias(name)
return found_cs
return CompoundStructure.objects.get(compound=compound, smiles=smiles)
if compound.pk is None:
raise ValueError("Unpersisted Compound! Persist compound first!")
cs = CompoundStructure()
# Clean for potential XSS
if name is not None:
cs.name = name
cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None:
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
@ -1195,10 +1155,6 @@ class CompoundStructure(
cs.smiles = smiles
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:
cs.normalized_structure = kwargs["normalized_structure"]
@ -1216,8 +1172,6 @@ class CompoundStructure(
@property
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)
@property
@ -1254,9 +1208,6 @@ class CompoundStructure(
return dict(hls)
def d3_json(self):
return {}
class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
rule = models.ForeignKey("Rule", on_delete=models.CASCADE, db_index=True)
@ -1415,9 +1366,6 @@ class SimpleAmbitRule(SimpleRule):
if not FormatConverter.is_valid_smirks(smirks):
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)
if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "":
@ -1429,17 +1377,14 @@ class SimpleAmbitRule(SimpleRule):
if query.exists():
if query.count() > 1:
logger.error(f"More than one rule matched this one! {query}")
found_rule = query.first()
if name:
found_rule.add_alias(name)
return found_rule
return query.first()
r = SimpleAmbitRule()
r.package = package
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"Rule {Rule.objects.filter(package=package).count() + 1}"
@ -1660,15 +1605,10 @@ class Reaction(
products = models.ManyToManyField(
"epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products"
)
rules = models.ManyToManyField(
"epdb.Rule", verbose_name="Rule", related_name="reaction_rule", blank=True
)
rules = models.ManyToManyField("epdb.Rule", verbose_name="Rule", related_name="reaction_rule")
multi_step = models.BooleanField(verbose_name="Multistep Reaction")
medline_references = ArrayField(
models.TextField(blank=False, null=False),
null=True,
verbose_name="Medline References",
blank=True,
models.TextField(blank=False, null=False), null=True, verbose_name="Medline References"
)
external_identifiers = GenericRelation("ExternalIdentifier")
@ -1685,13 +1625,8 @@ class Reaction(
educts: Union[List[str], List[CompoundStructure]] = None,
products: Union[List[str], List[CompoundStructure]] = None,
rules: Union[Rule | List[Rule]] = None,
multi_step: bool = False,
multi_step: bool = True,
):
# Clean for potential XSS
if name is not None and name.strip() != "":
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
_educts = []
_products = []
@ -1746,21 +1681,16 @@ class Reaction(
logger.error(
f"Found more than one reaction for given input! {existing_reaction_qs}"
)
found_reaction = existing_reaction_qs.first()
if name:
found_reaction.add_alias(name)
return found_reaction
return existing_reaction_qs.first()
r = Reaction()
r.package = package
if r is not None:
r.name = name
# Clean for potential XSS
if name is not None and name.strip() != "":
r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None and description.strip() != "":
if description is not None and name.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
r.multi_step = multi_step
@ -1838,7 +1768,7 @@ class Reaction(
return new_reaction
def smirks(self):
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')])}"
return f"{'.'.join([cs.smiles for cs in self.educts.all()])}>>{'.'.join([cs.smiles for cs in self.products.all()])}"
@property
def as_svg(self):
@ -1951,9 +1881,6 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
if n not in queue:
queue.append(n)
for i in queue:
processed.add(i)
while len(queue):
current = queue.pop()
processed.add(current)
@ -2231,7 +2158,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
smiles: str,
name: Optional[str] = None,
description: Optional[str] = None,
depth: Optional[int] = -1,
depth: Optional[int] = 0,
):
return Node.create(self, smiles, depth, name=name, description=description)
@ -2246,68 +2173,6 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
):
return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description)
def update_depths(self):
# Collect number of in and out links per node
in_count = defaultdict(lambda: 0)
out_count = defaultdict(lambda: 0)
for e in self.edges:
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
depth_map = {}
depth_map[0] = list()
processed = set()
data_driven_root_nodes = (
self.node_set.all()
.annotate(prod_cnt=Count("edge_products"), educt_cnt=Count("edge_educts"))
.filter(prod_cnt=0, educt_cnt__gt=0)
.distinct()
)
# Eval QuerySet
root_nodes_by_depth = list(self.root_nodes)
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
for i in range(self.nodes.count()):
level_nodes = depth_map.get(i, [])
if len(level_nodes) == 0:
break
unique_next_level = set()
for n in level_nodes:
processed.add(n)
for e in self.edges:
if n in e.start_nodes.all():
for p in e.end_nodes.all():
if p not in processed:
unique_next_level.add(p)
if len(unique_next_level) > 0:
depth_map[i + 1] = list(unique_next_level)
for depth, nodes in depth_map.items():
for n in nodes:
if n.depth != depth and depth != 0:
n.depth = depth
n.save()
class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
pathway = models.ForeignKey(
@ -2349,25 +2214,15 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
if isinstance(ai.get(), PropertyPrediction):
predicted_properties[ai.get().__class__.__name__].append(ai.data)
# If we have Subclasses of a CompoundStructure we can overwrite keys (e.g. images)
# by overwriting keys
structure_data = self.default_node_label.d3_json()
res = {
return {
"depth": self.depth,
"stereo_removed": self.stereo_removed,
"url": self.url,
"node_label_id": self.default_node_label.url,
"image": f"{self.url}?image=svg",
"image_svg": IndigoUtils.mol_to_svg(
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,
self.default_node_label.smiles, width=40, height=40
),
"image_type": "svg",
"name": self.get_name(),
"smiles": self.default_node_label.smiles,
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
@ -2379,13 +2234,9 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
},
"predicted_properties": predicted_properties,
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
"proposed": self.is_proposed_intermediate(),
"timeseries": self.get_timeseries_data(),
**structure_data,
}
return res
@staticmethod
@transaction.atomic
def create(
@ -2420,17 +2271,12 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
@property
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)
def get_timeseries_data(self):
for ai in self.additional_information.all():
if ai.type == "OECD301FTimeSeries":
return ai.get().model_dump(mode="json")
if ai.__class__.__name__ == "OECD301FTimeSeries":
return ai.model_dump(mode="json")
return None
@ -2453,26 +2299,6 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
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):
res = super().simple_json()
name = res.get("name", None)
@ -2487,7 +2313,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"epdb.Pathway", verbose_name="belongs to", on_delete=models.CASCADE, db_index=True
)
edge_label = models.ForeignKey(
"epdb.Reaction", verbose_name="Edge label", null=True, on_delete=models.CASCADE
"epdb.Reaction", verbose_name="Edge label", null=True, on_delete=models.SET_NULL
)
start_nodes = models.ManyToManyField(
"epdb.Node", verbose_name="Start Nodes", related_name="edge_educts"
@ -2566,8 +2392,6 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
rule: Optional[Rule] = None,
name: Optional[str] = None,
description: Optional[str] = None,
*args,
**kwargs,
):
e = Edge()
e.pathway = pathway
@ -2597,7 +2421,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
educts=[n.default_node_label for n in e.start_nodes.all()],
products=[n.default_node_label for n in e.end_nodes.all()],
rules=rule,
multi_step=kwargs.get("multi_step", False),
multi_step=False,
)
e.edge_label = r
@ -4605,22 +4429,18 @@ class AdditionalInformation(models.Model):
return f"{self.scenario.url}/additional-information/{self.uuid}"
@staticmethod
def from_dict(ai_type: str, ai_data: Dict[str, Any]):
def get(self) -> "EnviPyModel":
from envipy_additional_information import registry
MAPPING = {c.__name__: c for c in registry.list_models().values()}
try:
inst = MAPPING[ai_type](**ai_data)
inst = MAPPING[self.type](**self.data)
except Exception as e:
print(f"Error loading {ai_type}: {e}")
print(f"Error loading {self.type}: {e}")
raise e
return inst
def get(self) -> "EnviPyModel":
inst = AdditionalInformation.from_dict(self.type, self.data)
inst.__dict__["uuid"] = str(self.uuid)
return inst
def __str__(self) -> str:

View File

@ -19,7 +19,6 @@ from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required
from .exceptions import InvalidSMILESException
from .logic import (
EPDBURLParser,
@ -389,9 +388,6 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
"debug": s.DEBUG,
"external_databases": ExternalDatabase.get_databases(),
"site_id": s.MATOMO_SITE_ID,
# EDIT START
"secret_groups": Group.objects.filter(secret=True),
# EDIT END
},
}
@ -591,38 +587,10 @@ def packages(request):
"package-description", s.DEFAULT_VALUES["description"]
)
# EDIT START
data_pool = None
package_classification = request.POST.get("package-classification")
classification = Package.Classification(int(package_classification))
# For SECRET we'll need a data pool which will be an additional perm check later
if classification == Package.Classification.SECRET:
package_data_pool = request.POST.get("package-data-pool")
if package_data_pool is None:
return error(request, "Invalid data pool.", "Data Pool is required!")
data_pool = GroupManager.get_group_by_url(current_user, package_data_pool)
if data_pool is None:
return error(request, "Invalid data pool.", "Data Pool does not exist or no access!")
if not data_pool.secret:
return error(request, "Invalid data pool.", "Data Pool is not a secret group!")
created_package = PackageManager.create_package(
current_user, package_name, package_description
)
created_package.classification_level = classification
# Set previously determined data pool
if classification == Package.Classification.SECRET:
created_package.data_pool = data_pool
created_package.save()
# EDIT END
return redirect(created_package.url)
elif request.method == "OPTIONS":
@ -786,11 +754,6 @@ def models(request):
{"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
context["model_types"] = {
"ML Relative Reasoning": {
@ -803,14 +766,12 @@ def models(request):
"requires_rule_packages": True,
"requires_data_packages": True,
},
}
if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = {
"EnviFormer": {
"type": "enviformer",
"requires_rule_packages": False,
"requires_data_packages": True,
}
},
}
if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items():
@ -818,9 +779,6 @@ def models(request):
"type": k,
"requires_rule_packages": v.requires_rule_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():
context["model_types"][v.display()] = {
@ -829,6 +787,12 @@ def models(request):
"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)
elif request.method == "POST":
@ -942,13 +906,12 @@ def package_models(request, package_uuid):
"requires_rule_packages": True,
"requires_data_packages": True,
},
}
if s.ENVIFORMER_PRESENT:
context["model_types"]["EnviFormer"] = {
"EnviFormer": {
"type": "enviformer",
"requires_rule_packages": False,
"requires_data_packages": True,
}
},
}
if s.FLAGS.get("PLUGINS", False):
for k, v in s.CLASSIFIER_PLUGINS.items():
@ -1124,11 +1087,6 @@ 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)
elif half_life:
@ -1232,7 +1190,9 @@ def package(request, package_uuid):
if request.method == "GET":
if request.GET.get("export", False) == "true":
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
pack_json = PackageManager.export_package(current_package)
pack_json = PackageManager.export_package(
current_package, include_models=False, include_external_identifiers=False
)
response = JsonResponse(pack_json, content_type="application/json")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
@ -2368,13 +2328,7 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles").strip()
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"
)
current_pathway.add_node(node_smiles, name=node_name, description=node_description)
return redirect(current_pathway.url)
@ -2482,22 +2436,7 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
return JsonResponse({"success": current_node.url})
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")
return HttpResponseBadRequest()
else:
return HttpResponseNotAllowed(["GET", "POST"])
@ -2567,9 +2506,6 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
substrate_nodes, product_nodes, name=edge_name, description=edge_description
)
# Update depths as sideeffect of above operation
current_pathway.update_depths()
return redirect(current_pathway.url)
else:
@ -2910,15 +2846,9 @@ def groups(request):
{"Group": s.SERVER_URL + "/group"},
]
# Context for paginated template
context["entity_type"] = "group"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/groups/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "groups"
context["list_mode"] = "combined"
return render(request, "collections/groups_paginated.html", context)
context["objects"] = Group.objects.all()
return render(request, "collections/objects_list.html", context)
elif request.method == "POST":
group_name = request.POST.get("group-name")
group_description = request.POST.get("group-description", s.DEFAULT_VALUES["description"])
@ -3029,15 +2959,9 @@ def settings(request):
new_default = request.POST.get("prediction-setting-new-default", "off") == "on"
# 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(
temp_max_nodes,
int(request.POST.get("prediction-setting-max-nodes", 1)),
2,
),
s.DEFAULT_MAX_NUMBER_OF_NODES,
@ -3058,7 +2982,6 @@ def settings(request):
model_uuid = model_url.split("/")[-1]
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(
"model-based-prediction-setting-threshold", s.DEFAULT_MODEL_THRESHOLD
)
@ -3148,21 +3071,12 @@ def jobs(request):
{"Home": s.SERVER_URL},
{"Jobs": s.SERVER_URL + "/jobs"},
]
# if current_user.is_superuser:
# context["jobs"] = JobLog.objects.all().order_by("-created")
# else:
# context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created")
if current_user.is_superuser:
context["jobs"] = JobLog.objects.all().order_by("-created")
else:
context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created")
# 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)
return render(request, "collections/joblog.html", context)
elif request.method == "POST":
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

File diff suppressed because one or more lines are too long

View File

@ -65,25 +65,6 @@ def run_both_engines(SMILES, SMIRKS):
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":
context = get_base_context(request)
@ -128,11 +109,11 @@ def migration(request):
),
"id": str(r.uuid),
"url": r.url,
"status": res or r.name in accepted_diffs,
"status": res,
}
)
if res or r.name in accepted_diffs:
if res:
success += 1
else:
error += 1
@ -154,16 +135,7 @@ def migration(request):
for r in migration_status["results"]:
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)
return render(request, "migration.html", context)

View File

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

View File

@ -46,7 +46,7 @@ class PepperPrediction(PropertyPrediction):
import matplotlib.patches as mpatches
import numpy as np
from matplotlib.figure import Figure
from matplotlib import pyplot as plt
from scipy import stats
"""
@ -101,8 +101,7 @@ class PepperPrediction(PropertyPrediction):
mask_red = x > vp
# Plot
fig = Figure(figsize=(9, 5.5))
ax = fig.subplots()
fig, ax = plt.subplots(figsize=(9, 5.5))
ax.plot(x, y, color="#1f4e79", lw=2, label="Lognormal PDF")
if np.any(mask_green):
@ -147,12 +146,13 @@ class PepperPrediction(PropertyPrediction):
]
ax.legend(handles=patches, frameon=True)
fig.tight_layout()
plt.tight_layout()
# --- Export to SVG string ---
buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight")
svg = buf.getvalue()
plt.close(fig)
buf.close()
return svg

View File

@ -187,9 +187,8 @@ class Pepper:
groups = [group for group in dataset.group_by("structure_id")]
# Unless explicitly set compute everything serial
n_threads = int(os.environ.get("N_PEPPER_THREADS", 1))
if n_threads > 1:
results = Parallel(n_jobs=n_threads)(
if os.environ.get("N_PEPPER_THREADS", 1) > 1:
results = Parallel(n_jobs=os.environ["N_PEPPER_THREADS"])(
delayed(compute_bayes_per_group)(group[1])
for group in dataset.group_by("structure_id")
)

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 76 76" style="enable-background:new 0 0 76 76;" xml:space="preserve">
<style type="text/css">
.st0{fill:#10384F;}
.st1{fill:#89D329;}
.st2{fill:#00BCFF;}
</style>
<g id="Bayer_Cross_1_">
<path class="st0" d="M35.9,11.3h4.4c0.5,0,0.9-0.4,0.9-0.9c0-0.5-0.4-0.9-0.9-0.9h-4.4V11.3z M35.9,15.5h4.5c0.6,0,1-0.4,1-1
c0-0.6-0.4-1-1-1h-4.5V15.5z M43,12.3c0.6,0.6,1,1.4,1,2.3c0,1.8-1.4,3.2-3.2,3.2h-7.3V7.3l7.2,0c1.7,0,3.1,1.4,3.1,3.1
C43.7,11.1,43.4,11.8,43,12.3z M44.7,30.3H42l-0.8-1.8h-5.9l-0.8,1.8h-2.7L37,19.8h2.4L44.7,30.3z M38.2,22.5l-1.8,3.8H40
L38.2,22.5z M41.8,32.6h3l-5.3,6.8v3.7h-2.5v-3.7l-5.3-6.8h3l3.6,4.8L41.8,32.6z M55.7,32.6v2.3h-7v1.8l6.8,0v2.3h-6.8v2h7v2.3
h-9.5V32.6H55.7z M63.4,39.1h-1.9v4h-2.5V32.6h6.4c1.8,0,3.2,1.5,3.2,3.3c0,1.5-1,2.7-2.3,3.1l3.1,4.1h-3L63.4,39.1z M65.2,34.8
h-3.6v2h3.6c0.6,0,1-0.5,1-1C66.2,35.3,65.7,34.8,65.2,34.8z M32.8,43.1h-2.7l-0.8-1.8h-5.9l-0.8,1.8h-2.7l5.3-10.5h2.4L32.8,43.1z
M26.3,35.3l-1.8,3.8h3.7L26.3,35.3z M10.4,36.6h4.4c0.5,0,0.9-0.4,0.9-0.9c0-0.5-0.4-0.9-0.9-0.9l-4.4,0V36.6z M10.4,40.8h4.5
c0.6,0,1-0.4,1-1c0-0.6-0.4-1-1-1h-4.5V40.8z M17.5,37.6c0.6,0.6,1,1.4,1,2.3c0,1.8-1.4,3.2-3.2,3.2H7.9V32.6h7.2
c1.7,0,3.1,1.4,3.1,3.1C18.2,36.4,17.9,37.1,17.5,37.6z M43,45.3v2.3h-7v1.8l6.8,0v2.3h-6.8v2h7v2.3h-9.5V45.3H43z M41.2,61.6
c0-0.6-0.4-1-1-1h-4.3v2h4.3C40.8,62.6,41.2,62.2,41.2,61.6z M33.4,68.9V58.4h7c1.8,0,3.2,1.5,3.2,3.3c0,1.4-0.8,2.5-2,3l3.2,4.2
h-3l-3-4h-2.9v4H33.4z"/>
<path class="st1" d="M76.1,35.6C74.9,15.8,58.4,0,38.2,0C18,0,1.5,15.8,0.3,35.6c0,0.8,0.1,1.6,0.2,2.4c0.8,6.6,3.3,12.7,7.1,17.8
c6.9,9.4,18,15.5,30.6,15.5c-17.6,0-32-13.7-33.2-30.9c-0.1-0.8-0.1-1.6-0.1-2.4c0-0.8,0-1.6,0.1-2.4C6.2,18.4,20.6,4.7,38.2,4.7
c12.6,0,23.7,6.1,30.6,15.5c3.8,5.1,6.3,11.2,7.1,17.8c0.1,0.8,0.2,1.6,0.2,2.3c0-0.8,0.1-1.6,0.1-2.4
C76.2,37.2,76.2,36.4,76.1,35.6"/>
<path class="st2" d="M0.3,40.4C1.5,60.2,18,76,38.2,76c20.2,0,36.7-15.8,37.9-35.6c0-0.8-0.1-1.6-0.2-2.4
c-0.8-6.6-3.3-12.7-7.1-17.8c-6.9-9.4-18-15.5-30.6-15.5c17.6,0,32,13.7,33.2,30.9c0.1,0.8,0.1,1.6,0.1,2.4c0,0.8,0,1.6-0.1,2.4
c-1.2,17.3-15.6,30.9-33.2,30.9c-12.6,0-23.7-6.1-30.6-15.5C3.8,50.7,1.3,44.6,0.5,38c-0.1-0.8-0.2-1.6-0.2-2.3
c0,0.8-0.1,1.6-0.1,2.4C0.2,38.8,0.2,39.6,0.3,40.4"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -59,9 +59,6 @@ document.addEventListener("alpine:init", () => {
get isEditMode() {
return this.mode === "edit";
},
get isRequired() {
return (this.schema.required || []).indexOf(this.fieldName) > -1
}
});
// Text widget
@ -296,34 +293,6 @@ 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
Alpine.data(
"compoundWidget",

View File

@ -5,125 +5,6 @@
*/
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 = {}) => ({
items: [],
currentPage: 1,

View File

@ -1,37 +1,5 @@
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) {
fetch("", {
method: "POST",
@ -55,7 +23,6 @@ function predictFromNode(url) {
// elem = 'vizdiv'
function draw(pathway, elem) {
const initialzoom = 2.5
const nodeRadius = 20;
const linkDistance = 100;
const chargeStrength = -200;
@ -67,9 +34,6 @@ function draw(pathway, elem) {
const horizontalSpacing = 75; // horizontal space between nodes
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
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
@ -99,7 +63,7 @@ function draw(pathway, elem) {
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
}
node.fy = (node.depth + initialzoom + 0.5) * levelSpacing + 50;
node.fy = node.depth * levelSpacing + 50;
depthMap.set(node.depth, depthMap.get(node.depth) + 1);
});
}
@ -137,30 +101,12 @@ function draw(pathway, elem) {
// Update pseudo node positions first
updatePseudoNodePositions();
link.attr("d", d => {
// Check if it's a self-loop (source equals target)
if (d.source.id === d.target.id) {
// 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}`;
}
});
link.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.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) {
@ -263,7 +209,7 @@ function draw(pathway, elem) {
}
// Wait before showing popup (ms)
var popupWaitBeforeShow = 500;
var popupWaitBeforeShow = 1000;
// Custom popover element
let popoverTimeout = null;
@ -517,7 +463,7 @@ function draw(pathway, elem) {
// TODO needs to be generic once we store it as AddInf
for (var s of n.predicted_properties["PepperPrediction"]) {
if (s["mean"] != null) {
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + " days<br>"
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + "<br>"
}
}
}
@ -617,8 +563,6 @@ function draw(pathway, elem) {
// Apply zoom to the SVG element - this enables wheel zoom
svg.call(zoom);
svg.call(zoom.scaleBy, initialzoom);
// 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
container.call(zoom);
@ -637,11 +581,11 @@ function draw(pathway, elem) {
for (idx in parents) {
p = nodes[parents[idx]]
// console.log(p.depth)
// if (p.depth >= n.depth) {
// // keep the .5 steps for pseudo nodes
// n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1);
// // console.log("Adjusting", orig_depth, Math.floor(p.depth + 1));
// }
if (p.depth >= n.depth) {
// keep the .5 steps for pseudo nodes
n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1);
// console.log("Adjusting", orig_depth, Math.floor(p.depth + 1));
}
}
});
@ -654,68 +598,14 @@ function draw(pathway, elem) {
.on("tick", ticked);
// Kanten zeichnen
const linkGroup = zoomable.append("g")
.selectAll("g")
.data(links)
.enter()
.append("g")
.attr("class", "link-group");
const link = zoomable.append("g")
.selectAll("line")
.data(links)
.enter().append("line")
// 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 => 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) {
const wasHighlighted = d3.select(this).classed("highlighted");
d3.selectAll("path").classed("highlighted", false);
if (!wasHighlighted) {
const toHighlight = [];
toHighlight.push(d.el);
if (d.source.pseudo || d.target.pseudo) {
if (d.target.pseudo) {
d3.selectAll("path").each(e => {
if (e !== undefined && e.source.id === d.target.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) {
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
link.each(function (d) {
@ -732,41 +622,10 @@ function draw(pathway, elem) {
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended)
)
.on("end", dragended))
.on("click", function (event, d) {
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);
if (!wasHighlighted) {
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
const inEdges = findPaths(d.id, links);
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
node.append("circle")
@ -778,56 +637,44 @@ function draw(pathway, elem) {
node.filter(d => !d.pseudo).each(function (d, i) {
const g = d3.select(this);
if (d.image_type === "svg") {
// Parse the SVG string
const parser = new DOMParser();
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
const svgElem = svgDoc.documentElement;
// Parse the SVG string
const parser = new DOMParser();
const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml");
const svgElem = svgDoc.documentElement;
// Create a unique prefix per node
const prefix = `node-${i}-`;
// Create a unique prefix per node
const prefix = `node-${i}-`;
// Rename all IDs and fix <use> references
svgElem.querySelectorAll("[id]").forEach(el => {
const oldId = el.id;
const newId = prefix + oldId;
el.id = newId;
// Rename all IDs and fix <use> references
svgElem.querySelectorAll('[id]').forEach(el => {
const oldId = el.id;
const newId = prefix + oldId;
el.id = newId;
const XLINK_NS = "http://www.w3.org/1999/xlink";
// Update <use> elements that reference this old ID
const uses = Array.from(svgElem.querySelectorAll("use")).filter(
u => u.getAttributeNS(XLINK_NS, "href") === `#${oldId}`
);
const XLINK_NS = "http://www.w3.org/1999/xlink";
// Update <use> elements that reference this old ID
const uses = Array.from(svgElem.querySelectorAll('use')).filter(
u => u.getAttributeNS(XLINK_NS, 'href') === `#${oldId}`
);
uses.forEach(u => {
u.setAttributeNS(XLINK_NS, "href", `#${newId}`);
});
uses.forEach(u => {
u.setAttributeNS(XLINK_NS, 'href', `#${newId}`);
});
});
g.node().appendChild(svgElem);
g.node().appendChild(svgElem);
const vb = svgElem.viewBox.baseVal;
const svgWidth = vb.width || 40;
const svgHeight = vb.height || 40;
const vb = svgElem.viewBox.baseVal;
const svgWidth = vb.width || 40;
const svgHeight = vb.height || 40;
const scale = (nodeRadius * 2) / Math.max(svgWidth, svgHeight);
g.select("svg")
.attr("width", svgWidth * scale)
.attr("height", svgHeight * scale)
.attr("x", -svgWidth * scale / 2)
.attr("y", -svgHeight * scale / 2);
} else {
// We have a image type different than svg
// include it via img url
g.append("svg:image")
.attr("xlink:href", d.image)
.attr("width", 40)
.attr("height", 40)
.attr("x", -20)
.attr("y", -20);
}
const scale = (nodeRadius * 2) / Math.max(svgWidth, svgHeight);
g.select("svg")
.attr("width", svgWidth * scale)
.attr("height", svgHeight * scale)
.attr("x", -svgWidth * scale / 2)
.attr("y", -svgHeight * scale / 2);
});
// add element to nodes array
@ -840,14 +687,14 @@ function draw(pathway, elem) {
function serializeSVG(svgElement) {
svgElement.querySelectorAll("path.link").forEach(line => {
svgElement.querySelectorAll("line.link").forEach(line => {
const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth);
line.setAttribute("fill", style.fill);
});
svgElement.querySelectorAll("path.link_no_arrow").forEach(line => {
svgElement.querySelectorAll("line.link_no_arrow").forEach(line => {
const style = getComputedStyle(line);
line.setAttribute("stroke", style.stroke);
line.setAttribute("stroke-width", style.strokeWidth);

View File

@ -0,0 +1,8 @@
<li>
<a
role="button"
onclick="document.getElementById('new_group_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Group</a
>
</li>

View File

@ -1,5 +1,3 @@
{% load envipytags %}
{% if meta.can_edit %}
<li>
<a
@ -17,12 +15,7 @@
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a
>
</li>
{% epdb_slot_templates "epdb.actions.objects.pathway.add" as action_button_templates %}
{% for tpl in action_button_templates %}
{% include tpl %}
{% endfor %}
<li role="separator" class="divider h-px"></li>
<li role="separator" class="divider"></li>
{% endif %}
<li>
<a
@ -74,7 +67,7 @@
Rules</a
>
</li>
<li role="separator" class="divider h-px"></li>
<li role="separator" class="divider"></li>
<li>
<a
class="button"
@ -99,16 +92,11 @@
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li>
<li role="separator" class="divider h-px"></li>
<li role="separator" class="divider"></li>
<li>
<a
class="button"
onclick="
const modal = document.getElementById('delete_pathway_node_modal');
modal.showModal();
window.dispatchEvent(new Event('modal-opened'));
return false;
"
onclick="document.getElementById('delete_pathway_node_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
>
@ -116,12 +104,7 @@
<li>
<a
class="button"
onclick="
const modal = document.getElementById('delete_pathway_edge_modal');
modal.showModal();
window.dispatchEvent(new Event('modal-opened'));
return false;
"
onclick="document.getElementById('delete_pathway_edge_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
>

View File

@ -1,102 +0,0 @@
{# 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

@ -5,7 +5,7 @@
{% block action_button %}
<div class="flex items-center gap-2">
{% if meta.can_edit or not meta.url_contains_package %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -1,21 +0,0 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Groups{% endblock %}
{% block action_button %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_group_modal').showModal(); return false;"
>
New Group
</button>
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_group_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>Users can team up in groups to share packages.</p>
{% endblock description %}

View File

@ -1,13 +0,0 @@
{% 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,16 +3,14 @@
{% block page_title %}Models{% endblock %}
{% block action_button %}
{% if meta.can_edit or not meta.url_contains_package %}
{% if meta.enabled_features.MODEL_BUILDING %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
New Model
</button>
{% endif %}
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
New Model
</button>
{% endif %}
{% endblock action_button %}

View File

@ -3,73 +3,75 @@
{% block page_title %}Packages{% endblock %}
{% block action_button %}
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
id="new-package-button"
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"
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
id="new-package-button"
onclick="document.getElementById('new_package_modal').showModal(); return false;"
>
<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
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
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-chevron-down ml-1"
class="lucide lucide-folder-plus-icon lucide-folder-plus"
>
<path d="m6 9 6 6 6-6" />
<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
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>
<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>
{% endif %}
{% endblock action_button %}
{% block action_modals %}

View File

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

View File

@ -3,7 +3,7 @@
{% block page_title %}Pathways{% endblock %}
{% block action_button %}
{% if meta.can_edit or not meta.url_contains_package %}
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<a
class="btn btn-primary btn-sm"

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
{% block action_button %}
{% if meta.can_edit or not meta.url_contains_package %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"

View File

@ -35,7 +35,6 @@
<use href="{% static "/images/logo-name.svg" %}#ep-logo-name" />
</svg>
</a>
<img src="{% static 'images/bayer-logo.svg' %}" width="40">
</div>
{% if not public_mode %}
@ -89,23 +88,12 @@
>Scenario</a
>
</li>
<hr/>
<li>
<a href="{{ meta.server_url }}/group" id="scenarioLink"
>Group</a
>
</li>
</ul>
</div>
</div>
{% endif %}
<div class="navbar-end">
{% if meta.url_contains_package and meta.current_package.get_classification_level_display == "Restricted" %}
<img src="{% static 'images/restricted_mid.png' %}" width="200">
{% elif meta.url_contains_package and meta.current_package.get_classification_level_display == "Secret" %}
<img src="{% static 'images/secret_mid.png' %}" width="120">
{% endif %}
{% if not public_mode %}
<a id="search-trigger" role="button" class="cursor-pointer">
<div

View File

@ -123,17 +123,6 @@
</div>
</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 -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"

View File

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

View File

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

View File

@ -203,11 +203,11 @@
id="model-based-prediction-setting-threshold"
name="model-based-prediction-setting-threshold"
class="input input-bordered w-full"
value="0.25"
placeholder="0.25"
type="number"
min="0"
max="1"
step="any"
step="0.05"
/>
</div>

View File

@ -5,7 +5,6 @@
x-data="{
isSubmitting: false,
reactionImageUrl: '',
pes: false,
reset() {
this.isSubmitting = false;
@ -16,30 +15,20 @@
const substratesSelect = document.getElementById('add_pathway_edge_substrates');
const productsSelect = document.getElementById('add_pathway_edge_products');
const pesLinks = [];
const substrates = [];
for (const option of substratesSelect.selectedOptions) {
substrates.push(option.dataset.smiles);
if (option.dataset.pes === 'true') {
pesLinks.push(option.dataset.pes);
}
}
const products = [];
for (const option of productsSelect.selectedOptions) {
products.push(option.dataset.smiles);
if (option.dataset.pes === 'true') {
pesLinks.push(option.dataset.pes);
}
}
if (substrates.length > 0 && products.length > 0) {
const reaction = substrates.join('.') + '>>' + products.join('.');
this.reactionImageUrl = '{% url "depict" %}?smirks=' + encodeURIComponent(reaction);
this.pes = pesLinks.length > 0;
} else {
this.pes = false;
this.reactionImageUrl = '';
}
},
@ -117,7 +106,6 @@
{% for n in pathway.nodes %}
<option
data-smiles="{{ n.default_node_label.smiles }}"
data-pes="{% if n.default_node_label.pes_link %}true{% else %}false{% endif %}"
value="{{ n.url }}"
>
{{ n.default_node_label.name|safe }}
@ -144,7 +132,6 @@
{% for n in pathway.nodes %}
<option
data-smiles="{{ n.default_node_label.smiles }}"
data-pes="{% if n.default_node_label.pes_link %}true{% else %}false{% endif %}"
value="{{ n.url }}"
>
{{ n.default_node_label.name|safe }}
@ -157,9 +144,6 @@
<div class="mb-3" x-show="reactionImageUrl" x-cloak>
<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>
</form>
</div>

View File

@ -4,22 +4,6 @@
id="delete_pathway_edge_modal"
class="modal"
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
@modal-opened.window="
const links = d3.selectAll('path.highlighted');
if (!links.empty()) {
const el = links.node();
const selectElement = document.getElementById('delete_pathway_edge_edges');
console.log(el);
console.log(el.__data__);
for (let option of selectElement.options) {
if (option.value === el.__data__.url) {
option.selected = true;
break;
}
}
selectElement.dispatchEvent(new Event('change'));
}
"
@close="reset()"
>
<div class="modal-box">

View File

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

View File

@ -1,5 +1,4 @@
{% extends "framework_modern.html" %}
{% load envipytags %}
{% block content %}
@ -83,12 +82,6 @@
<div class="collapse-content">{{ compound.description }}</div>
</div>
<!-- Extension Slot for Viz -->
{% epdb_slot_templates "epdb.objects.compound.viz" as viz_templates %}
{% for tpl in viz_templates %}
{% include tpl %}
{% endfor %}
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />

View File

@ -1,5 +1,4 @@
{% extends "framework_modern.html" %}
{% load envipytags %}
{% block content %}
@ -51,12 +50,6 @@
</div>
</div>
<!-- Extension Slot for Viz -->
{% epdb_slot_templates "epdb.objects.compound_structure.viz" as viz_templates %}
{% for tpl in viz_templates %}
{% include tpl %}
{% endfor %}
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />

View File

@ -82,9 +82,6 @@
<div class="collapse-content">
<div class="flex justify-center">{{ edge.edge_label.as_svg|safe }}</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>
<!-- Reaction Description -->
@ -95,7 +92,7 @@
<div class="flex flex-wrap items-center justify-center gap-4">
{% for educt in edge.start_nodes.all %}
<a href="{{ educt.url }}" class="btn btn-outline btn-sm"
>{{ educt.get_name }}</a
>{{ educt.name }}</a
>
{% endfor %}
<svg
@ -115,7 +112,7 @@
</svg>
{% for product in edge.end_nodes.all %}
<a href="{{ product.url }}" class="btn btn-outline btn-sm"
>{{ product.get_name }}</a
>{{ product.name }}</a
>
{% endfor %}
</div>

View File

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

View File

@ -1,5 +1,4 @@
{% extends "framework_modern.html" %}
{% load envipytags %}
{% block content %}
@ -55,21 +54,6 @@
</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 %}
{% for tpl in viz_templates %}
{% include tpl %}
{% endfor %}
<!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />

View File

@ -1,7 +1,5 @@
{% extends "framework_modern.html" %}
{% load static %}
{% load envipytags %}
{% block content %}
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
@ -21,7 +19,6 @@
.link {
stroke: #999;
stroke-opacity: 0.6;
stroke-width: 1.5px;
/* marker-end: url(#arrow); */
}
@ -73,25 +70,12 @@
stroke: red;
stroke-width: 3px;
}
.inedge {
stroke: red;
stroke-width: 3px;
}
.outedge {
stroke: green;
stroke-width: 3px;
}
</style>
<script src="{% static 'js/pw.js' %}"></script>
{% block action_modals %}
{% include "modals/objects/add_pathway_node_modal.html" %}
{% include "modals/objects/add_pathway_edge_modal.html" %}
{% epdb_slot_templates "epdb.modals.objects.pathway.add" as add_templates %}
{% for tpl in add_templates %}
{% include tpl %}
{% endfor %}
{% include "modals/objects/download_pathway_csv_modal.html" %}
{% include "modals/objects/download_pathway_image_modal.html" %}
{% include "modals/objects/identify_missing_rules_modal.html" %}
@ -116,7 +100,7 @@
</div>
<!-- Graphical Representation -->
<div class="collapse-arrow bg-base-200 collapse overflow-y-auto">
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
Graphical Representation
@ -150,7 +134,7 @@
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-96 p-2"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% include "actions/objects/pathway.html" %}
</ul>
@ -178,52 +162,6 @@
tabindex="0"
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 %}
<li>
<a id="app-domain-toggle-button" class="cursor-pointer">
@ -249,7 +187,6 @@
</a>
</li>
{% endif %}
{% if 1 == 0 %}
<li>
<a id="timeseries-toggle-button" class="cursor-pointer">
<svg
@ -300,7 +237,6 @@
Show Predicted Properties
</a>
</li>
{% endif %}
</ul>
</div>
</div>
@ -425,18 +361,6 @@
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</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
id="doublearrow"
viewBox="0 0 20 30"
@ -544,10 +468,6 @@
{{ pathway.d3_json|json_script:"pathway" }}
<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
var appDomainViewEnabled = false;
// Global switch for timeseries view
@ -583,67 +503,6 @@
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
const appDomainBtn = document.getElementById("app-domain-toggle-button");
if (appDomainBtn) {

View File

@ -79,9 +79,6 @@
<div class="collapse-content">
<div class="flex justify-center">{{ reaction.as_svg|safe }}</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>
<!-- Reaction Description -->

View File

@ -24,76 +24,6 @@
<td>Setting Name</td>
<td>{{ setting_to_render.name }}</td>
</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 %}
<tr>
<td>Setting Description</td>

View File

@ -105,7 +105,7 @@
></iframe>
</div>
<label class="select mb-8 w-full" id="prediction-setting-label">
<label class="select mb-8 w-full">
<span class="label">Predictor</span>
<select id="prediction-setting" name="prediction-setting">
<option disabled>Select a Setting</option>
@ -148,21 +148,6 @@
</div>
{# prettier-ignore-start #}
<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
function getKetcherInstance(iframeId) {
const ketcherFrame = document.getElementById(iframeId);
@ -212,13 +197,7 @@
e.preventDefault();
const button = this;
button.disabled = true;
// Set text depending on mode
if (document.getElementById("predict-submit-button").innerText === "Build") {
button.textContent = "Building...";
} else {
button.textContent = "Predicting...";
}
button.textContent = "Predicting...";
// Get SMILES from either input or Ketcher
const smilesInput = document.getElementById("predict-smiles");

View File

@ -110,6 +110,8 @@
<div
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>
<span class="text-base-content/30"></span>
<a href="/privacy" class="link link-hover">Privacy Policy</a>

View File

@ -1,7 +1,5 @@
from django.conf import settings as s
from django.test import TestCase, override_settings
from epdb.exceptions import InvalidSMILESException
from epdb.logic import PackageManager
from epdb.models import Compound, User, CompoundStructure
@ -35,13 +33,13 @@ class CompoundTest(TestCase):
self.assertEqual(c.description, "No Desc")
def test_missing_smiles(self):
with self.assertRaises(InvalidSMILESException):
with self.assertRaises(ValueError):
_ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc")
with self.assertRaises(InvalidSMILESException):
with self.assertRaises(ValueError):
_ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc")
with self.assertRaises(InvalidSMILESException):
with self.assertRaises(ValueError):
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
def test_smiles_are_trimmed(self):
@ -98,7 +96,7 @@ class CompoundTest(TestCase):
self.assertEqual(len(self.package.compounds), 1)
def test_wrong_smiles(self):
with self.assertRaises(InvalidSMILESException):
with self.assertRaises(ValueError):
_ = Compound.create(
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",

View File

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

View File

@ -46,14 +46,6 @@ class ModelViewTest(TestCase):
)
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"]],
"probability": 0.25,
@ -70,6 +62,14 @@ class ModelViewTest(TestCase):
"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"]

View File

@ -88,10 +88,6 @@ class FormatConverter(object):
def from_smiles(smiles):
return Chem.MolFromSmiles(smiles)
@staticmethod
def from_molfile(molfile: str):
return Chem.MolFromMolBlock(molfile)
@staticmethod
def to_smiles(mol, canonical=False):
return Chem.MolToSmiles(mol, canonical=canonical)
@ -175,17 +171,12 @@ class FormatConverter(object):
try:
Chem.Kekulize(mol)
except Exception:
mol = Chem.Mol(mol.ToBinary())
mc = Chem.Mol(mol.ToBinary())
if not mol.GetNumConformers():
Chem.rdDepictor.Compute2DCoords(mol)
if not mc.GetNumConformers():
Chem.rdDepictor.Compute2DCoords(mc)
drawer = rdMolDraw2D.MolDraw2DCairo(*mol_size)
opts = drawer.drawOptions()
opts.clearBackground = False
drawer.DrawMolecule(mol)
drawer.FinishDrawing()
return drawer.GetDrawingText()
pass
@staticmethod
def normalize(smiles):

Some files were not shown because too many files have changed in this diff Show More