wip
Some checks failed
API CI / api-tests (pull_request) Failing after 15s
CI / test (pull_request) Failing after 32s

This commit is contained in:
Tim Lorsbach
2026-04-21 10:26:35 +02:00
parent d1a00f71b4
commit f9f65de8b3
30 changed files with 740 additions and 184 deletions

View File

@ -37,12 +37,13 @@ RUN --mount=type=ssh \
# Now copy source and do a final sync to install the project itself # Now copy source and do a final sync to install the project itself
# Ensure .dockerignore is reasonable # Ensure .dockerignore is reasonable
COPY bayer bayer COPY bayer bayer
COPY bridge bridge
COPY biotransformer biotransformer COPY biotransformer biotransformer
COPY bridge bridge
COPY envipath envipath COPY envipath envipath
COPY epapi epapi COPY epapi epapi
COPY epauth epauth COPY epauth epauth
COPY epdb epdb COPY epdb epdb
COPY epiuclid epiuclid
COPY fixtures fixtures COPY fixtures fixtures
COPY migration migration COPY migration migration
COPY pepper pepper COPY pepper pepper

View File

@ -1,20 +0,0 @@
# Generated by Django 6.0.3 on 2026-04-14 19:07
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.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,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-04-15 20:03 # Generated by Django 6.0.3 on 2026-04-17 21:22
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bayer', '0003_package_data_pool'), ('bayer', '0002_initial'),
('epdb', '0023_alter_compoundstructure_options_and_more'), ('epdb', '0023_alter_compoundstructure_options_and_more'),
] ]
@ -26,10 +26,16 @@ class Migration(migrations.Migration):
name='PESStructure', name='PESStructure',
fields=[ 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')), ('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={ options={
'abstract': False, 'abstract': False,
}, },
bases=('epdb.compoundstructure',), 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,19 +0,0 @@
# Generated by Django 6.0.3 on 2026-04-16 08:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bayer', '0004_pescompound_pesstructure'),
]
operations = [
migrations.AddField(
model_name='pesstructure',
name='pes_link',
field=models.URLField(default=None, verbose_name='PES Link'),
preserve_default=False,
),
]

View File

