[Feature] Leftovers after Release (#303)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#303
This commit is contained in:
2026-01-22 10:26:38 +13:00
parent f905bf21cf
commit ab0b5a5186
16 changed files with 465 additions and 11 deletions

View File

@ -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)

View File

@ -1,7 +1,18 @@
from ninja import Router from ninja import Router
from ninja.security import SessionAuth from ninja.security import SessionAuth
from .auth import BearerTokenAuth 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 # Main router with authentication
router = Router( router = Router(
@ -20,3 +31,4 @@ router.add_router("", reactions.router)
router.add_router("", pathways.router) router.add_router("", pathways.router)
router.add_router("", models.router) router.add_router("", models.router)
router.add_router("", structure.router) router.add_router("", structure.router)
router.add_router("", settings.router)

View File

@ -102,3 +102,10 @@ class PackageOutSchema(Schema):
@staticmethod @staticmethod
def resolve_review_status(obj): def resolve_review_status(obj):
return "reviewed" if obj.reviewed else "unreviewed" return "reviewed" if obj.reviewed else "unreviewed"
class SettingOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str

View File

@ -28,7 +28,7 @@ Package = s.GET_PACKAGE_MODEL()
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ["username", "email", "is_active"] list_display = ["username", "email", "is_active", "is_staff", "is_superuser"]
class UserPackagePermissionAdmin(admin.ModelAdmin): class UserPackagePermissionAdmin(admin.ModelAdmin):
@ -48,7 +48,7 @@ class JobLogAdmin(admin.ModelAdmin):
class EPAdmin(admin.ModelAdmin): class EPAdmin(admin.ModelAdmin):
search_fields = ["name", "description"] search_fields = ["name", "description", "url", "uuid"]
list_display = ["name", "url", "created"] list_display = ["name", "url", "created"]
ordering = ["-created"] ordering = ["-created"]

View File

@ -18,6 +18,8 @@ from .models import (
Edge, Edge,
EnviFormer, EnviFormer,
EPModel, EPModel,
Group,
GroupPackagePermission,
MLRelativeReasoning, MLRelativeReasoning,
Node, Node,
PackageBasedModel, 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): class Search(Schema):
packages: List[str] = Field(alias="packages[]") packages: List[str] = Field(alias="packages[]")
search: str search: str

View File

@ -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),
),
]

View File

