20 Commits

Author SHA1 Message Date
c367d24d9e missing import
Some checks failed
API CI / api-tests (pull_request) Failing after 15s
CI / test (pull_request) Failing after 29s
2026-06-12 10:51:52 +02:00
597213286a auth log, bb4g fix
Some checks failed
CI / test (pull_request) Failing after 15s
API CI / api-tests (pull_request) Failing after 27s
2026-06-12 09:59:31 +02:00
6680668c89 adjusted migration
Some checks failed
CI / test (pull_request) Failing after 15s
API CI / api-tests (pull_request) Failing after 27s
Initial bayer app

Show Pack Classification

Adjusted docker compose to bayer specifics

Adjusted Dockerfile for Bayer

Adding secret flags to group, add secret pools to packages

Adjusted View for Package creation

Prep configs, added Package Create Modal

wip

More on PES

wip

wip

Wip

minor

PW interactions

API PES

wip

Make Select Widget reflect required

make required generallay available

Update UI if pathway mode is set to build

Added ais

circle adjustments

Initial Zoom, fix AD Creation

wip
2026-06-11 09:41:08 +02:00
6ab9180291 [Feature] Vizualize Reaction Circles (#410)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#410
2026-06-11 02:07:49 +12:00
3657d14659 [Fix] Cascade deletion of Reactions to Edges (#409)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#409
2026-06-11 00:24:06 +12:00
ef6091d416 [Fix] Set depth to -1 when adding a Node via Pathway.add_node to be in sync with legacy_api (#408)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#408
2026-06-09 23:55:16 +12:00
14cfc1e4d7 [Feature] Integrate DOI Links, Handle Cycles in Pathway Viz (#407)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#407
2026-06-03 06:00:38 +12:00
868bbf5c05 [Fix] False as multi_step default in legacy API (#406)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#406
2026-05-29 08:12:52 +12:00
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
1e43c298d2 [Fix] Simplify Depth adjustment (#386)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#386
2026-05-12 21:04:56 +12:00
b39fc7eaf8 [Fix] Update Node depth when adding new Edges to a Pathway (#384)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#384
2026-05-12 09:40:35 +12:00
a2fc9f72cb [Feature] Make use of HalfLifeModel Enum (#383)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#383
2026-05-12 09:23:56 +12:00
734b02767e [Fix] Update plotting imports and thread handling in Pepper class (#382)
- plt.subplot does not work reliably with async/ threads.
- Bug in thread run that would fail with env set (string to number)

Reviewed-on: enviPath/enviPy#382
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2026-05-12 06:43:26 +12:00
37 changed files with 1013 additions and 182 deletions

View File

@ -6,18 +6,23 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
curl \ curl \
openssh-client \ openssh-client \
git \ git \
nodejs \ ca-certificates \
npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install pnpm # Install Node 22 + pnpm
RUN npm install -g pnpm RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& corepack enable \
&& corepack prepare pnpm@latest --activate \
&& rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh RUN curl -LsSf https://astral.sh/uv/install.sh | sh
ENV PATH="/root/.local/bin:${PATH}" ENV PATH="/root/.local/bin:${PATH}"

View File

@ -0,0 +1,178 @@
import enum
from typing import Optional
from envipy_additional_information import GroupEnum as G
from envipy_additional_information import SubcategoryEnum as S
from envipy_additional_information import (
register,
register_parser_command,
EnviPyModel,
# EnviPyModelParser,
Interval,
UIConfig,
IntervalConfig,
WidgetType,
registry,
)
@register(keyname="compoundlabel", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class CompoundLabel(EnviPyModel):
label: str
class UI:
title = "Compound Label"
label = UIConfig(widget=WidgetType.TEXT, label="Label", order=1)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("compoundlabel")
class CompoundLabelParser:
@staticmethod
def from_string(data: str) -> CompoundLabel:
return CompoundLabel(label=data)
@register(keyname="studywaterstoragecapacity", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class StudyWaterStorageCapacity(EnviPyModel):
capacity: str
class UI:
title = "Study Water Storage Capacity"
capacity = UIConfig(widget=WidgetType.TEXT, label="Study Water Storage Capacity", order=1)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("studywst")
class StudyWaterStorageCapacityParser:
@staticmethod
def from_string(data: str) -> StudyWaterStorageCapacity:
return StudyWaterStorageCapacity(capacity=data)
class ObservationType(enum.Enum):
OBSERVED = "observed"
APPLIED = "applied"
NA = 'NA'
@register(keyname="observation", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class Observation(EnviPyModel):
type: ObservationType
min_value: Optional[float] = None
max_value: Optional[float] = None
class UI:
title = "Observation"
type = UIConfig(widget=WidgetType.SELECT, label="Observed or Applied", order=1)
min_value = UIConfig(widget=WidgetType.NUMBER, label="Min Value", order=2)
max_value = UIConfig(widget=WidgetType.NUMBER, label="Max Value", order=3)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("observation")
class ObservationParser:
@staticmethod
def from_string(data: str) -> Observation:
parts = data.split(";")
observation_type = ObservationType(parts[0])
min_value = None
if parts[1]:
try:
min_value = float(parts[1])
except ValueError:
pass
max_value = None
if parts[2]:
try:
max_value = float(parts[2])
except ValueError:
pass
return Observation(type=observation_type, min_value=min_value, max_value=max_value)
@register(keyname="kinetics", groups=[S.MISC, G.SLUDGE, G.SEDIMENT, G.SOIL])
class Kinetics(EnviPyModel):
dt50: Interval[float]
normalized_dt50: bool
chi2err: Optional[float] = None
t_test: Optional[float] = None
swarc: Optional[float] = None
visual_fit: Optional[int] = None
comment: str
source: str
kinetic_model: str
k1: Optional[float] = None
k2: Optional[float] = None
g: Optional[float] = None
tb: Optional[float] = None
alpha: Optional[float] = None
beta: Optional[float] = None
class UI:
title = "Kinetics"
# Field config
dt50 = IntervalConfig(label="DT50 Range", order=1, unit="d")
normalized_dt50 = UIConfig(widget=WidgetType.CHECKBOX, label="Normalized DT50", order=2)
chi2err = UIConfig(widget=WidgetType.NUMBER, label="Chi2err", order=3)
t_test = UIConfig(widget=WidgetType.NUMBER, label="T-Test", order=4)
swarc = UIConfig(widget=WidgetType.NUMBER, label="SWARC", order=5)
visual_fit = UIConfig(widget=WidgetType.NUMBER, label="Visual Fit", order=6)
comment = UIConfig(widget=WidgetType.TEXTAREA, label="Comments", order=7)
source = UIConfig(widget=WidgetType.TEXT, label="Source", order=8)
kinetic_model = UIConfig(widget=WidgetType.SELECT, label="Kinetic Model", order=9)
k1 = UIConfig(widget=WidgetType.NUMBER, label="K1", order=10)
k2 = UIConfig(widget=WidgetType.NUMBER, label="K2", order=11)
g = UIConfig(widget=WidgetType.NUMBER, label="G", order=12)
tb = UIConfig(widget=WidgetType.NUMBER, label="TB", order=13)
alpha = UIConfig(widget=WidgetType.NUMBER, label="Alpha", order=14)
beta = UIConfig(widget=WidgetType.NUMBER, label="Beta", order=15)
# TODO Expose EnviPyModelParser in lib and subclass
@register_parser_command("kineticevaluation")
class KinecticsParser:
@staticmethod
def from_string(data: str) -> Kinetics:
parts = data.split(";")
dt50 = registry.get_parser("interval").from_string(parts[0])
normalized_dt50 = parts[1] == "true"
chi2err = float(parts[2]) if parts[2] else None
t_test = float(parts[3]) if parts[3] else None
swarc = float(parts[4]) if parts[4] else None
visual_fit = int(parts[5]) if parts[5] else None
comment = parts[6]
source = parts[7]
kinetic_model = parts[8]
k1 = float(parts[9]) if parts[9] else None
k2 = float(parts[10]) if parts[10] else None
g = float(parts[11]) if parts[11] else None
tb = float(parts[12]) if parts[12] else None
alpha = float(parts[13]) if parts[13] else None
beta = float(parts[14]) if parts[14] else None
return Kinetics(
dt50=dt50,
normalized_dt50=normalized_dt50,
chi2err=chi2err,
t_test=t_test,
swarc=swarc,
visual_fit=visual_fit,
comment=comment,
source=source,
kinetic_model=kinetic_model,
k1=k1,
k2=k2,
g=g,
tb=tb,
alpha=alpha,
beta=beta,
)
if __name__ == '__main__':
print(KinecticsParser.from_string("187.0 - 187.0;false;;;;;;;AFO;;;;;;"))

View File

@ -1,5 +1,6 @@
import logging import logging
from bayer import additional_information # noqa: F401
from epdb.template_registry import register_template from epdb.template_registry import register_template
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -232,5 +232,6 @@ class PESStructure(CompoundStructure):
"is_pes": True, "is_pes": True,
"pes_link": self.pes_link, "pes_link": self.pes_link,
# Will overwrite image from Node # Will overwrite image from Node
"image": f"{reverse("depict_pes")}?pesLink={urllib.parse.quote(self.pes_link)}" "image": f"{reverse('depict_pes')}?pesLink={urllib.parse.quote(self.pes_link)}",
"image_type": "png",
} }

View File

@ -37,6 +37,10 @@ def create_pes(request, package_uuid):
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return BadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
@ -75,6 +79,10 @@ def create_pes_node(request, package_uuid, pathway_uuid):
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if current_package.classification_level != Package.Classification.SECRET:
return BadRequest("Cannot create PESs for non-secret packages.")
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[current_package.data_pool.name] not in data_pools:
@ -83,6 +91,10 @@ def create_pes_node(request, package_uuid, pathway_uuid):
pes = PESCompound.create(current_package, pes_data, compound_name, compound_description) pes = PESCompound.create(current_package, pes_data, compound_name, compound_description)
node_qs = Node.objects.filter(pathway=current_pathway, default_node_label=pes.default_structure)
if node_qs.exists():
return redirect(current_pathway.url)
n = Node() n = Node()
n.stereo_removed = False n.stereo_removed = False
n.pathway = current_pathway n.pathway = current_pathway

View File

@ -1,12 +1,13 @@
import enum
import json import json
import logging
import math import math
from datetime import datetime from datetime import datetime
from typing import List from typing import List
import enum
import requests import requests
from django.conf import settings as s from django.conf import settings as s
from envipy_additional_information import EnviPyModel, UIConfig, WidgetType from envipy_additional_information import register, EnviPyModel, UIConfig, WidgetType
from envipy_additional_information import register
from bridge.contracts import Classifier # noqa: I001 from bridge.contracts import Classifier # noqa: I001
from bridge.dto import ( from bridge.dto import (
@ -17,6 +18,9 @@ from bridge.dto import (
TransformationProductPrediction, TransformationProductPrediction,
) # noqa: I001 ) # noqa: I001
logger = logging.getLogger("epdb")
class SamplingAlgorithm(enum.Enum): class SamplingAlgorithm(enum.Enum):
EXACT = "exact" EXACT = "exact"
@ -88,7 +92,7 @@ class BB4G(Classifier):
retries = 0 retries = 0
while not started and retries < 5: while not started and retries < 5:
res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None) res = requests.post(f"{self.url}/start", headers=header, data={}, proxies=s.PROXIES or None)
logger.info(f"Starting BB4G: {res.status_code}")
if res.status_code == 200: if res.status_code == 200:
started = True started = True
elif res.status_code in [500, 502]: elif res.status_code in [500, 502]:
@ -166,7 +170,17 @@ class BB4G(Classifier):
"cutoff": self.config.cutoff, "cutoff": self.config.cutoff,
} }
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data), proxies=s.PROXIES or None) retries = 0
while retries < 5:
resp = requests.post(f"{self.url}/compute", headers=header, data=json.dumps(data),
proxies=s.PROXIES or None)
if resp.status_code == 418:
retries += 1
logger.info(f"BB4G predict hit a 418, retrying in 60 seconds")
import time
time.sleep(60)
continue
resp.raise_for_status() resp.raise_for_status()
@ -180,4 +194,6 @@ class BB4G(Classifier):
result[substrate] = preds result[substrate] = preds
break
return result return result

View File

@ -25,7 +25,7 @@ services:
- ep_bayer_redis_data:/data - ep_bayer_redis_data:/data
biotransformer3: biotransformer3:
image: envipath/biotransformer3:1.0 image: git.envipath.com/envipath/biotransformer3:1.0
container_name: epbiotransformer3 container_name: epbiotransformer3
# web: # web:
@ -40,7 +40,7 @@ services:
# - ep_bayer_data:/opt/enviPy/ # - ep_bayer_data:/opt/enviPy/
celery_worker: celery_worker:
image: envipath/envipy-bayer:1.0 image: git.envipath.com/envipath/envipy-bayer:1.0
container_name: epcelery container_name: epcelery
env_file: env_file:
- .env.dev - .env.dev

View File

@ -275,6 +275,12 @@ LOGGING = {
"filename": os.path.join(LOG_DIR, "debug.log"), "filename": os.path.join(LOG_DIR, "debug.log"),
"formatter": "simple", "formatter": "simple",
}, },
"auth_file": {
"level": "INFO", # Or higher
"class": "logging.FileHandler",
"filename": os.path.join(LOG_DIR, "auth.log"),
"formatter": "simple",
}
}, },
"loggers": { "loggers": {
# For everything under epdb/ loaded via getlogger(__name__) # For everything under epdb/ loaded via getlogger(__name__)
@ -295,6 +301,11 @@ LOGGING = {
"propagate": True, "propagate": True,
"level": os.environ.get("LOG_LEVEL", "INFO"), "level": os.environ.get("LOG_LEVEL", "INFO"),
}, },
"auth": {
"handlers": ["auth_file"],
"propagate": True,
"level": os.environ.get("LOG_LEVEL", "INFO"),
}
}, },
} }
@ -491,3 +502,5 @@ BB4G_TENANT_ID = os.environ.get("BB4G_TENANT_ID")
BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID") BB4G_CLIENT_ID = os.environ.get("BB4G_CLIENT_ID")
BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET") BB4G_CLIENT_SECRET = os.environ.get("BB4G_CLIENT_SECRET")
BB4G_SCOPE = os.environ.get("BB4G_SCOPE") BB4G_SCOPE = os.environ.get("BB4G_SCOPE")
os.environ["NO_PROXY"] = "localhost,127.0.0.1,epbiotransformer3"

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, additional_information,
settings, settings,
groups, groups,
joblogs,
) )
# Main router with authentication # Main router with authentication
@ -37,6 +38,7 @@ router.add_router("", structure.router)
router.add_router("", additional_information.router) router.add_router("", additional_information.router)
router.add_router("", settings.router) router.add_router("", settings.router)
router.add_router("", groups.router) router.add_router("", groups.router)
router.add_router("", joblogs.router)
if s.IUCLID_EXPORT_ENABLED: if s.IUCLID_EXPORT_ENABLED:
from epiuclid.api import router as iuclid_router 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 typing import Annotated, Optional, List, Dict, Any
from uuid import UUID from uuid import UUID
from django.urls import reverse
from ninja import Field, FilterSchema, FilterLookup, Schema
# Filter schema for query parameters # Filter schema for query parameters
class ReviewStatusFilter(FilterSchema): class ReviewStatusFilter(FilterSchema):
@ -133,3 +136,23 @@ class GroupOutSchema(Schema):
url: str = "" url: str = ""
name: str name: str
description: 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

