More on PES

This commit is contained in:
Tim Lorsbach
2026-04-15 21:14:47 +02:00
parent 349877b5e3
commit ca0508d96a
11 changed files with 544 additions and 34 deletions

15
bayer/epdb_hooks.py Normal file
View File

@ -0,0 +1,15 @@
import logging
from epdb.template_registry import register_template
logger = logging.getLogger(__name__)
register_template(
"epdb.actions.collections.compound",
"actions/collections/new_pes.html",
)
register_template(
"modals.collections.compound",
"modals/collections/new_pes_modal.html",
)

View File

@ -0,0 +1,9 @@
{% 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

@ -133,10 +133,8 @@
class="select select-bordered w-full"
>
<option value="" disabled selected>Select Data Pool</option>
{% for obj in meta.available_groups %}
{% if obj.secret %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endif %}
{% for obj in meta.secret_groups %}
<option value="{{ obj.url }}">{{ obj.name|safe }}</option>
{% endfor %}
</select>
</div>

View File

@ -0,0 +1,173 @@
{% 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 'package compound list' 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"
x-model="pesLink"
@input="updatePesViz()"
/>
</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-compound-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

@ -0,0 +1,149 @@
{% extends "static/login_base.html" %}
{% 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 class="card bg-base-200 mb-6 ">
<div class="card-body">
<h3 class="card-title">Welcome to the new enviPath!</h3>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-base-300 mb-6 border-b">
<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 %}

8
bayer/urls.py Normal file
View File

@ -0,0 +1,8 @@
from django.urls import re_path
from . import views as v
urlpatterns = [
re_path(r"^depict_pes$", v.visualize_pes, name="depict_pes"),
]

View File

@ -1,3 +1,59 @@
from django.shortcuts import render
import requests
from django.conf import settings as s
from django.http import HttpResponse
from pydantic import BaseModel
# Create your views here.
from utilities.chem import FormatConverter
class PES(BaseModel):
pass
def fetch_pes(request, pes_url) -> dict:
proxies = {
"http": "http://10.185.190.100:8080",
"https": "http://10.185.190.100:8080",
}
from epauth.views import get_access_token_from_request
token = get_access_token_from_request(request)
if token:
for k, v in s.PES_API_MAPPING.items():
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]
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)
print(pes_data)
return HttpResponse(
FormatConverter.to_png("c1ccccc1"), content_type="image/png"
)

View File

@ -1,12 +1,33 @@
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.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
def entra_login(request):
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
@ -23,11 +44,7 @@ def entra_login(request):
def entra_callback(request):
msal_app = msal.ConfidentialClientApplication(
client_id=s.MS_ENTRA_CLIENT_ID,
client_credential=s.MS_ENTRA_CLIENT_SECRET,
authority=s.MS_ENTRA_AUTHORITY,
)
msal_app, cache = get_msal_app_with_cache(request)
flow = request.session.pop("msal_auth_flow", None)
if not flow:
@ -35,24 +52,10 @@ def entra_callback(request):
# Acquire token using the flow and callback request
result = msal_app.acquire_token_by_auth_code_flow(flow, request.GET)
print(result)
# if "error" in result:
# {'correlation_id': '626f511b-5230-4d06-9ffd-d89a764082c6',
# 'error': 'invalid_client',
# 'error_codes': [7000222],
# 'error_description': 'AADSTS7000222: The provided client secret keys for app '
# "'35c75dfb-bd15-493d-b4e9-af847f2df894' are expired. "
# 'Visit the Azure portal to create new keys for your app: '
# 'https://aka.ms/NewClientSecret, or consider using '
# 'certificate credentials for added security: '
# 'https://aka.ms/certCreds. Trace ID: '
# '30ba1c58-c949-4432-9ed6-3b6136856700 Correlation ID: '
# '626f511b-5230-4d06-9ffd-d89a764082c6 Timestamp: '
# '2026-04-15 08:21:15Z',
# 'error_uri': 'https://login.microsoftonline.com/error?code=7000222',
# 'timestamp': '2026-04-15 08:21:15Z',
# 'trace_id': '30ba1c58-c949-4432-9ed6-3b6136856700'}
# return redirect("/")
# Save the token cache to session
if cache.has_state_changed:
request.session["msal_token_cache"] = cache.serialize()
claims = result["id_token_claims"]
@ -79,7 +82,8 @@ def entra_callback(request):
# 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)
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
@ -88,7 +92,8 @@ def entra_callback(request):
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)
g = GroupManager.create_group(User.objects.get(username="admin"), name, f"Synced Entra Group {name} ",
uuid=id)
else:
g = Group.objects.get(uuid=id)
@ -100,3 +105,53 @@ def entra_callback(request):
# 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.
"""
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

View File

@ -160,8 +160,46 @@ class SimpleModel(SimpleObject):
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 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

View File

@ -388,6 +388,9 @@ 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
},
}

View File

@ -88,6 +88,12 @@
>Scenario</a
>
</li>
<hr/>
<li>
<a href="{{ meta.server_url }}/group" id="scenarioLink"
>Group</a
>
</li>
</ul>
</div>
</div>