37 Commits

Author SHA1 Message Date
6499a0c659 [Feature] Prediction settings list on User page (#276)
Some checks failed
Build CI Docker Image / build-and-push (push) Failing after 23s
I have added a list of other prediction settings to the User page and a way to change a setting to the default.
<img width="500" alt="{4EFA1273-E53A-4333-948B-8AE3597821A8}.png" src="attachments/048fdc83-1c3e-41d2-a59b-44b0337a05bf">

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#276
Reviewed-by: jebus <lorsbach@envipath.com>
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2025-12-20 03:19:31 +13:00
7c60a28801 [Feature] Threshold Warning + Cosmetics (#277)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#277
2025-12-20 02:11:47 +13:00
a4a4179261 [Fix] Added libglib2.0 (#280)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#280
2025-12-20 01:26:25 +13:00
6ee4ac535a [Fix] Added libfreetype6 and libcairo2 (#279)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#279
2025-12-20 00:26:23 +13:00
d6065ee888 [Fix] Add libxrender, libxext6 and libfontconfig1 libs to envipy image (#278)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#278
2025-12-19 23:03:02 +13:00
9db4806d75 [Chore] Add custom CI Docker image with Node.js 24, pnpm 10, and uv (#268)
This is meant to drastically speed up CI because it skips the lengthy installation.

Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#268
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-12-17 23:06:58 +13:00
4bf20e62ef [Fix] UI Fixes (#266)
Rather than have a bunch of pull-requests that @jebus will have to merge shall we collect some of the fixes for the UI issues I found in here.

- [x] #259
- [x] #260
- [x] #261
- [x] #262
- [x] #263
- [x] #264
- [x] #265

Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Reviewed-on: enviPath/enviPy#266
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2025-12-15 21:28:43 +13:00
8adb93012a [Feature] Server pagination implementation (#243)
## Major Changes
- Implement a REST style API app in epapi
- Currently implements a GET method for all entity types in the browse menu (both package level and global)
- Provides paginated results per default with query style filtering for reviewed vs unreviewed.
- Provides new paginated templates with thin wrappers per entity types for easier maintainability
- Implements e2e tests for the API

## Minor changes
- Added more comprehensive gitignore to cover coverage reports and other test/node.js etc. data.
- Add additional CI file for API tests that only gets triggered on API relevant changes.

## ⚠️ Currently only works with session-based authentication. Token based will be added in new PR.

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#243
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-12-15 11:34:53 +13:00
d2d475b990 [Feature] Show Multi Gen Eval + Batch Prediction (#267)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#267
2025-12-15 08:48:28 +13:00
648ec150a9 [Feature] Engineer Pathway (#256)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#256
2025-12-10 07:35:42 +13:00
46b0f1c124 [Fix] Remove bootsrap code from AD (#257)
Closes #251

Reviewed-on: enviPath/enviPy#257
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-12-09 01:02:12 +13:00
d5af898053 [Fix] Create ML model command fixed (#255) (#258)
Removed the eval packages being incorrectly passed to the create method of enviFormer and MLRelativeReasoning.

Reviewed-on: enviPath/enviPy#258
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2025-12-09 00:57:35 +13:00
b7379b3337 [Fix] Remove double action menu on pathway (#254)
Reviewed-on: enviPath/enviPy#254
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-12-03 21:38:36 +13:00
d6440f416c [Fix] Frontend Testing Fixtures (#249)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Reviewed-on: enviPath/enviPy#249
2025-12-03 10:49:23 +13:00
901de4640c [Fix] Stereochemistry prediction handling (#228 and #238) (#250)
**This pull request will need a separate migration pull-request**

I have added an alert box in two places when the user tries to predict with stereo chemistry.

When a user predicts a pathway with stereo chemistry an alert box is shown in that node's hover.
To do this I added two new fields. Pathway now has a "predicted" BooleanField indicating whether it was predicted or not. It is set to True if the pathway mode for prediction is "predict" or "incremental" and False if it is "build". I think it is a flag that could be useful in the future, perhaps for analysing how many predicted pathways are in enviPath?
Node now has a `stereo_removed` BooleanField which is set to True if the Node's parent Pathways has "predicted" as true and the node SMILES has stereochemistry.
<img width="500" alt="{927AC9FF-DBC9-4A19-9E6E-0EDD3B08C7AC}.png" src="attachments/69ea29bc-c2d2-4cd2-8e98-aae5c5737f69">

When a user does a prediction on a model's page it shows at the top of the list. This did not require any new fields as the entered SMILES does not get saved anywhere.
<img width="500" alt="{BED66F12-5F07-419E-AAA6-FE1FE5B4F266}.png" src="attachments/5fcc3a9b-4d1a-4e48-acac-76b7571f6507">

I think the alert box is an alright solution but if you have a great idea for something that looks/fits better please change it or let me know.

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#250
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2025-12-03 10:19:34 +13:00
69df139256 [Fix] Registration (#247)
Fixes:

- Register now works again with the html form action pointing to `register` instead of `login`

Since this is a major issue the above change should probably be merged soon. However, I will open another issue (#248) suggesting we add better help for password creation as currently we give password requirements but do not check them.

Reviewed-on: enviPath/enviPy#247
Reviewed-by: Tobias O <tobias.olenyi@envipath.com>
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2025-12-01 20:47:04 +13:00
e8ae494c16 [Feature] Implemented SMARTS filtering for Rules (#246)
Reactant Filter SMARTS as well as Product Filter SMARTS are now reflected when applying rules.

Fixes #245

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#246
2025-11-28 23:28:41 +13:00
fd2e2c2534 [Fix] Post Modern UI deploy Bugfixes (#240)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#240
2025-11-27 10:28:04 +13:00
1a2c9bb543 [Feature] Modern UI roll out (#236)
This PR moves all the collection pages into the new UI in a rough push.
I did not put the same amount of care into these as into search, index, and predict.

## Major changes

- All modals are now migrated to a state based alpine.js implementation.
- jQuery is no longer present in the base layout; ajax is replace by native fetch api
- most of the pps.js is now obsolte (as I understand it; the code is not referenced any more @jebus  please double check)
- in-memory pagination for large result lists (set to 50; we can make that configurable later; performance degrades at around 1k) stukk a bit rough tracked in #235

## Minor things

- Sarch and index also use alpine now
- The loading spinner is now CSS animated (not sure if it currently gets correctly called)

## Not done

- Ihave not even cheked the admin pages. Not sure If these need migrations
- The temporary migration pages still use the old template. Not sure what is supposed to happen with those? @jebus

## What I did to test

- opend all pages in browse, and user ; plus all pages reachable from there.
- Interacted and tested the functionality of each modal superfically with exception of the API key modal (no functional test).

---
This PR is massive sorry for that; just did not want to push half-brokenn state.
@jebus @liambrydon I would be glad if you could click around and try to break it :)

Finally closes #133

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#236
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-26 23:16:44 +13:00
7f6f209b4a [Feature] Frontend Testing #140 (#218)
I added playwright for frontend testing and got a couple simple test cases working.
I have updated pyproject.toml but it can also be installed with `pip install pytest-playwright` followed by `playwright install`

With the django server running you can do `playwright codegen http://localhost:8000/` which will generate test code based on the actions you take on the webpage it opens. Be sure to change the target to pytest in the code pop up.

I will add more test cases but @jebus and @t03i feel free to add more. Especially once we are done with the full front-end redesign.

I have put the tests under `tests/frontend/` but I am not sure how to add them to the CI. They give steps for CI integration but maybe we want to somehow include them in our exisiting CI yaml? https://playwright.dev/python/docs/ci-intro

Reviewed-on: enviPath/enviPy#218
Reviewed-by: Tobias O <tobias.olenyi@envipath.com>
Co-authored-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
Co-committed-by: Liam Brydon <lbry121@aucklanduni.ac.nz>
2025-11-26 19:44:35 +13:00
b6c35fea76 [Feature] Search API Endpoint (#227)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#227
2025-11-20 09:56:11 +13:00
fa8a191383 [Fix] Show the User who ran the Job for Admins (#226)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#226
2025-11-20 08:05:15 +13:00
67b1baa5b0 [Feature] Legacy API (#224)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#224
2025-11-19 20:45:16 +13:00
89c194dcca [Enhancement] Restyle Discourse Cards for title only (#220)
Excerpts are only delivered for pinned posts. So all cards apart from pinned look empty.
Changed to only display (more of) the title now.

closes  #214

Reviewed-on: enviPath/enviPy#220
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-14 21:43:52 +13:00
a8554c903c [Enhancement] Swappable Packages (#216)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#216
Reviewed-by: liambrydon <lbry121@aucklanduni.ac.nz>
Reviewed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-14 21:42:39 +13:00
d584791ee8 [Fix] Ketcher submission now recognized (#213)
This will hack the ketcher submission to work again (see #207).
The problem seems to be that the iframe loads slower than the script tag so the reference is not available on page load.

Registering from within the code to poll until ketcher is ready is a bit messy.
Tracked the introduced dept in #212.

Reviewed-on: enviPath/enviPy#213
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-13 23:27:29 +13:00
e60052b05c [Fix] Remove Search from Old Framework Navbar (#211)
fixes #204

Reviewed-on: enviPath/enviPy#211
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-13 23:16:52 +13:00
3ff8d938d6 [Fix] Advanced now redirects to predict_pathway. (#210)
fixes #208

Reviewed-on: enviPath/enviPy#210
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-13 23:15:50 +13:00
a7f48c2cf9 [Fix] Predict page scrolls to submit button (#209)
Autofocus on form is automatically placed on cancel button. Now it is on Name.

fixes #205

Reviewed-on: enviPath/enviPy#209
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-13 23:15:08 +13:00
39faab3d11 [Fix] Add extra styles to make show login form (#203)
FIx display on the login page

Reviewed-on: enviPath/enviPy#203
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-13 09:11:32 +13:00
4e80cd63cd [Fix] Added loading of envipytags (#201)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#201
2025-11-13 08:43:21 +13:00
6592f0a68e [Fix] Package Link + Adjusted License container (#197)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#197
2025-11-13 08:35:42 +13:00
21d30a923f [Refactor] Large scale formatting/linting (#193)
All html files now prettier formatted and fixes for incompatible blocks applied

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#193
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-12 22:47:10 +13:00
12a20756d6 [Fix] Add/Update missing nav links (#196)
Adds packages to the footer (instead of Browse link) and removes search from footer (search page not directly linked anymore).
Packages are also available in Browse now.

This is temporary until there is a proper data browsing page.

Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#196
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-12 22:24:33 +13:00
d20a705011 [Feature] Add per-package pathway prediction (#195)
## Major Changes

- Introduces a new view for per-package predictions

Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#195
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-12 22:01:34 +13:00
debbef8158 [Enhancement] Cleanup Landing Page Form (#194)
I changed the toggle style to be more self evident.
Do you think this is enough, or should I add an (ugly) label?

![image.png](/attachments/0e4ce043-7544-4852-9db9-460517b36d64)

Co-authored-by: jebus <lorsbach@envipath.com>
Reviewed-on: enviPath/enviPy#194
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-12 21:42:02 +13:00
2799718951 fix: open and close search modal (#192)
Modal now opens on badge click.
Modal now closes on random click around

Reviewed-on: enviPath/enviPy#192
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
2025-11-12 20:53:52 +13:00
228 changed files with 21752 additions and 10676 deletions

View File

@ -20,3 +20,16 @@ LOG_LEVEL='INFO'
SERVER_URL='http://localhost:8000' SERVER_URL='http://localhost:8000'
PLUGINS_ENABLED=True PLUGINS_ENABLED=True
EP_DATA_DIR='data' EP_DATA_DIR='data'
EMAIL_HOST_USER='admin@envipath.com'
EMAIL_HOST_PASSWORD='dummy-password'
DEFAULT_FROM_EMAIL="test@test.com"
SERVER_EMAIL='test@test.com'
# Testing settings VScode
DJANGO_SETTINGS_MODULE='envipath.settings'
MANAGE_PY_PATH='./manage.py'
APPLICABILITY_DOMAIN_ENABLED=True
ENVIFORMER_PRESENT=True
MODEL_BUILDING_ENABLED=True

View File

@ -0,0 +1,72 @@
name: 'Setup enviPy Environment'
description: 'Shared setup for enviPy CI - installs dependencies and prepares environment'
inputs:
skip-frontend:
description: 'Skip frontend build steps (pnpm, tailwind)'
required: false
default: 'false'
skip-playwright:
description: 'Skip Playwright installation'
required: false
default: 'false'
ssh-private-key:
description: 'SSH private key for git access'
required: true
run-migrations:
description: 'Run Django migrations after setup'
required: false
default: 'true'
runs:
using: "composite"
steps:
- name: Setup ssh
shell: bash
run: |
mkdir -p ~/.ssh
echo "${{ inputs.ssh-private-key }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
- name: Setup Python venv
shell: bash
run: |
uv sync --locked --all-extras --dev
- name: Install Playwright
if: inputs.skip-playwright == 'false'
shell: bash
run: |
source .venv/bin/activate
playwright install --with-deps
- name: Build Frontend
if: inputs.skip-frontend == 'false'
shell: bash
run: |
uv run python scripts/pnpm_wrapper.py install
cat << 'EOF' > pnpm-workspace.yaml
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
EOF
uv run python scripts/pnpm_wrapper.py run build
- name: Wait for Postgres
shell: bash
run: |
until pg_isready -h postgres -U ${{ env.POSTGRES_USER }}; do
echo "Waiting for postgres..."
sleep 2
done
echo "Postgres is ready!"
- name: Run Django Migrations
if: inputs.run-migrations == 'true'
shell: bash
run: |
source .venv/bin/activate
python manage.py migrate --noinput

View File

@ -0,0 +1,53 @@
# Custom CI Docker image for Gitea runners
# Pre-installs Node.js 24, pnpm 10, uv, and system dependencies
# to eliminate setup time in CI workflows
FROM ubuntu:24.04
# Prevent interactive prompts during package installation
ENV DEBIAN_FRONTEND=noninteractive
# Install system dependencies
RUN apt-get update && \
apt-get install -y \
postgresql-client \
redis-tools \
openjdk-11-jre-headless \
curl \
ca-certificates \
gnupg \
lsb-release \
git \
ssh \
libxrender1 \
libxext6 \
libfontconfig1 \
libfreetype6 \
libcairo2 \
libglib2.0-0t64 \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 24 via NodeSource
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# Enable corepack and install pnpm 10
RUN corepack enable && \
corepack prepare pnpm@10 --activate
# Install uv https://docs.astral.sh/uv/guides/integration/docker/#available-images
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PATH="/root/.cargo/bin:${PATH}"
# Verify installations
RUN node --version && \
npm --version && \
pnpm --version && \
uv --version && \
pg_isready --version && \
redis-cli --version && \
java -version
# Set working directory
WORKDIR /workspace

View File

@ -0,0 +1,86 @@
name: API CI
on:
pull_request:
branches:
- develop
paths:
- 'epapi/**'
- 'epdb/models.py' # API depends on models
- 'epdb/logic.py' # API depends on business logic
- 'tests/fixtures/**' # API tests use fixtures
workflow_dispatch:
jobs:
api-tests:
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }}
runs-on: ubuntu-latest
container:
image: git.envipath.com/envipath/envipy-ci:latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: ${{ vars.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_DB: ${{ vars.POSTGRES_DB }}
ports:
- ${{ vars.POSTGRES_PORT}}:5432
options: >-
--health-cmd="pg_isready -U postgres"
--health-interval=10s
--health-timeout=5s
--health-retries=5
env:
RUNNER_TOOL_CACHE: /toolcache
EP_DATA_DIR: /opt/enviPy/
ALLOWED_HOSTS: 127.0.0.1,localhost
DEBUG: True
LOG_LEVEL: INFO
MODEL_BUILDING_ENABLED: True
APPLICABILITY_DOMAIN_ENABLED: True
ENVIFORMER_PRESENT: True
ENVIFORMER_DEVICE: cpu
FLAG_CELERY_PRESENT: False
PLUGINS_ENABLED: True
SERVER_URL: http://localhost:8000
ADMIN_APPROVAL_REQUIRED: True
REGISTRATION_MANDATORY: True
LOG_DIR: ''
# DB
POSTGRES_SERVICE_NAME: postgres
POSTGRES_DB: ${{ vars.POSTGRES_DB }}
POSTGRES_USER: ${{ vars.POSTGRES_USER }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
POSTGRES_PORT: 5432
# SENTRY
SENTRY_ENABLED: False
# MS ENTRA
MS_ENTRA_ENABLED: False
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Use shared setup action - skips frontend builds for API-only tests
- name: Setup enviPy Environment
uses: ./.gitea/actions/setup-envipy
with:
skip-frontend: 'true'
skip-playwright: 'false'
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
run-migrations: 'true'
- name: Run API tests
run: |
.venv/bin/python manage.py test epapi -v 2
- name: Test API endpoints availability
run: |
.venv/bin/python manage.py runserver 0.0.0.0:8000 &
SERVER_PID=$!
sleep 5
curl -f http://localhost:8000/api/v1/docs || echo "API docs not available"
kill $SERVER_PID

View File

@ -0,0 +1,48 @@
name: Build CI Docker Image
on:
workflow_dispatch:
push:
branches:
- develop
- main
paths:
- '.gitea/docker/Dockerfile.ci'
- '.gitea/workflows/build-ci-image.yaml'
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.envipath.com
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.envipath.com/envipath/envipy-ci
tags: |
type=raw,value=latest
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: .gitea/docker/Dockerfile.ci
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=git.envipath.com/envipath/envipy-ci:latest
cache-to: type=inline

View File

@ -8,7 +8,10 @@ on:
jobs: jobs:
test: test:
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
container:
image: git.envipath.com/envipath/envipy-ci:latest
services: services:
postgres: postgres:
@ -40,7 +43,7 @@ jobs:
EP_DATA_DIR: /opt/enviPy/ EP_DATA_DIR: /opt/enviPy/
ALLOWED_HOSTS: 127.0.0.1,localhost ALLOWED_HOSTS: 127.0.0.1,localhost
DEBUG: True DEBUG: True
LOG_LEVEL: DEBUG LOG_LEVEL: INFO
MODEL_BUILDING_ENABLED: True MODEL_BUILDING_ENABLED: True
APPLICABILITY_DOMAIN_ENABLED: True APPLICABILITY_DOMAIN_ENABLED: True
ENVIFORMER_PRESENT: True ENVIFORMER_PRESENT: True
@ -63,54 +66,22 @@ jobs:
MS_ENTRA_ENABLED: False MS_ENTRA_ENABLED: False
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install system tools via apt # Use shared setup action - includes all dependencies and migrations
run: | - name: Setup enviPy Environment
sudo apt-get update uses: ./.gitea/actions/setup-envipy
sudo apt-get install -y postgresql-client redis-tools openjdk-11-jre-headless
- name: Setup ssh
run: |
echo "${{ secrets.ENVIPY_CI_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
- name: Install pnpm
uses: pnpm/action-setup@v4
with: with:
version: 10 skip-frontend: 'false'
skip-playwright: 'false'
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
run-migrations: 'true'
- name: Use Node.js - name: Run frontend tests
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Setup venv
run: | run: |
uv sync --locked --all-extras --dev .venv/bin/python manage.py test --tag frontend
- name: Wait for services
run: |
until pg_isready -h postgres -U postgres; do sleep 2; done
# until redis-cli -h redis ping; do sleep 2; done
- name: Run Django Migrations
run: |
source .venv/bin/activate
python manage.py migrate --noinput
- name: Run Django tests - name: Run Django tests
run: | run: |
source .venv/bin/activate .venv/bin/python manage.py test tests --exclude-tag slow --exclude-tag frontend
python manage.py test tests --exclude-tag slow

372
.gitignore vendored
View File

@ -1,17 +1,375 @@
*.pyc
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3 db.sqlite3
.idea/ db.sqlite3-journal
static/admin/ static/admin/
static/django_extensions/ static/django_extensions/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env .env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
.vscode/
*.code-workspace
# Ruff stuff:
.ruff_cache/
# UV cache
.uv-cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
### Agents ###
.claude/
.codex/
.cursor/
.github/prompts/
.junie/
.windsurf/
AGENTS.md
CLAUDE.md
GEMINI.md
.aider.*
### Node.js ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
### Custom ###
debug.log debug.log
scratches/ scratches/
test-results/
data/ data/
*.arff
.DS_Store # Auto generated
node_modules/
static/css/output.css static/css/output.css
*.code-workspace # macOS system files
.DS_Store
.Trashes
._*

View File

@ -1,4 +1,4 @@
from epdb.api import router as epdb_app_router from epapi.v1.router import router as v1_router # Refactored API from epdb.api_v2
from epdb.legacy_api import router as epdb_legacy_app_router from epdb.legacy_api import router as epdb_legacy_app_router
from ninja import NinjaAPI from ninja import NinjaAPI
@ -8,5 +8,5 @@ api_v1 = NinjaAPI(title="API V1 Docs", urls_namespace="api-v1")
api_legacy = NinjaAPI(title="Legacy API Docs", urls_namespace="api-legacy") api_legacy = NinjaAPI(title="Legacy API Docs", urls_namespace="api-legacy")
# Add routers # Add routers
api_v1.add_router("/", epdb_app_router) api_v1.add_router("/", v1_router)
api_legacy.add_router("/", epdb_legacy_app_router) api_legacy.add_router("/", epdb_legacy_app_router)

View File

@ -48,10 +48,25 @@ INSTALLED_APPS = [
"django_extensions", "django_extensions",
"oauth2_provider", "oauth2_provider",
# Custom # Custom
"epapi", # API endpoints (v1, etc.)
"epdb", "epdb",
"migration", # "migration",
] ]
TENANT = os.environ.get("TENANT", "public")
if TENANT != "public":
INSTALLED_APPS.append(TENANT)
EPDB_PACKAGE_MODEL = os.environ.get("EPDB_PACKAGE_MODEL", "epdb.Package")
def GET_PACKAGE_MODEL():
from django.apps import apps
return apps.get_model(EPDB_PACKAGE_MODEL)
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
] ]
@ -184,6 +199,12 @@ if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR) os.mkdir(LOG_DIR)
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins") PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50))
PAGINATION_MAX_PER_PAGE_SIZE = int(
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
) # Ninja override
if not os.path.exists(PLUGIN_DIR): if not os.path.exists(PLUGIN_DIR):
os.mkdir(PLUGIN_DIR) os.mkdir(PLUGIN_DIR)
@ -341,6 +362,7 @@ FLAGS = {
# -> /password_reset/done is covered as well # -> /password_reset/done is covered as well
LOGIN_EXEMPT_URLS = [ LOGIN_EXEMPT_URLS = [
"/register", "/register",
"/api/v1/", # Let API handle its own authentication
"/api/legacy/", "/api/legacy/",
"/o/token/", "/o/token/",
"/o/userinfo/", "/o/userinfo/",
@ -352,7 +374,7 @@ LOGIN_EXEMPT_URLS = [
"/cookie-policy", "/cookie-policy",
"/about", "/about",
"/contact", "/contact",
"/jobs", "/careers",
"/cite", "/cite",
"/legal", "/legal",
] ]

View File

@ -23,12 +23,20 @@ from .api import api_v1, api_legacy
urlpatterns = [ urlpatterns = [
path("", include("epdb.urls")), path("", include("epdb.urls")),
path("", include("migration.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/v1/", api_v1.urls), path("api/v1/", api_v1.urls),
path("api/legacy/", api_legacy.urls), path("api/legacy/", api_legacy.urls),
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
] ]
if "migration" in s.INSTALLED_APPS:
urlpatterns.append(path("", include("migration.urls")))
if s.MS_ENTRA_ENABLED: if s.MS_ENTRA_ENABLED:
urlpatterns.append(path("", include("epauth.urls"))) urlpatterns.append(path("", include("epauth.urls")))
# Custom error handlers
handler400 = "epdb.views.handler400"
handler403 = "epdb.views.handler403"
handler404 = "epdb.views.handler404"
handler500 = "epdb.views.handler500"

0
epapi/__init__.py Normal file
View File

6
epapi/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class EpapiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "epapi"

View File

1
epapi/tests/__init__.py Normal file
View File

@ -0,0 +1 @@
# Tests for epapi app

View File

@ -0,0 +1 @@
# Tests for epapi v1 API

View File

@ -0,0 +1,532 @@
from django.test import TestCase, tag
from epdb.logic import GroupManager, PackageManager, UserManager
from epdb.models import (
Compound,
GroupPackagePermission,
Permission,
UserPackagePermission,
)
@tag("api", "end2end")
class APIPermissionTestBase(TestCase):
"""
Base class for API permission tests.
Sets up common test data:
- user1: Owner of packages
- user2: User with various permissions
- user3: User with no permissions
- reviewed_package: Public package (reviewed=True)
- unreviewed_package_owned: Unreviewed package owned by user1
- unreviewed_package_read: Unreviewed package with READ permission for user2
- unreviewed_package_write: Unreviewed package with WRITE permission for user2
- unreviewed_package_all: Unreviewed package with ALL permission for user2
- unreviewed_package_no_access: Unreviewed package with no permissions for user2/user3
- group_package: Unreviewed package accessible via group permission
- test_group: Group containing user2
"""
@classmethod
def setUpTestData(cls):
# Create users
cls.user1 = UserManager.create_user(
"permission-user1",
"permission-user1@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.user2 = UserManager.create_user(
"permission-user2",
"permission-user2@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
cls.user3 = UserManager.create_user(
"permission-user3",
"permission-user3@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
# Delete default packages to ensure clean test data
for user in [cls.user1, cls.user2, cls.user3]:
default_pkg = user.default_package
user.default_package = None
user.save()
if default_pkg:
default_pkg.delete()
# Create reviewed package (public)
cls.reviewed_package = PackageManager.create_package(
cls.user1, "Reviewed Package", "Public package"
)
cls.reviewed_package.reviewed = True
cls.reviewed_package.save()
# Create unreviewed packages with various permissions
cls.unreviewed_package_owned = PackageManager.create_package(
cls.user1, "User1 Owned Package", "Owned by user1"
)
cls.unreviewed_package_read = PackageManager.create_package(
cls.user1, "User2 Read Package", "User2 has READ permission"
)
UserPackagePermission.objects.create(
user=cls.user2, package=cls.unreviewed_package_read, permission=Permission.READ[0]
)
cls.unreviewed_package_write = PackageManager.create_package(
cls.user1, "User2 Write Package", "User2 has WRITE permission"
)
UserPackagePermission.objects.create(
user=cls.user2, package=cls.unreviewed_package_write, permission=Permission.WRITE[0]
)
cls.unreviewed_package_all = PackageManager.create_package(
cls.user1, "User2 All Package", "User2 has ALL permission"
)
UserPackagePermission.objects.create(
user=cls.user2, package=cls.unreviewed_package_all, permission=Permission.ALL[0]
)
cls.unreviewed_package_no_access = PackageManager.create_package(
cls.user1, "No Access Package", "No permissions for user2/user3"
)
# Create group and group package
cls.test_group = GroupManager.create_group(
cls.user1, "Test Group", "Group for permission testing"
)
cls.test_group.user_member.add(cls.user2)
cls.test_group.save()
cls.group_package = PackageManager.create_package(
cls.user1, "Group Package", "Accessible via group permission"
)
GroupPackagePermission.objects.create(
group=cls.test_group, package=cls.group_package, permission=Permission.READ[0]
)
# Create test compounds in each package
cls.reviewed_compound = Compound.create(
cls.reviewed_package, "C", "Reviewed Compound", "Test compound"
)
cls.owned_compound = Compound.create(
cls.unreviewed_package_owned, "CC", "Owned Compound", "Test compound"
)
cls.read_compound = Compound.create(
cls.unreviewed_package_read, "CCC", "Read Compound", "Test compound"
)
cls.write_compound = Compound.create(
cls.unreviewed_package_write, "CCCC", "Write Compound", "Test compound"
)
cls.all_compound = Compound.create(
cls.unreviewed_package_all, "CCCCC", "All Compound", "Test compound"
)
cls.no_access_compound = Compound.create(
cls.unreviewed_package_no_access, "CCCCCC", "No Access Compound", "Test compound"
)
cls.group_compound = Compound.create(
cls.group_package, "CCCCCCC", "Group Compound", "Test compound"
)
@tag("api", "end2end")
class PackageListPermissionTest(APIPermissionTestBase):
"""
Test permissions for /api/v1/packages/ endpoint.
Special case: This endpoint allows anonymous access (auth=None)
"""
ENDPOINT = "/api/v1/packages/"
def test_anonymous_user_sees_only_reviewed_packages(self):
"""Anonymous users should only see reviewed packages."""
self.client.logout()
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Should only see reviewed package
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.reviewed_package.uuid))
self.assertEqual(payload["items"][0]["review_status"], "reviewed")
def test_authenticated_user_sees_all_readable_packages(self):
"""Authenticated users see reviewed + packages they have access to."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2 should see:
# - reviewed_package (public)
# - unreviewed_package_read (READ permission)
# - unreviewed_package_write (WRITE permission)
# - unreviewed_package_all (ALL permission)
# - group_package (via group membership)
# Total: 5 packages
self.assertEqual(payload["total_items"], 5)
visible_uuids = {item["uuid"] for item in payload["items"]}
expected_uuids = {
str(self.reviewed_package.uuid),
str(self.unreviewed_package_read.uuid),
str(self.unreviewed_package_write.uuid),
str(self.unreviewed_package_all.uuid),
str(self.group_package.uuid),
}
self.assertEqual(visible_uuids, expected_uuids)
def test_owner_sees_all_owned_packages(self):
"""Package owner sees all packages they created."""
self.client.force_login(self.user1)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# user1 owns all packages
# Total: 7 packages (all packages created in setUpTestData)
self.assertEqual(payload["total_items"], 7)
def test_filter_by_review_status_true(self):
"""Filter to show only reviewed packages."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT, {"review_status": True})
self.assertEqual(response.status_code, 200)
payload = response.json()
# Only reviewed_package
self.assertEqual(payload["total_items"], 1)
self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"]))
def test_filter_by_review_status_false(self):
"""Filter to show only unreviewed packages."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT, {"review_status": False})
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2's accessible unreviewed packages: 4
self.assertEqual(payload["total_items"], 4)
self.assertTrue(all(item["review_status"] == "unreviewed" for item in payload["items"]))
def test_anonymous_filter_unreviewed_returns_empty(self):
"""Anonymous users get no results when filtering for unreviewed."""
self.client.logout()
response = self.client.get(self.ENDPOINT, {"review_status": False})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 0)
@tag("api", "end2end")
class GlobalCompoundListPermissionTest(APIPermissionTestBase):
"""
Test permissions for /api/v1/compounds/ endpoint.
This endpoint requires authentication.
"""
ENDPOINT = "/api/v1/compounds/"
def test_anonymous_user_cannot_access(self):
"""Anonymous users should get 401 Unauthorized."""
self.client.logout()
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 401)
def test_authenticated_user_sees_compounds_from_readable_packages(self):
"""Authenticated users see compounds from packages they can read."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2 should see compounds from:
# - reviewed_package (public)
# - unreviewed_package_read (READ permission)
# - unreviewed_package_write (WRITE permission)
# - unreviewed_package_all (ALL permission)
# - group_package (via group membership)
# Total: 5 compounds
self.assertEqual(payload["total_items"], 5)
visible_uuids = {item["uuid"] for item in payload["items"]}
expected_uuids = {
str(self.reviewed_compound.uuid),
str(self.read_compound.uuid),
str(self.write_compound.uuid),
str(self.all_compound.uuid),
str(self.group_compound.uuid),
}
self.assertEqual(visible_uuids, expected_uuids)
def test_user_without_permission_cannot_see_compound(self):
"""User without permission to package cannot see its compounds."""
self.client.force_login(self.user3)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# user3 should only see compounds from reviewed_package
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.reviewed_compound.uuid))
def test_owner_sees_all_compounds(self):
"""Package owner sees all compounds they created."""
self.client.force_login(self.user1)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# user1 owns all packages, so sees all compounds
self.assertEqual(payload["total_items"], 7)
def test_read_permission_allows_viewing(self):
"""READ permission allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that read_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.read_compound.uuid), uuids)
def test_write_permission_allows_viewing(self):
"""WRITE permission also allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that write_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.write_compound.uuid), uuids)
def test_all_permission_allows_viewing(self):
"""ALL permission allows viewing compounds."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that all_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.all_compound.uuid), uuids)
def test_group_permission_allows_viewing(self):
"""Group membership grants access to group-permitted packages."""
self.client.force_login(self.user2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Check that group_compound is included
uuids = [item["uuid"] for item in payload["items"]]
self.assertIn(str(self.group_compound.uuid), uuids)
@tag("api", "end2end")
class PackageScopedCompoundListPermissionTest(APIPermissionTestBase):
"""
Test permissions for /api/v1/package/{uuid}/compound/ endpoint.
This endpoint requires authentication AND package access.
"""
def test_anonymous_user_cannot_access_reviewed_package(self):
"""Anonymous users should get 401 even for reviewed packages."""
self.client.logout()
endpoint = f"/api/v1/package/{self.reviewed_package.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 401)
def test_authenticated_user_can_access_reviewed_package(self):
"""Authenticated users can access reviewed packages."""
self.client.force_login(self.user3)
endpoint = f"/api/v1/package/{self.reviewed_package.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.reviewed_compound.uuid))
def test_user_can_access_package_with_read_permission(self):
"""User with READ permission can access package-scoped endpoint."""
self.client.force_login(self.user2)
endpoint = f"/api/v1/package/{self.unreviewed_package_read.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.read_compound.uuid))
def test_user_can_access_package_with_write_permission(self):
"""User with WRITE permission can access package-scoped endpoint."""
self.client.force_login(self.user2)
endpoint = f"/api/v1/package/{self.unreviewed_package_write.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.write_compound.uuid))
def test_user_can_access_package_with_all_permission(self):
"""User with ALL permission can access package-scoped endpoint."""
self.client.force_login(self.user2)
endpoint = f"/api/v1/package/{self.unreviewed_package_all.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.all_compound.uuid))
def test_user_cannot_access_package_without_permission(self):
"""User without permission gets 403 Forbidden."""
self.client.force_login(self.user2)
endpoint = f"/api/v1/package/{self.unreviewed_package_no_access.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 403)
def test_nonexistent_package_returns_404(self):
"""Request for non-existent package returns 404."""
self.client.force_login(self.user2)
fake_uuid = "00000000-0000-0000-0000-000000000000"
endpoint = f"/api/v1/package/{fake_uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 404)
def test_owner_can_access_owned_package(self):
"""Package owner can access their package."""
self.client.force_login(self.user1)
endpoint = f"/api/v1/package/{self.unreviewed_package_owned.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.owned_compound.uuid))
def test_group_member_can_access_group_package(self):
"""Group member can access package via group permission."""
self.client.force_login(self.user2)
endpoint = f"/api/v1/package/{self.group_package.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], 1)
self.assertEqual(payload["items"][0]["uuid"], str(self.group_compound.uuid))
def test_non_group_member_cannot_access_group_package(self):
"""Non-group member cannot access package with only group permission."""
self.client.force_login(self.user3)
endpoint = f"/api/v1/package/{self.group_package.uuid}/compound/"
response = self.client.get(endpoint)
self.assertEqual(response.status_code, 403)
@tag("api", "end2end")
class MultiResourcePermissionTest(APIPermissionTestBase):
"""
Test that permission system works consistently across all resource types.
Tests a sample of other endpoints to ensure permission logic is consistent.
"""
def test_rules_endpoint_respects_permissions(self):
"""Rules endpoint uses same permission logic."""
from epdb.models import SimpleAmbitRule
# Create rule in no-access package
rule = SimpleAmbitRule.create(
self.unreviewed_package_no_access, "Test Rule", "Test", "[C:1]>>[C:1]O"
)
self.client.force_login(self.user2)
response = self.client.get("/api/v1/rules/")
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2 should not see the rule from no_access_package
rule_uuids = [item["uuid"] for item in payload["items"]]
self.assertNotIn(str(rule.uuid), rule_uuids)
def test_reactions_endpoint_respects_permissions(self):
"""Reactions endpoint uses same permission logic."""
from epdb.models import Reaction
# Create reaction in no-access package
reaction = Reaction.create(
self.unreviewed_package_no_access, "Test Reaction", "Test", ["C"], ["CO"]
)
self.client.force_login(self.user2)
response = self.client.get("/api/v1/reactions/")
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2 should not see the reaction from no_access_package
reaction_uuids = [item["uuid"] for item in payload["items"]]
self.assertNotIn(str(reaction.uuid), reaction_uuids)
def test_pathways_endpoint_respects_permissions(self):
"""Pathways endpoint uses same permission logic."""
from epdb.models import Pathway
# Create pathway in no-access package
pathway = Pathway.objects.create(
package=self.unreviewed_package_no_access, name="Test Pathway", description="Test"
)
self.client.force_login(self.user2)
response = self.client.get("/api/v1/pathways/")
self.assertEqual(response.status_code, 200)
payload = response.json()
# user2 should not see the pathway from no_access_package
pathway_uuids = [item["uuid"] for item in payload["items"]]
self.assertNotIn(str(pathway.uuid), pathway_uuids)

View File

@ -0,0 +1,477 @@
from django.test import TestCase, tag
from epdb.logic import PackageManager, UserManager
from epdb.models import Compound, Reaction, Pathway, EPModel, SimpleAmbitRule, Scenario
class BaseTestAPIGetPaginated:
"""
Mixin class for API pagination tests.
Subclasses must inherit from both this class and TestCase, e.g.:
class MyTest(BaseTestAPIGetPaginated, TestCase):
...
Subclasses must define:
- resource_name: Singular name (e.g., "compound")
- resource_name_plural: Plural name (e.g., "compounds")
- global_endpoint: Global listing endpoint (e.g., "/api/v1/compounds/")
- package_endpoint_template: Template for package-scoped endpoint or None
- total_reviewed: Number of reviewed items to create
- total_unreviewed: Number of unreviewed items to create
- create_reviewed_resource(cls, package, idx): Factory method
- create_unreviewed_resource(cls, package, idx): Factory method
"""
# Configuration to be overridden by subclasses
resource_name = None
resource_name_plural = None
global_endpoint = None
package_endpoint_template = None
total_reviewed = 50
total_unreviewed = 20
default_page_size = 50
max_page_size = 100
@classmethod
def setUpTestData(cls):
# Create test user
cls.user = UserManager.create_user(
f"{cls.resource_name}-user",
f"{cls.resource_name}-user@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
# Delete the auto-created default package to ensure clean test data
default_pkg = cls.user.default_package
cls.user.default_package = None
cls.user.save()
default_pkg.delete()
# Create reviewed package
cls.reviewed_package = PackageManager.create_package(
cls.user, "Reviewed Package", f"Reviewed package for {cls.resource_name} tests"
)
cls.reviewed_package.reviewed = True
cls.reviewed_package.save()
# Create unreviewed package
cls.unreviewed_package = PackageManager.create_package(
cls.user, "Draft Package", f"Unreviewed package for {cls.resource_name} tests"
)
# Create reviewed resources
for idx in range(cls.total_reviewed):
cls.create_reviewed_resource(cls.reviewed_package, idx)
# Create unreviewed resources
for idx in range(cls.total_unreviewed):
cls.create_unreviewed_resource(cls.unreviewed_package, idx)
# Set up package-scoped endpoints if applicable
if cls.package_endpoint_template:
cls.reviewed_package_endpoint = cls.package_endpoint_template.format(
uuid=cls.reviewed_package.uuid
)
cls.unreviewed_package_endpoint = cls.package_endpoint_template.format(
uuid=cls.unreviewed_package.uuid
)
@classmethod
def create_reviewed_resource(cls, package, idx):
"""
Create a single reviewed resource.
Must be implemented by subclass.
Args:
package: The package to create the resource in
idx: Index of the resource (0-based)
"""
raise NotImplementedError(f"{cls.__name__} must implement create_reviewed_resource()")
@classmethod
def create_unreviewed_resource(cls, package, idx):
"""
Create a single unreviewed resource.
Must be implemented by subclass.
Args:
package: The package to create the resource in
idx: Index of the resource (0-based)
"""
raise NotImplementedError(f"{cls.__name__} must implement create_unreviewed_resource()")
def setUp(self):
self.client.force_login(self.user)
def test_requires_session_authentication(self):
"""Test that the global endpoint requires authentication."""
self.client.logout()
response = self.client.get(self.global_endpoint)
self.assertEqual(response.status_code, 401)
def test_global_listing_uses_default_page_size(self):
"""Test that the global endpoint uses default pagination settings."""
response = self.client.get(self.global_endpoint, {"review_status": True})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["page"], 1)
self.assertEqual(payload["page_size"], self.default_page_size)
self.assertEqual(payload["total_items"], self.total_reviewed)
# Verify only reviewed items are returned
self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"]))
def test_can_request_later_page(self):
"""Test that pagination works for later pages."""
if self.total_reviewed <= self.default_page_size:
self.skipTest(
f"Not enough items to test pagination "
f"({self.total_reviewed} <= {self.default_page_size})"
)
response = self.client.get(self.global_endpoint, {"page": 2})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["page"], 2)
# Calculate expected items on page 2
expected_items = min(self.default_page_size, self.total_reviewed - self.default_page_size)
self.assertEqual(len(payload["items"]), expected_items)
# Verify only reviewed items are returned
self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"]))
def test_page_size_is_capped(self):
"""Test that page size is capped at the maximum."""
if self.total_reviewed <= self.max_page_size:
self.skipTest(
f"Not enough items to test page size cap "
f"({self.total_reviewed} <= {self.max_page_size})"
)
response = self.client.get(self.global_endpoint, {"page_size": 150})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["page_size"], self.max_page_size)
self.assertEqual(len(payload["items"]), self.max_page_size)
def test_package_endpoint_for_reviewed_package(self):
"""Test the package-scoped endpoint for reviewed packages."""
if not self.package_endpoint_template:
self.skipTest("No package endpoint for this resource")
response = self.client.get(self.reviewed_package_endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], self.total_reviewed)
# Verify only reviewed items are returned
self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"]))
def test_package_endpoint_for_unreviewed_package(self):
"""Test the package-scoped endpoint for unreviewed packages."""
if not self.package_endpoint_template:
self.skipTest("No package endpoint for this resource")
response = self.client.get(self.unreviewed_package_endpoint)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], self.total_unreviewed)
# Verify only unreviewed items are returned
self.assertTrue(all(item["review_status"] == "unreviewed" for item in payload["items"]))
@tag("api", "end2end")
class PackagePaginationAPITest(TestCase):
ENDPOINT = "/api/v1/packages/"
@classmethod
def setUpTestData(cls):
cls.user = UserManager.create_user(
"package-user",
"package-user@envipath.com",
"SuperSafe",
set_setting=False,
add_to_group=False,
is_active=True,
)
# Delete the auto-created default package to ensure clean test data
default_pkg = cls.user.default_package
cls.user.default_package = None
cls.user.save()
default_pkg.delete()
# Create reviewed packages
cls.total_reviewed = 25
for idx in range(cls.total_reviewed):
package = PackageManager.create_package(
cls.user, f"Reviewed Package {idx:03d}", "Reviewed package for tests"
)
package.reviewed = True
package.save()
# Create unreviewed packages
cls.total_unreviewed = 15
for idx in range(cls.total_unreviewed):
PackageManager.create_package(
cls.user, f"Draft Package {idx:03d}", "Unreviewed package for tests"
)
def setUp(self):
self.client.force_login(self.user)
def test_anonymous_can_access_reviewed_packages(self):
self.client.logout()
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
# Anonymous users can only see reviewed packages
self.assertEqual(payload["total_items"], self.total_reviewed)
self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"]))
def test_listing_uses_default_page_size(self):
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["page"], 1)
self.assertEqual(payload["page_size"], 50)
self.assertEqual(payload["total_items"], self.total_reviewed + self.total_unreviewed)
def test_reviewed_filter_true(self):
response = self.client.get(self.ENDPOINT, {"review_status": True})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], self.total_reviewed)
self.assertTrue(all(item["review_status"] == "reviewed" for item in payload["items"]))
def test_reviewed_filter_false(self):
response = self.client.get(self.ENDPOINT, {"review_status": False})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["total_items"], self.total_unreviewed)
self.assertTrue(all(item["review_status"] == "unreviewed" for item in payload["items"]))
def test_reviewed_filter_false_anonymous(self):
self.client.logout()
response = self.client.get(self.ENDPOINT, {"review_status": False})
self.assertEqual(response.status_code, 200)
payload = response.json()
# Anonymous users cannot access unreviewed packages
self.assertEqual(payload["total_items"], 0)
@tag("api", "end2end")
class CompoundPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
"""Compound pagination tests using base class."""
resource_name = "compound"
resource_name_plural = "compounds"
global_endpoint = "/api/v1/compounds/"
package_endpoint_template = "/api/v1/package/{uuid}/compound/"
total_reviewed = 125
total_unreviewed = 35
@classmethod
def create_reviewed_resource(cls, package, idx):
simple_smiles = ["C", "CC", "CCC", "CCCC", "CCCCC"]
smiles = simple_smiles[idx % len(simple_smiles)] + ("O" * (idx // len(simple_smiles)))
return Compound.create(
package,
smiles,
f"Reviewed Compound {idx:03d}",
"Compound for pagination tests",
)
@classmethod
def create_unreviewed_resource(cls, package, idx):
simple_smiles = ["C", "CC", "CCC", "CCCC", "CCCCC"]
smiles = simple_smiles[idx % len(simple_smiles)] + ("N" * (idx // len(simple_smiles)))
return Compound.create(
package,
smiles,
f"Draft Compound {idx:03d}",
"Compound for pagination tests",
)
@tag("api", "end2end")
class RulePaginationAPITest(BaseTestAPIGetPaginated, TestCase):
"""Rule pagination tests using base class."""
resource_name = "rule"
resource_name_plural = "rules"
global_endpoint = "/api/v1/rules/"
package_endpoint_template = "/api/v1/package/{uuid}/rule/"
total_reviewed = 125
total_unreviewed = 35
@classmethod
def create_reviewed_resource(cls, package, idx):
# Create unique SMIRKS by combining chain length and functional group variations
# This ensures each idx gets a truly unique SMIRKS pattern
carbon_chain = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx)
smirks = f"[{carbon_chain}:1]>>[{carbon_chain}:1]O"
return SimpleAmbitRule.create(
package,
f"Reviewed Rule {idx:03d}",
f"Rule {idx} for pagination tests",
smirks,
)
@classmethod
def create_unreviewed_resource(cls, package, idx):
# Create unique SMIRKS by varying the carbon chain length
carbon_chain = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx)
smirks = f"[{carbon_chain}:1]>>[{carbon_chain}:1]N"
return SimpleAmbitRule.create(
package,
f"Draft Rule {idx:03d}",
f"Rule {idx} for pagination tests",
smirks,
)
@tag("api", "end2end")
class ReactionPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
"""Reaction pagination tests using base class."""
resource_name = "reaction"
resource_name_plural = "reactions"
global_endpoint = "/api/v1/reactions/"
package_endpoint_template = "/api/v1/package/{uuid}/reaction/"
total_reviewed = 125
total_unreviewed = 35
@classmethod
def create_reviewed_resource(cls, package, idx):
# Generate unique SMILES with growing chain lengths to avoid duplicates
# Each idx gets a unique chain length
educt_smiles = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx)
product_smiles = educt_smiles + "O"
return Reaction.create(
package=package,
name=f"Reviewed Reaction {idx:03d}",
description="Reaction for pagination tests",
educts=[educt_smiles],
products=[product_smiles],
)
@classmethod
def create_unreviewed_resource(cls, package, idx):
# Generate unique SMILES with growing chain lengths to avoid duplicates
# Each idx gets a unique chain length
educt_smiles = "C" * (idx + 1) # C, CC, CCC, ... (grows with idx)
product_smiles = educt_smiles + "N"
return Reaction.create(
package=package,
name=f"Draft Reaction {idx:03d}",
description="Reaction for pagination tests",
educts=[educt_smiles],
products=[product_smiles],
)
@tag("api", "end2end")
class PathwayPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
"""Pathway pagination tests using base class."""
resource_name = "pathway"
resource_name_plural = "pathways"
global_endpoint = "/api/v1/pathways/"
package_endpoint_template = "/api/v1/package/{uuid}/pathway/"
total_reviewed = 125
total_unreviewed = 35
@classmethod
def create_reviewed_resource(cls, package, idx):
return Pathway.objects.create(
package=package,
name=f"Reviewed Pathway {idx:03d}",
description="Pathway for pagination tests",
)
@classmethod
def create_unreviewed_resource(cls, package, idx):
return Pathway.objects.create(
package=package,
name=f"Draft Pathway {idx:03d}",
description="Pathway for pagination tests",
)
@tag("api", "end2end")
class ModelPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
"""Model pagination tests using base class."""
resource_name = "model"
resource_name_plural = "models"
global_endpoint = "/api/v1/models/"
package_endpoint_template = "/api/v1/package/{uuid}/model/"
total_reviewed = 125
total_unreviewed = 35
@classmethod
def create_reviewed_resource(cls, package, idx):
return EPModel.objects.create(
package=package,
name=f"Reviewed Model {idx:03d}",
description="Model for pagination tests",
)
@classmethod
def create_unreviewed_resource(cls, package, idx):
return EPModel.objects.create(
package=package,
name=f"Draft Model {idx:03d}",
description="Model for pagination tests",
)
@tag("api", "end2end")
class ScenarioPaginationAPITest(BaseTestAPIGetPaginated, TestCase):
"""Scenario pagination tests using base class."""
resource_name = "scenario"
resource_name_plural = "scenarios"
global_endpoint = "/api/v1/scenarios/"
package_endpoint_template = "/api/v1/package/{uuid}/scenario/"
total_reviewed = 125
total_unreviewed = 35
@classmethod
def create_reviewed_resource(cls, package, idx):
return Scenario.create(
package,
f"Reviewed Scenario {idx:03d}",
"Scenario for pagination tests",
"2025-01-01",
"lab",
[],
)
@classmethod
def create_unreviewed_resource(cls, package, idx):
return Scenario.create(
package,
f"Draft Scenario {idx:03d}",
"Scenario for pagination tests",
"2025-01-01",
"field",
[],
)

0
epapi/v1/__init__.py Normal file
View File

8
epapi/v1/auth.py Normal file
View File

@ -0,0 +1,8 @@
from ninja.security import HttpBearer
from ninja.errors import HttpError
class BearerTokenAuth(HttpBearer):
def authenticate(self, request, token):
# FIXME: placeholder; implement it in O(1) time
raise HttpError(401, "Invalid or expired token")

95
epapi/v1/dal.py Normal file
View File

@ -0,0 +1,95 @@
from django.db.models import Model
from epdb.logic import PackageManager
from epdb.models import CompoundStructure, User, Package, Compound
from uuid import UUID
from .errors import EPAPINotFoundError, EPAPIPermissionDeniedError
def get_compound_or_error(user, compound_uuid: UUID):
"""
Get compound by UUID with permission check.
"""
try:
compound = Compound.objects.get(uuid=compound_uuid)
package = compound.package
except Compound.DoesNotExist:
raise EPAPINotFoundError(f"Compound with UUID {compound_uuid} not found")
# FIXME: optimize package manager to exclusively work with UUIDs
if not user or user.is_anonymous or not PackageManager.readable(user, package):
raise EPAPIPermissionDeniedError("Insufficient permissions to access this compound.")
return compound
def get_package_or_error(user, package_uuid: UUID):
"""
Get package by UUID with permission check.
"""
# FIXME: update package manager with custom exceptions to avoid manual checks here
try:
package = Package.objects.get(uuid=package_uuid)
except Package.DoesNotExist:
raise EPAPINotFoundError(f"Package with UUID {package_uuid} not found")
# FIXME: optimize package manager to exclusively work with UUIDs
if not user or user.is_anonymous or not PackageManager.readable(user, package):
raise EPAPIPermissionDeniedError("Insufficient permissions to access this package.")
return package
def get_user_packages_qs(user: User | None):
"""Get all packages readable by the user."""
if not user or user.is_anonymous:
return PackageManager.get_reviewed_packages()
return PackageManager.get_all_readable_packages(user, include_reviewed=True)
def get_user_entities_qs(model_class: Model, user: User | None):
"""Build queryset for reviewed package entities."""
if not user or user.is_anonymous:
return model_class.objects.filter(package__reviewed=True).select_related("package")
qs = model_class.objects.filter(
package__in=PackageManager.get_all_readable_packages(user, include_reviewed=True)
).select_related("package")
return qs
def get_package_scoped_entities_qs(
model_class: Model, package_uuid: UUID, user: User | None = None
):
"""Build queryset for specific package entities."""
package = get_package_or_error(user, package_uuid)
qs = model_class.objects.filter(package=package).select_related("package")
return qs
def get_user_structures_qs(user: User | None):
"""Build queryset for structures accessible to the user (via compound->package)."""
if not user or user.is_anonymous:
return CompoundStructure.objects.filter(compound__package__reviewed=True).select_related(
"compound__package"
)
qs = CompoundStructure.objects.filter(
compound__package__in=PackageManager.get_all_readable_packages(user, include_reviewed=True)
).select_related("compound__package")
return qs
def get_package_compound_scoped_structure_qs(
package_uuid: UUID, compound_uuid: UUID, user: User | None = None
):
"""Build queryset for specific package compound structures."""
get_package_or_error(user, package_uuid)
compound = get_compound_or_error(user, compound_uuid)
qs = CompoundStructure.objects.filter(compound=compound).select_related("compound__package")
return qs

View File

View File

@ -0,0 +1,41 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from epdb.models import Compound
from ..pagination import EnhancedPageNumberPagination
from ..schemas import CompoundOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
router = Router()
@router.get("/compounds/", response=EnhancedPageNumberPagination.Output[CompoundOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_all_compounds(request):
"""
List all compounds from reviewed packages.
"""
return get_user_entities_qs(Compound, request.user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/compound/",
response=EnhancedPageNumberPagination.Output[CompoundOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_package_compounds(request, package_uuid: UUID):
"""
List all compounds for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Compound, package_uuid, user).order_by("name").all()

View File

@ -0,0 +1,41 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from epdb.models import EPModel
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ModelOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
router = Router()
@router.get("/models/", response=EnhancedPageNumberPagination.Output[ModelOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_all_models(request):
"""
List all models from reviewed packages.
"""
return get_user_entities_qs(EPModel, request.user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/model/",
response=EnhancedPageNumberPagination.Output[ModelOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_package_models(request, package_uuid: UUID):
"""
List all models for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(EPModel, package_uuid, user).order_by("name").all()

View File

@ -0,0 +1,27 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
import logging
from ..dal import get_user_packages_qs
from ..pagination import EnhancedPageNumberPagination
from ..schemas import PackageOutSchema, SelfReviewStatusFilter
router = Router()
logger = logging.getLogger(__name__)
@router.get("/packages/", response=EnhancedPageNumberPagination.Output[PackageOutSchema], auth=None)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=SelfReviewStatusFilter,
)
def list_all_packages(request):
"""
List packages accessible to the user.
"""
user = request.user
qs = get_user_packages_qs(user)
return qs.order_by("name").all()

View File

@ -0,0 +1,42 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from epdb.models import Pathway
from ..pagination import EnhancedPageNumberPagination
from ..schemas import PathwayOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
router = Router()
@router.get("/pathways/", response=EnhancedPageNumberPagination.Output[PathwayOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_all_pathways(request):
"""
List all pathways from reviewed packages.
"""
user = request.user
return get_user_entities_qs(Pathway, user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/pathway/",
response=EnhancedPageNumberPagination.Output[PathwayOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_package_pathways(request, package_uuid: UUID):
"""
List all pathways for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Pathway, package_uuid, user).order_by("name").all()

View File

@ -0,0 +1,42 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from epdb.models import Reaction
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReactionOutSchema, ReviewStatusFilter
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
router = Router()
@router.get("/reactions/", response=EnhancedPageNumberPagination.Output[ReactionOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_all_reactions(request):
"""
List all reactions from reviewed packages.
"""
user = request.user
return get_user_entities_qs(Reaction, user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/reaction/",
response=EnhancedPageNumberPagination.Output[ReactionOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_package_reactions(request, package_uuid: UUID):
"""
List all reactions for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Reaction, package_uuid, user).order_by("name").all()

View File

@ -0,0 +1,42 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from epdb.models import Rule
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReviewStatusFilter, RuleOutSchema
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
router = Router()
@router.get("/rules/", response=EnhancedPageNumberPagination.Output[RuleOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_all_rules(request):
"""
List all rules from reviewed packages.
"""
user = request.user
return get_user_entities_qs(Rule, user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/rule/",
response=EnhancedPageNumberPagination.Output[RuleOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_package_rules(request, package_uuid: UUID):
"""
List all rules for a specific package.
"""
user = request.user
return get_package_scoped_entities_qs(Rule, package_uuid, user).order_by("name").all()

View File

@ -0,0 +1,36 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from epdb.models import Scenario
from ..pagination import EnhancedPageNumberPagination
from ..schemas import ReviewStatusFilter, ScenarioOutSchema
from ..dal import get_user_entities_qs, get_package_scoped_entities_qs
router = Router()
@router.get("/scenarios/", response=EnhancedPageNumberPagination.Output[ScenarioOutSchema])
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_all_scenarios(request):
user = request.user
return get_user_entities_qs(Scenario, user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/scenario/",
response=EnhancedPageNumberPagination.Output[ScenarioOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=ReviewStatusFilter,
)
def list_package_scenarios(request, package_uuid: UUID):
user = request.user
return get_package_scoped_entities_qs(Scenario, package_uuid, user).order_by("name").all()

View File

@ -0,0 +1,50 @@
from django.conf import settings as s
from ninja import Router
from ninja_extra.pagination import paginate
from uuid import UUID
from ..pagination import EnhancedPageNumberPagination
from ..schemas import CompoundStructureOutSchema, StructureReviewStatusFilter
from ..dal import (
get_user_structures_qs,
get_package_compound_scoped_structure_qs,
)
router = Router()
@router.get(
"/structures/", response=EnhancedPageNumberPagination.Output[CompoundStructureOutSchema]
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=StructureReviewStatusFilter,
)
def list_all_structures(request):
"""
List all structures from all packages.
"""
user = request.user
return get_user_structures_qs(user).order_by("name").all()
@router.get(
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/",
response=EnhancedPageNumberPagination.Output[CompoundStructureOutSchema],
)
@paginate(
EnhancedPageNumberPagination,
page_size=s.API_PAGINATION_DEFAULT_PAGE_SIZE,
filter_schema=StructureReviewStatusFilter,
)
def list_package_structures(request, package_uuid: UUID, compound_uuid: UUID):
"""
List all structures for a specific package and compound.
"""
user = request.user
return (
get_package_compound_scoped_structure_qs(package_uuid, compound_uuid, user)
.order_by("name")
.all()
)

28
epapi/v1/errors.py Normal file
View File

@ -0,0 +1,28 @@
from ninja.errors import HttpError
class EPAPIError(HttpError):
status_code: int = 500
def __init__(self, message: str) -> None:
super().__init__(status_code=self.status_code, message=message)
@classmethod
def from_exception(cls, exc: Exception):
return cls(message=str(exc))
class EPAPIUnauthorizedError(EPAPIError):
status_code = 401
class EPAPIPermissionDeniedError(EPAPIError):
status_code = 403
class EPAPINotFoundError(EPAPIError):
status_code = 404
class EPAPIValidationError(EPAPIError):
status_code = 422

60
epapi/v1/pagination.py Normal file
View File

@ -0,0 +1,60 @@
import math
from typing import Any, Generic, List, TypeVar
from django.db.models import QuerySet
from ninja import Schema
from ninja.pagination import PageNumberPagination
T = TypeVar("T")
class EnhancedPageNumberPagination(PageNumberPagination):
class Output(Schema, Generic[T]):
items: List[T]
page: int
page_size: int
total_items: int
total_pages: int
def paginate_queryset(
self,
queryset: QuerySet,
pagination: PageNumberPagination.Input,
**params: Any,
) -> Any:
page_size = self._get_page_size(pagination.page_size)
offset = (pagination.page - 1) * page_size
total_items = self._items_count(queryset)
total_pages = math.ceil(total_items / page_size) if page_size > 0 else 0
return {
"items": queryset[offset : offset + page_size],
"page": pagination.page,
"page_size": page_size,
"total_items": total_items,
"total_pages": total_pages,
}
async def apaginate_queryset(
self,
queryset: QuerySet,
pagination: PageNumberPagination.Input,
**params: Any,
) -> Any:
page_size = self._get_page_size(pagination.page_size)
offset = (pagination.page - 1) * page_size
total_items = await self._aitems_count(queryset)
total_pages = math.ceil(total_items / page_size) if page_size > 0 else 0
if isinstance(queryset, QuerySet):
items = [obj async for obj in queryset[offset : offset + page_size]]
else:
items = queryset[offset : offset + page_size]
return {
"items": items,
"page": pagination.page,
"page_size": page_size,
"total_items": total_items,
"total_pages": total_pages,
}

22
epapi/v1/router.py Normal file
View File

@ -0,0 +1,22 @@
from ninja import Router
from ninja.security import SessionAuth
from .auth import BearerTokenAuth
from .endpoints import packages, scenarios, compounds, rules, reactions, pathways, models, structure
# Main router with authentication
router = Router(
auth=[
SessionAuth(),
BearerTokenAuth(),
]
)
# Include all endpoint routers
router.add_router("", packages.router)
router.add_router("", scenarios.router)
router.add_router("", compounds.router)
router.add_router("", rules.router)
router.add_router("", reactions.router)
router.add_router("", pathways.router)
router.add_router("", models.router)
router.add_router("", structure.router)

104
epapi/v1/schemas.py Normal file
View File

@ -0,0 +1,104 @@
from ninja import FilterSchema, FilterLookup, Schema
from typing import Annotated, Optional
from uuid import UUID
# Filter schema for query parameters
class ReviewStatusFilter(FilterSchema):
"""Filter schema for review_status query parameter."""
review_status: Annotated[Optional[bool], FilterLookup("package__reviewed")] = None
class SelfReviewStatusFilter(FilterSchema):
"""Filter schema for review_status query parameter on self-reviewed entities."""
review_status: Annotated[Optional[bool], FilterLookup("reviewed")] = None
class StructureReviewStatusFilter(FilterSchema):
"""Filter schema for review_status on structures (via compound->package)."""
review_status: Annotated[Optional[bool], FilterLookup("compound__package__reviewed")] = None
# Base schema for all package-scoped entities
class PackageEntityOutSchema(Schema):
"""Base schema for entities belonging to a package."""
uuid: UUID
url: str = ""
name: str
description: str
review_status: str = ""
package: str = ""
@staticmethod
def resolve_url(obj):
return obj.url
@staticmethod
def resolve_package(obj):
return obj.package.url
@staticmethod
def resolve_review_status(obj):
return "reviewed" if obj.package.reviewed else "unreviewed"
# All package-scoped entities inherit from base
class ScenarioOutSchema(PackageEntityOutSchema):
pass
class CompoundOutSchema(PackageEntityOutSchema):
pass
class RuleOutSchema(PackageEntityOutSchema):
pass
class ReactionOutSchema(PackageEntityOutSchema):
pass
class PathwayOutSchema(PackageEntityOutSchema):
pass
class ModelOutSchema(PackageEntityOutSchema):
pass
class CompoundStructureOutSchema(PackageEntityOutSchema):
compound: str = ""
@staticmethod
def resolve_compound(obj):
return obj.compound.url
@staticmethod
def resolve_package(obj):
return obj.compound.package.url
@staticmethod
def resolve_review_status(obj):
return "reviewed" if obj.compound.package.reviewed else "unreviewed"
# Package is special (no package FK)
class PackageOutSchema(Schema):
uuid: UUID
url: str = ""
name: str
description: str
review_status: str = ""
@staticmethod
def resolve_url(obj):
return obj.url
@staticmethod
def resolve_review_status(obj):
return "reviewed" if obj.reviewed else "unreviewed"

View File

@ -1,29 +1,31 @@
from django.conf import settings as s
from django.contrib import admin from django.contrib import admin
from .models import ( from .models import (
User,
UserPackagePermission,
Group,
GroupPackagePermission,
Package,
MLRelativeReasoning,
EnviFormer,
Compound, Compound,
CompoundStructure, CompoundStructure,
SimpleAmbitRule,
ParallelRule,
Reaction,
Pathway,
Node,
Edge, Edge,
Scenario, EnviFormer,
Setting,
ExternalDatabase, ExternalDatabase,
ExternalIdentifier, ExternalIdentifier,
Group,
GroupPackagePermission,
JobLog, JobLog,
License, License,
MLRelativeReasoning,
Node,
ParallelRule,
Pathway,
Reaction,
Scenario,
Setting,
SimpleAmbitRule,
User,
UserPackagePermission,
) )
Package = s.GET_PACKAGE_MODEL()
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ["username", "email", "is_active"] list_display = ["username", "email", "is_active"]

View File

@ -1,4 +1,9 @@
import logging
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(__name__)
class EPDBConfig(AppConfig): class EPDBConfig(AppConfig):
@ -7,3 +12,6 @@ class EPDBConfig(AppConfig):
def ready(self): def ready(self):
import epdb.signals # noqa: F401 import epdb.signals # noqa: F401
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
logger.info(f"Using Package model: {model_name}")

View File

@ -5,7 +5,7 @@ Context processors automatically make variables available to all templates.
""" """
from .logic import PackageManager from .logic import PackageManager
from .models import Package from django.conf import settings as s
def package_context(request): def package_context(request):
@ -20,7 +20,7 @@ def package_context(request):
reviewed_package_qs = PackageManager.get_reviewed_packages() reviewed_package_qs = PackageManager.get_reviewed_packages()
unreviewed_package_qs = Package.objects.none() unreviewed_package_qs = s.GET_PACKAGE_MODEL().objects.none()
# Only get user-specific packages if user is authenticated # Only get user-specific packages if user is authenticated
if current_user.is_authenticated: if current_user.is_authenticated:

View File

@ -1,27 +1,35 @@
from typing import List, Dict, Optional, Any from typing import Any, Dict, List, Optional
import nh3
from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from ninja import Router, Schema, Field, Form from ninja import Field, Form, Router, Schema, Query
from ninja.security import SessionAuth
from utilities.chem import FormatConverter from utilities.chem import FormatConverter
from .logic import PackageManager, UserManager, SettingManager from utilities.misc import PackageExporter
from .logic import GroupManager, PackageManager, SettingManager, UserManager, SearchManager
from .models import ( from .models import (
Compound, Compound,
CompoundStructure, CompoundStructure,
Package, Edge,
EPModel,
Node,
Pathway,
Reaction,
Rule,
Scenario,
SimpleAmbitRule,
User, User,
UserPackagePermission, UserPackagePermission,
Rule, ParallelRule,
Reaction,
Scenario,
Pathway,
Node,
Edge,
SimpleAmbitRule,
) )
Package = s.GET_PACKAGE_MODEL()
def _anonymous_or_real(request): def _anonymous_or_real(request):
if request.user.is_authenticated and not request.user.is_anonymous: if request.user.is_authenticated and not request.user.is_anonymous:
@ -29,8 +37,7 @@ def _anonymous_or_real(request):
return get_user_model().objects.get(username="anonymous") return get_user_model().objects.get(username="anonymous")
# router = Router(auth=SessionAuth()) router = Router(auth=SessionAuth(csrf=False))
router = Router()
class Error(Schema): class Error(Schema):
@ -118,13 +125,16 @@ class SimpleEdge(SimpleObject):
identifier: str = "edge" identifier: str = "edge"
class SimpleModel(SimpleObject):
identifier: str = "relative-reasoning"
################ ################
# Login/Logout # # Login/Logout #
################ ################
@router.post("/", response={200: SimpleUser, 403: Error}) @router.post("/", response={200: SimpleUser, 403: Error}, auth=None)
def login(request, loginusername: Form[str], loginpassword: Form[str]): def login(request, loginusername: Form[str], loginpassword: Form[str]):
from django.contrib.auth import authenticate from django.contrib.auth import authenticate, login
from django.contrib.auth import login
email = User.objects.get(username=loginusername).email email = User.objects.get(username=loginusername).email
user = authenticate(username=email, password=loginpassword) user = authenticate(username=email, password=loginpassword)
@ -167,9 +177,13 @@ class UserSchema(Schema):
return SettingManager.get_all_settings(obj) return SettingManager.get_all_settings(obj)
class Me(Schema):
whoami: str | None = None
@router.get("/user", response={200: UserWrapper, 403: Error}) @router.get("/user", response={200: UserWrapper, 403: Error})
def get_users(request, whoami: str = None): def get_users(request, me: Query[Me]):
if whoami: if me.whoami:
return {"user": [request.user]} return {"user": [request.user]}
else: else:
return {"user": User.objects.all()} return {"user": User.objects.all()}
@ -186,6 +200,61 @@ def get_user(request, user_uuid):
} }
class Search(Schema):
packages: List[str] = Field(alias="packages[]")
search: str
method: str
@router.get("/search", response={200: Any, 403: Error})
def search(request, search: Query[Search]):
try:
packs = []
for package in search.packages:
packs.append(PackageManager.get_package_by_url(request.user, package))
method = None
if search.method == "text":
method = "text"
elif search.method == "inchikey":
method = "inchikey"
elif search.method == "defaultSmiles":
method = "default"
elif search.method == "canonicalSmiles":
method = "canonical"
elif search.method == "exactSmiles":
method = "exact"
if method is None:
raise ValueError(f"Search method {search.method} is not supported!")
search_res = SearchManager.search(packs, search.search, method)
res = {}
if "Compounds" in search_res:
res["compound"] = search_res["Compounds"]
if "Compound Structures" in search_res:
res["structure"] = search_res["Compound Structures"]
if "Reaction" in search_res:
res["reaction"] = search_res["Reaction"]
if "Pathway" in search_res:
res["pathway"] = search_res["Pathway"]
if "Rules" in search_res:
res["rule"] = search_res["Rules"]
for key in res:
for v in res[key]:
v["id"] = v["url"].replace("simple-ambit-rule", "simple-rule")
return res
except ValueError as e:
return 403, {"message": f"Search failed due to {e}"}
########### ###########
# Package # # Package #
########### ###########
@ -251,67 +320,110 @@ def get_packages(request):
} }
@router.get("/package/{uuid:package_uuid}", response={200: PackageSchema, 403: Error}) class GetPackage(Schema):
def get_package(request, package_uuid): exportAsJson: str | None = None
@router.get("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 403: Error})
def get_package(request, package_uuid, gp: Query[GetPackage]):
try: try:
return PackageManager.get_package_by_id(request.user, package_uuid) p = PackageManager.get_package_by_id(request.user, package_uuid)
if gp.exportAsJson and gp.exportAsJson.strip() == "true":
return PackageExporter(p).do_export()
return p
except ValueError: except ValueError:
return 403, { return 403, {
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!" "message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
} }
class CreatePackage(Schema):
packageName: str
packageDescription: str | None = None
@router.post("/package") @router.post("/package")
def create_packages( def create_packages(
request, packageName: Form[str], packageDescription: Optional[str] = Form(None) request,
p: Form[CreatePackage],
): ):
try: try:
if packageName.strip() == "": if p.packageName.strip() == "":
raise ValueError("Package name cannot be empty!") raise ValueError("Package name cannot be empty!")
new_pacakge = PackageManager.create_package(request.user, packageName, packageDescription) new_pacakge = PackageManager.create_package(
request.user, p.packageName, p.packageDescription
)
return redirect(new_pacakge.url) return redirect(new_pacakge.url)
except ValueError as e: except ValueError as e:
return 400, {"message": str(e)} return 400, {"message": str(e)}
class UpdatePackage(Schema):
packageDescription: str | None = None
hiddenMethod: str | None = None
permissions: str | None = None
ppsURI: str | None = None
read: str | None = None
write: str | None = None
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error}) @router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
def update_package( def update_package(request, package_uuid, pack: Form[UpdatePackage]):
request,
package_uuid,
packageDescription: Optional[str] = Form(None),
hiddenMethod: Optional[str] = Form(None),
exportAsJson: Optional[str] = Form(None),
permissions: Optional[str] = Form(None),
ppsURI: Optional[str] = Form(None),
read: Optional[str] = Form(None),
write: Optional[str] = Form(None),
):
try: try:
p = PackageManager.get_package_by_id(request.user, package_uuid) p = PackageManager.get_package_by_id(request.user, package_uuid)
if hiddenMethod: if pack.hiddenMethod:
if hiddenMethod == "DELETE": if pack.hiddenMethod == "DELETE":
p.delete() p.delete()
elif packageDescription and packageDescription.strip() != "": elif pack.packageDescription is not None:
p.description = packageDescription description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip()
p.save()
return
elif exportAsJson == "true":
pack_json = PackageManager.export_package(
p, include_models=False, include_external_identifiers=False
)
return pack_json
elif all([permissions, ppsURI, read]):
PackageManager.update_permissions
elif all([permissions, ppsURI, write]):
pass
if description:
p.description = description
p.save()
return HttpResponse(status=200)
else:
raise ValueError("Package description cannot be empty!")
elif all([pack.permissions, pack.ppsURI, pack.read]):
if "group" in pack.ppsURI:
grantee = GroupManager.get_group_lp(pack.ppsURI)
else:
grantee = UserManager.get_user_lp(pack.ppsURI)
PackageManager.grant_read(request.user, p, grantee)
return HttpResponse(status=200)
elif all([pack.permissions, pack.ppsURI, pack.write]):
if "group" in pack.ppsURI:
grantee = GroupManager.get_group_lp(pack.ppsURI)
else:
grantee = UserManager.get_user_lp(pack.ppsURI)
PackageManager.grant_write(request.user, p, grantee)
return HttpResponse(status=200)
except ValueError as e: except ValueError as e:
return 400, {"message": str(e)} return 400, {"message": str(e)}
@router.delete("/package/{uuid:package_uuid}")
def delete_package(request, package_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.administrable(request.user, p):
p.delete()
return redirect(f"{s.SERVER_URL}/package")
else:
raise ValueError("You do not have the rights to delete this Package!")
except ValueError:
return 403, {
"message": f"Deleting Package with id {package_uuid} failed due to insufficient rights!"
}
################################ ################################
# Compound / CompoundStructure # # Compound / CompoundStructure #
################################ ################################
@ -509,6 +621,83 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
} }
class CreateCompound(Schema):
compoundSmiles: str
compoundName: str | None = None
compoundDescription: str | None = None
inchi: str | None = None
@router.post("/package/{uuid:package_uuid}/compound")
def create_package_compound(
request,
package_uuid,
c: Form[CreateCompound],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
# inchi is not used atm
c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
)
return redirect(c.url)
except ValueError as e:
return 400, {"message": str(e)}
@router.delete("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}")
def delete_compound(request, package_uuid, compound_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
c = Compound.objects.get(package=p, uuid=compound_uuid)
c.delete()
return redirect(f"{p.url}/compound")
else:
raise ValueError("You do not have the rights to delete this Compound!")
except ValueError:
return 403, {
"message": f"Deleting Compound with id {compound_uuid} failed due to insufficient rights!"
}
@router.delete(
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
)
def delete_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
c = Compound.objects.get(package=p, uuid=compound_uuid)
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
# Check if we have to delete the compound as no structure is left
if len(cs.compound.structures.all()) == 1:
# This will delete the structure as well
c.delete()
return redirect(p.url + "/compound")
else:
if cs.normalized_structure:
c.delete()
return redirect(p.url + "/compound")
else:
if c.default_structure == cs:
cs.delete()
c.default_structure = c.structures.all().first()
return redirect(c.url + "/structure")
else:
cs.delete()
return redirect(c.url + "/structure")
else:
raise ValueError("You do not have the rights to delete this CompoundStructure!")
except ValueError:
return 403, {
"message": f"Deleting CompoundStructure with id {compound_uuid} failed due to insufficient rights!"
}
######### #########
# Rules # # Rules #
######### #########
@ -672,6 +861,73 @@ def _get_package_rule(request, package_uuid, rule_uuid):
# POST # POST
class CreateSimpleRule(Schema):
smirks: str
name: str | None = None
description: str | None = None
reactantFilterSmarts: str | None = None
productFilterSmarts: str | None = None
immediate: str | None = None
rdkitrule: str | None = None
@router.post("/package/{uuid:package_uuid}/simple-rule")
def create_package_simple_rule(
request,
package_uuid,
r: Form[CreateSimpleRule],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if r.rdkitrule and r.rdkitrule.strip() == "true":
raise ValueError("Not yet implemented!")
else:
sr = SimpleAmbitRule.create(
p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts
)
return redirect(sr.url)
except ValueError as e:
return 400, {"message": str(e)}
class CreateParallelRule(Schema):
simpleRules: str
name: str | None = None
description: str | None = None
reactantFilterSmarts: str | None = None
productFilterSmarts: str | None = None
immediate: str | None = None
@router.post("/package/{uuid:package_uuid}/parallel-rule")
def create_package_parallel_rule(
request,
package_uuid,
r: Form[CreateParallelRule],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
srs = SimpleRule.objects.filter(package=p, url__in=r.simpleRules)
if srs.count() != len(r.simpleRules):
raise ValueError(
f"Not all SimpleRules could be found in Package with id {package_uuid}!"
)
sr = ParallelRule.create(
p, list(srs), r.name, r.description, r.reactantFilterSmarts, r.productFilterSmarts
)
return redirect(sr.url)
except ValueError as e:
return 400, {"message": str(e)}
@router.post( @router.post(
"/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}", response={200: str | Any, 403: Error} "/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}", response={200: str | Any, 403: Error}
) )
@ -721,6 +977,41 @@ def _post_package_rule(request, package_uuid, rule_uuid, compound: Form[str]):
} }
@router.delete("/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}")
def delete_rule(request, package_uuid, rule_uuid):
return _delete_rule(request, package_uuid, rule_uuid)
@router.delete(
"/package/{uuid:package_uuid}/simple-rule/{uuid:rule_uuid}",
)
def delete_simple_rule(request, package_uuid, rule_uuid):
return _delete_rule(request, package_uuid, rule_uuid)
@router.delete(
"/package/{uuid:package_uuid}/parallel-rule/{uuid:rule_uuid}",
)
def delete_parallel_rule(request, package_uuid, rule_uuid):
return _delete_rule(request, package_uuid, rule_uuid)
def _delete_rule(request, package_uuid, rule_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
r = Rule.objects.get(package=p, uuid=rule_uuid)
r.delete()
return redirect(f"{p.url}/rule")
else:
raise ValueError("You do not have the rights to delete this Rule!")
except ValueError:
return 403, {
"message": f"Deleting Rule with id {rule_uuid} failed due to insufficient rights!"
}
############ ############
# Reaction # # Reaction #
############ ############
@ -809,6 +1100,82 @@ def get_package_reaction(request, package_uuid, reaction_uuid):
} }
class CreateReaction(Schema):
reactionName: str | None = None
reactionDescription: str | None = None
smirks: str | None = None
educt: str | None = None
product: str | None = None
rule: str | None = None
@router.post("/package/{uuid:package_uuid}/reaction")
def create_package_reaction(
request,
package_uuid,
r: Form[CreateReaction],
):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if r.smirks is None and (r.educt is None or r.product is None):
raise ValueError("Either SMIRKS or educt/product must be provided")
if r.smirks is not None and (r.educt is not None and r.product is not None):
raise ValueError("SMIRKS and educt/product provided!")
rule = None
if r.rule:
try:
rule = Rule.objects.get(package=p, url=r.rule)
except Rule.DoesNotExist:
raise ValueError(f"Rule with id {r.rule} does not exist!")
if r.educt is not None:
try:
educt_cs = CompoundStructure.objects.get(compound__package=p, url=r.educt)
except CompoundStructure.DoesNotExist:
raise ValueError(f"Compound with id {r.educt} does not exist!")
try:
product_cs = CompoundStructure.objects.get(compound__package=p, url=r.product)
except CompoundStructure.DoesNotExist:
raise ValueError(f"Compound with id {r.product} does not exist!")
new_r = Reaction.create(
p, r.reactionName, r.reactionDescription, [educt_cs], [product_cs], rule
)
else:
educts = r.smirks.split(">>")[0].split("\\.")
products = r.smirks.split(">>")[1].split("\\.")
new_r = Reaction.create(
p, r.reactionName, r.reactionDescription, educts, products, rule
)
return redirect(new_r.url)
except ValueError as e:
return 400, {"message": str(e)}
@router.delete("/package/{uuid:package_uuid}/reaction/{uuid:reaction_uuid}")
def delete_reaction(request, package_uuid, reaction_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
r.delete()
return redirect(f"{p.url}/reaction")
else:
raise ValueError("You do not have the rights to delete this Reaction!")
except ValueError:
return 403, {
"message": f"Deleting Reaction with id {reaction_uuid} failed due to insufficient rights!"
}
############ ############
# Scenario # # Scenario #
############ ############
@ -823,7 +1190,7 @@ class ScenarioSchema(Schema):
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"
linkedTo: List[Dict[str, str]] = Field({}, alias="linked_to") linkedTo: List[Dict[str, str]] = Field([], alias="linked_to")
name: str = Field(None, alias="name") name: str = Field(None, alias="name")
pathways: List["SimplePathway"] = Field([], alias="related_pathways") pathways: List["SimplePathway"] = Field([], alias="related_pathways")
relatedScenarios: List[Dict[str, str]] = Field([], alias="related_scenarios") relatedScenarios: List[Dict[str, str]] = Field([], alias="related_scenarios")
@ -874,6 +1241,38 @@ def get_package_scenario(request, package_uuid, scenario_uuid):
} }
@router.delete("/package/{uuid:package_uuid}/scenario")
def delete_scenarios(request, package_uuid, scenario_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
scens = Scenario.objects.filter(package=p)
scens.delete()
return redirect(f"{p.url}/scenario")
else:
raise ValueError("You do not have the rights to delete Scenarios!")
except ValueError:
return 403, {"message": "Deleting Scenarios failed due to insufficient rights!"}
@router.delete("/package/{uuid:package_uuid}/scenario/{uuid:scenario_uuid}")
def delete_scenario(request, package_uuid, scenario_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
scen.delete()
return redirect(f"{p.url}/scenario")
else:
raise ValueError("You do not have the rights to delete this Scenario!")
except ValueError:
return 403, {
"message": f"Deleting Scenario with id {scenario_uuid} failed due to insufficient rights!"
}
########### ###########
# Pathway # # Pathway #
########### ###########
@ -1013,46 +1412,67 @@ def get_package_pathway(request, package_uuid, pathway_uuid):
} }
class CreatePathway(Schema):
smilesinput: str
name: str | None = None
description: str | None = None
rootOnly: str | None = None
selectedSetting: str | None = None
@router.post("/package/{uuid:package_uuid}/pathway") @router.post("/package/{uuid:package_uuid}/pathway")
def create_pathway( def create_pathway(
request, request,
package_uuid, package_uuid,
smilesinput: Form[str], pw: Form[CreatePathway],
name: Optional[str] = Form(None),
description: Optional[str] = Form(None),
rootOnly: Optional[str] = Form(None),
selectedSetting: Optional[str] = Form(None),
): ):
try: try:
p = PackageManager.get_package_by_id(request.user, package_uuid) p = PackageManager.get_package_by_id(request.user, package_uuid)
stand_smiles = FormatConverter.standardize(smilesinput.strip()) stand_smiles = FormatConverter.standardize(pw.smilesinput.strip())
pw = Pathway.create(p, stand_smiles, name=name, description=description) new_pw = Pathway.create(p, stand_smiles, name=pw.name, description=pw.description)
pw_mode = "predict" pw_mode = "predict"
if rootOnly and rootOnly == "true": if pw.rootOnly and pw.rootOnly.strip() == "true":
pw_mode = "build" pw_mode = "build"
pw.kv.update({"mode": pw_mode}) new_pw.kv.update({"mode": pw_mode})
pw.save() new_pw.save()
if pw_mode == "predict": if pw_mode == "predict":
setting = request.user.prediction_settings() setting = request.user.prediction_settings()
if selectedSetting: if pw.selectedSetting:
setting = SettingManager.get_setting_by_url(request.user, selectedSetting) setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting)
pw.setting = setting new_pw.setting = setting
pw.save() new_pw.save()
from .tasks import predict from .tasks import dispatch, predict
predict.delay(pw.pk, setting.pk, limit=-1) dispatch(request.user, predict, new_pw.pk, setting.pk, limit=None)
return redirect(pw.url) return redirect(new_pw.url)
except ValueError as e: except ValueError as e:
print(e) return 400, {"message": str(e)}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
def delete_pathway(request, package_uuid, pathway_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
pw.delete()
return redirect(f"{p.url}/pathway")
else:
raise ValueError("You do not have the rights to delete this pathway!")
except ValueError:
return 403, {
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
}
######## ########
@ -1143,6 +1563,52 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
} }
class CreateNode(Schema):
nodeAsSmiles: str
nodeName: str | None = None
nodeReason: str | None = None
nodeDepth: str | None = None
@router.post(
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node",
response={200: str | Any, 403: Error},
)
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
node_depth = int(n.nodeDepth)
else:
node_depth = -1
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
return redirect(n.url)
except ValueError:
return 403, {"message": "Adding node failed!"}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node/{uuid:node_uuid}")
def delete_node(request, package_uuid, pathway_uuid, node_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
n = Node.objects.get(pathway=pw, uuid=node_uuid)
n.delete()
return redirect(f"{pw.url}/node")
else:
raise ValueError("You do not have the rights to delete this Node!")
except ValueError:
return 403, {
"message": f"Deleting Node with id {node_uuid} failed due to insufficient rights!"
}
######## ########
# Edge # # Edge #
######## ########
@ -1206,6 +1672,200 @@ def get_package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
} }
class CreateEdge(Schema):
edgeAsSmirks: str | None = None
educts: str | None = None # Node URIs comma sep
products: str | None = None # Node URIs comma sep
multistep: str | None = None
edgeReason: str | None = None
@router.post(
"/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge",
response={200: str | Any, 403: Error},
)
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
if e.edgeAsSmirks is None and (e.educts is None or e.products is None):
raise ValueError("Either SMIRKS or educt/product must be provided")
if e.edgeAsSmirks is not None and (e.educts is not None and e.products is not None):
raise ValueError("SMIRKS and educt/product provided!")
educts = []
products = []
if e.edgeAsSmirks:
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed))
for pr in e.edgeAsSmirks.split(">>")[1].split("\\."):
products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr))
else:
for ed in e.educts.split(","):
educts.append(Node.objects.get(pathway=pw, url=ed.strip()))
for pr in e.products.split(","):
products.append(Node.objects.get(pathway=pw, url=pr.strip()))
new_e = Edge.create(
pathway=pw,
start_nodes=educts,
end_nodes=products,
rule=None,
name=e.name,
description=e.edgeReason,
)
return redirect(new_e.url)
except ValueError:
return 403, {"message": "Adding node failed!"}
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
e.delete()
return redirect(f"{pw.url}/edge")
else:
raise ValueError("You do not have the rights to delete this Edge!")
except ValueError:
return 403, {
"message": f"Deleting Edge with id {edge_uuid} failed due to insufficient rights!"
}
#########
# Model #
#########
class ModelWrapper(Schema):
relative_reasoning: List["SimpleModel"] = Field(..., alias="relative-reasoning")
class ModelSchema(Schema):
aliases: List[str] = Field([], alias="aliases")
description: str = Field(None, alias="description")
evalPackages: List["SimplePackage"] = Field([])
id: str = Field(None, alias="url")
identifier: str = "relative-reasoning"
# "info" : {
# "Accuracy (Single-Gen)" : "0.5932962678936605" ,
# "Area under PR-Curve (Single-Gen)" : "0.5654653182134282" ,
# "Area under ROC-Curve (Single-Gen)" : "0.8178302405034772" ,
# "Precision (Single-Gen)" : "0.6978730822873083" ,
# "Probability Threshold" : "0.5" ,
# "Recall/Sensitivity (Single-Gen)" : "0.4484149210261006"
# } ,
name: str = Field(None, alias="name")
pathwayPackages: List["SimplePackage"] = Field([])
reviewStatus: str = Field(None, alias="review_status")
rulePackages: List["SimplePackage"] = Field([])
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
status: str
statusMessage: str
threshold: str
type: str
@router.get("/model", response={200: ModelWrapper, 403: Error})
def get_models(request):
pass
@router.get("/package/{uuid:package_uuid}/model", response={200: ModelWrapper, 403: Error})
def get_package_models(request, package_uuid, model_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
return EPModel.objects.filter(package=p)
except ValueError:
return 403, {
"message": f"Getting Reaction with id {model_uuid} failed due to insufficient rights!"
}
class Classify(Schema):
smiles: str | None = None
@router.get(
"/package/{uuid:package_uuid}/model/{uuid:model_uuid}",
response={200: ModelSchema | Any, 403: Error, 400: Error},
)
def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
mod = EPModel.objects.get(package=p, uuid=model_uuid)
if c.smiles:
if c.smiles == "":
return 400, {"message": "Received empty SMILES"}
try:
stand_smiles = FormatConverter.standardize(c.smiles)
except ValueError:
return 400, {"message": f'"{c.smiles}" is not a valid SMILES'}
from epdb.tasks import dispatch_eager, predict_simple
_, pred_res = dispatch_eager(request.user, predict_simple, mod.pk, stand_smiles)
result = []
for pr in pred_res:
if len(pr) > 0:
products = []
for prod_set in pr.product_sets:
products.append(tuple([x for x in prod_set]))
res = {
"probability": pr.probability,
"products": list(set(products)),
}
if pr.rule:
res["id"] = pr.rule.url
res["identifier"] = pr.rule.get_rule_identifier()
res["name"] = pr.rule.name
res["reviewStatus"] = (
"reviewed" if pr.rule.package.reviewed else "unreviewed"
)
result.append(res)
return result
return mod
except ValueError:
return 403, {
"message": f"Getting Reaction with id {model_uuid} failed due to insufficient rights!"
}
@router.delete("/package/{uuid:package_uuid}/model/{uuid:model_uuid}")
def delete_model(request, package_uuid, model_uuid):
try:
p = PackageManager.get_package_by_id(request.user, package_uuid)
if PackageManager.writable(request.user, p):
m = EPModel.objects.get(package=p, uuid=model_uuid)
m.delete()
return redirect(f"{p.url}/model")
else:
raise ValueError("You do not have the rights to delete this Model!")
except ValueError:
return 403, {
"message": f"Deleting Model with id {model_uuid} failed due to insufficient rights!"
}
########### ###########
# Setting # # Setting #
########### ###########

View File

@ -1,39 +1,41 @@
import re
import logging
import json import json
from typing import Union, List, Optional, Set, Dict, Any import logging
import re
from typing import Any, Dict, List, Optional, Set, Union, Tuple
from uuid import UUID from uuid import UUID
import nh3 import nh3
from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from django.conf import settings as s
from pydantic import ValidationError from pydantic import ValidationError
from epdb.models import ( from epdb.models import (
User,
Package,
UserPackagePermission,
GroupPackagePermission,
Permission,
Group,
Setting,
EPModel,
UserSettingPermission,
Rule,
Pathway,
Node,
Edge,
Compound, Compound,
Reaction,
CompoundStructure, CompoundStructure,
Edge,
EnzymeLink, EnzymeLink,
EPModel,
ExpansionSchemeChoice,
Group,
GroupPackagePermission,
Node,
Pathway,
Permission,
Reaction,
Rule,
Setting,
User,
UserPackagePermission,
UserSettingPermission,
) )
from utilities.chem import FormatConverter from utilities.chem import FormatConverter
from utilities.misc import PackageImporter, PackageExporter from utilities.misc import PackageExporter, PackageImporter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Package = s.GET_PACKAGE_MODEL()
class EPDBURLParser: class EPDBURLParser:
UUID_PATTERN = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}" UUID_PATTERN = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
@ -442,6 +444,7 @@ class PackageManager(object):
if PackageManager.readable(user, p): if PackageManager.readable(user, p):
return p return p
else: else:
# FIXME: use custom exception to be translatable to 403 in API
raise ValueError( raise ValueError(
"Insufficient permissions to access Package with ID {}".format(package_id) "Insufficient permissions to access Package with ID {}".format(package_id)
) )
@ -578,30 +581,39 @@ class PackageManager(object):
else: else:
_ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data) _ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data)
@staticmethod
def grant_read(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.READ[0])
@staticmethod
def grant_write(caller: User, package: Package, grantee: Union[User, Group]):
PackageManager.update_permissions(caller, package, grantee, Permission.WRITE[0])
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def import_legacy_package( def import_legacy_package(
data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False
): ):
from uuid import UUID, uuid4
from datetime import datetime
from collections import defaultdict from collections import defaultdict
from datetime import datetime
from uuid import UUID, uuid4
from envipy_additional_information import AdditionalInformationConverter
from .models import ( from .models import (
Package,
Compound, Compound,
CompoundStructure, CompoundStructure,
SimpleRule, Edge,
SimpleAmbitRule, Node,
ParallelRule, ParallelRule,
Pathway,
Reaction,
Scenario,
SequentialRule, SequentialRule,
SequentialRuleOrdering, SequentialRuleOrdering,
Reaction, SimpleAmbitRule,
Pathway, SimpleRule,
Node,
Edge,
Scenario,
) )
from envipy_additional_information import AdditionalInformationConverter
pack = Package() pack = Package()
pack.uuid = UUID(data["id"].split("/")[-1]) if keep_ids else uuid4() pack.uuid = UUID(data["id"].split("/")[-1]) if keep_ids else uuid4()
@ -1106,6 +1118,7 @@ class SettingManager(object):
rule_packages: List[Package] = None, rule_packages: List[Package] = None,
model: EPModel = None, model: EPModel = None,
model_threshold: float = None, model_threshold: float = None,
expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS,
): ):
new_s = Setting() new_s = Setting()
# Clean for potential XSS # Clean for potential XSS
@ -1388,6 +1401,9 @@ class SEdge(object):
self.rule = rule self.rule = rule
self.probability = probability self.probability = probability
def product_smiles(self):
return [p.smiles for p in self.products]
def __hash__(self): def __hash__(self):
full_hash = 0 full_hash = 0
@ -1473,6 +1489,7 @@ class SPathway(object):
self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes}) self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes})
self.edges: Set["SEdge"] = set() self.edges: Set["SEdge"] = set()
self.done = False self.done = False
self.empty_due_to_threshold = False
@staticmethod @staticmethod
def from_pathway(pw: "Pathway", persist: bool = True): def from_pathway(pw: "Pathway", persist: bool = True):
@ -1537,22 +1554,32 @@ class SPathway(object):
return sorted(res, key=lambda x: hash(x)) return sorted(res, key=lambda x: hash(x))
def predict_step(self, from_depth: int = None, from_node: "Node" = None): def _expand(self, substrates: List[SNode]) -> Tuple[List[SNode], List[SEdge]]:
substrates: List[SNode] = [] """
Expands the given substrates by generating new nodes and edges based on prediction settings.
if from_depth is not None: This method processes a list of substrates and expands them into new nodes and edges using defined
substrates = self._get_nodes_for_depth(from_depth) rules and settings. It evaluates each substrate to determine its applicability domain, persists
elif from_node is not None: domain assessments, and generates candidates for further processing. Newly created nodes and edges
for k, v in self.snode_persist_lookup.items(): are returned, and any applicable information is stored or updated internally during the process.
if from_node == v:
substrates = [k] Parameters:
break substrates (List[SNode]): A list of substrate nodes to be expanded.
else:
raise ValueError("Neither from_depth nor from_node_url specified") Returns:
Tuple[List[SNode], List[SEdge]]:
A tuple containing:
- A list of new nodes generated during the expansion.
- A list of new edges representing connections between nodes based on candidate reactions.
Raises:
ValueError: If a node does not have an ID when it should have been saved already.
"""
new_nodes: List[SNode] = []
new_edges: List[SEdge] = []
new_tp = False
if substrates:
for sub in substrates: for sub in substrates:
# For App Domain we have to ensure that each Node is evaluated
if sub.app_domain_assessment is None: if sub.app_domain_assessment is None:
if self.prediction_setting.model: if self.prediction_setting.model:
if self.prediction_setting.model.app_domain: if self.prediction_setting.model.app_domain:
@ -1563,9 +1590,9 @@ class SPathway(object):
if self.persist is not None: if self.persist is not None:
n = self.snode_persist_lookup[sub] n = self.snode_persist_lookup[sub]
assert n.id is not None, ( if n.id is None:
"Node has no id! Should have been saved already... aborting!" raise ValueError(f"Node {n} has no ID... aborting!")
)
node_data = n.simple_json() node_data = n.simple_json()
node_data["image"] = f"{n.url}?image=svg" node_data["image"] = f"{n.url}?image=svg"
app_domain_assessment["assessment"]["node"] = node_data app_domain_assessment["assessment"]["node"] = node_data
@ -1575,11 +1602,25 @@ class SPathway(object):
sub.app_domain_assessment = app_domain_assessment sub.app_domain_assessment = app_domain_assessment
candidates = self.prediction_setting.expand(self, sub) expansion_result = self.prediction_setting.expand(self, sub)
# We don't have any substrate, but technically we have at least one rule that triggered.
# If our substrate is a root node a.k.a. depth == 0 store that info in SPathway
if (
len(expansion_result["transformations"]) == 0
and expansion_result["rule_triggered"]
and sub.depth == 0
):
self.empty_due_to_threshold = True
# Emit directly
if self.persist is not None:
self.persist.kv["empty_due_to_threshold"] = True
self.persist.save()
# candidates is a List of PredictionResult. The length of the List is equal to the number of rules # candidates is a List of PredictionResult. The length of the List is equal to the number of rules
for cand_set in candidates: for cand_set in expansion_result["transformations"]:
if cand_set: if cand_set:
new_tp = True
# cand_set is a PredictionResult object that can consist of multiple candidate reactions # cand_set is a PredictionResult object that can consist of multiple candidate reactions
for cand in cand_set: for cand in cand_set:
cand_nodes = [] cand_nodes = []
@ -1593,10 +1634,9 @@ class SPathway(object):
app_domain_assessment = ( app_domain_assessment = (
self.prediction_setting.model.app_domain.assess(c) self.prediction_setting.model.app_domain.assess(c)
) )
snode = SNode(c, sub.depth + 1, app_domain_assessment)
self.smiles_to_node[c] = SNode( self.smiles_to_node[c] = snode
c, sub.depth + 1, app_domain_assessment new_nodes.append(snode)
)
node = self.smiles_to_node[c] node = self.smiles_to_node[c]
cand_nodes.append(node) cand_nodes.append(node)
@ -1608,6 +1648,132 @@ class SPathway(object):
probability=cand_set.probability, probability=cand_set.probability,
) )
self.edges.add(edge) self.edges.add(edge)
new_edges.append(edge)
return new_nodes, new_edges
def predict(self):
"""
Predicts outcomes based on a graph traversal algorithm using the specified expansion schema.
This method iteratively explores the nodes of a graph starting from the root nodes, propagating
probabilities through edges, and updating the probabilities of the connected nodes. The traversal
can follow one of three predefined expansion schemas: Depth-First Search (DFS), Breadth-First Search
(BFS), or a Greedy approach based on node probabilities. The methodology ensures that all reachable
nodes are processed systematically according to the specified schema.
Errors will be raised if the expansion schema is undefined or invalid. Additionally, this method
supports persisting changes by writing back data to the database when configured to do so.
Attributes
----------
done : bool
A flag indicating whether the prediction process is completed.
persist : Any
An optional object that manages persistence operations for saving modifications.
root_nodes : List[SNode]
A collection of initial nodes in the graph from which traversal begins.
prediction_setting : Any
Configuration object specifying settings for graph traversal, such as the choice of
expansion schema.
Raises
------
ValueError
If an invalid or unknown expansion schema is provided in `prediction_setting`.
"""
# populate initial queue
queue = list(self.root_nodes)
processed = set()
# initial nodes have prob 1.0
node_probs: Dict[SNode, float] = {}
node_probs.update({n: 1.0 for n in queue})
while queue:
current = queue.pop(0)
if current in processed:
continue
processed.add(current)
new_nodes, new_edges = self._expand([current])
if new_nodes or new_edges:
# Check if we need to write back data to the database
if self.persist:
self._sync_to_pathway()
# call save to update the internal modified field
self.persist.save()
if new_nodes:
for edge in new_edges:
# All edge have `current` as educt
# Use `current` and adjust probs
current_prob = node_probs[current]
for prod in edge.products:
# Either is a new product or a product and we found a path with a higher prob
if (
prod not in node_probs
or current_prob * edge.probability > node_probs[prod]
):
node_probs[prod] = current_prob * edge.probability
# Update Queue to proceed
if self.prediction_setting.expansion_scheme == "DFS":
for n in new_nodes:
if n not in processed:
# We want to follow this path -> prepend queue
queue.insert(0, n)
elif self.prediction_setting.expansion_scheme == "BFS":
for n in new_nodes:
if n not in processed:
# Add at the end, everything queued before will be processed
# before new_nodese
queue.append(n)
elif self.prediction_setting.expansion_scheme == "GREEDY":
# Simply add them, as we will re-order the queue later
for n in new_nodes:
if n not in processed:
queue.append(n)
node_and_probs = []
for queued_val in queue:
node_and_probs.append((queued_val, node_probs[queued_val]))
# re-order the queue and only pick smiles
queue = [
n[0] for n in sorted(node_and_probs, key=lambda x: x[1], reverse=True)
]
else:
raise ValueError(
f"Unknown expansion schema: {self.prediction_setting.expansion_scheme}"
)
# Queue exhausted, we're done
self.done = True
def predict_step(self, from_depth: int = None, from_node: "Node" = None):
substrates: List[SNode] = []
if from_depth is not None:
substrates = self._get_nodes_for_depth(from_depth)
elif from_node is not None:
for k, v in self.snode_persist_lookup.items():
if from_node == v:
substrates = [k]
break
else:
raise ValueError(f"Node {from_node} not found in SPathway!")
else:
raise ValueError("Neither from_depth nor from_node_url specified")
new_tp = False
if substrates:
new_nodes, _ = self._expand(substrates)
new_tp = len(new_nodes) > 0
# In case no substrates are found, we're done. # In case no substrates are found, we're done.
# For "predict from node" we're always done # For "predict from node" we're always done
@ -1620,6 +1786,14 @@ class SPathway(object):
# call save to update the internal modified field # call save to update the internal modified field
self.persist.save() self.persist.save()
def get_edge_for_educt_smiles(self, smiles: str) -> List[SEdge]:
res = []
for e in self.edges:
for n in e.educts:
if n.smiles == smiles:
res.append(e)
return res
def _sync_to_pathway(self) -> None: def _sync_to_pathway(self) -> None:
logger.info("Updating Pathway with SPathway") logger.info("Updating Pathway with SPathway")
@ -1683,11 +1857,6 @@ class SPathway(object):
"to": to_indices, "to": to_indices,
} }
# if edge.rule:
# e['rule'] = {
# 'name': edge.rule.name,
# 'id': edge.rule.url,
# }
edges.append(e) edges.append(e)
return { return {

View File

@ -2,7 +2,9 @@ from django.conf import settings as s
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from epdb.models import MLRelativeReasoning, EnviFormer, Package from epdb.models import EnviFormer, MLRelativeReasoning
Package = s.GET_PACKAGE_MODEL()
class Command(BaseCommand): class Command(BaseCommand):
@ -75,11 +77,13 @@ class Command(BaseCommand):
return packages return packages
# Iteratively create models in options["model_names"] # Iteratively create models in options["model_names"]
print(f"Creating models: {options['model_names']}\n" print(
f"Creating models: {options['model_names']}\n"
f"Data packages: {options['data_packages']}\n" f"Data packages: {options['data_packages']}\n"
f"Rule Packages (only for MLRR): {options['rule_packages']}\n" f"Rule Packages (only for MLRR): {options['rule_packages']}\n"
f"Eval Packages: {options['eval_packages']}\n" f"Eval Packages: {options['eval_packages']}\n"
f"Threshold: {options['threshold']:.2f}") f"Threshold: {options['threshold']:.2f}"
)
data_packages = decode_packages(options["data_packages"]) data_packages = decode_packages(options["data_packages"])
eval_packages = decode_packages(options["eval_packages"]) eval_packages = decode_packages(options["eval_packages"])
rule_packages = decode_packages(options["rule_packages"]) rule_packages = decode_packages(options["rule_packages"])
@ -89,8 +93,7 @@ class Command(BaseCommand):
model = EnviFormer.create( model = EnviFormer.create(
pack, pack,
data_packages=data_packages, data_packages=data_packages,
eval_packages=eval_packages, threshold=options["threshold"],
threshold=options['threshold'],
name=f"EnviFormer - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}", name=f"EnviFormer - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}",
description=f"EnviFormer transformer trained on {options['data_packages']} " description=f"EnviFormer transformer trained on {options['data_packages']} "
f"evaluated on {options['eval_packages']}.", f"evaluated on {options['eval_packages']}.",
@ -100,8 +103,7 @@ class Command(BaseCommand):
package=pack, package=pack,
rule_packages=rule_packages, rule_packages=rule_packages,
data_packages=data_packages, data_packages=data_packages,
eval_packages=eval_packages, threshold=options["threshold"],
threshold=options['threshold'],
name=f"ECC - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}", name=f"ECC - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}",
description=f"ML Relative Reasoning trained on {options['data_packages']} with rules from " description=f"ML Relative Reasoning trained on {options['data_packages']} with rules from "
f"{options['rule_packages']} and evaluated on {options['eval_packages']}.", f"{options['rule_packages']} and evaluated on {options['eval_packages']}.",

View File

@ -8,7 +8,9 @@ from django.conf import settings as s
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import transaction
from epdb.models import EnviFormer, Package from epdb.models import EnviFormer
Package = s.GET_PACKAGE_MODEL()
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -1,8 +1,8 @@
from django.apps import apps from django.apps import apps
from django.conf import settings as s
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import F, JSONField, TextField, Value
from django.db.models import F, Value, TextField, JSONField from django.db.models.functions import Cast, Replace
from django.db.models.functions import Replace, Cast
from epdb.models import EnviPathModel from epdb.models import EnviPathModel
@ -23,10 +23,12 @@ class Command(BaseCommand):
) )
def handle(self, *args, **options): def handle(self, *args, **options):
Package = s.GET_PACKAGE_MODEL()
Package.objects.update(url=Replace(F("url"), Value(options["old"]), Value(options["new"])))
MODELS = [ MODELS = [
"User", "User",
"Group", "Group",
"Package",
"Compound", "Compound",
"CompoundStructure", "CompoundStructure",
"Pathway", "Pathway",
@ -47,7 +49,6 @@ class Command(BaseCommand):
] ]
for model in MODELS: for model in MODELS:
obj_cls = apps.get_model("epdb", model) obj_cls = apps.get_model("epdb", model)
print(f"Localizing urls for {model}")
obj_cls.objects.update( obj_cls.objects.update(
url=Replace(F("url"), Value(options["old"]), Value(options["new"])) url=Replace(F("url"), Value(options["old"]), Value(options["new"]))
) )

View File

@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-12-02 13:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0011_auto_20251111_1413"),
]
operations = [
migrations.AddField(
model_name="node",
name="stereo_removed",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="pathway",
name="predicted",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2025-12-14 11:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0012_node_stereo_removed_pathway_predicted"),
]
operations = [
migrations.AddField(
model_name="setting",
name="expansion_schema",
field=models.CharField(
choices=[
("BFS", "Breadth First Search"),
("DFS", "Depth First Search"),
("GREEDY", "Greedy"),
],
default="BFS",
max_length=20,
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2025-12-14 16:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("epdb", "0013_setting_expansion_schema"),
]
operations = [
migrations.RenameField(
model_name="setting",
old_name="expansion_schema",
new_name="expansion_scheme",
),
]

View File

@ -2,40 +2,41 @@ import abc
import hashlib import hashlib
import json import json
import logging import logging
import math
import os import os
import secrets import secrets
from abc import abstractmethod from abc import abstractmethod
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import Union, List, Optional, Dict, Tuple, Set, Any from typing import Any, Dict, List, Optional, Set, Tuple, Union
from uuid import uuid4 from uuid import uuid4
import math
import joblib import joblib
import nh3 import nh3
import numpy as np import numpy as np
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db import models, transaction from django.db import models, transaction
from django.db.models import JSONField, Count, Q, QuerySet from django.db.models import Count, JSONField, Q, QuerySet
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from envipy_additional_information import EnviPyModel from envipy_additional_information import EnviPyModel, HalfLife
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from sklearn.metrics import precision_score, recall_score, jaccard_score from sklearn.metrics import jaccard_score, precision_score, recall_score
from sklearn.model_selection import ShuffleSplit from sklearn.model_selection import ShuffleSplit
from utilities.chem import FormatConverter, ProductSet, PredictionResult, IndigoUtils from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import ( from utilities.ml import (
RuleBasedDataset,
ApplicabilityDomainPCA, ApplicabilityDomainPCA,
EnsembleClassifierChain,
RelativeReasoning,
EnviFormerDataset,
Dataset, Dataset,
EnsembleClassifierChain,
EnviFormerDataset,
RelativeReasoning,
RuleBasedDataset,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,8 +45,6 @@ logger = logging.getLogger(__name__)
########################## ##########################
# User/Groups/Permission # # User/Groups/Permission #
########################## ##########################
class User(AbstractUser): class User(AbstractUser):
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
uuid = models.UUIDField( uuid = models.UUIDField(
@ -53,7 +52,10 @@ class User(AbstractUser):
) )
url = models.TextField(blank=False, null=True, verbose_name="URL", unique=True) url = models.TextField(blank=False, null=True, verbose_name="URL", unique=True)
default_package = models.ForeignKey( default_package = models.ForeignKey(
"epdb.Package", verbose_name="Default Package", null=True, on_delete=models.SET_NULL s.EPDB_PACKAGE_MODEL,
verbose_name="Default Package",
null=True,
on_delete=models.SET_NULL,
) )
default_group = models.ForeignKey( default_group = models.ForeignKey(
"Group", "Group",
@ -243,7 +245,7 @@ class UserPackagePermission(Permission):
) )
user = models.ForeignKey("User", verbose_name="Permission to", on_delete=models.CASCADE) user = models.ForeignKey("User", verbose_name="Permission to", on_delete=models.CASCADE)
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Permission on", on_delete=models.CASCADE s.EPDB_PACKAGE_MODEL, verbose_name="Permission on", on_delete=models.CASCADE
) )
class Meta: class Meta:
@ -259,7 +261,7 @@ class GroupPackagePermission(Permission):
) )
group = models.ForeignKey("Group", verbose_name="Permission to", on_delete=models.CASCADE) group = models.ForeignKey("Group", verbose_name="Permission to", on_delete=models.CASCADE)
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Permission on", on_delete=models.CASCADE s.EPDB_PACKAGE_MODEL, verbose_name="Permission on", on_delete=models.CASCADE
) )
class Meta: class Meta:
@ -728,10 +730,13 @@ class Package(EnviPathModel):
rules = sorted(rules, key=lambda x: x.url) rules = sorted(rules, key=lambda x: x.url)
return rules return rules
class Meta:
swappable = "EPDB_PACKAGE_MODEL"
class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin): class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin):
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
) )
default_structure = models.ForeignKey( default_structure = models.ForeignKey(
"CompoundStructure", "CompoundStructure",
@ -749,6 +754,30 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
@property @property
def normalized_structure(self) -> "CompoundStructure": def normalized_structure(self) -> "CompoundStructure":
if not CompoundStructure.objects.filter(compound=self, normalized_structure=True).exists():
num_structs = self.structures.count()
stand_smiles = set()
for structure in self.structures.all():
stand_smiles.add(FormatConverter.standardize(structure.smiles))
if len(stand_smiles) != 1:
logger.debug(
f"#Structures: {num_structs} - #Standardized SMILES: {len(stand_smiles)}"
)
logger.debug(f"Couldn't infer normalized structure for {self.name} - {self.url}")
raise ValueError(
f"Couldn't find nor infer normalized structure for {self.name} ({self.url})"
)
else:
cs = CompoundStructure.create(
self,
stand_smiles.pop(),
name="Normalized structure of {}".format(self.name),
description="{} (in its normalized form)".format(self.description),
normalized_structure=True,
)
return cs
return CompoundStructure.objects.get(compound=self, normalized_structure=True) return CompoundStructure.objects.get(compound=self, normalized_structure=True)
def _url(self): def _url(self):
@ -766,9 +795,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
@property @property
def related_pathways(self): def related_pathways(self):
pathways = Node.objects.filter(node_labels__in=[self.default_structure]).values_list( pathways = self.related_nodes.values_list("pathway", flat=True)
"pathway", flat=True
)
return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by("name") return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by("name")
@property @property
@ -778,10 +805,16 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
| Reaction.objects.filter(package=self.package, products__in=[self.default_structure]) | Reaction.objects.filter(package=self.package, products__in=[self.default_structure])
).order_by("name") ).order_by("name")
@property
def related_nodes(self):
return Node.objects.filter(
node_labels__in=[self.default_structure], pathway__package=self.package
)
@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, name: str = None, description: str = None, *args, **kwargs
) -> "Compound": ) -> "Compound":
if smiles is None or smiles.strip() == "": if smiles is None or smiles.strip() == "":
raise ValueError("SMILES is required") raise ValueError("SMILES is required")
@ -896,15 +929,79 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
if self in mapping: if self in mapping:
return mapping[self] return mapping[self]
default_structure_smiles = self.default_structure.smiles
normalized_structure_smiles = self.normalized_structure.smiles
existing_compound = None
existing_normalized_compound = None
# Dedup check - Check if we find a direct match for a given SMILES
if CompoundStructure.objects.filter(
smiles=default_structure_smiles, compound__package=target
).exists():
existing_compound = CompoundStructure.objects.get(
smiles=default_structure_smiles, compound__package=target
).compound
# Check if we can find the standardized one
if CompoundStructure.objects.filter(
smiles=normalized_structure_smiles, compound__package=target
).exists():
existing_normalized_compound = CompoundStructure.objects.get(
smiles=normalized_structure_smiles, compound__package=target
).compound
if any([existing_compound, existing_normalized_compound]):
if existing_normalized_compound and existing_compound:
# We only have to set the mapping
mapping[self] = existing_compound
for structure in self.structures.all():
if structure not in mapping:
mapping[structure] = existing_compound.structures.get(
smiles=structure.smiles
)
return existing_compound
elif existing_normalized_compound:
mapping[self] = existing_normalized_compound
# Merge the structure into the existing compound
for structure in self.structures.all():
if existing_normalized_compound.structures.filter(
smiles=structure.smiles
).exists():
continue
# Create a new Structure
cs = CompoundStructure.create(
existing_normalized_compound,
structure.smiles,
name=structure.name,
description=structure.description,
normalized_structure=structure.normalized_structure,
)
mapping[structure] = cs
return existing_normalized_compound
else:
raise ValueError(
f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.name}"
)
else:
# Here we can safely use Compound.objects.create as we won't end up in a duplicate
new_compound = Compound.objects.create( new_compound = Compound.objects.create(
package=target, package=target,
name=self.name, name=self.name,
description=self.description, description=self.description,
kv=self.kv.copy() if self.kv else {}, kv=self.kv.copy() if self.kv else {},
) )
mapping[self] = new_compound mapping[self] = new_compound
# Copy compound structures # Copy underlying structures
for structure in self.structures.all(): for structure in self.structures.all():
if structure not in mapping: if structure not in mapping:
new_structure = CompoundStructure.objects.create( new_structure = CompoundStructure.objects.create(
@ -949,6 +1046,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
return new_compound return new_compound
def half_lifes(self):
hls: Dict[Scenario, List[HalfLife]] = defaultdict(list)
for n in self.related_nodes:
for scen in n.scenarios.all().order_by("name"):
for ai in scen.get_additional_information():
if isinstance(ai, HalfLife):
hls[scen].append(ai)
return dict(hls)
class Meta: class Meta:
unique_together = [("uuid", "package")] unique_together = [("uuid", "package")]
@ -1061,7 +1169,7 @@ class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin): class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
) )
# # https://github.com/django-polymorphic/django-polymorphic/issues/229 # # https://github.com/django-polymorphic/django-polymorphic/issues/229
@ -1074,6 +1182,10 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
def apply(self, *args, **kwargs): def apply(self, *args, **kwargs):
pass pass
@abc.abstractmethod
def get_rule_identifier(self) -> str:
pass
@staticmethod @staticmethod
def cls_for_type(rule_type: str): def cls_for_type(rule_type: str):
if rule_type == "SimpleAmbitRule": if rule_type == "SimpleAmbitRule":
@ -1103,34 +1215,44 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
rule_type = type(self) rule_type = type(self)
if rule_type == SimpleAmbitRule: if rule_type == SimpleAmbitRule:
new_rule = SimpleAmbitRule.objects.create( new_rule = SimpleAmbitRule.create(
package=target, package=target,
name=self.name, name=self.name,
description=self.description, description=self.description,
smirks=self.smirks, smirks=self.smirks,
reactant_filter_smarts=self.reactant_filter_smarts, reactant_filter_smarts=self.reactant_filter_smarts,
product_filter_smarts=self.product_filter_smarts, product_filter_smarts=self.product_filter_smarts,
kv=self.kv.copy() if self.kv else {},
) )
if self.kv:
new_rule.kv.update(**self.kv)
new_rule.save()
elif rule_type == SimpleRDKitRule: elif rule_type == SimpleRDKitRule:
new_rule = SimpleRDKitRule.objects.create( new_rule = SimpleRDKitRule.create(
package=target, package=target,
name=self.name, name=self.name,
description=self.description, description=self.description,
reaction_smarts=self.reaction_smarts, reaction_smarts=self.reaction_smarts,
kv=self.kv.copy() if self.kv else {},
) )
if self.kv:
new_rule.kv.update(**self.kv)
new_rule.save()
elif rule_type == ParallelRule: elif rule_type == ParallelRule:
new_rule = ParallelRule.objects.create( new_srs = []
package=target,
name=self.name,
description=self.description,
kv=self.kv.copy() if self.kv else {},
)
# Copy simple rules relationships
for simple_rule in self.simple_rules.all(): for simple_rule in self.simple_rules.all():
copied_simple_rule = simple_rule.copy(target, mapping) copied_simple_rule = simple_rule.copy(target, mapping)
new_rule.simple_rules.add(copied_simple_rule) new_srs.append(copied_simple_rule)
new_rule = ParallelRule.create(
package=target,
simple_rules=new_srs,
name=self.name,
description=self.description,
)
elif rule_type == SequentialRule: elif rule_type == SequentialRule:
raise ValueError("SequentialRule copy not implemented!") raise ValueError("SequentialRule copy not implemented!")
else: else:
@ -1167,7 +1289,7 @@ class SimpleAmbitRule(SimpleRule):
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
package: Package, package: "Package",
name: str = None, name: str = None,
description: str = None, description: str = None,
smirks: str = None, smirks: str = None,
@ -1228,8 +1350,16 @@ class SimpleAmbitRule(SimpleRule):
def _url(self): def _url(self):
return "{}/simple-ambit-rule/{}".format(self.package.url, self.uuid) return "{}/simple-ambit-rule/{}".format(self.package.url, self.uuid)
def get_rule_identifier(self) -> str:
return "simple-rule"
def apply(self, smiles): def apply(self, smiles):
return FormatConverter.apply(smiles, self.smirks) return FormatConverter.apply(
smiles,
self.smirks,
reactant_filter_smarts=self.reactant_filter_smarts,
product_filter_smarts=self.product_filter_smarts,
)
@property @property
def reactants_smarts(self): def reactants_smarts(self):
@ -1241,7 +1371,7 @@ class SimpleAmbitRule(SimpleRule):
@property @property
def related_reactions(self): def related_reactions(self):
qs = Package.objects.filter(reviewed=True) qs = s.GET_PACKAGE_MODEL().objects.filter(reviewed=True)
return self.reaction_rule.filter(package__in=qs).order_by("name") return self.reaction_rule.filter(package__in=qs).order_by("name")
@property @property
@ -1273,6 +1403,9 @@ class ParallelRule(Rule):
def _url(self): def _url(self):
return "{}/parallel-rule/{}".format(self.package.url, self.uuid) return "{}/parallel-rule/{}".format(self.package.url, self.uuid)
def get_rule_identifier(self) -> str:
return "parallel-rule"
@cached_property @cached_property
def srs(self) -> QuerySet: def srs(self) -> QuerySet:
return self.simple_rules.all() return self.simple_rules.all()
@ -1304,6 +1437,71 @@ class ParallelRule(Rule):
return res return res
@staticmethod
@transaction.atomic
def create(
package: "Package",
simple_rules: List["SimpleRule"],
name: str = None,
description: str = None,
reactant_filter_smarts: str = None,
product_filter_smarts: str = None,
):
if len(simple_rules) == 0:
raise ValueError("At least one simple rule is required!")
for sr in simple_rules:
if sr.package != package:
raise ValueError(
f"Simple rule {sr.uuid} does not belong to package {package.uuid}!"
)
# Deduplication check
query = ParallelRule.objects.annotate(
srs_count=Count("simple_rules", filter=Q(simple_rules__in=simple_rules), distinct=True)
)
existing_rule_qs = query.filter(
srs_count=len(simple_rules),
)
if existing_rule_qs.exists():
if existing_rule_qs.count() > 1:
logger.error(f"Found more than one reaction for given input! {existing_rule_qs}")
return existing_rule_qs.first()
r = ParallelRule()
r.package = package
if name is not None:
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
if name is None or name == "":
name = f"Rule {Rule.objects.filter(package=package).count() + 1}"
r.name = name
if description is not None and description.strip() != "":
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "":
if not FormatConverter.is_valid_smarts(reactant_filter_smarts.strip()):
raise ValueError(f'Reactant Filter SMARTS "{reactant_filter_smarts}" is invalid!')
else:
r.reactant_filter_smarts = reactant_filter_smarts.strip()
if product_filter_smarts is not None and product_filter_smarts.strip() != "":
if not FormatConverter.is_valid_smarts(product_filter_smarts.strip()):
raise ValueError(f'Product Filter SMARTS "{product_filter_smarts}" is invalid!')
else:
r.product_filter_smarts = product_filter_smarts.strip()
r.save()
for sr in simple_rules:
r.simple_rules.add(sr)
return r
class SequentialRule(Rule): class SequentialRule(Rule):
simple_rules = models.ManyToManyField( simple_rules = models.ManyToManyField(
@ -1313,6 +1511,9 @@ class SequentialRule(Rule):
def _url(self): def _url(self):
return "{}/sequential-rule/{}".format(self.compound.url, self.uuid) return "{}/sequential-rule/{}".format(self.compound.url, self.uuid)
def get_rule_identifier(self) -> str:
return "sequential-rule"
@property @property
def srs(self): def srs(self):
return self.simple_rules.all() return self.simple_rules.all()
@ -1333,7 +1534,7 @@ class SequentialRuleOrdering(models.Model):
class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin): class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin):
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
) )
educts = models.ManyToManyField( educts = models.ManyToManyField(
"epdb.CompoundStructure", verbose_name="Educts", related_name="reaction_educts" "epdb.CompoundStructure", verbose_name="Educts", related_name="reaction_educts"
@ -1355,7 +1556,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def create( def create(
package: Package, package: "Package",
name: str = None, name: str = None,
description: str = None, description: str = None,
educts: Union[List[str], List[CompoundStructure]] = None, educts: Union[List[str], List[CompoundStructure]] = None,
@ -1450,31 +1651,44 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
if self in mapping: if self in mapping:
return mapping[self] return mapping[self]
# Create new reaction copied_reaction_educts = []
new_reaction = Reaction.objects.create( copied_reaction_products = []
package=target, copied_reaction_rules = []
name=self.name,
description=self.description,
multi_step=self.multi_step,
medline_references=self.medline_references,
kv=self.kv.copy() if self.kv else {},
)
mapping[self] = new_reaction
# Copy educts (reactant compounds) # Copy educts (reactant compounds)
for educt in self.educts.all(): for educt in self.educts.all():
copied_educt = educt.copy(target, mapping) copied_educt = educt.copy(target, mapping)
new_reaction.educts.add(copied_educt) copied_reaction_educts.append(copied_educt)
# Copy products # Copy products
for product in self.products.all(): for product in self.products.all():
copied_product = product.copy(target, mapping) copied_product = product.copy(target, mapping)
new_reaction.products.add(copied_product) copied_reaction_products.append(copied_product)
# Copy rules # Copy rules
for rule in self.rules.all(): for rule in self.rules.all():
copied_rule = rule.copy(target, mapping) copied_rule = rule.copy(target, mapping)
new_reaction.rules.add(copied_rule) copied_reaction_rules.append(copied_rule)
new_reaction = Reaction.create(
package=target,
name=self.name,
description=self.description,
educts=copied_reaction_educts,
products=copied_reaction_products,
rules=copied_reaction_rules,
multi_step=self.multi_step,
)
if self.medline_references:
new_reaction.medline_references = self.medline_references
new_reaction.save()
if self.kv:
new_reaction.kv = self.kv
new_reaction.save()
mapping[self] = new_reaction
# Copy external identifiers # Copy external identifiers
for ext_id in self.external_identifiers.all(): for ext_id in self.external_identifiers.all():
@ -1514,11 +1728,12 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
) )
setting = models.ForeignKey( setting = models.ForeignKey(
"epdb.Setting", verbose_name="Setting", on_delete=models.CASCADE, null=True, blank=True "epdb.Setting", verbose_name="Setting", on_delete=models.CASCADE, null=True, blank=True
) )
predicted = models.BooleanField(default=False, null=False)
@property @property
def root_nodes(self): def root_nodes(self):
@ -1544,6 +1759,16 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
# potentially prefetched edge_set # potentially prefetched edge_set
return self.edge_set.all() return self.edge_set.all()
@property
def setting_with_overrides(self):
mem_copy = Setting.objects.get(pk=self.setting.pk)
if "setting_overrides" in self.kv:
for k, v in self.kv["setting_overrides"].items():
setattr(mem_copy, k, f"{v} (this is an override for this particular pathway)")
return mem_copy
def _url(self): def _url(self):
return "{}/pathway/{}".format(self.package.url, self.uuid) return "{}/pathway/{}".format(self.package.url, self.uuid)
@ -1570,6 +1795,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
def failed(self): def failed(self):
return self.status() == "failed" return self.status() == "failed"
def empty_due_to_threshold(self):
return self.kv.get("empty_due_to_threshold", False)
def d3_json(self): def d3_json(self):
# Ideally it would be something like this but # Ideally it would be something like this but
# to reduce crossing in edges do a DFS # to reduce crossing in edges do a DFS
@ -1591,11 +1819,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
while len(queue): while len(queue):
current = queue.pop() current = queue.pop()
processed.add(current) processed.add(current)
nodes.append(current.d3_json()) nodes.append(current.d3_json())
for e in self.edges: for e in self.edges.filter(start_nodes=current).distinct():
if current in e.start_nodes.all():
for prod in e.end_nodes.all(): for prod in e.end_nodes.all():
if prod not in queue and prod not in processed: if prod not in queue and prod not in processed:
queue.append(prod) queue.append(prod)
@ -1679,15 +1905,18 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"status": self.status(), "status": self.status(),
} }
return json.dumps(res) return res
def to_csv(self) -> str: def to_csv(self, include_header=True, include_pathway_url=False) -> str:
import csv import csv
import io import io
rows = [] header = []
rows.append(
[ if include_pathway_url:
header += ["Pathway URL"]
header += [
"SMILES", "SMILES",
"name", "name",
"depth", "depth",
@ -1696,10 +1925,20 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
"rule_ids", "rule_ids",
"parent_smiles", "parent_smiles",
] ]
)
rows = []
if include_header:
rows.append(header)
for n in self.nodes.order_by("depth"): for n in self.nodes.order_by("depth"):
cs = n.default_node_label cs = n.default_node_label
row = [cs.smiles, cs.name, n.depth] row = []
if include_pathway_url:
row.append(n.pathway.url)
row += [cs.smiles, cs.name, n.depth]
edges = self.edges.filter(end_nodes__in=[n]) edges = self.edges.filter(end_nodes__in=[n])
if len(edges): if len(edges):
@ -1730,6 +1969,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
smiles: str, smiles: str,
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
predicted: bool = False,
): ):
pw = Pathway() pw = Pathway()
pw.package = package pw.package = package
@ -1742,6 +1982,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
pw.name = name pw.name = name
if description is not None and description.strip() != "": if description is not None and description.strip() != "":
pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
pw.predicted = predicted
pw.save() pw.save()
try: try:
@ -1761,6 +2002,8 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
return mapping[self] return mapping[self]
# Start copying the pathway # Start copying the pathway
# Its safe to use .objects.create here as Pathways itself aren't
# deduplicated
new_pathway = Pathway.objects.create( new_pathway = Pathway.objects.create(
package=target, package=target,
name=self.name, name=self.name,
@ -1872,6 +2115,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
) )
out_edges = models.ManyToManyField("epdb.Edge", verbose_name="Outgoing Edges") out_edges = models.ManyToManyField("epdb.Edge", verbose_name="Outgoing Edges")
depth = models.IntegerField(verbose_name="Node depth", null=False, blank=False) depth = models.IntegerField(verbose_name="Node depth", null=False, blank=False)
stereo_removed = models.BooleanField(default=False, null=False)
def _url(self): def _url(self):
return "{}/node/{}".format(self.pathway.url, self.uuid) return "{}/node/{}".format(self.pathway.url, self.uuid)
@ -1881,6 +2125,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
return { return {
"depth": self.depth, "depth": self.depth,
"stereo_removed": self.stereo_removed,
"url": self.url, "url": self.url,
"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",
@ -1896,6 +2141,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
else None, else None,
"uncovered_functional_groups": False, "uncovered_functional_groups": False,
}, },
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
} }
@staticmethod @staticmethod
@ -1906,12 +2152,17 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
name: Optional[str] = None, name: Optional[str] = None,
description: Optional[str] = None, description: Optional[str] = None,
): ):
stereo_removed = False
if pathway.predicted and FormatConverter.has_stereo(smiles):
smiles = FormatConverter.standardize(smiles, remove_stereo=True)
stereo_removed = True
c = Compound.create(pathway.package, smiles, name=name, description=description) c = Compound.create(pathway.package, smiles, name=name, description=description)
if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists(): if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists():
return Node.objects.get(pathway=pathway, default_node_label=c.default_structure) return Node.objects.get(pathway=pathway, default_node_label=c.default_structure)
n = Node() n = Node()
n.stereo_removed = stereo_removed
n.pathway = pathway n.pathway = pathway
n.depth = depth n.depth = depth
@ -2076,7 +2327,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
class EPModel(PolymorphicModel, EnviPathModel): class EPModel(PolymorphicModel, EnviPathModel):
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
) )
def _url(self): def _url(self):
@ -2085,17 +2336,17 @@ class EPModel(PolymorphicModel, EnviPathModel):
class PackageBasedModel(EPModel): class PackageBasedModel(EPModel):
rule_packages = models.ManyToManyField( rule_packages = models.ManyToManyField(
"Package", s.EPDB_PACKAGE_MODEL,
verbose_name="Rule Packages", verbose_name="Rule Packages",
related_name="%(app_label)s_%(class)s_rule_packages", related_name="%(app_label)s_%(class)s_rule_packages",
) )
data_packages = models.ManyToManyField( data_packages = models.ManyToManyField(
"Package", s.EPDB_PACKAGE_MODEL,
verbose_name="Data Packages", verbose_name="Data Packages",
related_name="%(app_label)s_%(class)s_data_packages", related_name="%(app_label)s_%(class)s_data_packages",
) )
eval_packages = models.ManyToManyField( eval_packages = models.ManyToManyField(
"Package", s.EPDB_PACKAGE_MODEL,
verbose_name="Evaluation Packages", verbose_name="Evaluation Packages",
related_name="%(app_label)s_%(class)s_eval_packages", related_name="%(app_label)s_%(class)s_eval_packages",
) )
@ -2152,6 +2403,29 @@ class PackageBasedModel(EPModel):
return res return res
@property
def mg_pr_curve(self):
if self.model_status != self.FINISHED:
raise ValueError(f"Expected {self.FINISHED} but model is in status {self.model_status}")
if not self.multigen_eval:
raise ValueError("MG PR Curve is only available for multigen models")
res = []
thresholds = self.eval_results["multigen_average_precision_per_threshold"].keys()
for t in thresholds:
res.append(
{
"precision": self.eval_results["multigen_average_precision_per_threshold"][t],
"recall": self.eval_results["multigen_average_recall_per_threshold"][t],
"threshold": float(t),
}
)
return res
@cached_property @cached_property
def applicable_rules(self) -> List["Rule"]: def applicable_rules(self) -> List["Rule"]:
""" """
@ -2213,6 +2487,13 @@ class PackageBasedModel(EPModel):
return Dataset.load(ds_path) return Dataset.load(ds_path)
def retrain(self): def retrain(self):
# Reset eval fields
self.eval_results = {}
self.eval_packages.clear()
self.model_status = False
self.save()
# Do actual retrain
self.build_dataset() self.build_dataset()
self.build_model() self.build_model()
@ -2250,7 +2531,7 @@ class PackageBasedModel(EPModel):
self.save() self.save()
def evaluate_model(self, multigen: bool, eval_packages: List["Package"] = None, **kwargs): def evaluate_model(self, multigen: bool, eval_packages: List["Package"] = None, **kwargs):
if self.model_status != self.BUILT_NOT_EVALUATED: if self.model_status not in [self.BUILT_NOT_EVALUATED, self.FINISHED]:
raise ValueError(f"Can't evaluate a model in state {self.model_status}!") raise ValueError(f"Can't evaluate a model in state {self.model_status}!")
if multigen: if multigen:
@ -2258,9 +2539,12 @@ class PackageBasedModel(EPModel):
self.save() self.save()
if eval_packages is not None: if eval_packages is not None:
self.eval_packages.clear()
for p in eval_packages: for p in eval_packages:
self.eval_packages.add(p) self.eval_packages.add(p)
self.eval_results = {}
self.model_status = self.EVALUATING self.model_status = self.EVALUATING
self.save() self.save()
@ -2314,9 +2598,14 @@ class PackageBasedModel(EPModel):
recall = {f"{t:.2f}": [] for t in thresholds} recall = {f"{t:.2f}": [] for t in thresholds}
# Note: only one root compound supported at this time # Note: only one root compound supported at this time
root_compounds = [ root_compounds = []
[p.default_node_label.smiles for p in p.root_nodes][0] for p in pathways for pw in pathways:
] if pw.root_nodes:
root_compounds.append(pw.root_nodes[0].default_node_label)
else:
logger.info(
f"Skipping MG Eval of Pathway {pw.name} ({pw.uuid}) as it has no root compounds!"
)
# As we need a Model Instance in our setting, get a fresh copy from db, overwrite the serialized mode and # As we need a Model Instance in our setting, get a fresh copy from db, overwrite the serialized mode and
# pass it to the setting used in prediction # pass it to the setting used in prediction
@ -2340,7 +2629,7 @@ class PackageBasedModel(EPModel):
for i, root in enumerate(root_compounds): for i, root in enumerate(root_compounds):
logger.debug(f"Evaluating pathway {i + 1} of {len(root_compounds)}...") logger.debug(f"Evaluating pathway {i + 1} of {len(root_compounds)}...")
spw = SPathway(root_nodes=root, prediction_setting=s) spw = SPathway(root_nodes=root.smiles, prediction_setting=s)
level = 0 level = 0
while not spw.done: while not spw.done:
@ -3123,7 +3412,7 @@ class EnviFormer(PackageBasedModel):
return args return args
def evaluate_model(self, multigen: bool, eval_packages: List["Package"] = None, **kwargs): def evaluate_model(self, multigen: bool, eval_packages: List["Package"] = None, **kwargs):
if self.model_status != self.BUILT_NOT_EVALUATED: if self.model_status not in [self.BUILT_NOT_EVALUATED, self.FINISHED]:
raise ValueError(f"Can't evaluate a model in state {self.model_status}!") raise ValueError(f"Can't evaluate a model in state {self.model_status}!")
if multigen: if multigen:
@ -3131,9 +3420,12 @@ class EnviFormer(PackageBasedModel):
self.save() self.save()
if eval_packages is not None: if eval_packages is not None:
self.eval_packages.clear()
for p in eval_packages: for p in eval_packages:
self.eval_packages.add(p) self.eval_packages.add(p)
self.eval_results = {}
self.model_status = self.EVALUATING self.model_status = self.EVALUATING
self.save() self.save()
@ -3400,7 +3692,7 @@ class PluginModel(EPModel):
class Scenario(EnviPathModel): class Scenario(EnviPathModel):
package = models.ForeignKey( package = models.ForeignKey(
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
) )
scenario_date = models.CharField(max_length=256, null=False, blank=False, default="No date") scenario_date = models.CharField(max_length=256, null=False, blank=False, default="No date")
scenario_type = models.CharField( scenario_type = models.CharField(
@ -3543,6 +3835,12 @@ class UserSettingPermission(Permission):
return f"User: {self.user} has Permission: {self.permission} on Setting: {self.setting}" return f"User: {self.user} has Permission: {self.permission} on Setting: {self.setting}"
class ExpansionSchemeChoice(models.TextChoices):
BFS = "BFS", "Breadth First Search"
DFS = "DFS", "Depth First Search"
GREEDY = "GREEDY", "Greedy"
class Setting(EnviPathModel): class Setting(EnviPathModel):
public = models.BooleanField(null=False, blank=False, default=False) public = models.BooleanField(null=False, blank=False, default=False)
global_default = models.BooleanField(null=False, blank=False, default=False) global_default = models.BooleanField(null=False, blank=False, default=False)
@ -3555,7 +3853,7 @@ class Setting(EnviPathModel):
) )
rule_packages = models.ManyToManyField( rule_packages = models.ManyToManyField(
"Package", s.EPDB_PACKAGE_MODEL,
verbose_name="Setting Rule Packages", verbose_name="Setting Rule Packages",
related_name="setting_rule_packages", related_name="setting_rule_packages",
blank=True, blank=True,
@ -3567,6 +3865,12 @@ class Setting(EnviPathModel):
null=True, blank=True, verbose_name="Setting Model Threshold", default=0.25 null=True, blank=True, verbose_name="Setting Model Threshold", default=0.25
) )
expansion_scheme = models.CharField(
max_length=20,
choices=ExpansionSchemeChoice.choices,
default=ExpansionSchemeChoice.BFS,
)
def _url(self): def _url(self):
return "{}/setting/{}".format(s.SERVER_URL, self.uuid) return "{}/setting/{}".format(s.SERVER_URL, self.uuid)
@ -3601,33 +3905,48 @@ class Setting(EnviPathModel):
rules = sorted(rules, key=lambda x: x.url) rules = sorted(rules, key=lambda x: x.url)
return rules return rules
def expand(self, pathway, current_node): def expand(self, pathway, current_node) -> Dict[str, Any]:
res: Dict[str, Any] = defaultdict(list)
"""Decision Method whether to expand on a certain Node or not""" """Decision Method whether to expand on a certain Node or not"""
if pathway.num_nodes() >= self.max_nodes: if pathway.num_nodes() >= self.max_nodes:
logger.info( logger.info(
f"Pathway has {pathway.num_nodes()} which exceeds the limit of {self.max_nodes}" f"Pathway has {pathway.num_nodes()} Nodes which exceeds the limit of {self.max_nodes}"
) )
return [] res["expansion_skipped"] = True
return res
if pathway.depth() >= self.max_depth: if pathway.depth() >= self.max_depth:
logger.info( logger.info(
f"Pathway has reached depth {pathway.depth()} which exceeds the limit of {self.max_depth}" f"Pathway has reached depth {pathway.depth()} which exceeds the limit of {self.max_depth}"
) )
return [] res["expansion_skipped"] = True
return res
transformations = []
if self.model is not None: if self.model is not None:
pred_results = self.model.predict(current_node.smiles) pred_results = self.model.predict(current_node.smiles)
# Store whether there are results that may be removed as they are below
# the given threshold
if len(pred_results):
res["rule_triggered"] = True
for pred_result in pred_results: for pred_result in pred_results:
if pred_result.probability >= self.model_threshold: if (
transformations.append(pred_result) len(pred_result.product_sets)
and pred_result.probability >= self.model_threshold
):
res["transformations"].append(pred_result)
else: else:
for rule in self.applicable_rules: for rule in self.applicable_rules:
tmp_products = rule.apply(current_node.smiles) tmp_products = rule.apply(current_node.smiles)
if tmp_products: if tmp_products:
transformations.append(PredictionResult(tmp_products, 1.0, rule)) res["transformations"].append(PredictionResult(tmp_products, 1.0, rule))
return transformations if len(res["transformations"]):
res["rule_triggered"] = True
return res
@transaction.atomic @transaction.atomic
def make_global_default(self): def make_global_default(self):
@ -3660,10 +3979,6 @@ class JobLog(TimeStampedModel):
done_at = models.DateTimeField(null=True, blank=True, default=None) done_at = models.DateTimeField(null=True, blank=True, default=None)
task_result = models.TextField(null=True, blank=True, default=None) task_result = models.TextField(null=True, blank=True, default=None)
def check_for_update(self):
async_res = self.get_result()
new_status = async_res.state
TERMINAL_STATES = [ TERMINAL_STATES = [
"SUCCESS", "SUCCESS",
"FAILURE", "FAILURE",
@ -3671,12 +3986,22 @@ class JobLog(TimeStampedModel):
"IGNORED", "IGNORED",
] ]
if new_status != self.status and new_status in TERMINAL_STATES: def is_in_terminal_state(self):
return self.status in self.TERMINAL_STATES
def check_for_update(self):
if self.is_in_terminal_state():
return
async_res = self.get_result()
new_status = async_res.state
if new_status != self.status and new_status in self.TERMINAL_STATES:
self.status = new_status self.status = new_status
self.done_at = async_res.date_done self.done_at = async_res.date_done
if new_status == "SUCCESS": if new_status == "SUCCESS":
self.task_result = async_res.result self.task_result = str(async_res.result) if async_res.result else None
self.save() self.save()
@ -3687,3 +4012,18 @@ class JobLog(TimeStampedModel):
from celery.result import AsyncResult from celery.result import AsyncResult
return AsyncResult(str(self.task_id)) return AsyncResult(str(self.task_id))
def parsed_result(self):
if not self.is_in_terminal_state() or self.task_result is None:
return None
import ast
if self.job_name == "engineer_pathways":
return ast.literal_eval(self.task_result)
return self.task_result
def is_result_downloadable(self):
downloadable = ["batch_predict"]
return self.job_name in downloadable

View File

@ -6,14 +6,18 @@ from uuid import uuid4
from celery import shared_task from celery import shared_task
from celery.utils.functional import LRUCache from celery.utils.functional import LRUCache
from django.conf import settings as s
from django.utils import timezone from django.utils import timezone
from epdb.logic import SPathway from epdb.logic import SPathway
from epdb.models import Edge, EPModel, JobLog, Node, Package, Pathway, Rule, Setting, User from epdb.models import Edge, EPModel, JobLog, Node, Pathway, Rule, Setting, User
from utilities.chem import FormatConverter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ML_CACHE = LRUCache(3) # Cache the three most recent ML models to reduce load times. ML_CACHE = LRUCache(3) # Cache the three most recent ML models to reduce load times.
Package = s.GET_PACKAGE_MODEL()
def get_ml_model(model_pk: int): def get_ml_model(model_pk: int):
if model_pk not in ML_CACHE: if model_pk not in ML_CACHE:
@ -33,7 +37,7 @@ def dispatch_eager(user: "User", job: Callable, *args, **kwargs):
log.task_result = str(x) if x else None log.task_result = str(x) if x else None
log.save() log.save()
return x return log, x
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
raise e raise e
@ -49,7 +53,7 @@ def dispatch(user: "User", job: Callable, *args, **kwargs):
log.status = "INITIAL" log.status = "INITIAL"
log.save() log.save()
return x.result return log
except Exception as e: except Exception as e:
logger.exception(e) logger.exception(e)
raise e raise e
@ -136,14 +140,25 @@ def predict(
pred_setting_pk: int, pred_setting_pk: int,
limit: Optional[int] = None, limit: Optional[int] = None,
node_pk: Optional[int] = None, node_pk: Optional[int] = None,
setting_overrides: Optional[dict] = None,
) -> Pathway: ) -> Pathway:
pw = Pathway.objects.get(id=pw_pk) pw = Pathway.objects.get(id=pw_pk)
setting = Setting.objects.get(id=pred_setting_pk) setting = Setting.objects.get(id=pred_setting_pk)
if setting_overrides:
for k, v in setting_overrides.items():
setattr(setting, k, v)
# If the setting has a model add/restore it from the cache # If the setting has a model add/restore it from the cache
if setting.model is not None: if setting.model is not None:
setting.model = get_ml_model(setting.model.pk) setting.model = get_ml_model(setting.model.pk)
pw.kv.update(**{"status": "running"}) kv = {"status": "running"}
if setting_overrides:
kv["setting_overrides"] = setting_overrides
pw.kv.update(**kv)
pw.save() pw.save()
if JobLog.objects.filter(task_id=self.request.id).exists(): if JobLog.objects.filter(task_id=self.request.id).exists():
@ -168,10 +183,12 @@ def predict(
spw = SPathway.from_pathway(pw) spw = SPathway.from_pathway(pw)
spw.predict_step(from_node=n) spw.predict_step(from_node=n)
else: else:
raise ValueError("Neither limit nor node_pk given!") spw = SPathway(prediction_setting=setting, persist=pw)
spw.predict()
except Exception as e: except Exception as e:
pw.kv.update({"status": "failed"}) pw.kv.update({"status": "failed"})
pw.kv.update(**{"error": str(e)})
pw.save() pw.save()
if JobLog.objects.filter(task_id=self.request.id).exists(): if JobLog.objects.filter(task_id=self.request.id).exists():
@ -281,3 +298,144 @@ def identify_missing_rules(
buffer.seek(0) buffer.seek(0)
return buffer.getvalue() return buffer.getvalue()
@shared_task(bind=True, queue="background")
def engineer_pathways(self, pw_pks: List[int], setting_pk: int, target_package_pk: int):
from utilities.misc import PathwayUtils
setting = Setting.objects.get(pk=setting_pk)
# Temporarily set model_threshold to 0.0 to keep all tps
setting.model_threshold = 0.0
target = Package.objects.get(pk=target_package_pk)
intermediate_pathways = []
predicted_pathways = []
for pw in Pathway.objects.filter(pk__in=pw_pks):
pu = PathwayUtils(pw)
eng_pw, node_to_snode_mapping, intermediates = pu.engineer(setting)
# If we've found intermediates, do the following
# - Get a copy of the original pathway and add intermediates
# - Store the predicted pathway for further investigation
if len(intermediates):
copy_mapping = {}
copied_pw = pw.copy(target, copy_mapping)
copied_pw.name = f"{copied_pw.name} (Engineered)"
copied_pw.description = f"The original Pathway can be found here: {pw.url}"
copied_pw.save()
for inter in intermediates:
start = copy_mapping[inter[0]]
end = copy_mapping[inter[1]]
start_snode = inter[2]
end_snode = inter[3]
for idx, intermediate_edge in enumerate(inter[4]):
smiles_to_node = {}
snodes_to_create = list(
set(intermediate_edge.educts + intermediate_edge.products)
)
for snode in snodes_to_create:
if snode == start_snode or snode == end_snode:
smiles_to_node[snode.smiles] = start if snode == start_snode else end
continue
if snode.smiles not in smiles_to_node:
n = Node.create(copied_pw, smiles=snode.smiles, depth=snode.depth)
# Used in viz to highlight intermediates
n.kv.update({"is_engineered_intermediate": True})
n.save()
smiles_to_node[snode.smiles] = n
Edge.create(
copied_pw,
[smiles_to_node[educt.smiles] for educt in intermediate_edge.educts],
[smiles_to_node[product.smiles] for product in intermediate_edge.products],
rule=intermediate_edge.rule,
)
# Persist the predicted pathway
pred_pw = pu.spathway_to_pathway(target, eng_pw, name=f"{pw.name} (Predicted)")
intermediate_pathways.append(copied_pw.url)
predicted_pathways.append(pred_pw.url)
return intermediate_pathways, predicted_pathways
@shared_task(bind=True, queue="background")
def batch_predict(
self,
substrates: List[str] | List[List[str]],
prediction_setting_pk: int,
target_package_pk: int,
num_tps: int = 50,
):
target_package = Package.objects.get(pk=target_package_pk)
prediction_setting = Setting.objects.get(pk=prediction_setting_pk)
if len(substrates) == 0:
raise ValueError("No substrates given!")
is_pair = isinstance(substrates[0], list)
substrate_and_names = []
if not is_pair:
for sub in substrates:
substrate_and_names.append([sub, None])
else:
substrate_and_names = substrates
# Check prerequisite that we can standardize all substrates
standardized_substrates_and_smiles = []
for substrate in substrate_and_names:
try:
stand_smiles = FormatConverter.standardize(substrate[0])
standardized_substrates_and_smiles.append([stand_smiles, substrate[1]])
except ValueError:
raise ValueError(
f'Pathway prediction failed as standardization of SMILES "{substrate}" failed!'
)
pathways = []
for pair in standardized_substrates_and_smiles:
pw = Pathway.create(
target_package,
pair[0],
name=pair[1],
predicted=True,
)
# set mode and setting
pw.setting = prediction_setting
pw.kv.update({"mode": "predict"})
pw.save()
predict(
pw.pk,
prediction_setting.pk,
limit=None,
setting_overrides={
"max_nodes": num_tps,
"max_depth": num_tps,
"model_threshold": 0.001,
},
)
pathways.append(pw)
buffer = io.StringIO()
for idx, pw in enumerate(pathways):
# Carry out header only for the first pathway
buffer.write(pw.to_csv(include_header=idx == 0, include_pathway_url=True))
buffer.seek(0)
return buffer.getvalue()

View File

@ -49,6 +49,7 @@ urlpatterns = [
re_path(r"^group$", v.groups, name="groups"), re_path(r"^group$", v.groups, name="groups"),
re_path(r"^search$", v.search, name="search"), re_path(r"^search$", v.search, name="search"),
re_path(r"^predict$", v.predict_pathway, name="predict_pathway"), re_path(r"^predict$", v.predict_pathway, name="predict_pathway"),
re_path(r"^batch-predict$", v.batch_predict_pathway, name="batch_predict_pathway"),
# User Detail # User Detail
re_path(rf"^user/(?P<user_uuid>{UUID})", v.user, name="user"), re_path(rf"^user/(?P<user_uuid>{UUID})", v.user, name="user"),
# Group Detail # Group Detail
@ -142,6 +143,11 @@ urlpatterns = [
v.package_pathway, v.package_pathway,
name="package pathway detail", name="package pathway detail",
), ),
re_path(
rf"^package/(?P<package_uuid>{UUID})/predict$",
v.package_predict_pathway,
name="package predict pathway",
),
# Pathway Nodes # Pathway Nodes
re_path( re_path(
rf"^package/(?P<package_uuid>{UUID})/pathway/(?P<pathway_uuid>{UUID})/node$", rf"^package/(?P<package_uuid>{UUID})/pathway/(?P<pathway_uuid>{UUID})/node$",
@ -191,7 +197,8 @@ urlpatterns = [
re_path(r"^indigo/dearomatize$", v.dearomatize, name="indigo_dearomatize"), re_path(r"^indigo/dearomatize$", v.dearomatize, name="indigo_dearomatize"),
re_path(r"^indigo/layout$", v.layout, name="indigo_layout"), re_path(r"^indigo/layout$", v.layout, name="indigo_layout"),
re_path(r"^depict$", v.depict, name="depict"), re_path(r"^depict$", v.depict, name="depict"),
re_path(r"^jobs", v.jobs, name="jobs"), path("jobs", v.jobs, name="jobs"),
path("jobs/<uuid:job_uuid>", v.job, name="job detail"),
# OAuth Stuff # OAuth Stuff
path("o/userinfo/", v.userinfo, name="oauth_userinfo"), path("o/userinfo/", v.userinfo, name="oauth_userinfo"),
# Static Pages # Static Pages

View File

@ -1,58 +1,63 @@
import json import json
import logging import logging
from typing import List, Dict, Any from typing import Any, Dict, List
from datetime import datetime
import nh3
from django.conf import settings as s from django.conf import settings as s
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.http import JsonResponse, HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest from django.core.exceptions import BadRequest, PermissionDenied
from django.shortcuts import render, redirect from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from envipy_additional_information import NAME_MAPPING from envipy_additional_information import NAME_MAPPING
from oauth2_provider.decorators import protected_resource from oauth2_provider.decorators import protected_resource
import nh3
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 utilities.misc import HTMLGenerator from utilities.misc import HTMLGenerator
from .logic import ( from .logic import (
EPDBURLParser,
GroupManager, GroupManager,
PackageManager, PackageManager,
UserManager,
SettingManager,
SearchManager, SearchManager,
EPDBURLParser, SettingManager,
UserManager,
) )
from .models import ( from .models import (
Package, APIToken,
GroupPackagePermission,
Group,
CompoundStructure,
Compound, Compound,
CompoundStructure,
Edge,
EnviFormer,
EnzymeLink,
EPModel,
ExternalDatabase,
ExternalIdentifier,
Group,
GroupPackagePermission,
JobLog,
License,
MLRelativeReasoning,
Node,
Pathway,
Permission,
Reaction, Reaction,
Rule, Rule,
Pathway,
Node,
EPModel,
EnviFormer,
MLRelativeReasoning,
RuleBasedRelativeReasoning, RuleBasedRelativeReasoning,
Scenario, Scenario,
SimpleAmbitRule, SimpleAmbitRule,
APIToken,
UserPackagePermission,
Permission,
License,
User, User,
Edge, UserPackagePermission,
ExternalDatabase, ExpansionSchemeChoice,
ExternalIdentifier,
EnzymeLink,
JobLog,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Package = s.GET_PACKAGE_MODEL()
def log_post_params(request): def log_post_params(request):
if s.DEBUG: if s.DEBUG:
@ -60,6 +65,26 @@ def log_post_params(request):
logger.debug(f"{k}\t{v}") logger.debug(f"{k}\t{v}")
def get_error_handler_context(request, for_user=None) -> Dict[str, Any]:
current_user = _anonymous_or_real(request)
if for_user:
current_user = for_user
ctx = {
"title": "enviPath",
"meta": {
"site_id": s.MATOMO_SITE_ID,
"version": "0.0.1",
"server_url": s.SERVER_URL,
"user": current_user,
"enabled_features": s.FLAGS,
"debug": s.DEBUG,
},
}
return ctx
def error(request, message: str, detail: str, code: int = 400): def error(request, message: str, detail: str, code: int = 400):
context = get_base_context(request) context = get_base_context(request)
error_context = { error_context = {
@ -74,6 +99,48 @@ def error(request, message: str, detail: str, code: int = 400):
return render(request, "errors/error.html", context, status=code) return render(request, "errors/error.html", context, status=code)
def handler400(request, exception):
"""Custom 400 Bad Request error handler"""
context = get_error_handler_context(request)
context["public_mode"] = True
return render(request, "errors/400_bad_request.html", context, status=400)
def handler403(request, exception):
"""Custom 403 Forbidden error handler"""
context = get_error_handler_context(request)
context["public_mode"] = True
return render(request, "errors/403_access_denied.html", context, status=403)
def handler404(request, exception):
"""Custom 404 Not Found error handler"""
context = get_error_handler_context(request)
context["public_mode"] = True
return render(request, "errors/404_not_found.html", context, status=404)
def handler500(request):
"""Custom 500 Internal Server Error handler"""
context = get_error_handler_context(request)
error_context = {}
error_context["error_message"] = "Internal Server Error"
error_context["error_detail"] = "An unexpected error occurred. Please try again later."
if request.headers.get("Accept") == "application/json":
return JsonResponse(error_context, status=500)
context["public_mode"] = True
context["error_code"] = 500
context["error_description"] = (
"We encountered an unexpected error while processing your request. Our team has been notified and is working to resolve the issue."
)
context.update(**error_context)
return render(request, "errors/error.html", context, status=500)
def login(request): def login(request):
context = get_base_context(request) context = get_base_context(request)
@ -83,8 +150,7 @@ def login(request):
return render(request, "static/login.html", context) return render(request, "static/login.html", context)
elif request.method == "POST": elif request.method == "POST":
from django.contrib.auth import authenticate from django.contrib.auth import authenticate, login
from django.contrib.auth import login
username = request.POST.get("username").strip() username = request.POST.get("username").strip()
if username != request.POST.get("username"): if username != request.POST.get("username"):
@ -191,8 +257,8 @@ def register(request):
def editable(request, user): def editable(request, user):
if user.is_superuser: # if user.is_superuser:
return True # return True
url = request.build_absolute_uri(request.path) url = request.build_absolute_uri(request.path)
if PackageManager.is_package_url(url): if PackageManager.is_package_url(url):
@ -256,7 +322,7 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
def _anonymous_or_real(request): def _anonymous_or_real(request):
if request.user.is_authenticated and not request.user.is_anonymous: if request.user and (request.user.is_authenticated and not request.user.is_anonymous):
return request.user return request.user
return get_user_model().objects.get(username="anonymous") return get_user_model().objects.get(username="anonymous")
@ -374,26 +440,49 @@ def predict_pathway(request):
return render(request, "predict_pathway.html", context) return render(request, "predict_pathway.html", context)
def batch_predict_pathway(request):
"""Top-level predict pathway view using user's default package."""
if request.method != "GET":
return HttpResponseNotAllowed(["GET"])
context = get_base_context(request)
context["title"] = "enviPath - Batch Predict Pathway"
context["meta"]["current_package"] = context["meta"]["user"].default_package
return render(request, "batch_predict_pathway.html", context)
@package_permission_required()
def package_predict_pathway(request, package_uuid):
"""Package-specific predict pathway view."""
if request.method != "GET":
return HttpResponseNotAllowed(["GET"])
current_user = _anonymous_or_real(request)
current_package = PackageManager.get_package_by_id(current_user, package_uuid)
context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - Predict Pathway"
context["meta"]["current_package"] = current_package
return render(request, "predict_pathway.html", context)
def packages(request): def packages(request):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Packages" context["title"] = "enviPath - Packages"
context["object_type"] = "package"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["meta"]["can_edit"] = True
reviewed_package_qs = Package.objects.filter(reviewed=True).order_by("created") # Context for paginated template
unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user).order_by( context["entity_type"] = "package"
"name" context["api_endpoint"] = "/api/v1/packages/"
) context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "packages"
context["reviewed_objects"] = reviewed_package_qs return render(request, "collections/packages_paginated.html", context)
context["unreviewed_objects"] = unreviewed_package_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
if hidden := request.POST.get("hidden", None): if hidden := request.POST.get("hidden", None):
@ -439,29 +528,16 @@ def compounds(request):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Compounds" context["title"] = "enviPath - Compounds"
context["object_type"] = "compound"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
reviewed_compound_qs = Compound.objects.none() # Context for paginated template
context["entity_type"] = "compound"
context["api_endpoint"] = "/api/v1/compounds/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_mode"] = "tabbed"
context["list_title"] = "compounds"
for p in PackageManager.get_reviewed_packages(): return render(request, "collections/compounds_paginated.html", context)
reviewed_compound_qs |= Compound.objects.filter(package=p)
reviewed_compound_qs = reviewed_compound_qs.order_by("name")
if request.GET.get("all"):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": True}
for pw in reviewed_compound_qs
]
}
)
context["reviewed_objects"] = reviewed_compound_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
# delegate to default package # delegate to default package
@ -477,32 +553,19 @@ def rules(request):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Rules" context["title"] = "enviPath - Rules"
context["object_type"] = "rule"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Rule": s.SERVER_URL + "/rule"}, {"Rule": s.SERVER_URL + "/rule"},
] ]
reviewed_rule_qs = Rule.objects.none()
for p in PackageManager.get_reviewed_packages(): # Context for paginated template
reviewed_rule_qs |= Rule.objects.filter(package=p) context["entity_type"] = "rule"
context["api_endpoint"] = "/api/v1/rules/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "rules"
reviewed_rule_qs = reviewed_rule_qs.order_by("name") return render(request, "collections/rules_paginated.html", context)
if request.GET.get("all"):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": True}
for pw in reviewed_rule_qs
]
}
)
context["reviewed_objects"] = reviewed_rule_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
# delegate to default package # delegate to default package
@ -518,32 +581,19 @@ def reactions(request):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Reactions" context["title"] = "enviPath - Reactions"
context["object_type"] = "reaction"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Reaction": s.SERVER_URL + "/reaction"}, {"Reaction": s.SERVER_URL + "/reaction"},
] ]
reviewed_reaction_qs = Reaction.objects.none()
for p in PackageManager.get_reviewed_packages(): # Context for paginated template
reviewed_reaction_qs |= Reaction.objects.filter(package=p).order_by("name") context["entity_type"] = "reaction"
context["api_endpoint"] = "/api/v1/reactions/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "reactions"
reviewed_reaction_qs = reviewed_reaction_qs.order_by("name") return render(request, "collections/reactions_paginated.html", context)
if request.GET.get("all"):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": True}
for pw in reviewed_reaction_qs
]
}
)
context["reviewed_objects"] = reviewed_reaction_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
# delegate to default package # delegate to default package
@ -559,33 +609,19 @@ def pathways(request):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Pathways" context["title"] = "enviPath - Pathways"
context["object_type"] = "pathway"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Pathway": s.SERVER_URL + "/pathway"}, {"Pathway": s.SERVER_URL + "/pathway"},
] ]
reviewed_pathway_qs = Pathway.objects.none() # Context for paginated template
context["entity_type"] = "pathway"
context["api_endpoint"] = "/api/v1/pathways/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "pathways"
for p in PackageManager.get_reviewed_packages(): return render(request, "collections/pathways_paginated.html", context)
reviewed_pathway_qs |= Pathway.objects.filter(package=p).order_by("name")
reviewed_pathway_qs = reviewed_pathway_qs.order_by("name")
if request.GET.get("all"):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": True}
for pw in reviewed_pathway_qs
]
}
)
context["reviewed_objects"] = reviewed_pathway_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
# delegate to default package # delegate to default package
@ -609,25 +645,13 @@ def scenarios(request):
{"Scenario": s.SERVER_URL + "/scenario"}, {"Scenario": s.SERVER_URL + "/scenario"},
] ]
reviewed_scenario_qs = Scenario.objects.none() # Context for paginated template
context["entity_type"] = "scenario"
context["api_endpoint"] = "/api/v1/scenarios/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "scenarios"
for p in PackageManager.get_reviewed_packages(): return render(request, "collections/scenarios_paginated.html", context)
reviewed_scenario_qs |= Scenario.objects.filter(package=p).order_by("name")
reviewed_scenario_qs = reviewed_scenario_qs.order_by("name")
if request.GET.get("all"):
return JsonResponse(
{
"objects": [
{"name": s.name, "url": s.url, "reviewed": True}
for s in reviewed_scenario_qs
]
}
)
context["reviewed_objects"] = reviewed_scenario_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
# delegate to default package # delegate to default package
@ -642,42 +666,28 @@ def models(request):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = "enviPath - Models" context["title"] = "enviPath - Models"
context["object_type"] = "model"
context["meta"]["current_package"] = context["meta"]["user"].default_package context["meta"]["current_package"] = context["meta"]["user"].default_package
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Model": s.SERVER_URL + "/model"}, {"Model": s.SERVER_URL + "/model"},
] ]
# Keep model_types for potential modal/action use
context["model_types"] = { context["model_types"] = {
"ML Relative Reasoning": "ml-relative-reasoning", "ML Relative Reasoning": "ml-relative-reasoning",
"Rule Based Relative Reasoning": "rule-based-relative-reasoning", "Rule Based Relative Reasoning": "rule-based-relative-reasoning",
"EnviFormer": "enviformer", "EnviFormer": "enviformer",
} }
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v.display()] = k context["model_types"][v.display()] = k
reviewed_model_qs = EPModel.objects.none() # Context for paginated template
context["entity_type"] = "model"
context["api_endpoint"] = "/api/v1/models/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
for p in PackageManager.get_reviewed_packages(): return render(request, "collections/models_paginated.html", context)
reviewed_model_qs |= EPModel.objects.filter(package=p).order_by("name")
reviewed_model_qs = reviewed_model_qs.order_by("name")
if request.GET.get("all"):
return JsonResponse(
{
"objects": [
{"name": pw.name, "url": pw.url, "reviewed": True}
for pw in reviewed_model_qs
]
}
)
context["reviewed_objects"] = reviewed_model_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
@ -754,6 +764,10 @@ def package_models(request, package_uuid):
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "model" context["object_type"] = "model"
context["breadcrumbs"] = breadcrumbs(current_package, "model") context["breadcrumbs"] = breadcrumbs(current_package, "model")
context["entity_type"] = "model"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/model/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "models"
reviewed_model_qs = EPModel.objects.none() reviewed_model_qs = EPModel.objects.none()
unreviewed_model_qs = EPModel.objects.none() unreviewed_model_qs = EPModel.objects.none()
@ -775,9 +789,6 @@ def package_models(request, package_uuid):
} }
) )
context["reviewed_objects"] = reviewed_model_qs
context["unreviewed_objects"] = unreviewed_model_qs
context["model_types"] = { context["model_types"] = {
"ML Relative Reasoning": "mlrr", "ML Relative Reasoning": "mlrr",
"Rule Based Relative Reasoning": "rbrr", "Rule Based Relative Reasoning": "rbrr",
@ -790,7 +801,7 @@ def package_models(request, package_uuid):
for k, v in s.CLASSIFIER_PLUGINS.items(): for k, v in s.CLASSIFIER_PLUGINS.items():
context["model_types"][v.display()] = k context["model_types"][v.display()] = k
return render(request, "collections/objects_list.html", context) return render(request, "collections/models_paginated.html", context)
elif request.method == "POST": elif request.method == "POST":
log_post_params(request) log_post_params(request)
@ -856,7 +867,7 @@ def package_models(request, package_uuid):
request, "Invalid model type.", f'Model type "{model_type}" is not supported."' request, "Invalid model type.", f'Model type "{model_type}" is not supported."'
) )
from .tasks import dispatch, build_model from .tasks import build_model, dispatch
dispatch(current_user, build_model, mod.pk) dispatch(current_user, build_model, mod.pk)
@ -881,19 +892,20 @@ def package_model(request, package_uuid, model_uuid):
# Check if smiles is non empty and valid # Check if smiles is non empty and valid
if smiles == "": if smiles == "":
return JsonResponse({"error": "Received empty SMILES"}, status=400) return JsonResponse({"error": "Received empty SMILES"}, status=400)
stereo = FormatConverter.has_stereo(smiles)
try: try:
stand_smiles = FormatConverter.standardize(smiles) stand_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
except ValueError: except ValueError:
return JsonResponse({"error": f'"{smiles}" is not a valid SMILES'}, status=400) return JsonResponse({"error": f'"{smiles}" is not a valid SMILES'}, status=400)
if classify: if classify:
from epdb.tasks import dispatch_eager, predict_simple from epdb.tasks import dispatch_eager, predict_simple
res = dispatch_eager(current_user, predict_simple, current_model.pk, stand_smiles) _, pred_res = dispatch_eager(
current_user, predict_simple, current_model.pk, stand_smiles
)
pred_res = current_model.predict(stand_smiles) res = {"pred": [], "stereo": stereo}
res = []
for pr in pred_res: for pr in pred_res:
if len(pr) > 0: if len(pr) > 0:
@ -902,7 +914,7 @@ def package_model(request, package_uuid, model_uuid):
logger.debug(f"Checking {prod_set}") logger.debug(f"Checking {prod_set}")
products.append(tuple([x for x in prod_set])) products.append(tuple([x for x in prod_set]))
res.append( res["pred"].append(
{ {
"products": list(set(products)), "products": list(set(products)),
"probability": pr.probability, "probability": pr.probability,
@ -1052,9 +1064,7 @@ def package(request, package_uuid):
return redirect(s.SERVER_URL + "/package") return redirect(s.SERVER_URL + "/package")
elif hidden == "publish-package": elif hidden == "publish-package":
for g in Group.objects.filter(public=True): for g in Group.objects.filter(public=True):
PackageManager.update_permissions( PackageManager.grant_read(current_user, current_package, g)
current_user, current_package, g, Permission.READ[0]
)
return redirect(current_package.url) return redirect(current_package.url)
elif hidden == "copy": elif hidden == "copy":
object_to_copy = request.POST.get("object_to_copy") object_to_copy = request.POST.get("object_to_copy")
@ -1149,6 +1159,11 @@ def package_compounds(request, package_uuid):
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "compound" context["object_type"] = "compound"
context["breadcrumbs"] = breadcrumbs(current_package, "compound") context["breadcrumbs"] = breadcrumbs(current_package, "compound")
context["entity_type"] = "compound"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/compound/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_mode"] = "tabbed"
context["list_title"] = "compounds"
reviewed_compound_qs = Compound.objects.none() reviewed_compound_qs = Compound.objects.none()
unreviewed_compound_qs = Compound.objects.none() unreviewed_compound_qs = Compound.objects.none()
@ -1174,17 +1189,18 @@ def package_compounds(request, package_uuid):
} }
) )
context["reviewed_objects"] = reviewed_compound_qs return render(request, "collections/compounds_paginated.html", context)
context["unreviewed_objects"] = unreviewed_compound_qs
return render(request, "collections/objects_list.html", context)
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_description = request.POST.get("compound-description") compound_description = request.POST.get("compound-description")
try:
c = Compound.create(current_package, compound_smiles, compound_name, compound_description) c = Compound.create(
current_package, compound_smiles, compound_name, compound_description
)
except ValueError as e:
raise BadRequest(str(e))
return redirect(c.url) return redirect(c.url)
@ -1292,19 +1308,17 @@ def package_compound_structures(request, package_uuid, compound_uuid):
context["breadcrumbs"] = breadcrumbs( context["breadcrumbs"] = breadcrumbs(
current_package, "compound", current_compound, "structure" current_package, "compound", current_compound, "structure"
) )
context["entity_type"] = "structure"
context["page_title"] = f"{current_compound.name} - Structures"
context["api_endpoint"] = (
f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
)
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["compound"] = current_compound
context["list_mode"] = "combined"
context["list_title"] = "structures"
reviewed_compound_structure_qs = CompoundStructure.objects.none() return render(request, "collections/structures_paginated.html", context)
unreviewed_compound_structure_qs = CompoundStructure.objects.none()
if current_package.reviewed:
reviewed_compound_structure_qs = current_compound.structures.order_by("name")
else:
unreviewed_compound_structure_qs = current_compound.structures.order_by("name")
context["reviewed_objects"] = reviewed_compound_structure_qs
context["unreviewed_objects"] = unreviewed_compound_structure_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
structure_name = request.POST.get("structure-name") structure_name = request.POST.get("structure-name")
@ -1451,6 +1465,10 @@ def package_rules(request, package_uuid):
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "rule" context["object_type"] = "rule"
context["breadcrumbs"] = breadcrumbs(current_package, "rule") context["breadcrumbs"] = breadcrumbs(current_package, "rule")
context["entity_type"] = "rule"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/rule/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "rules"
reviewed_rule_qs = Rule.objects.none() reviewed_rule_qs = Rule.objects.none()
unreviewed_rule_qs = Rule.objects.none() unreviewed_rule_qs = Rule.objects.none()
@ -1472,10 +1490,7 @@ def package_rules(request, package_uuid):
} }
) )
context["reviewed_objects"] = reviewed_rule_qs return render(request, "collections/rules_paginated.html", context)
context["unreviewed_objects"] = unreviewed_rule_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
log_post_params(request) log_post_params(request)
@ -1653,11 +1668,15 @@ def package_reactions(request, package_uuid):
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
context["title"] = f"enviPath - {current_package.name} - {current_package.name} - Reactions" context["title"] = f"enviPath - {current_package.name} - Reactions"
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "reaction" context["object_type"] = "reaction"
context["breadcrumbs"] = breadcrumbs(current_package, "reaction") context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
context["entity_type"] = "reaction"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/reaction/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "reactions"
reviewed_reaction_qs = Reaction.objects.none() reviewed_reaction_qs = Reaction.objects.none()
unreviewed_reaction_qs = Reaction.objects.none() unreviewed_reaction_qs = Reaction.objects.none()
@ -1683,10 +1702,7 @@ def package_reactions(request, package_uuid):
} }
) )
context["reviewed_objects"] = reviewed_reaction_qs return render(request, "collections/reactions_paginated.html", context)
context["unreviewed_objects"] = unreviewed_reaction_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
reaction_name = request.POST.get("reaction-name") reaction_name = request.POST.get("reaction-name")
@ -1805,6 +1821,10 @@ def package_pathways(request, package_uuid):
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "pathway" context["object_type"] = "pathway"
context["breadcrumbs"] = breadcrumbs(current_package, "pathway") context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
context["entity_type"] = "pathway"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/pathway/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "pathways"
reviewed_pathway_qs = Pathway.objects.none() reviewed_pathway_qs = Pathway.objects.none()
unreviewed_pathway_qs = Pathway.objects.none() unreviewed_pathway_qs = Pathway.objects.none()
@ -1828,10 +1848,7 @@ def package_pathways(request, package_uuid):
} }
) )
context["reviewed_objects"] = reviewed_pathway_qs return render(request, "collections/pathways_paginated.html", context)
context["unreviewed_objects"] = unreviewed_pathway_qs
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
log_post_params(request) log_post_params(request)
@ -1848,7 +1865,6 @@ def package_pathways(request, package_uuid):
"Pathway prediction failed!", "Pathway prediction failed!",
"Pathway prediction failed due to missing or empty SMILES", "Pathway prediction failed due to missing or empty SMILES",
) )
try: try:
stand_smiles = FormatConverter.standardize(smiles) stand_smiles = FormatConverter.standardize(smiles)
except ValueError: except ValueError:
@ -1871,8 +1887,13 @@ def package_pathways(request, package_uuid):
prediction_setting = SettingManager.get_setting_by_url(current_user, prediction_setting) prediction_setting = SettingManager.get_setting_by_url(current_user, prediction_setting)
else: else:
prediction_setting = current_user.prediction_settings() prediction_setting = current_user.prediction_settings()
pw = Pathway.create(
pw = Pathway.create(current_package, stand_smiles, name=name, description=description) current_package,
stand_smiles,
name=name,
description=description,
predicted=pw_mode in {"predict", "incremental"},
)
# set mode # set mode
pw.kv.update({"mode": pw_mode}) pw.kv.update({"mode": pw_mode})
@ -1880,7 +1901,7 @@ def package_pathways(request, package_uuid):
if pw_mode == "predict" or pw_mode == "incremental": if pw_mode == "predict" or pw_mode == "incremental":
# unlimited pred (will be handled by setting) # unlimited pred (will be handled by setting)
limit = -1 limit = None
# For incremental predict first level and return # For incremental predict first level and return
if pw_mode == "incremental": if pw_mode == "incremental":
@ -1916,6 +1937,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
{ {
"status": current_pathway.status(), "status": current_pathway.status(),
"modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"), "modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"),
"emptyDueToThreshold": current_pathway.empty_due_to_threshold(),
} }
) )
@ -1936,7 +1958,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
rule_package = PackageManager.get_package_by_url( rule_package = PackageManager.get_package_by_url(
current_user, request.GET.get("rule-package") current_user, request.GET.get("rule-package")
) )
res = dispatch_eager( _, res = dispatch_eager(
current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk
) )
@ -2364,6 +2386,10 @@ def package_scenarios(request, package_uuid):
context["meta"]["current_package"] = current_package context["meta"]["current_package"] = current_package
context["object_type"] = "scenario" context["object_type"] = "scenario"
context["breadcrumbs"] = breadcrumbs(current_package, "scenario") context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
context["entity_type"] = "scenario"
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/scenario/"
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
context["list_title"] = "scenarios"
reviewed_scenario_qs = Scenario.objects.none() reviewed_scenario_qs = Scenario.objects.none()
unreviewed_scenario_qs = Scenario.objects.none() unreviewed_scenario_qs = Scenario.objects.none()
@ -2389,13 +2415,10 @@ def package_scenarios(request, package_uuid):
} }
) )
context["reviewed_objects"] = reviewed_scenario_qs
context["unreviewed_objects"] = unreviewed_scenario_qs
from envipy_additional_information import ( from envipy_additional_information import (
SEDIMENT_ADDITIONAL_INFORMATION,
SLUDGE_ADDITIONAL_INFORMATION, SLUDGE_ADDITIONAL_INFORMATION,
SOIL_ADDITIONAL_INFORMATION, SOIL_ADDITIONAL_INFORMATION,
SEDIMENT_ADDITIONAL_INFORMATION,
) )
context["scenario_types"] = { context["scenario_types"] = {
@ -2426,7 +2449,7 @@ def package_scenarios(request, package_uuid):
context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION
context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION
return render(request, "collections/objects_list.html", context) return render(request, "collections/scenarios_paginated.html", context)
elif request.method == "POST": elif request.method == "POST":
log_post_params(request) log_post_params(request)
@ -2641,6 +2664,14 @@ def user(request, user_uuid):
return redirect(current_user.url) return redirect(current_user.url)
if "change_default" in request.POST:
new_default_uuid = request.POST["change_default"]
current_user.default_setting = SettingManager.get_setting_by_id(
current_user, new_default_uuid
)
current_user.save()
return redirect(current_user.url)
return HttpResponseBadRequest() return HttpResponseBadRequest()
else: else:
@ -2741,14 +2772,18 @@ def settings(request):
context = get_base_context(request) context = get_base_context(request)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request)
context["title"] = "enviPath - Settings"
context["object_type"] = "setting" context["object_type"] = "setting"
# Even if settings are aready in "meta", for consistency add it on root level
context["settings"] = SettingManager.get_all_settings(current_user)
context["breadcrumbs"] = [ context["breadcrumbs"] = [
{"Home": s.SERVER_URL}, {"Home": s.SERVER_URL},
{"Group": s.SERVER_URL + "/setting"}, {"Group": s.SERVER_URL + "/setting"},
] ]
return
context["objects"] = SettingManager.get_all_settings(current_user)
return render(request, "collections/objects_list.html", context)
elif request.method == "POST": elif request.method == "POST":
if s.DEBUG: if s.DEBUG:
for k, v in request.POST.items(): for k, v in request.POST.items():
@ -2786,15 +2821,25 @@ def settings(request):
) )
if not PackageManager.readable(current_user, params["model"].package): if not PackageManager.readable(current_user, params["model"].package):
raise ValueError("") raise PermissionDenied("You're not allowed to access this model!")
expansion_scheme = request.POST.get(
"model-based-prediction-setting-expansion-scheme", "BFS"
)
if expansion_scheme not in ExpansionSchemeChoice.values:
raise BadRequest(f"Unknown expansion scheme: {expansion_scheme}")
params["expansion_scheme"] = ExpansionSchemeChoice(expansion_scheme)
elif tp_gen_method == "rule-based-prediction-setting": elif tp_gen_method == "rule-based-prediction-setting":
rule_packages = request.POST.getlist("rule-based-prediction-setting-packages") rule_packages = request.POST.getlist("rule-based-prediction-setting-packages")
params["rule_packages"] = [ params["rule_packages"] = [
PackageManager.get_package_by_url(current_user, p) for p in rule_packages PackageManager.get_package_by_url(current_user, p) for p in rule_packages
] ]
else: else:
raise ValueError("") raise BadRequest("Neither Model-Based nor Rule-Based as Method selected!")
created_setting = SettingManager.create_setting( created_setting = SettingManager.create_setting(
current_user, current_user,
@ -2836,6 +2881,143 @@ def jobs(request):
return render(request, "collections/joblog.html", context) return render(request, "collections/joblog.html", context)
elif request.method == "POST":
job_name = request.POST.get("job-name")
if job_name == "engineer-pathway":
pathway_to_engineer = request.POST.get("pathway-to-engineer")
engineer_setting = request.POST.get("engineer-setting")
if not all([pathway_to_engineer, engineer_setting]):
raise BadRequest(
f"Unable to run {job_name} as it requires 'pathway-to-engineer' and 'engineer-setting' parameters."
)
pathway_package = PackageManager.get_package_by_url(current_user, pathway_to_engineer)
pathway_to_engineer = Pathway.objects.get(
url=pathway_to_engineer, package=pathway_package
)
engineer_setting = SettingManager.get_setting_by_url(current_user, engineer_setting)
target_package = PackageManager.create_package(
current_user,
f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.name}",
f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.name}.",
)
from .tasks import dispatch, engineer_pathways
res = dispatch(
current_user,
engineer_pathways,
[pathway_to_engineer.pk],
engineer_setting.pk,
target_package.pk,
)
return redirect(f"{s.SERVER_URL}/jobs/{res.task_id}")
elif job_name == "batch-predict":
substrates = request.POST.get("substrates")
prediction_setting_url = request.POST.get("prediction-setting")
num_tps = request.POST.get("num-tps")
if substrates is None or substrates.strip() == "":
raise BadRequest("No substrates provided.")
pred_data = []
for pair in substrates.split("\n"):
parts = pair.split(",")
try:
smiles = FormatConverter.standardize(parts[0])
except ValueError:
raise BadRequest(f"Couldn't standardize SMILES {parts[0]}!")
# name is optional
name = parts[1] if len(parts) > 1 else None
pred_data.append([smiles, name])
max_tps = 50
if num_tps is not None and num_tps.strip() != "":
try:
num_tps = int(num_tps)
max_tps = max(min(num_tps, 50), 1)
except ValueError:
raise BadRequest(f"Parameter for num-tps {num_tps} is not a valid integer.")
batch_predict_setting = SettingManager.get_setting_by_url(
current_user, prediction_setting_url
)
target_package = PackageManager.create_package(
current_user,
f"Autogenerated Package for Batch Prediction {datetime.now()}",
"This Package was generated automatically for the batch prediction task.",
)
from .tasks import dispatch, batch_predict
res = dispatch(
current_user,
batch_predict,
pred_data,
batch_predict_setting.pk,
target_package.pk,
num_tps=max_tps,
)
return redirect(f"{s.SERVER_URL}/jobs/{res.task_id}")
else:
raise BadRequest(f"Job {job_name} is not supported!")
else:
return HttpResponseNotAllowed(["GET", "POST"])
def job(request, job_uuid):
current_user = _anonymous_or_real(request)
context = get_base_context(request)
if request.method == "GET":
if current_user.is_superuser:
job = JobLog.objects.get(task_id=job_uuid)
else:
job = JobLog.objects.get(task_id=job_uuid, user=current_user)
# No op if status is already in a terminal state
job.check_for_update()
if request.GET.get("download", False) == "true":
if not job.is_result_downloadable():
raise BadRequest("Result is not downloadable!")
if job.job_name == "batch_predict":
filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv"
else:
raise BadRequest("Result is not downloadable!")
res_str = job.task_result
response = HttpResponse(res_str, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
context["object_type"] = "joblog"
context["breadcrumbs"] = [
{"Home": s.SERVER_URL},
{"Jobs": s.SERVER_URL + "/jobs"},
{job.job_name: f"{s.SERVER_URL}/jobs/{job.task_id}"},
]
context["job"] = job
return render(request, "objects/joblog.html", context)
else:
return HttpResponseNotAllowed(["GET"])
########### ###########
# KETCHER # # KETCHER #

View File

@ -1,24 +1,21 @@
import gzip
import json import json
import logging import logging
import os.path import os.path
from datetime import datetime
from django.conf import settings as s from django.conf import settings as s
from django.http import HttpResponseNotAllowed from django.http import HttpResponseNotAllowed
from django.shortcuts import render from django.shortcuts import render
from epdb.logic import PackageManager
from epdb.models import Rule, SimpleAmbitRule, Package, CompoundStructure
from epdb.views import get_base_context, _anonymous_or_real
from utilities.chem import FormatConverter
from rdkit import Chem from rdkit import Chem
from rdkit.Chem.MolStandardize import rdMolStandardize from rdkit.Chem.MolStandardize import rdMolStandardize
from epdb.models import CompoundStructure, Rule, SimpleAmbitRule
from epdb.views import get_base_context
from utilities.chem import FormatConverter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Package = s.GET_PACKAGE_MODEL()
def normalize_smiles(smiles): def normalize_smiles(smiles):
m1 = Chem.MolFromSmiles(smiles) m1 = Chem.MolFromSmiles(smiles)
@ -59,9 +56,7 @@ def run_both_engines(SMILES, SMIRKS):
set( set(
[ [
normalize_smiles(str(x)) normalize_smiles(str(x))
for x in FormatConverter.sanitize_smiles( for x in FormatConverter.sanitize_smiles([str(s) for s in all_rdkit_prods])[0]
[str(s) for s in all_rdkit_prods]
)[0]
] ]
) )
) )
@ -85,8 +80,7 @@ def migration(request):
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1" url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
) )
ALL_SMILES = [ ALL_SMILES = [
cs.smiles cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD)
for cs in CompoundStructure.objects.filter(compound__package=BBD)
] ]
RULES = SimpleAmbitRule.objects.filter(package=BBD) RULES = SimpleAmbitRule.objects.filter(package=BBD)
@ -142,9 +136,7 @@ def migration(request):
) )
for r in migration_status["results"]: for r in migration_status["results"]:
r["detail_url"] = r["detail_url"].replace( r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
"http://localhost:8000", s.SERVER_URL
)
context.update(**migration_status) context.update(**migration_status)
@ -152,8 +144,6 @@ def migration(request):
def migration_detail(request, package_uuid, rule_uuid): def migration_detail(request, package_uuid, rule_uuid):
current_user = _anonymous_or_real(request)
if request.method == "GET": if request.method == "GET":
context = get_base_context(request) context = get_base_context(request)
@ -235,9 +225,7 @@ def compare(request):
context["smirks"] = ( context["smirks"] = (
"[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O" "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O"
) )
context["smiles"] = ( context["smiles"] = "C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
"C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
)
return render(request, "compare.html", context) return render(request, "compare.html", context)
elif request.method == "POST": elif request.method == "POST":

