forked from enviPath/enviPy
Compare commits
25 Commits
689f7998eb
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 72a63b4876 | |||
| 2c2437e3f5 | |||
| 9bc9f86ff1 | |||
| dba6514013 | |||
| a092d4a558 | |||
| 2502c020f7 | |||
| 6ab9180291 | |||
| 3657d14659 | |||
| ef6091d416 | |||
| 14cfc1e4d7 | |||
| 868bbf5c05 | |||
| be5ee1d1d7 | |||
| 20fd949dfd | |||
| c9b643fe6e | |||
| 1a9f1cf9af | |||
| c7c7e17e43 | |||
| 674e10c7fa | |||
| 8079b80d57 | |||
| 76e63fda2c | |||
| 1e43c298d2 | |||
| b39fc7eaf8 | |||
| a2fc9f72cb | |||
| 734b02767e | |||
| 9d70db2ca2 | |||
| fec26d0233 |
15
Dockerfile
15
Dockerfile
@ -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}"
|
||||||
@ -32,7 +37,7 @@ RUN mkdir -p -m 0700 /root/.ssh \
|
|||||||
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
|
# We'll need access to private repos, let docker make use of host ssh agent and use it like:
|
||||||
# docker build --ssh default -t envipath/envipy:1.0 .
|
# docker build --ssh default -t envipath/envipy:1.0 .
|
||||||
RUN --mount=type=ssh \
|
RUN --mount=type=ssh \
|
||||||
uv sync --locked --extra ms-login --extra pepper-plugin
|
uv sync --locked --extra pepper-plugin
|
||||||
|
|
||||||
# Now copy source and do a final sync to install the project itself
|
# Now copy source and do a final sync to install the project itself
|
||||||
# Ensure .dockerignore is reasonable
|
# Ensure .dockerignore is reasonable
|
||||||
|
|||||||
@ -254,7 +254,14 @@ class Classifier(Plugin):
|
|||||||
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
|
def parse_config(cls, data: dict | None = None) -> EnviPyModel | None:
|
||||||
if cls.Config is None:
|
if cls.Config is None:
|
||||||
return None
|
return None
|
||||||
return cls.Config(**(data or {}))
|
|
||||||
|
# remove empty strings a.k.a unset params to not overwrite defaults
|
||||||
|
cpy = {}
|
||||||
|
if data is not None:
|
||||||
|
for k, v in data.items():
|
||||||
|
if v != "":
|
||||||
|
cpy[k] = v
|
||||||
|
return cls.Config(**cpy)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, data: dict | None = None):
|
def create(cls, data: dict | None = None):
|
||||||
|
|||||||
@ -117,25 +117,28 @@ class APIPermissionTestBase(TestCase):
|
|||||||
|
|
||||||
# Create test compounds in each package
|
# Create test compounds in each package
|
||||||
cls.reviewed_compound = Compound.create(
|
cls.reviewed_compound = Compound.create(
|
||||||
cls.reviewed_package, "C", "Reviewed Compound", "Test compound"
|
cls.reviewed_package, "C", name="Reviewed Compound", description="Test compound"
|
||||||
)
|
)
|
||||||
cls.owned_compound = Compound.create(
|
cls.owned_compound = Compound.create(
|
||||||
cls.unreviewed_package_owned, "CC", "Owned Compound", "Test compound"
|
cls.unreviewed_package_owned, "CC", name="Owned Compound", description="Test compound"
|
||||||
)
|
)
|
||||||
cls.read_compound = Compound.create(
|
cls.read_compound = Compound.create(
|
||||||
cls.unreviewed_package_read, "CCC", "Read Compound", "Test compound"
|
cls.unreviewed_package_read, "CCC", name="Read Compound", description="Test compound"
|
||||||
)
|
)
|
||||||
cls.write_compound = Compound.create(
|
cls.write_compound = Compound.create(
|
||||||
cls.unreviewed_package_write, "CCCC", "Write Compound", "Test compound"
|
cls.unreviewed_package_write, "CCCC", name="Write Compound", description="Test compound"
|
||||||
)
|
)
|
||||||
cls.all_compound = Compound.create(
|
cls.all_compound = Compound.create(
|
||||||
cls.unreviewed_package_all, "CCCCC", "All Compound", "Test compound"
|
cls.unreviewed_package_all, "CCCCC", name="All Compound", description="Test compound"
|
||||||
)
|
)
|
||||||
cls.no_access_compound = Compound.create(
|
cls.no_access_compound = Compound.create(
|
||||||
cls.unreviewed_package_no_access, "CCCCCC", "No Access Compound", "Test compound"
|
cls.unreviewed_package_no_access,
|
||||||
|
"CCCCCC",
|
||||||
|
name="No Access Compound",
|
||||||
|
description="Test compound",
|
||||||
)
|
)
|
||||||
cls.group_compound = Compound.create(
|
cls.group_compound = Compound.create(
|
||||||
cls.group_package, "CCCCCCC", "Group Compound", "Test compound"
|
cls.group_package, "CCCCCCC", name="Group Compound", description="Test compound"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -294,8 +294,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
|
|||||||
return Compound.create(
|
return Compound.create(
|
||||||
package,
|
package,
|
||||||
smiles,
|
smiles,
|
||||||
f"Reviewed Compound {idx:03d}",
|
name=f"Reviewed Compound {idx:03d}",
|
||||||
"Compound for pagination tests",
|
description="Compound for pagination tests",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -305,8 +305,8 @@ class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
|
|||||||
return Compound.create(
|
return Compound.create(
|
||||||
package,
|
package,
|
||||||
smiles,
|
smiles,
|
||||||
f"Draft Compound {idx:03d}",
|
name=f"Draft Compound {idx:03d}",
|
||||||
"Compound for pagination tests",
|
description="Compound for pagination tests",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
epapi/v1/endpoints/joblogs.py
Normal file
26
epapi/v1/endpoints/joblogs.py
Normal 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")
|
||||||
@ -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
|
||||||
|
|||||||
@ -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})
|
||||||
|
|||||||
@ -87,6 +87,11 @@ def get_access_token_from_request(request, scopes=None):
|
|||||||
"""
|
"""
|
||||||
Get an access token from the request using MSAL token cache.
|
Get an access token from the request using MSAL token cache.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Check if auth via Access Token
|
||||||
|
if request.headers.get("Authorization"):
|
||||||
|
return {"access_token": request.headers.get("Authorization").split(" ")[1]}
|
||||||
|
|
||||||
if scopes is None:
|
if scopes is None:
|
||||||
scopes = s.MS_ENTRA_SCOPES
|
scopes = s.MS_ENTRA_SCOPES
|
||||||
|
|
||||||
|
|||||||
112
epdb/admin.py
112
epdb/admin.py
@ -1,5 +1,8 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
AdditionalInformation,
|
AdditionalInformation,
|
||||||
@ -29,6 +32,8 @@ from .models import (
|
|||||||
|
|
||||||
Package = s.GET_PACKAGE_MODEL()
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AdditionalInformationAdmin(admin.ModelAdmin):
|
class AdditionalInformationAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@ -45,6 +50,113 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
"date_joined",
|
"date_joined",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
actions = ["send_welcome_mail", "send_affiliation_mail"]
|
||||||
|
|
||||||
|
@admin.action(description="Send welcome mail")
|
||||||
|
def send_welcome_mail(self, request, queryset):
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
|
||||||
|
tpl = """Hello {username},
|
||||||
|
|
||||||
|
Your account has been successfully activated.
|
||||||
|
|
||||||
|
To log in, please visit
|
||||||
|
https://envipath.org/password_reset/
|
||||||
|
and request a new password.
|
||||||
|
|
||||||
|
If you have any questions or feedback, feel free to visit our community forum at
|
||||||
|
https://community.envipath.org/.
|
||||||
|
You do not need to register again for the forum - you can log in using your enviPath account by clicking "Log In" and then "Log in with enviPath."
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
|
||||||
|
The enviPath Team"""
|
||||||
|
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for user in queryset:
|
||||||
|
if user.is_active:
|
||||||
|
logger.info(f"{user.username} already active - not sending mail again")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
"Your enviPath Account Is Now Active",
|
||||||
|
tpl.format(username=user.username),
|
||||||
|
"admin@envipath.org",
|
||||||
|
[user.email],
|
||||||
|
bcc=["admin@envipath.org"],
|
||||||
|
)
|
||||||
|
|
||||||
|
msg.send(fail_silently=False)
|
||||||
|
|
||||||
|
user.is_active = True
|
||||||
|
user.password = "ASDF"
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
users.append(user)
|
||||||
|
logger.info(f"{user.username} -> {user.email} mail sent")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Error sending mail to {user.username}: {e}")
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request, f"Sent welcome mail to {[u.email for u in users]}", messages.SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Send affiliation mail")
|
||||||
|
def send_affiliation_mail(self, request, queryset):
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
|
||||||
|
tpl = """Dear {username},
|
||||||
|
|
||||||
|
Thank you for your interest in enviPath!
|
||||||
|
|
||||||
|
Please note that the public enviPath system is intended for non-commercial use only.
|
||||||
|
We see that you registered using the email address {email}.
|
||||||
|
If possible, we kindly ask you to register using an official email address that reflects your affiliation (e.g., a university, NGO, or research organization).
|
||||||
|
|
||||||
|
If you would like us to update your account, simply reply to this email and let us know which address we should use.
|
||||||
|
We will then change it in our system, and you will receive a password reset email at the new address.
|
||||||
|
|
||||||
|
If you are registering with a company email address and are interested in commercial use, you are very welcome to book a meeting with us so we can discuss how we can best support you.
|
||||||
|
To book a meeting, please visit https://envipath.com/book
|
||||||
|
|
||||||
|
If changing to an affiliation email address is not possible, please contact us at registration@envipath.org
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
|
||||||
|
enviPath team"""
|
||||||
|
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for user in queryset:
|
||||||
|
if user.is_active or user.contacted:
|
||||||
|
logger.info(
|
||||||
|
f"{user.username} already active or already contacted - not sending mail again"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
"Regarding your enviPath registration",
|
||||||
|
tpl.format(username=user.username, email=user.email),
|
||||||
|
"admin@envipath.org",
|
||||||
|
[user.email],
|
||||||
|
bcc=["admin@envipath.org"],
|
||||||
|
)
|
||||||
|
|
||||||
|
msg.send(fail_silently=False)
|
||||||
|
|
||||||
|
user.contacted = True
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
users.append(user)
|
||||||
|
logger.info(f"{user.username} -> {user.email} affiliation mail sent")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(f"Error sending mail to {user.username}: {e}")
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request, f"Sent affiliation mail to {[u.email for u in users]}", messages.SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserPackagePermissionAdmin(admin.ModelAdmin):
|
class UserPackagePermissionAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|||||||
10
epdb/exceptions.py
Normal file
10
epdb/exceptions.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
class InvalidSMILESException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidMolfileException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PackageImportException(Exception):
|
||||||
|
pass
|
||||||
@ -386,23 +386,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):
|
||||||
@ -764,6 +791,7 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
|
|||||||
|
|
||||||
class CreateCompound(Schema):
|
class CreateCompound(Schema):
|
||||||
compoundSmiles: str
|
compoundSmiles: str
|
||||||
|
compoundMolFile: str | None = None
|
||||||
compoundName: str | None = None
|
compoundName: str | None = None
|
||||||
compoundDescription: str | None = None
|
compoundDescription: str | None = None
|
||||||
inchi: str | None = None
|
inchi: str | None = None
|
||||||
@ -777,9 +805,13 @@ def create_package_compound(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
p = get_package_for_write(request.user, package_uuid)
|
p = get_package_for_write(request.user, package_uuid)
|
||||||
# inchi is not used atm
|
|
||||||
c = Compound.create(
|
c = Compound.create(
|
||||||
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
|
p,
|
||||||
|
c.compoundSmiles,
|
||||||
|
molfile=c.compoundMolFile,
|
||||||
|
name=c.compoundName,
|
||||||
|
description=c.compoundDescription,
|
||||||
|
inchi=c.inchi,
|
||||||
)
|
)
|
||||||
return redirect(c.url)
|
return redirect(c.url)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@ -799,6 +831,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}"
|
||||||
)
|
)
|
||||||
@ -1325,6 +1378,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"
|
||||||
@ -1468,6 +1522,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()
|
||||||
|
|
||||||
@ -1491,6 +1546,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:
|
||||||
@ -1533,13 +1615,14 @@ class PathwayNode(Schema):
|
|||||||
dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
|
dt50s: List[Dict[str, str]] = Field([], alias="dt50s")
|
||||||
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
|
engineeredIntermediate: bool = Field(None, alias="engineered_intermediate")
|
||||||
id: str = Field(None, alias="url")
|
id: str = Field(None, alias="url")
|
||||||
idcomp: str = Field(None, alias="default_node_label.url")
|
idcomp: str = Field(None, alias="node_label_id")
|
||||||
idreact: str = Field(None, alias="default_node_label.url")
|
idreact: str = Field(None, alias="node_label_id")
|
||||||
image: str = Field(None, alias="image")
|
image: str = Field(None, alias="image")
|
||||||
imageSize: int = Field(None, alias="image_size")
|
imageSize: int = Field(None, alias="image_size")
|
||||||
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="smiles")
|
||||||
|
pseudo: bool = Field(False, alias="pseudo")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_atom_count(obj: Node):
|
def resolve_atom_count(obj: Node):
|
||||||
@ -1552,24 +1635,10 @@ class PathwayNode(Schema):
|
|||||||
# TODO
|
# TODO
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_engineered_intermediate(obj: Node):
|
|
||||||
# TODO
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_image(obj: Node):
|
|
||||||
return f"{obj.default_node_label.url}?image=svg"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_image_size(obj: Node):
|
def resolve_image_size(obj: Node):
|
||||||
return 400
|
return 400
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def resolve_proposed_intermediate(obj: Node):
|
|
||||||
# TODO
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class PathwaySchema(Schema):
|
class PathwaySchema(Schema):
|
||||||
aliases: List[str] = Field([], alias="aliases")
|
aliases: List[str] = Field([], alias="aliases")
|
||||||
@ -1693,6 +1762,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:
|
||||||
@ -1798,6 +1890,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
|
|||||||
|
|
||||||
class CreateNode(Schema):
|
class CreateNode(Schema):
|
||||||
nodeAsSmiles: str
|
nodeAsSmiles: str
|
||||||
|
nodeAsMolFile: str | None = None
|
||||||
nodeName: str | None = None
|
nodeName: str | None = None
|
||||||
nodeReason: str | None = None
|
nodeReason: str | None = None
|
||||||
nodeDepth: str | None = None
|
nodeDepth: str | None = None
|
||||||
@ -1813,11 +1906,18 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
|
|||||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||||
|
|
||||||
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
|
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
|
||||||
node_depth = int(n.nodeDepth)
|
node_depth = int(float(n.nodeDepth))
|
||||||
else:
|
else:
|
||||||
node_depth = -1
|
node_depth = -1
|
||||||
|
|
||||||
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
|
n = Node.create(
|
||||||
|
pw,
|
||||||
|
n.nodeAsSmiles,
|
||||||
|
node_depth,
|
||||||
|
molfile=n.nodeAsMolFile,
|
||||||
|
name=n.nodeName,
|
||||||
|
description=n.nodeReason,
|
||||||
|
)
|
||||||
|
|
||||||
return redirect(n.url)
|
return redirect(n.url)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -1958,6 +2058,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,
|
||||||
@ -1965,8 +2069,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!"}
|
||||||
@ -2160,3 +2268,26 @@ def get_setting(request, setting_uuid):
|
|||||||
return 403, {
|
return 403, {
|
||||||
"message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!"
|
"message": f"Getting Setting with id {setting_uuid} failed due to insufficient rights!"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
########
|
||||||
|
# Util #
|
||||||
|
########
|
||||||
|
class NonPersistent(Schema):
|
||||||
|
smiles: str
|
||||||
|
setting_url: str = Field(..., alias="settingUri")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/util", response={200: Any, 403: Error})
|
||||||
|
def predict(request, np: Form[NonPersistent]):
|
||||||
|
try:
|
||||||
|
from epdb.logic import SPathway
|
||||||
|
|
||||||
|
setting = SettingManager.get_setting_by_url(request.user, np.setting_url)
|
||||||
|
spw = SPathway(prediction_setting=setting, root_nodes=[np.smiles])
|
||||||
|
spw.predict()
|
||||||
|
return spw.to_json()
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|||||||
@ -995,52 +995,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
|
||||||
@ -1054,7 +1011,7 @@ class PackageManager(object):
|
|||||||
add_import_timestamp=True,
|
add_import_timestamp=True,
|
||||||
trust_reviewed=False,
|
trust_reviewed=False,
|
||||||
) -> Package:
|
) -> Package:
|
||||||
importer = PackageImporter(data, preserve_uuids, add_import_timestamp, trust_reviewed)
|
importer = PackageImporter(data, preserve_uuids)
|
||||||
imported_package = importer.do_import()
|
imported_package = importer.do_import()
|
||||||
|
|
||||||
up = UserPackagePermission()
|
up = UserPackagePermission()
|
||||||
@ -1871,6 +1828,11 @@ class SPathway(object):
|
|||||||
"to": to_indices,
|
"to": to_indices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if edge.rule:
|
||||||
|
e["rule"] = edge.rule.simple_json()
|
||||||
|
if edge.probability:
|
||||||
|
e["probability"] = edge.probability
|
||||||
|
|
||||||
edges.append(e)
|
edges.append(e)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -99,11 +99,15 @@ class Command(BaseCommand):
|
|||||||
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
|
new_license.image_link = f"https://licensebuttons.net/l/{cc_string}/4.0/88x31.png"
|
||||||
new_license.save()
|
new_license.save()
|
||||||
|
|
||||||
def import_package(self, data, owner):
|
def import_package(self, data, owner, all_envipath_user_group):
|
||||||
return PackageManager.import_legacy_package(
|
p = PackageManager.import_legacy_package(
|
||||||
data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True
|
data, owner, keep_ids=True, add_import_timestamp=False, trust_reviewed=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PackageManager.grant_read(owner, p, all_envipath_user_group)
|
||||||
|
|
||||||
|
return p
|
||||||
|
|
||||||
def create_default_setting(self, owner, packages):
|
def create_default_setting(self, owner, packages):
|
||||||
s = SettingManager.create_setting(
|
s = SettingManager.create_setting(
|
||||||
owner,
|
owner,
|
||||||
@ -198,7 +202,7 @@ class Command(BaseCommand):
|
|||||||
s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8"
|
s.BASE_DIR / "fixtures" / "packages" / "2025-07-18" / p, encoding="utf-8"
|
||||||
).read()
|
).read()
|
||||||
)
|
)
|
||||||
imported_package = self.import_package(package_data, admin)
|
imported_package = self.import_package(package_data, admin, g)
|
||||||
mapping[p.replace(".json", "")] = imported_package
|
mapping[p.replace(".json", "")] = imported_package
|
||||||
|
|
||||||
setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]])
|
setting = self.create_default_setting(admin, [mapping["EAWAG-BBD"]])
|
||||||
|
|||||||
17
epdb/migrations/0024_user_contacted.py
Normal file
17
epdb/migrations/0024_user_contacted.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-04-21 19:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("epdb", "0023_alter_compoundstructure_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="contacted",
|
||||||
|
field=models.BooleanField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
56
epdb/migrations/0025_auto_20260511_2025.py
Normal file
56
epdb/migrations/0025_auto_20260511_2025.py
Normal 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),
|
||||||
|
]
|
||||||
48
epdb/migrations/0026_auto_20260602_1718.py
Normal file
48
epdb/migrations/0026_auto_20260602_1718.py
Normal 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),
|
||||||
|
]
|
||||||
150
epdb/migrations/0027_alter_compound_aliases_and_more.py
Normal file
150
epdb/migrations/0027_alter_compound_aliases_and_more.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# Generated by Django 6.0.3 on 2026-07-01 20:59
|
||||||
|
|
||||||
|
import django.contrib.postgres.fields
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("epdb", "0026_auto_20260602_1718"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="compound",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="compound",
|
||||||
|
name="default_structure",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="compound_default_structure",
|
||||||
|
to="epdb.compoundstructure",
|
||||||
|
verbose_name="Default Structure",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="compound",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="compoundstructure",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="compoundstructure",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edge",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edge",
|
||||||
|
name="edge_label",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="epdb.reaction",
|
||||||
|
verbose_name="Edge label",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="edge",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="node",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="node",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="pathway",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="pathway",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="reaction",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="reaction",
|
||||||
|
name="medline_references",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(),
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Medline References",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="reaction",
|
||||||
|
name="rules",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, related_name="reaction_rule", to="epdb.rule", verbose_name="Rule"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="reaction",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="rule",
|
||||||
|
name="aliases",
|
||||||
|
field=django.contrib.postgres.fields.ArrayField(
|
||||||
|
base_field=models.TextField(), blank=True, default=list, verbose_name="Aliases"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="rule",
|
||||||
|
name="scenarios",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, to="epdb.scenario", verbose_name="Attached Scenarios"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
340
epdb/models.py
340
epdb/models.py
@ -31,6 +31,10 @@ from sklearn.model_selection import ShuffleSplit
|
|||||||
|
|
||||||
from bridge.contracts import Property
|
from bridge.contracts import Property
|
||||||
from bridge.dto import RunResult, PropertyPrediction
|
from bridge.dto import RunResult, PropertyPrediction
|
||||||
|
from epdb.exceptions import (
|
||||||
|
InvalidMolfileException,
|
||||||
|
InvalidSMILESException,
|
||||||
|
)
|
||||||
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
|
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
|
||||||
from utilities.ml import (
|
from utilities.ml import (
|
||||||
ApplicabilityDomainPCA,
|
ApplicabilityDomainPCA,
|
||||||
@ -75,6 +79,7 @@ class User(AbstractUser):
|
|||||||
blank=False,
|
blank=False,
|
||||||
)
|
)
|
||||||
is_reviewer = models.BooleanField(default=False)
|
is_reviewer = models.BooleanField(default=False)
|
||||||
|
contacted = models.BooleanField(null=True, blank=True)
|
||||||
|
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
REQUIRED_FIELDS = ["username"]
|
REQUIRED_FIELDS = ["username"]
|
||||||
@ -630,7 +635,7 @@ class EnviPathModel(TimeStampedModel):
|
|||||||
|
|
||||||
class AliasMixin(models.Model):
|
class AliasMixin(models.Model):
|
||||||
aliases = ArrayField(
|
aliases = ArrayField(
|
||||||
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list
|
models.TextField(blank=False, null=False), verbose_name="Aliases", default=list, blank=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@ -653,7 +658,9 @@ class AliasMixin(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class ScenarioMixin(models.Model):
|
class ScenarioMixin(models.Model):
|
||||||
scenarios = models.ManyToManyField("epdb.Scenario", verbose_name="Attached Scenarios")
|
scenarios = models.ManyToManyField(
|
||||||
|
"epdb.Scenario", verbose_name="Attached Scenarios", blank=True
|
||||||
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def set_scenarios(self, scenarios: List["Scenario"]):
|
def set_scenarios(self, scenarios: List["Scenario"]):
|
||||||
@ -779,12 +786,19 @@ class Compound(
|
|||||||
"CompoundStructure",
|
"CompoundStructure",
|
||||||
verbose_name="Default Structure",
|
verbose_name="Default Structure",
|
||||||
related_name="compound_default_structure",
|
related_name="compound_default_structure",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
external_identifiers = GenericRelation("ExternalIdentifier")
|
external_identifiers = GenericRelation("ExternalIdentifier")
|
||||||
|
|
||||||
|
def get_structure_by_smiles(self, smiles: str) -> "CompoundStructure":
|
||||||
|
for struct in self.structures.all():
|
||||||
|
if struct.smiles == smiles:
|
||||||
|
return struct
|
||||||
|
|
||||||
|
raise ValueError(f"No structure with SMILES {smiles} found for {self.get_name()}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def structures(self) -> QuerySet:
|
def structures(self) -> QuerySet:
|
||||||
return CompoundStructure.objects.filter(compound=self)
|
return CompoundStructure.objects.filter(compound=self)
|
||||||
@ -853,16 +867,32 @@ class Compound(
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create(
|
def create(
|
||||||
package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs
|
package: "Package",
|
||||||
|
smiles: str,
|
||||||
|
molfile: str | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
) -> "Compound":
|
) -> "Compound":
|
||||||
|
# Molfile has precendence over SMILES
|
||||||
|
if molfile is not None and molfile.strip() != "":
|
||||||
|
mol = FormatConverter.from_molfile(molfile)
|
||||||
|
|
||||||
|
if mol is None:
|
||||||
|
raise InvalidMolfileException("Given molfile is invalid")
|
||||||
|
else:
|
||||||
|
# Overwrite SMILES from molfile
|
||||||
|
smiles = FormatConverter.to_smiles(mol)
|
||||||
|
|
||||||
if smiles is None or smiles.strip() == "":
|
if smiles is None or smiles.strip() == "":
|
||||||
raise ValueError("SMILES is required")
|
raise InvalidSMILESException("SMILES is required")
|
||||||
|
|
||||||
smiles = smiles.strip()
|
smiles = smiles.strip()
|
||||||
|
|
||||||
parsed = FormatConverter.from_smiles(smiles)
|
parsed = FormatConverter.from_smiles(smiles)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
raise ValueError("Given SMILES is invalid")
|
raise InvalidSMILESException("Given SMILES is invalid")
|
||||||
|
|
||||||
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
standardized_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
||||||
|
|
||||||
@ -874,11 +904,16 @@ class Compound(
|
|||||||
if CompoundStructure.objects.filter(
|
if CompoundStructure.objects.filter(
|
||||||
smiles=standardized_smiles, compound__package=package
|
smiles=standardized_smiles, compound__package=package
|
||||||
).exists():
|
).exists():
|
||||||
# TODO should we add a structure?
|
found_c = CompoundStructure.objects.get(
|
||||||
return CompoundStructure.objects.get(
|
|
||||||
smiles=standardized_smiles, compound__package=package
|
smiles=standardized_smiles, compound__package=package
|
||||||
).compound
|
).compound
|
||||||
|
|
||||||
|
# As a direct match wasn't found add a new structure
|
||||||
|
# TODO return newly created structure
|
||||||
|
_ = found_c.add_structure(smiles, molfile=molfile, name=name, description=description)
|
||||||
|
|
||||||
|
return found_c
|
||||||
|
|
||||||
# Generate Compound
|
# Generate Compound
|
||||||
c = Compound()
|
c = Compound()
|
||||||
c.package = package
|
c.package = package
|
||||||
@ -910,7 +945,12 @@ class Compound(
|
|||||||
)
|
)
|
||||||
|
|
||||||
cs = CompoundStructure.create(
|
cs = CompoundStructure.create(
|
||||||
c, smiles, name=name, description=description, normalized_structure=is_standardized
|
c,
|
||||||
|
smiles,
|
||||||
|
molfile=molfile,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
normalized_structure=is_standardized,
|
||||||
)
|
)
|
||||||
|
|
||||||
c.default_structure = cs
|
c.default_structure = cs
|
||||||
@ -923,11 +963,22 @@ class Compound(
|
|||||||
self,
|
self,
|
||||||
smiles: str,
|
smiles: str,
|
||||||
name: str = None,
|
name: str = None,
|
||||||
|
molfile: str = None,
|
||||||
description: str = None,
|
description: str = None,
|
||||||
default_structure: bool = False,
|
default_structure: bool = False,
|
||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> "CompoundStructure":
|
) -> "CompoundStructure":
|
||||||
|
# Molfile has precendence over SMILES
|
||||||
|
if molfile is not None and molfile.strip() != "":
|
||||||
|
mol = FormatConverter.from_molfile(molfile)
|
||||||
|
|
||||||
|
if mol is None:
|
||||||
|
raise InvalidMolfileException("Given molfile is invalid")
|
||||||
|
else:
|
||||||
|
# Overwrite SMILES from molfile
|
||||||
|
smiles = FormatConverter.to_smiles(mol)
|
||||||
|
|
||||||
if smiles is None or smiles == "":
|
if smiles is None or smiles == "":
|
||||||
raise ValueError("SMILES is required")
|
raise ValueError("SMILES is required")
|
||||||
|
|
||||||
@ -947,16 +998,28 @@ class Compound(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if is_standardized:
|
if is_standardized:
|
||||||
CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package)
|
CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
|
||||||
|
|
||||||
# Check if we find a direct match for a given SMILES and/or its standardized SMILES
|
# Check if we find a direct match for a given SMILES and/or its standardized SMILES
|
||||||
if CompoundStructure.objects.filter(
|
if CompoundStructure.objects.filter(smiles=smiles, compound__package=self.package).exists():
|
||||||
smiles__in=smiles, compound__package=self.package
|
found_cs = CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
|
||||||
).exists():
|
|
||||||
return CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package)
|
if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""):
|
||||||
|
logger.info(
|
||||||
|
f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty"
|
||||||
|
)
|
||||||
|
found_cs.molfile = molfile
|
||||||
|
found_cs.save()
|
||||||
|
|
||||||
|
return found_cs
|
||||||
|
|
||||||
cs = CompoundStructure.create(
|
cs = CompoundStructure.create(
|
||||||
self, smiles, name=name, description=description, normalized_structure=is_standardized
|
self,
|
||||||
|
smiles,
|
||||||
|
name=name,
|
||||||
|
molfile=molfile,
|
||||||
|
description=description,
|
||||||
|
normalized_structure=is_standardized,
|
||||||
)
|
)
|
||||||
|
|
||||||
if default_structure:
|
if default_structure:
|
||||||
@ -1137,10 +1200,35 @@ class CompoundStructure(
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create(
|
def create(
|
||||||
compound: Compound, smiles: str, name: str = None, description: str = None, *args, **kwargs
|
compound: Compound,
|
||||||
|
smiles: str,
|
||||||
|
molfile: str = None,
|
||||||
|
name: str = None,
|
||||||
|
description: str = None,
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
|
# Molfile has precendence over SMILES
|
||||||
|
if molfile is not None and molfile.strip() != "":
|
||||||
|
mol = FormatConverter.from_molfile(molfile)
|
||||||
|
|
||||||
|
if mol is None:
|
||||||
|
raise InvalidMolfileException("Given molfile is invalid")
|
||||||
|
else:
|
||||||
|
# Overwrite SMILES from molfile
|
||||||
|
smiles = FormatConverter.to_smiles(mol)
|
||||||
|
|
||||||
if CompoundStructure.objects.filter(compound=compound, smiles=smiles).exists():
|
if CompoundStructure.objects.filter(compound=compound, smiles=smiles).exists():
|
||||||
return CompoundStructure.objects.get(compound=compound, smiles=smiles)
|
found_cs = CompoundStructure.objects.get(smiles=smiles, compound=compound)
|
||||||
|
|
||||||
|
if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""):
|
||||||
|
logger.info(
|
||||||
|
f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty"
|
||||||
|
)
|
||||||
|
found_cs.molfile = molfile
|
||||||
|
found_cs.save()
|
||||||
|
|
||||||
|
return found_cs
|
||||||
|
|
||||||
if compound.pk is None:
|
if compound.pk is None:
|
||||||
raise ValueError("Unpersisted Compound! Persist compound first!")
|
raise ValueError("Unpersisted Compound! Persist compound first!")
|
||||||
@ -1153,8 +1241,12 @@ class CompoundStructure(
|
|||||||
if description is not None:
|
if description is not None:
|
||||||
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
|
|
||||||
cs.smiles = smiles
|
|
||||||
cs.compound = compound
|
cs.compound = compound
|
||||||
|
cs.smiles = smiles
|
||||||
|
|
||||||
|
# Check if molfile is present and valid
|
||||||
|
if molfile is not None and molfile.strip() != "":
|
||||||
|
cs.molfile = molfile
|
||||||
|
|
||||||
if "normalized_structure" in kwargs:
|
if "normalized_structure" in kwargs:
|
||||||
cs.normalized_structure = kwargs["normalized_structure"]
|
cs.normalized_structure = kwargs["normalized_structure"]
|
||||||
@ -1173,6 +1265,8 @@ class CompoundStructure(
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def as_svg(self, width: int = 800, height: int = 400):
|
def as_svg(self, width: int = 800, height: int = 400):
|
||||||
|
if self.molfile is not None and self.molfile.strip() != "":
|
||||||
|
return IndigoUtils.mol_to_svg(self.molfile, width=width, height=height)
|
||||||
return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height)
|
return IndigoUtils.mol_to_svg(self.smiles, width=width, height=height)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -1609,10 +1703,15 @@ class Reaction(
|
|||||||
products = models.ManyToManyField(
|
products = models.ManyToManyField(
|
||||||
"epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products"
|
"epdb.CompoundStructure", verbose_name="Products", related_name="reaction_products"
|
||||||
)
|
)
|
||||||
rules = models.ManyToManyField("epdb.Rule", verbose_name="Rule", related_name="reaction_rule")
|
rules = models.ManyToManyField(
|
||||||
|
"epdb.Rule", verbose_name="Rule", related_name="reaction_rule", blank=True
|
||||||
|
)
|
||||||
multi_step = models.BooleanField(verbose_name="Multistep Reaction")
|
multi_step = models.BooleanField(verbose_name="Multistep Reaction")
|
||||||
medline_references = ArrayField(
|
medline_references = ArrayField(
|
||||||
models.TextField(blank=False, null=False), null=True, verbose_name="Medline References"
|
models.TextField(blank=False, null=False),
|
||||||
|
null=True,
|
||||||
|
verbose_name="Medline References",
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
external_identifiers = GenericRelation("ExternalIdentifier")
|
external_identifiers = GenericRelation("ExternalIdentifier")
|
||||||
@ -1629,7 +1728,7 @@ class Reaction(
|
|||||||
educts: Union[List[str], List[CompoundStructure]] = None,
|
educts: Union[List[str], List[CompoundStructure]] = None,
|
||||||
products: Union[List[str], List[CompoundStructure]] = None,
|
products: Union[List[str], List[CompoundStructure]] = None,
|
||||||
rules: Union[Rule | List[Rule]] = None,
|
rules: Union[Rule | List[Rule]] = None,
|
||||||
multi_step: bool = True,
|
multi_step: bool = False,
|
||||||
):
|
):
|
||||||
_educts = []
|
_educts = []
|
||||||
_products = []
|
_products = []
|
||||||
@ -1694,7 +1793,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
|
||||||
@ -1772,7 +1871,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):
|
||||||
@ -1885,6 +1984,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)
|
||||||
@ -1925,6 +2027,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
|||||||
# add links start -> pseudo
|
# add links start -> pseudo
|
||||||
new_link = {
|
new_link = {
|
||||||
"name": link["name"],
|
"name": link["name"],
|
||||||
|
"plain_name": link["plain_name"],
|
||||||
"id": link["id"],
|
"id": link["id"],
|
||||||
"url": link["url"],
|
"url": link["url"],
|
||||||
"image": link["image"],
|
"image": link["image"],
|
||||||
@ -1934,6 +2037,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
|||||||
"source": node_url_to_idx[link["start_node_urls"][0]],
|
"source": node_url_to_idx[link["start_node_urls"][0]],
|
||||||
"target": pseudo_idx,
|
"target": pseudo_idx,
|
||||||
"app_domain": link.get("app_domain", None),
|
"app_domain": link.get("app_domain", None),
|
||||||
|
"to_pseudo": True,
|
||||||
}
|
}
|
||||||
adjusted_links.append(new_link)
|
adjusted_links.append(new_link)
|
||||||
|
|
||||||
@ -1941,6 +2045,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
|||||||
for target in link["end_node_urls"]:
|
for target in link["end_node_urls"]:
|
||||||
new_link = {
|
new_link = {
|
||||||
"name": link["name"],
|
"name": link["name"],
|
||||||
|
"plain_name": link["plain_name"],
|
||||||
"id": link["id"],
|
"id": link["id"],
|
||||||
"url": link["url"],
|
"url": link["url"],
|
||||||
"image": link["image"],
|
"image": link["image"],
|
||||||
@ -1951,6 +2056,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
|||||||
"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"],
|
"multi_step": link["multi_step"],
|
||||||
|
"from_pseudo": True,
|
||||||
}
|
}
|
||||||
adjusted_links.append(new_link)
|
adjusted_links.append(new_link)
|
||||||
|
|
||||||
@ -2160,11 +2266,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
|
|||||||
def add_node(
|
def add_node(
|
||||||
self,
|
self,
|
||||||
smiles: str,
|
smiles: str,
|
||||||
name: Optional[str] = None,
|
molfile: str | None = None,
|
||||||
description: Optional[str] = None,
|
name: str | None = None,
|
||||||
depth: Optional[int] = 0,
|
description: str | None = None,
|
||||||
|
depth: int = -1,
|
||||||
):
|
):
|
||||||
return Node.create(self, smiles, depth, name=name, description=description)
|
return Node.create(self, smiles, depth, molfile=molfile, name=name, description=description)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def add_edge(
|
def add_edge(
|
||||||
@ -2177,6 +2284,68 @@ 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(
|
||||||
@ -2198,17 +2367,19 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
def _url(self):
|
def _url(self):
|
||||||
return "{}/node/{}".format(self.pathway.url, self.uuid)
|
return "{}/node/{}".format(self.pathway.url, self.uuid)
|
||||||
|
|
||||||
def get_name(self):
|
def get_name(self, include_suffix=True):
|
||||||
non_generic_name = True
|
non_generic_name = True
|
||||||
|
|
||||||
if self.name == "no name":
|
if self.name is None or self.name == "no name":
|
||||||
non_generic_name = False
|
non_generic_name = False
|
||||||
|
|
||||||
return (
|
if non_generic_name:
|
||||||
self.name
|
return self.name
|
||||||
if non_generic_name
|
else:
|
||||||
else f"{self.default_node_label.name} (taken from underlying structure)"
|
if include_suffix:
|
||||||
)
|
return f"{self.default_node_label.name} (taken from underlying structure)"
|
||||||
|
else:
|
||||||
|
return self.default_node_label.name
|
||||||
|
|
||||||
def d3_json(self):
|
def d3_json(self):
|
||||||
app_domain_data = self.get_app_domain_assessment_data()
|
app_domain_data = self.get_app_domain_assessment_data()
|
||||||
@ -2229,10 +2400,16 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
"node_label_id": self.default_node_label.url,
|
"node_label_id": self.default_node_label.url,
|
||||||
"image": f"{self.url}?image=svg",
|
"image": f"{self.url}?image=svg",
|
||||||
"image_svg": IndigoUtils.mol_to_svg(
|
"image_svg": IndigoUtils.mol_to_svg(
|
||||||
self.default_node_label.smiles, width=40, height=40
|
self.default_node_label.molfile
|
||||||
|
if self.default_node_label.molfile is not None
|
||||||
|
and self.default_node_label.molfile.strip()
|
||||||
|
else self.default_node_label.smiles,
|
||||||
|
width=40,
|
||||||
|
height=40,
|
||||||
),
|
),
|
||||||
"image_type": "svg",
|
"image_type": "svg",
|
||||||
"name": self.get_name(),
|
"name": self.get_name(),
|
||||||
|
"plain_name": self.get_name(include_suffix=False),
|
||||||
"smiles": self.default_node_label.smiles,
|
"smiles": self.default_node_label.smiles,
|
||||||
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
|
"scenarios": [{"name": s.get_name(), "url": s.url} for s in self.scenarios.all()],
|
||||||
"app_domain": {
|
"app_domain": {
|
||||||
@ -2255,40 +2432,60 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
pathway: "Pathway",
|
pathway: "Pathway",
|
||||||
smiles: str,
|
smiles: str,
|
||||||
depth: int,
|
depth: int,
|
||||||
name: Optional[str] = None,
|
molfile: str | None = None,
|
||||||
description: Optional[str] = None,
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
):
|
):
|
||||||
|
# Molfile has precendence over SMILES
|
||||||
|
if molfile is not None and molfile.strip() != "":
|
||||||
|
mol = FormatConverter.from_molfile(molfile)
|
||||||
|
|
||||||
|
if mol is None:
|
||||||
|
raise InvalidMolfileException("Given molfile is invalid")
|
||||||
|
else:
|
||||||
|
# Overwrite SMILES from molfile
|
||||||
|
smiles = FormatConverter.to_smiles(mol)
|
||||||
|
|
||||||
stereo_removed = False
|
stereo_removed = False
|
||||||
if pathway.predicted and FormatConverter.has_stereo(smiles):
|
if pathway.predicted and FormatConverter.has_stereo(smiles):
|
||||||
smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
||||||
stereo_removed = True
|
stereo_removed = True
|
||||||
|
|
||||||
c = Compound.create(pathway.package, smiles, name=name, description=description)
|
c = Compound.create(
|
||||||
|
pathway.package, smiles, molfile=molfile, name=name, description=description
|
||||||
|
)
|
||||||
|
|
||||||
if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists():
|
structure = c.get_structure_by_smiles(smiles)
|
||||||
return Node.objects.get(pathway=pathway, default_node_label=c.default_structure)
|
|
||||||
|
if Node.objects.filter(pathway=pathway, default_node_label=structure).exists():
|
||||||
|
return Node.objects.get(pathway=pathway, default_node_label=structure)
|
||||||
|
|
||||||
n = Node()
|
n = Node()
|
||||||
n.stereo_removed = stereo_removed
|
n.stereo_removed = stereo_removed
|
||||||
n.pathway = pathway
|
n.pathway = pathway
|
||||||
n.depth = depth
|
n.depth = depth
|
||||||
|
|
||||||
n.default_node_label = c.default_structure
|
n.default_node_label = structure
|
||||||
n.save()
|
n.save()
|
||||||
|
|
||||||
n.node_labels.add(c.default_structure)
|
n.node_labels.add(structure)
|
||||||
n.save()
|
n.save()
|
||||||
|
|
||||||
return n
|
return n
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def as_svg(self):
|
def as_svg(self):
|
||||||
|
if (
|
||||||
|
self.default_node_label.molfile is not None
|
||||||
|
and self.default_node_label.molfile.strip() != ""
|
||||||
|
):
|
||||||
|
return IndigoUtils.mol_to_svg(self.default_node_label.molfile)
|
||||||
return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
|
return IndigoUtils.mol_to_svg(self.default_node_label.smiles)
|
||||||
|
|
||||||
def get_timeseries_data(self):
|
def get_timeseries_data(self):
|
||||||
for ai in self.additional_information.all():
|
for ai in self.additional_information.all():
|
||||||
if ai.__class__.__name__ == "OECD301FTimeSeries":
|
if ai.type == "OECD301FTimeSeries":
|
||||||
return ai.model_dump(mode="json")
|
return ai.get().model_dump(mode="json")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -2319,13 +2516,35 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def is_proposed_intermediate(self):
|
||||||
|
collected = defaultdict(dict)
|
||||||
|
for ai in self.additional_information.filter(
|
||||||
|
type__in=["ProposedIntermediate", "TransformationProductImportance", "Confidence"],
|
||||||
|
scenario__isnull=False,
|
||||||
|
):
|
||||||
|
collected[str(ai.scenario.uuid)]["scenarioId"] = ai.scenario.url
|
||||||
|
collected[str(ai.scenario.uuid)]["scenarioName"] = ai.scenario.name
|
||||||
|
|
||||||
|
if ai.type == "ProposedIntermediate":
|
||||||
|
collected[str(ai.scenario.uuid)]["proposed"] = True
|
||||||
|
|
||||||
|
if ai.type == "Confidence":
|
||||||
|
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level
|
||||||
|
|
||||||
|
if ai.type == "TransformationProductImportance":
|
||||||
|
collected[str(ai.scenario.uuid)]["Transformation product importance"] = (
|
||||||
|
ai.get().importance.value
|
||||||
|
)
|
||||||
|
|
||||||
|
return list(collected.values())
|
||||||
|
|
||||||
|
|
||||||
class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
|
class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin):
|
||||||
pathway = models.ForeignKey(
|
pathway = models.ForeignKey(
|
||||||
"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"
|
||||||
@ -2340,6 +2559,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
def d3_json(self):
|
def d3_json(self):
|
||||||
edge_json = {
|
edge_json = {
|
||||||
"name": self.get_name(),
|
"name": self.get_name(),
|
||||||
|
"plain_name": self.get_name(include_suffix=False),
|
||||||
"id": self.url,
|
"id": self.url,
|
||||||
"url": self.url,
|
"url": self.url,
|
||||||
"image": self.url + "?image=svg",
|
"image": self.url + "?image=svg",
|
||||||
@ -2404,6 +2624,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
|
||||||
@ -2433,7 +2655,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
|
||||||
@ -2452,17 +2674,19 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
|
|||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def get_name(self):
|
def get_name(self, include_suffix=True):
|
||||||
non_generic_name = True
|
non_generic_name = True
|
||||||
|
|
||||||
if self.name == "no name":
|
if self.name == "no name":
|
||||||
non_generic_name = False
|
non_generic_name = False
|
||||||
|
|
||||||
return (
|
if non_generic_name:
|
||||||
self.name
|
return self.name
|
||||||
if non_generic_name
|
else:
|
||||||
else f"{self.edge_label.name} (taken from underlying reaction)"
|
if include_suffix:
|
||||||
)
|
return f"{self.edge_label.name} (taken from underlying reaction)"
|
||||||
|
else:
|
||||||
|
return self.edge_label.name
|
||||||
|
|
||||||
|
|
||||||
class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin):
|
class EPModel(PolymorphicModel, EnviPathModel, AdditionalInformationMixin):
|
||||||
@ -4441,18 +4665,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:
|
||||||
|
|||||||
111
epdb/views.py
111
epdb/views.py
@ -19,6 +19,7 @@ from sentry_sdk import capture_exception
|
|||||||
|
|
||||||
from utilities.chem import FormatConverter, IndigoUtils
|
from utilities.chem import FormatConverter, IndigoUtils
|
||||||
from utilities.decorators import package_permission_required
|
from utilities.decorators import package_permission_required
|
||||||
|
from .exceptions import InvalidMolfileException, InvalidSMILESException
|
||||||
|
|
||||||
from .logic import (
|
from .logic import (
|
||||||
EPDBURLParser,
|
EPDBURLParser,
|
||||||
@ -754,6 +755,11 @@ def models(request):
|
|||||||
{"Model": s.SERVER_URL + "/model"},
|
{"Model": s.SERVER_URL + "/model"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
context["entity_type"] = "model"
|
||||||
|
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "models"
|
||||||
|
|
||||||
# Keep model_types for potential modal/action use
|
# Keep model_types for potential modal/action use
|
||||||
context["model_types"] = {
|
context["model_types"] = {
|
||||||
"ML Relative Reasoning": {
|
"ML Relative Reasoning": {
|
||||||
@ -766,11 +772,13 @@ def models(request):
|
|||||||
"requires_rule_packages": True,
|
"requires_rule_packages": True,
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": True,
|
||||||
},
|
},
|
||||||
"EnviFormer": {
|
}
|
||||||
|
|
||||||
|
if s.ENVIFORMER_PRESENT:
|
||||||
|
context["model_types"]["EnviFormer"] = {
|
||||||
"type": "enviformer",
|
"type": "enviformer",
|
||||||
"requires_rule_packages": False,
|
"requires_rule_packages": False,
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": True,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.FLAGS.get("PLUGINS", False):
|
if s.FLAGS.get("PLUGINS", False):
|
||||||
@ -779,6 +787,9 @@ def models(request):
|
|||||||
"type": k,
|
"type": k,
|
||||||
"requires_rule_packages": v.requires_rule_packages(),
|
"requires_rule_packages": v.requires_rule_packages(),
|
||||||
"requires_data_packages": v.requires_data_packages(),
|
"requires_data_packages": v.requires_data_packages(),
|
||||||
|
"additional_parameters": v.Config.__name__.lower()
|
||||||
|
if v.Config.__name__ != ""
|
||||||
|
else None,
|
||||||
}
|
}
|
||||||
for k, v in s.PROPERTY_PLUGINS.items():
|
for k, v in s.PROPERTY_PLUGINS.items():
|
||||||
context["model_types"][v.display()] = {
|
context["model_types"][v.display()] = {
|
||||||
@ -787,12 +798,6 @@ def models(request):
|
|||||||
"requires_data_packages": v.requires_data_packages(),
|
"requires_data_packages": v.requires_data_packages(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Context for paginated template
|
|
||||||
context["entity_type"] = "model"
|
|
||||||
context["api_endpoint"] = f"{s.SERVER_PATH}/api/v1/models/"
|
|
||||||
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
|
||||||
context["list_title"] = "models"
|
|
||||||
|
|
||||||
return render(request, "collections/models_paginated.html", context)
|
return render(request, "collections/models_paginated.html", context)
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
@ -906,11 +911,12 @@ def package_models(request, package_uuid):
|
|||||||
"requires_rule_packages": True,
|
"requires_rule_packages": True,
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": True,
|
||||||
},
|
},
|
||||||
"EnviFormer": {
|
}
|
||||||
|
if s.ENVIFORMER_PRESENT:
|
||||||
|
context["model_types"]["EnviFormer"] = {
|
||||||
"type": "enviformer",
|
"type": "enviformer",
|
||||||
"requires_rule_packages": False,
|
"requires_rule_packages": False,
|
||||||
"requires_data_packages": True,
|
"requires_data_packages": True,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.FLAGS.get("PLUGINS", False):
|
if s.FLAGS.get("PLUGINS", False):
|
||||||
@ -1087,6 +1093,11 @@ def package_model(request, package_uuid, model_uuid):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sort data by prob desc
|
||||||
|
res["pred"] = sorted(
|
||||||
|
res["pred"], key=lambda x: x["probability"], reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
return JsonResponse(res, safe=False)
|
return JsonResponse(res, safe=False)
|
||||||
|
|
||||||
elif half_life:
|
elif half_life:
|
||||||
@ -1190,9 +1201,7 @@ def package(request, package_uuid):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
if request.GET.get("export", False) == "true":
|
if request.GET.get("export", False) == "true":
|
||||||
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
|
filename = f"{current_package.get_name().replace(' ', '_')}_{current_package.uuid}.json"
|
||||||
pack_json = PackageManager.export_package(
|
pack_json = PackageManager.export_package(current_package)
|
||||||
current_package, include_models=False, include_external_identifiers=False
|
|
||||||
)
|
|
||||||
response = JsonResponse(pack_json, content_type="application/json")
|
response = JsonResponse(pack_json, content_type="application/json")
|
||||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
@ -1375,12 +1384,18 @@ def package_compounds(request, package_uuid):
|
|||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
compound_name = request.POST.get("compound-name")
|
compound_name = request.POST.get("compound-name")
|
||||||
compound_smiles = request.POST.get("compound-smiles")
|
compound_smiles = request.POST.get("compound-smiles")
|
||||||
|
compound_molfile = request.POST.get("compound-molfile")
|
||||||
compound_description = request.POST.get("compound-description")
|
compound_description = request.POST.get("compound-description")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
c = Compound.create(
|
c = Compound.create(
|
||||||
current_package, compound_smiles, compound_name, compound_description
|
current_package,
|
||||||
|
compound_smiles,
|
||||||
|
molfile=compound_molfile,
|
||||||
|
name=compound_name,
|
||||||
|
description=compound_description,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except (InvalidSMILESException, InvalidMolfileException) as e:
|
||||||
raise BadRequest(str(e))
|
raise BadRequest(str(e))
|
||||||
|
|
||||||
return redirect(c.url)
|
return redirect(c.url)
|
||||||
@ -1506,11 +1521,15 @@ def package_compound_structures(request, package_uuid, compound_uuid):
|
|||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
structure_name = request.POST.get("structure-name")
|
structure_name = request.POST.get("structure-name")
|
||||||
structure_smiles = request.POST.get("structure-smiles")
|
structure_smiles = request.POST.get("structure-smiles")
|
||||||
|
structure_molfile = request.POST.get("structure-molfile")
|
||||||
structure_description = request.POST.get("structure-description")
|
structure_description = request.POST.get("structure-description")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cs = current_compound.add_structure(
|
cs = current_compound.add_structure(
|
||||||
structure_smiles, structure_name, structure_description
|
structure_smiles,
|
||||||
|
molfile=structure_molfile,
|
||||||
|
name=structure_name,
|
||||||
|
description=structure_description,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return error(
|
return error(
|
||||||
@ -2081,12 +2100,14 @@ def package_pathways(request, package_uuid):
|
|||||||
else:
|
else:
|
||||||
prediction_setting = current_user.prediction_settings()
|
prediction_setting = current_user.prediction_settings()
|
||||||
|
|
||||||
|
is_predict_mode = pw_mode in {"predict", "incremental"}
|
||||||
|
|
||||||
pw = Pathway.create(
|
pw = Pathway.create(
|
||||||
current_package,
|
current_package,
|
||||||
stand_smiles,
|
stand_smiles if is_predict_mode else smiles,
|
||||||
name=name,
|
name=name,
|
||||||
description=description,
|
description=description,
|
||||||
predicted=pw_mode in {"predict", "incremental"},
|
predicted=is_predict_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# set mode
|
# set mode
|
||||||
@ -2328,7 +2349,16 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
|
|||||||
node_description = request.POST.get("node-description")
|
node_description = request.POST.get("node-description")
|
||||||
|
|
||||||
node_smiles = request.POST.get("node-smiles").strip()
|
node_smiles = request.POST.get("node-smiles").strip()
|
||||||
current_pathway.add_node(node_smiles, name=node_name, description=node_description)
|
node_molfile = request.POST.get("node-molfile").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_pathway.add_node(
|
||||||
|
node_smiles, molfile=node_molfile, name=node_name, description=node_description
|
||||||
|
)
|
||||||
|
except InvalidSMILESException:
|
||||||
|
return error(
|
||||||
|
request, "Node creation failed!", f"Given SMILES ({node_smiles}) is invalid"
|
||||||
|
)
|
||||||
|
|
||||||
return redirect(current_pathway.url)
|
return redirect(current_pathway.url)
|
||||||
|
|
||||||
@ -2436,7 +2466,26 @@ def package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
|
|||||||
|
|
||||||
return JsonResponse({"success": current_node.url})
|
return JsonResponse({"success": current_node.url})
|
||||||
|
|
||||||
return HttpResponseBadRequest()
|
new_node_name = request.POST.get("node-name")
|
||||||
|
new_node_description = request.POST.get("node-description")
|
||||||
|
|
||||||
|
if any([new_node_name, new_node_description]):
|
||||||
|
if new_node_name is not None and new_node_name.strip() != "":
|
||||||
|
new_node_name = nh3.clean(new_node_name.strip(), tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
|
current_node.name = new_node_name
|
||||||
|
|
||||||
|
if new_node_description is not None and new_node_description.strip() != "":
|
||||||
|
new_node_description = nh3.clean(
|
||||||
|
new_node_description.strip(), tags=s.ALLOWED_HTML_TAGS
|
||||||
|
).strip()
|
||||||
|
current_node.description = new_node_description
|
||||||
|
|
||||||
|
current_node.save()
|
||||||
|
|
||||||
|
return redirect(current_node.url)
|
||||||
|
|
||||||
|
return error(request, "Node update failed!", "No changes were made to the node")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return HttpResponseNotAllowed(["GET", "POST"])
|
return HttpResponseNotAllowed(["GET", "POST"])
|
||||||
|
|
||||||
@ -2506,6 +2555,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:
|
||||||
@ -3077,12 +3129,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")
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -65,6 +65,25 @@ def run_both_engines(SMILES, SMIRKS):
|
|||||||
|
|
||||||
|
|
||||||
def migration(request):
|
def migration(request):
|
||||||
|
accepted_diffs = [
|
||||||
|
"bt0055-3469.1",
|
||||||
|
"bt0056-2685",
|
||||||
|
"bt0193-4263",
|
||||||
|
"bt0231-1871.1",
|
||||||
|
"bt0242-3803",
|
||||||
|
"bt0254-4224.1",
|
||||||
|
"bt0254-4224.2",
|
||||||
|
"bt0291-1129",
|
||||||
|
"bt0337-3543",
|
||||||
|
"bt0391-4285",
|
||||||
|
"bt0402-3576",
|
||||||
|
"bt0404-3928",
|
||||||
|
"bt0416-4269",
|
||||||
|
"bt0432-4254",
|
||||||
|
"bt0337-4117",
|
||||||
|
"bt0322-3393",
|
||||||
|
]
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
|
|
||||||
@ -109,11 +128,11 @@ def migration(request):
|
|||||||
),
|
),
|
||||||
"id": str(r.uuid),
|
"id": str(r.uuid),
|
||||||
"url": r.url,
|
"url": r.url,
|
||||||
"status": res,
|
"status": res or r.name in accepted_diffs,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if res:
|
if res or r.name in accepted_diffs:
|
||||||
success += 1
|
success += 1
|
||||||
else:
|
else:
|
||||||
error += 1
|
error += 1
|
||||||
@ -135,7 +154,16 @@ def migration(request):
|
|||||||
|
|
||||||
for r in migration_status["results"]:
|
for r in migration_status["results"]:
|
||||||
r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
|
r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
|
||||||
|
if r["name"] in accepted_diffs:
|
||||||
|
r["status"] = True
|
||||||
|
|
||||||
|
migration_status["results"] = sorted(
|
||||||
|
migration_status["results"], key=lambda x: (x["status"], x["name"])
|
||||||
|
)
|
||||||
|
|
||||||
|
num_success = sum([int(x["status"]) for x in migration_status["results"]])
|
||||||
|
migration_status["success"] = num_success
|
||||||
|
migration_status["error"] = migration_status["total"] - num_success
|
||||||
context.update(**migration_status)
|
context.update(**migration_status)
|
||||||
|
|
||||||
return render(request, "migration.html", context)
|
return render(request, "migration.html", context)
|
||||||
|
|||||||
@ -21,5 +21,11 @@
|
|||||||
"django",
|
"django",
|
||||||
"tailwindcss",
|
"tailwindcss",
|
||||||
"daisyui"
|
"daisyui"
|
||||||
|
],
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"@tailwindcss/oxide"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@parcel/watcher'
|
- '@parcel/watcher'
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
|
|||||||
@ -34,3 +34,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@import "./daisyui-theme.css";
|
@import "./daisyui-theme.css";
|
||||||
|
|
||||||
|
select.select[multiple] {
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
p a {
|
||||||
|
@apply underline;
|
||||||
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -5,6 +5,126 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
|
|
||||||
|
const basePagination = (
|
||||||
|
items,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems,
|
||||||
|
perPage,
|
||||||
|
isReviewed,
|
||||||
|
instanceId
|
||||||
|
) => ({
|
||||||
|
items,
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems,
|
||||||
|
perPage,
|
||||||
|
isReviewed,
|
||||||
|
instanceId,
|
||||||
|
|
||||||
|
get paginatedItems() {
|
||||||
|
return this.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
get showingStart() {
|
||||||
|
if (this.totalItems === 0) return 0;
|
||||||
|
return (this.currentPage - 1) * this.perPage + 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
get showingEnd() {
|
||||||
|
if (this.totalItems === 0) return 0;
|
||||||
|
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
|
||||||
|
},
|
||||||
|
|
||||||
|
nextPage() {
|
||||||
|
if (this.currentPage < this.totalPages) {
|
||||||
|
this.fetchPage(this.currentPage + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prevPage() {
|
||||||
|
if (this.currentPage > 1) {
|
||||||
|
this.fetchPage(this.currentPage - 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToPage(page) {
|
||||||
|
if (page >= 1 && page <= this.totalPages) {
|
||||||
|
this.fetchPage(page);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get pageNumbers() {
|
||||||
|
const pages = [];
|
||||||
|
const total = this.totalPages;
|
||||||
|
const current = this.currentPage;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total <= 7) {
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
|
||||||
|
|
||||||
|
let rangeStart;
|
||||||
|
let rangeEnd;
|
||||||
|
|
||||||
|
if (current <= 4) {
|
||||||
|
rangeStart = 2;
|
||||||
|
rangeEnd = 5;
|
||||||
|
} else if (current >= total - 3) {
|
||||||
|
rangeStart = total - 4;
|
||||||
|
rangeEnd = total - 1;
|
||||||
|
} else {
|
||||||
|
rangeStart = current - 1;
|
||||||
|
rangeEnd = current + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeStart > 2) {
|
||||||
|
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||||
|
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeEnd < total - 1) {
|
||||||
|
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.data('paginatedList',
|
||||||
|
(items, options = {}) => ({
|
||||||
|
...basePagination(items,1, 0, 0, options.perPage || 50, options.isReviewed || false, options.instanceId || Math.random().toString(36).substring(2, 9),),
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.fetchPage(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchPage(page) {
|
||||||
|
this.totalItems = this.items.length;
|
||||||
|
this.totalPages = Math.ceil(this.items.length / this.perPage);
|
||||||
|
this.currentPage = page
|
||||||
|
|
||||||
|
const start = page * this.perPage - this.perPage;
|
||||||
|
const end = page * this.perPage;
|
||||||
|
return this.items.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
Alpine.data('remotePaginatedList', (options = {}) => ({
|
Alpine.data('remotePaginatedList', (options = {}) => ({
|
||||||
items: [],
|
items: [],
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
|
|||||||
245
static/js/pw.js
245
static/js/pw.js
@ -1,5 +1,37 @@
|
|||||||
console.log("loaded pw.js")
|
console.log("loaded pw.js")
|
||||||
|
|
||||||
|
function findPaths(source_idx, links) {
|
||||||
|
const resultLinks = new Set();
|
||||||
|
const visited = new Set();
|
||||||
|
// Helper function for depth-first search
|
||||||
|
function dfs(current) {
|
||||||
|
|
||||||
|
if (visited.has(current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (links.filter(link => link.target.id === current).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(current);
|
||||||
|
links.forEach(link => {
|
||||||
|
if (
|
||||||
|
link.target.id === current &&
|
||||||
|
!visited.has(link.source.id) &&
|
||||||
|
link.source.id !== link.target.id // Avoid self-loops
|
||||||
|
) {
|
||||||
|
resultLinks.add(link); // Add link to result
|
||||||
|
dfs(link.source.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
visited.delete(current);
|
||||||
|
}
|
||||||
|
// Start DFS
|
||||||
|
dfs(source_idx);
|
||||||
|
return Array.from(resultLinks);
|
||||||
|
}
|
||||||
|
|
||||||
function predictFromNode(url) {
|
function predictFromNode(url) {
|
||||||
fetch("", {
|
fetch("", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -23,6 +55,7 @@ function predictFromNode(url) {
|
|||||||
// 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;
|
||||||
@ -34,6 +67,9 @@ function draw(pathway, elem) {
|
|||||||
const horizontalSpacing = 75; // horizontal space between nodes
|
const horizontalSpacing = 75; // horizontal space between nodes
|
||||||
const depthMap = new Map();
|
const depthMap = new Map();
|
||||||
|
|
||||||
|
// Avoid leaving unconnected Nodes leaving the viewport
|
||||||
|
nodes.forEach(node => {if (node.depth < 0) node.depth = 0;});
|
||||||
|
|
||||||
// Sort nodes by depth first to minimize crossings
|
// Sort nodes by depth first to minimize crossings
|
||||||
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
|
const sortedNodes = [...nodes].sort((a, b) => a.depth - b.depth);
|
||||||
|
|
||||||
@ -63,7 +99,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,12 +137,31 @@ 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})`);
|
||||||
|
|
||||||
|
linkText
|
||||||
|
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||||
|
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function dragstarted(event, d) {
|
function dragstarted(event, d) {
|
||||||
@ -209,7 +264,7 @@ function draw(pathway, elem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait before showing popup (ms)
|
// Wait before showing popup (ms)
|
||||||
var popupWaitBeforeShow = 1000;
|
var popupWaitBeforeShow = 750;
|
||||||
|
|
||||||
// Custom popover element
|
// Custom popover element
|
||||||
let popoverTimeout = null;
|
let popoverTimeout = null;
|
||||||
@ -441,7 +496,7 @@ function draw(pathway, elem) {
|
|||||||
if (n.stereo_removed) {
|
if (n.stereo_removed) {
|
||||||
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
|
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
|
||||||
}
|
}
|
||||||
popupContent += "<a href='" + n.url + "'>" + n.name + "</a><br>";
|
popupContent += "<a href='" + n.url + "'>" + n.plain_name + "</a><br>";
|
||||||
popupContent += "Depth " + n.depth + "<br>"
|
popupContent += "Depth " + n.depth + "<br>"
|
||||||
|
|
||||||
if (appDomainViewEnabled) {
|
if (appDomainViewEnabled) {
|
||||||
@ -463,7 +518,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>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -481,6 +536,21 @@ function draw(pathway, elem) {
|
|||||||
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
||||||
for (var s of n.scenarios) {
|
for (var s of n.scenarios) {
|
||||||
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
|
popupContent += "<a href='" + s.url + "'>" + s.name + "</a><br>";
|
||||||
|
for (var prop in n.proposed) {
|
||||||
|
if (n.proposed[prop].scenarioId === s.url) {
|
||||||
|
if("proposed" in n.proposed[prop]){
|
||||||
|
popupContent += "This compound is a proposed intermediate" + "<br>";
|
||||||
|
}
|
||||||
|
if("Confidence" in n.proposed[prop]){
|
||||||
|
popupContent += "Confidence: " + n.proposed[prop]["Confidence"] + "<br>";
|
||||||
|
}
|
||||||
|
if("Transformation product importance" in n.proposed[prop]){
|
||||||
|
popupContent += "Transformation product importance: " + n.proposed[prop]["Transformation product importance"] + "<br>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
popupContent += "<br>";
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,7 +563,7 @@ function draw(pathway, elem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function edge_popup(e) {
|
function edge_popup(e) {
|
||||||
popupContent = "<a href='" + e.url + "'>" + e.name + "</a><br><br>";
|
popupContent = "<a href='" + e.url + "'>" + e.plain_name + "</a><br><br>";
|
||||||
|
|
||||||
if (e.reaction.rules) {
|
if (e.reaction.rules) {
|
||||||
for (var rule of e.reaction.rules) {
|
for (var rule of e.reaction.rules) {
|
||||||
@ -544,7 +614,7 @@ function draw(pathway, elem) {
|
|||||||
|
|
||||||
// Add background rectangle FIRST to enable pan/zoom on empty space
|
// Add background rectangle FIRST to enable pan/zoom on empty space
|
||||||
// This must be inserted before zoomable group so it's behind everything
|
// This must be inserted before zoomable group so it's behind everything
|
||||||
svg.insert("rect", "#zoomable")
|
const rect = svg.insert("rect", "#zoomable")
|
||||||
.attr("x", 0)
|
.attr("x", 0)
|
||||||
.attr("y", 0)
|
.attr("y", 0)
|
||||||
.attr("width", width)
|
.attr("width", width)
|
||||||
@ -553,6 +623,30 @@ function draw(pathway, elem) {
|
|||||||
.attr("pointer-events", "all")
|
.attr("pointer-events", "all")
|
||||||
.style("cursor", "grab");
|
.style("cursor", "grab");
|
||||||
|
|
||||||
|
// Click on empty SVG space (not on a node or link)
|
||||||
|
rect.on("click", function(event) {
|
||||||
|
// Only treat it as a background click if the direct target is the rect itself
|
||||||
|
// (clicks on nodes/links bubble up but originate from different elements)
|
||||||
|
const target = event.target;
|
||||||
|
const isNode = target.closest && (
|
||||||
|
target.closest("circle") !== null ||
|
||||||
|
target.closest("image") !== null ||
|
||||||
|
target.closest(".node") !== null
|
||||||
|
);
|
||||||
|
const isLink = target.closest && (
|
||||||
|
target.closest("path.link") !== null ||
|
||||||
|
target.closest("path.link_no_arrow") !== null
|
||||||
|
);
|
||||||
|
if (!isNode && !isLink) {
|
||||||
|
// console.log("Clicked outside a node or link");
|
||||||
|
// Clear all highlights
|
||||||
|
d3.selectAll("circle").classed("highlighted", false);
|
||||||
|
d3.selectAll("path").classed("highlighted", false);
|
||||||
|
d3.selectAll("path").classed("inedge", false);
|
||||||
|
d3.selectAll("path").classed("outedge", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Zoom Funktion aktivieren
|
// Zoom Funktion aktivieren
|
||||||
const zoom = d3.zoom()
|
const zoom = d3.zoom()
|
||||||
.scaleExtent([0.5, 5])
|
.scaleExtent([0.5, 5])
|
||||||
@ -563,6 +657,8 @@ function draw(pathway, elem) {
|
|||||||
// 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 +677,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));
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -597,15 +693,83 @@ function draw(pathway, elem) {
|
|||||||
.force("center", d3.forceCenter(width / 2, height / 4))
|
.force("center", d3.forceCenter(width / 2, height / 4))
|
||||||
.on("tick", ticked);
|
.on("tick", ticked);
|
||||||
|
|
||||||
// Kanten zeichnen
|
// Draw Edges
|
||||||
const link = zoomable.append("g")
|
const linkGroup = zoomable.append("g")
|
||||||
.selectAll("line")
|
.selectAll("g")
|
||||||
.data(links)
|
.data(links)
|
||||||
.enter().append("line")
|
.enter()
|
||||||
|
.append("g")
|
||||||
|
.attr("class", "link-group");
|
||||||
|
|
||||||
|
const link = linkGroup
|
||||||
|
.append("path")
|
||||||
|
.attr("class", d => d.target.pseudo ? "link_no_arrow" : "link")
|
||||||
|
.attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)")
|
||||||
|
.attr("fill", "none")
|
||||||
|
|
||||||
// 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) {
|
||||||
|
const wasHighlighted = d3.select(this).classed("highlighted");
|
||||||
|
d3.selectAll("path").classed("highlighted", false);
|
||||||
|
if (!wasHighlighted) {
|
||||||
|
const toHighlight = [];
|
||||||
|
toHighlight.push(d.el);
|
||||||
|
|
||||||
|
if (d.source.pseudo || d.target.pseudo) {
|
||||||
|
if (d.target.pseudo) {
|
||||||
|
d3.selectAll("path").each(e => {
|
||||||
|
if (e !== undefined && e.source.id === d.target.id) {
|
||||||
|
toHighlight.push(e.el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
d3.selectAll("path").each(e => {
|
||||||
|
if (e !== undefined && (e.target.id === d.source.id || e.source.id === d.source.id)) {
|
||||||
|
toHighlight.push(e.el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of toHighlight) {
|
||||||
|
d3.select(e).classed("highlighted", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function reaction_name(d) {
|
||||||
|
const to_pseudo = d?.to_pseudo || false;
|
||||||
|
|
||||||
|
if (to_pseudo) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix = "";
|
||||||
|
if ("reaction_probability" in d && d["reaction_probability"] !== null) {
|
||||||
|
suffix = " (p= " + d["reaction_probability"].toFixed(2) + ")"
|
||||||
|
}
|
||||||
|
return d.plain_name + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkText = linkGroup
|
||||||
|
.append("text")
|
||||||
|
.attr("class", "link-label")
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.attr("dy", -6)
|
||||||
|
.style("font-size", "4px")
|
||||||
|
.style("pointer-events", "none")
|
||||||
|
.style("display", "none")
|
||||||
|
.text(d => reaction_name(d))
|
||||||
|
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||||
|
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||||
|
|
||||||
// add element to links array
|
// add element to links array
|
||||||
link.each(function (d) {
|
link.each(function (d) {
|
||||||
@ -614,7 +778,7 @@ function draw(pathway, elem) {
|
|||||||
|
|
||||||
pop_add(link, "Reaction", edge_popup);
|
pop_add(link, "Reaction", edge_popup);
|
||||||
|
|
||||||
// Knoten zeichnen
|
// Draw Nodes
|
||||||
const node = zoomable.append("g")
|
const node = zoomable.append("g")
|
||||||
.selectAll("g")
|
.selectAll("g")
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
@ -622,10 +786,41 @@ function draw(pathway, elem) {
|
|||||||
.call(d3.drag()
|
.call(d3.drag()
|
||||||
.on("start", dragstarted)
|
.on("start", dragstarted)
|
||||||
.on("drag", dragged)
|
.on("drag", dragged)
|
||||||
.on("end", dragended))
|
.on("end", dragended)
|
||||||
|
)
|
||||||
.on("click", function (event, d) {
|
.on("click", function (event, d) {
|
||||||
|
const wasHighlighted = d3.select(this).select("circle").classed("highlighted");
|
||||||
|
d3.selectAll('circle.highlighted').classed('highlighted', false);
|
||||||
|
d3.selectAll("path").classed("inedge", false);
|
||||||
|
d3.selectAll("path").classed("outedge", false);
|
||||||
|
|
||||||
|
if (!wasHighlighted) {
|
||||||
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
|
d3.select(this).select("circle").classed("highlighted", !d3.select(this).select("circle").classed("highlighted"));
|
||||||
})
|
const inEdges = findPaths(d.id, links);
|
||||||
|
const outEdges = []
|
||||||
|
// Colorize out edges green
|
||||||
|
for (const l of links) {
|
||||||
|
if (l.source.id === d.id) {
|
||||||
|
outEdges.push(l);
|
||||||
|
if (l.target.pseudo) {
|
||||||
|
for (const l2 of links) {
|
||||||
|
if (l.target.id === l2.source.id) {
|
||||||
|
outEdges.push(l2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of inEdges) {
|
||||||
|
d3.select(e.el).classed("inedge", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of outEdges) {
|
||||||
|
d3.select(e.el).classed("outedge", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Kreise für die Knoten hinzufügen
|
// Kreise für die Knoten hinzufügen
|
||||||
node.append("circle")
|
node.append("circle")
|
||||||
@ -699,14 +894,14 @@ function draw(pathway, elem) {
|
|||||||
|
|
||||||
function serializeSVG(svgElement) {
|
function serializeSVG(svgElement) {
|
||||||
|
|
||||||
svgElement.querySelectorAll("line.link").forEach(line => {
|
svgElement.querySelectorAll("path.link").forEach(line => {
|
||||||
const style = getComputedStyle(line);
|
const style = getComputedStyle(line);
|
||||||
line.setAttribute("stroke", style.stroke);
|
line.setAttribute("stroke", style.stroke);
|
||||||
line.setAttribute("stroke-width", style.strokeWidth);
|
line.setAttribute("stroke-width", style.strokeWidth);
|
||||||
line.setAttribute("fill", style.fill);
|
line.setAttribute("fill", style.fill);
|
||||||
});
|
});
|
||||||
|
|
||||||
svgElement.querySelectorAll("line.link_no_arrow").forEach(line => {
|
svgElement.querySelectorAll("path.link_no_arrow").forEach(line => {
|
||||||
const style = getComputedStyle(line);
|
const style = getComputedStyle(line);
|
||||||
line.setAttribute("stroke", style.stroke);
|
line.setAttribute("stroke", style.stroke);
|
||||||
line.setAttribute("stroke-width", style.strokeWidth);
|
line.setAttribute("stroke-width", style.strokeWidth);
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
{% for tpl in action_button_templates %}
|
{% for tpl in action_button_templates %}
|
||||||
{% include tpl %}
|
{% include tpl %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<li role="separator" class="divider"></li>
|
<li role="separator" class="divider h-px"></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
@ -74,7 +74,7 @@
|
|||||||
Rules</a
|
Rules</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li role="separator" class="divider"></li>
|
<li role="separator" class="divider h-px"></li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
class="button"
|
class="button"
|
||||||
@ -99,11 +99,16 @@
|
|||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li role="separator" class="divider"></li>
|
<li role="separator" class="divider h-px"></li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
class="button"
|
class="button"
|
||||||
onclick="document.getElementById('delete_pathway_node_modal').showModal(); return false;"
|
onclick="
|
||||||
|
const modal = document.getElementById('delete_pathway_node_modal');
|
||||||
|
modal.showModal();
|
||||||
|
window.dispatchEvent(new Event('modal-opened'));
|
||||||
|
return false;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
|
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
|
||||||
>
|
>
|
||||||
@ -111,7 +116,12 @@
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
class="button"
|
class="button"
|
||||||
onclick="document.getElementById('delete_pathway_edge_modal').showModal(); return false;"
|
onclick="
|
||||||
|
const modal = document.getElementById('delete_pathway_edge_modal');
|
||||||
|
modal.showModal();
|
||||||
|
window.dispatchEvent(new Event('modal-opened'));
|
||||||
|
return false;
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
|
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
|
||||||
>
|
>
|
||||||
|
|||||||
102
templates/collections/_joblog_paginated_list_partial.html
Normal file
102
templates/collections/_joblog_paginated_list_partial.html
Normal 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>
|
||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
13
templates/collections/joblog_paginated.html
Normal file
13
templates/collections/joblog_paginated.html
Normal 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 %}
|
||||||
@ -3,7 +3,8 @@
|
|||||||
{% block page_title %}Models{% endblock %}
|
{% block page_title %}Models{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
|
{% if meta.enabled_features.MODEL_BUILDING %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
@ -12,6 +13,7 @@
|
|||||||
New Model
|
New Model
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
{% endblock action_button %}
|
{% endblock action_button %}
|
||||||
|
|
||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
{% block page_title %}Packages{% endblock %}
|
{% block page_title %}Packages{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -71,7 +70,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% endblock action_button %}
|
{% endblock action_button %}
|
||||||
|
|
||||||
{% block action_modals %}
|
{% block action_modals %}
|
||||||
|
|||||||
@ -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) ===== #}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}Pathways{% endblock %}
|
{% block page_title %}Pathways{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}Reactions{% endblock %}
|
{% block page_title %}Reactions{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}Rules{% endblock %}
|
{% block page_title %}Rules{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
{% endblock action_modals %}
|
{% endblock action_modals %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
|
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
|
||||||
|
|
||||||
{% block action_button %}
|
{% block action_button %}
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit or not meta.url_contains_package %}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
|
|||||||
@ -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'"
|
||||||
|
|||||||
69
templates/components/widgets/doi_link_widget.html
Normal file
69
templates/components/widgets/doi_link_widget.html
Normal 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>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -203,11 +203,11 @@
|
|||||||
id="model-based-prediction-setting-threshold"
|
id="model-based-prediction-setting-threshold"
|
||||||
name="model-based-prediction-setting-threshold"
|
name="model-based-prediction-setting-threshold"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
placeholder="0.25"
|
value="0.25"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
step="0.05"
|
step="any"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,22 @@
|
|||||||
id="delete_pathway_edge_modal"
|
id="delete_pathway_edge_modal"
|
||||||
class="modal"
|
class="modal"
|
||||||
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
|
x-data="modalForm({ state: { selectedEdge: '', imageUrl: '' } })"
|
||||||
|
@modal-opened.window="
|
||||||
|
const links = d3.selectAll('path.highlighted');
|
||||||
|
if (!links.empty()) {
|
||||||
|
const el = links.node();
|
||||||
|
const selectElement = document.getElementById('delete_pathway_edge_edges');
|
||||||
|
console.log(el);
|
||||||
|
console.log(el.__data__);
|
||||||
|
for (let option of selectElement.options) {
|
||||||
|
if (option.value === el.__data__.url) {
|
||||||
|
option.selected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectElement.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
"
|
||||||
@close="reset()"
|
@close="reset()"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
|
|||||||
@ -4,6 +4,19 @@
|
|||||||
id="delete_pathway_node_modal"
|
id="delete_pathway_node_modal"
|
||||||
class="modal"
|
class="modal"
|
||||||
x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })"
|
x-data="modalForm({ state: { selectedNode: '', imageUrl: '' } })"
|
||||||
|
@modal-opened.window="
|
||||||
|
const el = d3.select('circle.highlighted').node();
|
||||||
|
if (el !== null) {
|
||||||
|
const selectElement = document.getElementById('delete_pathway_node_nodes');
|
||||||
|
for (let option of selectElement.options) {
|
||||||
|
if (option.value === el.__data__.url) {
|
||||||
|
option.selected = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectElement.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
"
|
||||||
@close="reset()"
|
@close="reset()"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
|
|||||||
@ -92,7 +92,7 @@
|
|||||||
<div class="flex flex-wrap items-center justify-center gap-4">
|
<div class="flex flex-wrap items-center justify-center gap-4">
|
||||||
{% for educt in edge.start_nodes.all %}
|
{% for educt in edge.start_nodes.all %}
|
||||||
<a href="{{ educt.url }}" class="btn btn-outline btn-sm"
|
<a href="{{ educt.url }}" class="btn btn-outline btn-sm"
|
||||||
>{{ educt.name }}</a
|
>{{ educt.get_name }}</a
|
||||||
>
|
>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<svg
|
<svg
|
||||||
@ -112,7 +112,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{% for product in edge.end_nodes.all %}
|
{% for product in edge.end_nodes.all %}
|
||||||
<a href="{{ product.url }}" class="btn btn-outline btn-sm"
|
<a href="{{ product.url }}" class="btn btn-outline btn-sm"
|
||||||
>{{ product.name }}</a
|
>{{ product.get_name }}</a
|
||||||
>
|
>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,7 +56,9 @@
|
|||||||
<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 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
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{% extends "framework_modern.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
|
{% load envipytags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@ -54,6 +55,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
{% if node.description and node.description != "no description" %}
|
||||||
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
|
<input type="checkbox" checked />
|
||||||
|
<div class="collapse-title text-xl font-medium">Description</div>
|
||||||
|
<div class="collapse-content">{{ node.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% epdb_slot_templates "epdb.objects.node.viz" as viz_templates %}
|
||||||
|
|
||||||
|
{% for tpl in viz_templates %}
|
||||||
|
{% include tpl %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Image Representation -->
|
<!-- Image Representation -->
|
||||||
<div class="collapse-arrow bg-base-200 collapse">
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
<input type="checkbox" checked />
|
<input type="checkbox" checked />
|
||||||
|
|||||||
@ -21,12 +21,13 @@
|
|||||||
.link {
|
.link {
|
||||||
stroke: #999;
|
stroke: #999;
|
||||||
stroke-opacity: 0.6;
|
stroke-opacity: 0.6;
|
||||||
/* marker-end: url(#arrow); */
|
stroke-width: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link_no_arrow {
|
.link_no_arrow {
|
||||||
stroke: #999;
|
stroke: #999;
|
||||||
stroke-opacity: 0.6;
|
stroke-opacity: 0.6;
|
||||||
|
stroke-width: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node image {
|
.node image {
|
||||||
@ -72,6 +73,15 @@
|
|||||||
stroke: red;
|
stroke: red;
|
||||||
stroke-width: 3px;
|
stroke-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inedge {
|
||||||
|
stroke: red;
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
.outedge {
|
||||||
|
stroke: green;
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<script src="{% static 'js/pw.js' %}"></script>
|
<script src="{% static 'js/pw.js' %}"></script>
|
||||||
|
|
||||||
@ -106,7 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Graphical Representation -->
|
<!-- Graphical Representation -->
|
||||||
<div class="collapse-arrow bg-base-200 collapse">
|
<div class="collapse-arrow bg-base-200 collapse overflow-y-auto">
|
||||||
<input type="checkbox" checked />
|
<input type="checkbox" checked />
|
||||||
<div class="collapse-title text-xl font-medium">
|
<div class="collapse-title text-xl font-medium">
|
||||||
Graphical Representation
|
Graphical Representation
|
||||||
@ -140,7 +150,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-96 p-2"
|
||||||
>
|
>
|
||||||
{% include "actions/objects/pathway.html" %}
|
{% include "actions/objects/pathway.html" %}
|
||||||
</ul>
|
</ul>
|
||||||
@ -168,6 +178,52 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2"
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-60 p-2"
|
||||||
>
|
>
|
||||||
|
<li>
|
||||||
|
<a id="compound-names-toggle-button" class="cursor-pointer">
|
||||||
|
<svg
|
||||||
|
id="compound-names-icon"
|
||||||
|
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-eye"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
Compound Names
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a id="reaction-names-toggle-button" class="cursor-pointer">
|
||||||
|
<svg
|
||||||
|
id="reaction-names-icon"
|
||||||
|
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-eye"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
Reaction Names
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% if pathway.setting.model.app_domain %}
|
{% if pathway.setting.model.app_domain %}
|
||||||
<li>
|
<li>
|
||||||
<a id="app-domain-toggle-button" class="cursor-pointer">
|
<a id="app-domain-toggle-button" class="cursor-pointer">
|
||||||
@ -367,6 +423,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"
|
||||||
@ -474,6 +542,10 @@
|
|||||||
{{ pathway.d3_json|json_script:"pathway" }}
|
{{ pathway.d3_json|json_script:"pathway" }}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// Global switch for compound names view
|
||||||
|
var compoundNamesViewEnabled = false;
|
||||||
|
// Gloabl switch for reaction names view
|
||||||
|
var reactionNamesViewEnabled = false;
|
||||||
// Global switch for app domain view
|
// Global switch for app domain view
|
||||||
var appDomainViewEnabled = false;
|
var appDomainViewEnabled = false;
|
||||||
// Global switch for timeseries view
|
// Global switch for timeseries view
|
||||||
@ -509,6 +581,58 @@
|
|||||||
descContent.innerHTML = newDesc;
|
descContent.innerHTML = newDesc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const compoundNamesBtn = document.getElementById(
|
||||||
|
"compound-names-toggle-button",
|
||||||
|
);
|
||||||
|
if (compoundNamesBtn) {
|
||||||
|
compoundNamesBtn.addEventListener("click", function () {
|
||||||
|
compoundNamesViewEnabled = !compoundNamesViewEnabled;
|
||||||
|
const icon = document.getElementById("compound-names-icon");
|
||||||
|
if (compoundNamesViewEnabled) {
|
||||||
|
// Change to eye-off icon
|
||||||
|
icon.innerHTML =
|
||||||
|
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
|
||||||
|
nodes.forEach((x) => {
|
||||||
|
if (x.name) {
|
||||||
|
d3.select(x.el)
|
||||||
|
.append("text")
|
||||||
|
.text((d) => d.plain_name)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.attr("y", -20)
|
||||||
|
.style("font-size", "4px");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Change back to eye icon
|
||||||
|
icon.innerHTML =
|
||||||
|
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
|
||||||
|
nodes.forEach((x) => {
|
||||||
|
d3.select(x.el).select("text").remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const reactionNamesBtn = document.getElementById(
|
||||||
|
"reaction-names-toggle-button",
|
||||||
|
);
|
||||||
|
if (reactionNamesBtn) {
|
||||||
|
reactionNamesBtn.addEventListener("click", function () {
|
||||||
|
reactionNamesViewEnabled = !reactionNamesViewEnabled;
|
||||||
|
const icon = document.getElementById("reaction-names-icon");
|
||||||
|
if (reactionNamesViewEnabled) {
|
||||||
|
// Change to eye-off icon
|
||||||
|
icon.innerHTML =
|
||||||
|
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
|
||||||
|
d3.selectAll(".link-label").style("display", null);
|
||||||
|
} else {
|
||||||
|
// Change back to eye icon
|
||||||
|
icon.innerHTML =
|
||||||
|
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
|
||||||
|
d3.selectAll(".link-label").style("display", "none");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// App domain toggle
|
// App domain toggle
|
||||||
const appDomainBtn = document.getElementById("app-domain-toggle-button");
|
const appDomainBtn = document.getElementById("app-domain-toggle-button");
|
||||||
if (appDomainBtn) {
|
if (appDomainBtn) {
|
||||||
|
|||||||
@ -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,21 @@
|
|||||||
</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);
|
||||||
@ -197,7 +212,13 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const button = this;
|
const button = this;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
|
// Set text depending on mode
|
||||||
|
if (document.getElementById("predict-submit-button").innerText === "Build") {
|
||||||
|
button.textContent = "Building...";
|
||||||
|
} else {
|
||||||
button.textContent = "Predicting...";
|
button.textContent = "Predicting...";
|
||||||
|
}
|
||||||
|
|
||||||
// Get SMILES from either input or Ketcher
|
// Get SMILES from either input or Ketcher
|
||||||
const smilesInput = document.getElementById("predict-smiles");
|
const smilesInput = document.getElementById("predict-smiles");
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from epdb.exceptions import InvalidSMILESException, InvalidMolfileException
|
||||||
from epdb.logic import PackageManager
|
from epdb.logic import PackageManager
|
||||||
from epdb.models import Compound, User, CompoundStructure
|
from epdb.models import Compound, User, CompoundStructure
|
||||||
|
|
||||||
@ -17,6 +19,10 @@ class CompoundTest(TestCase):
|
|||||||
cls.user = User.objects.get(username="anonymous")
|
cls.user = User.objects.get(username="anonymous")
|
||||||
cls.package = PackageManager.create_package(cls.user, "Anon Test Package", "No Desc")
|
cls.package = PackageManager.create_package(cls.user, "Anon Test Package", "No Desc")
|
||||||
|
|
||||||
|
# A valid V2000 molfile for 4-Nitrobenzoic acid (O=C(O)C1=CC=C([N+](=O)[O-])C=C1)
|
||||||
|
cls.VALID_MOLFILE = """\n Mrv2211 01012500002D\n\n 12 12 0 0 0 0 999 V2000\n 1.4289 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.0625 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 1.2375 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 0.8250 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.8875 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0\n 0.0000 -3.3000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0\n 1.4289 -3.3000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 2 0 0 0 0\n 2 3 1 0 0 0 0\n 3 4 2 0 0 0 0\n 4 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 1 1 0 0 0 0\n 2 7 1 0 0 0 0\n 7 8 2 0 0 0 0\n 7 9 1 0 0 0 0\n 5 10 1 0 0 0 0\n 10 11 1 0 0 0 0\n 10 12 2 0 0 0 0\nM CHG 2 10 1 11 -1\nM END\n"""
|
||||||
|
cls.INVALID_MOLFILE = "this is not a valid molfile"
|
||||||
|
|
||||||
def test_smoke(self):
|
def test_smoke(self):
|
||||||
c = Compound.create(
|
c = Compound.create(
|
||||||
self.package,
|
self.package,
|
||||||
@ -33,13 +39,13 @@ class CompoundTest(TestCase):
|
|||||||
self.assertEqual(c.description, "No Desc")
|
self.assertEqual(c.description, "No Desc")
|
||||||
|
|
||||||
def test_missing_smiles(self):
|
def test_missing_smiles(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSMILESException):
|
||||||
_ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc")
|
_ = Compound.create(self.package, smiles=None, name="Afoxolaner", description="No Desc")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSMILESException):
|
||||||
_ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc")
|
_ = Compound.create(self.package, smiles="", name="Afoxolaner", description="No Desc")
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSMILESException):
|
||||||
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
|
_ = Compound.create(self.package, smiles=" ", name="Afoxolaner", description="No Desc")
|
||||||
|
|
||||||
def test_smiles_are_trimmed(self):
|
def test_smiles_are_trimmed(self):
|
||||||
@ -96,7 +102,7 @@ class CompoundTest(TestCase):
|
|||||||
self.assertEqual(len(self.package.compounds), 1)
|
self.assertEqual(len(self.package.compounds), 1)
|
||||||
|
|
||||||
def test_wrong_smiles(self):
|
def test_wrong_smiles(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSMILESException):
|
||||||
_ = Compound.create(
|
_ = Compound.create(
|
||||||
self.package,
|
self.package,
|
||||||
smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F",
|
smiles="C1C(=NOC1(C2=CC(=CC(=C2)Cl)C(F)(F)F)C(F)(F)F)C3=CC=C(C=CC=CC=C43)C(=O)NCC(=O)NCC(F)(F)F",
|
||||||
@ -188,3 +194,153 @@ class CompoundTest(TestCase):
|
|||||||
|
|
||||||
c1.set_default_structure(c2)
|
c1.set_default_structure(c2)
|
||||||
self.assertNotEqual(default_structure, c2)
|
self.assertNotEqual(default_structure, c2)
|
||||||
|
|
||||||
|
def test_create_structure_from_molfile(self):
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="PLACEHOLDER",
|
||||||
|
molfile=self.VALID_MOLFILE,
|
||||||
|
name="Molfile Compound",
|
||||||
|
description="Created from molfile",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="PLACEHOLDER",
|
||||||
|
molfile=self.VALID_MOLFILE,
|
||||||
|
name="Molfile Structure",
|
||||||
|
description="Structure from molfile",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The SMILES must have been derived from the molfile, not the placeholder
|
||||||
|
self.assertNotEqual(cs.smiles, "PLACEHOLDER")
|
||||||
|
self.assertIsNotNone(cs.smiles)
|
||||||
|
self.assertTrue(len(cs.smiles) > 0)
|
||||||
|
|
||||||
|
def test_create_structure_from_molfile_stores_molfile(self):
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="c1c(C(=O)O)ccc([N+]([O-])=O)c1",
|
||||||
|
name="Molfile Store Test",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs = c.default_structure
|
||||||
|
|
||||||
|
# O=C(O)C1=CC=C([N+](=O)[O-])C=C1 will be overwritten with
|
||||||
|
# c1c(C(=O)O)ccc([N+]([O-])=O)c1 and molfile will be set
|
||||||
|
# on the existing structure
|
||||||
|
_ = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
molfile=self.VALID_MOLFILE,
|
||||||
|
name="Structure with Molfile",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch fresh from DB to confirm persistence
|
||||||
|
cs_db = CompoundStructure.objects.get(pk=cs.pk)
|
||||||
|
self.assertIsNotNone(cs_db.molfile)
|
||||||
|
self.assertNotEqual(cs_db.molfile.strip(), "")
|
||||||
|
|
||||||
|
def test_create_structure_from_invalid_molfile_raises(self):
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
name="Invalid Molfile Test",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(InvalidMolfileException):
|
||||||
|
CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
molfile=self.INVALID_MOLFILE,
|
||||||
|
name="Bad Structure",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_molfile_takes_precedence_over_smiles(self):
|
||||||
|
"""When both molfile and smiles are supplied, molfile must win."""
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
name="Precedence Test",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="C", # intentionally wrong / different SMILES
|
||||||
|
molfile=self.VALID_MOLFILE,
|
||||||
|
name="Molfile Wins",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
# SMILES should be derived from molfile, not the supplied "C"
|
||||||
|
self.assertNotEqual(cs.smiles, "C")
|
||||||
|
|
||||||
|
def test_empty_molfile_falls_back_to_smiles(self):
|
||||||
|
"""An empty or whitespace-only molfile should be ignored and SMILES used instead."""
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
name="Empty Molfile Test",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
molfile=" ", # whitespace only – should be ignored
|
||||||
|
name="Fallback to SMILES",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(cs.smiles, "O=C(O)C1=CC=C([N+](=O)[O-])C=C1")
|
||||||
|
|
||||||
|
def test_none_molfile_falls_back_to_smiles(self):
|
||||||
|
"""None as molfile should be ignored and SMILES used instead."""
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
name="None Molfile Test",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
molfile=None,
|
||||||
|
name="Fallback None Molfile",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(cs.smiles, "O=C(O)C1=CC=C([N+](=O)[O-])C=C1")
|
||||||
|
|
||||||
|
def test_molfile_deduplication(self):
|
||||||
|
"""Creating a structure twice from the same molfile should return the existing object."""
|
||||||
|
c = Compound.create(
|
||||||
|
self.package,
|
||||||
|
smiles="O=C(O)C1=CC=C([N+](=O)[O-])C=C1",
|
||||||
|
name="Molfile Dedup Test",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs1 = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="PLACEHOLDER",
|
||||||
|
molfile=self.VALID_MOLFILE,
|
||||||
|
name="Molfile Structure",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
cs2 = CompoundStructure.create(
|
||||||
|
compound=c,
|
||||||
|
smiles="PLACEHOLDER",
|
||||||
|
molfile=self.VALID_MOLFILE,
|
||||||
|
name="Molfile Structure",
|
||||||
|
description="No Desc",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(cs1.pk, cs2.pk)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from epdb.exceptions import InvalidSMILESException
|
||||||
from epdb.logic import PackageManager
|
from epdb.logic import PackageManager
|
||||||
from epdb.models import Compound, User, Reaction, Rule
|
from epdb.models import Compound, User, Reaction, Rule
|
||||||
|
|
||||||
@ -163,7 +164,7 @@ class ReactionTest(TestCase):
|
|||||||
self.assertEqual(len(self.package.reactions), 1)
|
self.assertEqual(len(self.package.reactions), 1)
|
||||||
|
|
||||||
def test_wrong_smiles(self):
|
def test_wrong_smiles(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(InvalidSMILESException):
|
||||||
_ = Reaction.create(
|
_ = Reaction.create(
|
||||||
package=self.package,
|
package=self.package,
|
||||||
name="Eawag BBD reaction r0001",
|
name="Eawag BBD reaction r0001",
|
||||||
|
|||||||
@ -11,6 +11,9 @@ from epdb.models import Compound, Scenario, ExternalDatabase
|
|||||||
class CompoundViewTest(TestCase):
|
class CompoundViewTest(TestCase):
|
||||||
fixtures = ["test_fixtures_incl_model.jsonl.gz"]
|
fixtures = ["test_fixtures_incl_model.jsonl.gz"]
|
||||||
|
|
||||||
|
# A valid V2000 molfile for 4-Nitrobenzoic acid (O=C(O)C1=CC=C([N+](=O)[O-])C=C1)
|
||||||
|
VALID_MOLFILE = """\n Mrv2211 01012500002D\n\n 12 12 0 0 0 0 999 V2000\n 1.4289 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -0.8250 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.0000 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.0625 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 -1.6500 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 0.4125 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 1.2375 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1.4289 0.8250 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 0.7145 -2.8875 0.0000 N 0 3 0 0 0 0 0 0 0 0 0 0\n 0.0000 -3.3000 0.0000 O 0 5 0 0 0 0 0 0 0 0 0 0\n 1.4289 -3.3000 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0\n 1 2 2 0 0 0 0\n 2 3 1 0 0 0 0\n 3 4 2 0 0 0 0\n 4 5 1 0 0 0 0\n 5 6 2 0 0 0 0\n 6 1 1 0 0 0 0\n 2 7 1 0 0 0 0\n 7 8 2 0 0 0 0\n 7 9 1 0 0 0 0\n 5 10 1 0 0 0 0\n 10 11 1 0 0 0 0\n 10 12 2 0 0 0 0\nM CHG 2 10 1 11 -1\nM END\n"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super(CompoundViewTest, cls).setUpClass()
|
super(CompoundViewTest, cls).setUpClass()
|
||||||
@ -396,3 +399,108 @@ class CompoundViewTest(TestCase):
|
|||||||
|
|
||||||
c = Compound.objects.get(url=compound_url)
|
c = Compound.objects.get(url=compound_url)
|
||||||
self.assertEqual(len(c.aliases), 0)
|
self.assertEqual(len(c.aliases), 0)
|
||||||
|
|
||||||
|
def test_create_compound_via_molfile(self):
|
||||||
|
"""POSTing a valid molfile without a SMILES should create a compound successfully."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("compounds"),
|
||||||
|
{
|
||||||
|
"compound-name": "4-Nitrobenzoic acid",
|
||||||
|
"compound-description": "Created from molfile",
|
||||||
|
"compound-molfile": self.VALID_MOLFILE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
compound_url = response.url
|
||||||
|
|
||||||
|
c = Compound.objects.get(url=compound_url)
|
||||||
|
|
||||||
|
self.assertEqual(c.name, "4-Nitrobenzoic acid")
|
||||||
|
self.assertEqual(c.description, "Created from molfile")
|
||||||
|
# SMILES should have been extracted from molfile, so it must be non-empty
|
||||||
|
self.assertIsNotNone(c.default_structure.smiles)
|
||||||
|
self.assertNotEqual(c.default_structure.smiles.strip(), "")
|
||||||
|
|
||||||
|
def test_create_compound_molfile_takes_precedence_over_smiles(self):
|
||||||
|
"""When both molfile and SMILES are submitted, the molfile should win."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("compounds"),
|
||||||
|
{
|
||||||
|
"compound-name": "4-Nitrobenzoic acid",
|
||||||
|
"compound-description": "Molfile precedence test",
|
||||||
|
"compound-smiles": "C", # intentionally wrong/different
|
||||||
|
"compound-molfile": self.VALID_MOLFILE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
compound_url = response.url
|
||||||
|
|
||||||
|
c = Compound.objects.get(url=compound_url)
|
||||||
|
|
||||||
|
# The resulting SMILES must NOT be the dummy "C" supplied via compound-smiles
|
||||||
|
self.assertNotEqual(c.default_structure.smiles, "C")
|
||||||
|
|
||||||
|
def test_create_compound_via_invalid_molfile_returns_error(self):
|
||||||
|
"""POSTing an invalid molfile should not create a compound and should return an error response."""
|
||||||
|
initial_count = self.user1_default_package.compounds.count()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("compounds"),
|
||||||
|
{
|
||||||
|
"compound-name": "Bad Molfile Compound",
|
||||||
|
"compound-description": "Should fail",
|
||||||
|
"compound-molfile": "this is not a molfile at all",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The view should signal an error (non-2xx or a redirect to an error page, not 302 to a new compound)
|
||||||
|
self.assertNotEqual(response.status_code, 302)
|
||||||
|
# No new compound should have been created
|
||||||
|
self.assertEqual(self.user1_default_package.compounds.count(), initial_count)
|
||||||
|
|
||||||
|
def test_create_compound_via_empty_molfile_falls_back_to_smiles(self):
|
||||||
|
"""Submitting an empty molfile with a valid SMILES should fall back to SMILES."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("compounds"),
|
||||||
|
{
|
||||||
|
"compound-name": "Fallback SMILES Compound",
|
||||||
|
"compound-description": "Empty molfile fallback",
|
||||||
|
"compound-smiles": "C(CCl)Cl",
|
||||||
|
"compound-molfile": " ", # whitespace only
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
compound_url = response.url
|
||||||
|
|
||||||
|
c = Compound.objects.get(url=compound_url)
|
||||||
|
self.assertEqual(c.default_structure.smiles, "C(CCl)Cl")
|
||||||
|
|
||||||
|
def test_create_compound_via_molfile_deduplication(self):
|
||||||
|
"""Submitting the same molfile twice should return the existing compound."""
|
||||||
|
response1 = self.client.post(
|
||||||
|
reverse("compounds"),
|
||||||
|
{
|
||||||
|
"compound-name": "4-Nitrobenzoic acid",
|
||||||
|
"compound-description": "Molfile dedup test",
|
||||||
|
"compound-molfile": self.VALID_MOLFILE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response1.status_code, 302)
|
||||||
|
compound_url_1 = response1.url
|
||||||
|
|
||||||
|
response2 = self.client.post(
|
||||||
|
reverse("compounds"),
|
||||||
|
{
|
||||||
|
"compound-name": "4-Nitrobenzoic acid",
|
||||||
|
"compound-description": "Molfile dedup test",
|
||||||
|
"compound-molfile": self.VALID_MOLFILE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response2.status_code, 302)
|
||||||
|
self.assertEqual(response2.url, compound_url_1)
|
||||||
|
self.assertEqual(self.user1_default_package.compounds.count(), 1)
|
||||||
|
|||||||
@ -46,6 +46,14 @@ class ModelViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
expected = [
|
expected = [
|
||||||
|
{
|
||||||
|
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
|
||||||
|
"probability": 0.75,
|
||||||
|
"btrule": {
|
||||||
|
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
|
||||||
|
"name": "bt0001-3568",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]],
|
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]],
|
||||||
"probability": 0.25,
|
"probability": 0.25,
|
||||||
@ -62,14 +70,6 @@ class ModelViewTest(TestCase):
|
|||||||
"name": "bt0243-4301",
|
"name": "bt0243-4301",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]],
|
|
||||||
"probability": 0.75,
|
|
||||||
"btrule": {
|
|
||||||
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
|
|
||||||
"name": "bt0001-3568",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
actual = response.json()["pred"]
|
actual = response.json()["pred"]
|
||||||
|
|||||||
@ -72,8 +72,10 @@ class PackageViewTest(TestCase):
|
|||||||
|
|
||||||
def test_import_package(self):
|
def test_import_package(self):
|
||||||
file = SimpleUploadedFile(
|
file = SimpleUploadedFile(
|
||||||
"Fixture_Package.json",
|
"EAWAG-BBD_32de3cf4-e3e6-4168-956e-32fa5ddb0ce1.json",
|
||||||
open(s.FIXTURE_DIRS[0] / "Fixture_Package.json", "rb").read(),
|
open(
|
||||||
|
s.FIXTURE_DIRS[0] / "EAWAG-BBD_32de3cf4-e3e6-4168-956e-32fa5ddb0ce1.json", "rb"
|
||||||
|
).read(),
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -86,10 +88,11 @@ class PackageViewTest(TestCase):
|
|||||||
|
|
||||||
p = Package.objects.get(url=package_url)
|
p = Package.objects.get(url=package_url)
|
||||||
|
|
||||||
self.assertEqual(p.pathways.count(), 22)
|
self.assertEqual(p.pathways.count(), 219)
|
||||||
self.assertEqual(p.rules.count(), 45)
|
self.assertEqual(p.rules.count(), 498)
|
||||||
self.assertEqual(p.compounds.count(), 223)
|
self.assertEqual(p.compounds.count(), 1399)
|
||||||
self.assertEqual(p.reactions.count(), 212)
|
self.assertEqual(p.reactions.count(), 1480)
|
||||||
|
self.assertEqual(p.scenarios.count(), 1914)
|
||||||
|
|
||||||
upp = UserPackagePermission.objects.get(package=p, user=self.user1)
|
upp = UserPackagePermission.objects.get(package=p, user=self.user1)
|
||||||
self.assertEqual(upp.permission, Permission.ALL[0])
|
self.assertEqual(upp.permission, Permission.ALL[0])
|
||||||
|
|||||||
@ -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!")
|
||||||
|
|
||||||
|
|||||||
1426
utilities/misc.py
1426
utilities/misc.py
File diff suppressed because it is too large
Load Diff
34
uv.lock
generated
34
uv.lock
generated
@ -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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user