@ -439,23 +439,50 @@ class PackageSchema(Schema):
@staticmethod @staticmethod
def resolve_readers(obj: Package): def resolve_readers(obj: Package):
users = User.objects.filter( readers = []
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.READ[0]
).values_list("user", flat=True)
).distinct()
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 @staticmethod
def resolve_writers(obj: Package): def resolve_writers(obj: Package):
users = User.objects.filter( writers = []
id__in=UserPackagePermission.objects.filter(
package=obj, permission=UserPackagePermission.WRITE[0]
).values_list("user", flat=True)
).distinct()
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 @staticmethod
def resolve_review_comment(obj): def resolve_review_comment(obj):
@ -844,6 +871,10 @@ def create_package_compound(
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
return 400, {"Cannot create PESs for non-secret packages."}
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
@ -872,6 +903,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( @router.delete(
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}" "/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
) )
@ -1398,6 +1450,7 @@ class ScenarioSchema(Schema):
aliases: List[str] = Field([], alias="aliases") aliases: List[str] = Field([], alias="aliases")
collection: Dict["str", List[Dict[str, Any]]] = Field([], alias="collection") collection: Dict["str", List[Dict[str, Any]]] = Field([], alias="collection")
collectionID: Optional[str] = None collectionID: Optional[str] = None
date: str = Field(None, alias="scenario_date")
description: str = Field(None, alias="description") description: str = Field(None, alias="description")
id: str = Field(None, alias="url") id: str = Field(None, alias="url")
identifier: str = "scenario" identifier: str = "scenario"
@ -1541,6 +1594,7 @@ def create_package_additional_information(request, package_uuid):
scen = request.POST.get("scenario") scen = request.POST.get("scenario")
scenario = Scenario.objects.get(package=p, url=scen) scenario = Scenario.objects.get(package=p, url=scen)
if request.POST.get("adInfoTypes[]"):
url_parser = EPDBURLParser(request.POST.get("attach_obj")) url_parser = EPDBURLParser(request.POST.get("attach_obj"))
attach_obj = url_parser.get_object() attach_obj = url_parser.get_object()
@ -1564,6 +1618,33 @@ def create_package_additional_information(request, package_uuid):
content_object=attach_obj, 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 ? # TODO implement additional information endpoint ?
return redirect(f"{scenario.url}") return redirect(f"{scenario.url}")
except ValueError: except ValueError:
@ -1613,6 +1694,7 @@ class PathwayNode(Schema):
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate") proposed: List[Dict[str, str]] = Field([], alias="proposed_intermediate")
smiles: str = Field(None, alias="default_node_label.smiles") smiles: str = Field(None, alias="default_node_label.smiles")
pseudo: bool = Field(False, alias="pseudo")
@staticmethod @staticmethod
def resolve_atom_count(obj: Node): def resolve_atom_count(obj: Node):
@ -1766,6 +1848,29 @@ def create_package_pathway(
return 403, {"message": str(e)} 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}") @router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def delete_pathway(request, package_uuid, pathway_uuid): def delete_pathway(request, package_uuid, pathway_uuid):
try: try:
@ -1879,30 +1984,42 @@ class CreateNode(Schema):
@router.post( @router.post(
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node", "/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node",
response={200: str | Any, 403: Error}, response={200: str | Any, 400: Error, 403: Error},
) )
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]): def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
try: try:
p = get_package_for_write(request.user, package_uuid) p = get_package_for_write(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid) pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
# TODO Code Dup from bayer.views
if n.pesLink: if n.pesLink:
from bayer.views import fetch_pes from bayer.views import fetch_pes
from bayer.models import PESCompound from bayer.models import PESCompound
try: try:
pes_data = fetch_pes(request, c.pesLink) pes_data = fetch_pes(request, n.pesLink)
except ValueError as e: except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"} return 400, {"message": f"Could not fetch PES data for {n.pesLink}"}
classification = pes_data.get("classificationLevel", "") classification = pes_data.get("classificationLevel", "")
if "secret" == classification.lower(): if "secret" == classification.lower():
if p.classification_level != Package.Classification.SECRET:
return 400, "Cannot create PESs for non-secret packages."
data_pools = pes_data.get("dataPools") data_pools = pes_data.get("dataPools")
if data_pools: if data_pools:
if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools: if s.DATA_POOL_MAPPING[p.data_pool.name] not in data_pools:
return 400, { "messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"} return 400, {
"messsage": f"PES data pool {s.DATA_POOL_MAPPING[p.data_pool.name]} not found in PES data"
}
c = PESCompound.create(p, pes_data, c.compoundName, c.compoundDescription) c = PESCompound.create(p, pes_data, n.nodeName, n.nodeReason)
node_qs = Node.objects.filter(pathway=pw, default_node_label=c.default_structure)
if node_qs.exists():
return redirect(pw.url)
node = Node() node = Node()
node.stereo_removed = False node.stereo_removed = False
@ -2065,6 +2182,10 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
for pr in e.products.split(","): for pr in e.products.split(","):
products.append(Node.objects.get(pathway=pw, url=pr.strip())) products.append(Node.objects.get(pathway=pw, url=pr.strip()))
multi_step = False
if e.multistep and e.multistep.strip() == "true":
multi_step = True
new_e = Edge.create( new_e = Edge.create(
pathway=pw, pathway=pw,
start_nodes=educts, start_nodes=educts,
@ -2072,8 +2193,12 @@ def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
rule=None, rule=None,
name=None, name=None,
description=e.edgeReason, description=e.edgeReason,
multi_step=multi_step,
) )
# Update depths as sideeffect of above operation
pw.update_depths()
return redirect(new_e.url) return redirect(new_e.url)
except ValueError: except ValueError:
return 403, {"message": "Adding Edge failed!"} return 403, {"message": "Adding Edge failed!"}