View File

@ -9,7 +9,8 @@ dependencies = [
"django>=5.2.1", "django>=5.2.1",
"django-extensions>=4.1", "django-extensions>=4.1",
"django-model-utils>=5.0.0", "django-model-utils>=5.0.0",
"django-ninja>=1.4.1", "django-ninja>=1.4.5",
"django-ninja-extra>=0.30.6",
"django-oauth-toolkit>=3.0.1", "django-oauth-toolkit>=3.0.1",
"django-polymorphic>=4.1.0", "django-polymorphic>=4.1.0",
"enviformer", "enviformer",
@ -45,6 +46,9 @@ dev = [
"poethepoet>=0.37.0", "poethepoet>=0.37.0",
"pre-commit>=4.3.0", "pre-commit>=4.3.0",
"ruff>=0.13.3", "ruff>=0.13.3",
"pytest-playwright>=0.7.1",
"pytest-django>=4.11.1",
"pytest-cov>=7.0.0",
] ]
[tool.ruff] [tool.ruff]
@ -66,47 +70,31 @@ docstring-code-format = true
[tool.poe.tasks] [tool.poe.tasks]
# Main tasks # Main tasks
setup = { sequence = ["db-up", "migrate", "bootstrap"], help = "Complete setup: start database, run migrations, and bootstrap data" } setup = { sequence = [
dev = { shell = """ "db-up",
# Start pnpm CSS watcher in background "migrate",
pnpm run dev & "bootstrap",
PNPM_PID=$! ], help = "Complete setup: start database, run migrations, and bootstrap data" }
echo "Started CSS watcher (PID: $PNPM_PID)" dev = { cmd = "uv run python scripts/dev_server.py", help = "Start the development server with CSS watcher", deps = [
"db-up",
# Cleanup function "js-deps",
cleanup() { ] }
echo "\nShutting down..." build = { sequence = [
if kill -0 $PNPM_PID 2>/dev/null; then "build-frontend",
kill $PNPM_PID "collectstatic",
echo " CSS watcher stopped" ], help = "Build frontend assets and collect static files" }
fi
if [ ! -z "${DJ_PID:-}" ] && kill -0 $DJ_PID 2>/dev/null; then
kill $DJ_PID
echo " Django server stopped"
fi
}
# Set trap for cleanup
trap cleanup EXIT INT TERM
# Start Django dev server in background
uv run python manage.py runserver &
DJ_PID=$!
# Wait for Django to finish
wait $DJ_PID
""", help = "Start the development server with CSS watcher", deps = ["db-up", "js-deps"] }
build = { sequence = ["build-frontend", "collectstatic"], help = "Build frontend assets and collect static files" }
# Database tasks # Database tasks
db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" } db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" } db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
# Frontend tasks # Frontend tasks
js-deps = { cmd = "pnpm install", help = "Install frontend dependencies" } js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" }
# Full cleanup tasks # Full cleanup tasks
clean = { sequence = ["clean-db"], help = "Remove model files and database volumes (WARNING: destroys all data!)" } clean = { sequence = [
"clean-db",
], help = "Remove model files and database volumes (WARNING: destroys all data!)" }
clean-db = { cmd = "docker compose -f docker-compose.dev.yml down -v", help = "Removes the database container and volume." } clean-db = { cmd = "docker compose -f docker-compose.dev.yml down -v", help = "Removes the database container and volume." }
# Django tasks # Django tasks
@ -124,6 +112,33 @@ echo " Password: SuperSafe"
""", help = "Bootstrap initial data (anonymous user, packages, models)" } """, help = "Bootstrap initial data (anonymous user, packages, models)" }
shell = { cmd = "uv run python manage.py shell", help = "Open Django shell" } shell = { cmd = "uv run python manage.py shell", help = "Open Django shell" }
# Build tasks
build-frontend = { cmd = "pnpm run build", help = "Build frontend assets using pnpm", deps = ["js-deps"] } build-frontend = { cmd = "uv run python scripts/pnpm_wrapper.py run build", help = "Build frontend assets using pnpm", deps = [
collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = ["build-frontend"] } "js-deps",
] } # Build tasks
collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = [
"build-frontend",
] }
frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" }
[tool.pytest.ini_options]
addopts = "--verbose --capture=no --durations=10"
testpaths = ["tests", "*/tests"]
pythonpath = ["."]
norecursedirs = [
"env",
"venv",
"envipy-plugins",
"envipy-additional-information",
"envipy-ambit",
"enviformer",
]
markers = [
"api: API tests",
"frontend: Frontend tests",
"end2end: End-to-end tests",
"slow: Slow tests",
]

