Compare commits

...
12 Commits
Author SHA1 Message Date
Tim Lorsbach 493990566a adjusted bb4g for dev 2026-10-21 12:00:02 +02:00
Tim Lorsbach 7a6d8d7aec ... 2026-10-21 11:59:53 +02:00
Tim Lorsbach 2e2abf0287 ...
API CI / api-tests (pull_request) Failing after 32s
CI / test (pull_request) Failing after 33s
2026-10-21 11:59:11 +02:00
Tim Lorsbach 86c9ac887e Added news entries
API CI / api-tests (pull_request) Failing after 32s
CI / test (pull_request) Failing after 33s
2026-10-21 09:53:58 +02:00
Tim Lorsbach 5bbe316c24 ...
API CI / api-tests (pull_request) Failing after 0s
CI / test (pull_request) Failing after 35s
2026-10-07 15:00:19 +02:00
Tim Lorsbach d63c40e041 Machine users 2026-10-07 14:59:57 +02:00
Tim Lorsbach 8dc0e5e7a3 exclude groups
CI / test (pull_request) Failing after 28s
API CI / api-tests (pull_request) Failing after 35s
2026-09-23 20:08:35 +02:00
Tim Lorsbach ff37a17edb ...
CI / test (pull_request) Failing after 28s
API CI / api-tests (pull_request) Failing after 31s
2026-09-23 10:12:48 +02:00
Tim Lorsbach c60eac879e wip 2026-09-15 23:13:21 +02:00
Tim Lorsbach b269b7dfd8 ... 2026-09-08 21:49:54 +02:00
Tim Lorsbach b12c2dda69 wip
CI / test (pull_request) Failing after 27s
API CI / api-tests (pull_request) Failing after 31s
2026-09-08 15:11:47 +02:00
Tim Lorsbach 0b325a30b7 updated add if dep
CI / test (pull_request) Failing after 28s
API CI / api-tests (pull_request) Failing after 43s
2026-09-03 12:04:14 +02:00
32 changed files with 643 additions and 65 deletions
+6 -1
View File
@@ -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)
+25
View File
@@ -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,18 @@
# Generated by Django 6.0.3 on 2026-09-08 08:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bayer', '0003_pescompound_pesstructure_package_data_pool'),
]
operations = [
migrations.AddField(
model_name='package',
name='shareable',
field=models.BooleanField(default=True, verbose_name='Shareable'),
),
]
+29
View File
@@ -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',
),
]
+32 -10
View File
@@ -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,
@@ -23,10 +25,11 @@ class Package(EnviPathModel):
license = models.ForeignKey(
"epdb.License", on_delete=models.SET_NULL, blank=True, null=True, verbose_name="License"
)
shareable = models.BooleanField(verbose_name="Shareable", default=True)
class Classification(models.IntegerChoices):
INTERNAL = 0, "Internal"
RESTRICTED = 10 , "Restricted"
RESTRICTED = 10, "Restricted"
SECRET = 20, "Secret"
classification_level = models.IntegerField(
@@ -108,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"]
@@ -160,7 +163,6 @@ class PESCompound(Compound):
normalized_structure=True,
)
cs = PESStructure.create(
c,
pes_url,
@@ -217,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!")
@@ -235,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>`;
};
},
+47
View File
@@ -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 %}
+1
View File
@@ -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"),
]
+29 -7
View File
@@ -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()
@@ -119,8 +128,8 @@ def create_pes_node(request, package_uuid, pathway_uuid):
except ValueError as e:
return error(
request,
"Could not fetch PES",
f"Could not fetch PES data for {pes_link}"
"Failed to fetch this PES",
"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."
)
classification = pes_data.get("classificationLevel", "")
@@ -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)
+17 -1
View File
@@ -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
@@ -127,7 +141,7 @@ class BB4G(Classifier):
def run(self, eP: EnviPyDTO, *args, **kwargs) -> RunResult:
# Ensure Service is running
self.start()
# self.start()
smiles = [c.smiles for c in eP.get_compounds()]
preds = self._post(smiles)
@@ -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
+1
View File
@@ -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"
],
},
},
+9 -3
View File
@@ -77,7 +77,7 @@ def entra_callback(request):
return error(
request,
"Login Failed",
"The user is not authenticated. A reason for this might be a missing assignment to the respective enviPath group.",
"Request access to role 1230/MON/APPS/Envipath/Envipath_Prod on go/idnow",
403,
)
@@ -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)}")
@@ -139,10 +139,16 @@ def entra_callback(request):
auth_log.info(f"Login Group Sync: Adding {u.username} to Group {g.name} ({ uuid })")
else:
g = Group.objects.get(uuid=uuid)
if g.user_member.contains(u):
# Do not remove users from All enviPath Users
if g.user_member.contains(u) and g.name != 'All enviPath Users':
g.user_member.remove(u)
auth_log.info(f"Login Group Sync: Removing {u.username} from Group {g.name} ({ uuid })")
# Ensure people are part of All enviPath Users -> they have to as, envipath_registered_user is granted
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 registered:
# #72 make package secret if user is part of a secret group
for id, name in s.ENTRA_SECRET_GROUPS.items():
+6
View File
@@ -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)
+90 -3
View File
@@ -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)
@@ -2473,3 +2484,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}"
}
+16 -7
View File
@@ -211,7 +211,7 @@ class UserManager(object):
# Create package
package_name = f"{u.username}{'’' if u.username[-1] in 'sxzß' else 's'} Package"
package_description = "This package was generated during registration."
p = PackageManager.create_package(u, package_name, package_description)
p = PackageManager.create_package(u, package_name, package_description, shareable=False)
u.default_package = p
u.save()
@@ -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
@@ -547,7 +550,7 @@ class PackageManager(object):
@staticmethod
@transaction.atomic
def create_package(current_user, name: str, description: str = None):
def create_package(current_user, name: str, description: str = None, *args, **kwargs):
p = Package()
# Clean for potential XSS
@@ -556,6 +559,9 @@ class PackageManager(object):
if description is not None and description.strip() != "":
p.description = nh3.clean(description.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
if "shareable" in kwargs:
p.shareable = kwargs["shareable"]
p.save()
up = UserPackagePermission()
@@ -578,6 +584,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',
),
]
+19
View File
@@ -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),
),
]
+58
View File
@@ -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.
@@ -2456,6 +2486,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 +2817,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(
+26 -5
View File
@@ -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)
@@ -3314,6 +3330,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(
+3 -1
View File
@@ -7,6 +7,7 @@
<i class="glyphicon glyphicon-edit"></i> Edit Package</a
>
</li>
{% if meta.current_package.shareable %}
<li>
<a
role="button"
@@ -15,6 +16,7 @@
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a
>
</li>
{% endif %}
{% if meta.current_package.get_classification_level_display != "Secret" %}
<li>
<a
@@ -52,7 +54,7 @@
>
</li>
{% endif %}
{% if not meta.can_edit %}
{% if not meta.can_edit and meta.current_package.shareable %}
<li>
<a
role="button"
+2
View File
@@ -7,6 +7,7 @@
<i class="glyphicon glyphicon-edit"></i> Update</a
>
</li>
{% if 1 == 0 %}
<li>
<a
role="button"
@@ -15,6 +16,7 @@
<i class="glyphicon glyphicon-lock"></i> Update Password</a
>
</li>
{% endif %}
<li>
<a
role="button"
+59 -18
View File
@@ -180,12 +180,25 @@
>Aspartame</a
>
</div>
<a
class="absolute top-0 left-[calc(100%-5.4rem)]"
href="/predict"
>Advanced</a
>
</div>
{% 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 Package
<strong class="text-base-content"
><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"
><a class="link" href="{{ meta.user.default_setting.url}}">{{ meta.user.default_setting.name|safe }}</a></strong
>
{% endif %}
To use a different setting click
<a class="link" href="/predict">here</a>
</div>
{% endif %}
</div>
<div
id="ketcher-container"
@@ -208,9 +221,24 @@
>
Predict!
</button>
<div class="mt-1 flex w-full justify-end">
<a class="label justify-end" href="/predict">Advanced</a>
</div>
{% 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 Package
<strong class="text-base-content"
><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"
><a class="link" href="{{ meta.user.default_setting.url}}">{{ meta.user.default_setting.name|safe }}</a></strong
>
{% endif %}
To use a different setting click
<a class="link" href="/predict">here</a>
</div>
{% endif %}
</div>
<input
type="hidden"
@@ -232,15 +260,31 @@
</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">
<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>
</div>
{% endfor %}
</div>
<div class="mt-6 text-right">
@@ -252,9 +296,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>
+1 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+15 -3
View File
@@ -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>
Generated
+1 -1
View File
@@ -894,7 +894,7 @@ provides-extras = ["ms-login", "dev", "pepper-plugin"]
[[package]]
name = "envipy-additional-information"
version = "0.4.2"
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#ad825570480bbe2f1a35c04923fede756c450751" }
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#4909e35aac5a0a3969ac2a01f8d4ad8bdadae04c" }
dependencies = [
{ name = "pydantic" },
]