18 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
120 changed files with 6516 additions and 1671 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

@ -10,6 +10,8 @@ jobs:
test: test:
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }} 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:
@ -41,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
@ -64,71 +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'
- name: Use Node.js ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
uses: actions/setup-node@v4 run-migrations: 'true'
with:
node-version: 20
cache: "pnpm"
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Setup venv
run: |
uv sync --locked --all-extras --dev
source .venv/bin/activate
playwright install --with-deps
- name: Run PNPM Commands
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 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 frontend tests - name: Run frontend tests
run: | run: |
source .venv/bin/activate .venv/bin/python manage.py test --tag frontend
python manage.py test --tag frontend
- 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 --exclude-tag frontend

371
.gitignore vendored
View File

@ -1,18 +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/ 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,6 +48,7 @@ INSTALLED_APPS = [
"django_extensions", "django_extensions",
"oauth2_provider", "oauth2_provider",
# Custom # Custom
"epapi", # API endpoints (v1, etc.)
"epdb", "epdb",
# "migration", # "migration",
] ]
@ -198,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)
@ -355,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/",
@ -366,7 +374,7 @@ LOGIN_EXEMPT_URLS = [
"/cookie-policy", "/cookie-policy",
"/about", "/about",
"/contact", "/contact",
"/jobs", "/careers",
"/cite", "/cite",
"/legal", "/legal",
] ]

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

@ -1451,7 +1451,7 @@ def create_pathway(
from .tasks import dispatch, predict from .tasks import dispatch, predict
dispatch(request.user, predict, new_pw.pk, setting.pk, limit=-1) dispatch(request.user, predict, new_pw.pk, setting.pk, limit=None)
return redirect(new_pw.url) return redirect(new_pw.url)
except ValueError as e: except ValueError as e:
@ -1815,7 +1815,7 @@ def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
from epdb.tasks import dispatch_eager, predict_simple from epdb.tasks import dispatch_eager, predict_simple
pred_res = dispatch_eager(request.user, predict_simple, mod.pk, stand_smiles) _, pred_res = dispatch_eager(request.user, predict_simple, mod.pk, stand_smiles)
result = [] result = []

View File

@ -1,7 +1,7 @@
import json import json
import logging import logging
import re import re
from typing import Any, Dict, List, Optional, Set, Union from typing import Any, Dict, List, Optional, Set, Union, Tuple
from uuid import UUID from uuid import UUID
import nh3 import nh3
@ -16,6 +16,7 @@ from epdb.models import (
Edge, Edge,
EnzymeLink, EnzymeLink,
EPModel, EPModel,
ExpansionSchemeChoice,
Group, Group,
GroupPackagePermission, GroupPackagePermission,
Node, Node,
@ -443,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)
) )
@ -1116,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
@ -1398,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
@ -1483,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):
@ -1547,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:
@ -1573,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
@ -1585,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 = []
@ -1603,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)
@ -1618,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
@ -1630,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")
@ -1693,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

@ -93,7 +93,6 @@ 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']} "
@ -104,7 +103,6 @@ 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 "

View File

@ -24,7 +24,6 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
print("Localizing urls for Package")
Package.objects.update(url=Replace(F("url"), Value(options["old"]), Value(options["new"]))) Package.objects.update(url=Replace(F("url"), Value(options["old"]), Value(options["new"])))
MODELS = [ MODELS = [
@ -50,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

@ -23,7 +23,7 @@ from django.db import models, transaction
from django.db.models import Count, JSONField, 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 jaccard_score, precision_score, recall_score from sklearn.metrics import jaccard_score, precision_score, recall_score
@ -754,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):
@ -771,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
@ -783,6 +805,12 @@ 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(
@ -901,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(
@ -954,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")]
@ -1112,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:
@ -1241,7 +1354,12 @@ class SimpleAmbitRule(SimpleRule):
return "simple-rule" 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):
@ -1338,6 +1456,20 @@ class ParallelRule(Rule):
f"Simple rule {sr.uuid} does not belong to package {package.uuid}!" 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 = ParallelRule()
r.package = package r.package = package
@ -1519,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():
@ -1588,6 +1733,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
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):
@ -1613,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)
@ -1639,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
@ -1660,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)
@ -1748,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",
@ -1765,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):
@ -1799,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
@ -1811,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:
@ -1830,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,
@ -1941,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)
@ -1950,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",
@ -1965,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
@ -1975,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
@ -2221,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"]:
""" """
@ -2282,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()
@ -2319,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:
@ -2327,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()
@ -2383,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
@ -2409,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:
@ -3192,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:
@ -3200,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()
@ -3612,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)
@ -3636,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)
@ -3670,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):
@ -3729,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",
@ -3740,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()
@ -3756,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

@ -11,6 +11,7 @@ from django.utils import timezone
from epdb.logic import SPathway from epdb.logic import SPathway
from epdb.models import Edge, EPModel, JobLog, Node, 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.
@ -36,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
@ -52,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
@ -139,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():
@ -171,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():
@ -284,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
@ -196,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,10 +1,12 @@
import json import json
import logging import logging
from typing import Any, Dict, List from typing import Any, Dict, List
from datetime import datetime
import nh3 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.core.exceptions import BadRequest, PermissionDenied
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
@ -49,6 +51,7 @@ from .models import (
SimpleAmbitRule, SimpleAmbitRule,
User, User,
UserPackagePermission, UserPackagePermission,
ExpansionSchemeChoice,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -319,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")
@ -437,6 +440,18 @@ 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() @package_permission_required()
def package_predict_pathway(request, package_uuid): def package_predict_pathway(request, package_uuid):
"""Package-specific predict pathway view.""" """Package-specific predict pathway view."""
@ -459,20 +474,15 @@ def packages(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):
@ -518,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
@ -556,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
@ -597,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
@ -638,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
@ -688,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
@ -721,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)
@ -833,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()
@ -854,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",
@ -869,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)
@ -960,20 +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
pred_res = dispatch_eager( _, pred_res = dispatch_eager(
current_user, predict_simple, current_model.pk, stand_smiles current_user, predict_simple, current_model.pk, stand_smiles
) )
res = [] res = {"pred": [], "stereo": stereo}
for pr in pred_res: for pr in pred_res:
if len(pr) > 0: if len(pr) > 0:
@ -982,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,
@ -1227,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()
@ -1252,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)
@ -1370,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")
@ -1529,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()
@ -1550,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)
@ -1731,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()
@ -1761,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")
@ -1883,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()
@ -1906,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)
@ -1926,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:
@ -1949,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})
@ -1958,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":
@ -1994,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(),
} }
) )
@ -2014,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
) )
@ -2442,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()
@ -2467,9 +2415,6 @@ 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, SEDIMENT_ADDITIONAL_INFORMATION,
SLUDGE_ADDITIONAL_INFORMATION, SLUDGE_ADDITIONAL_INFORMATION,
@ -2504,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)
@ -2719,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:
@ -2819,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():
@ -2864,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,
@ -2914,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

@ -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",
@ -47,6 +48,7 @@ dev = [
"ruff>=0.13.3", "ruff>=0.13.3",
"pytest-playwright>=0.7.1", "pytest-playwright>=0.7.1",
"pytest-django>=4.11.1", "pytest-django>=4.11.1",
"pytest-cov>=7.0.0",
] ]
[tool.ruff] [tool.ruff]
@ -121,3 +123,22 @@ collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help
] } ] }
frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" } 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",
]

View File

