From ab0b5a51862e00f9b270a02511b33ad196cb46a6 Mon Sep 17 00:00:00 2001
From: jebus
Date: Thu, 22 Jan 2026 10:26:38 +1300
Subject: [PATCH] [Feature] Leftovers after Release (#303)
Co-authored-by: Tim Lorsbach
Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/303
---
epapi/v1/endpoints/settings.py | 23 +++++
epapi/v1/router.py | 14 ++-
epapi/v1/schemas.py | 7 ++
epdb/admin.py | 4 +-
epdb/legacy_api.py | 78 ++++++++++++++++
epdb/migrations/0015_user_is_reviewer.py | 17 ++++
epdb/models.py | 21 ++++-
epdb/views.py | 62 ++++++++++++-
static/js/pw.js | 3 +-
templates/actions/objects/scenario.html | 8 ++
templates/collections/settings_paginated.html | 20 ++++
.../modals/objects/edit_scenario_modal.html | 91 +++++++++++++++++++
templates/objects/pathway.html | 15 +++
templates/objects/scenario.html | 20 ++++
templates/objects/setting.html | 67 ++++++++++++++
templates/static/login.html | 26 ++++++
16 files changed, 465 insertions(+), 11 deletions(-)
create mode 100644 epapi/v1/endpoints/settings.py
create mode 100644 epdb/migrations/0015_user_is_reviewer.py
create mode 100644 templates/collections/settings_paginated.html
create mode 100644 templates/modals/objects/edit_scenario_modal.html
create mode 100644 templates/objects/setting.html
diff --git a/epapi/v1/endpoints/settings.py b/epapi/v1/endpoints/settings.py
new file mode 100644
index 00000000..dc6ce330
--- /dev/null
+++ b/epapi/v1/endpoints/settings.py
@@ -0,0 +1,23 @@
+from django.conf import settings as s
+from ninja import Router
+from ninja_extra.pagination import paginate
+
+from epdb.logic import SettingManager
+
+from ..pagination import EnhancedPageNumberPagination
+from ..schemas import SettingOutSchema
+
+router = Router()
+
+
+@router.get("/settings/", response=EnhancedPageNumberPagination.Output[SettingOutSchema])
+@paginate(
+ EnhancedPageNumberPagination,
+ page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
+)
+def list_all_pathways(request):
+ """
+ List all pathways from reviewed packages.
+ """
+ user = request.user
+ return SettingManager.get_all_settings(user)
diff --git a/epapi/v1/router.py b/epapi/v1/router.py
index a5cd2dea..fa2e7966 100644
--- a/epapi/v1/router.py
+++ b/epapi/v1/router.py
@@ -1,7 +1,18 @@
from ninja import Router
from ninja.security import SessionAuth
+
from .auth import BearerTokenAuth
-from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure
+from .endpoints import (
+ compounds,
+ models,
+ packages,
+ pathways,
+ reactions,
+ rules,
+ scenarios,
+ settings,
+ structure,
+)
# Main router with authentication
router = Router(
@@ -20,3 +31,4 @@ router.add_router("", reactions.router)
router.add_router("", pathways.router)
router.add_router("", models.router)
router.add_router("", structure.router)
+router.add_router("", settings.router)
diff --git a/epapi/v1/schemas.py b/epapi/v1/schemas.py
index b5620d86..7d6ac60f 100644
--- a/epapi/v1/schemas.py
+++ b/epapi/v1/schemas.py
@@ -102,3 +102,10 @@ class PackageOutSchema(Schema):
@staticmethod
def resolve_review_status(obj):
return "reviewed" if obj.reviewed else "unreviewed"
+
+
+class SettingOutSchema(Schema):
+ uuid: UUID
+ url: str = ""
+ name: str
+ description: str
diff --git a/epdb/admin.py b/epdb/admin.py
index aaa26a6e..6f9f4e39 100644
--- a/epdb/admin.py
+++ b/epdb/admin.py
@@ -28,7 +28,7 @@ Package = s.GET_PACKAGE_MODEL()
class UserAdmin(admin.ModelAdmin):
- list_display = ["username", "email", "is_active"]
+ list_display = ["username", "email", "is_active", "is_staff", "is_superuser"]
class UserPackagePermissionAdmin(admin.ModelAdmin):
@@ -48,7 +48,7 @@ class JobLogAdmin(admin.ModelAdmin):
class EPAdmin(admin.ModelAdmin):
- search_fields = ["name", "description"]
+ search_fields = ["name", "description", "url", "uuid"]
list_display = ["name", "url", "created"]
ordering = ["-created"]
diff --git a/epdb/legacy_api.py b/epdb/legacy_api.py
index 747d4ce7..d4cc4729 100644
--- a/epdb/legacy_api.py
+++ b/epdb/legacy_api.py
@@ -18,6 +18,8 @@ from .models import (
Edge,
EnviFormer,
EPModel,
+ Group,
+ GroupPackagePermission,
MLRelativeReasoning,
Node,
PackageBasedModel,
@@ -204,6 +206,82 @@ def get_user(request, user_uuid):
}
+########
+# Group #
+########
+
+
+class GroupMember(Schema):
+ id: str = Field(None, alias="url")
+ identifier: str
+ name: str
+
+
+class GroupWrapper(Schema):
+ group: List[SimpleGroup]
+
+
+class GroupSchema(Schema):
+ description: str
+ id: str = Field(None, alias="url")
+ identifier: str = "group"
+ members: List[GroupMember] = Field([], alias="members")
+ name: str = Field(None, alias="name")
+ ownerid: str = Field(None, alias="owner.url")
+ ownername: str = Field(None, alias="owner.name")
+ packages: List["SimplePackage"] = Field([], alias="packages")
+ readers: List[GroupMember] = Field([], alias="readers")
+ writers: List[GroupMember] = Field([], alias="writers")
+
+ @staticmethod
+ def resolve_members(obj: Group):
+ res = []
+ for member in obj.user_member.all():
+ res.append(GroupMember(id=member.url, identifier="usermember", name=member.username))
+
+ for member in obj.group_member.all():
+ res.append(GroupMember(id=member.url, identifier="groupmember", name=member.name))
+
+ return res
+
+ @staticmethod
+ def resolve_packages(obj: Group):
+ return Package.objects.filter(
+ id__in=[
+ GroupPackagePermission.objects.filter(group=obj).values_list(
+ "package_id", flat=True
+ )
+ ]
+ )
+
+ @staticmethod
+ def resolve_readers(obj: Group):
+ return GroupSchema.resolve_members(obj)
+
+ @staticmethod
+ def resolve_writers(obj: Group):
+ return [GroupMember(id=obj.owner.url, identifier="usermember", name=obj.owner.username)]
+
+
+@router.get("/group", response={200: GroupWrapper, 403: Error})
+def get_groups(request):
+ return {"group": GroupManager.get_groups(request.user)}
+
+
+@router.get("/group/{uuid:group_uuid}", response={200: GroupSchema, 403: Error})
+def get_group(request, group_uuid):
+ try:
+ g = GroupManager.get_group_by_id(request.user, group_uuid)
+ return g
+ except ValueError:
+ return 403, {
+ "message": f"Getting Group with id {group_uuid} failed due to insufficient rights!"
+ }
+
+
+##########
+# Search #
+##########
class Search(Schema):
packages: List[str] = Field(alias="packages[]")
search: str
diff --git a/epdb/migrations/0015_user_is_reviewer.py b/epdb/migrations/0015_user_is_reviewer.py
new file mode 100644
index 00000000..b26db739
--- /dev/null
+++ b/epdb/migrations/0015_user_is_reviewer.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.2.7 on 2026-01-19 19:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("epdb", "0014_rename_expansion_schema_setting_expansion_scheme"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="is_reviewer",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/epdb/models.py b/epdb/models.py
index 864aa429..900e1d4a 100644
--- a/epdb/models.py
+++ b/epdb/models.py
@@ -72,6 +72,7 @@ class User(AbstractUser):
null=True,
blank=False,
)
+ is_reviewer = models.BooleanField(default=False)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"]
@@ -1828,8 +1829,8 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
queue.append(prod)
# We shouldn't lose or make up nodes...
- assert len(nodes) == len(self.nodes)
- logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}")
+ if len(nodes) != len(self.nodes):
+ logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}")
links = [e.d3_json() for e in self.edges]
@@ -1880,6 +1881,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"source": pseudo_idx,
"target": node_url_to_idx[target],
"app_domain": link.get("app_domain", None),
+ "multi_step": link["multi_step"],
}
adjusted_links.append(new_link)
@@ -2146,6 +2148,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
}
@staticmethod
+ @transaction.atomic
def create(
pathway: "Pathway",
smiles: str,
@@ -2185,8 +2188,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
if data:
rule_ids = defaultdict(list)
for e in Edge.objects.filter(start_nodes__in=[self]):
- for r in e.edge_label.rules.all():
- rule_ids[str(r.uuid)].append(e.simple_json())
+ # TODO While the Pathway is being predicted we sometimes
+ # TODO receive 'NoneType' object has no attribute 'rules'
+ if e.edge_label:
+ for r in e.edge_label.rules.all():
+ rule_ids[str(r.uuid)].append(e.simple_json())
for t in data["assessment"]["transformations"]:
if t["rule"]["uuid"] in rule_ids:
@@ -2230,6 +2236,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
"reaction": {"name": self.edge_label.name, "url": self.edge_label.url}
if self.edge_label
else None,
+ "multi_step": self.edge_label.multi_step if self.edge_label else False,
"reaction_probability": self.kv.get("probability"),
"start_node_urls": [x.url for x in self.start_nodes.all()],
"end_node_urls": [x.url for x in self.end_nodes.all()],
@@ -2270,6 +2277,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
return edge_json
@staticmethod
+ @transaction.atomic
def create(
pathway,
start_nodes: List[Node],
@@ -3819,6 +3827,11 @@ class Scenario(EnviPathModel):
yield inst
+ def related_pathways(self):
+ return Pathway.objects.filter(
+ scenarios__in=[self], package__reviewed=True, package=self.package
+ ).distinct()
+
class UserSettingPermission(Permission):
uuid = models.UUIDField(
diff --git a/epdb/views.py b/epdb/views.py
index dde70291..2d1595f2 100644
--- a/epdb/views.py
+++ b/epdb/views.py
@@ -1352,6 +1352,14 @@ def package_compound_structure(request, package_uuid, compound_uuid, structure_u
)
if request.method == "GET":
+ is_image_request = request.GET.get("image")
+
+ if is_image_request:
+ if is_image_request == "svg":
+ return HttpResponse(current_structure.as_svg, content_type="image/svg+xml")
+ else:
+ return HttpResponseBadRequest("Currently only SVG as image formate supported!")
+
context = get_base_context(request)
context["title"] = (
f"enviPath - {current_package.name} - {current_compound.name} - {current_structure.name}"
@@ -2569,6 +2577,28 @@ def package_scenario(request, package_uuid, scenario_uuid):
return redirect(current_scenario.url)
else:
return HttpResponseBadRequest()
+
+ new_scenario_name = request.POST.get("scenario-name")
+
+ if new_scenario_name is not None:
+ new_scenario_name = nh3.clean(new_scenario_name, tags=s.ALLOWED_HTML_TAGS).strip()
+
+ if new_scenario_name:
+ current_scenario.name = new_scenario_name
+
+ new_scenario_description = request.POST.get("scenario-description")
+
+ if new_scenario_description is not None:
+ new_scenario_description = nh3.clean(
+ new_scenario_description, tags=s.ALLOWED_HTML_TAGS
+ ).strip()
+
+ if new_scenario_description:
+ current_scenario.description = new_scenario_description
+
+ if any([new_scenario_name, new_scenario_description]):
+ current_scenario.save()
+ return redirect(current_scenario.url)
else:
return HttpResponseBadRequest()
else:
@@ -2784,10 +2814,17 @@ def settings(request):
context["object_type"] = "setting"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
- {"Group": s.SERVER_URL + "/setting"},
+ {"Setting": s.SERVER_URL + "/setting"},
]
- context["objects"] = SettingManager.get_all_settings(current_user)
+ # Context for paginated template
+ context["entity_type"] = "setting"
+ context["api_endpoint"] = "/api/v1/settings/"
+ context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
+ context["list_title"] = "settings"
+ context["list_mode"] = "combined"
+
+ return render(request, "collections/settings_paginated.html", context)
return render(request, "collections/objects_list.html", context)
elif request.method == "POST":
@@ -2867,7 +2904,26 @@ def settings(request):
def setting(request, setting_uuid):
- pass
+ current_user = _anonymous_or_real(request)
+ current_setting = SettingManager.get_setting_by_id(current_user, setting_uuid)
+
+ if request.method == "GET":
+ context = get_base_context(request)
+ context["title"] = f"enviPath - {current_setting.name}"
+
+ context["object_type"] = "setting"
+ context["breadcrumbs"] = [
+ {"Home": s.SERVER_URL},
+ {"Setting": s.SERVER_URL + "/setting"},
+ {f"{current_setting.name}": current_setting.url},
+ ]
+
+ context["setting"] = current_setting
+ context["current_object"] = current_setting
+
+ return render(request, "objects/setting.html", context)
+ else:
+ return HttpResponseNotAllowed(["GET"])
def jobs(request):
diff --git a/static/js/pw.js b/static/js/pw.js
index f514f975..7320c05e 100644
--- a/static/js/pw.js
+++ b/static/js/pw.js
@@ -498,7 +498,8 @@ function draw(pathway, elem) {
.enter().append("line")
// Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
- .attr("marker-end", d => d.target.pseudo ? '' : 'url(#arrow)')
+ .attr("marker-end", d => d.target.pseudo ? '' : d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)')
+
// add element to links array
link.each(function (d) {
diff --git a/templates/actions/objects/scenario.html b/templates/actions/objects/scenario.html
index 532ef6b1..ce38ba5d 100644
--- a/templates/actions/objects/scenario.html
+++ b/templates/actions/objects/scenario.html
@@ -1,4 +1,12 @@
{% if meta.can_edit %}
+
+
+ Edit Scenario
+
A setting includes configuration parameters for pathway predictions.
+
+ Learn more >>
+
+{% endblock description %}
diff --git a/templates/modals/objects/edit_scenario_modal.html b/templates/modals/objects/edit_scenario_modal.html
new file mode 100644
index 00000000..3e72d443
--- /dev/null
+++ b/templates/modals/objects/edit_scenario_modal.html
@@ -0,0 +1,91 @@
+{% load static %}
+
+
+
+
+
Edit Scenario
+
+
+
+
+
+
+
+
+
+
+ Close
+
+
+ Update
+
+ Updating...
+
+
+
+
+
+
+
diff --git a/templates/objects/pathway.html b/templates/objects/pathway.html
index dbcebaf9..549a6aae 100644
--- a/templates/objects/pathway.html
+++ b/templates/objects/pathway.html
@@ -306,6 +306,21 @@
>
+
+
+
+
+
+
+
+
+ {% if scenario.related_pathways %}
+
+ {% endif %}
+{% endblock content %}
diff --git a/templates/static/login.html b/templates/static/login.html
index a8719cdd..c46303d8 100644
--- a/templates/static/login.html
+++ b/templates/static/login.html
@@ -31,6 +31,32 @@
{% endblock %}
{% block content %}
+
+
+
Welcome to the new enviPath!
+
+ Rebuilt from the ground up for faster predictions, greater stability,
+ and powerful scalability. Explore a decade of research on a modern,
+ reliable platform.
+ The old system is still accessible but will be shut down at a later
+ date. If you want to back up your data, download it from the
+ old system
+ or contact us via the
+ community forum
+ for assistance.
+
+
+