View File

@ -1065,52 +1065,9 @@ class PackageManager(object):
print("Fixing Node depths...") print("Fixing Node depths...")
total_pws = Pathway.objects.filter(package=pack).count() total_pws = Pathway.objects.filter(package=pack).count()
for p, pw in enumerate(Pathway.objects.filter(package=pack)): for p, pw in enumerate(Pathway.objects.filter(package=pack)):
in_count = defaultdict(lambda: 0) pw.update_depths()
out_count = defaultdict(lambda: 0)
for e in pw.edges:
# TODO check if this will remain
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
root_nodes = []
for n in pw.nodes:
num_parents = in_count[str(n.uuid)]
if num_parents == 0:
# must be a root node or unconnected node
if n.depth != 0:
n.depth = 0
n.save()
# Only root node may have children
if out_count[str(n.uuid)] > 0:
root_nodes.append(n)
levels = [root_nodes]
seen = set()
# Do a bfs to determine depths starting with level 0 a.k.a. root nodes
for i, level_nodes in enumerate(levels):
new_level = []
for n in level_nodes:
for e in n.out_edges.all():
for prod in e.end_nodes.all():
if str(prod.uuid) not in seen:
old_depth = prod.depth
if old_depth != i + 1:
prod.depth = i + 1
prod.save()
new_level.append(prod)
seen.add(str(n.uuid))
if new_level:
levels.append(new_level)
print(f"{p + 1}/{total_pws} fixed.", end="\r") print(f"{p + 1}/{total_pws} fixed.", end="\r")
return pack return pack

