From 8498e59fa18fb602cbcabf45ff86ee555dea9487 Mon Sep 17 00:00:00 2001 From: jebus Date: Wed, 22 Apr 2026 06:08:39 +1200 Subject: [PATCH] [Feature] Changes required for non public tenants (#370) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/370 --- Dockerfile | 2 + envipath/urls.py | 3 + epauth/views.py | 90 ++++++++++++++++--- epdb/legacy_api.py | 2 +- epdb/logic.py | 63 +++++-------- ...lter_compoundstructure_options_and_more.py | 49 ++++++++++ epdb/models.py | 14 ++- static/js/pw.js | 70 +++++++++------ templates/actions/objects/pathway.html | 7 ++ templates/objects/compound.html | 7 ++ templates/objects/compound_structure.html | 7 ++ templates/objects/pathway.html | 6 ++ utilities/chem.py | 17 +++- 13 files changed, 249 insertions(+), 88 deletions(-) create mode 100644 epdb/migrations/0023_alter_compoundstructure_options_and_more.py diff --git a/Dockerfile b/Dockerfile index e8e99c4e..08cf1600 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,11 +36,13 @@ RUN --mount=type=ssh \ # Now copy source and do a final sync to install the project itself # Ensure .dockerignore is reasonable +COPY biotransformer biotransformer 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 diff --git a/envipath/urls.py b/envipath/urls.py index dc46f0d3..e42cf0a6 100644 --- a/envipath/urls.py +++ b/envipath/urls.py @@ -40,6 +40,9 @@ 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" diff --git a/epauth/views.py b/epauth/views.py index e73a3ff9..66b922c6 100644 --- a/epauth/views.py +++ b/epauth/views.py @@ -1,12 +1,32 @@ 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 +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 +43,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: @@ -36,11 +52,18 @@ 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["name"] - user_email = claims["emailaddress"] - user_oid = claims["oid"] + 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") # Get implementing class User = get_user_model() @@ -57,4 +80,51 @@ def entra_callback(request): login(request, u) - return redirect("/") # Handle errors + 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 diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py index 079ea3d0..1fed91fc 100644 --- a/epdb/legacy_api.py +++ b/epdb/legacy_api.py @@ -1969,7 +1969,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]): return redirect(new_e.url) 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}") diff --git a/epdb/logic.py b/epdb/logic.py index 2f0d6d87..842f8678 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -264,8 +264,12 @@ class GroupManager(object): return bool(re.findall(GroupManager.group_pattern, url)) @staticmethod - def create_group(current_user, name, description): + def create_group(current_user, name, description, *args, **kwargs): 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() @@ -341,52 +345,17 @@ class PackageManager(object): @staticmethod def readable(user, package): - 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 + return ( + PackageManager.has_package_permission(user, package, "read") | package.reviewed is True + ) @staticmethod def writable(user, package): - 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 + return PackageManager.has_package_permission(user, package, "write") @staticmethod def administrable(user, package): - 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 + return PackageManager.has_package_permission(user, package, "all") @staticmethod def has_package_permission(user: "User", package: Union[str, UUID, "Package"], permission: str): @@ -470,7 +439,9 @@ class PackageManager(object): # remove package if user is owner and package is reviewed e.g. admin qs = qs.filter(reviewed=False) - return qs.distinct() + qs = qs.distinct() + + return qs @staticmethod def get_all_writeable_packages(user): @@ -514,7 +485,9 @@ class PackageManager(object): qs = qs.filter(reviewed=False) - return qs.distinct() + qs = qs.distinct() + + return qs @staticmethod def get_packages(): @@ -716,6 +689,10 @@ 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"]: diff --git a/epdb/migrations/0023_alter_compoundstructure_options_and_more.py b/epdb/migrations/0023_alter_compoundstructure_options_and_more.py new file mode 100644 index 00000000..6868f514 --- /dev/null +++ b/epdb/migrations/0023_alter_compoundstructure_options_and_more.py @@ -0,0 +1,49 @@ +# 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"), + ), + ] diff --git a/epdb/models.py b/epdb/models.py index 30d7b200..67c2c397 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -1112,6 +1112,7 @@ 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") @@ -1208,6 +1209,9 @@ 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) @@ -2214,7 +2218,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) if isinstance(ai.get(), PropertyPrediction): predicted_properties[ai.get().__class__.__name__].append(ai.data) - return { + # 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 = { "depth": self.depth, "stereo_removed": self.stereo_removed, "url": self.url, @@ -2223,6 +2231,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) "image_svg": IndigoUtils.mol_to_svg( 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()], @@ -2235,8 +2244,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) "predicted_properties": predicted_properties, "is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False), "timeseries": self.get_timeseries_data(), + **structure_data, } + return res + @staticmethod @transaction.atomic def create( diff --git a/static/js/pw.js b/static/js/pw.js index 5072a030..26f4d870 100644 --- a/static/js/pw.js +++ b/static/js/pw.js @@ -637,44 +637,56 @@ function draw(pathway, elem) { node.filter(d => !d.pseudo).each(function (d, i) { const g = d3.select(this); - // Parse the SVG string - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(d.image_svg, "image/svg+xml"); - const svgElem = svgDoc.documentElement; + 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; - // 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 references - svgElem.querySelectorAll('[id]').forEach(el => { - const oldId = el.id; - const newId = prefix + oldId; - el.id = newId; + // Rename all IDs and fix 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 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 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); + 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); + } - 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 diff --git a/templates/actions/objects/pathway.html b/templates/actions/objects/pathway.html index 3aaaa248..85e4164b 100644 --- a/templates/actions/objects/pathway.html +++ b/templates/actions/objects/pathway.html @@ -1,3 +1,5 @@ +{% load envipytags %} + {% if meta.can_edit %}
  • Add Reaction
  • + {% epdb_slot_templates "epdb.actions.objects.pathway.add" as action_button_templates %} + + {% for tpl in action_button_templates %} + {% include tpl %} + {% endfor %} {% endif %}
  • diff --git a/templates/objects/compound.html b/templates/objects/compound.html index e920f469..c9f6c060 100644 --- a/templates/objects/compound.html +++ b/templates/objects/compound.html @@ -1,4 +1,5 @@ {% extends "framework_modern.html" %} +{% load envipytags %} {% block content %} @@ -82,6 +83,12 @@
    {{ compound.description }}
    + + {% epdb_slot_templates "epdb.objects.compound.viz" as viz_templates %} + {% for tpl in viz_templates %} + {% include tpl %} + {% endfor %} +
    diff --git a/templates/objects/compound_structure.html b/templates/objects/compound_structure.html index b705088e..22195004 100644 --- a/templates/objects/compound_structure.html +++ b/templates/objects/compound_structure.html @@ -1,4 +1,5 @@ {% extends "framework_modern.html" %} +{% load envipytags %} {% block content %} @@ -50,6 +51,12 @@
    + + {% epdb_slot_templates "epdb.objects.compound_structure.viz" as viz_templates %} + {% for tpl in viz_templates %} + {% include tpl %} + {% endfor %} +
    diff --git a/templates/objects/pathway.html b/templates/objects/pathway.html index 27d1aab6..3ca6045e 100644 --- a/templates/objects/pathway.html +++ b/templates/objects/pathway.html @@ -1,5 +1,7 @@ {% extends "framework_modern.html" %} {% load static %} +{% load envipytags %} + {% block content %}