8 Commits

Author SHA1 Message Date
be5ee1d1d7 [Fix] Propagate multi_step in Edge.create to Reaction.create to ensure deduplication is working (#405)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#405
2026-05-29 07:39:32 +12:00
20fd949dfd [Fix] Implement legacy PFASConfidence object construction (#404)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#404
2026-05-28 23:57:34 +12:00
c9b643fe6e [Feature] Make JobLog Page Paginated (#403)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#403
2026-05-28 23:01:27 +12:00
1a9f1cf9af [Fix] Legacy API Node Depth Parsing, description validation in Reaction.create (#402)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#402
2026-05-28 09:53:39 +12:00
c7c7e17e43 [Feature] Implement legacy endpoints to enable copy via python client (#400)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#400
2026-05-28 01:41:28 +12:00
674e10c7fa [Fix] Legacy API Package Endpoint, Pepper Unit in Pathway View (#399)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#399
2026-05-27 21:09:17 +12:00
8079b80d57 [Fix] Fix Typo in multi_step assignment (#397)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#397
2026-05-21 09:56:21 +12:00
76e63fda2c [Fix] Fix BART Upload (#396)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#396
2026-05-21 03:16:02 +12:00
12 changed files with 344 additions and 48 deletions

View File

@ -0,0 +1,26 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from epdb.models import JobLog
from ..pagination import EnhancedPageNumberPagination
from ..schemas import JobLogOutSchema
router = Router()
@router.get("/joblog/", response=EnhancedPageNumberPagination.Output[JobLogOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
)
def list_all_joblogs(request):
"""
List all JobLogs from reviewed packages.
"""
current_user = request.user
if current_user.is_superuser:
return JobLog.objects.all().order_by("-created")
else:
return JobLog.objects.filter(user=current_user).order_by("-created")

View File

@ -15,6 +15,7 @@ from .endpoints import (
additional_information,
settings,
groups,
joblogs,
)
# Main router with authentication
@ -37,6 +38,7 @@ router.add_router("", structure.router)
router.add_router("", additional_information.router)
router.add_router("", settings.router)
router.add_router("", groups.router)
router.add_router("", joblogs.router)
if s.IUCLID_EXPORT_ENABLED:
from epiuclid.api import router as iuclid_router

View File

@ -1,7 +1,10 @@
from ninja import FilterSchema, FilterLookup, Schema
from datetime import datetime
from typing import Annotated, Optional, List, Dict, Any
from uuid import UUID
from django.urls import reverse
from ninja import Field, FilterSchema, FilterLookup, Schema
# Filter schema for query parameters
class ReviewStatusFilter(FilterSchema):
@ -133,3 +136,23 @@ class GroupOutSchema(Schema):
url: str = ""
name: str
description: str
class SimpleUserOutSchema(Schema):
uuid: UUID
url: str
name: str = Field(alias="username")
class JobLogOutSchema(Schema):
user: SimpleUserOutSchema
id: UUID = Field(alias="task_id")
url: str
name: str = Field(alias="job_name")
created: datetime = Field(alias="created")
status: str = Field(alias="status")
done: Optional[datetime] = Field(None, alias="done_at")
@staticmethod
def resolve_url(obj):
return reverse("job detail", kwargs={"job_uuid": obj.task_id})

View File

@ -386,23 +386,50 @@ class PackageSchema(Schema):
@staticmethod
def resolve_readers(obj: Package):
users = User.objects.filter(
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.READ[0]
).values_list("user", flat=True)
).distinct()
readers = []
return [{u.id: u.get_name()} for u in users]
user_ids = UserPackagePermission.objects.filter(package=obj).values_list("user", flat=True)
users = User.objects.filter(id__in=user_ids).distinct()
for u in users:
readers.append({"id": str(u.url), "identifier": "user", "name": u.get_name()})
group_ids = GroupPackagePermission.objects.filter(package=obj).values_list(
"group", flat=True
)
groups = Group.objects.filter(id__in=group_ids).distinct()
for g in groups:
readers.append({"id": str(g.url), "identifier": "group", "name": g.get_name()})
return readers
@staticmethod
def resolve_writers(obj: Package):
users = User.objects.filter(
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.WRITE[0]
).values_list("user", flat=True)
).distinct()
writers = []
return [{u.id: u.get_name()} for u in users]
user_ids = UserPackagePermission.objects.filter(
package=obj,
permission__in=[UserPackagePermission.WRITE[0], UserPackagePermission.ALL[0]],
).values_list("user", flat=True)
users = User.objects.filter(id__in=user_ids).distinct()
for u in users:
writers.append({"id": str(u.url), "identifier": "user", "name": u.get_name()})
group_ids = GroupPackagePermission.objects.filter(
package=obj, permission=[UserPackagePermission.WRITE[0], UserPackagePermission.ALL[0]]
).values_list("group", flat=True)
groups = Group.objects.filter(id__in=group_ids).distinct()
for g in groups:
writers.append({"id": str(g.url), "identifier": "group", "name": g.get_name()})
return writers
@staticmethod
def resolve_review_comment(obj):
@ -799,6 +826,27 @@ def delete_compound(request, package_uuid, compound_uuid):
}
class CreateCompoundStructure(Schema):
smiles: str
name: str | None = None
description: str | None = None
inchi: str | None = None
molfile: str | None = None
@router.post("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure")
def create_package_compound_structure(
request, package_uuid, compound_uuid, structure: Form[CreateCompoundStructure]
):
try:
p = get_package_for_write(request.user, package_uuid)
c = Compound.objects.get(package=p, uuid=compound_uuid)
cs = CompoundStructure.create(c, structure.smiles, structure.name, structure.description)
return redirect(cs.url)
except ValueError as e:
return 400, {"message": str(e)}
@router.delete(
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
)
@ -1325,6 +1373,7 @@ class ScenarioSchema(Schema):
aliases: List[str] = Field([], alias="aliases")
collection: Dict["str", List[Dict[str, Any]]] = Field([], alias="collection")
collectionID: Optional[str] = None
date: str = Field(None, alias="scenario_date")
description: str = Field(None, alias="description")
id: str = Field(None, alias="url")
identifier: str = "scenario"
@ -1468,28 +1517,56 @@ def create_package_additional_information(request, package_uuid):
scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen)
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object()
if request.POST.get("adInfoTypes[]"):
url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
types = request.POST.get("adInfoTypes[]", "").split(",")
types = request.POST.get("adInfoTypes[]", "").split(",")
for t in types:
ai = build_additional_information_from_request(request, t)
for t in types:
ai = build_additional_information_from_request(request, t)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
content_object=attach_obj,
)
AdditionalInformation.create(
p,
ai,
scenario=scenario,
content_object=attach_obj,
)
elif request.POST.get("ais"):
import json
parsed_ais = json.loads(request.POST.get("ais"))
for ai_type, ais in parsed_ais.items():
for ai in ais:
attach_obj = None
if ai.get("related"):
url_parser = EPDBURLParser(ai.get("related").get("url"))
attach_obj = url_parser.get_object()
if not hasattr(attach_obj, "additional_information"):
raise ValueError("Can't attach additional information to this object!")
if not attach_obj.url.startswith(p.url):
raise ValueError(
"Additional Information can only be set to objects stored in the same package!"
)
AdditionalInformation.create(
p,
AdditionalInformation.from_dict(ai_type, ai),
scenario=scenario,
content_object=attach_obj,
)
# TODO implement additional information endpoint ?
return redirect(f"{scenario.url}")
@ -1540,6 +1617,7 @@ class PathwayNode(Schema):
name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate")
smiles: str = Field(None, alias="default_node_label.smiles")
pseudo: bool = Field(False, alias="pseudo")
@staticmethod
def resolve_atom_count(obj: Node):
@ -1693,6 +1771,29 @@ def create_package_pathway(
return 403, {"message": str(e)}
@router.post("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def update_pathway(request, package_uuid, pathway_uuid):
try:
p = get_package_for_write(request.user, package_uuid)
if request.POST.get("scenario"):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
scen = Scenario.objects.get(package=p, url=request.POST.get("scenario"))
pw.scenarios.add(scen)
pw.save()
return redirect(f"{pw.url}")
else:
return 400, {"message": "No scenario specified!"}
except ValueError:
return 403, {
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def delete_pathway(request, package_uuid, pathway_uuid):
try:
@ -1813,7 +1914,7 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
node_depth = int(n.nodeDepth)
node_depth = int(float(n.nodeDepth))
else:
node_depth = -1
@ -1958,6 +2059,10 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
for pr in e.products.split(","):
products.append(Node.objects.get(pathway=pw, url=pr.strip()))
multi_step = None
if e.multistep and e.multistep.strip() == "true":
multi_step = True
new_e = Edge.create(
pathway=pw,
start_nodes=educts,
@ -1965,6 +2070,7 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
rule=None,
name=None,
description=e.edgeReason,
multi_step=multi_step,
)
# Update depths as sideeffect of above operation

View File

@ -1695,7 +1695,7 @@ class Reaction(
if name is not None and name.strip() != "":
r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if description is not None and name.strip() != "":
if description is not None and description.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
r.multi_step = multi_step
@ -2192,6 +2192,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
depth_map = {}
depth_map[0] = list()
processed = set()
for n in self.nodes:
num_parents = in_count[str(n.uuid)]
@ -2214,10 +2215,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
unique_next_level = set()
for n in level_nodes:
processed.add(n)
for e in self.edges:
if n in e.start_nodes.all():
for p in e.end_nodes.all():
unique_next_level.add(p)
if p not in processed:
unique_next_level.add(p)
if len(unique_next_level) > 0:
depth_map[i + 1] = list(unique_next_level)
@ -2455,6 +2458,8 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
rule: Optional[Rule] = None,
name: Optional[str] = None,
description: Optional[str] = None,
*args,
**kwargs,
):
e = Edge()
e.pathway = pathway
@ -2484,7 +2489,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
educts=[n.default_node_label for n in e.start_nodes.all()],
products=[n.default_node_label for n in e.end_nodes.all()],
rules=rule,
multi_step=False,
multi_step=kwargs.get("multi_step", False),
)
e.edge_label = r
@ -4492,18 +4497,22 @@ class AdditionalInformation(models.Model):
return f"{self.scenario.url}/additional-information/{self.uuid}"
def get(self) -> "EnviPyModel":
@staticmethod
def from_dict(ai_type: str, ai_data: Dict[str, Any]):
from envipy_additional_information import registry
MAPPING = {c.__name__: c for c in registry.list_models().values()}
try:
inst = MAPPING[self.type](**self.data)
inst = MAPPING[ai_type](**ai_data)
except Exception as e:
print(f"Error loading {self.type}: {e}")
print(f"Error loading {ai_type}: {e}")
raise e
inst.__dict__["uuid"] = str(self.uuid)
return inst
def get(self) -> "EnviPyModel":
inst = AdditionalInformation.from_dict(self.type, self.data)
inst.__dict__["uuid"] = str(self.uuid)
return inst
def __str__(self) -> str:

View File

@ -3080,12 +3080,21 @@ def jobs(request):
{"Home": s.SERVER_URL},
{"Jobs": s.SERVER_URL + "/jobs"},
]
if current_user.is_superuser:
context["jobs"] = JobLog.objects.all().order_by("-created")
else:
context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created")
# if current_user.is_superuser:
# context["jobs"] = JobLog.objects.all().order_by("-created")
# else:
# context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created")
return render(request, "collections/joblog.html", context)
# Context for paginated template
context["entity_type"] = "joblog"
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/joblog/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "joblog"
context["list_mode"] = "combined"
return render(request, "collections/joblog_paginated.html", context)
# return render(request, "collections/joblog.html", context)
elif request.method == "POST":
job_name = request.POST.get("job-name")

View File

@ -463,7 +463,7 @@ function draw(pathway, elem) {
// TODO needs to be generic once we store it as AddInf
for (var s of n.predicted_properties["PepperPrediction"]) {
if (s["mean"] != null) {
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + "<br>"
tempContent += "<b>DT50 predicted via Pepper:</b> " + s["mean"].toFixed(2) + " days<br>"
}
}
}

View File

@ -0,0 +1,102 @@
{# Partial for paginated list content - expects to be inside a remotePaginatedList Alpine.js context #}
{# Variables: empty_text (string), show_review_badge (bool), always_show_badge (bool) #}
{% load envipytags %}
{# Loading state #}
<div
x-show="isLoading"
class="mx-auto flex h-32 w-32 items-center justify-center"
>
{% include "components/loading-spinner.html" %}
</div>
{# Error state #}
<div
x-show="!isLoading && error"
class="alert alert-error/50 text-sm"
x-text="error"
></div>
{# Content #}
<template x-if="!isLoading && !error">
<div>
{# Empty state #}
<div
x-show="totalItems === 0"
class="text-base-content/70 py-8 text-center"
>
<p>No {{ empty_text|default:"items" }} found.</p>
</div>
{# Items list #}
<ul class="menu bg-base-100 rounded-box w-full" x-show="totalItems > 0">
<table class="table-zebra table">
<thead>
<tr>
<th>User</th>
<th>ID</th>
<th>Name</th>
<th>Status</th>
<th>Queued At</th>
<th>Done At</th>
</tr>
</thead>
<tbody>
<template x-for="obj in paginatedItems" :key="obj.url">
<tr>
<td>
<a :href="obj.user.url"><span x-text="obj.user.name"></span></a>
</td>
<td>
<a :href="obj.url"><span x-text="obj.id"></span></a>
</td>
<td><span x-text="obj.name"></span></td>
<td><span x-text="obj.status"></span></td>
<td><span x-text="obj.created"></span></td>
<td><span x-text="obj.done"></span></td>
</tr>
</template>
</tbody>
</table>
</ul>
{# Pagination controls #}
<div
x-show="totalPages > 1"
class="mt-4 flex items-center justify-between px-2"
>
<span class="text-base-content/70 text-sm">
Showing <span x-text="showingStart"></span>-<span
x-text="showingEnd"
></span>
of <span x-text="totalItems"></span>
</span>
<div class="join">
<button
class="join-item btn btn-sm"
:disabled="currentPage === 1"
@click="prevPage()"
>
«
</button>
<template x-for="item in pageNumbers" :key="item.key">
<button
class="join-item btn btn-sm"
:class="{ 'btn-active': item.page === currentPage }"
:disabled="item.isEllipsis"
@click="!item.isEllipsis && goToPage(item.page)"
x-text="item.page"
></button>
</template>
<button
class="join-item btn btn-sm"
:disabled="currentPage === totalPages"
@click="nextPage()"
>
»
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,13 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Jobs{% endblock %}
{% block action_button %}
{% endblock action_button %}
{% block action_modals %}
{% endblock action_modals %}
{% block description %}
<p>List of Jobs submitted.</p>
{% endblock description %}

View File

@ -37,7 +37,11 @@
perPage: {{ per_page|default:50 }}
})"
>
{% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
{% if entity_type == 'joblog' %}
{% include "collections/_joblog_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
{% else %}
{% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
{% endif %}
</div>
{% else %}
{# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #}

View File

@ -125,6 +125,7 @@ from envipy_additional_information.parsers import (
LiquidMatrixSourceParser,
OxygenUptakeRateParser,
InitiatingOrganismParser,
PFASConfidenceParser,
)
logger = logging.getLogger(__name__)
@ -141,7 +142,7 @@ def get_parameter(request, paramname):
res = request.POST.get(paramname)
if res is not None and res.strip() != "":
return res
return ValueError("Not all parameters are set!")
raise ValueError("Not all parameters are set!")
def get_parameter_or_empty_string(request, paramname):
@ -673,7 +674,8 @@ def build_additional_information_from_request(request, type_):
elif type_ == "studywst":
# study_wst_cond = get_parameter(request, "studywstcond")
raise ValueError("studywstcond is not yet implemented")
elif type_ == "pfasconfidence":
return PFASConfidenceParser.from_string(get_parameter(request, "level"))
else:
raise ValueError(f"No corresponding AdditionalInformation for {type_} found!")

2
uv.lock generated
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#676dae1c5678539beac637b87e49b9dadfdfd85a" }
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#79285f522e0a6ed3f2e805dfeeb6b9fa5cea4323" }
dependencies = [
{ name = "pydantic" },
]