View File

@ -0,0 +1,56 @@
# Generated by Django 6.0.3 on 2026-05-11 20:25
from django.db import migrations
from envipy_additional_information import HalfLife, HalfLifeModel, HalfLifeWS
MAPPING = {
"": HalfLifeModel.OTHER,
"HS-SFO": HalfLifeModel.HS_SFO,
"FOMC": HalfLifeModel.FOMC,
"FOTC": HalfLifeModel.DFOP,
"FMOC": HalfLifeModel.FOMC,
"DFOP": HalfLifeModel.DFOP,
"SFO + SFO": HalfLifeModel.SFO_SFO,
"FOMC-SFO": HalfLifeModel.FOMC_SFO,
"first order kinetics": HalfLifeModel.SFO,
"SFO²": HalfLifeModel.SFO,
"HS": HalfLifeModel.HS,
"top down": HalfLifeModel.OTHER,
"SFO": HalfLifeModel.SFO,
"First Order": HalfLifeModel.SFO,
"SFO/SFO": HalfLifeModel.SFO_SFO,
"FOMC + SFO": HalfLifeModel.FOMC_SFO,
"true": HalfLifeModel.SFO,
"SFO-SFO": HalfLifeModel.SFO_SFO,
"DFOP-SFO": HalfLifeModel.DFOP_SFO,
}
def forward_func(apps, schema_editor):
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
hls = AdditionalInformation.objects.filter(type="HalfLife")
for hl in hls:
data = hl.data
data["model"] = MAPPING[data["model"]].value
hl.data = HalfLife(**data).model_dump(mode="json")
hl.save()
hlws = AdditionalInformation.objects.filter(type="HalfLifeWS")
for hl in hlws:
data = hl.data
data["model"] = MAPPING[data["model"]].value
hl.data = HalfLifeWS(**data).model_dump(mode="json")
hl.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0024_user_contacted"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 6.0.3 on 2026-06-02 17:18
from django.db import migrations
from envipy_additional_information import DOI
def forward_func(apps, schema_editor):
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
refs = AdditionalInformation.objects.filter(type="Reference")
remaining = []
for ref in refs:
r = ref.data["reference"]
try:
# PubMed IDs are plain ints, try parsing
_ = int(r)
# Nothing to do
except ValueError:
DOMAINS = [
"http://dx.doi.org/",
"https://dx.doi.org/",
"http://doi.org/",
"https://doi.org/",
]
for d in DOMAINS:
r = r.replace(d, "")
if r.startswith("10."):
ref.type = DOI.__name__
ref.data = {"doi": r}
ref.save()
else:
remaining.append(ref)
if len(remaining) > 0:
raise ValueError(f"Could not parse {len(remaining)} references")
class Migration(migrations.Migration):
dependencies = [
("epdb", "0025_auto_20260511_2025"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -1703,7 +1703,7 @@ class Reaction(
if name is not None and name.strip() != "": if name is not None and name.strip() != "":
r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).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.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
r.multi_step = multi_step r.multi_step = multi_step
@ -1781,7 +1781,7 @@ class Reaction(
return new_reaction return new_reaction
def smirks(self): def smirks(self):
return f"{'.'.join([cs.smiles for cs in self.educts.all()])}>>{'.'.join([cs.smiles for cs in self.products.all()])}" return f"{'.'.join([cs.smiles for cs in self.educts.all().order_by('-pk')])}>>{'.'.join([cs.smiles for cs in self.products.all().order_by('-pk')])}"
@property @property
def as_svg(self): def as_svg(self):
@ -1894,6 +1894,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
if n not in queue: if n not in queue:
queue.append(n) queue.append(n)
for i in queue:
processed.add(i)
while len(queue): while len(queue):
current = queue.pop() current = queue.pop()
processed.add(current) processed.add(current)
@ -2171,7 +2174,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
smiles: str, smiles: str,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
depth: Optional[int] = 0, depth: Optional[int] = -1,
): ):
return Node.create(self, smiles, depth, name=name, description=description) return Node.create(self, smiles, depth, name=name, description=description)
@ -2186,6 +2189,66 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
): ):
return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description) return Edge.create(self, start_nodes, end_nodes, rule, name=name, description=description)
def update_depths(self):
# Collect number of in and out links per node
in_count = defaultdict(lambda: 0)
out_count = defaultdict(lambda: 0)
for e in self.edges:
for react in e.start_nodes.all():
out_count[str(react.uuid)] += 1
for prod in e.end_nodes.all():
in_count[str(prod.uuid)] += 1
depth_map = {}
depth_map[0] = list()
processed = set()
data_driven_root_nodes = self.node_set.all().annotate(
prod_cnt=Count('edge_products'),
educt_cnt=Count('edge_educts')
).filter(prod_cnt=0, educt_cnt__gt=0).distinct()
# Eval QuerySet
root_nodes_by_depth = list(self.root_nodes)
data_driven_root_nodes.update(depth=0)
root_nodes = [n for n in data_driven_root_nodes]
for n in root_nodes_by_depth:
if n not in root_nodes:
if len(n.edge_products.all()) == 0:
root_nodes.append(n)
for n in root_nodes:
depth_map[0].append(n)
# At most depth len(nodes) is possible
for i in range(self.nodes.count()):
level_nodes = depth_map.get(i, [])
if len(level_nodes) == 0:
break
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():
if p not in processed:
unique_next_level.add(p)
if len(unique_next_level) > 0:
depth_map[i + 1] = list(unique_next_level)
for depth, nodes in depth_map.items():
for n in nodes:
if n.depth != depth and depth != 0:
n.depth = depth
n.save()
class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin): class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
pathway = models.ForeignKey( pathway = models.ForeignKey(
@ -2334,7 +2397,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
"epdb.Pathway", verbose_name="belongs to", on_delete=models.CASCADE, db_index=True "epdb.Pathway", verbose_name="belongs to", on_delete=models.CASCADE, db_index=True
) )
edge_label = models.ForeignKey( edge_label = models.ForeignKey(
"epdb.Reaction", verbose_name="Edge label", null=True, on_delete=models.SET_NULL "epdb.Reaction", verbose_name="Edge label", null=True, on_delete=models.CASCADE
) )
start_nodes = models.ManyToManyField( start_nodes = models.ManyToManyField(
"epdb.Node", verbose_name="Start Nodes", related_name="edge_educts" "epdb.Node", verbose_name="Start Nodes", related_name="edge_educts"
@ -2413,6 +2476,8 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
rule: Optional[Rule] = None, rule: Optional[Rule] = None,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
*args,
**kwargs,
): ):
e = Edge() e = Edge()
e.pathway = pathway e.pathway = pathway
@ -2442,7 +2507,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
educts=[n.default_node_label for n in e.start_nodes.all()], educts=[n.default_node_label for n in e.start_nodes.all()],
products=[n.default_node_label for n in e.end_nodes.all()], products=[n.default_node_label for n in e.end_nodes.all()],
rules=rule, rules=rule,
multi_step=False, multi_step=kwargs.get("multi_step", False),
) )
e.edge_label = r e.edge_label = r
@ -4450,18 +4515,22 @@ class AdditionalInformation(models.Model):
return f"{self.scenario.url}/additional-information/{self.uuid}" 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 from envipy_additional_information import registry
MAPPING = {c.__name__: c for c in registry.list_models().values()} MAPPING = {c.__name__: c for c in registry.list_models().values()}
try: try:
inst = MAPPING[self.type](**self.data) inst = MAPPING[ai_type](**ai_data)
except Exception as e: except Exception as e:
print(f"Error loading {self.type}: {e}") print(f"Error loading {ai_type}: {e}")
raise 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 return inst
def __str__(self) -> str: def __str__(self) -> str:

