forked from enviPath/enviPy
Compare commits
18 Commits
1a2c9bb543
...
6499a0c659
| Author | SHA1 | Date | |
|---|---|---|---|
| 6499a0c659 | |||
| 7c60a28801 | |||
| a4a4179261 | |||
| 6ee4ac535a | |||
| d6065ee888 | |||
| 9db4806d75 | |||
| 4bf20e62ef | |||
| 8adb93012a | |||
| d2d475b990 | |||
| 648ec150a9 | |||
| 46b0f1c124 | |||
| d5af898053 | |||
| b7379b3337 | |||
| d6440f416c | |||
| 901de4640c | |||
| 69df139256 | |||
| e8ae494c16 | |||
| fd2e2c2534 |
@ -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
|
||||||
|
|||||||
72
.gitea/actions/setup-envipy/action.yaml
Normal file
72
.gitea/actions/setup-envipy/action.yaml
Normal 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
|
||||||
53
.gitea/docker/Dockerfile.ci
Normal file
53
.gitea/docker/Dockerfile.ci
Normal 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
|
||||||
86
.gitea/workflows/api-ci.yaml
Normal file
86
.gitea/workflows/api-ci.yaml
Normal 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
|
||||||
48
.gitea/workflows/build-ci-image.yaml
Normal file
48
.gitea/workflows/build-ci-image.yaml
Normal 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
|
||||||
@ -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
371
.gitignore
vendored
@ -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
|
||||||
|
._*
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
0
epapi/__init__.py
Normal file
6
epapi/apps.py
Normal file
6
epapi/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EpapiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "epapi"
|
||||||
0
epapi/migrations/__init__.py
Normal file
0
epapi/migrations/__init__.py
Normal file
1
epapi/tests/__init__.py
Normal file
1
epapi/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Tests for epapi app
|
||||||
1
epapi/tests/v1/__init__.py
Normal file
1
epapi/tests/v1/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Tests for epapi v1 API
|
||||||
532
epapi/tests/v1/test_api_permissions.py
Normal file
532
epapi/tests/v1/test_api_permissions.py
Normal 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)
|
||||||
477
epapi/tests/v1/test_contract_get_entities.py
Normal file
477
epapi/tests/v1/test_contract_get_entities.py
Normal 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
0
epapi/v1/__init__.py
Normal file
8
epapi/v1/auth.py
Normal file
8
epapi/v1/auth.py
Normal 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
95
epapi/v1/dal.py
Normal 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
|
||||||
0
epapi/v1/endpoints/__init__.py
Normal file
0
epapi/v1/endpoints/__init__.py
Normal file
41
epapi/v1/endpoints/compounds.py
Normal file
41
epapi/v1/endpoints/compounds.py
Normal 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()
|
||||||
41
epapi/v1/endpoints/models.py
Normal file
41
epapi/v1/endpoints/models.py
Normal 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()
|
||||||
27
epapi/v1/endpoints/packages.py
Normal file
27
epapi/v1/endpoints/packages.py
Normal 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()
|
||||||
42
epapi/v1/endpoints/pathways.py
Normal file
42
epapi/v1/endpoints/pathways.py
Normal 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()
|
||||||
42
epapi/v1/endpoints/reactions.py
Normal file
42
epapi/v1/endpoints/reactions.py
Normal 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()
|
||||||
42
epapi/v1/endpoints/rules.py
Normal file
42
epapi/v1/endpoints/rules.py
Normal 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()
|
||||||
36
epapi/v1/endpoints/scenarios.py
Normal file
36
epapi/v1/endpoints/scenarios.py
Normal 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()
|
||||||
50
epapi/v1/endpoints/structure.py
Normal file
50
epapi/v1/endpoints/structure.py
Normal 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
28
epapi/v1/errors.py
Normal 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
60
epapi/v1/pagination.py
Normal 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
22
epapi/v1/router.py
Normal 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
104
epapi/v1/schemas.py
Normal 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"
|
||||||
@ -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 = []
|
||||||
|
|
||||||
|
|||||||
283
epdb/logic.py
283
epdb/logic.py
@ -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,6 +1554,207 @@ class SPathway(object):
|
|||||||
|
|
||||||
return sorted(res, key=lambda x: hash(x))
|
return sorted(res, key=lambda x: hash(x))
|
||||||
|
|
||||||
|
def _expand(self, substrates: List[SNode]) -> Tuple[List[SNode], List[SEdge]]:
|
||||||
|
"""
|
||||||
|
Expands the given substrates by generating new nodes and edges based on prediction settings.
|
||||||
|
|
||||||
|
This method processes a list of substrates and expands them into new nodes and edges using defined
|
||||||
|
rules and settings. It evaluates each substrate to determine its applicability domain, persists
|
||||||
|
domain assessments, and generates candidates for further processing. Newly created nodes and edges
|
||||||
|
are returned, and any applicable information is stored or updated internally during the process.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
substrates (List[SNode]): A list of substrate nodes to be expanded.
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
for sub in substrates:
|
||||||
|
# For App Domain we have to ensure that each Node is evaluated
|
||||||
|
if sub.app_domain_assessment is None:
|
||||||
|
if self.prediction_setting.model:
|
||||||
|
if self.prediction_setting.model.app_domain:
|
||||||
|
app_domain_assessment = self.prediction_setting.model.app_domain.assess(
|
||||||
|
sub.smiles
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.persist is not None:
|
||||||
|
n = self.snode_persist_lookup[sub]
|
||||||
|
|
||||||
|
if n.id is None:
|
||||||
|
raise ValueError(f"Node {n} has no ID... aborting!")
|
||||||
|
|
||||||
|
node_data = n.simple_json()
|
||||||
|
node_data["image"] = f"{n.url}?image=svg"
|
||||||
|
app_domain_assessment["assessment"]["node"] = node_data
|
||||||
|
|
||||||
|
n.kv["app_domain_assessment"] = app_domain_assessment
|
||||||
|
n.save()
|
||||||
|
|
||||||
|
sub.app_domain_assessment = app_domain_assessment
|
||||||
|
|
||||||
|
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
|
||||||
|
for cand_set in expansion_result["transformations"]:
|
||||||
|
if cand_set:
|
||||||
|
# cand_set is a PredictionResult object that can consist of multiple candidate reactions
|
||||||
|
for cand in cand_set:
|
||||||
|
cand_nodes = []
|
||||||
|
# candidate reactions can have multiple fragments
|
||||||
|
for c in cand:
|
||||||
|
if c not in self.smiles_to_node:
|
||||||
|
# For new nodes do an AppDomain Assessment if an AppDomain is attached
|
||||||
|
app_domain_assessment = None
|
||||||
|
if self.prediction_setting.model:
|
||||||
|
if self.prediction_setting.model.app_domain:
|
||||||
|
app_domain_assessment = (
|
||||||
|
self.prediction_setting.model.app_domain.assess(c)
|
||||||
|
)
|
||||||
|
snode = SNode(c, sub.depth + 1, app_domain_assessment)
|
||||||
|
self.smiles_to_node[c] = snode
|
||||||
|
new_nodes.append(snode)
|
||||||
|
|
||||||
|
node = self.smiles_to_node[c]
|
||||||
|
cand_nodes.append(node)
|
||||||
|
|
||||||
|
edge = SEdge(
|
||||||
|
sub,
|
||||||
|
cand_nodes,
|
||||||
|
rule=cand_set.rule,
|
||||||
|
probability=cand_set.probability,
|
||||||
|
)
|
||||||
|
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):
|
def predict_step(self, from_depth: int = None, from_node: "Node" = None):
|
||||||
substrates: List[SNode] = []
|
substrates: List[SNode] = []
|
||||||
|
|
||||||
@ -1557,67 +1765,15 @@ class SPathway(object):
|
|||||||
if from_node == v:
|
if from_node == v:
|
||||||
substrates = [k]
|
substrates = [k]
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Node {from_node} not found in SPathway!")
|
||||||
else:
|
else:
|
||||||
raise ValueError("Neither from_depth nor from_node_url specified")
|
raise ValueError("Neither from_depth nor from_node_url specified")
|
||||||
|
|
||||||
new_tp = False
|
new_tp = False
|
||||||
if substrates:
|
if substrates:
|
||||||
for sub in substrates:
|
new_nodes, _ = self._expand(substrates)
|
||||||
if sub.app_domain_assessment is None:
|
new_tp = len(new_nodes) > 0
|
||||||
if self.prediction_setting.model:
|
|
||||||
if self.prediction_setting.model.app_domain:
|
|
||||||
app_domain_assessment = self.prediction_setting.model.app_domain.assess(
|
|
||||||
sub.smiles
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.persist is not None:
|
|
||||||
n = self.snode_persist_lookup[sub]
|
|
||||||
|
|
||||||
assert n.id is not None, (
|
|
||||||
"Node has no id! Should have been saved already... aborting!"
|
|
||||||
)
|
|
||||||
node_data = n.simple_json()
|
|
||||||
node_data["image"] = f"{n.url}?image=svg"
|
|
||||||
app_domain_assessment["assessment"]["node"] = node_data
|
|
||||||
|
|
||||||
n.kv["app_domain_assessment"] = app_domain_assessment
|
|
||||||
n.save()
|
|
||||||
|
|
||||||
sub.app_domain_assessment = app_domain_assessment
|
|
||||||
|
|
||||||
candidates = self.prediction_setting.expand(self, sub)
|
|
||||||
# candidates is a List of PredictionResult. The length of the List is equal to the number of rules
|
|
||||||
for cand_set in candidates:
|
|
||||||
if cand_set:
|
|
||||||
new_tp = True
|
|
||||||
# cand_set is a PredictionResult object that can consist of multiple candidate reactions
|
|
||||||
for cand in cand_set:
|
|
||||||
cand_nodes = []
|
|
||||||
# candidate reactions can have multiple fragments
|
|
||||||
for c in cand:
|
|
||||||
if c not in self.smiles_to_node:
|
|
||||||
# For new nodes do an AppDomain Assessment if an AppDomain is attached
|
|
||||||
app_domain_assessment = None
|
|
||||||
if self.prediction_setting.model:
|
|
||||||
if self.prediction_setting.model.app_domain:
|
|
||||||
app_domain_assessment = (
|
|
||||||
self.prediction_setting.model.app_domain.assess(c)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.smiles_to_node[c] = SNode(
|
|
||||||
c, sub.depth + 1, app_domain_assessment
|
|
||||||
)
|
|
||||||
|
|
||||||
node = self.smiles_to_node[c]
|
|
||||||
cand_nodes.append(node)
|
|
||||||
|
|
||||||
edge = SEdge(
|
|
||||||
sub,
|
|
||||||
cand_nodes,
|
|
||||||
rule=cand_set.rule,
|
|
||||||
probability=cand_set.probability,
|
|
||||||
)
|
|
||||||
self.edges.add(edge)
|
|
||||||
|
|
||||||
# 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 {
|
||||||
|
|||||||
@ -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 "
|
||||||
|
|||||||
@ -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"]))
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
epdb/migrations/0013_setting_expansion_schema.py
Normal file
25
epdb/migrations/0013_setting_expansion_schema.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
505
epdb/models.py
505
epdb/models.py
@ -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,59 +929,134 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
|
|||||||
if self in mapping:
|
if self in mapping:
|
||||||
return mapping[self]
|
return mapping[self]
|
||||||
|
|
||||||
new_compound = Compound.objects.create(
|
default_structure_smiles = self.default_structure.smiles
|
||||||
package=target,
|
normalized_structure_smiles = self.normalized_structure.smiles
|
||||||
name=self.name,
|
|
||||||
description=self.description,
|
|
||||||
kv=self.kv.copy() if self.kv else {},
|
|
||||||
)
|
|
||||||
mapping[self] = new_compound
|
|
||||||
|
|
||||||
# Copy compound structures
|
existing_compound = None
|
||||||
for structure in self.structures.all():
|
existing_normalized_compound = None
|
||||||
if structure not in mapping:
|
|
||||||
new_structure = CompoundStructure.objects.create(
|
|
||||||
compound=new_compound,
|
|
||||||
smiles=structure.smiles,
|
|
||||||
canonical_smiles=structure.canonical_smiles,
|
|
||||||
inchikey=structure.inchikey,
|
|
||||||
normalized_structure=structure.normalized_structure,
|
|
||||||
name=structure.name,
|
|
||||||
description=structure.description,
|
|
||||||
kv=structure.kv.copy() if structure.kv else {},
|
|
||||||
)
|
|
||||||
mapping[structure] = new_structure
|
|
||||||
|
|
||||||
# Copy external identifiers for structure
|
# Dedup check - Check if we find a direct match for a given SMILES
|
||||||
for ext_id in structure.external_identifiers.all():
|
if CompoundStructure.objects.filter(
|
||||||
ExternalIdentifier.objects.create(
|
smiles=default_structure_smiles, compound__package=target
|
||||||
content_object=new_structure,
|
).exists():
|
||||||
database=ext_id.database,
|
existing_compound = CompoundStructure.objects.get(
|
||||||
identifier_value=ext_id.identifier_value,
|
smiles=default_structure_smiles, compound__package=target
|
||||||
url=ext_id.url,
|
).compound
|
||||||
is_primary=ext_id.is_primary,
|
|
||||||
|
# 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.default_structure:
|
mapping[structure] = cs
|
||||||
new_compound.default_structure = mapping.get(self.default_structure)
|
|
||||||
new_compound.save()
|
|
||||||
|
|
||||||
for a in self.aliases:
|
return existing_normalized_compound
|
||||||
new_compound.add_alias(a)
|
|
||||||
new_compound.save()
|
|
||||||
|
|
||||||
# Copy external identifiers for compound
|
else:
|
||||||
for ext_id in self.external_identifiers.all():
|
raise ValueError(
|
||||||
ExternalIdentifier.objects.create(
|
f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.name}"
|
||||||
content_object=new_compound,
|
)
|
||||||
database=ext_id.database,
|
else:
|
||||||
identifier_value=ext_id.identifier_value,
|
# Here we can safely use Compound.objects.create as we won't end up in a duplicate
|
||||||
url=ext_id.url,
|
new_compound = Compound.objects.create(
|
||||||
is_primary=ext_id.is_primary,
|
package=target,
|
||||||
|
name=self.name,
|
||||||
|
description=self.description,
|
||||||
|
kv=self.kv.copy() if self.kv else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mapping[self] = new_compound
|
||||||
|
|
||||||
|
# Copy underlying structures
|
||||||
|
for structure in self.structures.all():
|
||||||
|
if structure not in mapping:
|
||||||
|
new_structure = CompoundStructure.objects.create(
|
||||||
|
compound=new_compound,
|
||||||
|
smiles=structure.smiles,
|
||||||
|
canonical_smiles=structure.canonical_smiles,
|
||||||
|
inchikey=structure.inchikey,
|
||||||
|
normalized_structure=structure.normalized_structure,
|
||||||
|
name=structure.name,
|
||||||
|
description=structure.description,
|
||||||
|
kv=structure.kv.copy() if structure.kv else {},
|
||||||
|
)
|
||||||
|
mapping[structure] = new_structure
|
||||||
|
|
||||||
|
# Copy external identifiers for structure
|
||||||
|
for ext_id in structure.external_identifiers.all():
|
||||||
|
ExternalIdentifier.objects.create(
|
||||||
|
content_object=new_structure,
|
||||||
|
database=ext_id.database,
|
||||||
|
identifier_value=ext_id.identifier_value,
|
||||||
|
url=ext_id.url,
|
||||||
|
is_primary=ext_id.is_primary,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.default_structure:
|
||||||
|
new_compound.default_structure = mapping.get(self.default_structure)
|
||||||
|
new_compound.save()
|
||||||
|
|
||||||
|
for a in self.aliases:
|
||||||
|
new_compound.add_alias(a)
|
||||||
|
new_compound.save()
|
||||||
|
|
||||||
|
# Copy external identifiers for compound
|
||||||
|
for ext_id in self.external_identifiers.all():
|
||||||
|
ExternalIdentifier.objects.create(
|
||||||
|
content_object=new_compound,
|
||||||
|
database=ext_id.database,
|
||||||
|
identifier_value=ext_id.identifier_value,
|
||||||
|
url=ext_id.url,
|
||||||
|
is_primary=ext_id.is_primary,
|
||||||
|
)
|
||||||
|
|
||||||
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,14 +1819,12 @@ 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)
|
|
||||||
|
|
||||||
# We shouldn't lose or make up nodes...
|
# We shouldn't lose or make up nodes...
|
||||||
assert len(nodes) == len(self.nodes)
|
assert len(nodes) == len(self.nodes)
|
||||||
@ -1748,27 +1905,40 @@ 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
|
||||||
|
|
||||||
|
header = []
|
||||||
|
|
||||||
|
if include_pathway_url:
|
||||||
|
header += ["Pathway URL"]
|
||||||
|
|
||||||
|
header += [
|
||||||
|
"SMILES",
|
||||||
|
"name",
|
||||||
|
"depth",
|
||||||
|
"probability",
|
||||||
|
"rule_names",
|
||||||
|
"rule_ids",
|
||||||
|
"parent_smiles",
|
||||||
|
]
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
rows.append(
|
|
||||||
[
|
if include_header:
|
||||||
"SMILES",
|
rows.append(header)
|
||||||
"name",
|
|
||||||
"depth",
|
|
||||||
"probability",
|
|
||||||
"rule_names",
|
|
||||||
"rule_ids",
|
|
||||||
"parent_smiles",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
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,23 +3979,29 @@ 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)
|
||||||
|
|
||||||
|
TERMINAL_STATES = [
|
||||||
|
"SUCCESS",
|
||||||
|
"FAILURE",
|
||||||
|
"REVOKED",
|
||||||
|
"IGNORED",
|
||||||
|
]
|
||||||
|
|
||||||
|
def is_in_terminal_state(self):
|
||||||
|
return self.status in self.TERMINAL_STATES
|
||||||
|
|
||||||
def check_for_update(self):
|
def check_for_update(self):
|
||||||
|
if self.is_in_terminal_state():
|
||||||
|
return
|
||||||
|
|
||||||
async_res = self.get_result()
|
async_res = self.get_result()
|
||||||
new_status = async_res.state
|
new_status = async_res.state
|
||||||
|
|
||||||
TERMINAL_STATES = [
|
if new_status != self.status and new_status in self.TERMINAL_STATES:
|
||||||
"SUCCESS",
|
|
||||||
"FAILURE",
|
|
||||||
"REVOKED",
|
|
||||||
"IGNORED",
|
|
||||||
]
|
|
||||||
|
|
||||||
if new_status != self.status and new_status in 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
|
||||||
|
|||||||
163
epdb/tasks.py
163
epdb/tasks.py
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
470
epdb/views.py
470
epdb/views.py
@ -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 #
|
||||||
|
|||||||
Binary file not shown.
@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
106
static/js/alpine/pathway.js
Normal 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();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
126
static/js/pps.js
126
static/js/pps.js
@ -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 = '';
|
||||||
for (pw in neighObj['related_pathways']) {
|
if (neighObj['related_pathways'] && Object.keys(neighObj['related_pathways']).length > 0) {
|
||||||
var pwObj = neighObj['related_pathways'][pw];
|
pwLinksHtml = '<ul class="menu bg-base-100 rounded-box w-full">';
|
||||||
pwLinks += "<a class='list-group-item' href=" + pwObj['url'] + ">" + pwObj['name'] + "</a>";
|
for (pw in neighObj['related_pathways']) {
|
||||||
|
var pwObj = neighObj['related_pathways'][pw];
|
||||||
|
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'] ? ">" : "<") + " 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'] ? ">" : "<") + " 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'] ? ">" : "<")} 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'] ? ">" : "<")} 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>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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>
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
@ -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 %}
|
|
||||||
10
templates/actions/objects/joblog.html
Normal file
10
templates/actions/objects/joblog.html
Normal 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 %}
|
||||||
@ -7,22 +7,26 @@
|
|||||||
<i class="glyphicon glyphicon-edit"></i> Edit Model</a
|
<i class="glyphicon glyphicon-edit"></i> Edit Model</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
|
||||||
<a
|
<li>
|
||||||
role="button"
|
<a
|
||||||
onclick="document.getElementById('evaluate_model_modal').showModal(); return false;"
|
role="button"
|
||||||
>
|
onclick="document.getElementById('evaluate_model_modal').showModal(); return false;"
|
||||||
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a
|
>
|
||||||
>
|
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a
|
||||||
</li>
|
>
|
||||||
<li>
|
</li>
|
||||||
<a
|
{% endif %}
|
||||||
role="button"
|
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
|
||||||
onclick="document.getElementById('retrain_model_modal').showModal(); return false;"
|
<li>
|
||||||
>
|
<a
|
||||||
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a
|
role="button"
|
||||||
>
|
onclick="document.getElementById('retrain_model_modal').showModal(); return false;"
|
||||||
</li>
|
>
|
||||||
|
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
class="button"
|
class="button"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
168
templates/batch_predict_pathway.html
Normal file
168
templates/batch_predict_pathway.html
Normal 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 %}
|
||||||
103
templates/collections/_paginated_list_partial.html
Normal file
103
templates/collections/_paginated_list_partial.html
Normal 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>
|
||||||
33
templates/collections/compounds_paginated.html
Normal file
33
templates/collections/compounds_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
@ -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>
|
||||||
|
|||||||
32
templates/collections/models_paginated.html
Normal file
32
templates/collections/models_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
{# 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,46 +18,23 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
{# prettier-ignore-end #}
|
{% endif %}
|
||||||
|
{# 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"
|
id="object-search"
|
||||||
id="object-search"
|
class="input input-bordered hidden w-full max-w-xs"
|
||||||
class="input input-bordered hidden w-full max-w-xs"
|
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 >></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 >></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 >></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 >></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 >></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 >></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 >></a
|
>Learn more >></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 >></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 >></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 >></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 >></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 >></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>
|
||||||
|
|||||||
95
templates/collections/packages_paginated.html
Normal file
95
templates/collections/packages_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
128
templates/collections/paginated_base.html
Normal file
128
templates/collections/paginated_base.html
Normal 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 %}
|
||||||
29
templates/collections/pathways_paginated.html
Normal file
29
templates/collections/pathways_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
33
templates/collections/reactions_paginated.html
Normal file
33
templates/collections/reactions_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
33
templates/collections/rules_paginated.html
Normal file
33
templates/collections/rules_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
33
templates/collections/scenarios_paginated.html
Normal file
33
templates/collections/scenarios_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
30
templates/collections/structures_paginated.html
Normal file
30
templates/collections/structures_paginated.html
Normal 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 >>
|
||||||
|
</a>
|
||||||
|
{% endblock description %}
|
||||||
@ -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"
|
||||||
28
templates/components/loading-spinner.html
Normal file
28
templates/components/loading-spinner.html
Normal 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>
|
||||||
@ -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">
|
||||||
@ -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 %}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
66
templates/modals/objects/download_job_result_modal.html
Normal file
66
templates/modals/objects/download_job_result_modal.html
Normal 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>
|
||||||
107
templates/modals/objects/engineer_pathway_modal.html
Normal file
107
templates/modals/objects/engineer_pathway_modal.html
Normal 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>
|
||||||
@ -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 %}
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
185
templates/objects/joblog.html
Normal file
185
templates/objects/joblog.html
Normal 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 %}
|
||||||
@ -73,13 +73,29 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Reaction Packages -->
|
{% endif %}
|
||||||
|
<!-- Reaction Packages -->
|
||||||
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
|
<input type="checkbox" checked />
|
||||||
|
<div class="collapse-title text-xl font-medium">Reaction Packages</div>
|
||||||
|
<div class="collapse-content">
|
||||||
|
<ul class="menu bg-base-100 rounded-box w-full">
|
||||||
|
{% for p in model.data_packages.all %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ p.url }}" class="hover:bg-base-200">{{ p.name }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if model.eval_packages.all|length > 0 %}
|
||||||
|
<!-- Eval 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 />
|
||||||
<div class="collapse-title text-xl font-medium">Reaction Packages</div>
|
<div class="collapse-title text-xl font-medium">Eval Packages</div>
|
||||||
<div class="collapse-content">
|
<div class="collapse-content">
|
||||||
<ul class="menu bg-base-100 rounded-box w-full">
|
<ul class="menu bg-base-100 rounded-box w-full">
|
||||||
{% for p in model.data_packages.all %}
|
{% for p in model.eval_packages.all %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ p.url }}" class="hover:bg-base-200">{{ p.name }}</a>
|
<a href="{{ p.url }}" class="hover:bg-base-200">{{ p.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
@ -87,37 +103,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if model.eval_packages.all|length > 0 %}
|
|
||||||
<!-- Eval Packages -->
|
|
||||||
<div class="collapse-arrow bg-base-200 collapse">
|
|
||||||
<input type="checkbox" checked />
|
|
||||||
<div class="collapse-title text-xl font-medium">Eval Packages</div>
|
|
||||||
<div class="collapse-content">
|
|
||||||
<ul class="menu bg-base-100 rounded-box w-full">
|
|
||||||
{% for p in model.eval_packages.all %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ p.url }}" class="hover:bg-base-200"
|
|
||||||
>{{ p.name }}</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<!-- Model Status -->
|
|
||||||
<div class="collapse-arrow bg-base-200 collapse">
|
|
||||||
<input type="checkbox" checked />
|
|
||||||
<div class="collapse-title text-xl font-medium">Model Status</div>
|
|
||||||
<div class="collapse-content">{{ model.status }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<!-- Model Status -->
|
||||||
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
|
<input type="checkbox" checked />
|
||||||
|
<div class="collapse-title text-xl font-medium">Model Status</div>
|
||||||
|
<div class="collapse-content">{{ model.status }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% 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,6 +262,105 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeChart(selector, data) {
|
||||||
|
const x = ['Recall'];
|
||||||
|
const y = ['Precision'];
|
||||||
|
const thres = ['threshold'];
|
||||||
|
|
||||||
|
function compare(a, b) {
|
||||||
|
if (a.threshold < b.threshold)
|
||||||
|
return -1;
|
||||||
|
else if (a.threshold > b.threshold)
|
||||||
|
return 1;
|
||||||
|
else
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexForValue(data, val, val_name) {
|
||||||
|
for (const idx in data) {
|
||||||
|
if (data[idx][val_name] == val) {
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
console.warn('PR curve data is empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dataLength = data.length;
|
||||||
|
data.sort(compare);
|
||||||
|
|
||||||
|
for (const idx in data) {
|
||||||
|
const d = data[idx];
|
||||||
|
x.push(d.recall);
|
||||||
|
y.push(d.precision);
|
||||||
|
thres.push(d.threshold);
|
||||||
|
}
|
||||||
|
const chart = c3.generate({
|
||||||
|
bindto: selector,
|
||||||
|
data: {
|
||||||
|
onclick: function (d, e) {
|
||||||
|
const idx = d.index;
|
||||||
|
const thresh = data[dataLength - idx - 1].threshold;
|
||||||
|
},
|
||||||
|
x: 'Recall',
|
||||||
|
y: 'Precision',
|
||||||
|
columns: [
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
height: 400,
|
||||||
|
width: 480
|
||||||
|
},
|
||||||
|
axis: {
|
||||||
|
x: {
|
||||||
|
max: 1,
|
||||||
|
min: 0,
|
||||||
|
label: 'Recall',
|
||||||
|
padding: 0,
|
||||||
|
tick: {
|
||||||
|
fit: true,
|
||||||
|
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
max: 1,
|
||||||
|
min: 0,
|
||||||
|
label: 'Precision',
|
||||||
|
padding: 0,
|
||||||
|
tick: {
|
||||||
|
fit: true,
|
||||||
|
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
point: {
|
||||||
|
r: 4
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
format: {
|
||||||
|
title: function (recall) {
|
||||||
|
const idx = getIndexForValue(data, recall, "recall");
|
||||||
|
if (idx != -1) {
|
||||||
|
return "Threshold: " + data[idx].threshold;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
value: function (precision, ratio, id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function makeLoadingGif(selector, gifPath) {
|
function makeLoadingGif(selector, gifPath) {
|
||||||
const element = document.querySelector(selector);
|
const element = document.querySelector(selector);
|
||||||
if (element) {
|
if (element) {
|
||||||
@ -252,107 +377,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
{% if model.model_status == 'FINISHED' %}
|
{% if model.model_status == 'FINISHED' %}
|
||||||
// Precision Recall Curve
|
// Precision Recall Curve
|
||||||
const sgChart = document.getElementById('sg-chart');
|
makeChart('#sg-chart', {{ model.pr_curve|safe }});
|
||||||
if (sgChart) {
|
{% if model.multigen_eval %}
|
||||||
const x = ['Recall'];
|
// Multi Gen Precision Recall Curve
|
||||||
const y = ['Precision'];
|
makeChart('#mg-chart', {{ model.mg_pr_curve|safe }});
|
||||||
const thres = ['threshold'];
|
{% endif %}
|
||||||
|
|
||||||
function compare(a, b) {
|
|
||||||
if (a.threshold < b.threshold)
|
|
||||||
return -1;
|
|
||||||
else if (a.threshold > b.threshold)
|
|
||||||
return 1;
|
|
||||||
else
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIndexForValue(data, val, val_name) {
|
|
||||||
for (const idx in data) {
|
|
||||||
if (data[idx][val_name] == val) {
|
|
||||||
return idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = {{ model.pr_curve|safe }};
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
console.warn('PR curve data is empty');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dataLength = data.length;
|
|
||||||
data.sort(compare);
|
|
||||||
|
|
||||||
for (const idx in data) {
|
|
||||||
const d = data[idx];
|
|
||||||
x.push(d.recall);
|
|
||||||
y.push(d.precision);
|
|
||||||
thres.push(d.threshold);
|
|
||||||
}
|
|
||||||
const chart = c3.generate({
|
|
||||||
bindto: '#sg-chart',
|
|
||||||
data: {
|
|
||||||
onclick: function (d, e) {
|
|
||||||
const idx = d.index;
|
|
||||||
const thresh = data[dataLength - idx - 1].threshold;
|
|
||||||
},
|
|
||||||
x: 'Recall',
|
|
||||||
y: 'Precision',
|
|
||||||
columns: [
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
]
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
height: 400,
|
|
||||||
width: 480
|
|
||||||
},
|
|
||||||
axis: {
|
|
||||||
x: {
|
|
||||||
max: 1,
|
|
||||||
min: 0,
|
|
||||||
label: 'Recall',
|
|
||||||
padding: 0,
|
|
||||||
tick: {
|
|
||||||
fit: true,
|
|
||||||
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
max: 1,
|
|
||||||
min: 0,
|
|
||||||
label: 'Precision',
|
|
||||||
padding: 0,
|
|
||||||
tick: {
|
|
||||||
fit: true,
|
|
||||||
values: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
r: 4
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
format: {
|
|
||||||
title: function (recall) {
|
|
||||||
const idx = getIndexForValue(data, recall, "recall");
|
|
||||||
if (idx != -1) {
|
|
||||||
return "Threshold: " + data[idx].threshold;
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
value: function (precision, ratio, id) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
enabled: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
{% 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");
|
||||||
|
|||||||
@ -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,38 +104,36 @@
|
|||||||
<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
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
width="16"
|
||||||
width="16"
|
height="16"
|
||||||
height="16"
|
viewBox="0 0 24 24"
|
||||||
viewBox="0 0 24 24"
|
fill="none"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
stroke="currentColor"
|
stroke-width="2"
|
||||||
stroke-width="2"
|
stroke-linecap="round"
|
||||||
stroke-linecap="round"
|
stroke-linejoin="round"
|
||||||
stroke-linejoin="round"
|
class="lucide lucide-edit"
|
||||||
class="lucide lucide-edit"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Edit
|
|
||||||
</div>
|
|
||||||
<ul
|
|
||||||
tabindex="0"
|
|
||||||
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
|
|
||||||
>
|
>
|
||||||
{% include "actions/objects/pathway.html" %}
|
<path
|
||||||
</ul>
|
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Actions
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<ul
|
||||||
|
tabindex="0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
|
||||||
|
>
|
||||||
|
{% include "actions/objects/pathway.html" %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
{% 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,59 +211,87 @@
|
|||||||
</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 }}',
|
||||||
<div id="status" class="flex items-center">
|
modified: '{{ pathway.modified|date:"Y-m-d H:i:s" }}',
|
||||||
<svg
|
statusUrl: '{{ pathway.url }}?status=true',
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
emptyDueToThreshold: '{{ pathway.empty_due_to_threshold }}'
|
||||||
width="16"
|
})"
|
||||||
height="16"
|
x-init="init()"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
fill="none"
|
{% if pathway.predicted %}
|
||||||
stroke="currentColor"
|
<!-- Status Display -->
|
||||||
stroke-width="2"
|
<div class="tooltip tooltip-left absolute top-4 right-4 z-10">
|
||||||
stroke-linecap="round"
|
<div class="tooltip-content" x-text="statusTooltip"></div>
|
||||||
stroke-linejoin="round"
|
|
||||||
class="lucide lucide-check"
|
|
||||||
>
|
|
||||||
<path d="M20 6 9 17l-5-5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% elif pathway.failed %}
|
|
||||||
<div class="tooltip tooltip-bottom absolute top-4 right-4 z-10">
|
|
||||||
<div class="tooltip-content">Pathway prediction failed.</div>
|
|
||||||
<div id="status" class="flex items-center">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% 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 id="status" class="flex items-center">
|
||||||
|
<!-- Completed icon -->
|
||||||
|
<template x-if="status === 'completed'">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Failed icon -->
|
||||||
|
<template x-if="status === 'failed'">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
<!-- Loading spinner -->
|
||||||
<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,222 +388,117 @@
|
|||||||
<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
|
||||||
var appDomainViewEnabled = false;
|
var appDomainViewEnabled = false;
|
||||||
|
|
||||||
function goFullscreen(id) {
|
function goFullscreen(id) {
|
||||||
var element = document.getElementById(id);
|
var element = document.getElementById(id);
|
||||||
if (element.mozRequestFullScreen) {
|
if (element.mozRequestFullScreen) {
|
||||||
element.mozRequestFullScreen();
|
element.mozRequestFullScreen();
|
||||||
} else if (element.webkitRequestFullScreen) {
|
} else if (element.webkitRequestFullScreen) {
|
||||||
element.webkitRequestFullScreen();
|
element.webkitRequestFullScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
// Transform references in description
|
||||||
// Initialize loading spinner if pathway is running
|
const descContent = document.getElementById("DescriptionContent");
|
||||||
if (pathway.status === 'running') {
|
if (descContent) {
|
||||||
const spinnerContainer = document.getElementById('status-loading-spinner');
|
const newDesc = transformReferences(descContent.innerText);
|
||||||
if (spinnerContainer) {
|
descContent.innerHTML = newDesc;
|
||||||
showLoadingSpinner(spinnerContainer);
|
}
|
||||||
|
|
||||||
|
// App domain toggle
|
||||||
|
const appDomainBtn = document.getElementById("app-domain-toggle-button");
|
||||||
|
if (appDomainBtn) {
|
||||||
|
appDomainBtn.addEventListener("click", function () {
|
||||||
|
appDomainViewEnabled = !appDomainViewEnabled;
|
||||||
|
const icon = document.getElementById("app-domain-icon");
|
||||||
|
|
||||||
|
if (appDomainViewEnabled) {
|
||||||
|
// Change to eye-off icon
|
||||||
|
icon.innerHTML =
|
||||||
|
'<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
|
||||||
|
|
||||||
|
nodes.forEach((x) => {
|
||||||
|
if (x.app_domain) {
|
||||||
|
if (x.app_domain.inside_app_domain) {
|
||||||
|
d3.select(x.el)
|
||||||
|
.select("circle")
|
||||||
|
.classed("inside_app_domain", true);
|
||||||
|
} else {
|
||||||
|
d3.select(x.el)
|
||||||
|
.select("circle")
|
||||||
|
.classed("outside_app_domain", true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
links.forEach((x) => {
|
||||||
|
if (x.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).classed("passes_app_domain", true);
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Change back to eye icon
|
||||||
|
icon.innerHTML =
|
||||||
|
'<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
|
||||||
|
|
||||||
// If prediction is still running, regularly check status
|
nodes.forEach((x) => {
|
||||||
if (pathway.status === 'running') {
|
d3.select(x.el)
|
||||||
let last_modified = pathway.modified;
|
.select("circle")
|
||||||
|
.classed("inside_app_domain", false);
|
||||||
let pollInterval = setInterval(async () => {
|
d3.select(x.el)
|
||||||
try {
|
.select("circle")
|
||||||
const response = await fetch("{{ pathway.url }}?status=true", {});
|
.classed("outside_app_domain", false);
|
||||||
const data = await response.json();
|
});
|
||||||
|
links.forEach((x) => {
|
||||||
if (data.modified > last_modified) {
|
d3.select(x.el).attr("marker-end", (d) =>
|
||||||
var msg = 'Prediction ';
|
d.target.pseudo ? "" : "url(#arrow)",
|
||||||
var btn = '<button type="button" onclick="location.reload()" class="btn btn-primary btn-sm mt-2" id="reloadBtn">Reload page</button>';
|
);
|
||||||
|
d3.select(x.el).classed("passes_app_domain", false);
|
||||||
if (data.status === "running") {
|
d3.select(x.el).classed("fails_app_domain", false);
|
||||||
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
|
|
||||||
const descContent = document.getElementById('DescriptionContent');
|
|
||||||
if (descContent) {
|
|
||||||
const newDesc = transformReferences(descContent.innerText);
|
|
||||||
descContent.innerHTML = newDesc;
|
|
||||||
}
|
|
||||||
|
|
||||||
// App domain toggle
|
|
||||||
const appDomainBtn = document.getElementById('app-domain-toggle-button');
|
|
||||||
if (appDomainBtn) {
|
|
||||||
appDomainBtn.addEventListener('click', function() {
|
|
||||||
appDomainViewEnabled = !appDomainViewEnabled;
|
|
||||||
const icon = document.getElementById('app-domain-icon');
|
|
||||||
|
|
||||||
if (appDomainViewEnabled) {
|
|
||||||
// Change to eye-off icon
|
|
||||||
icon.innerHTML = '<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/>';
|
|
||||||
|
|
||||||
nodes.forEach((x) => {
|
|
||||||
if(x.app_domain) {
|
|
||||||
if (x.app_domain.inside_app_domain) {
|
|
||||||
d3.select(x.el).select("circle").classed("inside_app_domain", true);
|
|
||||||
} else {
|
|
||||||
d3.select(x.el).select("circle").classed("outside_app_domain", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
links.forEach((x) => {
|
|
||||||
if(x.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).classed("passes_app_domain", true);
|
|
||||||
} else {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Change back to eye icon
|
|
||||||
icon.innerHTML = '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
|
|
||||||
|
|
||||||
nodes.forEach((x) => {
|
|
||||||
d3.select(x.el).select("circle").classed("inside_app_domain", false);
|
|
||||||
d3.select(x.el).select("circle").classed("outside_app_domain", false);
|
|
||||||
});
|
|
||||||
links.forEach((x) => {
|
|
||||||
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("fails_app_domain", false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
// Show actions button if there are actions
|
||||||
{# prettier-ignore-end #}
|
const actionsButton = document.getElementById("actionsButton");
|
||||||
|
const actionsList = actionsButton?.querySelector("ul");
|
||||||
|
if (actionsList && actionsList.children.length > 0) {
|
||||||
|
actionsButton?.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
92
templates/objects/setting_template.html
Normal file
92
templates/objects/setting_template.html
Normal 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>
|
||||||
@ -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>
|
</div>
|
||||||
<th>Parameter</th>
|
</div>
|
||||||
<th>Value</th>
|
|
||||||
</tr>
|
<!-- Other Prediction Settings -->
|
||||||
</thead>
|
<div class="collapse-arrow bg-base-200 collapse">
|
||||||
<tbody>
|
<input type="checkbox" />
|
||||||
{% if user.default_setting.model %}
|
<div class="collapse-title text-xl font-medium">
|
||||||
<tr>
|
Other Prediction Settings
|
||||||
<td>Model</td>
|
</div>
|
||||||
<td>
|
<div class="collapse-content space-y-3">
|
||||||
<div class="space-y-2">
|
{% for setting in meta.available_settings %}
|
||||||
<a
|
{% if setting != user.default_setting %}
|
||||||
href="{{ user.default_setting.model.url }}"
|
{% with setting_to_render=setting can_be_default=True %}
|
||||||
class="link link-primary"
|
{% include "objects/setting_template.html" %}
|
||||||
>
|
{% endwith %}
|
||||||
{{ user.default_setting.model.name }}
|
{% endif %}
|
||||||
</a>
|
{% endfor %}
|
||||||
<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>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% 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 %}
|
|
||||||
</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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|
||||||
|
|||||||
69
tests/frontend/frontend_base.py
Normal file
69
tests/frontend/frontend_base.py
Normal 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
|
||||||
@ -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
|
|
||||||
|
|||||||
51
tests/frontend/test_loginpage.py
Normal file
51
tests/frontend/test_loginpage.py
Normal 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()
|
||||||
67
tests/frontend/test_packagepage.py
Normal file
67
tests/frontend/test_packagepage.py
Normal 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
|
||||||
@ -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
Reference in New Issue
Block a user