@ -15,6 +15,7 @@ from epdb.models import (
SimpleAmbitRule, SimpleAmbitRule,
SimpleRDKitRule, SimpleRDKitRule,
) )
from utilities.chem import FormatConverter
class Package(EnviPathModel): class Package(EnviPathModel):
@ -114,7 +115,9 @@ class PESCompound(Compound):
# Check if we find a direct match for a given pes_link # Check if we find a direct match for a given pes_link
if PESStructure.objects.filter(pes_link=pes_url, compound__package=package).exists(): if PESStructure.objects.filter(pes_link=pes_url, compound__package=package).exists():
return PESStructure.objects.get(pes_link=pes_url, compound__package=package).compound # 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 # Generate Compound
c = PESCompound() c = PESCompound()
@ -135,19 +138,37 @@ class PESCompound(Compound):
c.save() 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 is_standardized = standardized_smiles == smiles
if not is_standardized: if not is_standardized:
_ = CompoundStructure.create( _ = PESStructure.create(
c, c,
pes_url,
molfile,
standardized_smiles, standardized_smiles,
name="Normalized structure of {}".format(name), name="Normalized structure of {}".format(name),
description="{} (in its normalized form)".format(description), description="{} (in its normalized form)".format(description),
normalized_structure=True, normalized_structure=True,
) )
cs = CompoundStructure.create(
c, smiles, name=name, description=description, normalized_structure=is_standardized cs = PESStructure.create(
c,
pes_url,
molfile,
smiles,
name=name,
description=description,
normalized_structure=is_standardized
) )
c.default_structure = cs c.default_structure = cs
@ -159,6 +180,53 @@ class PESCompound(Compound):
class PESStructure(CompoundStructure): class PESStructure(CompoundStructure):
pes_link = models.URLField(blank=False, null=False, verbose_name="PES Link") pes_link = models.URLField(blank=False, null=False, verbose_name="PES Link")
@staticmethod
@transaction.atomic
def create(
compound: Compound,
pes_link: str,
mol_file: 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.mol_file = mol_file
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): def d3_json(self):
return { return {
"is_pes": True, "is_pes": True,

View File

@ -154,7 +154,7 @@
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
@click="submit('new-compound-modal-form')" @click="submit('new-pes-modal-form')"
:disabled="isSubmitting" :disabled="isSubmitting"
> >
<span x-show="!isSubmitting">Submit</span> <span x-show="!isSubmitting">Submit</span>

View File

@ -0,0 +1,174 @@
{% 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,4 +1,11 @@
{% if compound_structure.pes_link %} {% 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">{{ compound_structure.pes_link }}</div>
</div>
<!-- Image Representation --> <!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />

View File

@ -1,4 +1,11 @@
{% if compound.default_structure.pes_link %} {% 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">{{ compound.default_structure.pes_link }}</div>
</div>
<!-- Image Representation --> <!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />

View File

@ -1,4 +1,11 @@
{% if node.default_node_label.pes_link %} {% 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">{{ node.default_node_label.pes_link }}</div>
</div>
<!-- Image Representation --> <!-- Image Representation -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />

View File

@ -1,5 +1,5 @@
{% extends "framework_modern.html" %} {% extends "framework_modern.html" %}
{% load static %}
{% block content %} {% block content %}
{% block action_modals %} {% block action_modals %}
@ -16,7 +16,7 @@
<div class="card bg-base-100"> <div class="card bg-base-100">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ package.name }} - ({{ package.get_classification_level_display }})</h2> <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>
<div id="actionsButton" class="dropdown dropdown-e nd hidden"> <div id="actionsButton" class="dropdown dropdown-e nd hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg <svg

View File

@ -1,5 +1,5 @@
{% extends "static/login_base.html" %} {% extends "static/login_base.html" %}
{% load static %}
{% block title %}enviPath - Sign In{% endblock %} {% block title %}enviPath - Sign In{% endblock %}
{% block extra_styles %} {% block extra_styles %}
@ -31,13 +31,18 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="card bg-base-200 mb-6 "> <div>
<div class="card-body"> <img src="{% static 'images/bayer-logo.svg' %}">
<h3 class="card-title">Welcome to the new enviPath!</h3>
</div>
</div> </div>
<div class="flex flex-col space-y-4 ...">
<div><p></p></div>
<div><p></p></div>
</div>
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="border-base-300 mb-6 border-b"> <div class="border-base-300 mb-6 border-b" hidden>
<div class="flex justify-start"> <div class="flex justify-start">
<input <input
type="radio" type="radio"

0
bayer/tests/__init__.py Normal file
View File

View File

174
bayer/tests/pes/test_pes.py Normal file

File diff suppressed because one or more lines are too long

View File

@ -7,8 +7,13 @@ UUID = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
urlpatterns = [ urlpatterns = [
re_path(r"^depict_pes$", v.visualize_pes, name="depict_pes"), re_path(r"^depict_pes$", v.visualize_pes, name="depict_pes"),
re_path( re_path(
rf"^package/(?P<package_uuid>{UUID})/compound$", rf"^package/(?P<package_uuid>{UUID})/pes$",
v.create_pes, v.create_pes,
name="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

@ -8,9 +8,12 @@ from django.shortcuts import redirect
from bayer.models import PESCompound from bayer.models import PESCompound
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Pathway, Node
from epdb.views import _anonymous_or_real from epdb.views import _anonymous_or_real
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
Package = s.GET_PACKAGE_MODEL()
@package_permission_required() @package_permission_required()
def create_pes(request, package_uuid): def create_pes(request, package_uuid):
@ -18,6 +21,10 @@ def create_pes(request, package_uuid):
current_package = PackageManager.get_package_by_id(current_user, package_uuid) current_package = PackageManager.get_package_by_id(current_user, package_uuid)
if request.method == "POST": if request.method == "POST":
if current_package.classification_level == Package.Classification.INTERNAL:
raise BadRequest("Cannot create PESs for internal packages.")
compound_name = request.POST.get('compound-name') compound_name = request.POST.get('compound-name')
compound_description = request.POST.get('compound-description') compound_description = request.POST.get('compound-description')
pes_link = request.POST.get('pes-link') pes_link = request.POST.get('pes-link')
@ -28,6 +35,14 @@ def create_pes(request, package_uuid):
except ValueError as e: except ValueError as e:
return BadRequest(f"Could not fetch PES data for {pes_link}") return BadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest(
f"PES data pool {s.DATA_POOL_MAPPING[current_package.data_pool.name]} not found in PES data")
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
return redirect(pes.url) return redirect(pes.url)
@ -37,6 +52,55 @@ def create_pes(request, package_uuid):
pass 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:
raise BadRequest("Cannot create PESs for internal packages.")
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 BadRequest(f"Could not fetch PES data for {pes_link}")
classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower():
data_pools = pes_data.get("dataPools")
if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
return BadRequest(
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)
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 BadRequest("Please provide a PES link.")
else:
pass
def fetch_pes(request, pes_url) -> dict: def fetch_pes(request, pes_url) -> dict:
proxies = { proxies = {

View File

@ -1958,13 +1958,16 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
educts = [] educts = []
products = [] products = []
subclasses = CompoundStructure.__subclasses__()
if e.edgeAsSmirks: if e.edgeAsSmirks:
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."): for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
stand_ed = FormatConverter.standardize(ed, remove_stereo=True) stand_ed = FormatConverter.standardize(ed, remove_stereo=True)
educts.append( educts.append(
Node.objects.get( Node.objects.get(
pathway=pw, pathway=pw,
default_node_label=CompoundStructure.objects.get( default_node_label=CompoundStructure.objects.not_instance_of(*subclasses).
get(
compound__package=p, smiles=stand_ed compound__package=p, smiles=stand_ed
).compound.default_structure, ).compound.default_structure,
) )
@ -1975,7 +1978,8 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
products.append( products.append(
Node.objects.get( Node.objects.get(
pathway=pw, pathway=pw,
default_node_label=CompoundStructure.objects.get( default_node_label=CompoundStructure.objects.not_instance_of(*subclasses).
get(
compound__package=p, smiles=stand_pr compound__package=p, smiles=stand_pr
).compound.default_structure, ).compound.default_structure,
) )
@ -1998,7 +2002,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
return redirect(new_e.url) return redirect(new_e.url)
except ValueError: except ValueError:
return 403, {"message": "Adding node failed!"} return 403, {"message": "Adding Edge failed!"}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}") @router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")

View File

@ -7,6 +7,7 @@ import nh3
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.db.models import QuerySet
from pydantic import ValidationError from pydantic import ValidationError
from epdb.models import ( from epdb.models import (
@ -94,8 +95,8 @@ class EPDBURLParser:
def contains_package_url(self): def contains_package_url(self):
return ( return (
bool(self.MODEL_PATTERNS["epdb.Package"].findall(self.url)) bool(self.MODEL_PATTERNS["epdb.Package"].findall(self.url))
and not self.is_package_url() and not self.is_package_url()
) )
def is_user_url(self) -> bool: def is_user_url(self) -> bool:
@ -187,7 +188,7 @@ class UserManager(object):
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create_user( def create_user(
username, email, password, set_setting=True, add_to_group=True, *args, **kwargs username, email, password, set_setting=True, add_to_group=True, *args, **kwargs
): ):
# Clean for potential XSS # Clean for potential XSS
clean_username = nh3.clean(username).strip() clean_username = nh3.clean(username).strip()
@ -345,52 +346,15 @@ class PackageManager(object):
@staticmethod @staticmethod
def readable(user, package): def readable(user, package):
if ( return PackageManager.has_package_permission(user, package, "read")
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 @staticmethod
def writable(user, package): def writable(user, package):
if ( return PackageManager.has_package_permission(user, package, "write")
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 @staticmethod
def administrable(user, package): def administrable(user, package):
if ( return PackageManager.has_package_permission(user, package, "all")
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 @staticmethod
def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str): def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str):
@ -399,18 +363,26 @@ class PackageManager(object):
groups = GroupManager.get_groups(user) 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"]} perms = {"all": ["all"], "write": ["all", "write"], "read": ["all", "write", "read"]}
valid_perms = perms.get(permission) valid_perms = perms.get(permission)
if ( if (
UserPackagePermission.objects.filter( UserPackagePermission.objects.filter(
package=package, user=user, permission__in=valid_perms package=package, user=user, permission__in=valid_perms
).exists() ).exists()
or GroupPackagePermission.objects.filter( or GroupPackagePermission.objects.filter(
package=package, group__in=groups, permission__in=valid_perms package=package, group__in=groups, permission__in=valid_perms
).exists() ).exists()
or user.is_superuser or user.is_superuser
): ):
return True return True
@ -441,6 +413,7 @@ class PackageManager(object):
try: try:
p = Package.objects.get(uuid=package_id) p = Package.objects.get(uuid=package_id)
if PackageManager.readable(user, p): if PackageManager.readable(user, p):
p = PackageManager.check_package_classification(user, p)
return p return p
else: else:
# FIXME: use custom exception to be translatable to 403 in API # FIXME: use custom exception to be translatable to 403 in API
@ -450,6 +423,37 @@ class PackageManager(object):
except Package.DoesNotExist: except Package.DoesNotExist:
raise ValueError("Package with ID {} does not exist!".format(package_id)) 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 @staticmethod
def get_all_readable_packages(user, include_reviewed=False): def get_all_readable_packages(user, include_reviewed=False):
# UserPermission only exists if at least read is granted... # UserPermission only exists if at least read is granted...
@ -474,7 +478,13 @@ class PackageManager(object):
# remove package if user is owner and package is reviewed e.g. admin # remove package if user is owner and package is reviewed e.g. admin
qs = qs.filter(reviewed=False) qs = qs.filter(reviewed=False)
return qs.distinct() qs = qs.distinct()
# EDIT START
qs = PackageManager.check_package_classifications(user, qs)
# EDIT END
return qs
@staticmethod @staticmethod
def get_all_writeable_packages(user): def get_all_writeable_packages(user):
@ -518,11 +528,13 @@ class PackageManager(object):
qs = qs.filter(reviewed=False) qs = qs.filter(reviewed=False)
return qs.distinct() qs = qs.distinct()
@staticmethod # EDIT START
def get_packages(): qs = PackageManager.check_package_classifications(user, qs)
return Package.objects.all() # EDIT END
return qs
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
@ -548,7 +560,7 @@ class PackageManager(object):
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def update_permissions( def update_permissions(
caller: User, package: Package, grantee: Union[User, Group], new_perm: Optional[str] caller: User, package: Package, grantee: Union[User, Group], new_perm: Optional[str]
): ):
caller_perm = None caller_perm = None
if not caller.is_superuser: if not caller.is_superuser:
@ -591,7 +603,7 @@ class PackageManager(object):
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def import_legacy_package( def import_legacy_package(
data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False
): ):
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
@ -671,7 +683,7 @@ class PackageManager(object):
# Check if parent exists and park this Scenario to convert it later into an # Check if parent exists and park this Scenario to convert it later into an
# AdditionalInformation object # AdditionalInformation object
for ex in scenario.get("additionalInformationCollection", {}).get( for ex in scenario.get("additionalInformationCollection", {}).get(
"additionalInformation", [] "additionalInformation", []
): ):
if ex["name"] == "referringscenario": if ex["name"] == "referringscenario":
postponed_scens[ex["data"]].append(scenario) postponed_scens[ex["data"]].append(scenario)
@ -694,7 +706,7 @@ class PackageManager(object):
mapping[scenario["id"]] = scen.uuid mapping[scenario["id"]] = scen.uuid
for ex in scenario.get("additionalInformationCollection", {}).get( for ex in scenario.get("additionalInformationCollection", {}).get(
"additionalInformation", [] "additionalInformation", []
): ):
name = ex["name"] name = ex["name"]
addinf_data = ex["data"] addinf_data = ex["data"]
@ -963,7 +975,7 @@ class PackageManager(object):
for parent, children in postponed_scens.items(): for parent, children in postponed_scens.items():
for child in children: for child in children:
for ex in child.get("additionalInformationCollection", {}).get( for ex in child.get("additionalInformationCollection", {}).get(
"additionalInformation", [] "additionalInformation", []
): ):
child_id = child["id"] child_id = child["id"]
name = ex["name"] name = ex["name"]
@ -1104,11 +1116,11 @@ class PackageManager(object):
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def import_package( def import_package(
data: Dict[str, Any], data: Dict[str, Any],
owner: User, owner: User,
preserve_uuids=False, preserve_uuids=False,
add_import_timestamp=True, add_import_timestamp=True,
trust_reviewed=False, trust_reviewed=False,
) -> Package: ) -> Package:
importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed) importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed)
imported_package = importer.do_import() imported_package = importer.do_import()
@ -1123,7 +1135,7 @@ class PackageManager(object):
@staticmethod @staticmethod
def export_package( def export_package(
package: Package, include_models: bool = False, include_external_identifiers: bool = True package: Package, include_models: bool = False, include_external_identifiers: bool = True
) -> Dict[str, Any]: ) -> Dict[str, Any]:
return PackageExporter(package).do_export() return PackageExporter(package).do_export()
@ -1150,10 +1162,10 @@ class SettingManager(object):
s = Setting.objects.get(uuid=setting_id) s = Setting.objects.get(uuid=setting_id)
if ( if (
s.global_default s.global_default
or s.public or s.public
or user.is_superuser or user.is_superuser
or UserSettingPermission.objects.filter(user=user, setting=s).exists() or UserSettingPermission.objects.filter(user=user, setting=s).exists()
): ):
return s return s
@ -1163,24 +1175,24 @@ class SettingManager(object):
def get_all_settings(user): def get_all_settings(user):
sp = UserSettingPermission.objects.filter(user=user).values("setting") sp = UserSettingPermission.objects.filter(user=user).values("setting")
return ( return (
Setting.objects.filter(id__in=sp) Setting.objects.filter(id__in=sp)
| Setting.objects.filter(public=True) | Setting.objects.filter(public=True)
| Setting.objects.filter(global_default=True) | Setting.objects.filter(global_default=True)
).distinct() ).distinct()
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create_setting( def create_setting(
user: User, user: User,
name: str = None, name: str = None,
description: str = None, description: str = None,
max_nodes: int = None, max_nodes: int = None,
max_depth: int = None, max_depth: int = None,
rule_packages: List[Package] | None = None, rule_packages: List[Package] | None = None,
model: EPModel = None, model: EPModel = None,
model_threshold: float = None, model_threshold: float = None,
expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS, expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS,
property_models: List["PropertyPluginModel"] | None = None, property_models: List["PropertyPluginModel"] | None = None,
): ):
new_s = Setting() new_s = Setting()
@ -1259,8 +1271,8 @@ class SearchManager(object):
pathway_qs = Pathway.objects.filter( pathway_qs = Pathway.objects.filter(
Q(package__in=packages) Q(package__in=packages)
& ( & (
Q(edge__edge_label__educts__inchikey=searchterm) Q(edge__edge_label__educts__inchikey=searchterm)
| Q(edge__edge_label__products__inchikey=searchterm) | Q(edge__edge_label__products__inchikey=searchterm)
) )
).distinct() ).distinct()
@ -1298,8 +1310,8 @@ class SearchManager(object):
pathway_qs = Pathway.objects.filter( pathway_qs = Pathway.objects.filter(
Q(package__in=packages) Q(package__in=packages)
& ( & (
Q(edge__edge_label__educts__smiles=searchterm) Q(edge__edge_label__educts__smiles=searchterm)
| Q(edge__edge_label__products__smiles=searchterm) | Q(edge__edge_label__products__smiles=searchterm)
) )
).distinct() ).distinct()
@ -1335,15 +1347,15 @@ class SearchManager(object):
reactions_qs = Reaction.objects.filter( reactions_qs = Reaction.objects.filter(
Q(package__in=packages) Q(package__in=packages)
& ( & (
Q(educts__inchikey__startswith=inchi_front) Q(educts__inchikey__startswith=inchi_front)
| Q(products__inchikey__startswith=inchi_front) | Q(products__inchikey__startswith=inchi_front)
) )
).distinct() ).distinct()
pathway_qs = Pathway.objects.filter( pathway_qs = Pathway.objects.filter(
Q(package__in=packages) Q(package__in=packages)
& ( & (
Q(edge__edge_label__educts__inchikey__startswith=inchi_front) Q(edge__edge_label__educts__inchikey__startswith=inchi_front)
| Q(edge__edge_label__products__inchikey__startswith=inchi_front) | Q(edge__edge_label__products__inchikey__startswith=inchi_front)
) )
).distinct() ).distinct()
@ -1381,8 +1393,8 @@ class SearchManager(object):
pathway_qs = Pathway.objects.filter( pathway_qs = Pathway.objects.filter(
Q(package__in=packages) Q(package__in=packages)
& ( & (
Q(edge__edge_label__educts__canonical_smiles=searchterm) Q(edge__edge_label__educts__canonical_smiles=searchterm)
| Q(edge__edge_label__products__canonical_smiles=searchterm) | Q(edge__edge_label__products__canonical_smiles=searchterm)
) )
).distinct() ).distinct()
@ -1457,11 +1469,11 @@ class SNode(object):
class SEdge(object): class SEdge(object):
def __init__( def __init__(
self, self,
educts: Union[SNode, List[SNode]], educts: Union[SNode, List[SNode]],
products: Union[SNode | List[SNode]], products: Union[SNode | List[SNode]],
rule: Optional["Rule"] = None, rule: Optional["Rule"] = None,
probability: Optional[float] = None, probability: Optional[float] = None,
): ):
if not isinstance(educts, list): if not isinstance(educts, list):
educts = [educts] educts = [educts]
@ -1493,11 +1505,11 @@ class SEdge(object):
return False return False
if ( if (
self.rule is not None self.rule is not None
and other.rule is None and other.rule is None
or self.rule is None or self.rule is None
and other.rule is not None and other.rule is not None
or self.rule != other.rule or self.rule != other.rule
): ):
return False return False
@ -1505,8 +1517,8 @@ class SEdge(object):
return False return False
for n1, n2 in zip( for n1, n2 in zip(
sorted(self.educts, key=lambda x: x.smiles), sorted(self.educts, key=lambda x: x.smiles),
sorted(other.educts, key=lambda x: x.smiles), sorted(other.educts, key=lambda x: x.smiles),
): ):
if n1.smiles != n2.smiles: if n1.smiles != n2.smiles:
return False return False
@ -1515,8 +1527,8 @@ class SEdge(object):
return False return False
for n1, n2 in zip( for n1, n2 in zip(
sorted(self.products, key=lambda x: x.smiles), sorted(self.products, key=lambda x: x.smiles),
sorted(other.products, key=lambda x: x.smiles), sorted(other.products, key=lambda x: x.smiles),
): ):
if n1.smiles != n2.smiles: if n1.smiles != n2.smiles:
return False return False
@ -1529,10 +1541,10 @@ class SEdge(object):
class SPathway(object): class SPathway(object):
def __init__( def __init__(
self, self,
root_nodes: Optional[Union[str, SNode, List[str | SNode]]] = None, root_nodes: Optional[Union[str, SNode, List[str | SNode]]] = None,
persist: Optional["Pathway"] = None, persist: Optional["Pathway"] = None,
prediction_setting: Optional[Setting] = None, prediction_setting: Optional[Setting] = None,
): ):
self.root_nodes = [] self.root_nodes = []
@ -1677,9 +1689,9 @@ class SPathway(object):
# We don't have any substrate, but technically we have at least one rule that triggered. # We don't have any substrate, but technically we have at least one rule that triggered.
# If our substrate is a root node a.k.a. depth == 0 store that info in SPathway # If our substrate is a root node a.k.a. depth == 0 store that info in SPathway
if ( if (
len(expansion_result["transformations"]) == 0 len(expansion_result["transformations"]) == 0
and expansion_result["rule_triggered"] and expansion_result["rule_triggered"]
and sub.depth == 0 and sub.depth == 0
): ):
self.empty_due_to_threshold = True self.empty_due_to_threshold = True
@ -1786,8 +1798,8 @@ class SPathway(object):
for prod in edge.products: for prod in edge.products:
# Either is a new product or a product and we found a path with a higher prob # Either is a new product or a product and we found a path with a higher prob
if ( if (
prod not in node_probs prod not in node_probs
or current_prob * edge.probability > node_probs[prod] or current_prob * edge.probability > node_probs[prod]
): ):
node_probs[prod] = current_prob * edge.probability node_probs[prod] = current_prob * edge.probability

View File

@ -1,4 +1,4 @@
# Generated by Django 6.0.3 on 2026-04-14 19:07 # Generated by Django 6.0.3 on 2026-04-17 21:22
from django.db import migrations, models from django.db import migrations, models
@ -42,6 +42,11 @@ class Migration(migrations.Migration):
name='simplerule', name='simplerule',
options={}, options={},
), ),
migrations.AddField(
model_name='compoundstructure',
name='molfile',
field=models.TextField(blank=True, null=True, verbose_name='Molfile'),
),
migrations.AddField( migrations.AddField(
model_name='group', model_name='group',
name='secret', name='secret',

View File

@ -867,18 +867,25 @@ class Compound(
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True) 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 # Check if we find a direct match for a given SMILES
if CompoundStructure.objects.filter(smiles=smiles, compound__package=package).exists(): if qs.exists():
return CompoundStructure.objects.get(smiles=smiles, compound__package=package).compound return qs.first().compound
qs = CompoundStructure.objects.filter(smiles=standardized_smiles, compound__package=package)
if subclasses:
qs = qs.not_instance_of(*subclasses)
# Check if we can find the standardized one # Check if we can find the standardized one
if CompoundStructure.objects.filter( if qs.exists():
smiles=standardized_smiles, compound__package=package
).exists():
# TODO should we add a structure? # TODO should we add a structure?
return CompoundStructure.objects.get( return qs.first().compound
smiles=standardized_smiles, compound__package=package
).compound
# Generate Compound # Generate Compound
c = Compound() c = Compound()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,30 @@
<?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>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -7,6 +7,14 @@
<i class="glyphicon glyphicon-plus"></i> Add Compound</a <i class="glyphicon glyphicon-plus"></i> Add Compound</a
> >
</li> </li>
<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>
<li> <li>
<a <a
class="button" class="button"

View File

@ -35,6 +35,7 @@
<use href="{% static "/images/logo-name.svg" %}#ep-logo-name" /> <use href="{% static "/images/logo-name.svg" %}#ep-logo-name" />
</svg> </svg>
</a> </a>
<img src="{% static 'images/bayer-logo.svg' %}" width="40">
</div> </div>
{% if not public_mode %} {% if not public_mode %}
@ -100,6 +101,11 @@
{% endif %} {% endif %}
<div class="navbar-end"> <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 %} {% if not public_mode %}
<a id="search-trigger" role="button" class="cursor-pointer"> <a id="search-trigger" role="button" class="cursor-pointer">
<div <div

View File

@ -75,6 +75,7 @@
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/add_pathway_node_modal.html" %} {% include "modals/objects/add_pathway_node_modal.html" %}
{% include "modals/objects/add_pathway_pes_node_modal.html" %}
{% include "modals/objects/add_pathway_edge_modal.html" %} {% include "modals/objects/add_pathway_edge_modal.html" %}
{% include "modals/objects/download_pathway_csv_modal.html" %} {% include "modals/objects/download_pathway_csv_modal.html" %}
{% include "modals/objects/download_pathway_image_modal.html" %} {% include "modals/objects/download_pathway_image_modal.html" %}

View File

@ -88,6 +88,10 @@ class FormatConverter(object):
def from_smiles(smiles): def from_smiles(smiles):
return Chem.MolFromSmiles(smiles) return Chem.MolFromSmiles(smiles)
@staticmethod
def from_molfile(molfile: str):
return Chem.MolFromMolBlock(molfile)
@staticmethod @staticmethod
def to_smiles(mol, canonical=False): def to_smiles(mol, canonical=False):
return Chem.MolToSmiles(mol, canonical=canonical) return Chem.MolToSmiles(mol, canonical=canonical)