View File

@ -2539,6 +2539,9 @@ def package_pathway_edges(request, package_uuid, pathway_uuid):
substrate_nodes, product_nodes, name=edge_name, description=edge_description substrate_nodes, product_nodes, name=edge_name, description=edge_description
) )
# Update depths as sideeffect of above operation
current_pathway.update_depths()
return redirect(current_pathway.url) return redirect(current_pathway.url)
else: else:
@ -3110,12 +3113,21 @@ def jobs(request):
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Jobs": s.SERVER_URL + "/jobs"}, {"Jobs": s.SERVER_URL + "/jobs"},
] ]
if current_user.is_superuser: # if current_user.is_superuser:
context["jobs"] = JobLog.objects.all().order_by("-created") # context["jobs"] = JobLog.objects.all().order_by("-created")
else: # else:
context["jobs"] = JobLog.objects.filter(user=current_user).order_by("-created") # 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": elif request.method == "POST":
job_name = request.POST.get("job-name") job_name = request.POST.get("job-name")

View File

@ -21,5 +21,11 @@
"django", "django",
"tailwindcss", "tailwindcss",
"daisyui" "daisyui"
],
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@tailwindcss/oxide"
] ]
}
} }

View File

@ -46,7 +46,7 @@ class PepperPrediction(PropertyPrediction):
import matplotlib.patches as mpatches import matplotlib.patches as mpatches
import numpy as np import numpy as np
from matplotlib import pyplot as plt from matplotlib.figure import Figure
from scipy import stats from scipy import stats
""" """
@ -101,7 +101,8 @@ class PepperPrediction(PropertyPrediction):
mask_red = x > vp mask_red = x > vp
# Plot # Plot
fig, ax = plt.subplots(figsize=(9, 5.5)) fig = Figure(figsize=(9, 5.5))
ax = fig.subplots()
ax.plot(x, y, color="#1f4e79", lw=2, label="Lognormal PDF") ax.plot(x, y, color="#1f4e79", lw=2, label="Lognormal PDF")
if np.any(mask_green): if np.any(mask_green):
@ -146,13 +147,12 @@ class PepperPrediction(PropertyPrediction):
] ]
ax.legend(handles=patches, frameon=True) ax.legend(handles=patches, frameon=True)
plt.tight_layout() fig.tight_layout()
# --- Export to SVG string --- # --- Export to SVG string ---
buf = io.StringIO() buf = io.StringIO()
fig.savefig(buf, format="svg", bbox_inches="tight") fig.savefig(buf, format="svg", bbox_inches="tight")
svg = buf.getvalue() svg = buf.getvalue()
plt.close(fig)
buf.close() buf.close()
return svg return svg

View File

@ -187,8 +187,9 @@ class Pepper:
groups = [group for group in dataset.group_by("structure_id")] groups = [group for group in dataset.group_by("structure_id")]
# Unless explicitly set compute everything serial # Unless explicitly set compute everything serial
if os.environ.get("N_PEPPER_THREADS", 1) > 1: n_threads = int(os.environ.get("N_PEPPER_THREADS", 1))
results = Parallel(n_jobs=os.environ["N_PEPPER_THREADS"])( if n_threads > 1:
results = Parallel(n_jobs=n_threads)(
delayed(compute_bayes_per_group)(group[1]) delayed(compute_bayes_per_group)(group[1])
for group in dataset.group_by("structure_id") for group in dataset.group_by("structure_id")
) )

View File

@ -1,3 +1,5 @@
allowBuilds:
'@parcel/watcher': true
onlyBuiltDependencies: onlyBuiltDependencies:
- '@parcel/watcher' - '@parcel/watcher'
- '@tailwindcss/oxide' - '@tailwindcss/oxide'

View File