201
scripts/dev_server.py Executable file
View File

@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Cross-platform development server script.
Starts pnpm CSS watcher and Django dev server, handling cleanup on exit.
Works on both Windows and Unix systems.
"""
import atexit
import shutil
import signal
import subprocess
import sys
import time
def find_pnpm():
"""
Find pnpm executable on the system.
Returns the path to pnpm or None if not found.
"""
# Try to find pnpm using shutil.which
# On Windows, this will find pnpm.cmd if it's in PATH
pnpm_path = shutil.which("pnpm")
if pnpm_path:
return pnpm_path
# On Windows, also try pnpm.cmd explicitly
if sys.platform == "win32":
pnpm_cmd = shutil.which("pnpm.cmd")
if pnpm_cmd:
return pnpm_cmd
return None
class DevServerManager:
"""Manages background processes for development server."""
def __init__(self):
self.processes = []
self._cleanup_registered = False
def start_process(self, command, description, shell=False):
"""Start a background process and return the process object."""
print(f"Starting {description}...")
try:
if shell:
# Use shell=True for commands that need shell interpretation
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
else:
# Split command into list for subprocess
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
self.processes.append((process, description))
print(f"✓ Started {description} (PID: {process.pid})")
return process
except Exception as e:
print(f"✗ Failed to start {description}: {e}", file=sys.stderr)
self.cleanup()
sys.exit(1)
def cleanup(self):
"""Terminate all running processes."""
if not self.processes:
return
print("\nShutting down...")
for process, description in self.processes:
if process.poll() is None: # Process is still running
try:
# Try graceful termination first
if sys.platform == "win32":
process.terminate()
else:
process.send_signal(signal.SIGTERM)
# Wait up to 5 seconds for graceful shutdown
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
# Force kill if graceful shutdown failed
if sys.platform == "win32":
process.kill()
else:
process.send_signal(signal.SIGKILL)
process.wait()
print(f"{description} stopped")
except Exception as e:
print(f"✗ Error stopping {description}: {e}", file=sys.stderr)
self.processes.clear()
def register_cleanup(self):
"""Register cleanup handlers for various exit scenarios."""
if self._cleanup_registered:
return
self._cleanup_registered = True
# Register atexit handler (works on all platforms)
atexit.register(self.cleanup)
# Register signal handlers (Unix only)
if sys.platform != "win32":
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
def _signal_handler(self, signum, frame):
"""Handle Unix signals."""
self.cleanup()
sys.exit(0)
def wait_for_process(self, process, description):
"""Wait for a process to finish and handle its output."""
try:
# Stream output from the process
for line in iter(process.stdout.readline, ""):
if line:
print(f"[{description}] {line.rstrip()}")
process.wait()
return process.returncode
except KeyboardInterrupt:
# Handle Ctrl+C
self.cleanup()
sys.exit(0)
except Exception as e:
print(f"Error waiting for {description}: {e}", file=sys.stderr)
self.cleanup()
sys.exit(1)
def main():
"""Main entry point."""
manager = DevServerManager()
manager.register_cleanup()
# Find pnpm executable
pnpm_path = find_pnpm()
if not pnpm_path:
print("Error: pnpm not found in PATH.", file=sys.stderr)
print("\nPlease install pnpm:", file=sys.stderr)
print(" Windows: https://pnpm.io/installation#on-windows", file=sys.stderr)
print(" Unix: https://pnpm.io/installation#on-posix-systems", file=sys.stderr)
sys.exit(1)
# Determine shell usage based on platform
use_shell = sys.platform == "win32"
# Start pnpm CSS watcher
# Use the found pnpm path to ensure it works on Windows
pnpm_command = f'"{pnpm_path}" run dev' if use_shell else [pnpm_path, "run", "dev"]
manager.start_process(
pnpm_command,
"CSS watcher",
shell=use_shell,
)
# Give pnpm a moment to start
time.sleep(1)
# Start Django dev server
django_process = manager.start_process(
["uv", "run", "python", "manage.py", "runserver"],
"Django server",
shell=False,
)
print("\nDevelopment servers are running. Press Ctrl+C to stop.\n")
try:
# Wait for Django server (main process)
# If Django exits, we should clean up everything
return_code = manager.wait_for_process(django_process, "Django")
# If Django exited unexpectedly, clean up and exit
if return_code != 0:
manager.cleanup()
sys.exit(return_code)
except KeyboardInterrupt:
# Ctrl+C was pressed
manager.cleanup()
sys.exit(0)
if __name__ == "__main__":
main()

59
scripts/pnpm_wrapper.py Executable file
View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
Cross-platform pnpm command wrapper.
Finds pnpm correctly on Windows (handles pnpm.cmd) and Unix systems.
"""
import shutil
import subprocess
import sys
def find_pnpm():
"""
Find pnpm executable on the system.
Returns the path to pnpm or None if not found.
"""
# Try to find pnpm using shutil.which
# On Windows, this will find pnpm.cmd if it's in PATH
pnpm_path = shutil.which("pnpm")
if pnpm_path:
return pnpm_path
# On Windows, also try pnpm.cmd explicitly
if sys.platform == "win32":
pnpm_cmd = shutil.which("pnpm.cmd")
if pnpm_cmd:
return pnpm_cmd
return None
def main():
"""Main entry point - execute pnpm with provided arguments."""
pnpm_path = find_pnpm()
if not pnpm_path:
print("Error: pnpm not found in PATH.", file=sys.stderr)
print("\nPlease install pnpm:", file=sys.stderr)
print(" Windows: https://pnpm.io/installation#on-windows", file=sys.stderr)
print(" Unix: https://pnpm.io/installation#on-posix-systems", file=sys.stderr)
sys.exit(1)
# Get all arguments passed to this script
args = sys.argv[1:]
# Execute pnpm with the provided arguments
try:
sys.exit(subprocess.call([pnpm_path] + args))
except KeyboardInterrupt:
# Handle Ctrl+C gracefully
sys.exit(130)
except Exception as e:
print(f"Error executing pnpm: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 814 B

265
static/js/alpine/index.js Normal file
View File

@ -0,0 +1,265 @@
/**
* Alpine.js Components for enviPath
*
* This module provides reusable Alpine.js data components for modals,
* form validation, and form submission.
*/
document.addEventListener('alpine:init', () => {
/**
* Modal Form Component
*
* Provides form validation using HTML5 Constraint Validation API,
* loading states for submission, and error message management.
*
* Basic Usage:
* <dialog x-data="modalForm()" @close="reset()">
* <form id="my-form">
* <input name="field" required>
* </form>
* <button @click="submit('my-form')" :disabled="isSubmitting">Submit</button>
* </dialog>
*
* With Custom State:
* <dialog x-data="modalForm({ state: { selectedItem: '', imageUrl: '' } })" @close="reset()">
* <select x-model="selectedItem" @change="updateImagePreview(selectedItem + '?image=svg')">
* <img :src="imageUrl" x-show="imageUrl">
* </dialog>
*
* With AJAX:
* <button @click="submitAsync('my-form', { onSuccess: (data) => console.log(data) })">
*/
Alpine.data('modalForm', (options = {}) => ({
isSubmitting: false,
errors: {},
// Spread custom initial state from options
...(options.state || {}),
/**
* Validate a single field using HTML5 Constraint Validation API
* @param {HTMLElement} field - The input/select/textarea element
*/
validateField(field) {
const name = field.name || field.id;
if (!name) return;
if (!field.validity.valid) {
this.errors[name] = field.validationMessage;
} else {
delete this.errors[name];
}
},
/**
* Clear error for a field (call on input)
* @param {HTMLElement} field - The input element
*/
clearError(field) {
const name = field.name || field.id;
if (name && this.errors[name]) {
delete this.errors[name];
}
},
/**
* Get error message for a field
* @param {string} name - Field name
* @returns {string|undefined} Error message or undefined
*/
getError(name) {
return this.errors[name];
},
/**
* Check if form has any errors
* @returns {boolean} True if there are errors
*/
hasErrors() {
return Object.keys(this.errors).length > 0;
},
/**
* Validate all fields in a form
* @param {string} formId - The form element ID
* @returns {boolean} True if form is valid
*/
validateAll(formId) {
const form = document.getElementById(formId);
if (!form) return false;
this.errors = {};
const fields = form.querySelectorAll('input, select, textarea');
fields.forEach(field => {
if (field.name && !field.validity.valid) {
this.errors[field.name] = field.validationMessage;
}
});
return !this.hasErrors();
},
/**
* Validate that two password fields match
* @param {string} password1Id - ID of first password field
* @param {string} password2Id - ID of second password field
* @returns {boolean} True if passwords match
*/
validatePasswordMatch(password1Id, password2Id) {
const pw1 = document.getElementById(password1Id);
const pw2 = document.getElementById(password2Id);
if (!pw1 || !pw2) return false;
if (pw1.value !== pw2.value) {
this.errors[pw2.name || password2Id] = 'Passwords do not match';
pw2.setCustomValidity('Passwords do not match');
return false;
}
delete this.errors[pw2.name || password2Id];
pw2.setCustomValidity('');
return true;
},
/**
* Submit a form with loading state
* @param {string} formId - The form element ID
*/
submit(formId) {
const form = document.getElementById(formId);
if (!form) return;
// Validate before submit
if (!form.checkValidity()) {
form.reportValidity();
return;
}
// Set action to current URL if empty
if (!form.action || form.action === window.location.href + '#') {
form.action = window.location.href;
}
// Set loading state and submit
this.isSubmitting = true;
form.submit();
},
/**
* Submit form via AJAX (fetch)
* @param {string} formId - The form element ID
* @param {Object} options - Options { onSuccess, onError, closeOnSuccess }
*/
async submitAsync(formId, options = {}) {
const form = document.getElementById(formId);
if (!form) return;
// Validate before submit
if (!form.checkValidity()) {
form.reportValidity();
return;
}
this.isSubmitting = true;
try {
const formData = new FormData(form);
const response = await fetch(form.action || window.location.href, {
method: form.method || 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
if (options.onSuccess) {
options.onSuccess(data);
}
if (data.redirect || data.success) {
window.location.href = data.redirect || data.success;
} else if (options.closeOnSuccess) {
this.$el.closest('dialog')?.close();
}
} else {
const errorMsg = data.error || data.message || `Error: ${response.status}`;
this.errors['_form'] = errorMsg;
if (options.onError) {
options.onError(errorMsg, data);
}
}
} catch (error) {
this.errors['_form'] = error.message;
if (options.onError) {
options.onError(error.message);
}
} finally {
this.isSubmitting = false;
}
},
/**
* Set form action URL dynamically
* @param {string} formId - The form element ID
* @param {string} url - The URL to set as action
*/
setFormAction(formId, url) {
const form = document.getElementById(formId);
if (form) {
form.action = url;
}
},
/**
* Update image preview
* @param {string} url - Image URL (with query params)
* @param {string} targetId - Target element ID for the image
*/
updateImagePreview(url) {
// Store URL for reactive binding with :src
this.imageUrl = url;
},
/**
* Reset form state (call on modal close)
* Resets to initial state from options
*/
reset() {
this.isSubmitting = false;
this.errors = {};
this.imageUrl = '';
// Reset custom state to initial values
if (options.state) {
Object.keys(options.state).forEach(key => {
this[key] = options.state[key];
});
}
// Call custom reset handler if provided
if (options.onReset) {
options.onReset.call(this);
}
}
}));
/**
* Simple Modal Component (no form)
*
* For modals that don't need form validation.
*
* Usage:
* <dialog x-data="modal()">
* <button @click="$el.closest('dialog').close()">Close</button>
* </dialog>
*/
Alpine.data('modal', () => ({
// Placeholder for simple modals that may need state later
}));
});

View File

@ -0,0 +1,148 @@
/**
* Alpine.js Pagination Component
*
* Provides client-side pagination for large lists.
*/
document.addEventListener('alpine:init', () => {
Alpine.data('remotePaginatedList', (options = {}) => ({
items: [],
currentPage: 1,
totalPages: 0,
totalItems: 0,
perPage: options.perPage || 50,
endpoint: options.endpoint || '',
isReviewed: options.isReviewed || false,
instanceId: options.instanceId || Math.random().toString(36).substring(2, 9),
isLoading: false,
error: null,
init() {
if (this.endpoint) {
this.fetchPage(1);
}
},
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);
},
async fetchPage(page) {
if (!this.endpoint) {
return;
}
this.isLoading = true;
this.error = null;
this.$dispatch('loading-start');
try {
const url = new URL(this.endpoint, window.location.origin);
// Preserve existing query parameters and add pagination params
url.searchParams.set('page', page.toString());
url.searchParams.set('page_size', this.perPage.toString());
const response = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`Failed to load ${this.endpoint} (status ${response.status})`);
}
const data = await response.json();
this.items = data.items || [];
this.totalItems = data.total_items || 0;
this.totalPages = data.total_pages || 0;
this.currentPage = data.page || page;
this.perPage = data.page_size || this.perPage;
// Dispatch event for parent components (e.g., tab count updates)
this.$dispatch('items-loaded', { totalItems: this.totalItems });
} catch (err) {
console.error(err);
this.error = `Unable to load ${this.endpoint}. Please try again.`;
} finally {
this.isLoading = false;
this.$dispatch('loading-end');
}
},
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;
}
}));
});

