diff --git a/Dockerfile b/Dockerfile index 909e0985..713b3c4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,12 +30,13 @@ 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:1.0 . +# docker build --ssh default -t envipath/envipy-bayer: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 diff --git a/bayer/views.py b/bayer/views.py index 58b2501a..0f1d5671 100644 --- a/bayer/views.py +++ b/bayer/views.py @@ -103,20 +103,15 @@ def create_pes_node(request, package_uuid, pathway_uuid): 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 or True: + 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' or True: + if pes_id == 'dummy': import json res_data = json.load(open(s.BASE_DIR / "fixtures/pes.json")) res_data["pes_url"] = pes_url @@ -125,7 +120,7 @@ def fetch_pes(request, pes_url) -> dict: 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) + res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None) try: res.raise_for_status() diff --git a/bb4g/__init__.py b/bb4g/__init__.py index 1a26effb..1b686c08 100644 --- a/bb4g/__init__.py +++ b/bb4g/__init__.py @@ -2,9 +2,11 @@ import json import math from datetime import datetime from typing import List - +import enum import requests from django.conf import settings as s +from envipy_additional_information import EnviPyModel, UIConfig, WidgetType +from envipy_additional_information import register from bridge.contracts import Classifier # noqa: I001 from bridge.dto import ( @@ -15,12 +17,36 @@ from bridge.dto import ( TransformationProductPrediction, ) # noqa: I001 +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 = None + Config = BB4GConfig - def __init__(self, config=None): + def __init__(self, config: BB4GConfig | None = None): super().__init__(config) self.url = f"{s.BB4G_URL}" @@ -29,10 +55,6 @@ class BB4G(Classifier): "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", } - self.proxies = { - "http": s.HTTP_PROXY, - "https": s.HTTPS_PROXY, - } def acquire_token(self): BB4G_TENANT_ID = s.BB4G_TENANT_ID @@ -65,7 +87,7 @@ class BB4G(Classifier): started = False retries = 0 while not started and retries < 5: - res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=self.proxies) + res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None) if res.status_code == 200: started = True @@ -140,11 +162,11 @@ class BB4G(Classifier): for smi in smiles: data = { "smiles": smi, - "sampling_alg": "exact", - "cutoff": -5, + "sampling_alg": self.config.sampling_algorithm.value, + "cutoff": self.config.cutoff, } - resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=self.proxies) + resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=s.PROXIES or None) resp.raise_for_status() diff --git a/bridge/contracts.py b/bridge/contracts.py index 0e74e9c0..9a5f998b 100644 --- a/bridge/contracts.py +++ b/bridge/contracts.py @@ -254,7 +254,15 @@ class Classifier(Plugin): def parse_config(cls, data: dict | None = None) -> EnviPyModel | None: if cls.Config is None: return None - return cls.Config(**(data or {})) + + # 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) @classmethod def create(cls, data: dict | None = None): diff --git a/envipath/settings.py b/envipath/settings.py index 66b8fea3..318686f6 100644 --- a/envipath/settings.py +++ b/envipath/settings.py @@ -143,6 +143,12 @@ 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 @@ -474,3 +480,14 @@ if 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") diff --git a/epauth/urls.py b/epauth/urls.py index c251d799..d777c9f1 100644 --- a/epauth/urls.py +++ b/epauth/urls.py @@ -5,4 +5,5 @@ 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"), ] diff --git a/epauth/views.py b/epauth/views.py index 1bab7da4..e0a5d795 100644 --- a/epauth/views.py +++ b/epauth/views.py @@ -2,9 +2,11 @@ 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 epdb.logic import UserManager +from epdb.logic import UserManager, GroupManager +from epdb.models import Group def get_msal_app_with_cache(request): @@ -81,10 +83,12 @@ 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) + 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 @@ -93,7 +97,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) @@ -152,3 +157,9 @@ def get_access_token_from_request(request, scopes=None): 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') diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py index ce09498b..57b01229 100644 --- a/epdb/legacy_api.py +++ b/epdb/legacy_api.py @@ -1,19 +1,17 @@ -import hashlib from collections import defaultdict from typing import Any, Dict, List, Optional import jwt -import requests - 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.errors import HttpError from ninja.security import HttpBearer -from ninja.security import SessionAuth from utilities.chem import FormatConverter from utilities.misc import PackageExporter @@ -51,6 +49,26 @@ 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): @@ -68,9 +86,7 @@ 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() + jwks = get_cached_jwks(TENANT_ID) header = jwt.get_unverified_header(token) @@ -78,13 +94,21 @@ def validate_token(token: str) -> dict: 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}/", - ) + # 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 diff --git a/epdb/views.py b/epdb/views.py index ad643475..1b293e37 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -937,12 +937,14 @@ def package_models(request, package_uuid): "requires_rule_packages": True, "requires_data_packages": True, }, - "EnviFormer": { + } + + if s.ENVIFORMER_PRESENT: + context["model_types"]["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(): diff --git a/templates/collections/compounds_paginated.html b/templates/collections/compounds_paginated.html index f5868225..fd30d98e 100644 --- a/templates/collections/compounds_paginated.html +++ b/templates/collections/compounds_paginated.html @@ -5,7 +5,7 @@ {% block action_button %}
- {% if meta.can_edit %} + {% if meta.can_edit or not meta.url_contains_package %} + {% if meta.can_edit or not meta.url_contains_package %} + {% if meta.enabled_features.MODEL_BUILDING %} + + {% endif %} {% endif %} {% endblock action_button %} diff --git a/templates/collections/packages_paginated.html b/templates/collections/packages_paginated.html index c9d9668b..cd59b6eb 100644 --- a/templates/collections/packages_paginated.html +++ b/templates/collections/packages_paginated.html @@ -3,7 +3,6 @@ {% block page_title %}Packages{% endblock %} {% block action_button %} - {% if meta.can_edit %}
- {% endif %} {% endblock action_button %} {% block action_modals %} diff --git a/templates/collections/pathways_paginated.html b/templates/collections/pathways_paginated.html index 900f68d4..e87711dd 100644 --- a/templates/collections/pathways_paginated.html +++ b/templates/collections/pathways_paginated.html @@ -3,7 +3,7 @@ {% block page_title %}Pathways{% endblock %} {% block action_button %} - {% if meta.can_edit %} + {% if meta.can_edit or not meta.url_contains_package %}