@ -34,30 +34,3 @@
} }
@import "./daisyui-theme.css"; @import "./daisyui-theme.css";
/* Loading Spinner - Benzene Ring */
.loading-spinner {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.loading-spinner svg {
width: 48px;
height: 48px;
animation: spin 2s linear infinite;
}
.loading-spinner .hexagon,
.loading-spinner .double-bonds {
fill: none;
stroke: currentColor;
stroke-width: 2;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}

View File

@ -5,31 +5,26 @@
*/ */
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('paginatedList', (initialItems = [], options = {}) => ({ Alpine.data('remotePaginatedList', (options = {}) => ({
allItems: initialItems, items: [],
filteredItems: [],
currentPage: 1, currentPage: 1,
totalPages: 0,
totalItems: 0,
perPage: options.perPage || 50, perPage: options.perPage || 50,
searchQuery: '', endpoint: options.endpoint || '',
isReviewed: options.isReviewed || false, isReviewed: options.isReviewed || false,
instanceId: options.instanceId || Math.random().toString(36).substring(2, 9), instanceId: options.instanceId || Math.random().toString(36).substring(2, 9),
isLoading: false,
error: null,
init() { init() {
this.filteredItems = this.allItems; if (this.endpoint) {
}, this.fetchPage(1);
}
get totalPages() {
return Math.ceil(this.filteredItems.length / this.perPage);
}, },
get paginatedItems() { get paginatedItems() {
const start = (this.currentPage - 1) * this.perPage; return this.items;
const end = start + this.perPage;
return this.filteredItems.slice(start, end);
},
get totalItems() {
return this.filteredItems.length;
}, },
get showingStart() { get showingStart() {
@ -38,36 +33,67 @@ document.addEventListener('alpine:init', () => {
}, },
get showingEnd() { get showingEnd() {
return Math.min(this.currentPage * this.perPage, this.totalItems); if (this.totalItems === 0) return 0;
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
}, },
search(query) { async fetchPage(page) {
this.searchQuery = query.toLowerCase(); if (!this.endpoint) {
if (this.searchQuery === '') { return;
this.filteredItems = this.allItems; }
} else {
this.filteredItems = this.allItems.filter(item => this.isLoading = true;
item.name.toLowerCase().includes(this.searchQuery) 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');
} }
this.currentPage = 1;
}, },
nextPage() { nextPage() {
if (this.currentPage < this.totalPages) { if (this.currentPage < this.totalPages) {
this.currentPage++; this.fetchPage(this.currentPage + 1);
} }
}, },
prevPage() { prevPage() {
if (this.currentPage > 1) { if (this.currentPage > 1) {
this.currentPage--; this.fetchPage(this.currentPage - 1);
} }
}, },
goToPage(page) { goToPage(page) {
if (page >= 1 && page <= this.totalPages) { if (page >= 1 && page <= this.totalPages) {
this.currentPage = page; this.fetchPage(page);
} }
}, },
@ -76,54 +102,43 @@ document.addEventListener('alpine:init', () => {
const total = this.totalPages; const total = this.totalPages;
const current = this.currentPage; const current = this.currentPage;
// Handle empty case
if (total === 0) { if (total === 0) {
return pages; return pages;
} }
if (total <= 7) { if (total <= 7) {
// Show all pages if 7 or fewer
for (let i = 1; i <= total; i++) { for (let i = 1; i <= total; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` }); pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
} }
} else { } else {
// More than 7 pages - show first, last, and sliding window around current
// Always show first page
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` }); pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
// Determine the start and end of the middle range let rangeStart;
let rangeStart, rangeEnd; let rangeEnd;
if (current <= 4) { if (current <= 4) {
// Near the beginning: show pages 2-5
rangeStart = 2; rangeStart = 2;
rangeEnd = 5; rangeEnd = 5;
} else if (current >= total - 3) { } else if (current >= total - 3) {
// Near the end: show last 4 pages before the last page
rangeStart = total - 4; rangeStart = total - 4;
rangeEnd = total - 1; rangeEnd = total - 1;
} else { } else {
// In the middle: show current page and one on each side
rangeStart = current - 1; rangeStart = current - 1;
rangeEnd = current + 1; rangeEnd = current + 1;
} }
// Add ellipsis before range if there's a gap
if (rangeStart > 2) { if (rangeStart > 2) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` }); pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
} }
// Add pages in the range
for (let i = rangeStart; i <= rangeEnd; i++) { for (let i = rangeStart; i <= rangeEnd; i++) {
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` }); pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
} }
// Add ellipsis after range if there's a gap
if (rangeEnd < total - 1) { if (rangeEnd < total - 1) {
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` }); pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
} }
// Always show last page
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` }); pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
} }

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();
}
}));
});

View File

@ -704,7 +704,7 @@ function makeLoadingGif(attachOb) {
function handleAssessmentResponse(depict_url, data) { function handleAssessmentResponse(depict_url, data) {
var inside_app_domain = "<a class='list-group-item'>This compound is " + (data["assessment"]["inside_app_domain"] ? "inside" : "outside") + " the Applicability Domain derived from the chemical (PCA) space constructed using the training data." + "</a>"; var inside_app_domain = "<p class='mb-2'>This compound is " + (data["assessment"]["inside_app_domain"] ? "inside" : "outside") + " the Applicability Domain derived from the chemical (PCA) space constructed using the training data.</p>";
var functionalGroupsImgSrc = null; var functionalGroupsImgSrc = null;
var reactivityCentersImgSrc = null; var reactivityCentersImgSrc = null;
@ -716,29 +716,22 @@ function handleAssessmentResponse(depict_url, data) {
reactivityCentersImgSrc = "<img width='400' src=\"" + depict_url + "?smiles=" + encodeURIComponent(data['assessment']['smiles']) + "\">" reactivityCentersImgSrc = "<img width='400' src=\"" + depict_url + "?smiles=" + encodeURIComponent(data['assessment']['smiles']) + "\">"
} }
tpl = `<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> tpl = `<div class="collapse collapse-arrow bg-base-200">
<h4 class="panel-title"> <input type="checkbox" checked />
<a id="app-domain-assessment-functional-groups-link" data-toggle="collapse" data-parent="#app-domain-assessment" href="#app-domain-assessment-functional-groups">Functional Groups Covered by Model</a> <div class="collapse-title text-xl font-medium">Functional Groups Covered by Model</div>
</h4> <div class="collapse-content">
</div>
<div id="app-domain-assessment-functional-groups" class="panel-collapse collapse">
<div class="panel-body list-group-item">
${inside_app_domain} ${inside_app_domain}
<p></p> <div class="flex justify-center my-4">
<div id="image-div" align="center">
${functionalGroupsImgSrc} ${functionalGroupsImgSrc}
</div> </div>
</div> </div>
</div> </div>
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> <div class="collapse collapse-arrow bg-base-200 mt-2">
<h4 class="panel-title"> <input type="checkbox" checked />
<a id="app-domain-assessment-reactivity-centers-link" data-toggle="collapse" data-parent="#app-domain-assessment" href="#app-domain-assessment-reactivity-centers">Reactivity Centers</a> <div class="collapse-title text-xl font-medium">Reactivity Centers</div>
</h4> <div class="collapse-content">
</div> <div class="flex justify-center my-4">
<div id="app-domain-assessment-reactivity-centers" class="panel-collapse collapse">
<div class="panel-body list-group-item">
<div id="image-div" align="center">
${reactivityCentersImgSrc} ${reactivityCentersImgSrc}
</div> </div>
</div> </div>
@ -752,45 +745,41 @@ function handleAssessmentResponse(depict_url, data) {
for (n in transObj['neighbors']) { for (n in transObj['neighbors']) {
neighObj = transObj['neighbors'][n]; neighObj = transObj['neighbors'][n];
var neighImg = "<img width='100%' src='" + transObj['rule']['url'] + "?smiles=" + encodeURIComponent(neighObj['smiles']) + "'>"; var neighImg = "<img width='100%' src='" + transObj['rule']['url'] + "?smiles=" + encodeURIComponent(neighObj['smiles']) + "'>";
var objLink = `<a class='list-group-item' href="${neighObj['url']}">${neighObj['name']}</a>`
var neighPredProb = "<a class='list-group-item'>Predicted probability: " + neighObj['probability'].toFixed(2) + "</a>";
var pwLinks = ''; var pwLinksHtml = '';
if (neighObj['related_pathways'] && Object.keys(neighObj['related_pathways']).length > 0) {
pwLinksHtml = '<ul class="menu bg-base-100 rounded-box w-full">';
for (pw in neighObj['related_pathways']) { for (pw in neighObj['related_pathways']) {
var pwObj = neighObj['related_pathways'][pw]; var pwObj = neighObj['related_pathways'][pw];
pwLinks += "<a class='list-group-item' href=" + pwObj['url'] + ">" + pwObj['name'] + "</a>"; pwLinksHtml += `<li><a href="${pwObj['url']}" class="link link-primary">${pwObj['name']}</a></li>`;
}
pwLinksHtml += '</ul>';
} }
var expPathways = ` var expPathways = '';
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> if (pwLinksHtml !== '') {
<h4 class="panel-title"> expPathways = `
<a id="transformation-${t}-neighbor-${n}-exp-pathway-link" data-toggle="collapse" data-parent="#transformation-${t}-neighbor-${n}" href="#transformation-${t}-neighbor-${n}-exp-pathway">Experimental Pathways</a> <div class="collapse collapse-arrow bg-base-200 mt-2">
</h4> <input type="checkbox" />
</div> <div class="collapse-title font-medium">Experimental Pathways</div>
<div id="transformation-${t}-neighbor-${n}-exp-pathway" class="panel-collapse collapse"> <div class="collapse-content">
<div class="panel-body list-group-item"> ${pwLinksHtml}
${pwLinks}
</div> </div>
</div> </div>
` `;
if (pwLinks === '') {
expPathways = ''
} }
neighbors += ` neighbors += `
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> <div class="collapse collapse-arrow bg-base-100 mt-2">
<h4 class="panel-title"> <input type="checkbox" />
<a id="transformation-${t}-neighbor-${n}-link" data-toggle="collapse" data-parent="#transformation-${t}" href="#transformation-${t}-neighbor-${n}">Analog Transformation on ${neighObj['name']}</a> <div class="collapse-title text-lg font-medium">Analog Transformation on ${neighObj['name']}</div>
</h4> <div class="collapse-content">
</div> <ul class="menu bg-base-100 rounded-box w-full">
<div id="transformation-${t}-neighbor-${n}" class="panel-collapse collapse"> <li><a href="${neighObj['url']}" class="link link-primary">${neighObj['name']}</a></li>
<div class="panel-body list-group-item"> <li>Predicted probability: ${neighObj['probability'].toFixed(2)}</li>
${objLink} </ul>
${neighPredProb}
${expPathways} ${expPathways}
<p></p> <div class="flex justify-center my-4">
<div id="image-div" align="center">
${neighImg} ${neighImg}
</div> </div>
</div> </div>
@ -799,43 +788,38 @@ function handleAssessmentResponse(depict_url, data) {
} }
var panelName = null; var panelName = null;
var objLink = null; var objLinkUrl = null;
var objLinkText = null;
if (transObj['is_predicted']) { if (transObj['is_predicted']) {
panelName = `Predicted Transformation by ${transObj['rule']['name']}`; panelName = `Predicted Transformation by ${transObj['rule']['name']}`;
for (e in transObj['edges']) { for (e in transObj['edges']) {
objLink = `<a class='list-group-item' href="${transObj['edges'][e]['url']}">${transObj['edges'][e]['name']}</a>` objLinkUrl = transObj['edges'][e]['url'];
objLinkText = transObj['edges'][e]['name'];
break; break;
} }
} else { } else {
panelName = `Potential Transformation by applying ${transObj['rule']['name']}`; panelName = `Potential Transformation by applying ${transObj['rule']['name']}`;
objLink = `<a class='list-group-item' href="${transObj['rule']['url']}">${transObj['rule']['name']}</a>` objLinkUrl = transObj['rule']['url'];
objLinkText = transObj['rule']['name'];
} }
var predProb = "<a class='list-group-item'>Predicted probability: " + transObj['probability'].toFixed(2) + "</a>";
var timesTriggered = "<a class='list-group-item'>This rule has triggered " + transObj['times_triggered'] + " times in the training set</a>";
var reliability = "<a class='list-group-item'>Reliability: " + transObj['reliability'].toFixed(2) + " (" + (transObj['reliability'] > data['ad_params']['reliability_threshold'] ? "&gt" : "&lt") + " Reliability Threshold of " + data['ad_params']['reliability_threshold'] + ") </a>";
var localCompatibility = "<a class='list-group-item'>Local Compatibility: " + transObj['local_compatibility'].toFixed(2) + " (" + (transObj['local_compatibility'] > data['ad_params']['local_compatibility_threshold'] ? "&gt" : "&lt") + " Local Compatibility Threshold of " + data['ad_params']['local_compatibility_threshold'] + ")</a>";
var transImg = "<img width='100%' src='" + transObj['rule']['url'] + "?smiles=" + encodeURIComponent(data['assessment']['smiles']) + "'>"; var transImg = "<img width='100%' src='" + transObj['rule']['url'] + "?smiles=" + encodeURIComponent(data['assessment']['smiles']) + "'>";
var transformation = ` var transformation = `
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"> <div class="collapse collapse-arrow bg-base-200 mt-2">
<h4 class="panel-title"> <input type="checkbox" />
<a id="transformation-${t}-link" data-toggle="collapse" data-parent="#transformation-${t}" href="#transformation-${t}">${panelName}</a> <div class="collapse-title text-xl font-medium">${panelName}</div>
</h4> <div class="collapse-content">
</div> <ul class="menu bg-base-100 rounded-box w-full">
<div id="transformation-${t}" class="panel-collapse collapse"> <li><a href="${objLinkUrl}" class="link link-primary">${objLinkText}</a></li>
<div class="panel-body list-group-item"> <li>Predicted probability: ${transObj['probability'].toFixed(2)}</li>
${objLink} <li>This rule has triggered ${transObj['times_triggered']} times in the training set</li>
${predProb} <li>Reliability: ${transObj['reliability'].toFixed(2)} (${(transObj['reliability'] > data['ad_params']['reliability_threshold'] ? "&gt;" : "&lt;")} Reliability Threshold of ${data['ad_params']['reliability_threshold']})</li>
${timesTriggered} <li>Local Compatibility: ${transObj['local_compatibility'].toFixed(2)} (${(transObj['local_compatibility'] > data['ad_params']['local_compatibility_threshold'] ? "&gt;" : "&lt;")} Local Compatibility Threshold of ${data['ad_params']['local_compatibility_threshold']})</li>
${reliability} </ul>
${localCompatibility} <div class="flex justify-center my-4">
<p></p>
<div id="image-div" align="center">
${transImg} ${transImg}
</div> </div>
<p></p>
${neighbors} ${neighbors}
</div> </div>
</div> </div>

View File

@ -360,7 +360,11 @@ function draw(pathway, elem) {
} }
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) {
@ -520,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,10 +0,0 @@
{% if meta.can_edit %}
<li>
<a
role="button"
onclick="document.getElementById('new_compound_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Compound</a
>
</li>
{% endif %}

View File

@ -1,10 +0,0 @@
{% if meta.can_edit %}
<li>
<a
role="button"
onclick="document.getElementById('new_compound_structure_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Compound Structure</a
>
</li>
{% endif %}

View File

@ -1,10 +0,0 @@
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
<li>
<a
role="button"
onclick="document.getElementById('new_model_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Model</a
>
</li>
{% endif %}

View File

@ -1,25 +0,0 @@
<li>
<a
role="button"
onclick="document.getElementById('new_package_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Package</a
>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_package_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-import"></span> Import Package from JSON</a
>
</li>
<li>
<a
role="button"
onclick="document.getElementById('import_legacy_package_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-import"></span> Import Package from legacy
JSON</a
>
</li>

View File

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

View File

@ -1,10 +0,0 @@
{% if meta.can_edit %}
<li>
<a
role="button"
onclick="document.getElementById('new_reaction_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Reaction</a
>
</li>
{% endif %}

View File

@ -1,10 +0,0 @@
{% if meta.can_edit %}
<li>
<a
role="button"
onclick="document.getElementById('new_rule_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Rule</a
>
</li>
{% endif %}

View File

@ -1,10 +0,0 @@
{% if meta.can_edit %}
<li>
<a
role="button"
onclick="document.getElementById('new_scenario_modal').showModal(); return false;"
>
<span class="glyphicon glyphicon-plus"></span> New Scenario</a
>
</li>
{% 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

@ -7,6 +7,7 @@
<i class="glyphicon glyphicon-edit"></i> Edit Model</a <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 <a
role="button" role="button"
@ -15,6 +16,8 @@
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a <i class="glyphicon glyphicon-ok"></i> Evaluate Model</a
> >
</li> </li>
{% endif %}
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
<li> <li>
<a <a
role="button" role="button"
@ -23,6 +26,7 @@
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a <i class="glyphicon glyphicon-repeat"></i> Retrain Model</a
> >
</li> </li>
{% endif %}
<li> <li>
<a <a
class="button" class="button"

View File

@ -41,6 +41,14 @@
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a <i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
> >
</li> </li>
<li>
<a
role="button"
onclick="document.getElementById('engineer_pathway_modal').showModal(); return false;"
>
<i class="glyphicon glyphicon-cog"></i> Engineer Pathway</a
>
</li>
{% if meta.can_edit %} {% if meta.can_edit %}
<li> <li>
<a <a

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

@ -20,6 +20,9 @@
<table class="table-zebra table"> <table class="table-zebra table">
<thead> <thead>
<tr> <tr>
{% if meta.user.is_superuser %}
<th>User</th>
{% endif %}
<th>ID</th> <th>ID</th>
<th>Name</th> <th>Name</th>
<th>Status</th> <th>Status</th>
@ -36,7 +39,11 @@
<a href="{{ job.user.url }}">{{ job.user.username }}</a> <a href="{{ job.user.url }}">{{ job.user.username }}</a>
</td> </td>
{% endif %} {% endif %}
<td>{{ job.task_id }}</td> <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>

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

@ -5,6 +5,7 @@
{# Serialize objects data for Alpine pagination #} {# Serialize objects data for Alpine pagination #}
{# prettier-ignore-start #} {# prettier-ignore-start #}
{# FIXME: This is a hack to get the objects data into the JavaScript code. #} {# FIXME: This is a hack to get the objects data into the JavaScript code. #}
{% if object_type != 'scenario' %}
<script> <script>
window.reviewedObjects = [ window.reviewedObjects = [
{% for obj in reviewed_objects %} {% for obj in reviewed_objects %}
@ -17,9 +18,9 @@
{% endfor %} {% endfor %}
]; ];
</script> </script>
{% endif %}
{# prettier-ignore-end #} {# prettier-ignore-end #}
{% if object_type != 'package' %}
<div class="px-8 py-4"> <div class="px-8 py-4">
<input <input
type="text" type="text"
@ -28,35 +29,12 @@
placeholder="Search by name" placeholder="Search by name"
/> />
</div> </div>
{% endif %}
{% 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 %}
@ -66,32 +44,10 @@
<div class="card-body px-0 py-4"> <div class="card-body px-0 py-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="card-title text-2xl"> <h2 class="card-title text-2xl">
{% if object_type == 'package' %} {% if object_type == 'node' %}
Packages
{% elif object_type == 'compound' %}
Compounds
{% 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 %}
</h2> </h2>
<div id="actionsButton" class="dropdown dropdown-end hidden"> <div id="actionsButton" class="dropdown dropdown-end hidden">
@ -119,103 +75,17 @@
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2" class="dropdown-content menu bg-base-100 rounded-box z-50 w-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="mt-2"> <div class="mt-2">
<!-- Set Text above links --> {% if object_type == 'node' %}
{% if object_type == 'package' %}
<p>
A package contains pathways, rules, etc. and can reflect specific
experimental conditions.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/packages"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'compound' %}
<p>
A compound stores the structure of a molecule and can include
meta-information.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'structure' %}
<p>
The structures stored in this compound
<a
target="_blank"
href="https://wiki.envipath.org/index.php/compounds"
class="link link-primary"
>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"
class="link link-primary"
>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"
class="link link-primary"
>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"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'node' %}
<p> <p>
Nodes represent the (predicted) compounds in a graph. Nodes represent the (predicted) compounds in a graph.
<a <a
@ -227,7 +97,7 @@
</p> </p>
{% elif object_type == 'edge' %} {% elif object_type == 'edge' %}
<p> <p>
Edges represent the links between Nodes in a graph Edges represent the links between nodes in a graph.
<a <a
target="_blank" target="_blank"
href="https://wiki.envipath.org/index.php/edges" href="https://wiki.envipath.org/index.php/edges"
@ -235,70 +105,15 @@
>Learn more &gt;&gt;</a >Learn more &gt;&gt;</a
> >
</p> </p>
{% elif object_type == 'scenario' %}
<p>
A scenario contains meta-information that can be attached to other
data (compounds, rules, ..).
<a
target="_blank"
href="https://wiki.envipath.org/index.php/scenarios"
class="link link-primary"
>Learn more &gt;&gt;</a
>
</p>
{% elif object_type == 'model' %}
<p>
A model applies machine learning to limit the combinatorial
explosion.
<a
target="_blank"
href="https://wiki.envipath.org/index.php/relative_reasoning"
class="link link-primary"
>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"
class="link link-primary"
>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"
class="link link-primary"
>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"
class="link link-primary"
>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 class="mt-4"> <p class="mt-4">
Nothing found. There are two possible reasons: <br /><br />1. Nothing found. There are two possible reasons:<br /><br />
There is no content yet.<br />2. You have no reading 1. There is no content yet.<br />
permissions.<br /><br />Please be sure you have at least reading 2. You have no reading permissions.<br /><br />
permissions. Please ensure you have at least reading permissions.
</p> </p>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -306,7 +121,7 @@
</div> </div>
</div> </div>
<!-- Lists Container - Full Width with Reviewed on Right --> <!-- Lists Container -->
<div class="w-full"> <div class="w-full">
{% if reviewed_objects %} {% if reviewed_objects %}
{% if reviewed_objects|length > 0 %} {% if reviewed_objects|length > 0 %}
@ -404,7 +219,7 @@
> >
<input <input
type="checkbox" type="checkbox"
{% if reviewed_objects|length == 0 or object_type == 'package' %}checked{% endif %} {% if reviewed_objects|length == 0 %}checked{% endif %}
/> />
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
Unreviewed Unreviewed
@ -466,31 +281,6 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if objects %}
<!-- Unreviewable objects such as User / Group / Setting -->
<div class="card bg-base-100">
<div class="card-body">
<ul class="menu bg-base-200 rounded-box">
{% for obj in objects %}
{% if object_type == 'user' %}
<li>
<a href="{{ obj.url }}" class="hover:bg-base-300"
>{{ obj.username }}</a
>
</li>
{% else %}
<li>
<a href="{{ obj.url }}" class="hover:bg-base-300"
>{{ obj.name }}</a
>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div> </div>
<script> <script>

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 %}

View File

@ -6,9 +6,9 @@
<h6 class="footer-title">Services</h6> <h6 class="footer-title">Services</h6>
<a class="link link-hover" href="/predict">Predict</a> <a class="link link-hover" href="/predict">Predict</a>
<a class="link link-hover" href="/package">Packages</a> <a class="link link-hover" href="/package">Packages</a>
{% if user.is_authenticated %} {# {% if user.is_authenticated %}#}
<a class="link link-hover" href="/model">Your Collections</a> {# <a class="link link-hover" href="/model">Your Collections</a>#}
{% endif %} {# {% endif %}#}
<a <a
href="https://wiki.envipath.org/" href="https://wiki.envipath.org/"
target="_blank" target="_blank"

View File

@ -0,0 +1,28 @@
<style>
@keyframes spin-slow {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinner-slow svg {
animation: spin-slow 3s linear infinite;
}
</style>
<div class="spinner-slow flex h-full w-full items-center justify-center">
<svg
viewBox="0 0 1000 1000"
xmlns="http://www.w3.org/2000/svg"
class="h-full w-full"
>
<path
class="hexagon"
d="m 758.78924,684.71562 0.65313,-363.85 33.725,0.066 -0.65313,363.85001 z M 201.52187,362.53368 512.50834,173.66181 530.01077,202.48506 219.03091,391.35694 z M 510.83924,841.63056 199.3448,653.59653 216.77465,624.72049 528.2691,812.76111 z M 500,975 85.905556,742.30278 l 0,-474.94722 L 500,24.999998 914.09445,257.64444 l 0,475.00001 z M 124.90833,722.45834 500,936.15556 880.26389,713.69722 l 0,-436.15555 L 500,63.949998 124.90833,286.40833 z"
fill="black"
stroke="black"
stroke-width="2"
/>
</svg>
</div>

View File

@ -118,7 +118,7 @@
</div> </div>
</a> </a>
{% endif %} {% endif %}
{% if meta.user.username == 'anonymous' or public_mode %} {% if meta.user.username == 'anonymous' %}
<a href="{% url 'login' %}" id="loginButton" class="p-2">Login</a> <a href="{% url 'login' %}" id="loginButton" class="p-2">Login</a>
{% else %} {% else %}
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">

View File

@ -29,6 +29,7 @@
<script src="{% static 'js/alpine/index.js' %}"></script> <script src="{% static 'js/alpine/index.js' %}"></script>
<script src="{% static 'js/alpine/search.js' %}"></script> <script src="{% static 'js/alpine/search.js' %}"></script>
<script src="{% static 'js/alpine/pagination.js' %}"></script> <script src="{% static 'js/alpine/pagination.js' %}"></script>
<script src="{% static 'js/alpine/pathway.js' %}"></script>
{# Font Awesome #} {# Font Awesome #}
<link <link
@ -68,7 +69,7 @@
{% endif %} {% endif %}
</head> </head>
<body class="bg-base-300 min-h-screen"> <body class="bg-base-300 min-h-screen">
{% include "includes/navbar.html" %} {% include "components/navbar.html" %}
{# Main Content Area #} {# Main Content Area #}
<main class="w-full"> <main class="w-full">
@ -128,7 +129,7 @@
{% endblock main_content %} {% endblock main_content %}
</main> </main>
{% include "includes/footer.html" %} {% include "components/footer.html" %}
{# Floating Help Tab #} {# Floating Help Tab #}
{% if not public_mode %} {% if not public_mode %}

View File

@ -210,6 +210,27 @@
step="0.05" step="0.05"
/> />
</div> </div>
<div class="form-control mb-3">
<label
class="label"
for="model-based-prediction-setting-expansion-scheme"
>
<span class="label-text">Select Expansion Scheme</span>
</label>
<select
id="model-based-prediction-setting-expansion-scheme"
name="model-based-prediction-setting-expansion-scheme"
class="select select-bordered w-full"
>
<option value="" disabled selected>
Select the Expansion Scheme
</option>
<option value="BFS">Breadth First Search</option>
<option value="DFS">Depth First Search</option>
<option value="GREEDY">Greedy</option>
</select>
</div>
</div> </div>
<div class="form-control"> <div class="form-control">

View File

@ -16,12 +16,12 @@
> >
<div class="modal-box max-w-3xl"> <div class="modal-box max-w-3xl">
<!-- Header --> <!-- Header -->
<h3 class="font-bold text-lg">New Scenario</h3> <h3 class="text-lg font-bold">New Scenario</h3>
<!-- Close button (X) --> <!-- Close button (X) -->
<form method="dialog"> <form method="dialog">
<button <button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
:disabled="isSubmitting" :disabled="isSubmitting"
> >
@ -114,20 +114,37 @@
</div> </div>
<div class="form-control mb-3"> <div class="form-control mb-3">
<label class="label" for="scenario-type"> <label class="label">
<span class="label-text">Scenario Type</span> <span class="label-text">Scenario Type</span>
</label> </label>
<select <div role="tablist" class="tabs tabs-border">
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === 'empty' }"
@click="scenarioType = 'empty'"
>
Empty Scenario
</button>
{% for k, v in scenario_types.items %}
<button
type="button"
role="tab"
class="tab"
:class="{ 'tab-active': scenarioType === '{{ v.name }}' }"
@click="scenarioType = '{{ v.name }}'"
>
{{ k }}
</button>
{% endfor %}
</div>
<input
type="hidden"
id="scenario-type" id="scenario-type"
name="scenario-type" name="scenario-type"
class="select select-bordered w-full"
x-model="scenarioType" x-model="scenarioType"
> />
<option value="empty" selected>Empty Scenario</option>
{% for k, v in scenario_types.items %}
<option value="{{ v.name }}">{{ k }}</option>
{% endfor %}
</select>
</div> </div>
{% for type in scenario_types.values %} {% for type in scenario_types.values %}

View File

@ -0,0 +1,66 @@
{% load static %}
<dialog
id="download_job_result_modal"
class="modal"
x-data="modalForm()"
@close="reset()"
>
<div class="modal-box">
<!-- Header -->
<h3 class="font-bold text-lg">Download Job Result</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<p>By clicking on Download the Result of this Job will be saved.</p>
<form
id="download-job-result-modal-form"
accept-charset="UTF-8"
action="{{ job.url }}"
method="GET"
>
<input type="hidden" name="download" value="true" />
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('download-job-result-modal-form'); $el.closest('dialog').close();"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Download</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -0,0 +1,107 @@
{% load static %}
<dialog
id="engineer_pathway_modal"
class="modal"
x-data="modalForm()"
@close="reset()"
>
<div class="modal-box max-w-2xl">
<!-- Header -->
<h3 class="font-bold text-lg">Engineer Pathway</h3>
<!-- Close button (X) -->
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
:disabled="isSubmitting"
>
</button>
</form>
<!-- Body -->
<div class="py-4">
<p class="mb-4">
Engineering Package is a process used to identify potential intermediate
transformation products. To achieve this, a pathway is predicted using
an existing setting. The threshold is temporarily set to zero to ensure
that even intermediates with very low probability are not filtered out.
<br /><br />
If any intermediates are found, two pathways will be saved in a
generated Package:
<br />
1. The engineered Pathway with the identified intermediates highlighted.
<br />
2. The fully predicted Pathway preserved for further analysis.
<br /><br />
Note: This is an asynchronous process and may take a few minutes to
complete. You will be redirected to a page containing details about the
task and its status.
</p>
<form
id="engineer-pathway-modal-form"
accept-charset="UTF-8"
action="{% url 'jobs' %}"
method="post"
>
{% csrf_token %}
<div class="form-control mb-3">
<label class="label" for="engineer-setting">
<span class="label-text">
Select the Setting you want to use for pathway engineering
</span>
</label>
<select
id="engineer-setting"
name="engineer-setting"
class="select select-bordered w-full"
required
>
<option value="" disabled selected>Select Setting</option>
{% for s in meta.available_settings %}
<option value="{{ s.url }}">{{ s.name|safe }}</option>
{% endfor %}
</select>
<input
type="hidden"
name="pathway-to-engineer"
value="{{ pathway.url }}"
/>
<input type="hidden" name="job-name" value="engineer-pathway" />
</div>
</form>
</div>
<!-- Footer -->
<div class="modal-action">
<button
type="button"
class="btn"
onclick="this.closest('dialog').close()"
:disabled="isSubmitting"
>
Close
</button>
<button
type="button"
class="btn btn-primary"
@click="submit('engineer-pathway-modal-form')"
:disabled="isSubmitting"
>
<span x-show="!isSubmitting">Engineer</span>
<span
x-show="isSubmitting"
class="loading loading-spinner loading-sm"
></span>
<span x-show="isSubmitting">Engineering...</span>
</button>
</div>
</div>
<!-- Backdrop -->
<form method="dialog" class="modal-backdrop">
<button :disabled="isSubmitting">close</button>
</form>
</dialog>

View File

@ -45,7 +45,6 @@
name="model-evaluation-packages" name="model-evaluation-packages"
class="select select-bordered w-full h-48" class="select select-bordered w-full h-48"
multiple multiple
required
> >
<optgroup label="Reviewed Packages"> <optgroup label="Reviewed Packages">
{% for obj in meta.readable_packages %} {% for obj in meta.readable_packages %}

View File

@ -65,6 +65,7 @@
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
}, },
body: formData body: formData
}); });

View File

@ -18,7 +18,11 @@
this.isLoading = true; this.isLoading = true;
try { try {
const response = await fetch('{% url "package scenario list" meta.current_package.uuid %}'); const response = await fetch('{% url "package scenario list" meta.current_package.uuid %}', {
headers: {
'Accept': 'application/json'
}
});
const data = await response.json(); const data = await response.json();
this.scenarios = data; this.scenarios = data;
this.loaded = true; this.loaded = true;
@ -47,7 +51,13 @@
} }
}" }"
@close="reset()" @close="reset()"
x-init="$watch('$el.open', value => { if (value) loadScenarios(); })" x-init="
new MutationObserver(() => {
if ($el.hasAttribute('open')) {
loadScenarios();
}
}).observe($el, { attributes: true });
"
> >
<div class="modal-box max-w-4xl"> <div class="modal-box max-w-4xl">
<!-- Header --> <!-- Header -->
@ -102,7 +112,8 @@
</select> </select>
<label class="label"> <label class="label">
<span class="label-text-alt" <span class="label-text-alt"
>Hold Ctrl/Cmd to select multiple scenarios</span >Hold Ctrl/Cmd to select multiple scenarios. Ctrl/Cmd + click one
item to deselect it</span
> >
</label> </label>
</div> </div>

View File

@ -52,7 +52,7 @@
}" }"
@close="reset()" @close="reset()"
> >
<div class="modal-box"> <div class="modal-box max-w-2xl">
<!-- Header --> <!-- Header -->
<h3 class="text-lg font-bold">Set License</h3> <h3 class="text-lg font-bold">Set License</h3>

View File

@ -1,11 +1,10 @@
{% load static %}
<dialog <dialog
id="search_modal" id="search_modal"
class="modal @max-sm:modal-top justify-center" class="modal items-start sm:items-center"
x-data="searchModal()" x-data="searchModal()"
@close="reset()" @close="reset()"
> >
<div class="modal-box h-full w-lvw p-1 sm:h-8/12 sm:w-[85vw] sm:max-w-5xl"> <div class="modal-box mt-4 sm:mt-0 p-1 sm:h-8/12 sm:w-[85vw] sm:max-w-5xl">
<!-- Search Input and Mode Selector --> <!-- Search Input and Mode Selector -->
<div class="form-control mb-4 w-full shrink-0"> <div class="form-control mb-4 w-full shrink-0">
<div class="join m-0 w-full items-center p-3"> <div class="join m-0 w-full items-center p-3">
@ -43,7 +42,7 @@
type="button" type="button"
tabindex="0" tabindex="0"
popovertarget="search_dropdown_menu" popovertarget="search_dropdown_menu"
style="anchor-name: --1" style="anchor-name: --anchor-mode"
class="btn join-item btn-ghost" class="btn join-item btn-ghost"
> >
<span x-text="searchModeLabel"></span> <span x-text="searchModeLabel"></span>
@ -67,7 +66,7 @@
popover popover
x-ref="modeDropdown" x-ref="modeDropdown"
id="search_dropdown_menu" id="search_dropdown_menu"
style="position-anchor: --anchor-2" style="position-anchor: --anchor-mode"
> >
<li class="menu-title">Text</li> <li class="menu-title">Text</li>
<li> <li>
@ -495,8 +494,7 @@
</div> </div>
</div> </div>
<!-- Backdrop to close -->
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button>close</button> <button aria-label="close"></button>
</form> </form>
</dialog> </dialog>

View File

@ -181,6 +181,55 @@
</div> </div>
{% endif %} {% endif %}
{% if compound.half_lifes %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">Half-lives</div>
<div class="collapse-content">
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Scenario</th>
<th>Values</th>
</tr>
</thead>
<tbody>
{% for scenario, half_lifes in compound.half_lifes.items %}
<tr>
<td>
<a href="{{ scenario.url }}" class="hover:bg-base-200"
>{{ scenario.name }}
<i>({{ scenario.package.name }})</i></a
>
</td>
<td>
<table class="table-zebra table">
<tbody>
<tr>
<td>Scenario Type</td>
<td>{{ scenario.scenario_type }}</td>
</tr>
<tr>
<td>Half-life (days)</td>
<td>{{ half_lifes.0.dt50 }}</td>
</tr>
<tr>
<td>Model</td>
<td>{{ half_lifes.0.model }}</td>
</tr>
</tbody>
</table>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- External Identifiers --> <!-- External Identifiers -->
{% if compound.get_external_identifiers %} {% if compound.get_external_identifiers %}
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">

View File

@ -0,0 +1,185 @@
{% extends "framework_modern.html" %}
{% block content %}
{% block action_modals %}
{% if job.is_result_downloadable %}
{% include "modals/objects/download_job_result_modal.html" %}
{% endif %}
{% endblock action_modals %}
<div class="space-y-2 p-4">
<!-- Header Section -->
<div class="card bg-base-100">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-2xl">Job Status for {{ job.job_name }}</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% include "actions/objects/joblog.html" %}
{% endblock %}
</ul>
</div>
</div>
</div>
</div>
<!-- Description -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Description</div>
<div class="collapse-content">Status page for Job {{ job.job_name }}</div>
</div>
<!-- Job Status -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Job Status</div>
<div class="collapse-content">{{ job.status }}</div>
</div>
<!-- Job ID -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Job ID</div>
<div class="collapse-content">{{ job.task_id }}</div>
</div>
<!-- Job Result -->
{% if job.is_in_terminal_state %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Job Result</div>
<div class="collapse-content">
{% if job.job_name == 'engineer_pathways' %}
<div class="card bg-base-100">
<div class="card-body">
<p>Engineered Pathways:</p>
<ul class="menu bg-base-200 rounded-box w-full">
{% for engineered_url in job.parsed_result.0 %}
<li>
<a href="{{ engineered_url }}" class="hover:bg-base-300"
>{{ engineered_url }}</a
>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="card bg-base-100">
<div class="card-body">
<p>Predicted Pathways:</p>
<ul class="menu bg-base-200 rounded-box w-full">
{% for engineered_url in job.parsed_result.1 %}
<li>
<a href="{{ engineered_url }}" class="hover:bg-base-300"
>{{ engineered_url }}</a
>
</li>
{% endfor %}
</ul>
</div>
</div>
{% elif job.job_name == 'batch_predict' %}
<div
id="table-container"
class="overflow-x-auto overflow-y-auto max-h-96 border rounded-lg"
></div>
<script>
const input = `{{ job.task_result }}`;
function renderCsvTable(str) {
const lines = str
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const [headerLine, ...rows] = lines;
const headers = headerLine.split(",").map((h) => h.trim());
const table = document.createElement("table");
table.className = "table table-zebra w-full";
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
headers.forEach((h) => {
const th = document.createElement("th");
th.textContent = h;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
const tbody = document.createElement("tbody");
rows.forEach((rowStr) => {
console.log(rowStr.split(","));
console.log(headers);
const row = document.createElement("tr");
const cells = rowStr.split(",").map((c) => c.trim());
headers.forEach((_, i) => {
const td = document.createElement("td");
const value = cells[i] || "";
td.textContent = value;
row.appendChild(td);
});
console.log(row);
tbody.appendChild(row);
});
table.appendChild(thead);
table.appendChild(tbody);
return table;
}
document
.getElementById("table-container")
.appendChild(renderCsvTable(input));
</script>
{% else %}
{{ job.parsed_result }}
{% endif %}
</div>
</div>
{% endif %}
<script>
// Show actions button if there are actions
document.addEventListener("DOMContentLoaded", function () {
const actionsButton = document.getElementById("actionsButton");
const actionsList = actionsButton?.querySelector("ul");
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove("hidden");
}
});
</script>
</div>
{% endblock content %}

View File

@ -73,6 +73,7 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
<!-- Reaction Packages --> <!-- Reaction Packages -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
@ -96,9 +97,7 @@
<ul class="menu bg-base-100 rounded-box w-full"> <ul class="menu bg-base-100 rounded-box w-full">
{% for p in model.eval_packages.all %} {% for p in model.eval_packages.all %}
<li> <li>
<a href="{{ p.url }}" class="hover:bg-base-200" <a href="{{ p.url }}" class="hover:bg-base-200">{{ p.name }}</a>
>{{ p.name }}</a
>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -111,13 +110,14 @@
<div class="collapse-title text-xl font-medium">Model Status</div> <div class="collapse-title text-xl font-medium">Model Status</div>
<div class="collapse-content">{{ model.status }}</div> <div class="collapse-content">{{ model.status }}</div>
</div> </div>
{% endif %}
{% if model.ready_for_prediction %} {% if model.ready_for_prediction %}
<!-- Predict Panel --> <!-- Predict Panel -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked /> <input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Predict</div> <div class="collapse-title text-xl font-medium" id="predictTitle">
Predict
</div>
<div class="collapse-content"> <div class="collapse-content">
<div class="form-control"> <div class="form-control">
<div class="join w-full"> <div class="join w-full">
@ -136,7 +136,11 @@
</button> </button>
</div> </div>
</div> </div>
<div id="predictLoading" class="mt-2"></div> <div id="predictLoading" class="mt-2 flex hidden justify-center">
<div class="h-8 w-8">
{% include "components/loading-spinner.html" %}
</div>
</div>
<div id="predictResultTable" class="mt-4"></div> <div id="predictResultTable" class="mt-4"></div>
</div> </div>
</div> </div>
@ -167,12 +171,15 @@
</button> </button>
</div> </div>
</div> </div>
<div id="appDomainLoading" class="mt-2"></div> <div id="appDomainLoading" class="mt-2 flex hidden justify-center">
<div class="h-8 w-8">
{% include "components/loading-spinner.html" %}
</div>
</div>
<div id="appDomainAssessmentResultTable" class="mt-4"></div> <div id="appDomainAssessmentResultTable" class="mt-4"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if model.model_status == 'FINISHED' %} {% if model.model_status == 'FINISHED' %}
<!-- Single Gen Curve Panel --> <!-- Single Gen Curve Panel -->
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
@ -186,6 +193,19 @@
</div> </div>
</div> </div>
</div> </div>
{% if model.multigen_eval %}
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
Multi Gen Precision Recall Curve
</div>
<div class="collapse-content">
<div class="flex justify-center">
<div id="mg-chart"></div>
</div>
</div>
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
@ -193,7 +213,13 @@
{# FIXME: This is a hack to get the precision recall curve data into the JavaScript code. #} {# FIXME: This is a hack to get the precision recall curve data into the JavaScript code. #}
<script> <script>
function handlePredictionResponse(data) { function handlePredictionResponse(data) {
let res = "<table class='table table-zebra'>" let stereo = data["stereo"]
data = data["pred"]
let res = ""
if (stereo) {
res += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span><br>"
}
res += "<table class='table table-zebra'>"
res += "<thead>" res += "<thead>"
res += "<th scope='col'>#</th>" res += "<th scope='col'>#</th>"
@ -236,25 +262,7 @@
} }
} }
function makeLoadingGif(selector, gifPath) { function makeChart(selector, data) {
const element = document.querySelector(selector);
if (element) {
element.innerHTML = '<img src="' + gifPath + '" alt="Loading...">';
}
}
document.addEventListener('DOMContentLoaded', function() {
// Show actions button if there are actions
const actionsButton = document.getElementById('actionsButton');
const actionsList = actionsButton?.querySelector('ul');
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove('hidden');
}
{% if model.model_status == 'FINISHED' %}
// Precision Recall Curve
const sgChart = document.getElementById('sg-chart');
if (sgChart) {
const x = ['Recall']; const x = ['Recall'];
const y = ['Precision']; const y = ['Precision'];
const thres = ['threshold']; const thres = ['threshold'];
@ -277,7 +285,6 @@
return -1; return -1;
} }
var data = {{ model.pr_curve|safe }};
if (!data || data.length === 0) { if (!data || data.length === 0) {
console.warn('PR curve data is empty'); console.warn('PR curve data is empty');
return; return;
@ -292,7 +299,7 @@
thres.push(d.threshold); thres.push(d.threshold);
} }
const chart = c3.generate({ const chart = c3.generate({
bindto: '#sg-chart', bindto: selector,
data: { data: {
onclick: function (d, e) { onclick: function (d, e) {
const idx = d.index; const idx = d.index;
@ -353,6 +360,29 @@
} }
}); });
} }
function makeLoadingGif(selector, gifPath) {
const element = document.querySelector(selector);
if (element) {
element.innerHTML = '<img src="' + gifPath + '" alt="Loading...">';
}
}
document.addEventListener('DOMContentLoaded', function() {
// Show actions button if there are actions
const actionsButton = document.getElementById('actionsButton');
const actionsList = actionsButton?.querySelector('ul');
if (actionsList && actionsList.children.length > 0) {
actionsButton?.classList.remove('hidden');
}
{% if model.model_status == 'FINISHED' %}
// Precision Recall Curve
makeChart('#sg-chart', {{ model.pr_curve|safe }});
{% if model.multigen_eval %}
// Multi Gen Precision Recall Curve
makeChart('#mg-chart', {{ model.mg_pr_curve|safe }});
{% endif %}
{% endif %} {% endif %}
// Predict button handler // Predict button handler
@ -375,7 +405,8 @@
return; return;
} }
makeLoadingGif("#predictLoading", "{% static '/images/wait.gif' %}"); const loadingEl = document.getElementById("predictLoading");
if (loadingEl) loadingEl.classList.remove("hidden");
const params = new URLSearchParams({ const params = new URLSearchParams({
smiles: smiles, smiles: smiles,
@ -396,12 +427,12 @@
}) })
.then(data => { .then(data => {
const loadingEl = document.getElementById("predictLoading"); const loadingEl = document.getElementById("predictLoading");
if (loadingEl) loadingEl.innerHTML = ""; if (loadingEl) loadingEl.classList.add("hidden");
handlePredictionResponse(data); handlePredictionResponse(data);
}) })
.catch(error => { .catch(error => {
const loadingEl = document.getElementById("predictLoading"); const loadingEl = document.getElementById("predictLoading");
if (loadingEl) loadingEl.innerHTML = ""; if (loadingEl) loadingEl.classList.add("hidden");
const resultTable = document.getElementById("predictResultTable"); const resultTable = document.getElementById("predictResultTable");
if (resultTable) { if (resultTable) {
resultTable.classList.add("alert", "alert-error"); resultTable.classList.add("alert", "alert-error");
@ -431,7 +462,8 @@
return; return;
} }
makeLoadingGif("#appDomainLoading", "{% static '/images/wait.gif' %}"); const loadingEl = document.getElementById("appDomainLoading");
if (loadingEl) loadingEl.classList.remove("hidden");
const params = new URLSearchParams({ const params = new URLSearchParams({
smiles: smiles, smiles: smiles,
@ -452,7 +484,7 @@
}) })
.then(data => { .then(data => {
const loadingEl = document.getElementById("appDomainLoading"); const loadingEl = document.getElementById("appDomainLoading");
if (loadingEl) loadingEl.innerHTML = ""; if (loadingEl) loadingEl.classList.add("hidden");
if (typeof handleAssessmentResponse === 'function') { if (typeof handleAssessmentResponse === 'function') {
handleAssessmentResponse("{% url 'depict' %}", data); handleAssessmentResponse("{% url 'depict' %}", data);
} }
@ -460,7 +492,7 @@
}) })
.catch(error => { .catch(error => {
const loadingEl = document.getElementById("appDomainLoading"); const loadingEl = document.getElementById("appDomainLoading");
if (loadingEl) loadingEl.innerHTML = ""; if (loadingEl) loadingEl.classList.add("hidden");
const resultTable = document.getElementById("appDomainAssessmentResultTable"); const resultTable = document.getElementById("appDomainAssessmentResultTable");
if (resultTable) { if (resultTable) {
resultTable.classList.add("alert", "alert-error"); resultTable.classList.add("alert", "alert-error");

View File

@ -81,6 +81,7 @@
{% include "modals/objects/delete_pathway_node_modal.html" %} {% include "modals/objects/delete_pathway_node_modal.html" %}
{% include "modals/objects/delete_pathway_edge_modal.html" %} {% include "modals/objects/delete_pathway_edge_modal.html" %}
{% include "modals/objects/generic_delete_modal.html" %} {% include "modals/objects/generic_delete_modal.html" %}
{% include "modals/objects/engineer_pathway_modal.html" %}
{% endblock action_modals %} {% endblock action_modals %}
<div class="space-y-2 p-4"> <div class="space-y-2 p-4">
@ -89,35 +90,6 @@
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="card-title text-2xl">{{ pathway.name }}</h2> <h2 class="card-title text-2xl">{{ pathway.name }}</h2>
<div id="actionsButton" class="dropdown dropdown-end hidden">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-wrench"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
Actions
</div>
<ul
tabindex="-1"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
>
{% block actions %}
{% include "actions/objects/pathway.html" %}
{% endblock %}
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -132,7 +104,6 @@
<div class="bg-base-100 mb-2 rounded-lg p-2"> <div class="bg-base-100 mb-2 rounded-lg p-2">
<div class="navbar bg-base-100 rounded-lg"> <div class="navbar bg-base-100 rounded-lg">
<div class="flex-1"> <div class="flex-1">
{% if meta.can_edit %}
<div class="dropdown"> <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<svg <svg
@ -154,7 +125,7 @@
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
/> />
</svg> </svg>
Edit Actions
</div> </div>
<ul <ul
tabindex="0" tabindex="0"
@ -163,7 +134,6 @@
{% include "actions/objects/pathway.html" %} {% include "actions/objects/pathway.html" %}
</ul> </ul>
</div> </div>
{% endif %}
{% if pathway.setting.model.app_domain %} {% if pathway.setting.model.app_domain %}
<div class="dropdown"> <div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm"> <div tabindex="0" role="button" class="btn btn-ghost btn-sm">
@ -241,11 +211,23 @@
</div> </div>
</div> </div>
</div> </div>
<div id="vizdiv"> <div
{% if pathway.completed %} id="vizdiv"
<div class="tooltip tooltip-bottom absolute top-4 right-4 z-10"> x-data="pathwayViewer({
<div class="tooltip-content">Pathway prediction complete.</div> status: '{{ pathway.status }}',
modified: '{{ pathway.modified|date:"Y-m-d H:i:s" }}',
statusUrl: '{{ pathway.url }}?status=true',
emptyDueToThreshold: '{{ pathway.empty_due_to_threshold }}'
})"
x-init="init()"
>
{% if pathway.predicted %}
<!-- Status Display -->
<div class="tooltip tooltip-left absolute top-4 right-4 z-10">
<div class="tooltip-content" x-text="statusTooltip"></div>
<div id="status" class="flex items-center"> <div id="status" class="flex items-center">
<!-- Completed icon -->
<template x-if="status === 'completed'">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -260,12 +242,10 @@
> >
<path d="M20 6 9 17l-5-5" /> <path d="M20 6 9 17l-5-5" />
</svg> </svg>
</div> </template>
</div>
{% elif pathway.failed %} <!-- Failed icon -->
<div class="tooltip tooltip-bottom absolute top-4 right-4 z-10"> <template x-if="status === 'failed'">
<div class="tooltip-content">Pathway prediction failed.</div>
<div id="status" class="flex items-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -281,19 +261,37 @@
<path d="M18 6 6 18" /> <path d="M18 6 6 18" />
<path d="M6 6l12 12" /> <path d="M6 6l12 12" />
</svg> </svg>
</div> </template>
</div> <!-- Loading spinner -->
{% else %}
<div class="tooltip tooltip-bottom absolute top-4 right-4 z-10">
<div class="tooltip-content">Pathway prediction running.</div>
<div id="status" class="flex items-center">
<div <div
id="status-loading-spinner" x-show="status === 'running'"
style="width: 20px; height: 20px;" style="width: 20px; height: 20px;"
></div> >
{% include "components/loading-spinner.html" %}
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Update Notice -->
<div
x-show="showUpdateNotice"
x-cloak
class="alert alert-info absolute right-4 bottom-4 left-4 z-10"
>
<span x-html="updateMessage"></span>
<button @click="reloadPage()" class="btn btn-primary btn-sm mt-2">
Reload page
</button>
</div>
<!-- Empty due to Threshold notice -->
<div
x-show="showEmptyDueToThresholdNotice"
x-cloak
class="alert alert-info absolute right-4 bottom-4 left-4 z-10"
>
<span x-html="emptyDueToThresholdMessage"></span>
</div>
<svg id="pwsvg"> <svg id="pwsvg">
<defs> <defs>
<marker <marker
@ -390,81 +388,14 @@
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-xl font-medium">Setting</div> <div class="collapse-title text-xl font-medium">Setting</div>
<div class="collapse-content"> <div class="collapse-content">
<div class="overflow-x-auto"> {% with setting_to_render=pathway.setting can_be_default=False %}
<table class="table-zebra table"> {% include "objects/setting_template.html" %}
<thead> {% endwith %}
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% if pathway.setting.model %}
<tr>
<td>Model</td>
<td>
<div class="space-y-2">
<div>
<a
href="{{ pathway.setting.model.url }}"
class="link link-primary"
>
{{ pathway.setting.model.name }}
</a>
</div>
<div class="overflow-x-auto">
<table class="table-xs table">
<thead>
<tr>
<th>Model Parameter</th>
<th>Parameter Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Threshold</td>
<td>{{ pathway.setting.model_threshold }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</td>
</tr>
{% endif %}
{% if pathway.setting.rule_packages.all %}
<tr>
<td>Rule Packages</td>
<td>
<ul class="menu bg-base-100 rounded-box">
{% for p in pathway.setting.rule_packages.all %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-200"
>{{ p.name }}</a
>
</li>
{% endfor %}
</ul>
</td>
</tr>
{% endif %}
<tr>
<td>Max Nodes</td>
<td>{{ pathway.setting.max_nodes }}</td>
</tr>
<tr>
<td>Max Depth</td>
<td>{{ pathway.setting.max_depth }}</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{# prettier-ignore-start #} {{ pathway.d3_json|json_script:"pathway" }}
{# FIXME: This is a hack to get the pathway data into the JavaScript code. #}
<script> <script>
// Global switch for app domain view // Global switch for app domain view
@ -480,117 +411,81 @@
} }
function transformReferences(text) { function transformReferences(text) {
return text.replace(/\[\s*(http[^\]|]+)\s*\|\s*([^\]]+)\s*\]/g, '<a target="parent" href="$1">$2</a>'); return text.replace(
/\[\s*(http[^\]|]+)\s*\|\s*([^\]]+)\s*\]/g,
'<a target="parent" href="$1">$2</a>',
);
} }
var pathway = JSON.parse(document.getElementById("pathway").textContent);
var pathway = {{ pathway.d3_json | safe }}; document.addEventListener("DOMContentLoaded", function () {
draw(pathway, "vizdiv");
document.addEventListener('DOMContentLoaded', function() {
// Initialize loading spinner if pathway is running
if (pathway.status === 'running') {
const spinnerContainer = document.getElementById('status-loading-spinner');
if (spinnerContainer) {
showLoadingSpinner(spinnerContainer);
}
}
// If prediction is still running, regularly check status
if (pathway.status === 'running') {
let last_modified = pathway.modified;
let pollInterval = setInterval(async () => {
try {
const response = await fetch("{{ pathway.url }}?status=true", {});
const data = await response.json();
if (data.modified > last_modified) {
var msg = 'Prediction ';
var btn = '<button type="button" onclick="location.reload()" class="btn btn-primary btn-sm mt-2" id="reloadBtn">Reload page</button>';
if (data.status === "running") {
msg += 'is still running. But the Pathway was updated.<br>' + btn;
} else if (data.status === "completed") {
msg += 'is completed. Reload the page to see the updated Pathway.<br>' + btn;
} else if (data.status === "failed") {
msg += 'failed. Reload the page to see the current shape.<br>' + btn;
}
showStatusPopover(msg);
}
if (data.status === "completed" || data.status === "failed") {
const statusBtn = document.getElementById('status');
const tooltipContent = statusBtn.parentElement.querySelector('.tooltip-content');
const spinner = statusBtn.querySelector('#status-loading-spinner');
if (spinner) spinner.remove();
if (data.status === "completed") {
statusBtn.innerHTML = `<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-check"><path d="M20 6 9 17l-5-5"/></svg>`;
tooltipContent.textContent = 'Pathway prediction complete.';
} else {
statusBtn.innerHTML = `<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-x"><path d="M18 6 6 18"/><path d="M6 6l12 12"/></svg>`;
tooltipContent.textContent = 'Pathway prediction failed.';
}
clearInterval(pollInterval);
}
} catch (err) {
console.error("Polling error:", err);
}
}, 5000);
}
draw(pathway, 'vizdiv');
// Transform references in description // Transform references in description
const descContent = document.getElementById('DescriptionContent'); const descContent = document.getElementById("DescriptionContent");
if (descContent) { if (descContent) {
const newDesc = transformReferences(descContent.innerText); const newDesc = transformReferences(descContent.innerText);
descContent.innerHTML = newDesc; descContent.innerHTML = newDesc;
} }
// App domain toggle // App domain toggle
const appDomainBtn = document.getElementById('app-domain-toggle-button'); const appDomainBtn = document.getElementById("app-domain-toggle-button");
if (appDomainBtn) { if (appDomainBtn) {
appDomainBtn.addEventListener('click', function() { appDomainBtn.addEventListener("click", function () {
appDomainViewEnabled = !appDomainViewEnabled; appDomainViewEnabled = !appDomainViewEnabled;
const icon = document.getElementById('app-domain-icon'); const icon = document.getElementById("app-domain-icon");
if (appDomainViewEnabled) { if (appDomainViewEnabled) {
// Change to eye-off icon // Change to eye-off icon
icon.innerHTML = '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>'; icon.innerHTML =
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
nodes.forEach((x) => { nodes.forEach((x) => {
if (x.app_domain) { if (x.app_domain) {
if (x.app_domain.inside_app_domain) { if (x.app_domain.inside_app_domain) {
d3.select(x.el).select("circle").classed("inside_app_domain", true); d3.select(x.el)
.select("circle")
.classed("inside_app_domain", true);
} else { } else {
d3.select(x.el).select("circle").classed("outside_app_domain", true); d3.select(x.el)
.select("circle")
.classed("outside_app_domain", true);
} }
} }
}); });
links.forEach((x) => { links.forEach((x) => {
if (x.app_domain) { if (x.app_domain) {
if (x.app_domain.passes_app_domain) { if (x.app_domain.passes_app_domain) {
d3.select(x.el).attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow_passes_app_domain)"); d3.select(x.el).attr("marker-end", (d) =>
d.target.pseudo ? "" : "url(#arrow_passes_app_domain)",
);
d3.select(x.el).classed("passes_app_domain", true); d3.select(x.el).classed("passes_app_domain", true);
} else { } else {
d3.select(x.el).attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow_fails_app_domain)"); d3.select(x.el).attr("marker-end", (d) =>
d.target.pseudo ? "" : "url(#arrow_fails_app_domain)",
);
d3.select(x.el).classed("fails_app_domain", true); d3.select(x.el).classed("fails_app_domain", true);
} }
} }
}); });
} else { } else {
// Change back to eye icon // Change back to eye icon
icon.innerHTML = '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>'; icon.innerHTML =
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
nodes.forEach((x) => { nodes.forEach((x) => {
d3.select(x.el).select("circle").classed("inside_app_domain", false); d3.select(x.el)
d3.select(x.el).select("circle").classed("outside_app_domain", false); .select("circle")
.classed("inside_app_domain", false);
d3.select(x.el)
.select("circle")
.classed("outside_app_domain", false);
}); });
links.forEach((x) => { links.forEach((x) => {
d3.select(x.el).attr("marker-end", d => d.target.pseudo ? "" : "url(#arrow)"); d3.select(x.el).attr("marker-end", (d) =>
d.target.pseudo ? "" : "url(#arrow)",
);
d3.select(x.el).classed("passes_app_domain", false); d3.select(x.el).classed("passes_app_domain", false);
d3.select(x.el).classed("fails_app_domain", false); d3.select(x.el).classed("fails_app_domain", false);
}); });
@ -605,7 +500,5 @@
actionsButton?.classList.remove("hidden"); actionsButton?.classList.remove("hidden");
} }
}); });
</script> </script>
{# prettier-ignore-end #}
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,92 @@
<div class="overflow-x-auto rounded-box shadow-md bg-base-100">
<table class="table table-fixed w-full">
<thead class="text-base">
<tr>
<th class="w-1/5">Parameter</th>
<th>Value</th>
{% if can_be_default %}
<th class="text-right">
<form method="post" action="{% url 'user' user.uuid %}">
{% csrf_token %}
<input
type="hidden"
name="change_default"
value="{{ setting_to_render.uuid }}"
/>
<button type="submit" class="btn">Make Default</button>
</form>
</th>
{% endif %}
</tr>
</thead>
<tbody>
<tr>
<td>Setting Name</td>
<td>{{ setting_to_render.name }}</td>
</tr>
{% if setting_to_render.description %}
<tr>
<td>Setting Description</td>
<td>{{ setting_to_render.description }}</td>
</tr>
{% endif %}
{% if setting_to_render.model %}
<tr>
<td>Model</td>
<td>
<div class="space-y-2">
<a
href="{{ setting_to_render.model.url }}"
class="link link-primary"
>
{{ setting_to_render.model.name }}
</a>
<table class="table-xs table">
<thead>
<tr>
<th>Model Parameter</th>
<th>Parameter Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Threshold</td>
<td>{{ setting_to_render.model_threshold }}</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
{% endif %}
{% if setting_to_render.rule_packages.all %}
<tr>
<td>Rule Packages</td>
<td>
<ul class="menu bg-base-200 rounded-box">
{% for p in setting_to_render.rule_packages.all %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-300"
>{{ p.name }}</a
>
</li>
{% endfor %}
</ul>
</td>
</tr>
{% endif %}
<tr>
<td>Max Nodes</td>
<td>{{ setting_to_render.max_nodes }}</td>
</tr>
<tr>
<td>Max Depth</td>
<td>{{ setting_to_render.max_depth }}</td>
</tr>
<tr>
<td>Expansion Scheme</td>
<td>{{ setting_to_render.expansion_scheme }}</td>
</tr>
</tbody>
</table>
</div>

View File

@ -88,71 +88,26 @@
Current Prediction Setting Current Prediction Setting
</div> </div>
<div class="collapse-content"> <div class="collapse-content">
<div class="overflow-x-auto"> {% with setting_to_render=user.default_setting can_be_default=False %}
<table class="table-zebra table"> {% include "objects/setting_template.html" %}
<thead> {% endwith %}
<tr>
<th>Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% if user.default_setting.model %}
<tr>
<td>Model</td>
<td>
<div class="space-y-2">
<a
href="{{ user.default_setting.model.url }}"
class="link link-primary"
>
{{ user.default_setting.model.name }}
</a>
<table class="table-xs table">
<thead>
<tr>
<th>Model Parameter</th>
<th>Parameter Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Threshold</td>
<td>{{ user.default_setting.model_threshold }}</td>
</tr>
</tbody>
</table>
</div> </div>
</td> </div>
</tr>
<!-- Other Prediction Settings -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
Other Prediction Settings
</div>
<div class="collapse-content space-y-3">
{% for setting in meta.available_settings %}
{% if setting != user.default_setting %}
{% with setting_to_render=setting can_be_default=True %}
{% include "objects/setting_template.html" %}
{% endwith %}
{% endif %} {% endif %}
{% if user.default_setting.rule_packages.all %}
<tr>
<td>Rule Packages</td>
<td>
<ul class="menu bg-base-200 rounded-box">
{% for p in user.default_setting.rule_packages.all %}
<li>
<a href="{{ p.url }}" class="hover:bg-base-300"
>{{ p.name }}</a
>
</li>
{% endfor %} {% endfor %}
</ul>
</td>
</tr>
{% endif %}
<tr>
<td>Max Nodes</td>
<td>{{ user.default_setting.max_nodes }}</td>
</tr>
<tr>
<td>Max Depth</td>
<td>{{ user.default_setting.max_depth }}</td>
</tr>
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -49,7 +49,7 @@
<a <a
href="https://community.envipath.org/" href="https://community.envipath.org/"
target="_blank" target="_blank"
class="btn btn-secondary" class="btn btn-neutral"
>Visit Forums</a >Visit Forums</a
> >
</div> </div>
@ -81,7 +81,7 @@
<a <a
href="https://wiki.envipath.org/" href="https://wiki.envipath.org/"
target="_blank" target="_blank"
class="btn btn-accent" class="btn btn-neutral"
>Read Docs</a >Read Docs</a
> >
</div> </div>

View File

@ -124,7 +124,7 @@
</div> </div>
</div> </div>
<form method="post" action="{% url 'login' %}" class="space-y-4"> <form method="post" action="{% url 'register' %}" class="space-y-4">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="register" value="true" /> <input type="hidden" name="register" value="true" />

View File

@ -0,0 +1,69 @@
import os
from django.conf import settings as s
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.core.management import call_command
from django.test import override_settings
from playwright.sync_api import sync_playwright
@override_settings(MODEL_DIR=s.FIXTURE_DIRS[0] / "models", CELERY_TASK_ALWAYS_EAGER=True)
class EnviPyStaticLiveServerTestCase(StaticLiveServerTestCase):
fixtures = ["test_fixtures_incl_model.jsonl.gz"]
@staticmethod
def repair_polymorphic_ctypes():
from django.contrib.contenttypes.models import ContentType
from epdb.models import EPModel
for obj in EPModel.objects.filter(polymorphic_ctype__isnull=True):
obj.polymorphic_ctype = ContentType.objects.get_for_model(obj.__class__)
obj.save(update_fields=["polymorphic_ctype"])
@classmethod
def setUpClass(cls):
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
super().setUpClass()
cls.playwright = sync_playwright().start()
cls.browser = cls.playwright.chromium.launch()
cls.username = "user0"
cls.password = "SuperSafe"
def setUp(self):
# DB gets flushed after each test and rolled back to initial fixture state.
# Hence, we have to localize the urls per test.
# The fixtures have "http://localhost:8000/" in all of the URLs
# Use the custom mgmt command to adjust it to the current live_server_url
call_command("localize_urls", old="http://localhost:8000/", new=f"{self.live_server_url}/")
# Fix broken polymorphic ctypes
EnviPyStaticLiveServerTestCase.repair_polymorphic_ctypes()
s.SERVER_URL = self.live_server_url
self.context = self.browser.new_context()
self.page = self.context.new_page()
def tearDown(self):
self.page.wait_for_load_state("networkidle")
self.page.close()
@classmethod
def tearDownClass(cls):
cls.browser.close()
cls.playwright.stop()
super().tearDownClass()
def login(self):
"""Sign in with the test user, 'user0'"""
self.page.goto(self.live_server_url + "/login")
self.page.get_by_role("textbox", name="Username").click()
self.page.get_by_role("textbox", name="Username").fill(self.username)
self.page.get_by_role("textbox", name="Password").click()
self.page.get_by_role("textbox", name="Password").fill(self.password)
with self.page.expect_navigation():
self.page.get_by_role("button", name="Sign In").click()
return self.page

View File

@ -1,108 +1,38 @@
import os
from django.conf import settings as s
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.test import tag from django.test import tag
from playwright.sync_api import expect, sync_playwright from playwright.sync_api import expect
from epdb.logic import UserManager from .frontend_base import EnviPyStaticLiveServerTestCase
from epdb.models import User, ExternalDatabase
class TestHomepage(StaticLiveServerTestCase): class TestHomepage(EnviPyStaticLiveServerTestCase):
@classmethod @tag("frontend")
def setUpClass(cls): def test_predict(self):
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" page = self.login()
super().setUpClass() page.get_by_role("textbox", name="canonical SMILES string").click()
cls.playwright = sync_playwright().start() page.get_by_role("textbox", name="canonical SMILES string").fill("CCCN")
cls.browser = cls.playwright.chromium.launch() page.get_by_role("button", name="Predict!").click()
# Check that the pathway box is visible
def setUp(self): expect(page.locator("rect")).to_be_visible(timeout=10000)
# Create test data
s.SERVER_URL = self.live_server_url
self.anonymous = UserManager.create_user(
"anonymous",
"anon@envipath.com",
"SuperSafe",
is_active=True,
add_to_group=False,
set_setting=False,
)
databases = [
{
"name": "PubChem Compound",
"full_name": "PubChem Compound Database",
"description": "Chemical database of small organic molecules",
"base_url": "https://pubchem.ncbi.nlm.nih.gov",
"url_pattern": "https://pubchem.ncbi.nlm.nih.gov/compound/{id}",
},
{
"name": "PubChem Substance",
"full_name": "PubChem Substance Database",
"description": "Database of chemical substances",
"base_url": "https://pubchem.ncbi.nlm.nih.gov",
"url_pattern": "https://pubchem.ncbi.nlm.nih.gov/substance/{id}",
},
{
"name": "ChEBI",
"full_name": "Chemical Entities of Biological Interest",
"description": "Dictionary of molecular entities",
"base_url": "https://www.ebi.ac.uk/chebi",
"url_pattern": "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:{id}",
},
{
"name": "RHEA",
"full_name": "RHEA Reaction Database",
"description": "Comprehensive resource of biochemical reactions",
"base_url": "https://www.rhea-db.org",
"url_pattern": "https://www.rhea-db.org/rhea/{id}",
},
{
"name": "KEGG Reaction",
"full_name": "KEGG Reaction Database",
"description": "Database of biochemical reactions",
"base_url": "https://www.genome.jp",
"url_pattern": "https://www.genome.jp/entry/{id}",
},
{
"name": "UniProt",
"full_name": "MetaCyc Metabolic Pathway Database",
"description": "UniProt is a freely accessible database of protein sequence and functional information",
"base_url": "https://www.uniprot.org",
"url_pattern": 'https://www.uniprot.org/uniprotkb?query="{id}"',
},
]
for db_info in databases:
ExternalDatabase.objects.get_or_create(name=db_info["name"], defaults=db_info)
self.username = "testuser"
self.password = "password123"
self.user = User.objects.create_user(username=self.username, password=self.password)
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.browser.close()
cls.playwright.stop()
@tag("frontend") @tag("frontend")
def test_login(self): def test_advanced_predict(self):
page = self.login() page = self.login()
expect(page.locator("#loggedInButton")).to_be_visible() page.get_by_role("link", name="Advanced").click()
page.close() # Check predict page opens correctly
expect(page.get_by_role("heading", name="Predict a Pathway in")).to_be_visible()
page.get_by_role("textbox", name="Name").click()
page.get_by_role("textbox", name="Name").fill("Test Pathway")
page.get_by_role("textbox", name="Description").click()
page.get_by_role("textbox", name="Description").fill("Test Description")
page.get_by_role("textbox", name="SMILES").click()
page.get_by_role("textbox", name="SMILES").fill("OCCCN")
page.locator("#predict-submit-button").click()
# Check that the pathway box is visible
expect(page.locator("rect")).to_be_visible(timeout=10000)
@tag("frontend") @tag("frontend")
def test_go_home(self) -> None: def test_go_home(self) -> None:
page = self.login() page = self.login()
page.get_by_role("link").first.click() page.get_by_role("link").first.click()
# Check the homepage predict box is visible
expect(page.get_by_text("SMILES Draw Predict! Caffeine")).to_be_visible() expect(page.get_by_text("SMILES Draw Predict! Caffeine")).to_be_visible()
def login(self):
page = self.browser.new_page()
page.goto(self.live_server_url + "/login/")
page.get_by_role("textbox", name="Username").click()
page.get_by_role("textbox", name="Username").fill(self.username)
page.get_by_role("textbox", name="Password").click()
page.get_by_role("textbox", name="Password").fill(self.password)
page.get_by_role("button", name="Sign In").click()
return page

View File

@ -0,0 +1,51 @@
from django.test import tag
from playwright.sync_api import expect
from django.conf import settings as s
from .frontend_base import EnviPyStaticLiveServerTestCase
class TestLoginPage(EnviPyStaticLiveServerTestCase):
@tag("frontend")
def test_register(self):
page = self.page
page.goto(self.live_server_url + "/login")
page.get_by_text("Register", exact=True).click()
page.get_by_role("textbox", name="Username").click()
page.get_by_role("textbox", name="Username").fill("newuser")
page.get_by_role("textbox", name="Email").click()
page.get_by_role("textbox", name="Email").fill("newuser@new.com")
page.get_by_role("textbox", name="Password", exact=True).click()
page.get_by_role("textbox", name="Password", exact=True).fill("NewUser_1")
page.get_by_role("textbox", name="Repeat Password").click()
page.get_by_role("textbox", name="Repeat Password").fill("NewUser_1")
page.get_by_role("button", name="Sign Up").click()
if s.ADMIN_APPROVAL_REQUIRED:
expected_text = "Your account has been created! An admin will activate it soon!"
else:
expected_text = (
"Account has been created! You'll receive a mail to activate your account shortly."
)
# Check for success text after Sign Up is clicked
expect(page.get_by_text(expected_text)).to_be_visible(timeout=10000)
if s.ADMIN_APPROVAL_REQUIRED:
from django.contrib.auth import get_user_model
u = get_user_model().objects.get(username="newuser")
u.is_active = True
u.save()
page.get_by_role("textbox", name="Username").click()
page.get_by_role("textbox", name="Username").fill("newuser")
page.get_by_role("textbox", name="Password").click()
page.get_by_role("textbox", name="Password").fill("NewUser_1")
page.get_by_role("button", name="Sign In").click()
# Check that the logged in button is visible indicating the user is logged in
expect(page.locator("#loggedInButton")).to_be_visible(timeout=100000000)
@tag("frontend")
def test_login(self):
page = self.login()
# Check that the logged in button is visible indicating the user is logged in
expect(page.locator("#loggedInButton")).to_be_visible()

View File

@ -0,0 +1,67 @@
import re
from django.test import tag
from playwright.sync_api import expect
from .frontend_base import EnviPyStaticLiveServerTestCase
class TestPackagePage(EnviPyStaticLiveServerTestCase):
@tag("frontend")
def test_create_package(self):
page = self.login()
page = self.create_package(page)
# Check the package name is correct
expect(page.locator("h2")).to_contain_text("test package")
@tag("frontend")
def test_package_permissions(self):
page = self.login()
page = self.create_package(page)
page.get_by_role("button", name="Actions").click()
page.get_by_role("button", name="Edit Permissions").click()
# Add read and write permission to enviPath Users group
page.locator("#select_grantee").select_option(label="enviPath Users")
page.locator("#read_new").check()
page.locator("#write_new").check()
page.get_by_role("button", name="+", exact=True).click()
page.get_by_role("button", name="Actions").click()
page.get_by_role("button", name="Edit Permissions").click()
# Check the permissions saved when re-opening the permissions box
expect(page.get_by_text("enviPath Users")).to_be_visible()
@tag("frontend")
def test_predict_in_package(self):
page = self.login()
page = self.create_package(page)
pathway_button = page.get_by_role("link", name="Pathways")
# Find number of current pathways by extracting it from pathway button
num_pathways = int(re.search(r"Pathways \((\d+)\)", pathway_button.inner_text()).group(1))
pathway_button.click()
page.get_by_role("link", name="New Pathway").click()
# Check that the predict page 'in [package_name]' text shows the current package
expect(page.get_by_role("strong").get_by_text("test package")).to_be_visible()
page.get_by_role("textbox", name="Name").click()
page.get_by_role("textbox", name="Name").fill("Test Pathway")
page.get_by_role("textbox", name="Description").click()
page.get_by_role("textbox", name="Description").fill("Test description")
page.get_by_role("textbox", name="SMILES").click()
page.get_by_role("textbox", name="SMILES").fill("OCCCN")
page.locator("#predict-submit-button").click()
# Check a pathway is visible
expect(page.locator("rect")).to_be_visible()
page.get_by_role("link", name="test package").click()
# Check that the package now has one more pathway than initially
expect(page.locator("#docContent")).to_contain_text(f"Pathways ({num_pathways + 1})")
@staticmethod
def create_package(page):
"""Make a new empty package with name 'test package'"""
page.get_by_role("button", name="Browse").click()
page.get_by_role("link", name="Package", exact=True).click()
page.locator("#new-package-button").click()
page.get_by_role("textbox", name="Name").click()
page.get_by_role("textbox", name="Name").fill("test package")
page.get_by_role("textbox", name="Description").click()
page.get_by_role("textbox", name="Description").fill("test description")
page.get_by_role("button", name="Submit").click()
return page

View File

@ -1,11 +1,12 @@
from django.test import TestCase from django.conf import settings as s
from django.test import TestCase, override_settings
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Compound, User, CompoundStructure from epdb.models import Compound, User, CompoundStructure
@override_settings(MODEL_DIR=s.FIXTURE_DIRS[0] / "models", CELERY_TASK_ALWAYS_EAGER=True)
class CompoundTest(TestCase): class CompoundTest(TestCase):
fixtures = ["test_fixtures.jsonl.gz"] fixtures = ["test_fixtures_incl_model.jsonl.gz"]
def setUp(self): def setUp(self):
pass pass

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