106
static/js/alpine/pathway.js Normal file
View File

@ -0,0 +1,106 @@
/**
* Pathway Viewer Alpine.js Component
*
* Provides reactive status management and polling for pathway predictions.
* Handles status updates, change detection, and update notices.
*/
document.addEventListener('alpine:init', () => {
/**
* Pathway Viewer Component
*
* Usage:
* <div x-data="pathwayViewer({
* status: 'running',
* modified: '2024-01-01T00:00:00Z',
* statusUrl: '/pathway/123?status=true'
* })" x-init="init()">
* ...
* </div>
*/
Alpine.data('pathwayViewer', (config) => ({
status: config.status,
modified: config.modified,
statusUrl: config.statusUrl,
emptyDueToThreshold: config.emptyDueToThreshold === "True",
showUpdateNotice: false,
showEmptyDueToThresholdNotice: false,
emptyDueToThresholdMessage: 'The Pathway is empty due to the selected threshold. Please try a different threshold.',
updateMessage: '',
pollInterval: null,
get statusTooltip() {
const tooltips = {
'completed': 'Pathway prediction completed.',
'failed': 'Pathway prediction failed.',
'running': 'Pathway prediction running.'
};
return tooltips[this.status] || '';
},
init() {
if (this.status === 'running') {
this.startPolling();
}
if (this.emptyDueToThreshold) {
this.showEmptyDueToThresholdNotice = true;
}
},
startPolling() {
if (this.pollInterval) {
return;
}
this.pollInterval = setInterval(() => this.checkStatus(), 5000);
},
async checkStatus() {
try {
const response = await fetch(this.statusUrl);
const data = await response.json();
if (data.emptyDueToThreshold) {
this.emptyDueToThreshold = true;
this.showEmptyDueToThresholdNotice = true;
}
if (data.modified > this.modified) {
if (!this.emptyDueToThreshold) {
this.showUpdateNotice = true;
this.updateMessage = this.getUpdateMessage(data.status);
}
}
if (data.status !== 'running') {
this.status = data.status;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
} catch (err) {
console.error('Polling error:', err);
}
},
getUpdateMessage(status) {
let msg = 'Prediction ';
if (status === 'running') {
msg += 'is still running. But the Pathway was updated.';
} else if (status === 'completed') {
msg += 'is completed. Reload the page to see the updated Pathway.';
} else if (status === 'failed') {
msg += 'failed. Reload the page to see the current shape.';
}
return msg;
},
reloadPage() {
location.reload();
}
}));
});

145
static/js/alpine/search.js Normal file
View File

@ -0,0 +1,145 @@
/**
* Search Modal Alpine.js Component
*
* Provides package selection, search mode switching, and results display
* for the search modal.
*/
document.addEventListener('alpine:init', () => {
/**
* Search Modal Component
*
* Usage:
* <dialog x-data="searchModal()" @close="reset()">
* ...
* </dialog>
*/
Alpine.data('searchModal', () => ({
// Package selector state
selectedPackages: [],
// Search state
searchMode: 'text',
searchModeLabel: 'Text',
query: '',
// Results state
results: null,
isSearching: false,
error: null,
// Initialize on modal open
init() {
// Load reviewed packages by default
this.loadInitialSelection();
// Watch for modal open to focus searchbar
this.$watch('$el.open', (open) => {
if (open) {
setTimeout(() => {
this.$refs.searchbar.focus();
}, 320);
}
});
},
loadInitialSelection() {
// Select all reviewed packages by default
const menuItems = this.$refs.packageDropdown.querySelectorAll('li');
for (const item of menuItems) {
// Stop at 'Unreviewed Packages' section
if (item.classList.contains('menu-title') &&
item.textContent.trim() === 'Unreviewed Packages') {
break;
}
const packageOption = item.querySelector('.package-option');
if (packageOption) {
this.selectedPackages.push({
url: packageOption.dataset.packageUrl,
name: packageOption.dataset.packageName
});
}
}
},
togglePackage(url, name) {
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
if (index !== -1) {
this.selectedPackages.splice(index, 1);
} else {
this.selectedPackages.push({ url, name });
}
},
removePackage(url) {
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
if (index !== -1) {
this.selectedPackages.splice(index, 1);
}
},
isPackageSelected(url) {
return this.selectedPackages.some(pkg => pkg.url === url);
},
setSearchMode(mode, label) {
this.searchMode = mode;
this.searchModeLabel = label;
this.$refs.modeDropdown.hidePopover();
},
async performSearch(serverBase) {
if (!this.query.trim()) {
return;
}
if (this.selectedPackages.length < 1) {
this.results = { error: 'no_packages' };
return;
}
const params = new URLSearchParams();
this.selectedPackages.forEach(pkg => params.append('packages', pkg.url));
params.append('search', this.query.trim());
params.append('mode', this.searchModeLabel.toLowerCase());
this.isSearching = true;
this.results = null;
this.error = null;
try {
const response = await fetch(`${serverBase}/search?${params.toString()}`, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
throw new Error('Search request failed');
}
this.results = await response.json();
} catch (err) {
console.error('Search error:', err);
this.error = 'Search failed. Please try again.';
} finally {
this.isSearching = false;
}
},
hasResults() {
if (!this.results || this.results.error) return false;
const categories = ['Compounds', 'Compound Structures', 'Rules', 'Reactions', 'Pathways'];
return categories.some(cat => this.results[cat] && this.results[cat].length > 0);
},
reset() {
this.query = '';
this.results = null;
this.error = null;
this.isSearching = false;
}
}));
});

View File

@ -63,17 +63,20 @@ class DiscourseAPI {
* @returns {string} Cleaned excerpt * @returns {string} Cleaned excerpt
*/ */
extractExcerpt(excerpt) { extractExcerpt(excerpt) {
if (!excerpt) return 'Click to read more'; if (!excerpt) return 'No preview available yet';
// Remove HTML tags and clean up; collapse whitespace; do not add manual ellipsis // Remove HTML tags and clean up; collapse whitespace; do not add manual ellipsis
return excerpt const cleaned = excerpt
.replace(/<[^>]*>/g, '') // Remove HTML tags .replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/&nbsp;/g, ' ') // Replace &nbsp; with spaces .replace(/&nbsp;/g, ' ') // Replace &nbsp; with spaces
.replace(/&amp;/g, '&') // Replace &amp; with & .replace(/&amp;/g, '&') // Replace &amp; with &
.replace(/&lt;/g, '<') // Replace &lt; with < .replace(/&lt;/g, '<') // Replace &lt; with <
.replace(/&gt;/g, '>') // Replace &gt; with > .replace(/&gt;/g, '>') // Replace &gt; with >
.replace(/\s+/g, ' ') // Collapse all whitespace/newlines .replace(/\s+/g, ' ') // Collapse all whitespace/newlines
.trim() .trim();
// Check if excerpt is empty after cleaning
return cleaned || 'No preview available yet';
} }
/** /**

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,21 @@
console.log("loaded pw.js") console.log("loaded pw.js")
function predictFromNode(url) { function predictFromNode(url) {
$.post("", {node: url}) fetch("", {
.done(function (data) { method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
},
body: new URLSearchParams({node: url})
})
.then(response => response.json())
.then(data => {
console.log("Success:", data); console.log("Success:", data);
window.location.href = data.success; window.location.href = data.success;
}) })
.fail(function (xhr, status, error) { .catch(error => {
console.error("Error:", xhr.status, xhr.responseText); console.error("Error:", error);
// show user-friendly message or log error
}); });
} }
@ -103,6 +110,9 @@ function draw(pathway, elem) {
} }
function dragstarted(event, d) { function dragstarted(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0.3).restart(); if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fx = d.x;
d.fy = d.y; d.fy = d.y;
@ -117,6 +127,9 @@ function draw(pathway, elem) {
} }
function dragged(event, d) { function dragged(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
d.fx = event.x; d.fx = event.x;
d.fy = event.y; d.fy = event.y;
@ -127,6 +140,9 @@ function draw(pathway, elem) {
} }
function dragended(event, d) { function dragended(event, d) {
// Prevent zoom pan when dragging nodes
event.sourceEvent.stopPropagation();
if (!event.active) simulation.alphaTarget(0); if (!event.active) simulation.alphaTarget(0);
// Mark that dragging has ended // Mark that dragging has ended
@ -192,58 +208,163 @@ function draw(pathway, elem) {
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted")); d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
} }
// Wait one second before showing popup // Wait before showing popup (ms)
var popupWaitBeforeShow = 1000; var popupWaitBeforeShow = 1000;
// Keep Popup at least for one second
var popushowAtLeast = 1000;
function pop_show_e(element) { // Custom popover element
var e = element; let popoverTimeout = null;
setTimeout(function () {
if ($(e).is(':hover')) { // if element is still hovered
$(e).popover("show");
// workaround to set fixed positions function createPopover() {
pop = $(e).attr("aria-describedby") const popover = document.createElement('div');
h = $('#' + pop).height(); popover.id = 'custom-popover';
$('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`) popover.className = 'fixed z-50';
setTimeout(function () { popover.style.cssText = `
var close = setInterval(function () { background: #ffffff;
if (!$(".popover:hover").length // mouse outside popover border: 1px solid #d1d5db;
&& !$(e).is(':hover')) { // mouse outside element box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
$(e).popover('hide'); max-width: 320px;
clearInterval(close); padding: 0.75rem;
border-radius: 0.5rem;
opacity: 0;
visibility: hidden;
transition: opacity 150ms ease-in-out, visibility 150ms ease-in-out;
pointer-events: auto;
`;
popover.setAttribute('role', 'tooltip');
popover.innerHTML = `
<div class="font-semibold mb-2 popover-title" style="font-weight: 600; margin-bottom: 0.5rem;"></div>
<div class="text-sm popover-content" style="font-size: 0.875rem;"></div>
`;
// Add styles for content images
const style = document.createElement('style');
style.textContent = `
#custom-popover img {
max-width: 100%;
height: auto;
display: block;
margin: 0.5rem 0;
} }
}, 100); #custom-popover a {
}, popushowAtLeast); color: #2563eb;
text-decoration: none;
} }
}, popupWaitBeforeShow); #custom-popover a:hover {
text-decoration: underline;
}
`;
if (!document.getElementById('popover-styles')) {
style.id = 'popover-styles';
document.head.appendChild(style);
}
// Keep popover open when hovering over it
popover.addEventListener('mouseenter', () => {
if (popoverTimeout) {
clearTimeout(popoverTimeout);
popoverTimeout = null;
}
});
popover.addEventListener('mouseleave', () => {
hidePopover();
});
document.body.appendChild(popover);
return popover;
}
function getPopover() {
return document.getElementById('custom-popover') || createPopover();
}
function showPopover(element, title, content) {
const popover = getPopover();
popover.querySelector('.popover-title').textContent = title;
popover.querySelector('.popover-content').innerHTML = content;
// Make visible to measure
popover.style.visibility = 'hidden';
popover.style.opacity = '0';
popover.style.display = 'block';
// Smart positioning - avoid viewport overflow
const padding = 10;
const popoverRect = popover.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = clientX + 15;
let top = clientY - (popoverRect.height / 2);
// Prevent right overflow
if (left + popoverRect.width > viewportWidth - padding) {
left = clientX - popoverRect.width - 15;
}
// Prevent bottom overflow
if (top + popoverRect.height > viewportHeight - padding) {
top = viewportHeight - popoverRect.height - padding;
}
// Prevent top overflow
if (top < padding) {
top = padding;
}
popover.style.top = `${top}px`;
popover.style.left = `${left}px`;
popover.style.visibility = 'visible';
popover.style.opacity = '1';
currentElement = element;
}
function hidePopover() {
const popover = getPopover();
popover.style.opacity = '0';
popover.style.visibility = 'hidden';
currentElement = null;
} }
function pop_add(objects, title, contentFunction) { function pop_add(objects, title, contentFunction) {
objects.attr("id", "pop") objects.each(function (d) {
.attr("data-container", "body") const element = this;
.attr("data-toggle", "popover")
.attr("data-placement", "right")
.attr("title", title);
objects.each(function (d, i) { element.addEventListener('mouseenter', () => {
options = {trigger: "manual", html: true, animation: false}; if (popoverTimeout) clearTimeout(popoverTimeout);
this_ = this;
var p = $(this).popover(options).on("mouseenter", function () { popoverTimeout = setTimeout(() => {
pop_show_e(this); if (element.matches(':hover')) {
const content = contentFunction(d);
showPopover(element, title, content);
}
}, popupWaitBeforeShow);
}); });
p.on("show.bs.popover", function (e) {
// this is to dynamically ajdust the content and bounds of the popup element.addEventListener('mouseleave', () => {
p.attr('data-content', contentFunction(d)); if (popoverTimeout) {
p.data("bs.popover").setContent(); clearTimeout(popoverTimeout);
p.data("bs.popover").tip().css({"max-width": "1000px"}); popoverTimeout = null;
}
// Delay hide to allow moving to popover
setTimeout(() => {
const popover = getPopover();
if (!popover.matches(':hover') && !element.matches(':hover')) {
hidePopover();
}
}, 100);
}); });
}); });
} }
function node_popup(n) { function node_popup(n) {
popupContent = "<a href='" + n.url + "'>" + n.name + "</a><br>"; popupContent = "";
if (n.stereo_removed) {
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
}
popupContent += "<a href='" + n.url + "'>" + n.name + "</a><br>";
popupContent += "Depth " + n.depth + "<br>" popupContent += "Depth " + n.depth + "<br>"
if (appDomainViewEnabled) { if (appDomainViewEnabled) {
@ -255,7 +376,7 @@ function draw(pathway, elem) {
} }
} }
popupContent += "<img src='" + n.image + "' width='" + 20 * nodeRadius + "'><br>" popupContent += "<img src='" + n.image + "'><br>"
if (n.scenarios.length > 0) { if (n.scenarios.length > 0) {
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) {
@ -265,7 +386,7 @@ function draw(pathway, elem) {
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0; var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
if (pathway.isIncremental && isLeaf) { if (pathway.isIncremental && isLeaf) {
popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>'; popupContent += '<br><a class="btn btn-primary btn-sm mt-2" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
} }
return popupContent; return popupContent;
@ -285,7 +406,7 @@ function draw(pathway, elem) {
popupContent += adcontent; popupContent += adcontent;
} }
popupContent += "<img src='" + e.image + "' width='" + 20 * nodeRadius + "'><br>" popupContent += "<img src='" + e.image + "'><br>"
if (e.reaction_probability) { if (e.reaction_probability) {
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>'; popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
} }
@ -308,6 +429,23 @@ function draw(pathway, elem) {
}); });
const zoomable = d3.select("#zoomable"); const zoomable = d3.select("#zoomable");
const svg = d3.select("#pwsvg");
const container = d3.select("#vizdiv");
// Set explicit SVG dimensions for proper zoom behavior
svg.attr("width", width)
.attr("height", height);
// Add background rectangle FIRST to enable pan/zoom on empty space
// This must be inserted before zoomable group so it's behind everything
svg.insert("rect", "#zoomable")
.attr("x", 0)
.attr("y", 0)
.attr("width", width)
.attr("height", height)
.attr("fill", "transparent")
.attr("pointer-events", "all")
.style("cursor", "grab");
// Zoom Funktion aktivieren // Zoom Funktion aktivieren
const zoom = d3.zoom() const zoom = d3.zoom()
@ -316,7 +454,12 @@ function draw(pathway, elem) {
zoomable.attr("transform", event.transform); zoomable.attr("transform", event.transform);
}); });
d3.select("svg").call(zoom); // Apply zoom to the SVG element - this enables wheel zoom
svg.call(zoom);
// 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
container.call(zoom);
nodes = pathway['nodes']; nodes = pathway['nodes'];
links = pathway['links']; links = pathway['links'];
@ -381,7 +524,7 @@ function draw(pathway, elem) {
node.append("circle") node.append("circle")
// make radius "invisible" for pseudo nodes // make radius "invisible" for pseudo nodes
.attr("r", d => d.pseudo ? 0.01 : nodeRadius) .attr("r", d => d.pseudo ? 0.01 : nodeRadius)
.style("fill", "#e8e8e8"); .style("fill", d => d.is_engineered_intermediate ? "#42eff5" : "#e8e8e8");
// Add image only for non pseudo nodes // Add image only for non pseudo nodes
node.filter(d => !d.pseudo).each(function (d, i) { node.filter(d => !d.pseudo).each(function (d, i) {

View File

@ -1,6 +0,0 @@
{% if meta.can_edit %}
<li>
<a role="button" data-toggle="modal" data-target="#new_compound_modal">
<span class="glyphicon glyphicon-plus"></span> New Compound</a>
</li>
{% endif %}

View File

@ -1,6 +0,0 @@
{% if meta.can_edit %}
<li>
<a role="button" data-toggle="modal" data-target="#new_compound_structure_modal">
<span class="glyphicon glyphicon-plus"></span> New Compound Structure</a>
</li>
{% endif %}

View File

@ -1,6 +1,10 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#new_edge_modal"> <a
<span class="glyphicon glyphicon-plus"></span> New Edge</a> role="button"
onclick="document.getElementById('new_edge_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Edge</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,4 +1,8 @@
<li> <li>
<a role="button" data-toggle="modal" data-target="#new_group_modal"> <a
<span class="glyphicon glyphicon-plus"></span> New Group</a> role="button"
onclick="document.getElementById('new_group_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Group</a
>
</li> </li>

View File

@ -1,6 +0,0 @@
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
<li>
<a role="button" data-toggle="modal" data-target="#new_model_modal">
<span class="glyphicon glyphicon-plus"></span> New Model</a>
</li>
{% endif %}

View File

@ -1,6 +1,10 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#new_node_modal"> <a
<span class="glyphicon glyphicon-plus"></span> New Node</a> role="button"
onclick="document.getElementById('new_node_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Node</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,12 +0,0 @@
<li>
<a role="button" data-toggle="modal" data-target="#new_package_modal">
<span class="glyphicon glyphicon-plus"></span> New Package</a>
</li>
<li>
<a role="button" data-toggle="modal" data-target="#import_package_modal">
<span class="glyphicon glyphicon-import"></span> Import Package from JSON</a>
</li>
<li>
<a role="button" data-toggle="modal" data-target="#import_legacy_package_modal">
<span class="glyphicon glyphicon-import"></span> Import Package from legacy JSON</a>
</li>

View File

@ -1,6 +0,0 @@
{% if meta.can_edit %}
<li>
<a href="{{ meta.server_url }}/predict">
<span class="glyphicon glyphicon-plus"></span> New Pathway</a>
</li>
{% endif %}

View File

@ -1,6 +0,0 @@
{% if meta.can_edit %}
<li>
<a role="button" data-toggle="modal" data-target="#new_reaction_modal">
<span class="glyphicon glyphicon-plus"></span> New Reaction</a>
</li>
{% endif %}

View File

@ -1,6 +0,0 @@
{% if meta.can_edit %}
<li>
<a role="button" data-toggle="modal" data-target="#new_rule_modal">
<span class="glyphicon glyphicon-plus"></span> New Rule</a>
</li>
{% endif %}

View File

@ -1,6 +0,0 @@
{% if meta.can_edit %}
<li>
<a role="button" data-toggle="modal" data-target="#new_scenario_modal">
<span class="glyphicon glyphicon-plus"></span> New Scenario</a>
</li>
{% endif %}

View File

@ -1,6 +1,10 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#new_setting_modal"> <a
<span class="glyphicon glyphicon-plus"></span>New Setting</a> role="button"
onclick="document.getElementById('new_setting_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span>New Setting</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,32 +1,60 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_compound_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Compound</a> role="button"
onclick="document.getElementById('edit_compound_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Compound</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#add_structure_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Add Structure</a> role="button"
onclick="document.getElementById('add_structure_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Add Structure</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_set_external_reference_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a> role="button"
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
>
</li> </li>
{% endif %} {% endif %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal"> <a
<i class="glyphicon glyphicon-duplicate"></i> Copy</a> role="button"
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
>
</li> </li>
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,22 +1,42 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_compound_structure_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Compound Structure</a> role="button"
onclick="document.getElementById('edit_compound_structure_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Compound Structure</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_set_external_reference_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a> role="button"
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Compound Structure</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Compound Structure</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,14 +1,26 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Edge</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Edge</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,10 +1,18 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_group_member_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Add/Remove Member</a> role="button"
onclick="document.getElementById('edit_group_member_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Add/Remove Member</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Group</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Group</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -0,0 +1,10 @@
{% if job.is_result_downloadable %}
<li>
<a
class="button"
onclick="document.getElementById('download_job_result_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-floppy-save"></i> Download Result</a
>
</li>
{% endif %}

View File

@ -1,18 +1,38 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_model_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Model</a> role="button"
onclick="document.getElementById('edit_model_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Model</a
>
</li> </li>
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#evaluate_model_modal"> <a
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a> role="button"
</li> onclick="document.getElementById('evaluate_model_modal').showModal(); return false;"
<li> >
<a role="button" data-toggle="modal" data-target="#retrain_model_modal"> <i class="glyphicon glyphicon-ok"></i> Evaluate Model</a
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a> >
</li> </li>
<li> {% endif %}
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> {% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
<i class="glyphicon glyphicon-trash"></i> Delete Model</a> <li>
<a
role="button"
onclick="document.getElementById('retrain_model_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a
>
</li>
{% endif %}
<li>
<a
class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Model</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,18 +1,34 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_node_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Node</a> role="button"
onclick="document.getElementById('edit_node_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Node</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Node</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Node</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,26 +1,50 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_package_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Package</a> role="button"
onclick="document.getElementById('edit_package_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Package</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_package_permissions_modal"> <a
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a> role="button"
onclick="document.getElementById('edit_package_permissions_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#publish_package_modal"> <a
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a> role="button"
onclick="document.getElementById('publish_package_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#export_package_modal"> <a
<i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a> role="button"
onclick="document.getElementById('export_package_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_license_modal"> <a
<i class="glyphicon glyphicon-duplicate"></i> License</a> role="button"
onclick="document.getElementById('set_license_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-duplicate"></i> License</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Package</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Package</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,59 +1,112 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#add_pathway_node_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Add Compound</a> class="button"
onclick="document.getElementById('add_pathway_node_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Add Compound</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#add_pathway_edge_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a> class="button"
onclick="document.getElementById('add_pathway_edge_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a
>
</li> </li>
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
{% endif %} {% endif %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal"> <a
<i class="glyphicon glyphicon-duplicate"></i> Copy</a> role="button"
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#download_pathway_csv_modal"> <a
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as CSV</a> class="button"
onclick="document.getElementById('download_pathway_csv_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as CSV</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#download_pathway_image_modal"> <a
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a> class="button"
onclick="document.getElementById('download_pathway_image_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
>
</li>
<li>
<a
role="button"
onclick="document.getElementById('engineer_pathway_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-cog"></i> Engineer Pathway</a
>
</li> </li>
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#identify_missing_rules_modal"> <a
<i class="glyphicon glyphicon-question-sign"></i> Identify Missing Rules</a> class="button"
onclick="document.getElementById('identify_missing_rules_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-question-sign"></i> Identify Missing
Rules</a
>
</li> </li>
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#edit_pathway_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a> class="button"
onclick="document.getElementById('edit_pathway_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
{# <li>#}
{# <a class="button" data-toggle="modal" data-target="#add_pathway_edge_modal">#}
{# <i class="glyphicon glyphicon-plus"></i> Calculate Compound Properties</a>#}
{# </li>#}
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#delete_pathway_node_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a> class="button"
onclick="document.getElementById('delete_pathway_node_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#delete_pathway_edge_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a> class="button"
onclick="document.getElementById('delete_pathway_edge_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Pathway</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Pathway</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,28 +1,52 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_reaction_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Reaction</a> role="button"
onclick="document.getElementById('edit_reaction_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Reaction</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_set_external_reference_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a> role="button"
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
>
</li> </li>
{% endif %} {% endif %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal"> <a
<i class="glyphicon glyphicon-duplicate"></i> Copy</a> role="button"
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
>
</li> </li>
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,24 +1,44 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_rule_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Edit Rule</a> role="button"
onclick="document.getElementById('edit_rule_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Edit Rule</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_aliases_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a> role="button"
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#set_scenario_modal"> <a
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a> role="button"
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
>
</li> </li>
{% endif %} {% endif %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal"> <a
<i class="glyphicon glyphicon-duplicate"></i> Copy</a> role="button"
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
>
</li> </li>
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Rule</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Rule</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,14 +1,26 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a class="button" data-toggle="modal" data-target="#add_additional_information_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a> class="button"
onclick="document.getElementById('add_additional_information_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#update_scenario_additional_information_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Set Additional Information</a> class="button"
onclick="document.getElementById('update_scenario_additional_information_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Set Additional Information</a
>
</li> </li>
<li> <li>
<a class="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Scenario</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Scenario</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,22 +1,38 @@
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_user_modal"> <a
<i class="glyphicon glyphicon-edit"></i> Update</a> role="button"
onclick="document.getElementById('edit_user_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-edit"></i> Update</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#edit_password_modal"> <a
<i class="glyphicon glyphicon-lock"></i> Update Password</a> role="button"
onclick="document.getElementById('edit_password_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-lock"></i> Update Password</a
>
</li> </li>
<li> <li>
<a role="button" data-toggle="modal" data-target="#new_prediction_setting_modal"> <a
<i class="glyphicon glyphicon-plus"></i> New Prediction Setting</a> role="button"
onclick="document.getElementById('new_prediction_setting_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-plus"></i> New Prediction Setting</a
>
</li> </li>
{# <li>#} {# <li>#}
{# <a role="button" data-toggle="modal" data-target="#manage_api_token_modal">#} {# <a role="button" data-toggle="modal" data-target="#manage_api_token_modal">#}
{# <i class="glyphicon glyphicon-console"></i> Manage API Token</a>#} {# <i class="glyphicon glyphicon-console"></i> Manage API Token</a>#}
{# </li>#} {# </li>#}
<li> <li>
<a role="button" data-toggle="modal" data-target="#generic_delete_modal"> <a
<i class="glyphicon glyphicon-trash"></i> Delete Account</a> class="button"
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-trash"></i> Delete Account</a
>
</li> </li>
{% endif %} {% endif %}

View File

@ -1,12 +1,17 @@
{% extends "framework.html" %} {% extends "framework.html" %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div id="searchContent">
<div id=searchContent>
<form id="admin-form" action="{{ SERVER_BASE }}/admin" method="post"> <form id="admin-form" action="{{ SERVER_BASE }}/admin" method="post">
<div class="form-group"> <div class="form-group">
<label for="textarea">Query</label> <label for="textarea">Query</label>
<textarea id="textarea" class="form-control" rows="10" placeholder="Paste query here" required> <textarea
id="textarea"
class="form-control"
rows="10"
placeholder="Paste query here"
required
>
PREFIX pps: <http://localhost:8080/vocabulary#> PREFIX pps: <http://localhost:8080/vocabulary#>
SELECT ?name (count(?objId) as ?xcnt) SELECT ?name (count(?objId) as ?xcnt)
WHERE { WHERE {
@ -15,32 +20,29 @@ WHERE {
?packageId pps:reviewStatus 'reviewed' . ?packageId pps:reviewStatus 'reviewed' .
?packageId pps:pathway ?objId . ?packageId pps:pathway ?objId .
} GROUP BY ?name } GROUP BY ?name
</textarea> </textarea
>
</div> </div>
<button id="submit" type="button" class="btn btn-primary">Submit</button> <button id="submit" type="button" class="btn btn-primary">Submit</button>
</form> </form>
<p></p> <p></p>
</div> </div>
<div id="results"> <div id="results"></div>
</div>
<div id="loading"></div> <div id="loading"></div>
</div>
<script> <script>
$(function () { $(function () {
$('#submit').on('click', function() { $("#submit").on("click", function () {
makeLoadingGif("#loading", "{% static '/images/wait.gif' %}"); makeLoadingGif("#loading", "{% static '/images/wait.gif' %}");
data = { data = {
"query": $("#textarea").val() query: $("#textarea").val(),
} };
$.post("{{ SERVER_BASE }}/expire", data, function (result) { $.post("{{ SERVER_BASE }}/expire", data, function (result) {
$("#loading").empty(); $("#loading").empty();
queryResultToTable("results", result); queryResultToTable("results", result);
})
}); });
}) });
});
</script> </script>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,168 @@
{% extends "framework_modern.html" %}
{% load static %}
{% block content %}
<div class="mx-auto w-full p-8">
<h1 class="h1 mb-4 text-3xl font-bold">Batch Predict Pathways</h1>
<form id="smiles-form" method="POST" action="{% url "jobs" %}">
{% csrf_token %}
<input type="hidden" name="substrates" id="substrates" />
<input type="hidden" name="job-name" value="batch-predict" />
<fieldset class="flex flex-col gap-4 md:flex-3/4">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>SMILES</th>
<th>Name</th>
</tr>
</thead>
<tbody id="smiles-table-body">
<tr>
<td>
<label>
<input
type="text"
class="input input-bordered w-full smiles-input"
placeholder="CN1C=NC2=C1C(=O)N(C(=O)N2C)C"
{% if meta.debug %}
value="CN1C=NC2=C1C(=O)N(C(=O)N2C)C"
{% endif %}
/>
</label>
</td>
<td>
<label>
<input
type="text"
class="input input-bordered w-full name-input"
placeholder="Caffeine"
{% if meta.debug %}
value="Caffeine"
{% endif %}
/>
</label>
</td>
</tr>
<tr>
<td>
<label>
<input
type="text"
class="input input-bordered w-full smiles-input"
placeholder="CC(C)CC1=CC=C(C=C1)C(C)C(=O)O"
{% if meta.debug %}
value="CC(C)CC1=CC=C(C=C1)C(C)C(=O)O"
{% endif %}
/>
</label>
</td>
<td>
<label>
<input
type="text"
class="input input-bordered w-full name-input"
placeholder="Ibuprofen"
{% if meta.debug %}
value="Ibuprofen"
{% endif %}
/>
</label>
</td>
</tr>
</tbody>
</table>
<label class="select mb-2 w-full">
<span class="label">Predictor</span>
<select id="prediction-setting" name="prediction-setting">
<option disabled>Select a Setting</option>
{% for s in meta.available_settings %}
<option
value="{{ s.url }}"
{% if s.id == meta.user.default_setting.id %}selected{% endif %}
>
{{ s.name }}{% if s.id == meta.user.default_setting.id %}
(User default)
{% endif %}
</option>
{% endfor %}
</select>
</label>
<label class="floating-label" for="num-tps">
<input
type="number"
name="num-tps"
value="50"
step="1"
min="1"
max="100"
id="num-tps"
class="input input-md w-full"
/>
<span>Max Transformation Products</span>
</label>
<div class="flex justify-end gap-2">
<button type="button" id="add-row-btn" class="btn btn-outline">
Add row
</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</fieldset>
</form>
</div>
<script>
const tableBody = document.getElementById("smiles-table-body");
const addRowBtn = document.getElementById("add-row-btn");
const form = document.getElementById("smiles-form");
const hiddenField = document.getElementById("substrates");
addRowBtn.addEventListener("click", () => {
const row = document.createElement("tr");
const tdSmiles = document.createElement("td");
const tdName = document.createElement("td");
const smilesInput = document.createElement("input");
smilesInput.type = "text";
smilesInput.className = "input input-bordered w-full smiles-input";
smilesInput.placeholder = "SMILES";
const nameInput = document.createElement("input");
nameInput.type = "text";
nameInput.className = "input input-bordered w-full name-input";
nameInput.placeholder = "Name";
tdSmiles.appendChild(smilesInput);
tdName.appendChild(nameInput);
row.appendChild(tdSmiles);
row.appendChild(tdName);
tableBody.appendChild(row);
});
// Before submit, gather table data into the hidden field
form.addEventListener("submit", (e) => {
const smilesInputs = Array.from(
document.querySelectorAll(".smiles-input"),
);
const nameInputs = Array.from(document.querySelectorAll(".name-input"));
const lines = [];
for (let i = 0; i < smilesInputs.length; i++) {
const smiles = smilesInputs[i].value.trim();
const name = nameInputs[i]?.value.trim() ?? "";
// Skip emtpy rows
if (!smiles && !name) {
continue;
}
lines.push(`${smiles},${name}`);
}
// Value looks like:
// "CN1C=NC2=C1C(=O)N(C(=O)N2C)C,Caffeine\nCC(C)CC1=CC=C(C=C1)C(C)C(=O)O,Ibuprofen"
hiddenField.value = lines.join("\n");
});
</script>
{% endblock content %}

View File

@ -0,0 +1,103 @@
{# 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) #}
{# 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">
<template x-for="obj in paginatedItems" :key="obj.url">
<li>
<a :href="obj.url" class="hover:bg-base-200">
<span x-text="obj.name"></span>
{% if show_review_badge %}
<span
class="tooltip tooltip-left ml-auto"
data-tip="Reviewed"
{% if not always_show_badge %}
x-show="obj.review_status === 'reviewed'"
{% endif %}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-check-icon lucide-check"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
{% endif %}
</a>
</li>
</template>
</ul>
{# Pagination controls #}
<div
x-show="totalPages > 1"
class="mt-4 flex items-center justify-between px-2"
>
<span class="text-base-content/70 text-sm">
Showing <span x-text="showingStart"></span>-<span
x-text="showingEnd"
></span>
of <span x-text="totalItems"></span>
</span>
<div class="join">
<button
class="join-item btn btn-sm"
:disabled="currentPage === 1"
@click="prevPage()"
>
«
</button>
<template x-for="item in pageNumbers" :key="item.key">
<button
class="join-item btn btn-sm"
:class="{ 'btn-active': item.page === currentPage }"
:disabled="item.isEllipsis"
@click="!item.isEllipsis && goToPage(item.page)"
x-text="item.page"
></button>
</template>
<button
class="join-item btn btn-sm"
:disabled="currentPage === totalPages"
@click="nextPage()"
>
»
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Compounds{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
>
New Compound
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_compound_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A compound stores the structure of a molecule and can include
meta-information.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -1,48 +1,59 @@
{% extends "framework.html" %} {% extends "framework_modern.html" %}
{% load static %} {% load static %}
{% load envipytags %}
{% block content %} {% block content %}
<div class="space-y-2 p-4">
<div class="panel-group" id="reviewListAccordion"> <!-- Header Section -->
<div class="panel panel-default"> <div class="card bg-base-100">
<div class="panel-heading" id="headingPanel" style="font-size:2rem;height: 46px"> <div class="card-body">
Jobs <h2 class="card-title text-2xl">User Prediction Jobs</h2>
<p class="mt-2">Job Logs Desc</p>
</div> </div>
<div class="panel-body">
<p>
Job Logs Desc
</p>
</div> </div>
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> <!-- Jobs -->
<h4 class="panel-title"> <div class="collapse-arrow bg-base-200 collapse">
<a id="job-accordion-link" data-toggle="collapse" data-parent="#job-accordion" href="#jobs"> <input type="checkbox" checked />
Jobs <div class="collapse-title text-xl font-medium">Recent Jobs</div>
</a> <div class="collapse-content" id="job-content">
</h4> <div class="overflow-x-auto">
</div> <table class="table-zebra table">
<div id="jobs" <thead>
class="panel-collapse collapse in"> <tr>
<div class="panel-body list-group-item" id="job-content"> {% if meta.user.is_superuser %}
<table class="table table-bordered table-hover"> <th>User</th>
<tr style="background-color: rgba(0, 0, 0, 0.08);"> {% endif %}
<th scope="col">ID</th> <th>ID</th>
<th scope="col">Name</th> <th>Name</th>
<th scope="col">Status</th> <th>Status</th>
<th scope="col">Queued</th> <th>Queued</th>
<th scope="col">Done</th> <th>Done</th>
<th scope="col">Result</th> <th>Result</th>
</tr> </tr>
</thead>
<tbody> <tbody>
{% for job in jobs %} {% for job in jobs %}
<tr> <tr>
<td>{{ job.task_id }}</td> {% if meta.user.is_superuser %}
<td>
<a href="{{ job.user.url }}">{{ job.user.username }}</a>
</td>
{% endif %}
<td>
<a href="{% url 'job detail' job.task_id %}"
>{{ job.task_id }}</a
>
</td>
<td>{{ job.job_name }}</td> <td>{{ job.job_name }}</td>
<td>{{ job.status }}</td> <td>{{ job.status }}</td>
<td>{{ job.created }}</td> <td>{{ job.created }}</td>
<td>{{ job.done_at }}</td> <td>{{ job.done_at }}</td>
{% if job.task_result and job.task_result|is_url == True %} {% if job.task_result and job.task_result|is_url == True %}
<td><a href="{{ job.task_result }}">Result</a></td> <td>
<a href="{{ job.task_result }}" class="link link-primary"
>Result</a
>
</td>
{% elif job.task_result %} {% elif job.task_result %}
<td>{{ job.task_result|slice:"40" }}...</td> <td>{{ job.task_result|slice:"40" }}...</td>
{% else %} {% else %}
@ -54,17 +65,31 @@
</table> </table>
</div> </div>
</div> </div>
</div>
{% if objects %}
<!-- Unreviewable objects such as User / Group / Setting --> <!-- Unreviewable objects such as User / Group / Setting -->
<ul class='list-group'> <div class="card bg-base-100 shadow-xl">
<div class="card-body">
<ul class="menu bg-base-200 rounded-box">
{% for obj in objects %} {% for obj in objects %}
{% if object_type == 'user' %} {% if object_type == 'user' %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.username }}</a> <li>
<a href="{{ obj.url }}" class="hover:bg-base-300"
>{{ obj.username }}</a
>
</li>
{% else %} {% else %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name }}</a> <li>
<a href="{{ obj.url }}" class="hover:bg-base-300"
>{{ obj.name }}</a
>
</li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
</div>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,32 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Models{% endblock %}
{% block action_button %}
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
New Model
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% if meta.enabled_features.MODEL_BUILDING %}
{% include "modals/collections/new_model_modal.html" %}
{% endif %}
{% endblock action_modals %}
{% block description %}
<p>A model applies machine learning to limit the combinatorial explosion.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/relative_reasoning"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -1,319 +1,323 @@
{% extends "framework.html" %} {% extends "framework_modern.html" %}
{% load static %} {% load static %}
{% block content %} {% block content %}
{% if object_type != 'package' %} {# Serialize objects data for Alpine pagination #}
<div> {# prettier-ignore-start #}
<div id="load-all-error" style="display: none;"> {# FIXME: This is a hack to get the objects data into the JavaScript code. #}
<div class="alert alert-danger" role="alert"> {% if object_type != 'scenario' %}
<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> <script>
<span class="sr-only">Error:</span> window.reviewedObjects = [
Getting objects failed! {% for obj in reviewed_objects %}
</div> { "name": "{{ obj.name|escapejs }}", "url": "{{ obj.url }}" }{% if not forloop.last %},{% endif %}
</div> {% endfor %}
];
<input type="text" id="object-search" class="form-control" placeholder="Search by name" window.unreviewedObjects = [
style="display: none;"> {% for obj in unreviewed_objects %}
<p></p> { "name": "{{ obj.name|escapejs }}", "url": "{{ obj.url }}" }{% if not forloop.last %},{% endif %}
</div> {% endfor %}
];
</script>
{% endif %} {% endif %}
{# prettier-ignore-end #}
<div class="px-8 py-4">
<input
type="text"
id="object-search"
class="input input-bordered hidden w-full max-w-xs"
placeholder="Search by name"
/>
</div>
{% block action_modals %} {% block action_modals %}
{% if object_type == 'package' %} {% if object_type == 'node' %}
{% include "modals/collections/new_package_modal.html" %}
{% include "modals/collections/import_package_modal.html" %}
{% include "modals/collections/import_legacy_package_modal.html" %}
{% elif object_type == 'compound' %}
{% include "modals/collections/new_compound_modal.html" %}
{% elif object_type == 'rule' %}
{% include "modals/collections/new_rule_modal.html" %}
{% elif object_type == 'reaction' %}
{% include "modals/collections/new_reaction_modal.html" %}
{% elif object_type == 'pathway' %}
{# {% include "modals/collections/new_pathway_modal.html" %} #}
{% elif object_type == 'node' %}
{% include "modals/collections/new_node_modal.html" %} {% include "modals/collections/new_node_modal.html" %}
{% elif object_type == 'edge' %} {% elif object_type == 'edge' %}
{% include "modals/collections/new_edge_modal.html" %} {% include "modals/collections/new_edge_modal.html" %}
{% elif object_type == 'scenario' %}
{% include "modals/collections/new_scenario_modal.html" %}
{% elif object_type == 'model' %}
{% include "modals/collections/new_model_modal.html" %}
{% elif object_type == 'setting' %}
{#{% include "modals/collections/new_setting_modal.html" %}#}
{% elif object_type == 'user' %}
<div></div>
{% elif object_type == 'group' %}
{% include "modals/collections/new_group_modal.html" %}
{% endif %} {% endif %}
{% endblock action_modals %} {% endblock action_modals %}
<div class="panel-group" id="reviewListAccordion"> <div class="px-8 py-4">
<div class="panel panel-default"> <!-- Header Section -->
<div class="panel-heading" id="headingPanel" style="font-size:2rem;height: 46px"> <div class="card bg-base-100">
{% if object_type == 'package' %} <div class="card-body px-0 py-4">
Packages <div class="flex items-center justify-between">
{% elif object_type == 'compound' %} <h2 class="card-title text-2xl">
Compounds {% if object_type == 'node' %}
{% elif object_type == 'structure' %}
Compound structures
{% elif object_type == 'rule' %}
Rules
{% elif object_type == 'reaction' %}
Reactions
{% elif object_type == 'pathway' %}
Pathways
{% elif object_type == 'node' %}
Nodes Nodes
{% elif object_type == 'edge' %} {% elif object_type == 'edge' %}
Edges Edges
{% elif object_type == 'scenario' %}
Scenarios
{% elif object_type == 'model' %}
Model
{% elif object_type == 'setting' %}
Settings
{% elif object_type == 'user' %}
Users
{% elif object_type == 'group' %}
Groups
{% endif %} {% endif %}
<div id="actionsButton" </h2>
style="float: right;font-weight: normal;font-size: medium;position: relative; top: 50%; transform: translateY(-50%);z-index:100;display: none;" <div id="actionsButton" class="dropdown dropdown-end hidden">
class="dropdown"><a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" <div tabindex="0" role="button" class="btn btn-ghost btn-sm">
aria-haspopup="true" aria-expanded="false"><span <svg
class="glyphicon glyphicon-wrench"></span> Actions <span class="caret"></span><span xmlns="http://www.w3.org/2000/svg"
style="padding-right:1em"></span></a> width="16"
<ul id="actionsList" class="dropdown-menu"> height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %} {% block actions %}
{% if object_type == 'package' %} {% if object_type == 'node' %}
{% include "actions/collections/package.html" %}
{% elif object_type == 'compound' %}
{% include "actions/collections/compound.html" %}
{% elif object_type == 'structure' %}
{% include "actions/collections/compound_structure.html" %}
{% elif object_type == 'rule' %}
{% include "actions/collections/rule.html" %}
{% elif object_type == 'reaction' %}
{% include "actions/collections/reaction.html" %}
{% elif object_type == 'setting' %}
{% include "actions/collections/setting.html" %}
{% elif object_type == 'scenario' %}
{% include "actions/collections/scenario.html" %}
{% elif object_type == 'model' %}
{% include "actions/collections/model.html" %}
{% elif object_type == 'pathway' %}
{% include "actions/collections/pathway.html" %}
{% elif object_type == 'node' %}
{% include "actions/collections/node.html" %} {% include "actions/collections/node.html" %}
{% elif object_type == 'edge' %} {% elif object_type == 'edge' %}
{% include "actions/collections/edge.html" %} {% include "actions/collections/edge.html" %}
{% elif object_type == 'group' %}
{% include "actions/collections/group.html" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
</ul> </ul>
</div> </div>
</div> </div>
<div class="panel-body"> <div class="mt-2">
<!-- Set Text above links --> {% if object_type == 'node' %}
{% if object_type == 'package' %} <p>
<p>A package contains pathways, rules, etc. and can reflect specific experimental Nodes represent the (predicted) compounds in a graph.
conditions. <a target="_blank" href="https://wiki.envipath.org/index.php/packages" role="button">Learn <a
more &gt;&gt;</a></p> target="_blank"
{% elif object_type == 'compound' %} href="https://wiki.envipath.org/index.php/nodes"
<p>A compound stores the structure of a molecule and can include meta-information. class="link link-primary"
<a target="_blank" href="https://wiki.envipath.org/index.php/compounds" role="button">Learn more >Learn more &gt;&gt;</a
&gt;&gt;</a></p> >
{% elif object_type == 'structure' %} </p>
<p>The structures stored in this compound
<a target="_blank" href="https://wiki.envipath.org/index.php/compounds" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'rule' %}
<p>A rule describes a biotransformation reaction template that is defined as SMIRKS.
<a target="_blank" href="https://wiki.envipath.org/index.php/Rules" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'reaction' %}
<p>A reaction is a specific biotransformation from educt compounds to product compounds.
<a target="_blank" href="https://wiki.envipath.org/index.php/reactions" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'pathway' %}
<p>A pathway displays the (predicted) biodegradation of a compound as graph.
<a target="_blank" href="https://wiki.envipath.org/index.php/pathways" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'node' %}
<p>Nodes represent the (predicted) compounds in a graph.
<a target="_blank" href="https://wiki.envipath.org/index.php/nodes" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'edge' %} {% elif object_type == 'edge' %}
<p>Edges represent the links between Nodes in a graph <p>
<a target="_blank" href="https://wiki.envipath.org/index.php/edges" role="button">Learn more Edges represent the links between nodes in a graph.
&gt;&gt;</a></p> <a
{% elif object_type == 'scenario' %} target="_blank"
<p>A scenario contains meta-information that can be attached to other data (compounds, rules, ..). href="https://wiki.envipath.org/index.php/edges"
<a target="_blank" href="https://wiki.envipath.org/index.php/scenarios" role="button">Learn more class="link link-primary"
&gt;&gt;</a></p> >Learn more &gt;&gt;</a
{% elif object_type == 'model' %} >
<p>A model applies machine learning to limit the combinatorial explosion. </p>
<a target="_blank" href="https://wiki.envipath.org/index.php/relative_reasoning" role="button">Learn
more
&gt;&gt;</a></p>
{% elif object_type == 'setting' %}
<p>A setting includes configuration parameters for pathway predictions.
<a target="_blank" href="https://wiki.envipath.org/index.php/settings" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'user' %}
<p>Register now to create own packages and to submit and manage your data.
<a target="_blank" href="https://wiki.envipath.org/index.php/users" role="button">Learn more
&gt;&gt;</a></p>
{% elif object_type == 'group' %}
<p>Users can team up in groups to share packages.
<a target="_blank" href="https://wiki.envipath.org/index.php/groups" role="button">Learn more
&gt;&gt;</a></p>
{% endif %} {% endif %}
<!-- If theres nothing to show extend the text above -->
{% if reviewed_objects and unreviewed_objects %} {% if reviewed_objects and unreviewed_objects %}
{% if reviewed_objects|length == 0 and unreviewed_objects|length == 0 %} {% if reviewed_objects|length == 0 and unreviewed_objects|length == 0 %}
<p>Nothing found. There are two possible reasons: <br><br>1. There is no content yet.<br>2. You have no <p class="mt-4">
reading permissions.<br><br>Please be sure you have at least reading permissions.</p> Nothing found. There are two possible reasons:<br /><br />
1. There is no content yet.<br />
2. You have no reading permissions.<br /><br />
Please ensure you have at least reading permissions.
</p>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
</div>
</div>
<!-- Lists Container -->
<div class="w-full">
{% if reviewed_objects %} {% if reviewed_objects %}
{% if reviewed_objects|length > 0 %} {% if reviewed_objects|length > 0 %}
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> <!-- Reviewed -->
<h4 class="panel-title"> <div
<a id="ReviewedLink" data-toggle="collapse" data-parent="#reviewListAccordion" class="collapse-arrow bg-base-200 collapse order-2 w-full"
href="#Reviewed">Reviewed</a> x-data="paginatedList(window.reviewedObjects || [], { isReviewed: true, instanceId: 'reviewed' })"
</h4> >
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
Reviewed
<span
class="badge badge-sm badge-neutral ml-2"
x-text="totalItems"
></span>
</div> </div>
<div id="Reviewed" class="panel-collapse collapse in"> <div class="collapse-content w-full">
<div class="panel-body list-group-item" id="ReviewedContent"> <ul class="menu bg-base-100 rounded-box w-full">
{% if object_type == 'package' %} <template x-for="obj in paginatedItems" :key="obj.url">
{% for obj in reviewed_objects %} <li>
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }} <a :href="obj.url" class="hover:bg-base-200">
<span class="glyphicon glyphicon-star" aria-hidden="true" <span x-text="obj.name"></span>
style="float:right" data-toggle="tooltip" <span
data-placement="top" title="" data-original-title="Reviewed"> class="tooltip tooltip-left ml-auto"
data-tip="Reviewed"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-star"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
</span> </span>
</a> </a>
{% endfor %} </li>
{% else %} </template>
{% for obj in reviewed_objects|slice:":50" %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}{# <i>({{ obj.package.name }})</i> #}
<span class="glyphicon glyphicon-star" aria-hidden="true"
style="float:right" data-toggle="tooltip"
data-placement="top" title="" data-original-title="Reviewed">
</span>
</a>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if unreviewed_objects %}
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"><h4
class="panel-title"><a id="UnreviewedLink" data-toggle="collapse" data-parent="#unReviewListAccordion"
href="#Unreviewed">Unreviewed</a></h4></div>
<div id="Unreviewed" class="panel-collapse collapse {% if reviewed_objects|length == 0 or object_type == 'package' %}in{% endif %}">
<div class="panel-body list-group-item" id="UnreviewedContent">
{% if object_type == 'package' %}
{% for obj in unreviewed_objects %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}</a>
{% endfor %}
{% else %}
{% for obj in unreviewed_objects|slice:":50" %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}</a>
{% endfor %}
{% endif %}
</div>
</div>
{% endif %}
{% if objects %}
<!-- Unreviewable objects such as User / Group / Setting -->
<ul class='list-group'>
{% for obj in objects %}
{% if object_type == 'user' %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.username|safe }}</a>
{% else %}
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}</a>
{% endif %}
{% endfor %}
</ul> </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>
</div>
{% endif %}
{% endif %}
{% if unreviewed_objects %}
<!-- Unreviewed -->
<div
class="collapse-arrow bg-base-200 collapse order-1 w-full"
x-data="paginatedList(window.unreviewedObjects || [], { isReviewed: false, instanceId: 'unreviewed' })"
>
<input
type="checkbox"
{% if reviewed_objects|length == 0 %}checked{% endif %}
/>
<div class="collapse-title text-xl font-medium">
Unreviewed
<span
class="badge badge-sm badge-neutral ml-2"
x-text="totalItems"
></span>
</div>
<div class="collapse-content w-full">
<ul class="menu bg-base-100 rounded-box w-full">
<template x-for="obj in paginatedItems" :key="obj.url">
<li>
<a
:href="obj.url"
class="hover:bg-base-200"
x-text="obj.name"
></a>
</li>
</template>
</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>
</div>
{% endif %} {% endif %}
</div> </div>
<style>
.spinner-widget {
position: fixed; /* stays in place on scroll */
bottom: 20px; /* distance from bottom */
right: 20px; /* distance from right */
z-index: 9999; /* above most elements */
width: 60px; /* adjust to gif size */
height: 60px;
}
.spinner-widget img {
width: 100%;
height: auto;
}
</style>
<div id="load-all-loading" class="spinner-widget" style="display: none">
<img id="loading-gif" src="{% static '/images/wait.gif' %}" alt="Loading...">
</div> </div>
</div>
<script> <script>
$(function () { document.addEventListener("DOMContentLoaded", function () {
// Show actions button if there are actions
$('#object-search').show(); const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
{% if object_type != 'package' and object_type != 'user' and object_type != 'group' %} if (actionsList && actionsList.children.length > 0) {
{% if reviewed_objects|length > 50 or unreviewed_objects|length > 50 %} actionsButton?.classList.remove("hidden");
$('#load-all-loading').show()
setTimeout(function () {
$('#load-all-error').hide();
$.getJSON('?all=true', function (resp) {
$('#ReviewedContent').empty();
$('#UnreviewedContent').empty();
for (o in resp.objects) {
obj = resp.objects[o];
if (obj.reviewed) {
$('#ReviewedContent').append('<a class="list-group-item" href="' + obj.url + '">' + obj.name + ' <span class="glyphicon glyphicon-star" aria-hidden="true" style="float:right" data-toggle="tooltip" data-placement="top" title="" data-original-title="Reviewed"></span></a>');
} else {
$('#UnreviewedContent').append('<a class="list-group-item" href="' + obj.url + '">' + obj.name + '</a>');
}
} }
$('#load-all-loading').hide(); // Show search input and connect to Alpine pagination
$('#load-remaining').hide(); const objectSearch = document.getElementById("object-search");
}).fail(function (resp) { if (objectSearch) {
$('#load-all-loading').hide(); objectSearch.classList.remove("hidden");
$('#load-all-error').show(); objectSearch.addEventListener("input", function () {
const query = this.value;
// Dispatch search to all paginatedList components
document
.querySelectorAll('[x-data*="paginatedList"]')
.forEach((el) => {
if (el._x_dataStack && el._x_dataStack[0]) {
el._x_dataStack[0].search(query);
}
}); });
});
}
}, 2500); // Delete form submit handler
{% endif %} const deleteSubmit = document.getElementById("modal-form-delete-submit");
{% endif %} const deleteForm = document.getElementById("modal-form-delete");
if (deleteSubmit && deleteForm) {
$('#modal-form-delete-submit').on('click', function (e) { deleteSubmit.addEventListener("click", function (e) {
e.preventDefault(); e.preventDefault();
$('#modal-form-delete').submit(); deleteForm.submit();
}); });
}
$('#object-search').on('keyup', function () {
let query = $(this).val().toLowerCase();
$('a.list-group-item').each(function () {
let text = $(this).text().toLowerCase();
$(this).toggle(text.indexOf(query) !== -1);
});
});
}); });
</script> </script>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,95 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Packages{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
id="new-package-button"
onclick="document.getElementById('new_package_modal').showModal(); return false;"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-folder-plus-icon lucide-folder-plus"
>
<path d="M12 10v6" />
<path d="M9 13h6" />
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg>
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm">
Import
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down ml-1"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2"
>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
Import Package from JSON
</a>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
Import Package from legacy JSON
</a>
</li>
</ul>
</div>
</div>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_package_modal.html" %}
{% include "modals/collections/import_package_modal.html" %}
{% include "modals/collections/import_legacy_package_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A package contains pathways, rules, etc. and can reflect specific
experimental conditions.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/packages"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,128 @@
{% extends "framework_modern.html" %}
{% load static %}
{# List title for empty text - defaults to "items", should be overridden by child templates #}
{% block list_title %}items{% endblock %}
{% block content %}
{% block action_modals %}
{% endblock action_modals %}
<div class="px-8 py-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body px-0 py-4">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">
{% block page_title %}{{ page_title|default:"Items" }}{% endblock %}
</h2>
{% block action_button %}
{# Can be overridden by including action buttons for entity type #}
{% endblock %}
</div>
<div class="mt-2">
{% block description %}
{% endblock %}
</div>
</div>
</div>
{% if list_mode == "combined" %}
{# ===== COMBINED MODE: Single list without tabs ===== #}
<div
class="mt-6 w-full"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}',
instanceId: '{{ entity_type }}_combined',
perPage: {{ per_page|default:50 }}
})"
>
{% include "collections/_paginated_list_partial.html" with empty_text=list_title|default:"items" show_review_badge=True %}
</div>
{% else %}
{# ===== TABBED MODE: Reviewed/Unreviewed tabs (default) ===== #}
<div
class="mt-6 w-full"
x-data="{
activeTab: 'reviewed',
reviewedCount: null,
unreviewedCount: null,
get bothLoaded() { return this.reviewedCount !== null && this.unreviewedCount !== null },
get isEmpty() { return this.bothLoaded && this.reviewedCount === 0 && this.unreviewedCount === 0 },
updateTabSelection() {
if (this.bothLoaded && this.reviewedCount === 0 && this.unreviewedCount > 0) {
this.activeTab = 'unreviewed';
}
}
}"
>
{# No items found message - only show after both tabs have loaded #}
<div x-show="isEmpty" class="text-base-content/70 py-8 text-center">
<p>No items found.</p>
</div>
{# Tabs Navigation #}
<div role="tablist" class="tabs tabs-border" x-show="!isEmpty">
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'reviewed' }"
@click="activeTab = 'reviewed'"
x-show="reviewedCount === null || reviewedCount > 0"
>
Reviewed
<span
class="badge badge-xs badge-dash badge-info mb-2 ml-2"
:class="{ 'animate-pulse': reviewedCount === null }"
x-text="reviewedCount ?? '…'"
></span>
</button>
<button
role="tab"
class="tab"
:class="{ 'tab-active': activeTab === 'unreviewed' }"
@click="activeTab = 'unreviewed'"
x-show="unreviewedCount === null || unreviewedCount > 0"
>
Unreviewed
<span
class="badge badge-xs badge-dash badge-info mb-2 ml-2"
:class="{ 'animate-pulse': unreviewedCount === null }"
x-text="unreviewedCount ?? '…'"
></span>
</button>
</div>
{# Reviewed Tab Content #}
<div
class="mt-6"
x-show="activeTab === 'reviewed' && !isEmpty"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=true',
instanceId: '{{ entity_type }}_reviewed',
isReviewed: true,
perPage: {{ per_page|default:50 }}
})"
@items-loaded="reviewedCount = totalItems; updateTabSelection()"
>
{% include "collections/_paginated_list_partial.html" with empty_text="reviewed "|add:list_title|default:"items" show_review_badge=True always_show_badge=True %}
</div>
{# Unreviewed Tab Content #}
<div
class="mt-6"
x-show="activeTab === 'unreviewed' && !isEmpty"
x-data="remotePaginatedList({
endpoint: '{{ api_endpoint }}?review_status=false',
instanceId: '{{ entity_type }}_unreviewed',
isReviewed: false,
perPage: {{ per_page|default:50 }}
})"
@items-loaded="unreviewedCount = totalItems; updateTabSelection()"
>
{% include "collections/_paginated_list_partial.html" with empty_text="unreviewed "|add:list_title|default:"items" %}
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@ -0,0 +1,29 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Pathways{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<div class="flex items-center gap-2">
<a
class="btn btn-primary btn-sm"
href="{% if meta.current_package %}{{ meta.current_package.url }}/predict{% else %}/predict{% endif %}"
>
New Pathway
</a>
</div>
{% endif %}
{% endblock action_button %}
{% block description %}
<p>
A pathway displays the (predicted) biodegradation of a compound as graph.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/pathways"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Reactions{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_reaction_modal').showModal(); return false;"
>
New Reaction
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_reaction_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A reaction is a specific biotransformation from educt compounds to product
compounds.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/reactions"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Rules{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_rule_modal').showModal(); return false;"
>
New Rule
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_rule_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A rule describes a biotransformation reaction template that is defined as
SMIRKS.
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/Rules"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,33 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}Scenarios{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_scenario_modal').showModal(); return false;"
>
New Scenario
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{% include "modals/collections/new_scenario_modal.html" %}
{% endblock action_modals %}
{% block description %}
<p>
A scenario contains meta-information that can be attached to other data
(compounds, rules, ..).
</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/scenarios"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

View File

@ -0,0 +1,30 @@
{% extends "collections/paginated_base.html" %}
{% block page_title %}{{ page_title|default:"Structures" }}{% endblock %}
{% block action_button %}
{% if meta.can_edit %}
<button
type="button"
class="btn btn-primary btn-sm"
onclick="document.getElementById('new_compound_structure_modal').showModal(); return false;"
>
New Structure
</button>
{% endif %}
{% endblock action_button %}
{% block action_modals %}
{# FIXME: New Compound Structure Modal #}
{% endblock action_modals %}
{% block description %}
<p>The structures stored in this compound.</p>
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>
Learn more &gt;&gt;
</a>
{% endblock description %}

Some files were not shown because too many files have changed in this diff Show More