forked from enviPath/enviPy
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a8974b1b8 | ||
|
|
20b3e73347 | ||
|
|
8a1ed2fc6d | ||
|
|
a6b901aa47 | ||
|
|
423ddf16d1 | ||
|
|
a7b2e60660 | ||
|
|
ae3a164e74 | ||
|
|
2e2abf0287 | ||
|
|
86c9ac887e | ||
|
|
5bbe316c24 | ||
|
|
d63c40e041 | ||
|
|
8dc0e5e7a3 | ||
|
|
ff37a17edb | ||
|
|
c60eac879e | ||
|
|
b269b7dfd8 |
+6
-1
@@ -3,7 +3,7 @@ from django.contrib import admin
|
||||
# Register your models here.
|
||||
from .models import (
|
||||
PESCompound,
|
||||
PESStructure
|
||||
PESStructure, NewsEntry
|
||||
)
|
||||
|
||||
|
||||
@@ -15,5 +15,10 @@ class PESStructureAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class NewsEntryAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(PESCompound, PESCompoundAdmin)
|
||||
admin.site.register(PESStructure, PESStructureAdmin)
|
||||
admin.site.register(NewsEntry, NewsEntryAdmin)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Context processors for enviPy application.
|
||||
|
||||
Context processors automatically make variables available to all templates.
|
||||
"""
|
||||
|
||||
|
||||
from django.utils import timezone
|
||||
from .models import NewsEntry
|
||||
|
||||
|
||||
def news_entries_context(request):
|
||||
"""
|
||||
Provides package data for the search modal which is included globally
|
||||
in framework_modern.html.
|
||||
|
||||
Returns:
|
||||
dict: Context dictionary with reviewed and unreviewed packages
|
||||
"""
|
||||
|
||||
|
||||
qs = NewsEntry.objects.filter(publication_date__lte=timezone.now()).order_by('-publication_date')[:3]
|
||||
return {
|
||||
"news_entries": qs,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-16 07:50
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bayer', '0004_package_shareable'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NewsEntry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID of this object')),
|
||||
('url', models.TextField(null=True, unique=True, verbose_name='URL')),
|
||||
('title', models.TextField(verbose_name='Title')),
|
||||
('publication_date', models.DateTimeField(verbose_name='Date')),
|
||||
('content', models.TextField(verbose_name='Content')),
|
||||
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-16 07:59
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bayer', '0005_newsentry'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='newsentry',
|
||||
name='url',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.3 on 2026-11-09 21:43
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bayer', '0006_remove_newsentry_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='newsentry',
|
||||
name='publication_date',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date'),
|
||||
),
|
||||
]
|
||||
+31
-10
@@ -1,10 +1,12 @@
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
import urllib.parse
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.db import models, transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from epdb.models import (
|
||||
EnviPathModel,
|
||||
@@ -27,7 +29,7 @@ class Package(EnviPathModel):
|
||||
|
||||
class Classification(models.IntegerChoices):
|
||||
INTERNAL = 0, "Internal"
|
||||
RESTRICTED = 10 , "Restricted"
|
||||
RESTRICTED = 10, "Restricted"
|
||||
SECRET = 20, "Secret"
|
||||
|
||||
classification_level = models.IntegerField(
|
||||
@@ -109,7 +111,7 @@ class PESCompound(Compound):
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
package: "Package", pes_data: dict, name: str = None, description: str = None, *args, **kwargs
|
||||
package: "Package", pes_data: dict, name: str = None, description: str = None, *args, **kwargs
|
||||
) -> "Compound":
|
||||
|
||||
pes_url = pes_data["pes_url"]
|
||||
@@ -161,7 +163,6 @@ class PESCompound(Compound):
|
||||
normalized_structure=True,
|
||||
)
|
||||
|
||||
|
||||
cs = PESStructure.create(
|
||||
c,
|
||||
pes_url,
|
||||
@@ -218,13 +219,13 @@ class PESStructure(CompoundStructure):
|
||||
|
||||
@transaction.atomic
|
||||
def add_structure(
|
||||
self,
|
||||
smiles: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
default_structure: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
self,
|
||||
smiles: str,
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
default_structure: bool = False,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "CompoundStructure":
|
||||
raise ValueError("Not supported!")
|
||||
|
||||
@@ -236,3 +237,23 @@ class PESStructure(CompoundStructure):
|
||||
"image": f"{reverse('depict_pes')}?pesLink={urllib.parse.quote(self.pes_link)}",
|
||||
"image_type": "png",
|
||||
}
|
||||
|
||||
|
||||
class NewsEntry(models.Model):
|
||||
uuid = models.UUIDField(
|
||||
null=False, blank=False, verbose_name="UUID of this object", unique=True, default=uuid4
|
||||
)
|
||||
title = models.TextField(verbose_name="Title")
|
||||
publication_date = models.DateTimeField(verbose_name="Date", default=timezone.now)
|
||||
content = models.TextField(verbose_name="Content")
|
||||
author = models.ForeignKey("epdb.User", on_delete=models.SET_NULL, blank=True, null=True)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f"{s.SERVER_URL}/news/{self.uuid}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.url:
|
||||
self.url = self._url()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
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>
|
||||
<h4 class='alert-heading'>Failed to fetch this PES!</h4>
|
||||
<p>Either the PES-ID is incorrect, or you're missing sufficient permissions to access this (potentially secret) item. In case you're missing permissions you can request the entitlement cs.u.enviPath_secret_data_user_group on go/idnow to gain access.</p>
|
||||
</div>`;
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
{% extends "framework_modern.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
{# Header / Title row #}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">{{ news_entry.title }}</h1>
|
||||
</div>
|
||||
|
||||
{# Meta info card #}
|
||||
<div class="card bg-base-100 shadow mb-6">
|
||||
<div class="card-body">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-base-content/60">Published</span>
|
||||
<p class="mt-1">{{ news_entry.publication_date|date:"N j, Y, P" }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-semibold text-base-content/60">Author</span>
|
||||
<p class="mt-1">
|
||||
{% if news_entry.author %}
|
||||
{{ news_entry.author.get_full_name|default:news_entry.author.username }}
|
||||
{% else %}
|
||||
<span class="text-base-content/40 italic">Unknown</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Content card #}
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body prose max-w-none">
|
||||
{{ news_entry.content|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Back link #}
|
||||
<div class="mt-6">
|
||||
<a href="javascript:history.back()" class="btn btn-ghost btn-sm">
|
||||
← Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
@@ -16,4 +16,5 @@ urlpatterns = [
|
||||
v.create_pes_node,
|
||||
name="create pes node",
|
||||
),
|
||||
re_path(rf"^news/(?P<news_entry_uuid>{UUID})$", v.news_detail, name="news detail"),
|
||||
]
|
||||
|
||||
+27
-5
@@ -4,12 +4,12 @@ import logging
|
||||
import requests
|
||||
from django.conf import settings as s
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from bayer.models import PESCompound
|
||||
from bayer.models import PESCompound, NewsEntry
|
||||
from epdb.logic import PackageManager
|
||||
from epdb.models import Pathway, Node, Group
|
||||
from epdb.views import _anonymous_or_real, error
|
||||
from epdb.views import _anonymous_or_real, get_base_context, error
|
||||
from utilities.decorators import package_permission_required
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
@@ -18,7 +18,13 @@ Package = s.GET_PACKAGE_MODEL()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def has_secret_group(user):
|
||||
def has_pes_allowance(user):
|
||||
if user.principal_type == user.PrincipalType.APP:
|
||||
return user.app_details.pes_allowance != user.app_details.PESAllowance.NONE
|
||||
|
||||
return True
|
||||
|
||||
def has_secret_pes_allowance(user):
|
||||
"""
|
||||
Determines if the specified user belongs to any secret group.
|
||||
|
||||
@@ -32,6 +38,9 @@ def has_secret_group(user):
|
||||
bool: True if the user belongs to at least one secret group,
|
||||
False otherwise.
|
||||
"""
|
||||
if user.principal_type == user.PrincipalType.APP:
|
||||
return user.app_details.pes_allowance == user.app_details.PESAllowance.SECRET
|
||||
|
||||
return Group.objects.filter(secret=True, user_member=user).exists()
|
||||
|
||||
|
||||
@@ -196,6 +205,10 @@ def get_application_token(prod: bool) -> str:
|
||||
|
||||
def fetch_pes(request, pes_url, user) -> dict:
|
||||
|
||||
if not has_pes_allowance(user):
|
||||
logger.info(f"User {user.username} does not have permission to fetch PESs")
|
||||
raise ValueError(f"User {user.username} does not have permission to fetch PESs")
|
||||
|
||||
for k, v in s.PES_API_MAPPING.items():
|
||||
if pes_url.startswith(k):
|
||||
|
||||
@@ -215,7 +228,7 @@ def fetch_pes(request, pes_url, user) -> dict:
|
||||
}
|
||||
|
||||
# Restrict request if user is not part of any secret group
|
||||
if not has_secret_group(user):
|
||||
if not has_secret_pes_allowance(user):
|
||||
headers["app-classification-level-restriction"] = "restrict-pes-secret-structure-access"
|
||||
|
||||
|
||||
@@ -257,3 +270,12 @@ def visualize_pes(request):
|
||||
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")
|
||||
|
||||
|
||||
def news_detail(request, news_entry_uuid):
|
||||
current_user = _anonymous_or_real(request)
|
||||
context = get_base_context(request, current_user)
|
||||
|
||||
context["entry"] = NewsEntry.objects.get(uuid=news_entry_uuid)
|
||||
|
||||
return render(request, "objects/news_entry.html", context)
|
||||
@@ -29,6 +29,8 @@ class SamplingAlgorithm(enum.Enum):
|
||||
class BB4GConfig(EnviPyModel):
|
||||
sampling_algorithm: SamplingAlgorithm = SamplingAlgorithm.EXACT
|
||||
cutoff: int = -5
|
||||
model: str = "rat"
|
||||
mode: str | None = None
|
||||
|
||||
class UI:
|
||||
title = "BB4G Configuration"
|
||||
@@ -44,6 +46,18 @@ class BB4GConfig(EnviPyModel):
|
||||
order=2,
|
||||
placeholder="If unset defaults to -5"
|
||||
)
|
||||
model = UIConfig(
|
||||
widget=WidgetType.TEXT,
|
||||
label="BB4G Model",
|
||||
order=3,
|
||||
placeholder="If unset defaults to rat"
|
||||
)
|
||||
mode = UIConfig(
|
||||
widget=WidgetType.TEXT,
|
||||
label="BB4G Mode",
|
||||
order=4,
|
||||
placeholder="If unset defaults to None"
|
||||
)
|
||||
|
||||
|
||||
# Once stable these will be exposed by enviPy-plugins lib
|
||||
@@ -167,6 +181,8 @@ class BB4G(Classifier):
|
||||
"smiles": smi,
|
||||
"sampling_alg": self.config.sampling_algorithm.value,
|
||||
"cutoff": self.config.cutoff,
|
||||
"model": self.config.model,
|
||||
"mode": self.config.mode,
|
||||
}
|
||||
|
||||
retries = 0
|
||||
|
||||
@@ -114,6 +114,7 @@ TEMPLATES = [
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"epdb.context_processors.package_context",
|
||||
"bayer.context_processors.news_entries_context"
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ def entra_callback(request):
|
||||
|
||||
else:
|
||||
auth_log.info(f"Registering {user_name} with OID {user_oid}")
|
||||
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True)
|
||||
u = UserManager.create_user(user_name, user_email, None, uuid=user_oid, is_active=True, add_to_group=False)
|
||||
registered = True
|
||||
|
||||
auth_log.info(f"User {user_name} {'(admin) ' if u.is_superuser else ''}with OID {user_oid} successfully logged in as {u.username} from {get_remote_address(request)}")
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib import messages
|
||||
|
||||
from .models import (
|
||||
AdditionalInformation,
|
||||
AppDetails,
|
||||
ClassifierPluginModel,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
@@ -40,6 +41,10 @@ class AdditionalInformationAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class AppDetailsAdmin(admin.ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"username",
|
||||
@@ -282,3 +287,4 @@ admin.site.register(Setting, SettingAdmin)
|
||||
admin.site.register(Scenario, ScenarioAdmin)
|
||||
admin.site.register(ExternalDatabase, ExternalDatabaseAdmin)
|
||||
admin.site.register(ExternalIdentifier, ExternalIdentifierAdmin)
|
||||
admin.site.register(AppDetails, AppDetailsAdmin)
|
||||
|
||||
+95
-3
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional
|
||||
import jwt
|
||||
import nh3
|
||||
import requests
|
||||
from django.db import transaction
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
@@ -43,7 +44,7 @@ from .models import (
|
||||
Scenario,
|
||||
SimpleAmbitRule,
|
||||
User,
|
||||
UserPackagePermission,
|
||||
UserPackagePermission, AppDetails,
|
||||
)
|
||||
from .views import delete_with_log, get_remote_address
|
||||
|
||||
@@ -122,7 +123,6 @@ def validate_token(token: str) -> dict:
|
||||
class MSBearerTokenAuth(HttpBearer):
|
||||
|
||||
def authenticate(self, request, token):
|
||||
|
||||
auth_log.info(f"Authentication request by {get_remote_address(request)}")
|
||||
|
||||
if token is None:
|
||||
@@ -130,6 +130,11 @@ class MSBearerTokenAuth(HttpBearer):
|
||||
|
||||
claims = validate_token(token)
|
||||
|
||||
if claims.get("roles") is None or claims.get("roles") == [] or "envipath_registered_user" not in claims.get(
|
||||
"roles"):
|
||||
auth_log.error(f"API authentication attempt by {get_remote_address(request)} failed due to missing role")
|
||||
return None
|
||||
|
||||
if not User.objects.filter(uuid=claims['oid']).exists():
|
||||
auth_log.info(f"Authentication request by {get_remote_address(request)} failed!")
|
||||
return None
|
||||
@@ -564,7 +569,7 @@ class UpdatePackage(Schema):
|
||||
write: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
|
||||
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error, 403: Error})
|
||||
def update_package(request, package_uuid, pack: Form[UpdatePackage]):
|
||||
try:
|
||||
p = get_package_for_write(request.user, package_uuid)
|
||||
@@ -590,6 +595,9 @@ def update_package(request, package_uuid, pack: Form[UpdatePackage]):
|
||||
grantee = GroupManager.get_group_lp(pack.ppsURI)
|
||||
else:
|
||||
grantee = UserManager.get_user_lp(pack.ppsURI)
|
||||
if grantee.principal_type == request.user.PrincipalType.APP:
|
||||
if not request.user.is_superuser:
|
||||
return 403, {"message": "You are not allowed to grant permissions to an app!"}
|
||||
|
||||
PackageManager.grant_read(request.user, p, grantee)
|
||||
return HttpResponse(status=200)
|
||||
@@ -598,6 +606,9 @@ def update_package(request, package_uuid, pack: Form[UpdatePackage]):
|
||||
grantee = GroupManager.get_group_lp(pack.ppsURI)
|
||||
else:
|
||||
grantee = UserManager.get_user_lp(pack.ppsURI)
|
||||
if grantee.principal_type == request.user.PrincipalType.APP:
|
||||
if not request.user.is_superuser:
|
||||
return 403, {"message": "You are not allowed to grant permissions to an app!"}
|
||||
|
||||
PackageManager.grant_write(request.user, p, grantee)
|
||||
return HttpResponse(status=200)
|
||||
@@ -2148,6 +2159,7 @@ class CreateEdge(Schema):
|
||||
products: str | None = None # Node URIs comma sep
|
||||
multistep: str | None = None
|
||||
edgeReason: str | None = None
|
||||
probability: float | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -2215,6 +2227,10 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
||||
multi_step=multi_step,
|
||||
)
|
||||
|
||||
if e.probability:
|
||||
new_e.kv["probability"] = e.probability
|
||||
new_e.save()
|
||||
|
||||
# Update depths as sideeffect of above operation
|
||||
pw.update_depths()
|
||||
|
||||
@@ -2473,3 +2489,79 @@ def export(request, q: Query[PackageExportInSchema]):
|
||||
return 403, {
|
||||
"message": f"Exporting Package with id {q.package_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
class RegisterApplicationInSchema(Schema):
|
||||
oid: str
|
||||
client_id: str
|
||||
name: str
|
||||
secret: bool = False
|
||||
data_pool_uuid: str | None = None
|
||||
pes: str | None
|
||||
|
||||
|
||||
@router.post("/integration", response={200: Any, 403: Error})
|
||||
@transaction.atomic
|
||||
def register_app(request, app: Form[RegisterApplicationInSchema]):
|
||||
try:
|
||||
user = request.user
|
||||
if user.is_superuser:
|
||||
user_name = f"APP_{app.oid}"
|
||||
user_email = f"{app.oid}@bayer.com"
|
||||
u = UserManager.create_user(user_name, user_email, None, uuid=app.oid, is_active=True, add_to_group=False)
|
||||
|
||||
# Adjust objects
|
||||
u.principal_type = User.PrincipalType.APP
|
||||
|
||||
if app.pes:
|
||||
pes_choice = next(
|
||||
choice for choice in AppDetails.PESAllowance
|
||||
if choice.label.lower() == app.pes.lower()
|
||||
)
|
||||
|
||||
if pes_choice is None:
|
||||
pes_choice = AppDetails.PESAllowance.NONE
|
||||
else:
|
||||
pes_choice = AppDetails.PESAllowance.NONE
|
||||
|
||||
if app.data_pool_uuid is not None:
|
||||
if not Group.objects.filter(uuid=app.data_pool_uuid, secret=True).exists():
|
||||
return 400, {"message": f"Group with id {app.data_pool_uuid} does not exist!"}
|
||||
|
||||
data_pool = Group.objects.get(uuid=app.data_pool_uuid, secret=True)
|
||||
else:
|
||||
data_pool = None
|
||||
|
||||
app_details = AppDetails.objects.create(
|
||||
owner=user,
|
||||
client_id=app.client_id,
|
||||
display_name=app.name,
|
||||
secret_allowed=app.secret,
|
||||
data_pool=data_pool,
|
||||
pes_allowance=pes_choice,
|
||||
)
|
||||
u.app_details = app_details
|
||||
|
||||
u.save()
|
||||
|
||||
# Add the integration to the "All enviPath Users" Group
|
||||
all_envipath_users = Group.objects.get(name="All enviPath Users")
|
||||
if not all_envipath_users.user_member.contains(u):
|
||||
all_envipath_users.user_member.add(u)
|
||||
|
||||
if app.secret:
|
||||
if app.data_pool_uuid is None or app.data_pool_uuid.strip() == "":
|
||||
return 400, {"message": "Secret is only allowed if data pool is specified!"}
|
||||
|
||||
default_package = u.default_package
|
||||
default_package.data_pool = Group.objects.get(uuid=app.data_pool_uuid, secret=True)
|
||||
default_package.classification_level = default_package.Classification.SECRET
|
||||
default_package.save()
|
||||
|
||||
return 200, {"message": "Application registered successfully"}
|
||||
else:
|
||||
raise ValueError("Only superusers can register apps")
|
||||
except ValueError as e:
|
||||
return 403, {
|
||||
"message": f"Registering application failed due to {e}"
|
||||
}
|
||||
+20
-7
@@ -220,11 +220,14 @@ class UserManager(object):
|
||||
u.save()
|
||||
|
||||
if add_to_group:
|
||||
g = Group.objects.get(public=True, name="enviPath Users")
|
||||
g.user_member.add(u)
|
||||
g.save()
|
||||
u.default_group = g
|
||||
u.save()
|
||||
if Group.objects.filter(public=True, name="enviPath Users").exists():
|
||||
g = Group.objects.get(public=True, name="enviPath Users")
|
||||
g.user_member.add(u)
|
||||
g.save()
|
||||
u.default_group = g
|
||||
u.save()
|
||||
else:
|
||||
logger.info(f"add_to_group was set to True but no public group named 'enviPath Users' exists.")
|
||||
|
||||
return u
|
||||
|
||||
@@ -373,9 +376,12 @@ class PackageManager(object):
|
||||
groups = GroupManager.get_groups(user)
|
||||
|
||||
# EDIT START
|
||||
|
||||
if package.classification_level == Package.Classification.SECRET:
|
||||
if package.data_pool not in groups:
|
||||
if user.principal_type == user.PrincipalType.APP:
|
||||
if not user.app_details.secret_allowed or package.data_pool != user.app_details.data_pool:
|
||||
return False
|
||||
|
||||
elif package.data_pool not in groups:
|
||||
return False
|
||||
|
||||
# EDIT END
|
||||
@@ -437,6 +443,10 @@ class PackageManager(object):
|
||||
@staticmethod
|
||||
def check_package_classification(user, pack: Package):
|
||||
if pack.classification_level == Package.Classification.SECRET:
|
||||
if user.principal_type == user.PrincipalType.APP:
|
||||
if pack.data_pool == user.app_details.data_pool:
|
||||
return pack
|
||||
|
||||
if pack.data_pool.user_member.filter(id=user.id).exists():
|
||||
return pack
|
||||
|
||||
@@ -581,6 +591,9 @@ class PackageManager(object):
|
||||
if caller_perm != Permission.ALL[0] and not caller.is_superuser:
|
||||
raise ValueError("Only owner are allowed to modify permissions")
|
||||
|
||||
if isinstance(grantee, Group) and package.classification_level == package.Classification.SECRET:
|
||||
raise ValueError("Cannot grant permissions to a Group on a secret package")
|
||||
|
||||
data = {
|
||||
"package": package,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 08:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0030_auto_20260814_0741'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='principal_type',
|
||||
field=models.IntegerField(choices=[(0, 'Internal'), (10, 'Restricted')], default=0),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AppDetails',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('client_id', models.TextField(verbose_name='Client ID')),
|
||||
('display_name', models.TextField(verbose_name='Display Name')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='app_details',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.appdetails'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 08:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0031_user_principal_type_appdetails_user_app_details'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='principal_type',
|
||||
field=models.IntegerField(choices=[(0, 'User'), (10, 'App')], default=0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 08:57
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0032_alter_user_principal_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='user',
|
||||
name='app_details',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 10:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0033_remove_user_app_details'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='app_details',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='epdb.appdetails'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 10:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0034_user_app_details'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='appdetails',
|
||||
name='data_pool',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='epdb.group'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='appdetails',
|
||||
name='secret_allowed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.3 on 2026-10-07 10:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epdb', '0035_appdetails_data_pool_appdetails_secret_allowed'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='appdetails',
|
||||
name='pes_allowance',
|
||||
field=models.IntegerField(choices=[(0, 'None'), (10, 'Non Secret'), (20, 'Secret')], default=0),
|
||||
),
|
||||
]
|
||||
+160
-15
@@ -84,6 +84,17 @@ class User(AbstractUser):
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = ["username"]
|
||||
|
||||
class PrincipalType(models.IntegerChoices):
|
||||
USER = 0, "User"
|
||||
APP = 10 , "App"
|
||||
|
||||
principal_type = models.IntegerField(
|
||||
choices=PrincipalType,
|
||||
default=PrincipalType.USER,
|
||||
)
|
||||
|
||||
app_details = models.OneToOneField("epdb.AppDetails", on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
def get_name(self):
|
||||
return self.username
|
||||
|
||||
@@ -103,6 +114,25 @@ class User(AbstractUser):
|
||||
return self.default_setting
|
||||
|
||||
|
||||
class AppDetails(models.Model):
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
client_id = models.TextField(blank=False, null=False, verbose_name="Client ID")
|
||||
display_name = models.TextField(blank=False, null=False, verbose_name="Display Name")
|
||||
|
||||
secret_allowed = models.BooleanField(default=False)
|
||||
data_pool = models.ForeignKey("epdb.Group", null=True, blank=True, on_delete=models.SET_NULL)
|
||||
|
||||
class PESAllowance(models.IntegerChoices):
|
||||
NONE = 0, "None"
|
||||
NON_SECRET = 10 , "Non Secret"
|
||||
SECRET = 20, "Secret"
|
||||
|
||||
pes_allowance = models.IntegerField(
|
||||
choices=PESAllowance,
|
||||
default=PESAllowance.NONE,
|
||||
)
|
||||
|
||||
|
||||
class APIToken(TimeStampedModel):
|
||||
"""
|
||||
API authentication token for users.
|
||||
@@ -2041,7 +2071,10 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
||||
def empty_due_to_threshold(self):
|
||||
return self.kv.get("empty_due_to_threshold", False)
|
||||
|
||||
def d3_json(self):
|
||||
def merged_d3_json(self):
|
||||
return self.d3_json(merged=True)
|
||||
|
||||
def d3_json(self, merged=False):
|
||||
# Ideally it would be something like this but
|
||||
# to reduce crossing in edges do a DFS
|
||||
# nodes = [n.d3_json() for n in self.nodes]
|
||||
@@ -2049,15 +2082,68 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
||||
nodes = []
|
||||
processed = set()
|
||||
|
||||
structure_to_combined_nodes = dict()
|
||||
node_to_combined_node = dict()
|
||||
queue = list()
|
||||
for n in self.root_nodes:
|
||||
queue.append(n)
|
||||
|
||||
# Add unconnected nodes
|
||||
for n in self.nodes.order_by("url"):
|
||||
if len(n.out_edges.all()) == 0:
|
||||
if n not in queue:
|
||||
queue.append(n)
|
||||
class CombinedNode(object):
|
||||
|
||||
def __init__(self, representative: "Node"):
|
||||
self.representative = representative
|
||||
self.nodes = set([self.representative])
|
||||
|
||||
def ids(self):
|
||||
return [n.id for n in self.nodes]
|
||||
|
||||
def d3_json(self):
|
||||
d3_json = self.representative.d3_json()
|
||||
compound = self.representative.default_node_label.compound
|
||||
d3_json["name"] = compound.get_name()
|
||||
d3_json["smiles"] = compound.default_structure.smiles
|
||||
d3_json["node_label_id"] = compound.default_structure.url
|
||||
d3_json["image"] = f"{compound.default_structure.url}?image=svg",
|
||||
d3_json["image_svg"] = IndigoUtils.mol_to_svg(
|
||||
compound.default_structure.molfile
|
||||
if compound.default_structure.molfile is not None
|
||||
and compound.default_structure.molfile.strip()
|
||||
else compound.default_structure.smiles,
|
||||
width=40,
|
||||
height=40,
|
||||
),
|
||||
|
||||
return d3_json
|
||||
|
||||
def __repr__(self):
|
||||
return f"CombinedNode({self.nodes})"
|
||||
|
||||
if merged:
|
||||
for n in self.nodes:
|
||||
def_struct = n.default_node_label.compound.default_structure
|
||||
if def_struct in structure_to_combined_nodes:
|
||||
structure_to_combined_nodes[def_struct].nodes.add(n)
|
||||
else:
|
||||
cn = CombinedNode(n)
|
||||
structure_to_combined_nodes[def_struct] = cn
|
||||
|
||||
# for n in self.nodes.order_by("url"):
|
||||
# if len(n.out_edges.all()) == 0:
|
||||
# if n not in queue:
|
||||
# queue.append(n)
|
||||
|
||||
for cn in structure_to_combined_nodes.values():
|
||||
for n in cn.nodes:
|
||||
node_to_combined_node[n] = cn
|
||||
|
||||
queue = list(structure_to_combined_nodes.values())
|
||||
else:
|
||||
for n in self.nodes:
|
||||
queue.append(n)
|
||||
|
||||
# Add unconnected nodes
|
||||
for n in self.nodes.order_by("url"):
|
||||
if len(n.out_edges.all()) == 0:
|
||||
if n not in queue:
|
||||
queue.append(n)
|
||||
|
||||
for i in queue:
|
||||
processed.add(i)
|
||||
@@ -2067,25 +2153,56 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
||||
processed.add(current)
|
||||
nodes.append(current.d3_json())
|
||||
|
||||
for e in self.edges.filter(start_nodes=current).order_by("url").distinct():
|
||||
if merged:
|
||||
q = Q(start_nodes__in=current.ids())
|
||||
else:
|
||||
q = Q(start_nodes=current)
|
||||
|
||||
for e in self.edges.filter(q).order_by("url").distinct():
|
||||
for prod in e.end_nodes.all().order_by("url"):
|
||||
if prod not in queue and prod not in processed:
|
||||
queue.append(prod)
|
||||
|
||||
if merged:
|
||||
def_struct = n.default_node_label.compound.default_structure
|
||||
if def_struct in structure_to_combined_nodes:
|
||||
structure_to_combined_nodes[def_struct].nodes.add(n)
|
||||
else:
|
||||
cn = CombinedNode(n)
|
||||
structure_to_combined_nodes[def_struct] = cn
|
||||
if cn not in queue and cn not in processed:
|
||||
queue.append(cn)
|
||||
else:
|
||||
if prod not in queue and prod not in processed:
|
||||
queue.append(prod)
|
||||
|
||||
# We shouldn't lose or make up nodes...
|
||||
if len(nodes) != len(self.nodes):
|
||||
logger.debug(
|
||||
f"{self.get_name()}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}"
|
||||
)
|
||||
if not merged:
|
||||
if len(nodes) != len(self.nodes):
|
||||
logger.debug(
|
||||
f"{self.get_name()}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}"
|
||||
)
|
||||
|
||||
links = [e.d3_json() for e in self.edges]
|
||||
|
||||
# D3 links Nodes based on indices in nodes array
|
||||
combined_node_depth_to_idx = dict()
|
||||
node_url_to_idx = dict()
|
||||
for i, n in enumerate(nodes):
|
||||
n["id"] = i
|
||||
node_url_to_idx[n["url"]] = i
|
||||
|
||||
# Add potential missing mappings
|
||||
if merged:
|
||||
cn_idx_mapping = dict()
|
||||
for n in nodes:
|
||||
for cn in processed:
|
||||
for sub_n in cn.nodes:
|
||||
if n["url"] == sub_n.url:
|
||||
cn_idx_mapping[cn] = n["id"]
|
||||
|
||||
for n in processed:
|
||||
for sub_n in n.nodes:
|
||||
node_url_to_idx[sub_n.url] = cn_idx_mapping[n]
|
||||
|
||||
adjusted_links = []
|
||||
for link in links:
|
||||
# Check if we'll need pseudo nodes
|
||||
@@ -2456,6 +2573,20 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
||||
else:
|
||||
return self.default_node_label.name
|
||||
|
||||
def get_description(self, include_suffix=True):
|
||||
default_desc = False
|
||||
|
||||
if self.description == "no description":
|
||||
default_desc = True
|
||||
|
||||
if not default_desc:
|
||||
return self.description
|
||||
else:
|
||||
if include_suffix:
|
||||
return f"{self.default_node_label.description} (taken from underlying structure)"
|
||||
else:
|
||||
return self.default_node_label.description
|
||||
|
||||
def d3_json(self):
|
||||
app_domain_data = self.get_app_domain_assessment_data()
|
||||
|
||||
@@ -2773,6 +2904,20 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
||||
else:
|
||||
return self.edge_label.name
|
||||
|
||||
def get_description(self, include_suffix=True):
|
||||
default_desc = False
|
||||
|
||||
if self.description == "no description":
|
||||
default_desc = True
|
||||
|
||||
if not default_desc:
|
||||
return self.description
|
||||
else:
|
||||
if include_suffix:
|
||||
return f"{self.edge_label.description} (taken from underlying reaction)"
|
||||
else:
|
||||
return self.edge_label.description
|
||||
|
||||
|
||||
class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin):
|
||||
package = models.ForeignKey(
|
||||
|
||||
+27
-5
@@ -1292,14 +1292,21 @@ def package(request, package_uuid):
|
||||
"user_id", flat=True
|
||||
)
|
||||
)
|
||||
users = users.filter(is_active=True)
|
||||
users = users.filter(is_active=True).exclude(username="anonymous")
|
||||
|
||||
if not current_user.is_superuser:
|
||||
users = users.exclude(principal_type=User.PrincipalType.APP)
|
||||
|
||||
group_perms = GroupPackagePermission.objects.filter(package=current_package)
|
||||
groups = Group.objects.exclude(
|
||||
id__in=GroupPackagePermission.objects.filter(package=current_package).values_list(
|
||||
"group_id", flat=True
|
||||
|
||||
if current_package.classification_level == current_package.Classification.SECRET:
|
||||
groups = Group.objects.none()
|
||||
else:
|
||||
groups = Group.objects.exclude(
|
||||
id__in=GroupPackagePermission.objects.filter(package=current_package).values_list(
|
||||
"group_id", flat=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
context["users"] = users
|
||||
context["groups"] = groups
|
||||
@@ -1324,6 +1331,12 @@ def package(request, package_uuid):
|
||||
|
||||
return redirect(s.SERVER_URL + "/package")
|
||||
elif hidden == "publish-package":
|
||||
if current_package.classification_level == current_package.Classification.SECRET:
|
||||
return error(
|
||||
request,
|
||||
"Cannot publish a secret package",
|
||||
"You cannot publish a secret package.",
|
||||
)
|
||||
for g in Group.objects.filter(public=True):
|
||||
PackageManager.grant_read(current_user, current_package, g)
|
||||
return redirect(current_package.url)
|
||||
@@ -1376,6 +1389,9 @@ def package(request, package_uuid):
|
||||
elif any([grantee_url, read, write, owner]):
|
||||
if "user" in grantee_url:
|
||||
grantee = UserManager.get_user_lp(grantee_url)
|
||||
if grantee.principal_type == current_user.PrincipalType.APP:
|
||||
if not request.user.is_superuser:
|
||||
return error(request, "You are not allowed to grant permissions to an app!")
|
||||
else:
|
||||
grantee = GroupManager.get_group_lp(grantee_url)
|
||||
|
||||
@@ -2297,6 +2313,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
|
||||
|
||||
context["pathway"] = current_pathway
|
||||
context["current_object"] = current_pathway
|
||||
context["merged"] = request.GET.get("merged", False) == "true"
|
||||
|
||||
context["breadcrumbs"] = [
|
||||
{"Home": s.SERVER_URL},
|
||||
@@ -3314,6 +3331,11 @@ def jobs(request):
|
||||
"This Package was generated automatically for the batch prediction task.",
|
||||
)
|
||||
|
||||
if current_user.default_package.classification_level == current_user.default_package.Classification.SECRET:
|
||||
target_package.classification_level = current_user.default_package.Classification.SECRET
|
||||
target_package.data_pool = current_user.default_package.data_pool
|
||||
target_package.save()
|
||||
|
||||
from .tasks import batch_predict, dispatch
|
||||
|
||||
res = dispatch(
|
||||
|
||||
@@ -15,7 +15,6 @@ dependencies = [
|
||||
"django-polymorphic>=4.1.0",
|
||||
"enviformer",
|
||||
"envipy-additional-information",
|
||||
"envipy-ambit>=0.1.0",
|
||||
"envipy-plugins",
|
||||
"epam-indigo>=1.30.1",
|
||||
"gunicorn>=23.0.0",
|
||||
|
||||
+42
-21
@@ -185,19 +185,18 @@
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-base-300 bg-base-50 px-4 py-2.5 text-sm text-base-content/70"
|
||||
>
|
||||
Prediction will be stored in
|
||||
Prediction will be stored in Package
|
||||
<strong class="text-base-content"
|
||||
>{{ meta.current_package.name|safe }}</strong
|
||||
><a class="link" href="{{ meta.current_package.url }}">{{ meta.current_package.name|safe }}</a></strong
|
||||
>
|
||||
{% if meta.user.default_setting %}
|
||||
using setting
|
||||
<strong class="text-base-content"
|
||||
>{{ meta.user.default_setting.name|safe }}</strong
|
||||
><a class="link" href="{{ meta.user.default_setting.url}}">{{ meta.user.default_setting.name|safe }}</a></strong
|
||||
>
|
||||
{% endif %}
|
||||
<br />
|
||||
To use a different setting click
|
||||
<a class="label link" href="/predict">here</a>
|
||||
<a class="link" href="/predict">here</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -222,23 +221,22 @@
|
||||
>
|
||||
Predict!
|
||||
</button>
|
||||
{% if meta.current_package %}
|
||||
{% if meta.current_package %}
|
||||
<div
|
||||
class="mt-4 rounded-lg border border-base-300 bg-base-50 px-4 py-2.5 text-sm text-base-content/70"
|
||||
>
|
||||
Prediction will be stored in
|
||||
Prediction will be stored in Package
|
||||
<strong class="text-base-content"
|
||||
>{{ meta.current_package.name|safe }}</strong
|
||||
><a class="link" href="{{ meta.current_package.url }}">{{ meta.current_package.name|safe }}</a></strong
|
||||
>
|
||||
{% if meta.user.default_setting %}
|
||||
using setting
|
||||
<strong class="text-base-content"
|
||||
>{{ meta.user.default_setting.name|safe }}</strong
|
||||
><a class="link" href="{{ meta.user.default_setting.url}}">{{ meta.user.default_setting.name|safe }}</a></strong
|
||||
>
|
||||
{% endif %}
|
||||
<br />
|
||||
To use a different setting click
|
||||
<a class="label link" href="/predict">here</a>
|
||||
<a class="link" href="/predict">here</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -262,17 +260,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Community News Section -->
|
||||
<section class="bg-base-200 z-10 mx-8 py-16">
|
||||
<section class="bg-base-200 z-10 mx-8 py-6">
|
||||
<div class="mx-auto max-w-7xl px-4">
|
||||
<h2 class="h2 mb-8 text-left font-bold">Community Updates</h2>
|
||||
<h2 class="h2 mb-8 text-left font-bold">News</h2>
|
||||
|
||||
<div id="community-news-container" class="flex justify-center gap-4">
|
||||
<!-- News cards will be populated here -->
|
||||
<div id="loading" class="flex w-full justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<div id="services-container" class="flex justify-center gap-4">
|
||||
{% for ne in news_entries %}
|
||||
<div class="card-body flex flex-col h-full justify-between p-5 w-96"> <!-- Fixed width applied via w-96 -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="bg-primary/10 text-primary rounded-lg p-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title text-base font-semibold tracking-tight">
|
||||
{{ ne.title }} - {{ ne.publication_date|date }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60 leading-snug line-clamp-4">
|
||||
{{ ne.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ ne.url }}" class="btn btn-primary btn-sm w-full group-hover:btn-active mt-1">
|
||||
Read Full
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-6 text-right">
|
||||
<a
|
||||
href="https://community.envipath.org/c/announcements/10"
|
||||
@@ -282,9 +306,6 @@
|
||||
Read More Announcements
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Discourse API integration -->
|
||||
<script src="{% static 'js/discourse-api.js' %}"></script>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-control mb-3">
|
||||
{% if 1 == 0 %}
|
||||
<label class="label" for="default-package">
|
||||
<span class="label-text">Default Package</span>
|
||||
</label>
|
||||
@@ -50,7 +51,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
<div class="form-control mb-3">
|
||||
<label class="label" for="default-group">
|
||||
<span class="label-text">Default Group</span>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">Description</div>
|
||||
<div class="collapse-content">{{ edge.description }}</div>
|
||||
<div class="collapse-content">{{ edge.get_description }}</div>
|
||||
</div>
|
||||
|
||||
{% if edge.aliases %}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">Description</div>
|
||||
<div class="collapse-content">{{ node.description }}</div>
|
||||
<div class="collapse-content">{{ node.get_description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %}
|
||||
|
||||
@@ -227,6 +227,29 @@
|
||||
Reaction Names
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% if merged %}{{ current_object.url }}{% else %}{{ current_object.url }}?merged=true{% endif %}" id="merged-toggle-button" class="cursor-pointer">
|
||||
<svg
|
||||
id="merged-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-eye"
|
||||
>
|
||||
<path
|
||||
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
Merged View
|
||||
</a>
|
||||
</li>
|
||||
{% if pathway.setting.model.app_domain %}
|
||||
<li>
|
||||
<a id="app-domain-toggle-button" class="cursor-pointer">
|
||||
@@ -544,8 +567,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if merged %}
|
||||
{{ pathway.merged_d3_json|json_script:"pathway" }}
|
||||
{% else %}
|
||||
{{ pathway.d3_json|json_script:"pathway" }}
|
||||
|
||||
{% endif %}
|
||||
<script>
|
||||
// Global switch for compound names view
|
||||
var compoundNamesViewEnabled = false;
|
||||
@@ -557,6 +583,8 @@
|
||||
var timeseriesViewEnabled = false;
|
||||
// Predicted Property View
|
||||
var predictedPropertyViewEnabled = false;
|
||||
// Merged View
|
||||
var mergedViewEnabled = new URLSearchParams(window.location.search).get("merged") !== null;
|
||||
|
||||
function goFullscreen(id) {
|
||||
var element = document.getElementById(id);
|
||||
@@ -702,6 +730,23 @@
|
||||
});
|
||||
}
|
||||
|
||||
const mergedViewBtn = document.getElementById(
|
||||
"merged-toggle-button",
|
||||
);
|
||||
if (mergedViewBtn) {
|
||||
const icon = document.getElementById("merged-icon");
|
||||
if (mergedViewEnabled) {
|
||||
// Change to eye-off icon
|
||||
icon.innerHTML =
|
||||
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
|
||||
} else {
|
||||
// Change back to eye icon
|
||||
icon.innerHTML =
|
||||
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Timeseries toggle
|
||||
const timeseriesBtn = document.getElementById("timeseries-toggle-button");
|
||||
if (timeseriesBtn) {
|
||||
|
||||
@@ -2,11 +2,23 @@
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
<div class="mx-auto w-full p-8">
|
||||
<h1 class="h1 mb-4 text-3xl font-bold">
|
||||
<h1 class="h1 mb-4 flex items-center gap-2 text-3xl font-bold">
|
||||
Predict a Pathway
|
||||
|
||||
<span class="text-base-content/50 text-xs"
|
||||
>in <strong>{{ meta.current_package.name|safe }}</strong>
|
||||
<span class="inline-flex items-center gap-1 text-base-content/50 text-xs"
|
||||
>in <strong>{{ meta.current_package.name|safe }}</strong>{% if meta.url_contains_package %}
|
||||
{% if meta.current_package.get_classification_level_display == "Restricted" %}
|
||||
<img src="{% static 'images/restricted_mid.png' %}" width="100">
|
||||
{% elif meta.current_package.get_classification_level_display == "Secret" %}
|
||||
<img src="{% static 'images/secret_mid.png' %}" width="60">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if meta.user.default_package.get_classification_level_display == "Restricted" %}
|
||||
<img src="{% static 'images/restricted_mid.png' %}" width="100">
|
||||
{% elif meta.user.default_package.get_classification_level_display == "Secret" %}
|
||||
<img src="{% static 'images/secret_mid.png' %}" width="60">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
|
||||
@@ -214,6 +214,7 @@ class EdgeExportSchema(RefEdgeExportSchema):
|
||||
start_nodes: List[RefNodeExportSchema]
|
||||
end_nodes: List[RefNodeExportSchema]
|
||||
scenarios: List[RefScenarioExportSchema]
|
||||
kv: Optional[dict]
|
||||
|
||||
|
||||
class PathwayExportSchema(RefPathwayExportSchema):
|
||||
@@ -623,6 +624,7 @@ class PackageImporter:
|
||||
new_e.description = e.description
|
||||
new_e.aliases = e.aliases
|
||||
new_e.edge_label = self._cache[e.edge_label.uuid]
|
||||
new_e.kv = e.kv
|
||||
new_e.save()
|
||||
|
||||
for sn in e.start_nodes:
|
||||
|
||||
@@ -802,7 +802,6 @@ dependencies = [
|
||||
{ name = "django-polymorphic" },
|
||||
{ name = "enviformer" },
|
||||
{ name = "envipy-additional-information" },
|
||||
{ name = "envipy-ambit" },
|
||||
{ name = "envipy-plugins" },
|
||||
{ name = "epam-indigo" },
|
||||
{ name = "gunicorn" },
|
||||
@@ -859,7 +858,6 @@ requires-dist = [
|
||||
{ name = "emcee", marker = "extra == 'pepper-plugin'", specifier = ">=3.1.6" },
|
||||
{ name = "enviformer", git = "ssh://git@git.envipath.com/enviPath/enviformer.git?rev=main" },
|
||||
{ name = "envipy-additional-information", git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop" },
|
||||
{ name = "envipy-ambit", git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" },
|
||||
{ name = "envipy-plugins", git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git?rev=v0.1.0" },
|
||||
{ name = "epam-indigo", specifier = ">=1.30.1" },
|
||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||
@@ -899,14 +897,6 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "envipy-ambit"
|
||||
version = "0.1.0"
|
||||
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git#0c424ba153105a54f758569a784d5871989338d8" }
|
||||
dependencies = [
|
||||
{ name = "jpype1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "envipy-plugins"
|
||||
version = "0.1.0"
|
||||
@@ -1190,30 +1180,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241", size = 308396, upload-time = "2025-08-27T12:15:45.188Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jpype1"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/49/6090a131d84b22c6aae13b1853092028b060fd17da1af87c0e42ad69d50f/jpype1-1.6.0.tar.gz", hash = "sha256:2d46b2a14f8f0e6f17d8aa22b4fc3a64b2790851ebf1409ad79a37c698fd6e9a", size = 1057888, upload-time = "2025-07-07T14:04:11.244Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/88/2156a27251492169f947850293d9870d49d784f1355fad7e920095636721/jpype1-1.6.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5454322887505097c46f41521becf31cda2303f54e35cb42e20f1a180055a558", size = 582187, upload-time = "2025-07-07T13:50:39.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/5d/51bde8695cc3eabcc465779f6dc23b149194bbcdcb8d221a9572422053f4/jpype1-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf646459023a0dd71b3cc3aeeb977225a93f94c97839bb4e6146977b166c7caf", size = 468341, upload-time = "2025-07-07T13:50:40.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a1/50cdc2bf9e51c0828faabec6fad50d61aada5a57d0bc435f4db0c16eb2ca/jpype1-1.6.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7a98b34bc68a117881382dffa9b02d5e1d0d94325f85d7f564c33cc9e9d2916b", size = 511724, upload-time = "2025-07-07T13:50:42.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/a4/6038228e44cbcdb8f96ff2518cd6c577730f31fb62e9f46de7efdfabc2de/jpype1-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56d7b662ce353c7f876e9ff3d4e775918348340795801e1f00c3b6241006264", size = 495897, upload-time = "2025-07-07T13:50:44.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/e4/af69d43779da0e49d37d7fd8a4a01bede03f6a3bab450eab1c10cf48e20e/jpype1-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf41134bc59c28de2721614ed00a34f76f69f2b525583a5c42fee5c7bae05d40", size = 355702, upload-time = "2025-07-07T13:50:45.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/1b/7167cfb702cfbcb17b0973150608f9cb92c3f409ebaaff655f7389617766/jpype1-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ce1da9ff01010a4529de194aecc053bfc8ab25440ea6b63579ef8c8d381da21d", size = 582384, upload-time = "2025-07-07T13:50:47.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/48/8ae425d8842bc76eb51052bbea5941dac230c8f6b58614ef2b7cb2da1a74/jpype1-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a387430cd4f3f68761013dca7f71b79d8d64b47dfd56eedcbd44c894d98d7f2", size = 468237, upload-time = "2025-07-07T13:50:48.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/dbdc15aa99404dda0315a9883add7422c9ac7acce28ce24b66c89052c2fb/jpype1-1.6.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6c9d99f41a5d05a8a2b6aeda796f646904c1c2b68aed0cc9a3bbc165483d9e1e", size = 511905, upload-time = "2025-07-07T13:50:50.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/0d/50fcdd6d3bb42a87cbda8b04fc72283a9c6d955ada0d011190ce31e0e027/jpype1-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55ba7257988a69bee84f7cd1444131725d5447999149fdafdba25bf46e4b1a3f", size = 496001, upload-time = "2025-07-07T13:50:52.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/b8/25214a9ebecbd31447b685500dca5fe2ac070df38358d6823f591a8ce52e/jpype1-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:ef8dd72442d91cde7e5eefef24ab71323cdd4137283a59f79d265a6620f7d0ab", size = 355727, upload-time = "2025-07-07T13:51:00.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a0/b2347572998020de8ce3ee0a285a1b84b052b522e8d452ee9ad330570918/jpype1-1.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc0c1c8b989b9857f87648980a746084b323858b0450225eab3642bb674645d6", size = 471150, upload-time = "2025-07-07T13:50:54.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/62/5e0f64a185f37303d0dd07e7b7a927cd5237fdc3c8176add949ed8cafaa2/jpype1-1.6.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cbbba16381ccd23530ad4ef09f7b2b1b12bd7fd0f6ca9fe4c7b4e3da8fe4cf63", size = 514015, upload-time = "2025-07-07T13:50:56.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/68/84050ed2679e2d2ee2030a60d3e5a59a29d0533cd03fd8d9891e36592b0f/jpype1-1.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1a3814e4f65d67e36bdb03b8851d5ece8d7a408aa3a24251ea0609bb8fba77dd", size = 497229, upload-time = "2025-07-07T13:50:57.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonref"
|
||||
version = "1.1.0"
|
||||
|
||||
Reference in New Issue
Block a user