From d1a00f71b47a9754cf64a7eaa634ff86349055b3 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 | 31 +++++- epdb/models.py | 12 ++- static/js/pw.js | 68 +++++++----- templates/objects/compound.html | 7 ++ templates/objects/compound_structure.html | 7 ++ templates/objects/node.html | 7 ++ 19 files changed, 412 insertions(+), 115 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 23e1b1f9..07e48aa4 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 13010a1d..e833f32d 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -627,6 +627,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() @@ -712,7 +731,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() @@ -720,6 +745,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/models.py b/epdb/models.py index cf1b71a8..84e0cf66 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -1113,6 +1113,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") @@ -1209,6 +1210,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) @@ -2215,7 +2219,9 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) if isinstance(ai.get(), PropertyPrediction): predicted_properties[ai.get().__class__.__name__].append(ai.data) - return { + extra_structure_data = self.default_node_label.d3_json() + + res = { "depth": self.depth, "stereo_removed": self.stereo_removed, "url": self.url, @@ -2224,6 +2230,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) "image_svg": IndigoUtils.mol_to_svg( self.default_node_label.smiles, width=40, height=40 ), + "name": self.get_name(), "smiles": self.default_node_label.smiles, "scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()], @@ -2238,6 +2245,9 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin) "timeseries": self.get_timeseries_data(), } + res.update(**extra_structure_data) + return res + @staticmethod @transaction.atomic def create( diff --git a/static/js/pw.js b/static/js/pw.js index 5072a030..32554eb2 100644 --- a/static/js/pw.js +++ b/static/js/pw.js @@ -637,44 +637,54 @@ 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.is_pes) { + g.append("svg:image") + .attr("xlink:href", d.image) + .attr("width", 40) + .attr("height", 40) + .attr("x", -20) + .attr("y", -20); + } else { + // 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); + } - 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/objects/compound.html b/templates/objects/compound.html index e920f469..05c8c809 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..33a73426 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/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 %} +