@ -59,6 +59,9 @@ document.addEventListener("alpine:init", () => {
get isEditMode() { get isEditMode() {
return this.mode === "edit"; return this.mode === "edit";
}, },
get isRequired() {
return (this.schema.required || []).indexOf(this.fieldName) > -1
}
}); });
// Text widget // Text widget
@ -293,6 +296,34 @@ document.addEventListener("alpine:init", () => {
}), }),
); );
// PubMed link widget
Alpine.data(
"doiWidget",
(fieldName, data, schema, uiSchema, mode, debugErrors, context = null) => ({
...baseWidget(
fieldName,
data,
schema,
uiSchema,
mode,
debugErrors,
context,
),
get value() {
return this.data[this.fieldName] || "";
},
set value(v) {
this.data[this.fieldName] = v;
},
get doiUrl() {
return this.value
? `https://dx.doi.org/${this.value}`
: null;
},
}),
);
// Compound link widget // Compound link widget
Alpine.data( Alpine.data(
"compoundWidget", "compoundWidget",

View File

@ -22,7 +22,7 @@ function predictFromNode(url) {
// data = {{ pathway.d3_json | safe }}; // data = {{ pathway.d3_json | safe }};
// elem = 'vizdiv' // elem = 'vizdiv'
function draw(pathway, elem) { function draw(pathway, elem) {
const initialzoom = 2.5
const nodeRadius = 20; const nodeRadius = 20;
const linkDistance = 100; const linkDistance = 100;
const chargeStrength = -200; const chargeStrength = -200;
@ -63,7 +63,7 @@ function draw(pathway, elem) {
node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2; node.fx = width / 2 + depthMap.get(node.depth) * horizontalSpacing - ((nodesInLevel - 1) * horizontalSpacing) / 2;
} }
node.fy = node.depth * levelSpacing + 50; node.fy = (node.depth + initialzoom + 0.5) * levelSpacing + 50;
depthMap.set(node.depth, depthMap.get(node.depth) + 1); depthMap.set(node.depth, depthMap.get(node.depth) + 1);
}); });
} }
@ -101,10 +101,24 @@ function draw(pathway, elem) {
// Update pseudo node positions first // Update pseudo node positions first
updatePseudoNodePositions(); updatePseudoNodePositions();
link.attr("x1", d => d.source.x) link.attr("d", d => {
.attr("y1", d => d.source.y) // Check if it's a self-loop (source equals target)
.attr("x2", d => d.target.x) if (d.source.id === d.target.id) {
.attr("y2", d => d.target.y); // Create a bezier curve for self-loops
const x = d.source.x;
const y = d.source.y;
const loopRadius = nodeRadius * 2; // Adjust size of the loop
// Create a circular path to the left of the node
return `M ${x},${y - nodeRadius}
C ${x - loopRadius},${y - nodeRadius - loopRadius}
${x - loopRadius},${y + nodeRadius + loopRadius}
${x},${y + nodeRadius}`;
} else {
// Regular straight line for normal edges
return `M ${d.source.x},${d.source.y} L ${d.target.x},${d.target.y}`;
}
});
node.attr("transform", d => `translate(${d.x},${d.y})`); node.attr("transform", d => `translate(${d.x},${d.y})`);
} }
@ -463,7 +477,7 @@ function draw(pathway, elem) {
// TODO needs to be generic once we store it as AddInf // TODO needs to be generic once we store it as AddInf
for (var s of n.predicted_properties["PepperPrediction"]) { for (var s of n.predicted_properties["PepperPrediction"]) {
if (s["mean"] != null) { 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>"
} }
} }
} }
@ -558,11 +572,12 @@ function draw(pathway, elem) {
.scaleExtent([0.5, 5]) .scaleExtent([0.5, 5])
.on("zoom", (event) => { .on("zoom", (event) => {
zoomable.attr("transform", event.transform); zoomable.attr("transform", event.transform);
}); })
// Apply zoom to the SVG element - this enables wheel zoom // Apply zoom to the SVG element - this enables wheel zoom
svg.call(zoom); svg.call(zoom);
svg.call(zoom.scaleBy, initialzoom);
// Also apply zoom to container to catch events that might not reach SVG // Also apply zoom to container to catch events that might not reach SVG
// This ensures drag-to-pan works even when clicking on empty space // This ensures drag-to-pan works even when clicking on empty space
container.call(zoom); container.call(zoom);
@ -581,11 +596,11 @@ function draw(pathway, elem) {
for (idx in parents) { for (idx in parents) {
p = nodes[parents[idx]] p = nodes[parents[idx]]
// console.log(p.depth) // console.log(p.depth)
if (p.depth >= n.depth) { // if (p.depth >= n.depth) {
// keep the .5 steps for pseudo nodes // // keep the .5 steps for pseudo nodes
n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1); // n.depth = n.pseudo ? p.depth + 1 : Math.floor(p.depth + 1);
// console.log("Adjusting", orig_depth, Math.floor(p.depth + 1)); // // console.log("Adjusting", orig_depth, Math.floor(p.depth + 1));
} // }
} }
}); });
@ -599,16 +614,21 @@ function draw(pathway, elem) {
// Kanten zeichnen // Kanten zeichnen
const link = zoomable.append("g") const link = zoomable.append("g")
.selectAll("line") .selectAll("path")
.data(links) .data(links)
.enter().append("line") .enter().append("path")
// 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 ? '' : d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)') .attr("marker-end", d => {
if (d.target.pseudo) return '';
if (d.source.id === d.target.id) return 'url(#curve-arrow)'; // Use curve arrow for self-loops
return d.multi_step ? 'url(#doublearrow)' : 'url(#arrow)';
})
.attr("fill", "none")
.on("click", function(event, d) { .on("click", function(event, d) {
const wasHighlighted = d3.select(this).classed("highlighted"); const wasHighlighted = d3.select(this).classed("highlighted");
d3.selectAll("line").classed("highlighted", false); d3.selectAll("path").classed("highlighted", false);
if (!wasHighlighted) { if (!wasHighlighted) {
const toHighlight = []; const toHighlight = [];
@ -616,13 +636,13 @@ function draw(pathway, elem) {
if (d.source.pseudo || d.target.pseudo) { if (d.source.pseudo || d.target.pseudo) {
if (d.target.pseudo) { if (d.target.pseudo) {
d3.selectAll("line").each(e => { d3.selectAll("path").each(e => {
if (e !== undefined && e.source.id === d.target.id) { if (e !== undefined && e.source.id === d.target.id) {
toHighlight.push(e.el); toHighlight.push(e.el);
} }
}); });
} else { } else {
d3.selectAll("line").each(e => { d3.selectAll("path").each(e => {
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) { if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
toHighlight.push(e.el); toHighlight.push(e.el);
} }

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 }} perPage: {{ per_page|default:50 }}
})" })"
> >
{% 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 %} {% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
{% endif %}
</div> </div>
{% else %} {% else %}
{# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #} {# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #}

View File

@ -123,6 +123,17 @@
</div> </div>
</template> </template>
<!-- DOI link widget -->
<template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'doi-link'"
>
<div
x-data="doiWidget(fieldName, data, schema, uiSchema, mode, debugErrors, context)"
>
{% include "components/widgets/doi_link_widget.html" %}
</div>
</template>
<!-- Compound link widget --> <!-- Compound link widget -->
<template <template
x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'" x-if="getWidget(fieldName, schema.properties[fieldName]) === 'compound-link'"

View File

@ -0,0 +1,69 @@
{# DOI link widget - pure HTML template #}
<div class="form-control">
<div class="flex flex-col gap-2 sm:flex-row sm:items-baseline">
<!-- Label -->
<label class="label sm:w-48 sm:shrink-0">
<span
class="label-text"
:class="{
'text-error': $store.validationErrors.hasError(fieldName, context),
'text-sm text-base-content/60': isViewMode
}"
x-text="label"
></span>
</label>
<!-- Input column -->
<div class="flex-1">
<!-- Help text -->
<template x-if="helpText">
<div class="label">
<span
class="label-text-alt text-base-content/60"
x-text="helpText"
></span>
</div>
</template>
<!-- View mode: display as link -->
<template x-if="isViewMode">
<div class="mt-1">
<template x-if="value && doiUrl">
<a
:href="doiUrl"
class="link link-primary"
target="_blank"
x-text="value"
></a>
</template>
<template x-if="!value">
<span class="text-base-content/50"></span>
</template>
</div>
</template>
<!-- Edit mode -->
<template x-if="isEditMode">
<input
type="text"
class="input input-bordered w-full"
:class="{ 'input-error': $store.validationErrors.hasError(fieldName, context) }"
placeholder="DOI e.g. 10.1016/j.jhazmat.2016.08.036"
x-model="value"
/>
</template>
<!-- Errors -->
<template x-if="$store.validationErrors.hasError(fieldName, context)">
<div class="label">
<template
x-for="errMsg in $store.validationErrors.getErrors(fieldName, context)"
:key="errMsg"
>
<span class="label-text-alt text-error" x-text="errMsg"></span>
</template>
</div>
</template>
</div>
</div>
</div>

View File

@ -44,6 +44,7 @@
:class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }" :class="{ 'select-error': $store.validationErrors.hasError(fieldName, context) }"
x-model="value" x-model="value"
:multiple="multiple" :multiple="multiple"
:required="isRequired"
> >
<option value="" :selected="!value">Select...</option> <option value="" :selected="!value">Select...</option>

View File

@ -65,11 +65,11 @@
}, },
get showMlrr() { get showMlrr() {
return this.selectedType === 'mlrr'; return this.selectedType === 'ml-relative-reasoning';
}, },
get showRbrr() { get showRbrr() {
return this.selectedType === 'rbrr'; return this.selectedType === 'rule-based-relative-reasoning';
}, },
get showEnviformer() { get showEnviformer() {

View File

@ -5,8 +5,8 @@
class="modal" class="modal"
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })" x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
@modal-opened.window=" @modal-opened.window="
const links = d3.selectAll('line.highlighted'); const links = d3.selectAll('path.highlighted');
console.log(links);
if (!links.empty()) { if (!links.empty()) {
const el = links.node(); const el = links.node();
const selectElement = document.getElementById('delete_pathway_edge_edges'); const selectElement = document.getElementById('delete_pathway_edge_edges');

View File

@ -56,7 +56,7 @@
<ul class="menu bg-base-200 rounded-box"> <ul class="menu bg-base-200 rounded-box">
{% for um in group.user_member.all %} {% for um in group.user_member.all %}
<li> <li>
<a href="{{ um.url }}" class="hover:bg-base-300" <a href="{% if not user.is_superuser %}{{ um.url }}{% else %}{{ "#" }}{% endif %}" class="hover:bg-base-300"
>{{ um.username }} >{{ um.username }}
{% if not um.is_active %}<i>(inactive)</i>{% endif %}</a {% if not um.is_active %}<i>(inactive)</i>{% endif %}</a
> >

View File

@ -367,6 +367,18 @@
> >
<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="curve-arrow"
viewBox="0 0 10 10"
refX="10"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#999" />
</marker>
<marker <marker
id="doublearrow" id="doublearrow"
viewBox="0 0 20 30" viewBox="0 0 20 30"

View File

@ -105,7 +105,7 @@
></iframe> ></iframe>
</div> </div>
<label class="select mb-8 w-full"> <label class="select mb-8 w-full" id="prediction-setting-label">
<span class="label">Predictor</span> <span class="label">Predictor</span>
<select id="prediction-setting" name="prediction-setting"> <select id="prediction-setting" name="prediction-setting">
<option disabled>Select a Setting</option> <option disabled>Select a Setting</option>
@ -148,6 +148,22 @@
</div> </div>
{# prettier-ignore-start #} {# prettier-ignore-start #}
<script> <script>
// Hide predictor selection and update button text if mode is "build"
function radioChange(event) {
if (event.target.value === "build") {
document.getElementById("prediction-setting-label").hidden = true;
document.getElementById("predict-submit-button").innerText = "Build";
} else {
document.getElementById("prediction-setting-label").hidden = false;
document.getElementById("predict-submit-button").innerText = "Predict";
}
}
const radioButtons = document.querySelectorAll('input[name="predict"]');
radioButtons.forEach(radio => {
radio.addEventListener('change', radioChange);
});
// Helper function to safely get Ketcher instance from iframe // Helper function to safely get Ketcher instance from iframe
function getKetcherInstance(iframeId) { function getKetcherInstance(iframeId) {
const ketcherFrame = document.getElementById(iframeId); const ketcherFrame = document.getElementById(iframeId);

View File

@ -110,8 +110,6 @@
<div <div
class="text-base-content/50 flex items-center justify-center space-x-6 text-sm" class="text-base-content/50 flex items-center justify-center space-x-6 text-sm"
> >
<a href="/legal" class="link link-hover">Legal</a>
<span class="text-base-content/30"></span>
<a href="/terms" class="link link-hover">Terms of Use</a> <a href="/terms" class="link link-hover">Terms of Use</a>
<span class="text-base-content/30"></span> <span class="text-base-content/30"></span>
<a href="/privacy" class="link link-hover">Privacy Policy</a> <a href="/privacy" class="link link-hover">Privacy Policy</a>

View File

@ -64,7 +64,7 @@
import logging import logging
from envipy_additional_information import HalfLife, HalfLifeWS from envipy_additional_information import HalfLife, HalfLifeWS, HalfLifeModel
from envipy_additional_information.information import Interval from envipy_additional_information.information import Interval
from envipy_additional_information.parsers import ( from envipy_additional_information.parsers import (
AcidityParser, AcidityParser,
@ -125,6 +125,7 @@ from envipy_additional_information.parsers import (
LiquidMatrixSourceParser, LiquidMatrixSourceParser,
OxygenUptakeRateParser, OxygenUptakeRateParser,
InitiatingOrganismParser, InitiatingOrganismParser,
PFASConfidenceParser,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -141,7 +142,7 @@ def get_parameter(request, paramname):
res = request.POST.get(paramname) res = request.POST.get(paramname)
if res is not None and res.strip() != "": if res is not None and res.strip() != "":
return res return res
return ValueError("Not all parameters are set!") raise ValueError("Not all parameters are set!")
def get_parameter_or_empty_string(request, paramname): def get_parameter_or_empty_string(request, paramname):
@ -473,17 +474,12 @@ def build_additional_information_from_request(request, type_):
comment = get_parameter_or_empty_string(request, "comment") comment = get_parameter_or_empty_string(request, "comment")
source = get_parameter_or_empty_string(request, "source") source = get_parameter_or_empty_string(request, "source")
first_order = get_parameter_or_empty_string(request, "firstOrder") # first_order = get_parameter_or_empty_string(request, "firstOrder")
model = get_parameter_or_empty_string(request, "model") model = get_parameter_or_empty_string(request, "model")
fit = get_parameter_or_empty_string(request, "fit") fit = get_parameter_or_empty_string(request, "fit")
if first_order != "": if model:
if model != "": model = HalfLifeModel(model.upper())
raise ValueError("not both, model and firstOrder can be set!")
if first_order == "true":
model = "SFO"
else:
logger.info("firstOrder is set to false which is not meaningful")
return HalfLife(model=model, fit=fit, comment=comment, dt50=i, source=source) return HalfLife(model=model, fit=fit, comment=comment, dt50=i, source=source)
@ -508,6 +504,10 @@ def build_additional_information_from_request(request, type_):
comment_ws = get_parameter_or_empty_string(request, "comment_ws") comment_ws = get_parameter_or_empty_string(request, "comment_ws")
source_ws = get_parameter_or_empty_string(request, "source_ws") source_ws = get_parameter_or_empty_string(request, "source_ws")
model_ws = get_parameter_or_empty_string(request, "model_ws") model_ws = get_parameter_or_empty_string(request, "model_ws")
if model_ws:
model_ws = HalfLifeModel(model_ws.upper())
fit_ws = get_parameter_or_empty_string(request, "fit_ws") fit_ws = get_parameter_or_empty_string(request, "fit_ws")
dt50_total = IntervalParser.from_string(hl_ws_total) dt50_total = IntervalParser.from_string(hl_ws_total)
@ -674,7 +674,8 @@ def build_additional_information_from_request(request, type_):
elif type_ == "studywst": elif type_ == "studywst":
# study_wst_cond = get_parameter(request, "studywstcond") # study_wst_cond = get_parameter(request, "studywstcond")
raise ValueError("studywstcond is not yet implemented") raise ValueError("studywstcond is not yet implemented")
elif type_ == "pfasconfidence":
return PFASConfidenceParser.from_string(get_parameter(request, "level"))
else: else:
raise ValueError(f"No corresponding AdditionalInformation for {type_} found!") raise ValueError(f"No corresponding AdditionalInformation for {type_} found!")

34
uv.lock generated
View File

@ -894,7 +894,7 @@ provides-extras = ["ms-login", "dev", "pepper-plugin"]
[[package]] [[package]]
name = "envipy-additional-information" name = "envipy-additional-information"
version = "0.4.2" version = "0.4.2"
source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#0a608c85c73a6ef5c38afea87d2b57fb43f01a70" } source = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git?branch=develop#f2f251e0214f016760348730c45e56183d961201" }
dependencies = [ dependencies = [
{ name = "pydantic" }, { name = "pydantic" },
] ]
@ -2763,9 +2763,9 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, { name = "typing-extensions", marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
] ]
wheels = [ wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54", upload-time = "2025-10-01T23:35:50Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:fbe2e149c5174ef90d29a5f84a554dfaf28e003cb4f61fa2c8c024c17ec7ca58" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:fbe2e149c5174ef90d29a5f84a554dfaf28e003cb4f61fa2c8c024c17ec7ca58", upload-time = "2025-10-01T23:35:52Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:057efd30a6778d2ee5e2374cd63a63f63311aa6f33321e627c655df60abdd390" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:057efd30a6778d2ee5e2374cd63a63f63311aa6f33321e627c655df60abdd390", upload-time = "2025-10-01T23:35:55Z" },
] ]
[[package]] [[package]]
@ -2785,19 +2785,19 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
] ]
wheels = [ wheels = [
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5", upload-time = "2025-10-01T23:33:41Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d", upload-time = "2025-10-01T23:33:45Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e", upload-time = "2025-10-01T23:33:48Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d", upload-time = "2025-10-01T23:33:52Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434", upload-time = "2025-10-01T23:34:10Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:8b5882276633cf91fe3d2d7246c743b94d44a7e660b27f1308007fdb1bb89f7d" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:8b5882276633cf91fe3d2d7246c743b94d44a7e660b27f1308007fdb1bb89f7d", upload-time = "2025-10-01T23:34:15Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a5064b5e23772c8d164068cc7c12e01a75faf7b948ecd95a0d4007d7487e5f25" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a5064b5e23772c8d164068cc7c12e01a75faf7b948ecd95a0d4007d7487e5f25", upload-time = "2025-10-01T23:34:19Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8f81dedb4c6076ec325acc3b47525f9c550e5284a18eae1d9061c543f7b6e7de" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8f81dedb4c6076ec325acc3b47525f9c550e5284a18eae1d9061c543f7b6e7de", upload-time = "2025-10-01T23:34:23Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:e1ee1b2346ade3ea90306dfbec7e8ff17bc220d344109d189ae09078333b0856" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:e1ee1b2346ade3ea90306dfbec7e8ff17bc220d344109d189ae09078333b0856", upload-time = "2025-10-01T23:34:28Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:64c187345509f2b1bb334feed4666e2c781ca381874bde589182f81247e61f88" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:64c187345509f2b1bb334feed4666e2c781ca381874bde589182f81247e61f88", upload-time = "2025-10-01T23:34:45Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af81283ac671f434b1b25c95ba295f270e72db1fad48831eb5e4748ff9840041" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:af81283ac671f434b1b25c95ba295f270e72db1fad48831eb5e4748ff9840041", upload-time = "2025-10-01T23:34:50Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a9dbb6f64f63258bc811e2c0c99640a81e5af93c531ad96e95c5ec777ea46dab" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a9dbb6f64f63258bc811e2c0c99640a81e5af93c531ad96e95c5ec777ea46dab", upload-time = "2025-10-01T23:34:53Z" },
{ url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:6d93a7165419bc4b2b907e859ccab0dea5deeab261448ae9a5ec5431f14c0e64" }, { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:6d93a7165419bc4b2b907e859ccab0dea5deeab261448ae9a5ec5431f14c0e64", upload-time = "2025-10-01T23:34:58Z" },
] ]
[[package]] [[package]]