From 4012ac356be0e4e8de0b616016db0697d7c5bb44 Mon Sep 17 00:00:00 2001 From: Tim Lorsbach Date: Fri, 17 Apr 2026 19:39:54 +0200 Subject: [PATCH] wip --- bayer/admin.py | 16 +++ bayer/epdb_hooks.py | 16 +++ .../0004_pescompound_pesstructure.py | 35 ++++++ .../migrations/0005_pesstructure_pes_link.py | 19 ++++ bayer/models.py | 76 ++++++++++++- bayer/templates/actions/objects/compound.html | 0 .../modals/collections/new_pes_modal.html | 5 +- .../objects/compound_structure_viz.html | 12 +++ bayer/templates/objects/compound_viz.html | 12 +++ bayer/templates/objects/node_viz.html | 12 +++ bayer/urls.py | 6 ++ bayer/views.py | 85 ++++++++++----- epdb/legacy_api.py | 101 ++++++++---------- epdb/logic.py | 27 ++++- templates/objects/node.html | 7 ++ 15 files changed, 344 insertions(+), 85 deletions(-) create mode 100644 bayer/migrations/0004_pescompound_pesstructure.py create mode 100644 bayer/migrations/0005_pesstructure_pes_link.py delete mode 100644 bayer/templates/actions/objects/compound.html create mode 100644 bayer/templates/objects/compound_structure_viz.html create mode 100644 bayer/templates/objects/compound_viz.html create mode 100644 bayer/templates/objects/node_viz.html diff --git a/bayer/admin.py b/bayer/admin.py index 8c38f3f3..f0b257bb 100644 --- a/bayer/admin.py +++ b/bayer/admin.py @@ -1,3 +1,19 @@ 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) diff --git a/bayer/epdb_hooks.py b/bayer/epdb_hooks.py index b843782b..a1a237f5 100644 --- a/bayer/epdb_hooks.py +++ b/bayer/epdb_hooks.py @@ -4,6 +4,7 @@ from epdb.template_registry import register_template logger = logging.getLogger(__name__) +# PES Create register_template( "epdb.actions.collections.compound", "actions/collections/new_pes.html", @@ -13,3 +14,18 @@ register_template( "modals/collections/new_pes_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", +) \ No newline at end of file diff --git a/bayer/migrations/0004_pescompound_pesstructure.py b/bayer/migrations/0004_pescompound_pesstructure.py new file mode 100644 index 00000000..b2a8f343 --- /dev/null +++ b/bayer/migrations/0004_pescompound_pesstructure.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.3 on 2026-04-15 20:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bayer', '0003_package_data_pool'), + ('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')), + ], + options={ + 'abstract': False, + }, + bases=('epdb.compoundstructure',), + ), + ] diff --git a/bayer/migrations/0005_pesstructure_pes_link.py b/bayer/migrations/0005_pesstructure_pes_link.py new file mode 100644 index 00000000..072b349b --- /dev/null +++ b/bayer/migrations/0005_pesstructure_pes_link.py @@ -0,0 +1,19 @@ +# 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, + ), + ] diff --git a/bayer/models.py b/bayer/models.py index aa075692..53bffc16 100644 --- a/bayer/models.py +++ b/bayer/models.py @@ -1,11 +1,15 @@ from typing import List - +import urllib.parse +import nh3 from django.conf import settings as s -from django.db import models +from django.db import models, transaction from django.db.models import QuerySet +from django.urls import reverse from epdb.models import ( EnviPathModel, + Compound, + CompoundStructure, ParallelRule, SequentialRule, SimpleAmbitRule, @@ -95,4 +99,70 @@ class Package(EnviPathModel): return rules class Meta: - db_table = "epdb_package" \ No newline at end of file + 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(): + return PESStructure.objects.get(pes_link=pes_url, compound__package=package).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() + + is_standardized = standardized_smiles == smiles + + if not is_standardized: + _ = CompoundStructure.create( + c, + standardized_smiles, + name="Normalized structure of {}".format(name), + description="{} (in its normalized form)".format(description), + normalized_structure=True, + ) + + cs = CompoundStructure.create( + c, 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") + + 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)}" + } diff --git a/bayer/templates/actions/objects/compound.html b/bayer/templates/actions/objects/compound.html deleted file mode 100644 index e69de29b..00000000 diff --git a/bayer/templates/modals/collections/new_pes_modal.html b/bayer/templates/modals/collections/new_pes_modal.html index 2cf11949..7727697c 100644 --- a/bayer/templates/modals/collections/new_pes_modal.html +++ b/bayer/templates/modals/collections/new_pes_modal.html @@ -90,7 +90,7 @@
{% csrf_token %} @@ -129,9 +129,10 @@ name="pes-link" type="text" class="input input-bordered w-full" - placeholder="Link to PES" + placeholder="Link to PES e.g. https://pesregapp-test.cropkey-np.ag/entities/PES-000126" x-model="pesLink" @input="updatePesViz()" + required /> diff --git a/bayer/templates/objects/compound_structure_viz.html b/bayer/templates/objects/compound_structure_viz.html new file mode 100644 index 00000000..aeab1fd4 --- /dev/null +++ b/bayer/templates/objects/compound_structure_viz.html @@ -0,0 +1,12 @@ +{% if compound_structure.pes_link %} + +
+ +
PES Image Representation
+
+
+ +
+
+
+{% endif %} \ No newline at end of file diff --git a/bayer/templates/objects/compound_viz.html b/bayer/templates/objects/compound_viz.html new file mode 100644 index 00000000..7727fa37 --- /dev/null +++ b/bayer/templates/objects/compound_viz.html @@ -0,0 +1,12 @@ +{% if compound.default_structure.pes_link %} + +
+ +
PES Image Representation
+
+
+ +
+
+
+{% endif %} \ No newline at end of file diff --git a/bayer/templates/objects/node_viz.html b/bayer/templates/objects/node_viz.html new file mode 100644 index 00000000..16b47afc --- /dev/null +++ b/bayer/templates/objects/node_viz.html @@ -0,0 +1,12 @@ +{% if node.default_node_label.pes_link %} + +
+ +
PES Image Representation
+
+
+ +
+
+
+{% endif %} \ No newline at end of file diff --git a/bayer/urls.py b/bayer/urls.py index ebe5fc0d..4ff5e5c4 100644 --- a/bayer/urls.py +++ b/bayer/urls.py @@ -2,7 +2,13 @@ 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{UUID})/compound$", + v.create_pes, + name="create pes", + ), ] diff --git a/bayer/views.py b/bayer/views.py index 41657eb1..581f3ceb 100644 --- a/bayer/views.py +++ b/bayer/views.py @@ -1,13 +1,41 @@ +import base64 + import requests from django.conf import settings as s +from django.core.exceptions import BadRequest from django.http import HttpResponse -from pydantic import BaseModel +from django.shortcuts import redirect -from utilities.chem import FormatConverter +from bayer.models import PESCompound +from epdb.logic import PackageManager +from epdb.views import _anonymous_or_real +from utilities.decorators import package_permission_required -class PES(BaseModel): - pass +@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": + 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}") + + pes = PESCompound.create(current_package, pes_data, compound_name, compound_description) + + return redirect(pes.url) + else: + return BadRequest("Please provide a PES link.") + else: + pass + def fetch_pes(request, pes_url) -> dict: @@ -19,28 +47,35 @@ 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: + if token or True: for k, v in s.PES_API_MAPPING.items(): - if pes_url.startsWith(k): + if pes_url.startswith(k): pes_id = pes_url.split('/')[-1] - headers = {"Authorization": f"Bearer {token['access_token']}"} - params = {"pes_reg_entity_corporate_id": pes_id} - res = requests.get(v, headers=headers, params=params, proxies=proxies) - - 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] + if pes_id == 'dummy' or True: + import json + res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json")) res_data["pes_url"] = pes_url return res_data + else: + headers = {"Authorization": f"Bearer {token['access_token']}"} + params = {"pes_reg_entity_corporate_id": pes_id} - except requests.exceptions.HTTPError as e: - raise ValueError(f"Error fetching PES with id {pes_id}: {e}") + res = requests.get(v, headers=headers, params=params, proxies=proxies) + + 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: @@ -52,8 +87,10 @@ def visualize_pes(request): if pes_link: pes_data = fetch_pes(request, pes_link) - print(pes_data) - return HttpResponse( - FormatConverter.to_png("c1ccccc1"), content_type="image/png" - ) + 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") diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py index 70c44090..e02ffbcb 100644 --- a/epdb/legacy_api.py +++ b/epdb/legacy_api.py @@ -1,17 +1,22 @@ +import hashlib from collections import defaultdict from typing import Any, Dict, List, Optional +import jwt +import requests + import nh3 from django.conf import settings as s from django.contrib.auth import get_user_model from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect from ninja import Field, Form, Query, Router, Schema +from ninja.errors import HttpError +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, @@ -59,7 +64,46 @@ def _anonymous_or_real(request): return get_user_model().objects.get(username="anonymous") -router = Router(auth=SessionAuth(csrf=False)) +def validate_token(token: str) -> dict: + TENANT_ID = s.MS_ENTRA_TENANT_ID + CLIENT_ID = s.MS_ENTRA_CLIENT_ID + + # Fetch Microsoft's public keys + jwks_uri = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys" + jwks = requests.get(jwks_uri).json() + + 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"]) + ) + + claims = jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=[CLIENT_ID, f"api://{CLIENT_ID}"], + issuer=f"https://sts.windows.net/{TENANT_ID}/", + ) + 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()) class Error(Schema): @@ -153,59 +197,6 @@ 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 - - if request.headers.get("Authorization"): - import jwt - import requests - - TENANT_ID = s.MS_ENTRA_TENANT_ID - CLIENT_ID = s.MS_ENTRA_CLIENT_ID - - def validate_token(token: str) -> dict: - # Fetch Microsoft's public keys - jwks_uri = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys" - jwks = requests.get(jwks_uri).json() - - 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"]) - ) - - claims = jwt.decode( - token, - public_key, - algorithms=["RS256"], - audience=[CLIENT_ID, f"api://{CLIENT_ID}"], - issuer=f"https://sts.windows.net/{TENANT_ID}/", - ) - return claims - - token = request.headers.get("Authorization").split(" ")[1] - - claims = validate_token(token) - - if not User.objects.filter(uuid=claims['oid']).exists(): - user = None - else: - user = User.objects.get(uuid=claims['oid']) - - else: - 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 # diff --git a/epdb/logic.py b/epdb/logic.py index 842f8678..36819048 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -596,6 +596,25 @@ 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() @@ -681,7 +700,13 @@ class PackageManager(object): default_structure = None for structure in compound["structures"]: - struc = CompoundStructure() + if structure.get("pesLink"): + from bayer.models import PESStructure + struc = PESStructure() + struc.pes_link = structure["pesLink"] + else: + 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() diff --git a/templates/objects/node.html b/templates/objects/node.html index 3278b69c..5f5515f9 100644 --- a/templates/objects/node.html +++ b/templates/objects/node.html @@ -1,4 +1,5 @@ {% extends "framework_modern.html" %} +{% load envipytags %} {% block content %} @@ -54,6 +55,12 @@ + {% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %} + + {% for tpl in viz_templates %} + {% include tpl %} + {% endfor %} +