@ -72,6 +72,7 @@ class User(AbstractUser):
null=True, null=True,
blank=False, blank=False,
) )
is_reviewer = models.BooleanField(default=False)
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
REQUIRED_FIELDS = ["username"] REQUIRED_FIELDS = ["username"]
@ -1828,8 +1829,8 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
queue.append(prod) queue.append(prod)
# We shouldn't lose or make up nodes... # We shouldn't lose or make up nodes...
assert len(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)}") logger.debug(f"{self.name}: Num Nodes {len(nodes)} vs. DB Nodes {len(self.nodes)}")
links = [e.d3_json() for e in self.edges] links = [e.d3_json() for e in self.edges]
@ -1880,6 +1881,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"source": pseudo_idx, "source": pseudo_idx,
"target": node_url_to_idx[target], "target": node_url_to_idx[target],
"app_domain": link.get("app_domain", None), "app_domain": link.get("app_domain", None),
"multi_step": link["multi_step"],
} }
adjusted_links.append(new_link) adjusted_links.append(new_link)
@ -2146,6 +2148,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
} }
@staticmethod @staticmethod
@transaction.atomic
def create( def create(
pathway: "Pathway", pathway: "Pathway",
smiles: str, smiles: str,
@ -2185,8 +2188,11 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
if data: if data:
rule_ids = defaultdict(list) rule_ids = defaultdict(list)
for e in Edge.objects.filter(start_nodes__in=[self]): for e in Edge.objects.filter(start_nodes__in=[self]):
for r in e.edge_label.rules.all(): # TODO While the Pathway is being predicted we sometimes
rule_ids[str(r.uuid)].append(e.simple_json()) # 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"]: for t in data["assessment"]["transformations"]:
if t["rule"]["uuid"] in rule_ids: 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} "reaction": {"name": self.edge_label.name, "url": self.edge_label.url}
if self.edge_label if self.edge_label
else None, else None,
"multi_step": self.edge_label.multi_step if self.edge_label else False,
"reaction_probability": self.kv.get("probability"), "reaction_probability": self.kv.get("probability"),
"start_node_urls": [x.url for x in self.start_nodes.all()], "start_node_urls": [x.url for x in self.start_nodes.all()],
"end_node_urls": [x.url for x in self.end_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 return edge_json
@staticmethod @staticmethod
@transaction.atomic
def create( def create(
pathway, pathway,
start_nodes: List[Node], start_nodes: List[Node],
@ -3819,6 +3827,11 @@ class Scenario(EnviPathModel):
yield inst yield inst
def related_pathways(self):
return Pathway.objects.filter(
scenarios__in=[self], package__reviewed=True, package=self.package
).distinct()
class UserSettingPermission(Permission): class UserSettingPermission(Permission):
uuid = models.UUIDField( uuid = models.UUIDField(

View File

@ -1352,6 +1352,14 @@ def package_compound_structure(request, package_uuid, compound_uuid, structure_u
) )
if request.method == "GET": 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 = get_base_context(request)
context["title"] = ( context["title"] = (
f"enviPath - {current_package.name} - {current_compound.name} - {current_structure.name}" 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) return redirect(current_scenario.url)
else: else:
return HttpResponseBadRequest() 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: else:
return HttpResponseBadRequest() return HttpResponseBadRequest()
else: else:
@ -2784,10 +2814,17 @@ def settings(request):
context["object_type"] = "setting" context["object_type"] = "setting"
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"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) return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
@ -2867,7 +2904,26 @@ def settings(request):
def setting(request, setting_uuid): 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): def jobs(request):

View File

@ -498,7 +498,8 @@ function draw(pathway, elem) {
.enter().append("line") .enter().append("line")
// Check if target is pseudo and draw marker only if not pseudo // Check if target is pseudo and draw marker only if not pseudo
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link") .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 // add element to links array
link.each(function (d) { link.each(function (d) {

View File

@ -1,4 +1,12 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li>
<a
class="button"
onclick="document.getElementById('edit_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Edit Scenario</a
>
</li>
<li> <li>
<a <a
class="button" class="button"

View File

@ -0,0 +1,20 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Settings{% endblock %}
{% block action_button %}
{% endblock action_button %}
{% block action_modals %}
{% endblock action_modals %}
{% block description %}
<p>A setting includes configuration parameters for pathway predictions.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/Setting"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,91 @@
{% load static %}
<dialog
id="edit_scenario_modal"
class="modal"
x-data="modalForm()"
@close="reset()"
>
<div class="modal-box">
<!-- Header -->
<h3 class="font-bold text-lg">Edit Scenario</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<form
id="edit-scenario-modal-form"
accept-charset="UTF-8"
action=""
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="scenario-name">
<span class="label-text">Name</span>
</label>
<input
id="scenario-name"
class="input input-bordered w-full"
name="scenario-name"
value="{{ scenario.name|safe }}"
required
/>
</div>
<div class="form-control mb-3">
<label class="label" for="scenario-description">
<span class="label-text">Description</span>
</label>
<input
id="scenario-description"
type="text"
class="input input-bordered w-full"
value="{{ scenario.description|safe }}"
name="scenario-description"
/>
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('edit-scenario-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Update</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Updating...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -306,6 +306,21 @@
> >
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" /> <path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker> </marker>
<marker
id="doublearrow"
viewBox="0 0 20 30"
refX="53"
refY="5"
markerWidth="18"
markerHeight="18"
orient="auto-start-reverse"
markerUnits="userSpaceOnUse"
>
<!-- first triangle -->
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
<!-- second triangle -->
<path d="M 10 0 L 20 5 L 10 10 Z" fill="#999" />
</marker>
<marker <marker
id="arrow_passes_app_domain" id="arrow_passes_app_domain"
viewBox="0 0 10 10" viewBox="0 0 10 10"

View File

@ -3,6 +3,7 @@
{% block content %} {% block content %}
{% block action_modals %} {% block action_modals %}
{% include "modals/objects/edit_scenario_modal.html" %}
{% include "modals/objects/add_additional_information_modal.html" %} {% include "modals/objects/add_additional_information_modal.html" %}
{% include "modals/objects/update_scenario_additional_information_modal.html" %} {% include "modals/objects/update_scenario_additional_information_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %}
@ -160,6 +161,25 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Pathways -->
{% if scenario.related_pathways %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Pathways</div>
<div class="collapse-content">
<ul class="menu bg-base-100 rounded-box">
{% for p in scenario.related_pathways %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-200"
>{{ p.name }} <i>({{ p.package.name }})</i></a
>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div> </div>
<script> <script>

View File

@ -0,0 +1,67 @@
{% extends "framework_modern.html" %}
{% block content %}
{% block action_modals %}
{% endblock action_modals %}
<div class="space-y-2 p-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ setting.name }}</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
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-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% endblock %}
</ul>
</div>
</div>
</div>
</div>
<!-- The actual setting -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-content">
{% with setting_to_render=setting can_be_default=False %}
{% include "objects/setting_template.html" %}
{% endwith %}
</div>
</div>
</div>
<script>
// Show actions button if there are actions
document.addEventListener("DOMContentLoaded", function () {
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
});
</script>
{% endblock content %}

View File

@ -31,6 +31,32 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="card bg-base-200 mb-6 ">
<div class="card-body">
<h3 class="card-title">Welcome to the new enviPath!</h3>
<p class="mb-4 text-sm">
Rebuilt from the ground up for faster predictions, greater stability,
and powerful scalability. Explore a decade of research on a modern,
reliable platform.<br />
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
<a
href="https://legacy.envipath.org"
class="link link-primary"
target="_blank"
>old system</a
>
or contact us via the
<a
href="https://community.envipath.org/"
class="link link-primary"
target="_blank"
>community forum</a
>
for assistance.
</p>
</div>
</div>
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="border-base-300 mb-6 border-b"> <div class="border-base-300 mb-6 border-b">
<div class="flex justify-start"> <div class="flex justify-start">