forked from enviPath/enviPy
Compare commits
29 Commits
fix/issue2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 6499a0c659 | |||
| 7c60a28801 | |||
| a4a4179261 | |||
| 6ee4ac535a | |||
| d6065ee888 | |||
| 9db4806d75 | |||
| 4bf20e62ef | |||
| 8adb93012a | |||
| d2d475b990 | |||
| 648ec150a9 | |||
| 46b0f1c124 | |||
| d5af898053 | |||
| b7379b3337 | |||
| d6440f416c | |||
| 901de4640c | |||
| 69df139256 | |||
| e8ae494c16 | |||
| fd2e2c2534 | |||
| 1a2c9bb543 | |||
| 7f6f209b4a | |||
| b6c35fea76 | |||
| fa8a191383 | |||
| 67b1baa5b0 | |||
| 89c194dcca | |||
| a8554c903c | |||
| d584791ee8 | |||
| e60052b05c | |||
| 3ff8d938d6 | |||
| a7f48c2cf9 |
@ -20,3 +20,16 @@ LOG_LEVEL='INFO'
|
||||
SERVER_URL='http://localhost:8000'
|
||||
PLUGINS_ENABLED=True
|
||||
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
|
||||
@ -8,7 +8,10 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.envipath.com/envipath/envipy-ci:latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@ -40,7 +43,7 @@ jobs:
|
||||
EP_DATA_DIR: /opt/enviPy/
|
||||
ALLOWED_HOSTS: 127.0.0.1,localhost
|
||||
DEBUG: True
|
||||
LOG_LEVEL: DEBUG
|
||||
LOG_LEVEL: INFO
|
||||
MODEL_BUILDING_ENABLED: True
|
||||
APPLICABILITY_DOMAIN_ENABLED: True
|
||||
ENVIFORMER_PRESENT: True
|
||||
@ -63,54 +66,22 @@ jobs:
|
||||
MS_ENTRA_ENABLED: False
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install system tools via apt
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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
|
||||
# Use shared setup action - includes all dependencies and migrations
|
||||
- name: Setup enviPy Environment
|
||||
uses: ./.gitea/actions/setup-envipy
|
||||
with:
|
||||
version: 10
|
||||
skip-frontend: 'false'
|
||||
skip-playwright: 'false'
|
||||
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
|
||||
run-migrations: 'true'
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Setup venv
|
||||
- name: Run frontend tests
|
||||
run: |
|
||||
uv sync --locked --all-extras --dev
|
||||
|
||||
- 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
|
||||
.venv/bin/python manage.py test --tag frontend
|
||||
|
||||
- name: Run Django tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python manage.py test tests --exclude-tag slow
|
||||
.venv/bin/python manage.py test tests --exclude-tag slow --exclude-tag frontend
|
||||
|
||||
372
.gitignore
vendored
372
.gitignore
vendored
@ -1,17 +1,375 @@
|
||||
*.pyc
|
||||
|
||||
|
||||
|
||||
### Python ###
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
.idea/
|
||||
db.sqlite3-journal
|
||||
static/admin/
|
||||
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
|
||||
.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
|
||||
scratches/
|
||||
|
||||
test-results/
|
||||
data/
|
||||
*.arff
|
||||
|
||||
.DS_Store
|
||||
|
||||
node_modules/
|
||||
# Auto generated
|
||||
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 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")
|
||||
|
||||
# Add routers
|
||||
api_v1.add_router("/", epdb_app_router)
|
||||
api_v1.add_router("/", v1_router)
|
||||
api_legacy.add_router("/", epdb_legacy_app_router)
|
||||
|
||||
@ -48,10 +48,25 @@ INSTALLED_APPS = [
|
||||
"django_extensions",
|
||||
"oauth2_provider",
|
||||
# Custom
|
||||
"epapi", # API endpoints (v1, etc.)
|
||||
"epdb",
|
||||
"migration",
|
||||
# "migration",
|
||||
]
|
||||
|
||||
TENANT = os.environ.get("TENANT", "public")
|
||||
|
||||
if TENANT != "public":
|
||||
INSTALLED_APPS.append(TENANT)
|
||||
|
||||
EPDB_PACKAGE_MODEL = os.environ.get("EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||
|
||||
|
||||
def GET_PACKAGE_MODEL():
|
||||
from django.apps import apps
|
||||
|
||||
return apps.get_model(EPDB_PACKAGE_MODEL)
|
||||
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
@ -184,6 +199,12 @@ if not os.path.exists(LOG_DIR):
|
||||
os.mkdir(LOG_DIR)
|
||||
|
||||
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):
|
||||
os.mkdir(PLUGIN_DIR)
|
||||
|
||||
@ -341,6 +362,7 @@ FLAGS = {
|
||||
# -> /password_reset/done is covered as well
|
||||
LOGIN_EXEMPT_URLS = [
|
||||
"/register",
|
||||
"/api/v1/", # Let API handle its own authentication
|
||||
"/api/legacy/",
|
||||
"/o/token/",
|
||||
"/o/userinfo/",
|
||||
@ -352,7 +374,7 @@ LOGIN_EXEMPT_URLS = [
|
||||
"/cookie-policy",
|
||||
"/about",
|
||||
"/contact",
|
||||
"/jobs",
|
||||
"/careers",
|
||||
"/cite",
|
||||
"/legal",
|
||||
]
|
||||
|
||||
@ -23,12 +23,20 @@ from .api import api_v1, api_legacy
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("epdb.urls")),
|
||||
path("", include("migration.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/v1/", api_v1.urls),
|
||||
path("api/legacy/", api_legacy.urls),
|
||||
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||
]
|
||||
|
||||
if "migration" in s.INSTALLED_APPS:
|
||||
urlpatterns.append(path("", include("migration.urls")))
|
||||
|
||||
if s.MS_ENTRA_ENABLED:
|
||||
urlpatterns.append(path("", include("epauth.urls")))
|
||||
|
||||
# Custom error handlers
|
||||
handler400 = "epdb.views.handler400"
|
||||
handler403 = "epdb.views.handler403"
|
||||
handler404 = "epdb.views.handler404"
|
||||
handler500 = "epdb.views.handler500"
|
||||
|
||||
0
epapi/__init__.py
Normal file
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"
|
||||
@ -1,29 +1,31 @@
|
||||
from django.conf import settings as s
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
User,
|
||||
UserPackagePermission,
|
||||
Group,
|
||||
GroupPackagePermission,
|
||||
Package,
|
||||
MLRelativeReasoning,
|
||||
EnviFormer,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
SimpleAmbitRule,
|
||||
ParallelRule,
|
||||
Reaction,
|
||||
Pathway,
|
||||
Node,
|
||||
Edge,
|
||||
Scenario,
|
||||
Setting,
|
||||
EnviFormer,
|
||||
ExternalDatabase,
|
||||
ExternalIdentifier,
|
||||
Group,
|
||||
GroupPackagePermission,
|
||||
JobLog,
|
||||
License,
|
||||
MLRelativeReasoning,
|
||||
Node,
|
||||
ParallelRule,
|
||||
Pathway,
|
||||
Reaction,
|
||||
Scenario,
|
||||
Setting,
|
||||
SimpleAmbitRule,
|
||||
User,
|
||||
UserPackagePermission,
|
||||
)
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
list_display = ["username", "email", "is_active"]
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EPDBConfig(AppConfig):
|
||||
@ -7,3 +12,6 @@ class EPDBConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
import epdb.signals # noqa: F401
|
||||
|
||||
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||
logger.info(f"Using Package model: {model_name}")
|
||||
|
||||
@ -5,7 +5,7 @@ Context processors automatically make variables available to all templates.
|
||||
"""
|
||||
|
||||
from .logic import PackageManager
|
||||
from .models import Package
|
||||
from django.conf import settings as s
|
||||
|
||||
|
||||
def package_context(request):
|
||||
@ -20,7 +20,7 @@ def package_context(request):
|
||||
|
||||
reviewed_package_qs = PackageManager.get_reviewed_packages()
|
||||
|
||||
unreviewed_package_qs = Package.objects.none()
|
||||
unreviewed_package_qs = s.GET_PACKAGE_MODEL().objects.none()
|
||||
|
||||
# Only get user-specific packages if user is authenticated
|
||||
if current_user.is_authenticated:
|
||||
|
||||
@ -1,27 +1,35 @@
|
||||
from typing import List, Dict, Optional, Any
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from ninja import Router, Schema, Field, Form
|
||||
from ninja import Field, Form, Router, Schema, Query
|
||||
from ninja.security import SessionAuth
|
||||
|
||||
from utilities.chem import FormatConverter
|
||||
from .logic import PackageManager, UserManager, SettingManager
|
||||
from utilities.misc import PackageExporter
|
||||
|
||||
from .logic import GroupManager, PackageManager, SettingManager, UserManager, SearchManager
|
||||
from .models import (
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Package,
|
||||
Edge,
|
||||
EPModel,
|
||||
Node,
|
||||
Pathway,
|
||||
Reaction,
|
||||
Rule,
|
||||
Scenario,
|
||||
SimpleAmbitRule,
|
||||
User,
|
||||
UserPackagePermission,
|
||||
Rule,
|
||||
Reaction,
|
||||
Scenario,
|
||||
Pathway,
|
||||
Node,
|
||||
Edge,
|
||||
SimpleAmbitRule,
|
||||
ParallelRule,
|
||||
)
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
def _anonymous_or_real(request):
|
||||
if request.user.is_authenticated and not request.user.is_anonymous:
|
||||
@ -29,8 +37,7 @@ def _anonymous_or_real(request):
|
||||
return get_user_model().objects.get(username="anonymous")
|
||||
|
||||
|
||||
# router = Router(auth=SessionAuth())
|
||||
router = Router()
|
||||
router = Router(auth=SessionAuth(csrf=False))
|
||||
|
||||
|
||||
class Error(Schema):
|
||||
@ -118,13 +125,16 @@ class SimpleEdge(SimpleObject):
|
||||
identifier: str = "edge"
|
||||
|
||||
|
||||
class SimpleModel(SimpleObject):
|
||||
identifier: str = "relative-reasoning"
|
||||
|
||||
|
||||
################
|
||||
# Login/Logout #
|
||||
################
|
||||
@router.post("/", response={200: SimpleUser, 403: Error})
|
||||
@router.post("/", response={200: SimpleUser, 403: Error}, auth=None)
|
||||
def login(request, loginusername: Form[str], loginpassword: Form[str]):
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth import authenticate, login
|
||||
|
||||
email = User.objects.get(username=loginusername).email
|
||||
user = authenticate(username=email, password=loginpassword)
|
||||
@ -167,9 +177,13 @@ class UserSchema(Schema):
|
||||
return SettingManager.get_all_settings(obj)
|
||||
|
||||
|
||||
class Me(Schema):
|
||||
whoami: str | None = None
|
||||
|
||||
|
||||
@router.get("/user", response={200: UserWrapper, 403: Error})
|
||||
def get_users(request, whoami: str = None):
|
||||
if whoami:
|
||||
def get_users(request, me: Query[Me]):
|
||||
if me.whoami:
|
||||
return {"user": [request.user]}
|
||||
else:
|
||||
return {"user": User.objects.all()}
|
||||
@ -186,6 +200,61 @@ def get_user(request, user_uuid):
|
||||
}
|
||||
|
||||
|
||||
class Search(Schema):
|
||||
packages: List[str] = Field(alias="packages[]")
|
||||
search: str
|
||||
method: str
|
||||
|
||||
|
||||
@router.get("/search", response={200: Any, 403: Error})
|
||||
def search(request, search: Query[Search]):
|
||||
try:
|
||||
packs = []
|
||||
for package in search.packages:
|
||||
packs.append(PackageManager.get_package_by_url(request.user, package))
|
||||
|
||||
method = None
|
||||
|
||||
if search.method == "text":
|
||||
method = "text"
|
||||
elif search.method == "inchikey":
|
||||
method = "inchikey"
|
||||
elif search.method == "defaultSmiles":
|
||||
method = "default"
|
||||
elif search.method == "canonicalSmiles":
|
||||
method = "canonical"
|
||||
elif search.method == "exactSmiles":
|
||||
method = "exact"
|
||||
|
||||
if method is None:
|
||||
raise ValueError(f"Search method {search.method} is not supported!")
|
||||
|
||||
search_res = SearchManager.search(packs, search.search, method)
|
||||
res = {}
|
||||
if "Compounds" in search_res:
|
||||
res["compound"] = search_res["Compounds"]
|
||||
|
||||
if "Compound Structures" in search_res:
|
||||
res["structure"] = search_res["Compound Structures"]
|
||||
|
||||
if "Reaction" in search_res:
|
||||
res["reaction"] = search_res["Reaction"]
|
||||
|
||||
if "Pathway" in search_res:
|
||||
res["pathway"] = search_res["Pathway"]
|
||||
|
||||
if "Rules" in search_res:
|
||||
res["rule"] = search_res["Rules"]
|
||||
|
||||
for key in res:
|
||||
for v in res[key]:
|
||||
v["id"] = v["url"].replace("simple-ambit-rule", "simple-rule")
|
||||
|
||||
return res
|
||||
except ValueError as e:
|
||||
return 403, {"message": f"Search failed due to {e}"}
|
||||
|
||||
|
||||
###########
|
||||
# Package #
|
||||
###########
|
||||
@ -251,67 +320,110 @@ def get_packages(request):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/package/{uuid:package_uuid}", response={200: PackageSchema, 403: Error})
|
||||
def get_package(request, package_uuid):
|
||||
class GetPackage(Schema):
|
||||
exportAsJson: str | None = None
|
||||
|
||||
|
||||
@router.get("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 403: Error})
|
||||
def get_package(request, package_uuid, gp: Query[GetPackage]):
|
||||
try:
|
||||
return PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if gp.exportAsJson and gp.exportAsJson.strip() == "true":
|
||||
return PackageExporter(p).do_export()
|
||||
|
||||
return p
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
class CreatePackage(Schema):
|
||||
packageName: str
|
||||
packageDescription: str | None = None
|
||||
|
||||
|
||||
@router.post("/package")
|
||||
def create_packages(
|
||||
request, packageName: Form[str], packageDescription: Optional[str] = Form(None)
|
||||
request,
|
||||
p: Form[CreatePackage],
|
||||
):
|
||||
try:
|
||||
if packageName.strip() == "":
|
||||
if p.packageName.strip() == "":
|
||||
raise ValueError("Package name cannot be empty!")
|
||||
|
||||
new_pacakge = PackageManager.create_package(request.user, packageName, packageDescription)
|
||||
new_pacakge = PackageManager.create_package(
|
||||
request.user, p.packageName, p.packageDescription
|
||||
)
|
||||
return redirect(new_pacakge.url)
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
class UpdatePackage(Schema):
|
||||
packageDescription: str | None = None
|
||||
hiddenMethod: str | None = None
|
||||
permissions: str | None = None
|
||||
ppsURI: str | None = None
|
||||
read: str | None = None
|
||||
write: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
|
||||
def update_package(
|
||||
request,
|
||||
package_uuid,
|
||||
packageDescription: Optional[str] = Form(None),
|
||||
hiddenMethod: Optional[str] = Form(None),
|
||||
exportAsJson: Optional[str] = Form(None),
|
||||
permissions: Optional[str] = Form(None),
|
||||
ppsURI: Optional[str] = Form(None),
|
||||
read: Optional[str] = Form(None),
|
||||
write: Optional[str] = Form(None),
|
||||
):
|
||||
def update_package(request, package_uuid, pack: Form[UpdatePackage]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if hiddenMethod:
|
||||
if hiddenMethod == "DELETE":
|
||||
if pack.hiddenMethod:
|
||||
if pack.hiddenMethod == "DELETE":
|
||||
p.delete()
|
||||
|
||||
elif packageDescription and packageDescription.strip() != "":
|
||||
p.description = packageDescription
|
||||
p.save()
|
||||
return
|
||||
elif exportAsJson == "true":
|
||||
pack_json = PackageManager.export_package(
|
||||
p, include_models=False, include_external_identifiers=False
|
||||
)
|
||||
return pack_json
|
||||
elif all([permissions, ppsURI, read]):
|
||||
PackageManager.update_permissions
|
||||
elif all([permissions, ppsURI, write]):
|
||||
pass
|
||||
elif pack.packageDescription is not None:
|
||||
description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
if description:
|
||||
p.description = description
|
||||
p.save()
|
||||
return HttpResponse(status=200)
|
||||
else:
|
||||
raise ValueError("Package description cannot be empty!")
|
||||
elif all([pack.permissions, pack.ppsURI, pack.read]):
|
||||
if "group" in pack.ppsURI:
|
||||
grantee = GroupManager.get_group_lp(pack.ppsURI)
|
||||
else:
|
||||
grantee = UserManager.get_user_lp(pack.ppsURI)
|
||||
|
||||
PackageManager.grant_read(request.user, p, grantee)
|
||||
return HttpResponse(status=200)
|
||||
elif all([pack.permissions, pack.ppsURI, pack.write]):
|
||||
if "group" in pack.ppsURI:
|
||||
grantee = GroupManager.get_group_lp(pack.ppsURI)
|
||||
else:
|
||||
grantee = UserManager.get_user_lp(pack.ppsURI)
|
||||
|
||||
PackageManager.grant_write(request.user, p, grantee)
|
||||
return HttpResponse(status=200)
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}")
|
||||
def delete_package(request, package_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.administrable(request.user, p):
|
||||
p.delete()
|
||||
return redirect(f"{s.SERVER_URL}/package")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Package!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Package with id {package_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
################################
|
||||
# Compound / CompoundStructure #
|
||||
################################
|
||||
@ -509,6 +621,83 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
|
||||
}
|
||||
|
||||
|
||||
class CreateCompound(Schema):
|
||||
compoundSmiles: str
|
||||
compoundName: str | None = None
|
||||
compoundDescription: str | None = None
|
||||
inchi: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/compound")
|
||||
def create_package_compound(
|
||||
request,
|
||||
package_uuid,
|
||||
c: Form[CreateCompound],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
# inchi is not used atm
|
||||
c = Compound.create(
|
||||
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
|
||||
)
|
||||
return redirect(c.url)
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}")
|
||||
def delete_compound(request, package_uuid, compound_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||
c.delete()
|
||||
return redirect(f"{p.url}/compound")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Compound!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Compound with id {compound_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
|
||||
)
|
||||
def delete_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
|
||||
|
||||
# Check if we have to delete the compound as no structure is left
|
||||
if len(cs.compound.structures.all()) == 1:
|
||||
# This will delete the structure as well
|
||||
c.delete()
|
||||
return redirect(p.url + "/compound")
|
||||
else:
|
||||
if cs.normalized_structure:
|
||||
c.delete()
|
||||
return redirect(p.url + "/compound")
|
||||
else:
|
||||
if c.default_structure == cs:
|
||||
cs.delete()
|
||||
c.default_structure = c.structures.all().first()
|
||||
return redirect(c.url + "/structure")
|
||||
else:
|
||||
cs.delete()
|
||||
return redirect(c.url + "/structure")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this CompoundStructure!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting CompoundStructure with id {compound_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
#########
|
||||
# Rules #
|
||||
#########
|
||||
@ -672,6 +861,73 @@ def _get_package_rule(request, package_uuid, rule_uuid):
|
||||
|
||||
|
||||
# POST
|
||||
class CreateSimpleRule(Schema):
|
||||
smirks: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
reactantFilterSmarts: str | None = None
|
||||
productFilterSmarts: str | None = None
|
||||
immediate: str | None = None
|
||||
rdkitrule: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/simple-rule")
|
||||
def create_package_simple_rule(
|
||||
request,
|
||||
package_uuid,
|
||||
r: Form[CreateSimpleRule],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if r.rdkitrule and r.rdkitrule.strip() == "true":
|
||||
raise ValueError("Not yet implemented!")
|
||||
else:
|
||||
sr = SimpleAmbitRule.create(
|
||||
p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts
|
||||
)
|
||||
|
||||
return redirect(sr.url)
|
||||
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
class CreateParallelRule(Schema):
|
||||
simpleRules: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
reactantFilterSmarts: str | None = None
|
||||
productFilterSmarts: str | None = None
|
||||
immediate: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/parallel-rule")
|
||||
def create_package_parallel_rule(
|
||||
request,
|
||||
package_uuid,
|
||||
r: Form[CreateParallelRule],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
srs = SimpleRule.objects.filter(package=p, url__in=r.simpleRules)
|
||||
|
||||
if srs.count() != len(r.simpleRules):
|
||||
raise ValueError(
|
||||
f"Not all SimpleRules could be found in Package with id {package_uuid}!"
|
||||
)
|
||||
|
||||
sr = ParallelRule.create(
|
||||
p, list(srs), r.name, r.description, r.reactantFilterSmarts, r.productFilterSmarts
|
||||
)
|
||||
|
||||
return redirect(sr.url)
|
||||
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}", response={200: str | Any, 403: Error}
|
||||
)
|
||||
@ -721,6 +977,41 @@ def _post_package_rule(request, package_uuid, rule_uuid, compound: Form[str]):
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}")
|
||||
def delete_rule(request, package_uuid, rule_uuid):
|
||||
return _delete_rule(request, package_uuid, rule_uuid)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/package/{uuid:package_uuid}/simple-rule/{uuid:rule_uuid}",
|
||||
)
|
||||
def delete_simple_rule(request, package_uuid, rule_uuid):
|
||||
return _delete_rule(request, package_uuid, rule_uuid)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/package/{uuid:package_uuid}/parallel-rule/{uuid:rule_uuid}",
|
||||
)
|
||||
def delete_parallel_rule(request, package_uuid, rule_uuid):
|
||||
return _delete_rule(request, package_uuid, rule_uuid)
|
||||
|
||||
|
||||
def _delete_rule(request, package_uuid, rule_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
r = Rule.objects.get(package=p, uuid=rule_uuid)
|
||||
r.delete()
|
||||
return redirect(f"{p.url}/rule")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Rule!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Rule with id {rule_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
############
|
||||
# Reaction #
|
||||
############
|
||||
@ -809,6 +1100,82 @@ def get_package_reaction(request, package_uuid, reaction_uuid):
|
||||
}
|
||||
|
||||
|
||||
class CreateReaction(Schema):
|
||||
reactionName: str | None = None
|
||||
reactionDescription: str | None = None
|
||||
smirks: str | None = None
|
||||
educt: str | None = None
|
||||
product: str | None = None
|
||||
rule: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/reaction")
|
||||
def create_package_reaction(
|
||||
request,
|
||||
package_uuid,
|
||||
r: Form[CreateReaction],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if r.smirks is None and (r.educt is None or r.product is None):
|
||||
raise ValueError("Either SMIRKS or educt/product must be provided")
|
||||
|
||||
if r.smirks is not None and (r.educt is not None and r.product is not None):
|
||||
raise ValueError("SMIRKS and educt/product provided!")
|
||||
|
||||
rule = None
|
||||
if r.rule:
|
||||
try:
|
||||
rule = Rule.objects.get(package=p, url=r.rule)
|
||||
except Rule.DoesNotExist:
|
||||
raise ValueError(f"Rule with id {r.rule} does not exist!")
|
||||
|
||||
if r.educt is not None:
|
||||
try:
|
||||
educt_cs = CompoundStructure.objects.get(compound__package=p, url=r.educt)
|
||||
except CompoundStructure.DoesNotExist:
|
||||
raise ValueError(f"Compound with id {r.educt} does not exist!")
|
||||
|
||||
try:
|
||||
product_cs = CompoundStructure.objects.get(compound__package=p, url=r.product)
|
||||
except CompoundStructure.DoesNotExist:
|
||||
raise ValueError(f"Compound with id {r.product} does not exist!")
|
||||
|
||||
new_r = Reaction.create(
|
||||
p, r.reactionName, r.reactionDescription, [educt_cs], [product_cs], rule
|
||||
)
|
||||
else:
|
||||
educts = r.smirks.split(">>")[0].split("\\.")
|
||||
products = r.smirks.split(">>")[1].split("\\.")
|
||||
|
||||
new_r = Reaction.create(
|
||||
p, r.reactionName, r.reactionDescription, educts, products, rule
|
||||
)
|
||||
|
||||
return redirect(new_r.url)
|
||||
|
||||
except ValueError as e:
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/reaction/{uuid:reaction_uuid}")
|
||||
def delete_reaction(request, package_uuid, reaction_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
|
||||
r.delete()
|
||||
return redirect(f"{p.url}/reaction")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Reaction!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Reaction with id {reaction_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
############
|
||||
# Scenario #
|
||||
############
|
||||
@ -823,7 +1190,7 @@ class ScenarioSchema(Schema):
|
||||
description: str = Field(None, alias="description")
|
||||
id: str = Field(None, alias="url")
|
||||
identifier: str = "scenario"
|
||||
linkedTo: List[Dict[str, str]] = Field({}, alias="linked_to")
|
||||
linkedTo: List[Dict[str, str]] = Field([], alias="linked_to")
|
||||
name: str = Field(None, alias="name")
|
||||
pathways: List["SimplePathway"] = Field([], alias="related_pathways")
|
||||
relatedScenarios: List[Dict[str, str]] = Field([], alias="related_scenarios")
|
||||
@ -874,6 +1241,38 @@ def get_package_scenario(request, package_uuid, scenario_uuid):
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/scenario")
|
||||
def delete_scenarios(request, package_uuid, scenario_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
scens = Scenario.objects.filter(package=p)
|
||||
scens.delete()
|
||||
return redirect(f"{p.url}/scenario")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete Scenarios!")
|
||||
except ValueError:
|
||||
return 403, {"message": "Deleting Scenarios failed due to insufficient rights!"}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/scenario/{uuid:scenario_uuid}")
|
||||
def delete_scenario(request, package_uuid, scenario_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
|
||||
scen.delete()
|
||||
return redirect(f"{p.url}/scenario")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Scenario!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Scenario with id {scenario_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
###########
|
||||
# Pathway #
|
||||
###########
|
||||
@ -1013,46 +1412,67 @@ def get_package_pathway(request, package_uuid, pathway_uuid):
|
||||
}
|
||||
|
||||
|
||||
class CreatePathway(Schema):
|
||||
smilesinput: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
rootOnly: str | None = None
|
||||
selectedSetting: str | None = None
|
||||
|
||||
|
||||
@router.post("/package/{uuid:package_uuid}/pathway")
|
||||
def create_pathway(
|
||||
request,
|
||||
package_uuid,
|
||||
smilesinput: Form[str],
|
||||
name: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
rootOnly: Optional[str] = Form(None),
|
||||
selectedSetting: Optional[str] = Form(None),
|
||||
pw: Form[CreatePathway],
|
||||
):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
stand_smiles = FormatConverter.standardize(smilesinput.strip())
|
||||
stand_smiles = FormatConverter.standardize(pw.smilesinput.strip())
|
||||
|
||||
pw = Pathway.create(p, stand_smiles, name=name, description=description)
|
||||
new_pw = Pathway.create(p, stand_smiles, name=pw.name, description=pw.description)
|
||||
|
||||
pw_mode = "predict"
|
||||
if rootOnly and rootOnly == "true":
|
||||
if pw.rootOnly and pw.rootOnly.strip() == "true":
|
||||
pw_mode = "build"
|
||||
|
||||
pw.kv.update({"mode": pw_mode})
|
||||
pw.save()
|
||||
new_pw.kv.update({"mode": pw_mode})
|
||||
new_pw.save()
|
||||
|
||||
if pw_mode == "predict":
|
||||
setting = request.user.prediction_settings()
|
||||
|
||||
if selectedSetting:
|
||||
setting = SettingManager.get_setting_by_url(request.user, selectedSetting)
|
||||
if pw.selectedSetting:
|
||||
setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting)
|
||||
|
||||
pw.setting = setting
|
||||
pw.save()
|
||||
new_pw.setting = setting
|
||||
new_pw.save()
|
||||
|
||||
from .tasks import predict
|
||||
from .tasks import dispatch, predict
|
||||
|
||||
predict.delay(pw.pk, setting.pk, limit=-1)
|
||||
dispatch(request.user, predict, new_pw.pk, setting.pk, limit=None)
|
||||
|
||||
return redirect(pw.url)
|
||||
return redirect(new_pw.url)
|
||||
except ValueError as e:
|
||||
print(e)
|
||||
return 400, {"message": str(e)}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
|
||||
def delete_pathway(request, package_uuid, pathway_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
pw.delete()
|
||||
return redirect(f"{p.url}/pathway")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this pathway!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
########
|
||||
@ -1143,6 +1563,52 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
|
||||
}
|
||||
|
||||
|
||||
class CreateNode(Schema):
|
||||
nodeAsSmiles: str
|
||||
nodeName: str | None = None
|
||||
nodeReason: str | None = None
|
||||
nodeDepth: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node",
|
||||
response={200: str | Any, 403: Error},
|
||||
)
|
||||
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
|
||||
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
|
||||
node_depth = int(n.nodeDepth)
|
||||
else:
|
||||
node_depth = -1
|
||||
|
||||
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
|
||||
|
||||
return redirect(n.url)
|
||||
except ValueError:
|
||||
return 403, {"message": "Adding node failed!"}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node/{uuid:node_uuid}")
|
||||
def delete_node(request, package_uuid, pathway_uuid, node_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
n = Node.objects.get(pathway=pw, uuid=node_uuid)
|
||||
n.delete()
|
||||
return redirect(f"{pw.url}/node")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Node!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Node with id {node_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
########
|
||||
# Edge #
|
||||
########
|
||||
@ -1206,6 +1672,200 @@ def get_package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
|
||||
}
|
||||
|
||||
|
||||
class CreateEdge(Schema):
|
||||
edgeAsSmirks: str | None = None
|
||||
educts: str | None = None # Node URIs comma sep
|
||||
products: str | None = None # Node URIs comma sep
|
||||
multistep: str | None = None
|
||||
edgeReason: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge",
|
||||
response={200: str | Any, 403: Error},
|
||||
)
|
||||
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
|
||||
if e.edgeAsSmirks is None and (e.educts is None or e.products is None):
|
||||
raise ValueError("Either SMIRKS or educt/product must be provided")
|
||||
|
||||
if e.edgeAsSmirks is not None and (e.educts is not None and e.products is not None):
|
||||
raise ValueError("SMIRKS and educt/product provided!")
|
||||
|
||||
educts = []
|
||||
products = []
|
||||
|
||||
if e.edgeAsSmirks:
|
||||
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
|
||||
educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed))
|
||||
|
||||
for pr in e.edgeAsSmirks.split(">>")[1].split("\\."):
|
||||
products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr))
|
||||
else:
|
||||
for ed in e.educts.split(","):
|
||||
educts.append(Node.objects.get(pathway=pw, url=ed.strip()))
|
||||
|
||||
for pr in e.products.split(","):
|
||||
products.append(Node.objects.get(pathway=pw, url=pr.strip()))
|
||||
|
||||
new_e = Edge.create(
|
||||
pathway=pw,
|
||||
start_nodes=educts,
|
||||
end_nodes=products,
|
||||
rule=None,
|
||||
name=e.name,
|
||||
description=e.edgeReason,
|
||||
)
|
||||
|
||||
return redirect(new_e.url)
|
||||
except ValueError:
|
||||
return 403, {"message": "Adding node failed!"}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
|
||||
def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
|
||||
e.delete()
|
||||
return redirect(f"{pw.url}/edge")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Edge!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Edge with id {edge_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
#########
|
||||
# Model #
|
||||
#########
|
||||
class ModelWrapper(Schema):
|
||||
relative_reasoning: List["SimpleModel"] = Field(..., alias="relative-reasoning")
|
||||
|
||||
|
||||
class ModelSchema(Schema):
|
||||
aliases: List[str] = Field([], alias="aliases")
|
||||
description: str = Field(None, alias="description")
|
||||
evalPackages: List["SimplePackage"] = Field([])
|
||||
id: str = Field(None, alias="url")
|
||||
identifier: str = "relative-reasoning"
|
||||
# "info" : {
|
||||
# "Accuracy (Single-Gen)" : "0.5932962678936605" ,
|
||||
# "Area under PR-Curve (Single-Gen)" : "0.5654653182134282" ,
|
||||
# "Area under ROC-Curve (Single-Gen)" : "0.8178302405034772" ,
|
||||
# "Precision (Single-Gen)" : "0.6978730822873083" ,
|
||||
# "Probability Threshold" : "0.5" ,
|
||||
# "Recall/Sensitivity (Single-Gen)" : "0.4484149210261006"
|
||||
# } ,
|
||||
name: str = Field(None, alias="name")
|
||||
pathwayPackages: List["SimplePackage"] = Field([])
|
||||
reviewStatus: str = Field(None, alias="review_status")
|
||||
rulePackages: List["SimplePackage"] = Field([])
|
||||
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
|
||||
status: str
|
||||
statusMessage: str
|
||||
threshold: str
|
||||
type: str
|
||||
|
||||
|
||||
@router.get("/model", response={200: ModelWrapper, 403: Error})
|
||||
def get_models(request):
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/package/{uuid:package_uuid}/model", response={200: ModelWrapper, 403: Error})
|
||||
def get_package_models(request, package_uuid, model_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
return EPModel.objects.filter(package=p)
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Getting Reaction with id {model_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
class Classify(Schema):
|
||||
smiles: str | None = None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/package/{uuid:package_uuid}/model/{uuid:model_uuid}",
|
||||
response={200: ModelSchema | Any, 403: Error, 400: Error},
|
||||
)
|
||||
def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
mod = EPModel.objects.get(package=p, uuid=model_uuid)
|
||||
|
||||
if c.smiles:
|
||||
if c.smiles == "":
|
||||
return 400, {"message": "Received empty SMILES"}
|
||||
|
||||
try:
|
||||
stand_smiles = FormatConverter.standardize(c.smiles)
|
||||
except ValueError:
|
||||
return 400, {"message": f'"{c.smiles}" is not a valid SMILES'}
|
||||
|
||||
from epdb.tasks import dispatch_eager, predict_simple
|
||||
|
||||
_, pred_res = dispatch_eager(request.user, predict_simple, mod.pk, stand_smiles)
|
||||
|
||||
result = []
|
||||
|
||||
for pr in pred_res:
|
||||
if len(pr) > 0:
|
||||
products = []
|
||||
for prod_set in pr.product_sets:
|
||||
products.append(tuple([x for x in prod_set]))
|
||||
|
||||
res = {
|
||||
"probability": pr.probability,
|
||||
"products": list(set(products)),
|
||||
}
|
||||
|
||||
if pr.rule:
|
||||
res["id"] = pr.rule.url
|
||||
res["identifier"] = pr.rule.get_rule_identifier()
|
||||
res["name"] = pr.rule.name
|
||||
res["reviewStatus"] = (
|
||||
"reviewed" if pr.rule.package.reviewed else "unreviewed"
|
||||
)
|
||||
|
||||
result.append(res)
|
||||
|
||||
return result
|
||||
|
||||
return mod
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Getting Reaction with id {model_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/package/{uuid:package_uuid}/model/{uuid:model_uuid}")
|
||||
def delete_model(request, package_uuid, model_uuid):
|
||||
try:
|
||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||
|
||||
if PackageManager.writable(request.user, p):
|
||||
m = EPModel.objects.get(package=p, uuid=model_uuid)
|
||||
m.delete()
|
||||
return redirect(f"{p.url}/model")
|
||||
else:
|
||||
raise ValueError("You do not have the rights to delete this Model!")
|
||||
except ValueError:
|
||||
return 403, {
|
||||
"message": f"Deleting Model with id {model_uuid} failed due to insufficient rights!"
|
||||
}
|
||||
|
||||
|
||||
###########
|
||||
# Setting #
|
||||
###########
|
||||
|
||||
285
epdb/logic.py
285
epdb/logic.py
@ -1,39 +1,41 @@
|
||||
import re
|
||||
import logging
|
||||
import json
|
||||
from typing import Union, List, Optional, Set, Dict, Any
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Set, Union, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.conf import settings as s
|
||||
from pydantic import ValidationError
|
||||
|
||||
from epdb.models import (
|
||||
User,
|
||||
Package,
|
||||
UserPackagePermission,
|
||||
GroupPackagePermission,
|
||||
Permission,
|
||||
Group,
|
||||
Setting,
|
||||
EPModel,
|
||||
UserSettingPermission,
|
||||
Rule,
|
||||
Pathway,
|
||||
Node,
|
||||
Edge,
|
||||
Compound,
|
||||
Reaction,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
EnzymeLink,
|
||||
EPModel,
|
||||
ExpansionSchemeChoice,
|
||||
Group,
|
||||
GroupPackagePermission,
|
||||
Node,
|
||||
Pathway,
|
||||
Permission,
|
||||
Reaction,
|
||||
Rule,
|
||||
Setting,
|
||||
User,
|
||||
UserPackagePermission,
|
||||
UserSettingPermission,
|
||||
)
|
||||
from utilities.chem import FormatConverter
|
||||
from utilities.misc import PackageImporter, PackageExporter
|
||||
from utilities.misc import PackageExporter, PackageImporter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
class EPDBURLParser:
|
||||
UUID_PATTERN = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
|
||||
@ -442,6 +444,7 @@ class PackageManager(object):
|
||||
if PackageManager.readable(user, p):
|
||||
return p
|
||||
else:
|
||||
# FIXME: use custom exception to be translatable to 403 in API
|
||||
raise ValueError(
|
||||
"Insufficient permissions to access Package with ID {}".format(package_id)
|
||||
)
|
||||
@ -578,30 +581,39 @@ class PackageManager(object):
|
||||
else:
|
||||
_ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data)
|
||||
|
||||
@staticmethod
|
||||
def grant_read(caller: User, package: Package, grantee: Union[User, Group]):
|
||||
PackageManager.update_permissions(caller, package, grantee, Permission.READ[0])
|
||||
|
||||
@staticmethod
|
||||
def grant_write(caller: User, package: Package, grantee: Union[User, Group]):
|
||||
PackageManager.update_permissions(caller, package, grantee, Permission.WRITE[0])
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def import_legacy_package(
|
||||
data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False
|
||||
):
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from envipy_additional_information import AdditionalInformationConverter
|
||||
|
||||
from .models import (
|
||||
Package,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
SimpleRule,
|
||||
SimpleAmbitRule,
|
||||
Edge,
|
||||
Node,
|
||||
ParallelRule,
|
||||
Pathway,
|
||||
Reaction,
|
||||
Scenario,
|
||||
SequentialRule,
|
||||
SequentialRuleOrdering,
|
||||
Reaction,
|
||||
Pathway,
|
||||
Node,
|
||||
Edge,
|
||||
Scenario,
|
||||
SimpleAmbitRule,
|
||||
SimpleRule,
|
||||
)
|
||||
from envipy_additional_information import AdditionalInformationConverter
|
||||
|
||||
pack = Package()
|
||||
pack.uuid = UUID(data["id"].split("/")[-1]) if keep_ids else uuid4()
|
||||
@ -1106,6 +1118,7 @@ class SettingManager(object):
|
||||
rule_packages: List[Package] = None,
|
||||
model: EPModel = None,
|
||||
model_threshold: float = None,
|
||||
expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS,
|
||||
):
|
||||
new_s = Setting()
|
||||
# Clean for potential XSS
|
||||
@ -1388,6 +1401,9 @@ class SEdge(object):
|
||||
self.rule = rule
|
||||
self.probability = probability
|
||||
|
||||
def product_smiles(self):
|
||||
return [p.smiles for p in self.products]
|
||||
|
||||
def __hash__(self):
|
||||
full_hash = 0
|
||||
|
||||
@ -1473,6 +1489,7 @@ class SPathway(object):
|
||||
self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes})
|
||||
self.edges: Set["SEdge"] = set()
|
||||
self.done = False
|
||||
self.empty_due_to_threshold = False
|
||||
|
||||
@staticmethod
|
||||
def from_pathway(pw: "Pathway", persist: bool = True):
|
||||
@ -1537,22 +1554,32 @@ class SPathway(object):
|
||||
|
||||
return sorted(res, key=lambda x: hash(x))
|
||||
|
||||
def predict_step(self, from_depth: int = None, from_node: "Node" = None):
|
||||
substrates: List[SNode] = []
|
||||
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.
|
||||
|
||||
if from_depth is not None:
|
||||
substrates = self._get_nodes_for_depth(from_depth)
|
||||
elif from_node is not None:
|
||||
for k, v in self.snode_persist_lookup.items():
|
||||
if from_node == v:
|
||||
substrates = [k]
|
||||
break
|
||||
else:
|
||||
raise ValueError("Neither from_depth nor from_node_url specified")
|
||||
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] = []
|
||||
|
||||
new_tp = False
|
||||
if substrates:
|
||||
for sub in substrates:
|
||||
# For App Domain we have to ensure that each Node is evaluated
|
||||
if sub.app_domain_assessment is None:
|
||||
if self.prediction_setting.model:
|
||||
if self.prediction_setting.model.app_domain:
|
||||
@ -1563,9 +1590,9 @@ class SPathway(object):
|
||||
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!"
|
||||
)
|
||||
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
|
||||
@ -1575,11 +1602,25 @@ class SPathway(object):
|
||||
|
||||
sub.app_domain_assessment = app_domain_assessment
|
||||
|
||||
candidates = self.prediction_setting.expand(self, sub)
|
||||
expansion_result = self.prediction_setting.expand(self, sub)
|
||||
|
||||
# We don't have any substrate, but technically we have at least one rule that triggered.
|
||||
# If our substrate is a root node a.k.a. depth == 0 store that info in SPathway
|
||||
if (
|
||||
len(expansion_result["transformations"]) == 0
|
||||
and expansion_result["rule_triggered"]
|
||||
and sub.depth == 0
|
||||
):
|
||||
self.empty_due_to_threshold = True
|
||||
|
||||
# Emit directly
|
||||
if self.persist is not None:
|
||||
self.persist.kv["empty_due_to_threshold"] = True
|
||||
self.persist.save()
|
||||
|
||||
# candidates is a List of PredictionResult. The length of the List is equal to the number of rules
|
||||
for cand_set in candidates:
|
||||
for cand_set in expansion_result["transformations"]:
|
||||
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 = []
|
||||
@ -1593,10 +1634,9 @@ class SPathway(object):
|
||||
app_domain_assessment = (
|
||||
self.prediction_setting.model.app_domain.assess(c)
|
||||
)
|
||||
|
||||
self.smiles_to_node[c] = SNode(
|
||||
c, sub.depth + 1, app_domain_assessment
|
||||
)
|
||||
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)
|
||||
@ -1608,6 +1648,132 @@ class SPathway(object):
|
||||
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):
|
||||
substrates: List[SNode] = []
|
||||
|
||||
if from_depth is not None:
|
||||
substrates = self._get_nodes_for_depth(from_depth)
|
||||
elif from_node is not None:
|
||||
for k, v in self.snode_persist_lookup.items():
|
||||
if from_node == v:
|
||||
substrates = [k]
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Node {from_node} not found in SPathway!")
|
||||
else:
|
||||
raise ValueError("Neither from_depth nor from_node_url specified")
|
||||
|
||||
new_tp = False
|
||||
if substrates:
|
||||
new_nodes, _ = self._expand(substrates)
|
||||
new_tp = len(new_nodes) > 0
|
||||
|
||||
# In case no substrates are found, we're done.
|
||||
# For "predict from node" we're always done
|
||||
@ -1620,6 +1786,14 @@ class SPathway(object):
|
||||
# call save to update the internal modified field
|
||||
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:
|
||||
logger.info("Updating Pathway with SPathway")
|
||||
|
||||
@ -1683,11 +1857,6 @@ class SPathway(object):
|
||||
"to": to_indices,
|
||||
}
|
||||
|
||||
# if edge.rule:
|
||||
# e['rule'] = {
|
||||
# 'name': edge.rule.name,
|
||||
# 'id': edge.rule.url,
|
||||
# }
|
||||
edges.append(e)
|
||||
|
||||
return {
|
||||
|
||||
@ -2,7 +2,9 @@ from django.conf import settings as s
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from epdb.models import MLRelativeReasoning, EnviFormer, Package
|
||||
from epdb.models import EnviFormer, MLRelativeReasoning
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -75,11 +77,13 @@ class Command(BaseCommand):
|
||||
return packages
|
||||
|
||||
# Iteratively create models in options["model_names"]
|
||||
print(f"Creating models: {options['model_names']}\n"
|
||||
print(
|
||||
f"Creating models: {options['model_names']}\n"
|
||||
f"Data packages: {options['data_packages']}\n"
|
||||
f"Rule Packages (only for MLRR): {options['rule_packages']}\n"
|
||||
f"Eval Packages: {options['eval_packages']}\n"
|
||||
f"Threshold: {options['threshold']:.2f}")
|
||||
f"Threshold: {options['threshold']:.2f}"
|
||||
)
|
||||
data_packages = decode_packages(options["data_packages"])
|
||||
eval_packages = decode_packages(options["eval_packages"])
|
||||
rule_packages = decode_packages(options["rule_packages"])
|
||||
@ -89,8 +93,7 @@ class Command(BaseCommand):
|
||||
model = EnviFormer.create(
|
||||
pack,
|
||||
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}",
|
||||
description=f"EnviFormer transformer trained on {options['data_packages']} "
|
||||
f"evaluated on {options['eval_packages']}.",
|
||||
@ -100,8 +103,7 @@ class Command(BaseCommand):
|
||||
package=pack,
|
||||
rule_packages=rule_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}",
|
||||
description=f"ML Relative Reasoning trained on {options['data_packages']} with rules from "
|
||||
f"{options['rule_packages']} and evaluated on {options['eval_packages']}.",
|
||||
|
||||
@ -8,7 +8,9 @@ from django.conf import settings as s
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from epdb.models import EnviFormer, Package
|
||||
from epdb.models import EnviFormer
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings as s
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from django.db.models import F, Value, TextField, JSONField
|
||||
from django.db.models.functions import Replace, Cast
|
||||
from django.db.models import F, JSONField, TextField, Value
|
||||
from django.db.models.functions import Cast, Replace
|
||||
|
||||
from epdb.models import EnviPathModel
|
||||
|
||||
@ -23,10 +23,12 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
Package.objects.update(url=Replace(F("url"), Value(options["old"]), Value(options["new"])))
|
||||
|
||||
MODELS = [
|
||||
"User",
|
||||
"Group",
|
||||
"Package",
|
||||
"Compound",
|
||||
"CompoundStructure",
|
||||
"Pathway",
|
||||
@ -47,7 +49,6 @@ class Command(BaseCommand):
|
||||
]
|
||||
for model in MODELS:
|
||||
obj_cls = apps.get_model("epdb", model)
|
||||
print(f"Localizing urls for {model}")
|
||||
obj_cls.objects.update(
|
||||
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",
|
||||
),
|
||||
]
|
||||
522
epdb/models.py
522
epdb/models.py
@ -2,40 +2,41 @@ import abc
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
from abc import abstractmethod
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Union, List, Optional, Dict, Tuple, Set, Any
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
from uuid import uuid4
|
||||
import math
|
||||
|
||||
import joblib
|
||||
import nh3
|
||||
import numpy as np
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db import models, transaction
|
||||
from django.db.models import JSONField, Count, Q, QuerySet
|
||||
from django.db.models import Count, JSONField, Q, QuerySet
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from envipy_additional_information import EnviPyModel
|
||||
from envipy_additional_information import EnviPyModel, HalfLife
|
||||
from model_utils.models import TimeStampedModel
|
||||
from polymorphic.models import PolymorphicModel
|
||||
from sklearn.metrics import precision_score, recall_score, jaccard_score
|
||||
from sklearn.metrics import jaccard_score, precision_score, recall_score
|
||||
from sklearn.model_selection import ShuffleSplit
|
||||
|
||||
from utilities.chem import FormatConverter, ProductSet, PredictionResult, IndigoUtils
|
||||
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
|
||||
from utilities.ml import (
|
||||
RuleBasedDataset,
|
||||
ApplicabilityDomainPCA,
|
||||
EnsembleClassifierChain,
|
||||
RelativeReasoning,
|
||||
EnviFormerDataset,
|
||||
Dataset,
|
||||
EnsembleClassifierChain,
|
||||
EnviFormerDataset,
|
||||
RelativeReasoning,
|
||||
RuleBasedDataset,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -44,8 +45,6 @@ logger = logging.getLogger(__name__)
|
||||
##########################
|
||||
# User/Groups/Permission #
|
||||
##########################
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
email = models.EmailField(unique=True)
|
||||
uuid = models.UUIDField(
|
||||
@ -53,7 +52,10 @@ class User(AbstractUser):
|
||||
)
|
||||
url = models.TextField(blank=False, null=True, verbose_name="URL", unique=True)
|
||||
default_package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Default Package", null=True, on_delete=models.SET_NULL
|
||||
s.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Default Package",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
default_group = models.ForeignKey(
|
||||
"Group",
|
||||
@ -243,7 +245,7 @@ class UserPackagePermission(Permission):
|
||||
)
|
||||
user = models.ForeignKey("User", verbose_name="Permission to", on_delete=models.CASCADE)
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Permission on", on_delete=models.CASCADE
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Permission on", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -259,7 +261,7 @@ class GroupPackagePermission(Permission):
|
||||
)
|
||||
group = models.ForeignKey("Group", verbose_name="Permission to", on_delete=models.CASCADE)
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Permission on", on_delete=models.CASCADE
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Permission on", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -728,10 +730,13 @@ class Package(EnviPathModel):
|
||||
rules = sorted(rules, key=lambda x: x.url)
|
||||
return rules
|
||||
|
||||
class Meta:
|
||||
swappable = "EPDB_PACKAGE_MODEL"
|
||||
|
||||
|
||||
class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin):
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
)
|
||||
default_structure = models.ForeignKey(
|
||||
"CompoundStructure",
|
||||
@ -749,6 +754,30 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
|
||||
|
||||
@property
|
||||
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)
|
||||
|
||||
def _url(self):
|
||||
@ -766,9 +795,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
|
||||
|
||||
@property
|
||||
def related_pathways(self):
|
||||
pathways = Node.objects.filter(node_labels__in=[self.default_structure]).values_list(
|
||||
"pathway", flat=True
|
||||
)
|
||||
pathways = self.related_nodes.values_list("pathway", flat=True)
|
||||
return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by("name")
|
||||
|
||||
@property
|
||||
@ -778,10 +805,16 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
|
||||
| Reaction.objects.filter(package=self.package, products__in=[self.default_structure])
|
||||
).order_by("name")
|
||||
|
||||
@property
|
||||
def related_nodes(self):
|
||||
return Node.objects.filter(
|
||||
node_labels__in=[self.default_structure], pathway__package=self.package
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
package: Package, smiles: str, name: str = None, description: str = None, *args, **kwargs
|
||||
package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs
|
||||
) -> "Compound":
|
||||
if smiles is None or smiles.strip() == "":
|
||||
raise ValueError("SMILES is required")
|
||||
@ -896,15 +929,79 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
|
||||
if self in mapping:
|
||||
return mapping[self]
|
||||
|
||||
default_structure_smiles = self.default_structure.smiles
|
||||
normalized_structure_smiles = self.normalized_structure.smiles
|
||||
|
||||
existing_compound = None
|
||||
existing_normalized_compound = None
|
||||
|
||||
# Dedup check - Check if we find a direct match for a given SMILES
|
||||
if CompoundStructure.objects.filter(
|
||||
smiles=default_structure_smiles, compound__package=target
|
||||
).exists():
|
||||
existing_compound = CompoundStructure.objects.get(
|
||||
smiles=default_structure_smiles, compound__package=target
|
||||
).compound
|
||||
|
||||
# Check if we can find the standardized one
|
||||
if CompoundStructure.objects.filter(
|
||||
smiles=normalized_structure_smiles, compound__package=target
|
||||
).exists():
|
||||
existing_normalized_compound = CompoundStructure.objects.get(
|
||||
smiles=normalized_structure_smiles, compound__package=target
|
||||
).compound
|
||||
|
||||
if any([existing_compound, existing_normalized_compound]):
|
||||
if existing_normalized_compound and existing_compound:
|
||||
# We only have to set the mapping
|
||||
mapping[self] = existing_compound
|
||||
for structure in self.structures.all():
|
||||
if structure not in mapping:
|
||||
mapping[structure] = existing_compound.structures.get(
|
||||
smiles=structure.smiles
|
||||
)
|
||||
|
||||
return existing_compound
|
||||
|
||||
elif existing_normalized_compound:
|
||||
mapping[self] = existing_normalized_compound
|
||||
|
||||
# Merge the structure into the existing compound
|
||||
for structure in self.structures.all():
|
||||
if existing_normalized_compound.structures.filter(
|
||||
smiles=structure.smiles
|
||||
).exists():
|
||||
continue
|
||||
|
||||
# Create a new Structure
|
||||
cs = CompoundStructure.create(
|
||||
existing_normalized_compound,
|
||||
structure.smiles,
|
||||
name=structure.name,
|
||||
description=structure.description,
|
||||
normalized_structure=structure.normalized_structure,
|
||||
)
|
||||
|
||||
mapping[structure] = cs
|
||||
|
||||
return existing_normalized_compound
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Found a CompoundStructure for {default_structure_smiles} but not for {normalized_structure_smiles} in target package {target.name}"
|
||||
)
|
||||
else:
|
||||
# Here we can safely use Compound.objects.create as we won't end up in a duplicate
|
||||
new_compound = Compound.objects.create(
|
||||
package=target,
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
kv=self.kv.copy() if self.kv else {},
|
||||
)
|
||||
|
||||
mapping[self] = new_compound
|
||||
|
||||
# Copy compound structures
|
||||
# Copy underlying structures
|
||||
for structure in self.structures.all():
|
||||
if structure not in mapping:
|
||||
new_structure = CompoundStructure.objects.create(
|
||||
@ -949,6 +1046,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin
|
||||
|
||||
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:
|
||||
unique_together = [("uuid", "package")]
|
||||
|
||||
@ -1061,7 +1169,7 @@ class EnzymeLink(EnviPathModel, KEGGIdentifierMixin):
|
||||
|
||||
class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
)
|
||||
|
||||
# # https://github.com/django-polymorphic/django-polymorphic/issues/229
|
||||
@ -1074,6 +1182,10 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
def apply(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_rule_identifier(self) -> str:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def cls_for_type(rule_type: str):
|
||||
if rule_type == "SimpleAmbitRule":
|
||||
@ -1103,34 +1215,44 @@ class Rule(PolymorphicModel, EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
rule_type = type(self)
|
||||
|
||||
if rule_type == SimpleAmbitRule:
|
||||
new_rule = SimpleAmbitRule.objects.create(
|
||||
new_rule = SimpleAmbitRule.create(
|
||||
package=target,
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
smirks=self.smirks,
|
||||
reactant_filter_smarts=self.reactant_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:
|
||||
new_rule = SimpleRDKitRule.objects.create(
|
||||
new_rule = SimpleRDKitRule.create(
|
||||
package=target,
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
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:
|
||||
new_rule = ParallelRule.objects.create(
|
||||
package=target,
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
kv=self.kv.copy() if self.kv else {},
|
||||
)
|
||||
# Copy simple rules relationships
|
||||
new_srs = []
|
||||
for simple_rule in self.simple_rules.all():
|
||||
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:
|
||||
raise ValueError("SequentialRule copy not implemented!")
|
||||
else:
|
||||
@ -1167,7 +1289,7 @@ class SimpleAmbitRule(SimpleRule):
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
package: Package,
|
||||
package: "Package",
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
smirks: str = None,
|
||||
@ -1228,8 +1350,16 @@ class SimpleAmbitRule(SimpleRule):
|
||||
def _url(self):
|
||||
return "{}/simple-ambit-rule/{}".format(self.package.url, self.uuid)
|
||||
|
||||
def get_rule_identifier(self) -> str:
|
||||
return "simple-rule"
|
||||
|
||||
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
|
||||
def reactants_smarts(self):
|
||||
@ -1241,7 +1371,7 @@ class SimpleAmbitRule(SimpleRule):
|
||||
|
||||
@property
|
||||
def related_reactions(self):
|
||||
qs = Package.objects.filter(reviewed=True)
|
||||
qs = s.GET_PACKAGE_MODEL().objects.filter(reviewed=True)
|
||||
return self.reaction_rule.filter(package__in=qs).order_by("name")
|
||||
|
||||
@property
|
||||
@ -1273,6 +1403,9 @@ class ParallelRule(Rule):
|
||||
def _url(self):
|
||||
return "{}/parallel-rule/{}".format(self.package.url, self.uuid)
|
||||
|
||||
def get_rule_identifier(self) -> str:
|
||||
return "parallel-rule"
|
||||
|
||||
@cached_property
|
||||
def srs(self) -> QuerySet:
|
||||
return self.simple_rules.all()
|
||||
@ -1304,6 +1437,71 @@ class ParallelRule(Rule):
|
||||
|
||||
return res
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
package: "Package",
|
||||
simple_rules: List["SimpleRule"],
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
reactant_filter_smarts: str = None,
|
||||
product_filter_smarts: str = None,
|
||||
):
|
||||
if len(simple_rules) == 0:
|
||||
raise ValueError("At least one simple rule is required!")
|
||||
|
||||
for sr in simple_rules:
|
||||
if sr.package != package:
|
||||
raise ValueError(
|
||||
f"Simple rule {sr.uuid} does not belong to package {package.uuid}!"
|
||||
)
|
||||
|
||||
# Deduplication check
|
||||
query = ParallelRule.objects.annotate(
|
||||
srs_count=Count("simple_rules", filter=Q(simple_rules__in=simple_rules), distinct=True)
|
||||
)
|
||||
|
||||
existing_rule_qs = query.filter(
|
||||
srs_count=len(simple_rules),
|
||||
)
|
||||
|
||||
if existing_rule_qs.exists():
|
||||
if existing_rule_qs.count() > 1:
|
||||
logger.error(f"Found more than one reaction for given input! {existing_rule_qs}")
|
||||
return existing_rule_qs.first()
|
||||
|
||||
r = ParallelRule()
|
||||
r.package = package
|
||||
|
||||
if name is not None:
|
||||
name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
if name is None or name == "":
|
||||
name = f"Rule {Rule.objects.filter(package=package).count() + 1}"
|
||||
|
||||
r.name = name
|
||||
if description is not None and description.strip() != "":
|
||||
r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
|
||||
if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "":
|
||||
if not FormatConverter.is_valid_smarts(reactant_filter_smarts.strip()):
|
||||
raise ValueError(f'Reactant Filter SMARTS "{reactant_filter_smarts}" is invalid!')
|
||||
else:
|
||||
r.reactant_filter_smarts = reactant_filter_smarts.strip()
|
||||
|
||||
if product_filter_smarts is not None and product_filter_smarts.strip() != "":
|
||||
if not FormatConverter.is_valid_smarts(product_filter_smarts.strip()):
|
||||
raise ValueError(f'Product Filter SMARTS "{product_filter_smarts}" is invalid!')
|
||||
else:
|
||||
r.product_filter_smarts = product_filter_smarts.strip()
|
||||
|
||||
r.save()
|
||||
|
||||
for sr in simple_rules:
|
||||
r.simple_rules.add(sr)
|
||||
|
||||
return r
|
||||
|
||||
|
||||
class SequentialRule(Rule):
|
||||
simple_rules = models.ManyToManyField(
|
||||
@ -1313,6 +1511,9 @@ class SequentialRule(Rule):
|
||||
def _url(self):
|
||||
return "{}/sequential-rule/{}".format(self.compound.url, self.uuid)
|
||||
|
||||
def get_rule_identifier(self) -> str:
|
||||
return "sequential-rule"
|
||||
|
||||
@property
|
||||
def srs(self):
|
||||
return self.simple_rules.all()
|
||||
@ -1333,7 +1534,7 @@ class SequentialRuleOrdering(models.Model):
|
||||
|
||||
class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin):
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
)
|
||||
educts = models.ManyToManyField(
|
||||
"epdb.CompoundStructure", verbose_name="Educts", related_name="reaction_educts"
|
||||
@ -1355,7 +1556,7 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create(
|
||||
package: Package,
|
||||
package: "Package",
|
||||
name: str = None,
|
||||
description: str = None,
|
||||
educts: Union[List[str], List[CompoundStructure]] = None,
|
||||
@ -1450,31 +1651,44 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
|
||||
if self in mapping:
|
||||
return mapping[self]
|
||||
|
||||
# Create new reaction
|
||||
new_reaction = Reaction.objects.create(
|
||||
package=target,
|
||||
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
|
||||
copied_reaction_educts = []
|
||||
copied_reaction_products = []
|
||||
copied_reaction_rules = []
|
||||
|
||||
# Copy educts (reactant compounds)
|
||||
for educt in self.educts.all():
|
||||
copied_educt = educt.copy(target, mapping)
|
||||
new_reaction.educts.add(copied_educt)
|
||||
copied_reaction_educts.append(copied_educt)
|
||||
|
||||
# Copy products
|
||||
for product in self.products.all():
|
||||
copied_product = product.copy(target, mapping)
|
||||
new_reaction.products.add(copied_product)
|
||||
copied_reaction_products.append(copied_product)
|
||||
|
||||
# Copy rules
|
||||
for rule in self.rules.all():
|
||||
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
|
||||
for ext_id in self.external_identifiers.all():
|
||||
@ -1514,11 +1728,12 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin
|
||||
|
||||
class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
)
|
||||
setting = models.ForeignKey(
|
||||
"epdb.Setting", verbose_name="Setting", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
predicted = models.BooleanField(default=False, null=False)
|
||||
|
||||
@property
|
||||
def root_nodes(self):
|
||||
@ -1544,6 +1759,16 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
# potentially prefetched edge_set
|
||||
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):
|
||||
return "{}/pathway/{}".format(self.package.url, self.uuid)
|
||||
|
||||
@ -1570,6 +1795,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
def failed(self):
|
||||
return self.status() == "failed"
|
||||
|
||||
def empty_due_to_threshold(self):
|
||||
return self.kv.get("empty_due_to_threshold", False)
|
||||
|
||||
def d3_json(self):
|
||||
# Ideally it would be something like this but
|
||||
# to reduce crossing in edges do a DFS
|
||||
@ -1591,11 +1819,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
while len(queue):
|
||||
current = queue.pop()
|
||||
processed.add(current)
|
||||
|
||||
nodes.append(current.d3_json())
|
||||
|
||||
for e in self.edges:
|
||||
if current in e.start_nodes.all():
|
||||
for e in self.edges.filter(start_nodes=current).distinct():
|
||||
for prod in e.end_nodes.all():
|
||||
if prod not in queue and prod not in processed:
|
||||
queue.append(prod)
|
||||
@ -1679,15 +1905,18 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
"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 io
|
||||
|
||||
rows = []
|
||||
rows.append(
|
||||
[
|
||||
header = []
|
||||
|
||||
if include_pathway_url:
|
||||
header += ["Pathway URL"]
|
||||
|
||||
header += [
|
||||
"SMILES",
|
||||
"name",
|
||||
"depth",
|
||||
@ -1696,10 +1925,20 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
"rule_ids",
|
||||
"parent_smiles",
|
||||
]
|
||||
)
|
||||
|
||||
rows = []
|
||||
|
||||
if include_header:
|
||||
rows.append(header)
|
||||
|
||||
for n in self.nodes.order_by("depth"):
|
||||
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])
|
||||
if len(edges):
|
||||
@ -1730,6 +1969,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
smiles: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
predicted: bool = False,
|
||||
):
|
||||
pw = Pathway()
|
||||
pw.package = package
|
||||
@ -1742,6 +1982,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
pw.name = name
|
||||
if description is not None and description.strip() != "":
|
||||
pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||
pw.predicted = predicted
|
||||
|
||||
pw.save()
|
||||
try:
|
||||
@ -1761,6 +2002,8 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
return mapping[self]
|
||||
|
||||
# Start copying the pathway
|
||||
# Its safe to use .objects.create here as Pathways itself aren't
|
||||
# deduplicated
|
||||
new_pathway = Pathway.objects.create(
|
||||
package=target,
|
||||
name=self.name,
|
||||
@ -1872,6 +2115,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
)
|
||||
out_edges = models.ManyToManyField("epdb.Edge", verbose_name="Outgoing Edges")
|
||||
depth = models.IntegerField(verbose_name="Node depth", null=False, blank=False)
|
||||
stereo_removed = models.BooleanField(default=False, null=False)
|
||||
|
||||
def _url(self):
|
||||
return "{}/node/{}".format(self.pathway.url, self.uuid)
|
||||
@ -1881,6 +2125,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
|
||||
return {
|
||||
"depth": self.depth,
|
||||
"stereo_removed": self.stereo_removed,
|
||||
"url": self.url,
|
||||
"node_label_id": self.default_node_label.url,
|
||||
"image": f"{self.url}?image=svg",
|
||||
@ -1896,6 +2141,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
else None,
|
||||
"uncovered_functional_groups": False,
|
||||
},
|
||||
"is_engineered_intermediate": self.kv.get("is_engineered_intermediate", False),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@ -1906,12 +2152,17 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
name: 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)
|
||||
|
||||
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)
|
||||
|
||||
n = Node()
|
||||
n.stereo_removed = stereo_removed
|
||||
n.pathway = pathway
|
||||
n.depth = depth
|
||||
|
||||
@ -2076,7 +2327,7 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin):
|
||||
|
||||
class EPModel(PolymorphicModel, EnviPathModel):
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
)
|
||||
|
||||
def _url(self):
|
||||
@ -2085,17 +2336,17 @@ class EPModel(PolymorphicModel, EnviPathModel):
|
||||
|
||||
class PackageBasedModel(EPModel):
|
||||
rule_packages = models.ManyToManyField(
|
||||
"Package",
|
||||
s.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Rule Packages",
|
||||
related_name="%(app_label)s_%(class)s_rule_packages",
|
||||
)
|
||||
data_packages = models.ManyToManyField(
|
||||
"Package",
|
||||
s.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Data Packages",
|
||||
related_name="%(app_label)s_%(class)s_data_packages",
|
||||
)
|
||||
eval_packages = models.ManyToManyField(
|
||||
"Package",
|
||||
s.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Evaluation Packages",
|
||||
related_name="%(app_label)s_%(class)s_eval_packages",
|
||||
)
|
||||
@ -2152,6 +2403,29 @@ class PackageBasedModel(EPModel):
|
||||
|
||||
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
|
||||
def applicable_rules(self) -> List["Rule"]:
|
||||
"""
|
||||
@ -2213,6 +2487,13 @@ class PackageBasedModel(EPModel):
|
||||
return Dataset.load(ds_path)
|
||||
|
||||
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_model()
|
||||
|
||||
@ -2250,7 +2531,7 @@ class PackageBasedModel(EPModel):
|
||||
self.save()
|
||||
|
||||
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}!")
|
||||
|
||||
if multigen:
|
||||
@ -2258,9 +2539,12 @@ class PackageBasedModel(EPModel):
|
||||
self.save()
|
||||
|
||||
if eval_packages is not None:
|
||||
self.eval_packages.clear()
|
||||
for p in eval_packages:
|
||||
self.eval_packages.add(p)
|
||||
|
||||
self.eval_results = {}
|
||||
|
||||
self.model_status = self.EVALUATING
|
||||
self.save()
|
||||
|
||||
@ -2314,9 +2598,14 @@ class PackageBasedModel(EPModel):
|
||||
recall = {f"{t:.2f}": [] for t in thresholds}
|
||||
|
||||
# Note: only one root compound supported at this time
|
||||
root_compounds = [
|
||||
[p.default_node_label.smiles for p in p.root_nodes][0] for p in pathways
|
||||
]
|
||||
root_compounds = []
|
||||
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
|
||||
# pass it to the setting used in prediction
|
||||
@ -2340,7 +2629,7 @@ class PackageBasedModel(EPModel):
|
||||
for i, root in enumerate(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
|
||||
|
||||
while not spw.done:
|
||||
@ -3123,7 +3412,7 @@ class EnviFormer(PackageBasedModel):
|
||||
return args
|
||||
|
||||
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}!")
|
||||
|
||||
if multigen:
|
||||
@ -3131,9 +3420,12 @@ class EnviFormer(PackageBasedModel):
|
||||
self.save()
|
||||
|
||||
if eval_packages is not None:
|
||||
self.eval_packages.clear()
|
||||
for p in eval_packages:
|
||||
self.eval_packages.add(p)
|
||||
|
||||
self.eval_results = {}
|
||||
|
||||
self.model_status = self.EVALUATING
|
||||
self.save()
|
||||
|
||||
@ -3400,7 +3692,7 @@ class PluginModel(EPModel):
|
||||
|
||||
class Scenario(EnviPathModel):
|
||||
package = models.ForeignKey(
|
||||
"epdb.Package", verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
s.EPDB_PACKAGE_MODEL, verbose_name="Package", on_delete=models.CASCADE, db_index=True
|
||||
)
|
||||
scenario_date = models.CharField(max_length=256, null=False, blank=False, default="No date")
|
||||
scenario_type = models.CharField(
|
||||
@ -3543,6 +3835,12 @@ class UserSettingPermission(Permission):
|
||||
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):
|
||||
public = models.BooleanField(null=False, blank=False, default=False)
|
||||
global_default = models.BooleanField(null=False, blank=False, default=False)
|
||||
@ -3555,7 +3853,7 @@ class Setting(EnviPathModel):
|
||||
)
|
||||
|
||||
rule_packages = models.ManyToManyField(
|
||||
"Package",
|
||||
s.EPDB_PACKAGE_MODEL,
|
||||
verbose_name="Setting Rule Packages",
|
||||
related_name="setting_rule_packages",
|
||||
blank=True,
|
||||
@ -3567,6 +3865,12 @@ class Setting(EnviPathModel):
|
||||
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):
|
||||
return "{}/setting/{}".format(s.SERVER_URL, self.uuid)
|
||||
|
||||
@ -3601,33 +3905,48 @@ class Setting(EnviPathModel):
|
||||
rules = sorted(rules, key=lambda x: x.url)
|
||||
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"""
|
||||
if pathway.num_nodes() >= self.max_nodes:
|
||||
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:
|
||||
logger.info(
|
||||
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:
|
||||
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:
|
||||
if pred_result.probability >= self.model_threshold:
|
||||
transformations.append(pred_result)
|
||||
if (
|
||||
len(pred_result.product_sets)
|
||||
and pred_result.probability >= self.model_threshold
|
||||
):
|
||||
res["transformations"].append(pred_result)
|
||||
else:
|
||||
for rule in self.applicable_rules:
|
||||
tmp_products = rule.apply(current_node.smiles)
|
||||
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
|
||||
def make_global_default(self):
|
||||
@ -3660,10 +3979,6 @@ class JobLog(TimeStampedModel):
|
||||
done_at = models.DateTimeField(null=True, blank=True, default=None)
|
||||
task_result = models.TextField(null=True, blank=True, default=None)
|
||||
|
||||
def check_for_update(self):
|
||||
async_res = self.get_result()
|
||||
new_status = async_res.state
|
||||
|
||||
TERMINAL_STATES = [
|
||||
"SUCCESS",
|
||||
"FAILURE",
|
||||
@ -3671,12 +3986,22 @@ class JobLog(TimeStampedModel):
|
||||
"IGNORED",
|
||||
]
|
||||
|
||||
if new_status != self.status and new_status in TERMINAL_STATES:
|
||||
def is_in_terminal_state(self):
|
||||
return self.status in self.TERMINAL_STATES
|
||||
|
||||
def check_for_update(self):
|
||||
if self.is_in_terminal_state():
|
||||
return
|
||||
|
||||
async_res = self.get_result()
|
||||
new_status = async_res.state
|
||||
|
||||
if new_status != self.status and new_status in self.TERMINAL_STATES:
|
||||
self.status = new_status
|
||||
self.done_at = async_res.date_done
|
||||
|
||||
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()
|
||||
|
||||
@ -3687,3 +4012,18 @@ class JobLog(TimeStampedModel):
|
||||
from celery.result import AsyncResult
|
||||
|
||||
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
|
||||
|
||||
168
epdb/tasks.py
168
epdb/tasks.py
@ -6,14 +6,18 @@ from uuid import uuid4
|
||||
|
||||
from celery import shared_task
|
||||
from celery.utils.functional import LRUCache
|
||||
from django.conf import settings as s
|
||||
from django.utils import timezone
|
||||
|
||||
from epdb.logic import SPathway
|
||||
from epdb.models import Edge, EPModel, JobLog, Node, Package, Pathway, Rule, Setting, User
|
||||
from epdb.models import Edge, EPModel, JobLog, Node, Pathway, Rule, Setting, User
|
||||
from utilities.chem import FormatConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ML_CACHE = LRUCache(3) # Cache the three most recent ML models to reduce load times.
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
def get_ml_model(model_pk: int):
|
||||
if model_pk not in ML_CACHE:
|
||||
@ -33,7 +37,7 @@ def dispatch_eager(user: "User", job: Callable, *args, **kwargs):
|
||||
log.task_result = str(x) if x else None
|
||||
log.save()
|
||||
|
||||
return x
|
||||
return log, x
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise e
|
||||
@ -49,7 +53,7 @@ def dispatch(user: "User", job: Callable, *args, **kwargs):
|
||||
log.status = "INITIAL"
|
||||
log.save()
|
||||
|
||||
return x.result
|
||||
return log
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise e
|
||||
@ -136,14 +140,25 @@ def predict(
|
||||
pred_setting_pk: int,
|
||||
limit: Optional[int] = None,
|
||||
node_pk: Optional[int] = None,
|
||||
setting_overrides: Optional[dict] = None,
|
||||
) -> Pathway:
|
||||
pw = Pathway.objects.get(id=pw_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 setting.model is not None:
|
||||
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()
|
||||
|
||||
if JobLog.objects.filter(task_id=self.request.id).exists():
|
||||
@ -168,10 +183,12 @@ def predict(
|
||||
spw = SPathway.from_pathway(pw)
|
||||
spw.predict_step(from_node=n)
|
||||
else:
|
||||
raise ValueError("Neither limit nor node_pk given!")
|
||||
spw = SPathway(prediction_setting=setting, persist=pw)
|
||||
spw.predict()
|
||||
|
||||
except Exception as e:
|
||||
pw.kv.update({"status": "failed"})
|
||||
pw.kv.update(**{"error": str(e)})
|
||||
pw.save()
|
||||
|
||||
if JobLog.objects.filter(task_id=self.request.id).exists():
|
||||
@ -281,3 +298,144 @@ def identify_missing_rules(
|
||||
buffer.seek(0)
|
||||
|
||||
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"^search$", v.search, name="search"),
|
||||
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
|
||||
re_path(rf"^user/(?P<user_uuid>{UUID})", v.user, name="user"),
|
||||
# Group Detail
|
||||
@ -196,7 +197,8 @@ urlpatterns = [
|
||||
re_path(r"^indigo/dearomatize$", v.dearomatize, name="indigo_dearomatize"),
|
||||
re_path(r"^indigo/layout$", v.layout, name="indigo_layout"),
|
||||
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
|
||||
path("o/userinfo/", v.userinfo, name="oauth_userinfo"),
|
||||
# Static Pages
|
||||
|
||||
602
epdb/views.py
602
epdb/views.py
@ -1,58 +1,63 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any, Dict, List
|
||||
from datetime import datetime
|
||||
|
||||
import nh3
|
||||
from django.conf import settings as s
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.http import JsonResponse, HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest
|
||||
from django.shortcuts import render, redirect
|
||||
from django.core.exceptions import BadRequest, PermissionDenied
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from envipy_additional_information import NAME_MAPPING
|
||||
from oauth2_provider.decorators import protected_resource
|
||||
import nh3
|
||||
|
||||
from utilities.chem import FormatConverter, IndigoUtils
|
||||
from utilities.decorators import package_permission_required
|
||||
from utilities.misc import HTMLGenerator
|
||||
|
||||
from .logic import (
|
||||
EPDBURLParser,
|
||||
GroupManager,
|
||||
PackageManager,
|
||||
UserManager,
|
||||
SettingManager,
|
||||
SearchManager,
|
||||
EPDBURLParser,
|
||||
SettingManager,
|
||||
UserManager,
|
||||
)
|
||||
from .models import (
|
||||
Package,
|
||||
GroupPackagePermission,
|
||||
Group,
|
||||
CompoundStructure,
|
||||
APIToken,
|
||||
Compound,
|
||||
CompoundStructure,
|
||||
Edge,
|
||||
EnviFormer,
|
||||
EnzymeLink,
|
||||
EPModel,
|
||||
ExternalDatabase,
|
||||
ExternalIdentifier,
|
||||
Group,
|
||||
GroupPackagePermission,
|
||||
JobLog,
|
||||
License,
|
||||
MLRelativeReasoning,
|
||||
Node,
|
||||
Pathway,
|
||||
Permission,
|
||||
Reaction,
|
||||
Rule,
|
||||
Pathway,
|
||||
Node,
|
||||
EPModel,
|
||||
EnviFormer,
|
||||
MLRelativeReasoning,
|
||||
RuleBasedRelativeReasoning,
|
||||
Scenario,
|
||||
SimpleAmbitRule,
|
||||
APIToken,
|
||||
UserPackagePermission,
|
||||
Permission,
|
||||
License,
|
||||
User,
|
||||
Edge,
|
||||
ExternalDatabase,
|
||||
ExternalIdentifier,
|
||||
EnzymeLink,
|
||||
JobLog,
|
||||
UserPackagePermission,
|
||||
ExpansionSchemeChoice,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
def log_post_params(request):
|
||||
if s.DEBUG:
|
||||
@ -60,6 +65,26 @@ def log_post_params(request):
|
||||
logger.debug(f"{k}\t{v}")
|
||||
|
||||
|
||||
def get_error_handler_context(request, for_user=None) -> Dict[str, Any]:
|
||||
current_user = _anonymous_or_real(request)
|
||||
|
||||
if for_user:
|
||||
current_user = for_user
|
||||
|
||||
ctx = {
|
||||
"title": "enviPath",
|
||||
"meta": {
|
||||
"site_id": s.MATOMO_SITE_ID,
|
||||
"version": "0.0.1",
|
||||
"server_url": s.SERVER_URL,
|
||||
"user": current_user,
|
||||
"enabled_features": s.FLAGS,
|
||||
"debug": s.DEBUG,
|
||||
},
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
def error(request, message: str, detail: str, code: int = 400):
|
||||
context = get_base_context(request)
|
||||
error_context = {
|
||||
@ -74,6 +99,48 @@ def error(request, message: str, detail: str, code: int = 400):
|
||||
return render(request, "errors/error.html", context, status=code)
|
||||
|
||||
|
||||
def handler400(request, exception):
|
||||
"""Custom 400 Bad Request error handler"""
|
||||
context = get_error_handler_context(request)
|
||||
context["public_mode"] = True
|
||||
return render(request, "errors/400_bad_request.html", context, status=400)
|
||||
|
||||
|
||||
def handler403(request, exception):
|
||||
"""Custom 403 Forbidden error handler"""
|
||||
context = get_error_handler_context(request)
|
||||
context["public_mode"] = True
|
||||
return render(request, "errors/403_access_denied.html", context, status=403)
|
||||
|
||||
|
||||
def handler404(request, exception):
|
||||
"""Custom 404 Not Found error handler"""
|
||||
context = get_error_handler_context(request)
|
||||
context["public_mode"] = True
|
||||
return render(request, "errors/404_not_found.html", context, status=404)
|
||||
|
||||
|
||||
def handler500(request):
|
||||
"""Custom 500 Internal Server Error handler"""
|
||||
context = get_error_handler_context(request)
|
||||
|
||||
error_context = {}
|
||||
error_context["error_message"] = "Internal Server Error"
|
||||
error_context["error_detail"] = "An unexpected error occurred. Please try again later."
|
||||
|
||||
if request.headers.get("Accept") == "application/json":
|
||||
return JsonResponse(error_context, status=500)
|
||||
|
||||
context["public_mode"] = True
|
||||
context["error_code"] = 500
|
||||
context["error_description"] = (
|
||||
"We encountered an unexpected error while processing your request. Our team has been notified and is working to resolve the issue."
|
||||
)
|
||||
context.update(**error_context)
|
||||
|
||||
return render(request, "errors/error.html", context, status=500)
|
||||
|
||||
|
||||
def login(request):
|
||||
context = get_base_context(request)
|
||||
|
||||
@ -83,8 +150,7 @@ def login(request):
|
||||
return render(request, "static/login.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth import login
|
||||
from django.contrib.auth import authenticate, login
|
||||
|
||||
username = request.POST.get("username").strip()
|
||||
if username != request.POST.get("username"):
|
||||
@ -191,8 +257,8 @@ def register(request):
|
||||
|
||||
|
||||
def editable(request, user):
|
||||
if user.is_superuser:
|
||||
return True
|
||||
# if user.is_superuser:
|
||||
# return True
|
||||
|
||||
url = request.build_absolute_uri(request.path)
|
||||
if PackageManager.is_package_url(url):
|
||||
@ -256,7 +322,7 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def _anonymous_or_real(request):
|
||||
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 get_user_model().objects.get(username="anonymous")
|
||||
|
||||
@ -374,6 +440,18 @@ def predict_pathway(request):
|
||||
return render(request, "predict_pathway.html", context)
|
||||
|
||||
|
||||
def batch_predict_pathway(request):
|
||||
"""Top-level predict pathway view using user's default package."""
|
||||
if request.method != "GET":
|
||||
return HttpResponseNotAllowed(["GET"])
|
||||
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Batch Predict Pathway"
|
||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||
|
||||
return render(request, "batch_predict_pathway.html", context)
|
||||
|
||||
|
||||
@package_permission_required()
|
||||
def package_predict_pathway(request, package_uuid):
|
||||
"""Package-specific predict pathway view."""
|
||||
@ -396,20 +474,15 @@ def packages(request):
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Packages"
|
||||
|
||||
context["object_type"] = "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")
|
||||
unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user).order_by(
|
||||
"name"
|
||||
)
|
||||
# Context for paginated template
|
||||
context["entity_type"] = "package"
|
||||
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
|
||||
context["unreviewed_objects"] = unreviewed_package_qs
|
||||
|
||||
return render(request, "collections/objects_list.html", context)
|
||||
return render(request, "collections/packages_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
if hidden := request.POST.get("hidden", None):
|
||||
@ -455,29 +528,16 @@ def compounds(request):
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Compounds"
|
||||
|
||||
context["object_type"] = "compound"
|
||||
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():
|
||||
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)
|
||||
return render(request, "collections/compounds_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
# delegate to default package
|
||||
@ -493,32 +553,19 @@ def rules(request):
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Rules"
|
||||
|
||||
context["object_type"] = "rule"
|
||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||
context["breadcrumbs"] = [
|
||||
{"Home": s.SERVER_URL},
|
||||
{"Rule": s.SERVER_URL + "/rule"},
|
||||
]
|
||||
reviewed_rule_qs = Rule.objects.none()
|
||||
|
||||
for p in PackageManager.get_reviewed_packages():
|
||||
reviewed_rule_qs |= Rule.objects.filter(package=p)
|
||||
# Context for paginated template
|
||||
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")
|
||||
|
||||
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)
|
||||
return render(request, "collections/rules_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
# delegate to default package
|
||||
@ -534,32 +581,19 @@ def reactions(request):
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Reactions"
|
||||
|
||||
context["object_type"] = "reaction"
|
||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||
context["breadcrumbs"] = [
|
||||
{"Home": s.SERVER_URL},
|
||||
{"Reaction": s.SERVER_URL + "/reaction"},
|
||||
]
|
||||
reviewed_reaction_qs = Reaction.objects.none()
|
||||
|
||||
for p in PackageManager.get_reviewed_packages():
|
||||
reviewed_reaction_qs |= Reaction.objects.filter(package=p).order_by("name")
|
||||
# Context for paginated template
|
||||
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")
|
||||
|
||||
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)
|
||||
return render(request, "collections/reactions_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
# delegate to default package
|
||||
@ -575,33 +609,19 @@ def pathways(request):
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Pathways"
|
||||
|
||||
context["object_type"] = "pathway"
|
||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||
context["breadcrumbs"] = [
|
||||
{"Home": s.SERVER_URL},
|
||||
{"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():
|
||||
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)
|
||||
return render(request, "collections/pathways_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
# delegate to default package
|
||||
@ -625,25 +645,13 @@ def scenarios(request):
|
||||
{"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():
|
||||
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)
|
||||
return render(request, "collections/scenarios_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
# delegate to default package
|
||||
@ -658,42 +666,28 @@ def models(request):
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Models"
|
||||
|
||||
context["object_type"] = "model"
|
||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||
context["breadcrumbs"] = [
|
||||
{"Home": s.SERVER_URL},
|
||||
{"Model": s.SERVER_URL + "/model"},
|
||||
]
|
||||
|
||||
# Keep model_types for potential modal/action use
|
||||
context["model_types"] = {
|
||||
"ML Relative Reasoning": "ml-relative-reasoning",
|
||||
"Rule Based Relative Reasoning": "rule-based-relative-reasoning",
|
||||
"EnviFormer": "enviformer",
|
||||
}
|
||||
|
||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||
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():
|
||||
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)
|
||||
return render(request, "collections/models_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
current_user = _anonymous_or_real(request)
|
||||
@ -770,6 +764,10 @@ def package_models(request, package_uuid):
|
||||
context["meta"]["current_package"] = current_package
|
||||
context["object_type"] = "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()
|
||||
unreviewed_model_qs = EPModel.objects.none()
|
||||
@ -791,9 +789,6 @@ def package_models(request, package_uuid):
|
||||
}
|
||||
)
|
||||
|
||||
context["reviewed_objects"] = reviewed_model_qs
|
||||
context["unreviewed_objects"] = unreviewed_model_qs
|
||||
|
||||
context["model_types"] = {
|
||||
"ML Relative Reasoning": "mlrr",
|
||||
"Rule Based Relative Reasoning": "rbrr",
|
||||
@ -806,7 +801,7 @@ def package_models(request, package_uuid):
|
||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||
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":
|
||||
log_post_params(request)
|
||||
@ -872,7 +867,7 @@ def package_models(request, package_uuid):
|
||||
request, "Invalid model type.", f'Model type "{model_type}" is not supported."'
|
||||
)
|
||||
|
||||
from .tasks import dispatch, build_model
|
||||
from .tasks import build_model, dispatch
|
||||
|
||||
dispatch(current_user, build_model, mod.pk)
|
||||
|
||||
@ -897,19 +892,20 @@ def package_model(request, package_uuid, model_uuid):
|
||||
# Check if smiles is non empty and valid
|
||||
if smiles == "":
|
||||
return JsonResponse({"error": "Received empty SMILES"}, status=400)
|
||||
|
||||
stereo = FormatConverter.has_stereo(smiles)
|
||||
try:
|
||||
stand_smiles = FormatConverter.standardize(smiles)
|
||||
stand_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
||||
except ValueError:
|
||||
return JsonResponse({"error": f'"{smiles}" is not a valid SMILES'}, status=400)
|
||||
|
||||
if classify:
|
||||
from epdb.tasks import dispatch_eager, predict_simple
|
||||
|
||||
res = dispatch_eager(current_user, predict_simple, current_model.pk, stand_smiles)
|
||||
_, pred_res = dispatch_eager(
|
||||
current_user, predict_simple, current_model.pk, stand_smiles
|
||||
)
|
||||
|
||||
pred_res = current_model.predict(stand_smiles)
|
||||
res = []
|
||||
res = {"pred": [], "stereo": stereo}
|
||||
|
||||
for pr in pred_res:
|
||||
if len(pr) > 0:
|
||||
@ -918,7 +914,7 @@ def package_model(request, package_uuid, model_uuid):
|
||||
logger.debug(f"Checking {prod_set}")
|
||||
products.append(tuple([x for x in prod_set]))
|
||||
|
||||
res.append(
|
||||
res["pred"].append(
|
||||
{
|
||||
"products": list(set(products)),
|
||||
"probability": pr.probability,
|
||||
@ -1068,9 +1064,7 @@ def package(request, package_uuid):
|
||||
return redirect(s.SERVER_URL + "/package")
|
||||
elif hidden == "publish-package":
|
||||
for g in Group.objects.filter(public=True):
|
||||
PackageManager.update_permissions(
|
||||
current_user, current_package, g, Permission.READ[0]
|
||||
)
|
||||
PackageManager.grant_read(current_user, current_package, g)
|
||||
return redirect(current_package.url)
|
||||
elif hidden == "copy":
|
||||
object_to_copy = request.POST.get("object_to_copy")
|
||||
@ -1165,6 +1159,11 @@ def package_compounds(request, package_uuid):
|
||||
context["meta"]["current_package"] = current_package
|
||||
context["object_type"] = "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()
|
||||
unreviewed_compound_qs = Compound.objects.none()
|
||||
@ -1190,17 +1189,18 @@ def package_compounds(request, package_uuid):
|
||||
}
|
||||
)
|
||||
|
||||
context["reviewed_objects"] = reviewed_compound_qs
|
||||
context["unreviewed_objects"] = unreviewed_compound_qs
|
||||
|
||||
return render(request, "collections/objects_list.html", context)
|
||||
return render(request, "collections/compounds_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
compound_name = request.POST.get("compound-name")
|
||||
compound_smiles = request.POST.get("compound-smiles")
|
||||
compound_description = request.POST.get("compound-description")
|
||||
|
||||
c = Compound.create(current_package, compound_smiles, compound_name, compound_description)
|
||||
try:
|
||||
c = Compound.create(
|
||||
current_package, compound_smiles, compound_name, compound_description
|
||||
)
|
||||
except ValueError as e:
|
||||
raise BadRequest(str(e))
|
||||
|
||||
return redirect(c.url)
|
||||
|
||||
@ -1308,19 +1308,17 @@ def package_compound_structures(request, package_uuid, compound_uuid):
|
||||
context["breadcrumbs"] = breadcrumbs(
|
||||
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()
|
||||
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)
|
||||
return render(request, "collections/structures_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
structure_name = request.POST.get("structure-name")
|
||||
@ -1467,6 +1465,10 @@ def package_rules(request, package_uuid):
|
||||
context["meta"]["current_package"] = current_package
|
||||
context["object_type"] = "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()
|
||||
unreviewed_rule_qs = Rule.objects.none()
|
||||
@ -1488,10 +1490,7 @@ def package_rules(request, package_uuid):
|
||||
}
|
||||
)
|
||||
|
||||
context["reviewed_objects"] = reviewed_rule_qs
|
||||
context["unreviewed_objects"] = unreviewed_rule_qs
|
||||
|
||||
return render(request, "collections/objects_list.html", context)
|
||||
return render(request, "collections/rules_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
log_post_params(request)
|
||||
@ -1669,11 +1668,15 @@ def package_reactions(request, package_uuid):
|
||||
|
||||
if request.method == "GET":
|
||||
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["object_type"] = "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()
|
||||
unreviewed_reaction_qs = Reaction.objects.none()
|
||||
@ -1699,10 +1702,7 @@ def package_reactions(request, package_uuid):
|
||||
}
|
||||
)
|
||||
|
||||
context["reviewed_objects"] = reviewed_reaction_qs
|
||||
context["unreviewed_objects"] = unreviewed_reaction_qs
|
||||
|
||||
return render(request, "collections/objects_list.html", context)
|
||||
return render(request, "collections/reactions_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
reaction_name = request.POST.get("reaction-name")
|
||||
@ -1821,6 +1821,10 @@ def package_pathways(request, package_uuid):
|
||||
context["meta"]["current_package"] = current_package
|
||||
context["object_type"] = "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()
|
||||
unreviewed_pathway_qs = Pathway.objects.none()
|
||||
@ -1844,10 +1848,7 @@ def package_pathways(request, package_uuid):
|
||||
}
|
||||
)
|
||||
|
||||
context["reviewed_objects"] = reviewed_pathway_qs
|
||||
context["unreviewed_objects"] = unreviewed_pathway_qs
|
||||
|
||||
return render(request, "collections/objects_list.html", context)
|
||||
return render(request, "collections/pathways_paginated.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
log_post_params(request)
|
||||
@ -1864,7 +1865,6 @@ def package_pathways(request, package_uuid):
|
||||
"Pathway prediction failed!",
|
||||
"Pathway prediction failed due to missing or empty SMILES",
|
||||
)
|
||||
|
||||
try:
|
||||
stand_smiles = FormatConverter.standardize(smiles)
|
||||
except ValueError:
|
||||
@ -1887,8 +1887,13 @@ def package_pathways(request, package_uuid):
|
||||
prediction_setting = SettingManager.get_setting_by_url(current_user, prediction_setting)
|
||||
else:
|
||||
prediction_setting = current_user.prediction_settings()
|
||||
|
||||
pw = Pathway.create(current_package, stand_smiles, name=name, description=description)
|
||||
pw = Pathway.create(
|
||||
current_package,
|
||||
stand_smiles,
|
||||
name=name,
|
||||
description=description,
|
||||
predicted=pw_mode in {"predict", "incremental"},
|
||||
)
|
||||
|
||||
# set mode
|
||||
pw.kv.update({"mode": pw_mode})
|
||||
@ -1896,7 +1901,7 @@ def package_pathways(request, package_uuid):
|
||||
|
||||
if pw_mode == "predict" or pw_mode == "incremental":
|
||||
# unlimited pred (will be handled by setting)
|
||||
limit = -1
|
||||
limit = None
|
||||
|
||||
# For incremental predict first level and return
|
||||
if pw_mode == "incremental":
|
||||
@ -1932,6 +1937,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
|
||||
{
|
||||
"status": current_pathway.status(),
|
||||
"modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"emptyDueToThreshold": current_pathway.empty_due_to_threshold(),
|
||||
}
|
||||
)
|
||||
|
||||
@ -1952,7 +1958,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
|
||||
rule_package = PackageManager.get_package_by_url(
|
||||
current_user, request.GET.get("rule-package")
|
||||
)
|
||||
res = dispatch_eager(
|
||||
_, res = dispatch_eager(
|
||||
current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk
|
||||
)
|
||||
|
||||
@ -2380,6 +2386,10 @@ def package_scenarios(request, package_uuid):
|
||||
context["meta"]["current_package"] = current_package
|
||||
context["object_type"] = "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()
|
||||
unreviewed_scenario_qs = Scenario.objects.none()
|
||||
@ -2405,13 +2415,10 @@ def package_scenarios(request, package_uuid):
|
||||
}
|
||||
)
|
||||
|
||||
context["reviewed_objects"] = reviewed_scenario_qs
|
||||
context["unreviewed_objects"] = unreviewed_scenario_qs
|
||||
|
||||
from envipy_additional_information import (
|
||||
SEDIMENT_ADDITIONAL_INFORMATION,
|
||||
SLUDGE_ADDITIONAL_INFORMATION,
|
||||
SOIL_ADDITIONAL_INFORMATION,
|
||||
SEDIMENT_ADDITIONAL_INFORMATION,
|
||||
)
|
||||
|
||||
context["scenario_types"] = {
|
||||
@ -2442,7 +2449,7 @@ def package_scenarios(request, package_uuid):
|
||||
context["soil_additional_information"] = SOIL_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":
|
||||
log_post_params(request)
|
||||
|
||||
@ -2657,6 +2664,14 @@ def user(request, user_uuid):
|
||||
|
||||
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()
|
||||
|
||||
else:
|
||||
@ -2757,14 +2772,18 @@ def settings(request):
|
||||
context = get_base_context(request)
|
||||
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
context["title"] = "enviPath - Settings"
|
||||
|
||||
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"] = [
|
||||
{"Home": s.SERVER_URL},
|
||||
{"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":
|
||||
if s.DEBUG:
|
||||
for k, v in request.POST.items():
|
||||
@ -2802,15 +2821,25 @@ def settings(request):
|
||||
)
|
||||
|
||||
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":
|
||||
rule_packages = request.POST.getlist("rule-based-prediction-setting-packages")
|
||||
params["rule_packages"] = [
|
||||
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
||||
]
|
||||
|
||||
else:
|
||||
raise ValueError("")
|
||||
raise BadRequest("Neither Model-Based nor Rule-Based as Method selected!")
|
||||
|
||||
created_setting = SettingManager.create_setting(
|
||||
current_user,
|
||||
@ -2852,6 +2881,143 @@ def jobs(request):
|
||||
|
||||
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 #
|
||||
|
||||
Binary file not shown.
@ -1,24 +1,21 @@
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os.path
|
||||
from datetime import datetime
|
||||
|
||||
from django.conf import settings as s
|
||||
from django.http import HttpResponseNotAllowed
|
||||
from django.shortcuts import render
|
||||
|
||||
from epdb.logic import PackageManager
|
||||
from epdb.models import Rule, SimpleAmbitRule, Package, CompoundStructure
|
||||
from epdb.views import get_base_context, _anonymous_or_real
|
||||
from utilities.chem import FormatConverter
|
||||
|
||||
|
||||
from rdkit import Chem
|
||||
from rdkit.Chem.MolStandardize import rdMolStandardize
|
||||
|
||||
from epdb.models import CompoundStructure, Rule, SimpleAmbitRule
|
||||
from epdb.views import get_base_context
|
||||
from utilities.chem import FormatConverter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Package = s.GET_PACKAGE_MODEL()
|
||||
|
||||
|
||||
def normalize_smiles(smiles):
|
||||
m1 = Chem.MolFromSmiles(smiles)
|
||||
@ -59,9 +56,7 @@ def run_both_engines(SMILES, SMIRKS):
|
||||
set(
|
||||
[
|
||||
normalize_smiles(str(x))
|
||||
for x in FormatConverter.sanitize_smiles(
|
||||
[str(s) for s in all_rdkit_prods]
|
||||
)[0]
|
||||
for x in FormatConverter.sanitize_smiles([str(s) for s in all_rdkit_prods])[0]
|
||||
]
|
||||
)
|
||||
)
|
||||
@ -85,8 +80,7 @@ def migration(request):
|
||||
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
|
||||
)
|
||||
ALL_SMILES = [
|
||||
cs.smiles
|
||||
for cs in CompoundStructure.objects.filter(compound__package=BBD)
|
||||
cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD)
|
||||
]
|
||||
RULES = SimpleAmbitRule.objects.filter(package=BBD)
|
||||
|
||||
@ -142,9 +136,7 @@ def migration(request):
|
||||
)
|
||||
|
||||
for r in migration_status["results"]:
|
||||
r["detail_url"] = r["detail_url"].replace(
|
||||
"http://localhost:8000", s.SERVER_URL
|
||||
)
|
||||
r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
|
||||
|
||||
context.update(**migration_status)
|
||||
|
||||
@ -152,8 +144,6 @@ def migration(request):
|
||||
|
||||
|
||||
def migration_detail(request, package_uuid, rule_uuid):
|
||||
current_user = _anonymous_or_real(request)
|
||||
|
||||
if request.method == "GET":
|
||||
context = get_base_context(request)
|
||||
|
||||
@ -235,9 +225,7 @@ def compare(request):
|
||||
context["smirks"] = (
|
||||
"[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O"
|
||||
)
|
||||
context["smiles"] = (
|
||||
"C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
|
||||
)
|
||||
context["smiles"] = "C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
|
||||
return render(request, "compare.html", context)
|
||||
|
||||
elif request.method == "POST":
|
||||
|
||||
@ -9,7 +9,8 @@ dependencies = [
|
||||
"django>=5.2.1",
|
||||
"django-extensions>=4.1",
|
||||
"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-polymorphic>=4.1.0",
|
||||
"enviformer",
|
||||
@ -45,6 +46,9 @@ dev = [
|
||||
"poethepoet>=0.37.0",
|
||||
"pre-commit>=4.3.0",
|
||||
"ruff>=0.13.3",
|
||||
"pytest-playwright>=0.7.1",
|
||||
"pytest-django>=4.11.1",
|
||||
"pytest-cov>=7.0.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
@ -66,47 +70,31 @@ docstring-code-format = true
|
||||
|
||||
[tool.poe.tasks]
|
||||
# Main tasks
|
||||
setup = { sequence = ["db-up", "migrate", "bootstrap"], help = "Complete setup: start database, run migrations, and bootstrap data" }
|
||||
dev = { shell = """
|
||||
# Start pnpm CSS watcher in background
|
||||
pnpm run dev &
|
||||
PNPM_PID=$!
|
||||
echo "Started CSS watcher (PID: $PNPM_PID)"
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo "\nShutting down..."
|
||||
if kill -0 $PNPM_PID 2>/dev/null; then
|
||||
kill $PNPM_PID
|
||||
echo "✓ CSS watcher stopped"
|
||||
fi
|
||||
if [ ! -z "${DJ_PID:-}" ] && kill -0 $DJ_PID 2>/dev/null; then
|
||||
kill $DJ_PID
|
||||
echo "✓ Django server stopped"
|
||||
fi
|
||||
}
|
||||
|
||||
# Set trap for cleanup
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Start Django dev server in background
|
||||
uv run python manage.py runserver &
|
||||
DJ_PID=$!
|
||||
|
||||
# Wait for Django to finish
|
||||
wait $DJ_PID
|
||||
""", help = "Start the development server with CSS watcher", deps = ["db-up", "js-deps"] }
|
||||
build = { sequence = ["build-frontend", "collectstatic"], help = "Build frontend assets and collect static files" }
|
||||
setup = { sequence = [
|
||||
"db-up",
|
||||
"migrate",
|
||||
"bootstrap",
|
||||
], help = "Complete setup: start database, run migrations, and bootstrap data" }
|
||||
dev = { cmd = "uv run python scripts/dev_server.py", help = "Start the development server with CSS watcher", deps = [
|
||||
"db-up",
|
||||
"js-deps",
|
||||
] }
|
||||
build = { sequence = [
|
||||
"build-frontend",
|
||||
"collectstatic",
|
||||
], help = "Build frontend assets and collect static files" }
|
||||
|
||||
# Database tasks
|
||||
db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
|
||||
db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
|
||||
|
||||
# Frontend tasks
|
||||
js-deps = { cmd = "pnpm install", help = "Install frontend dependencies" }
|
||||
js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" }
|
||||
|
||||
# Full cleanup tasks
|
||||
clean = { sequence = ["clean-db"], help = "Remove model files and database volumes (WARNING: destroys all data!)" }
|
||||
clean = { sequence = [
|
||||
"clean-db",
|
||||
], help = "Remove model files and database volumes (WARNING: destroys all data!)" }
|
||||
clean-db = { cmd = "docker compose -f docker-compose.dev.yml down -v", help = "Removes the database container and volume." }
|
||||
|
||||
# Django tasks
|
||||
@ -124,6 +112,33 @@ echo " Password: SuperSafe"
|
||||
""", help = "Bootstrap initial data (anonymous user, packages, models)" }
|
||||
shell = { cmd = "uv run python manage.py shell", help = "Open Django shell" }
|
||||
|
||||
# Build tasks
|
||||
build-frontend = { cmd = "pnpm run build", help = "Build frontend assets using pnpm", deps = ["js-deps"] }
|
||||
collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = ["build-frontend"] }
|
||||
|
||||
build-frontend = { cmd = "uv run python scripts/pnpm_wrapper.py run build", help = "Build frontend assets using pnpm", deps = [
|
||||
"js-deps",
|
||||
] } # Build tasks
|
||||
|
||||
|
||||
collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = [
|
||||
"build-frontend",
|
||||
] }
|
||||
|
||||
frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--verbose --capture=no --durations=10"
|
||||
testpaths = ["tests", "*/tests"]
|
||||
pythonpath = ["."]
|
||||
norecursedirs = [
|
||||
"env",
|
||||
"venv",
|
||||
"envipy-plugins",
|
||||
"envipy-additional-information",
|
||||
"envipy-ambit",
|
||||
"enviformer",
|
||||
]
|
||||
markers = [
|
||||
"api: API tests",
|
||||
"frontend: Frontend tests",
|
||||
"end2end: End-to-end tests",
|
||||
"slow: Slow tests",
|
||||
]
|
||||
|
||||
201
scripts/dev_server.py
Executable file
201
scripts/dev_server.py
Executable file
@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cross-platform development server script.
|
||||
Starts pnpm CSS watcher and Django dev server, handling cleanup on exit.
|
||||
Works on both Windows and Unix systems.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def find_pnpm():
|
||||
"""
|
||||
Find pnpm executable on the system.
|
||||
Returns the path to pnpm or None if not found.
|
||||
"""
|
||||
# Try to find pnpm using shutil.which
|
||||
# On Windows, this will find pnpm.cmd if it's in PATH
|
||||
pnpm_path = shutil.which("pnpm")
|
||||
|
||||
if pnpm_path:
|
||||
return pnpm_path
|
||||
|
||||
# On Windows, also try pnpm.cmd explicitly
|
||||
if sys.platform == "win32":
|
||||
pnpm_cmd = shutil.which("pnpm.cmd")
|
||||
if pnpm_cmd:
|
||||
return pnpm_cmd
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class DevServerManager:
|
||||
"""Manages background processes for development server."""
|
||||
|
||||
def __init__(self):
|
||||
self.processes = []
|
||||
self._cleanup_registered = False
|
||||
|
||||
def start_process(self, command, description, shell=False):
|
||||
"""Start a background process and return the process object."""
|
||||
print(f"Starting {description}...")
|
||||
try:
|
||||
if shell:
|
||||
# Use shell=True for commands that need shell interpretation
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
else:
|
||||
# Split command into list for subprocess
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
self.processes.append((process, description))
|
||||
print(f"✓ Started {description} (PID: {process.pid})")
|
||||
return process
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to start {description}: {e}", file=sys.stderr)
|
||||
self.cleanup()
|
||||
sys.exit(1)
|
||||
|
||||
def cleanup(self):
|
||||
"""Terminate all running processes."""
|
||||
if not self.processes:
|
||||
return
|
||||
|
||||
print("\nShutting down...")
|
||||
for process, description in self.processes:
|
||||
if process.poll() is None: # Process is still running
|
||||
try:
|
||||
# Try graceful termination first
|
||||
if sys.platform == "win32":
|
||||
process.terminate()
|
||||
else:
|
||||
process.send_signal(signal.SIGTERM)
|
||||
|
||||
# Wait up to 5 seconds for graceful shutdown
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Force kill if graceful shutdown failed
|
||||
if sys.platform == "win32":
|
||||
process.kill()
|
||||
else:
|
||||
process.send_signal(signal.SIGKILL)
|
||||
process.wait()
|
||||
|
||||
print(f"✓ {description} stopped")
|
||||
except Exception as e:
|
||||
print(f"✗ Error stopping {description}: {e}", file=sys.stderr)
|
||||
|
||||
self.processes.clear()
|
||||
|
||||
def register_cleanup(self):
|
||||
"""Register cleanup handlers for various exit scenarios."""
|
||||
if self._cleanup_registered:
|
||||
return
|
||||
|
||||
self._cleanup_registered = True
|
||||
|
||||
# Register atexit handler (works on all platforms)
|
||||
atexit.register(self.cleanup)
|
||||
|
||||
# Register signal handlers (Unix only)
|
||||
if sys.platform != "win32":
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle Unix signals."""
|
||||
self.cleanup()
|
||||
sys.exit(0)
|
||||
|
||||
def wait_for_process(self, process, description):
|
||||
"""Wait for a process to finish and handle its output."""
|
||||
try:
|
||||
# Stream output from the process
|
||||
for line in iter(process.stdout.readline, ""):
|
||||
if line:
|
||||
print(f"[{description}] {line.rstrip()}")
|
||||
|
||||
process.wait()
|
||||
return process.returncode
|
||||
except KeyboardInterrupt:
|
||||
# Handle Ctrl+C
|
||||
self.cleanup()
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"Error waiting for {description}: {e}", file=sys.stderr)
|
||||
self.cleanup()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
manager = DevServerManager()
|
||||
manager.register_cleanup()
|
||||
|
||||
# Find pnpm executable
|
||||
pnpm_path = find_pnpm()
|
||||
if not pnpm_path:
|
||||
print("Error: pnpm not found in PATH.", file=sys.stderr)
|
||||
print("\nPlease install pnpm:", file=sys.stderr)
|
||||
print(" Windows: https://pnpm.io/installation#on-windows", file=sys.stderr)
|
||||
print(" Unix: https://pnpm.io/installation#on-posix-systems", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Determine shell usage based on platform
|
||||
use_shell = sys.platform == "win32"
|
||||
|
||||
# Start pnpm CSS watcher
|
||||
# Use the found pnpm path to ensure it works on Windows
|
||||
pnpm_command = f'"{pnpm_path}" run dev' if use_shell else [pnpm_path, "run", "dev"]
|
||||
manager.start_process(
|
||||
pnpm_command,
|
||||
"CSS watcher",
|
||||
shell=use_shell,
|
||||
)
|
||||
|
||||
# Give pnpm a moment to start
|
||||
time.sleep(1)
|
||||
|
||||
# Start Django dev server
|
||||
django_process = manager.start_process(
|
||||
["uv", "run", "python", "manage.py", "runserver"],
|
||||
"Django server",
|
||||
shell=False,
|
||||
)
|
||||
|
||||
print("\nDevelopment servers are running. Press Ctrl+C to stop.\n")
|
||||
|
||||
try:
|
||||
# Wait for Django server (main process)
|
||||
# If Django exits, we should clean up everything
|
||||
return_code = manager.wait_for_process(django_process, "Django")
|
||||
|
||||
# If Django exited unexpectedly, clean up and exit
|
||||
if return_code != 0:
|
||||
manager.cleanup()
|
||||
sys.exit(return_code)
|
||||
except KeyboardInterrupt:
|
||||
# Ctrl+C was pressed
|
||||
manager.cleanup()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
59
scripts/pnpm_wrapper.py
Executable file
59
scripts/pnpm_wrapper.py
Executable file
@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cross-platform pnpm command wrapper.
|
||||
Finds pnpm correctly on Windows (handles pnpm.cmd) and Unix systems.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def find_pnpm():
|
||||
"""
|
||||
Find pnpm executable on the system.
|
||||
Returns the path to pnpm or None if not found.
|
||||
"""
|
||||
# Try to find pnpm using shutil.which
|
||||
# On Windows, this will find pnpm.cmd if it's in PATH
|
||||
pnpm_path = shutil.which("pnpm")
|
||||
|
||||
if pnpm_path:
|
||||
return pnpm_path
|
||||
|
||||
# On Windows, also try pnpm.cmd explicitly
|
||||
if sys.platform == "win32":
|
||||
pnpm_cmd = shutil.which("pnpm.cmd")
|
||||
if pnpm_cmd:
|
||||
return pnpm_cmd
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point - execute pnpm with provided arguments."""
|
||||
pnpm_path = find_pnpm()
|
||||
|
||||
if not pnpm_path:
|
||||
print("Error: pnpm not found in PATH.", file=sys.stderr)
|
||||
print("\nPlease install pnpm:", file=sys.stderr)
|
||||
print(" Windows: https://pnpm.io/installation#on-windows", file=sys.stderr)
|
||||
print(" Unix: https://pnpm.io/installation#on-posix-systems", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Get all arguments passed to this script
|
||||
args = sys.argv[1:]
|
||||
|
||||
# Execute pnpm with the provided arguments
|
||||
try:
|
||||
sys.exit(subprocess.call([pnpm_path] + args))
|
||||
except KeyboardInterrupt:
|
||||
# Handle Ctrl+C gracefully
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
print(f"Error executing pnpm: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 814 B |
265
static/js/alpine/index.js
Normal file
265
static/js/alpine/index.js
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Alpine.js Components for enviPath
|
||||
*
|
||||
* This module provides reusable Alpine.js data components for modals,
|
||||
* form validation, and form submission.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
/**
|
||||
* Modal Form Component
|
||||
*
|
||||
* Provides form validation using HTML5 Constraint Validation API,
|
||||
* loading states for submission, and error message management.
|
||||
*
|
||||
* Basic Usage:
|
||||
* <dialog x-data="modalForm()" @close="reset()">
|
||||
* <form id="my-form">
|
||||
* <input name="field" required>
|
||||
* </form>
|
||||
* <button @click="submit('my-form')" :disabled="isSubmitting">Submit</button>
|
||||
* </dialog>
|
||||
*
|
||||
* With Custom State:
|
||||
* <dialog x-data="modalForm({ state: { selectedItem: '', imageUrl: '' } })" @close="reset()">
|
||||
* <select x-model="selectedItem" @change="updateImagePreview(selectedItem + '?image=svg')">
|
||||
* <img :src="imageUrl" x-show="imageUrl">
|
||||
* </dialog>
|
||||
*
|
||||
* With AJAX:
|
||||
* <button @click="submitAsync('my-form', { onSuccess: (data) => console.log(data) })">
|
||||
*/
|
||||
Alpine.data('modalForm', (options = {}) => ({
|
||||
isSubmitting: false,
|
||||
errors: {},
|
||||
// Spread custom initial state from options
|
||||
...(options.state || {}),
|
||||
|
||||
/**
|
||||
* Validate a single field using HTML5 Constraint Validation API
|
||||
* @param {HTMLElement} field - The input/select/textarea element
|
||||
*/
|
||||
validateField(field) {
|
||||
const name = field.name || field.id;
|
||||
if (!name) return;
|
||||
|
||||
if (!field.validity.valid) {
|
||||
this.errors[name] = field.validationMessage;
|
||||
} else {
|
||||
delete this.errors[name];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear error for a field (call on input)
|
||||
* @param {HTMLElement} field - The input element
|
||||
*/
|
||||
clearError(field) {
|
||||
const name = field.name || field.id;
|
||||
if (name && this.errors[name]) {
|
||||
delete this.errors[name];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get error message for a field
|
||||
* @param {string} name - Field name
|
||||
* @returns {string|undefined} Error message or undefined
|
||||
*/
|
||||
getError(name) {
|
||||
return this.errors[name];
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if form has any errors
|
||||
* @returns {boolean} True if there are errors
|
||||
*/
|
||||
hasErrors() {
|
||||
return Object.keys(this.errors).length > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate all fields in a form
|
||||
* @param {string} formId - The form element ID
|
||||
* @returns {boolean} True if form is valid
|
||||
*/
|
||||
validateAll(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return false;
|
||||
|
||||
this.errors = {};
|
||||
const fields = form.querySelectorAll('input, select, textarea');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.name && !field.validity.valid) {
|
||||
this.errors[field.name] = field.validationMessage;
|
||||
}
|
||||
});
|
||||
|
||||
return !this.hasErrors();
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate that two password fields match
|
||||
* @param {string} password1Id - ID of first password field
|
||||
* @param {string} password2Id - ID of second password field
|
||||
* @returns {boolean} True if passwords match
|
||||
*/
|
||||
validatePasswordMatch(password1Id, password2Id) {
|
||||
const pw1 = document.getElementById(password1Id);
|
||||
const pw2 = document.getElementById(password2Id);
|
||||
|
||||
if (!pw1 || !pw2) return false;
|
||||
|
||||
if (pw1.value !== pw2.value) {
|
||||
this.errors[pw2.name || password2Id] = 'Passwords do not match';
|
||||
pw2.setCustomValidity('Passwords do not match');
|
||||
return false;
|
||||
}
|
||||
|
||||
delete this.errors[pw2.name || password2Id];
|
||||
pw2.setCustomValidity('');
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit a form with loading state
|
||||
* @param {string} formId - The form element ID
|
||||
*/
|
||||
submit(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
// Validate before submit
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set action to current URL if empty
|
||||
if (!form.action || form.action === window.location.href + '#') {
|
||||
form.action = window.location.href;
|
||||
}
|
||||
|
||||
// Set loading state and submit
|
||||
this.isSubmitting = true;
|
||||
form.submit();
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit form via AJAX (fetch)
|
||||
* @param {string} formId - The form element ID
|
||||
* @param {Object} options - Options { onSuccess, onError, closeOnSuccess }
|
||||
*/
|
||||
async submitAsync(formId, options = {}) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
// Validate before submit
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action || window.location.href, {
|
||||
method: form.method || 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.ok) {
|
||||
if (options.onSuccess) {
|
||||
options.onSuccess(data);
|
||||
}
|
||||
|
||||
if (data.redirect || data.success) {
|
||||
window.location.href = data.redirect || data.success;
|
||||
} else if (options.closeOnSuccess) {
|
||||
this.$el.closest('dialog')?.close();
|
||||
}
|
||||
} else {
|
||||
const errorMsg = data.error || data.message || `Error: ${response.status}`;
|
||||
this.errors['_form'] = errorMsg;
|
||||
|
||||
if (options.onError) {
|
||||
options.onError(errorMsg, data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.errors['_form'] = error.message;
|
||||
|
||||
if (options.onError) {
|
||||
options.onError(error.message);
|
||||
}
|
||||
} finally {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set form action URL dynamically
|
||||
* @param {string} formId - The form element ID
|
||||
* @param {string} url - The URL to set as action
|
||||
*/
|
||||
setFormAction(formId, url) {
|
||||
const form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.action = url;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update image preview
|
||||
* @param {string} url - Image URL (with query params)
|
||||
* @param {string} targetId - Target element ID for the image
|
||||
*/
|
||||
updateImagePreview(url) {
|
||||
// Store URL for reactive binding with :src
|
||||
this.imageUrl = url;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset form state (call on modal close)
|
||||
* Resets to initial state from options
|
||||
*/
|
||||
reset() {
|
||||
this.isSubmitting = false;
|
||||
this.errors = {};
|
||||
this.imageUrl = '';
|
||||
|
||||
// Reset custom state to initial values
|
||||
if (options.state) {
|
||||
Object.keys(options.state).forEach(key => {
|
||||
this[key] = options.state[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Call custom reset handler if provided
|
||||
if (options.onReset) {
|
||||
options.onReset.call(this);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Simple Modal Component (no form)
|
||||
*
|
||||
* For modals that don't need form validation.
|
||||
*
|
||||
* Usage:
|
||||
* <dialog x-data="modal()">
|
||||
* <button @click="$el.closest('dialog').close()">Close</button>
|
||||
* </dialog>
|
||||
*/
|
||||
Alpine.data('modal', () => ({
|
||||
// Placeholder for simple modals that may need state later
|
||||
}));
|
||||
});
|
||||
148
static/js/alpine/pagination.js
Normal file
148
static/js/alpine/pagination.js
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Alpine.js Pagination Component
|
||||
*
|
||||
* Provides client-side pagination for large lists.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('remotePaginatedList', (options = {}) => ({
|
||||
items: [],
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
perPage: options.perPage || 50,
|
||||
endpoint: options.endpoint || '',
|
||||
isReviewed: options.isReviewed || false,
|
||||
instanceId: options.instanceId || Math.random().toString(36).substring(2, 9),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
init() {
|
||||
if (this.endpoint) {
|
||||
this.fetchPage(1);
|
||||
}
|
||||
},
|
||||
|
||||
get paginatedItems() {
|
||||
return this.items;
|
||||
},
|
||||
|
||||
get showingStart() {
|
||||
if (this.totalItems === 0) return 0;
|
||||
return (this.currentPage - 1) * this.perPage + 1;
|
||||
},
|
||||
|
||||
get showingEnd() {
|
||||
if (this.totalItems === 0) return 0;
|
||||
return Math.min((this.currentPage - 1) * this.perPage + this.items.length, this.totalItems);
|
||||
},
|
||||
|
||||
async fetchPage(page) {
|
||||
if (!this.endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.$dispatch('loading-start');
|
||||
|
||||
try {
|
||||
const url = new URL(this.endpoint, window.location.origin);
|
||||
// Preserve existing query parameters and add pagination params
|
||||
url.searchParams.set('page', page.toString());
|
||||
url.searchParams.set('page_size', this.perPage.toString());
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${this.endpoint} (status ${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.items = data.items || [];
|
||||
this.totalItems = data.total_items || 0;
|
||||
this.totalPages = data.total_pages || 0;
|
||||
this.currentPage = data.page || page;
|
||||
this.perPage = data.page_size || this.perPage;
|
||||
|
||||
// Dispatch event for parent components (e.g., tab count updates)
|
||||
this.$dispatch('items-loaded', { totalItems: this.totalItems });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.error = `Unable to load ${this.endpoint}. Please try again.`;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.$dispatch('loading-end');
|
||||
}
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.fetchPage(this.currentPage + 1);
|
||||
}
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.fetchPage(this.currentPage - 1);
|
||||
}
|
||||
},
|
||||
|
||||
goToPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) {
|
||||
this.fetchPage(page);
|
||||
}
|
||||
},
|
||||
|
||||
get pageNumbers() {
|
||||
const pages = [];
|
||||
const total = this.totalPages;
|
||||
const current = this.currentPage;
|
||||
|
||||
if (total === 0) {
|
||||
return pages;
|
||||
}
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||
}
|
||||
} else {
|
||||
pages.push({ page: 1, isEllipsis: false, key: `${this.instanceId}-page-1` });
|
||||
|
||||
let rangeStart;
|
||||
let rangeEnd;
|
||||
|
||||
if (current <= 4) {
|
||||
rangeStart = 2;
|
||||
rangeEnd = 5;
|
||||
} else if (current >= total - 3) {
|
||||
rangeStart = total - 4;
|
||||
rangeEnd = total - 1;
|
||||
} else {
|
||||
rangeStart = current - 1;
|
||||
rangeEnd = current + 1;
|
||||
}
|
||||
|
||||
if (rangeStart > 2) {
|
||||
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-start` });
|
||||
}
|
||||
|
||||
for (let i = rangeStart; i <= rangeEnd; i++) {
|
||||
pages.push({ page: i, isEllipsis: false, key: `${this.instanceId}-page-${i}` });
|
||||
}
|
||||
|
||||
if (rangeEnd < total - 1) {
|
||||
pages.push({ page: '...', isEllipsis: true, key: `${this.instanceId}-ellipsis-end` });
|
||||
}
|
||||
|
||||
pages.push({ page: total, isEllipsis: false, key: `${this.instanceId}-page-${total}` });
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
}));
|
||||
});
|
||||
106
static/js/alpine/pathway.js
Normal file
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();
|
||||
}
|
||||
}));
|
||||
});
|
||||
145
static/js/alpine/search.js
Normal file
145
static/js/alpine/search.js
Normal file
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Search Modal Alpine.js Component
|
||||
*
|
||||
* Provides package selection, search mode switching, and results display
|
||||
* for the search modal.
|
||||
*/
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
/**
|
||||
* Search Modal Component
|
||||
*
|
||||
* Usage:
|
||||
* <dialog x-data="searchModal()" @close="reset()">
|
||||
* ...
|
||||
* </dialog>
|
||||
*/
|
||||
Alpine.data('searchModal', () => ({
|
||||
// Package selector state
|
||||
selectedPackages: [],
|
||||
|
||||
// Search state
|
||||
searchMode: 'text',
|
||||
searchModeLabel: 'Text',
|
||||
query: '',
|
||||
|
||||
// Results state
|
||||
results: null,
|
||||
isSearching: false,
|
||||
error: null,
|
||||
|
||||
// Initialize on modal open
|
||||
init() {
|
||||
// Load reviewed packages by default
|
||||
this.loadInitialSelection();
|
||||
|
||||
// Watch for modal open to focus searchbar
|
||||
this.$watch('$el.open', (open) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.$refs.searchbar.focus();
|
||||
}, 320);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadInitialSelection() {
|
||||
// Select all reviewed packages by default
|
||||
const menuItems = this.$refs.packageDropdown.querySelectorAll('li');
|
||||
|
||||
for (const item of menuItems) {
|
||||
// Stop at 'Unreviewed Packages' section
|
||||
if (item.classList.contains('menu-title') &&
|
||||
item.textContent.trim() === 'Unreviewed Packages') {
|
||||
break;
|
||||
}
|
||||
|
||||
const packageOption = item.querySelector('.package-option');
|
||||
if (packageOption) {
|
||||
this.selectedPackages.push({
|
||||
url: packageOption.dataset.packageUrl,
|
||||
name: packageOption.dataset.packageName
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
togglePackage(url, name) {
|
||||
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
|
||||
|
||||
if (index !== -1) {
|
||||
this.selectedPackages.splice(index, 1);
|
||||
} else {
|
||||
this.selectedPackages.push({ url, name });
|
||||
}
|
||||
},
|
||||
|
||||
removePackage(url) {
|
||||
const index = this.selectedPackages.findIndex(pkg => pkg.url === url);
|
||||
if (index !== -1) {
|
||||
this.selectedPackages.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
isPackageSelected(url) {
|
||||
return this.selectedPackages.some(pkg => pkg.url === url);
|
||||
},
|
||||
|
||||
setSearchMode(mode, label) {
|
||||
this.searchMode = mode;
|
||||
this.searchModeLabel = label;
|
||||
this.$refs.modeDropdown.hidePopover();
|
||||
},
|
||||
|
||||
async performSearch(serverBase) {
|
||||
if (!this.query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedPackages.length < 1) {
|
||||
this.results = { error: 'no_packages' };
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
this.selectedPackages.forEach(pkg => params.append('packages', pkg.url));
|
||||
params.append('search', this.query.trim());
|
||||
params.append('mode', this.searchModeLabel.toLowerCase());
|
||||
|
||||
this.isSearching = true;
|
||||
this.results = null;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverBase}/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search request failed');
|
||||
}
|
||||
|
||||
this.results = await response.json();
|
||||
} catch (err) {
|
||||
console.error('Search error:', err);
|
||||
this.error = 'Search failed. Please try again.';
|
||||
} finally {
|
||||
this.isSearching = false;
|
||||
}
|
||||
},
|
||||
|
||||
hasResults() {
|
||||
if (!this.results || this.results.error) return false;
|
||||
const categories = ['Compounds', 'Compound Structures', 'Rules', 'Reactions', 'Pathways'];
|
||||
return categories.some(cat => this.results[cat] && this.results[cat].length > 0);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.query = '';
|
||||
this.results = null;
|
||||
this.error = null;
|
||||
this.isSearching = false;
|
||||
}
|
||||
}));
|
||||
});
|
||||
@ -63,17 +63,20 @@ class DiscourseAPI {
|
||||
* @returns {string} Cleaned excerpt
|
||||
*/
|
||||
extractExcerpt(excerpt) {
|
||||
if (!excerpt) return 'Click to read more';
|
||||
if (!excerpt) return 'No preview available yet';
|
||||
|
||||
// Remove HTML tags and clean up; collapse whitespace; do not add manual ellipsis
|
||||
return excerpt
|
||||
const cleaned = excerpt
|
||||
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||
.replace(/ /g, ' ') // Replace with spaces
|
||||
.replace(/&/g, '&') // Replace & with &
|
||||
.replace(/</g, '<') // Replace < with <
|
||||
.replace(/>/g, '>') // Replace > with >
|
||||
.replace(/\s+/g, ' ') // Collapse all whitespace/newlines
|
||||
.trim()
|
||||
.trim();
|
||||
|
||||
// Check if excerpt is empty after cleaning
|
||||
return cleaned || 'No preview available yet';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1269
static/js/pps.js
1269
static/js/pps.js
File diff suppressed because it is too large
Load Diff
237
static/js/pw.js
237
static/js/pw.js
@ -1,14 +1,21 @@
|
||||
console.log("loaded pw.js")
|
||||
|
||||
function predictFromNode(url) {
|
||||
$.post("", {node: url})
|
||||
.done(function (data) {
|
||||
fetch("", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
|
||||
},
|
||||
body: new URLSearchParams({node: url})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log("Success:", data);
|
||||
window.location.href = data.success;
|
||||
})
|
||||
.fail(function (xhr, status, error) {
|
||||
console.error("Error:", xhr.status, xhr.responseText);
|
||||
// show user-friendly message or log error
|
||||
.catch(error => {
|
||||
console.error("Error:", error);
|
||||
});
|
||||
}
|
||||
|
||||
@ -103,6 +110,9 @@ function draw(pathway, elem) {
|
||||
}
|
||||
|
||||
function dragstarted(event, d) {
|
||||
// Prevent zoom pan when dragging nodes
|
||||
event.sourceEvent.stopPropagation();
|
||||
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
@ -117,6 +127,9 @@ function draw(pathway, elem) {
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
// Prevent zoom pan when dragging nodes
|
||||
event.sourceEvent.stopPropagation();
|
||||
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
|
||||
@ -127,6 +140,9 @@ function draw(pathway, elem) {
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
// Prevent zoom pan when dragging nodes
|
||||
event.sourceEvent.stopPropagation();
|
||||
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
|
||||
// Mark that dragging has ended
|
||||
@ -192,58 +208,163 @@ function draw(pathway, elem) {
|
||||
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
|
||||
}
|
||||
|
||||
// Wait one second before showing popup
|
||||
// Wait before showing popup (ms)
|
||||
var popupWaitBeforeShow = 1000;
|
||||
// Keep Popup at least for one second
|
||||
var popushowAtLeast = 1000;
|
||||
|
||||
function pop_show_e(element) {
|
||||
var e = element;
|
||||
setTimeout(function () {
|
||||
if ($(e).is(':hover')) { // if element is still hovered
|
||||
$(e).popover("show");
|
||||
// Custom popover element
|
||||
let popoverTimeout = null;
|
||||
|
||||
// workaround to set fixed positions
|
||||
pop = $(e).attr("aria-describedby")
|
||||
h = $('#' + pop).height();
|
||||
$('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`)
|
||||
setTimeout(function () {
|
||||
var close = setInterval(function () {
|
||||
if (!$(".popover:hover").length // mouse outside popover
|
||||
&& !$(e).is(':hover')) { // mouse outside element
|
||||
$(e).popover('hide');
|
||||
clearInterval(close);
|
||||
function createPopover() {
|
||||
const popover = document.createElement('div');
|
||||
popover.id = 'custom-popover';
|
||||
popover.className = 'fixed z-50';
|
||||
popover.style.cssText = `
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
max-width: 320px;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 150ms ease-in-out, visibility 150ms ease-in-out;
|
||||
pointer-events: auto;
|
||||
`;
|
||||
popover.setAttribute('role', 'tooltip');
|
||||
popover.innerHTML = `
|
||||
<div class="font-semibold mb-2 popover-title" style="font-weight: 600; margin-bottom: 0.5rem;"></div>
|
||||
<div class="text-sm popover-content" style="font-size: 0.875rem;"></div>
|
||||
`;
|
||||
|
||||
// Add styles for content images
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#custom-popover img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}, 100);
|
||||
}, popushowAtLeast);
|
||||
#custom-popover a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
}, popupWaitBeforeShow);
|
||||
#custom-popover a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
if (!document.getElementById('popover-styles')) {
|
||||
style.id = 'popover-styles';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Keep popover open when hovering over it
|
||||
popover.addEventListener('mouseenter', () => {
|
||||
if (popoverTimeout) {
|
||||
clearTimeout(popoverTimeout);
|
||||
popoverTimeout = null;
|
||||
}
|
||||
});
|
||||
|
||||
popover.addEventListener('mouseleave', () => {
|
||||
hidePopover();
|
||||
});
|
||||
|
||||
document.body.appendChild(popover);
|
||||
return popover;
|
||||
}
|
||||
|
||||
function getPopover() {
|
||||
return document.getElementById('custom-popover') || createPopover();
|
||||
}
|
||||
|
||||
function showPopover(element, title, content) {
|
||||
const popover = getPopover();
|
||||
popover.querySelector('.popover-title').textContent = title;
|
||||
popover.querySelector('.popover-content').innerHTML = content;
|
||||
|
||||
// Make visible to measure
|
||||
popover.style.visibility = 'hidden';
|
||||
popover.style.opacity = '0';
|
||||
popover.style.display = 'block';
|
||||
|
||||
// Smart positioning - avoid viewport overflow
|
||||
const padding = 10;
|
||||
const popoverRect = popover.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = clientX + 15;
|
||||
let top = clientY - (popoverRect.height / 2);
|
||||
|
||||
// Prevent right overflow
|
||||
if (left + popoverRect.width > viewportWidth - padding) {
|
||||
left = clientX - popoverRect.width - 15;
|
||||
}
|
||||
|
||||
// Prevent bottom overflow
|
||||
if (top + popoverRect.height > viewportHeight - padding) {
|
||||
top = viewportHeight - popoverRect.height - padding;
|
||||
}
|
||||
|
||||
// Prevent top overflow
|
||||
if (top < padding) {
|
||||
top = padding;
|
||||
}
|
||||
|
||||
popover.style.top = `${top}px`;
|
||||
popover.style.left = `${left}px`;
|
||||
popover.style.visibility = 'visible';
|
||||
popover.style.opacity = '1';
|
||||
|
||||
currentElement = element;
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
const popover = getPopover();
|
||||
popover.style.opacity = '0';
|
||||
popover.style.visibility = 'hidden';
|
||||
currentElement = null;
|
||||
}
|
||||
|
||||
function pop_add(objects, title, contentFunction) {
|
||||
objects.attr("id", "pop")
|
||||
.attr("data-container", "body")
|
||||
.attr("data-toggle", "popover")
|
||||
.attr("data-placement", "right")
|
||||
.attr("title", title);
|
||||
objects.each(function (d) {
|
||||
const element = this;
|
||||
|
||||
objects.each(function (d, i) {
|
||||
options = {trigger: "manual", html: true, animation: false};
|
||||
this_ = this;
|
||||
var p = $(this).popover(options).on("mouseenter", function () {
|
||||
pop_show_e(this);
|
||||
element.addEventListener('mouseenter', () => {
|
||||
if (popoverTimeout) clearTimeout(popoverTimeout);
|
||||
|
||||
popoverTimeout = setTimeout(() => {
|
||||
if (element.matches(':hover')) {
|
||||
const content = contentFunction(d);
|
||||
showPopover(element, title, content);
|
||||
}
|
||||
}, popupWaitBeforeShow);
|
||||
});
|
||||
p.on("show.bs.popover", function (e) {
|
||||
// this is to dynamically ajdust the content and bounds of the popup
|
||||
p.attr('data-content', contentFunction(d));
|
||||
p.data("bs.popover").setContent();
|
||||
p.data("bs.popover").tip().css({"max-width": "1000px"});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
if (popoverTimeout) {
|
||||
clearTimeout(popoverTimeout);
|
||||
popoverTimeout = null;
|
||||
}
|
||||
|
||||
// Delay hide to allow moving to popover
|
||||
setTimeout(() => {
|
||||
const popover = getPopover();
|
||||
if (!popover.matches(':hover') && !element.matches(':hover')) {
|
||||
hidePopover();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function node_popup(n) {
|
||||
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>"
|
||||
|
||||
if (appDomainViewEnabled) {
|
||||
@ -255,7 +376,7 @@ function draw(pathway, elem) {
|
||||
}
|
||||
}
|
||||
|
||||
popupContent += "<img src='" + n.image + "' width='" + 20 * nodeRadius + "'><br>"
|
||||
popupContent += "<img src='" + n.image + "'><br>"
|
||||
if (n.scenarios.length > 0) {
|
||||
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
||||
for (var s of n.scenarios) {
|
||||
@ -265,7 +386,7 @@ function draw(pathway, elem) {
|
||||
|
||||
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
|
||||
if (pathway.isIncremental && isLeaf) {
|
||||
popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
|
||||
popupContent += '<br><a class="btn btn-primary btn-sm mt-2" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
|
||||
}
|
||||
|
||||
return popupContent;
|
||||
@ -285,7 +406,7 @@ function draw(pathway, elem) {
|
||||
popupContent += adcontent;
|
||||
}
|
||||
|
||||
popupContent += "<img src='" + e.image + "' width='" + 20 * nodeRadius + "'><br>"
|
||||
popupContent += "<img src='" + e.image + "'><br>"
|
||||
if (e.reaction_probability) {
|
||||
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
|
||||
}
|
||||
@ -308,6 +429,23 @@ function draw(pathway, elem) {
|
||||
});
|
||||
|
||||
const zoomable = d3.select("#zoomable");
|
||||
const svg = d3.select("#pwsvg");
|
||||
const container = d3.select("#vizdiv");
|
||||
|
||||
// Set explicit SVG dimensions for proper zoom behavior
|
||||
svg.attr("width", width)
|
||||
.attr("height", height);
|
||||
|
||||
// Add background rectangle FIRST to enable pan/zoom on empty space
|
||||
// This must be inserted before zoomable group so it's behind everything
|
||||
svg.insert("rect", "#zoomable")
|
||||
.attr("x", 0)
|
||||
.attr("y", 0)
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("fill", "transparent")
|
||||
.attr("pointer-events", "all")
|
||||
.style("cursor", "grab");
|
||||
|
||||
// Zoom Funktion aktivieren
|
||||
const zoom = d3.zoom()
|
||||
@ -316,7 +454,12 @@ function draw(pathway, elem) {
|
||||
zoomable.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
d3.select("svg").call(zoom);
|
||||
// Apply zoom to the SVG element - this enables wheel zoom
|
||||
svg.call(zoom);
|
||||
|
||||
// Also apply zoom to container to catch events that might not reach SVG
|
||||
// This ensures drag-to-pan works even when clicking on empty space
|
||||
container.call(zoom);
|
||||
|
||||
nodes = pathway['nodes'];
|
||||
links = pathway['links'];
|
||||
@ -381,7 +524,7 @@ function draw(pathway, elem) {
|
||||
node.append("circle")
|
||||
// make radius "invisible" for pseudo nodes
|
||||
.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
|
||||
node.filter(d => !d.pseudo).each(function (d, i) {
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_compound_modal">
|
||||
<span class="glyphicon glyphicon-plus"></span> New Compound</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -1,11 +0,0 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#new_compound_structure_modal"
|
||||
>
|
||||
<span class="glyphicon glyphicon-plus"></span> New Compound Structure</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -1,6 +1,9 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_edge_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('new_edge_modal').showModal(); return false;"
|
||||
>
|
||||
<span class="glyphicon glyphicon-plus"></span> New Edge</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_group_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('new_group_modal').showModal(); return false;"
|
||||
>
|
||||
<span class="glyphicon glyphicon-plus"></span> New Group</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_model_modal">
|
||||
<span class="glyphicon glyphicon-plus"></span> New Model</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -1,6 +1,9 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_node_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('new_node_modal').showModal(); return false;"
|
||||
>
|
||||
<span class="glyphicon glyphicon-plus"></span> New Node</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_package_modal">
|
||||
<span class="glyphicon glyphicon-plus"></span> New Package</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#import_package_modal">
|
||||
<span class="glyphicon glyphicon-import"></span> Import Package from JSON</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#import_legacy_package_modal"
|
||||
>
|
||||
<span class="glyphicon glyphicon-import"></span> Import Package from legacy
|
||||
JSON</a
|
||||
>
|
||||
</li>
|
||||
@ -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,7 +0,0 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_reaction_modal">
|
||||
<span class="glyphicon glyphicon-plus"></span> New Reaction</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -1,7 +0,0 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_rule_modal">
|
||||
<span class="glyphicon glyphicon-plus"></span> New Rule</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -1,7 +0,0 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_scenario_modal">
|
||||
<span class="glyphicon glyphicon-plus"></span> New Scenario</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -1,6 +1,9 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#new_setting_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('new_setting_modal').showModal(); return false;"
|
||||
>
|
||||
<span class="glyphicon glyphicon-plus"></span>New Setting</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,42 +1,59 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_compound_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_compound_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Compound</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#add_structure_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('add_structure_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Add Structure</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#generic_set_external_reference_modal"
|
||||
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||
>
|
||||
</li>
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -2,33 +2,40 @@
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#edit_compound_structure_modal"
|
||||
onclick="document.getElementById('edit_compound_structure_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Compound Structure</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#generic_set_external_reference_modal"
|
||||
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound Structure</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,16 +1,25 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Edge</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,11 +1,17 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_group_member_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_group_member_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Add/Remove Member</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Group</a
|
||||
>
|
||||
</li>
|
||||
|
||||
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 %}
|
||||
@ -1,21 +1,37 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_model_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_model_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Model</a
|
||||
>
|
||||
</li>
|
||||
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#evaluate_model_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('evaluate_model_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#retrain_model_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('retrain_model_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Model</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,21 +1,33 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_node_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_node_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Node</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Node</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,35 +1,49 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_package_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_package_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Package</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#edit_package_permissions_modal"
|
||||
onclick="document.getElementById('edit_package_permissions_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#publish_package_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('publish_package_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#export_package_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('export_package_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_license_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_license_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-duplicate"></i> License</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Package</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,26 +1,34 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#add_pathway_node_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('add_pathway_node_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Add Compound</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#add_pathway_edge_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('add_pathway_edge_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a
|
||||
>
|
||||
</li>
|
||||
<li role="separator" class="divider"></li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#download_pathway_csv_modal"
|
||||
onclick="document.getElementById('download_pathway_csv_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as CSV</a
|
||||
>
|
||||
@ -28,18 +36,24 @@
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#download_pathway_image_modal"
|
||||
onclick="document.getElementById('download_pathway_image_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('engineer_pathway_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-cog"></i> Engineer Pathway</a
|
||||
>
|
||||
</li>
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#identify_missing_rules_modal"
|
||||
onclick="document.getElementById('identify_missing_rules_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-question-sign"></i> Identify Missing
|
||||
Rules</a
|
||||
@ -47,30 +61,34 @@
|
||||
</li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#edit_pathway_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('edit_pathway_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
{# <li>#}
|
||||
{# <a class="button" data-toggle="modal" data-target="#add_pathway_edge_modal">#}
|
||||
{# <i class="glyphicon glyphicon-plus"></i> Calculate Compound Properties</a>#}
|
||||
{# </li>#}
|
||||
<li role="separator" class="divider"></li>
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#delete_pathway_node_modal"
|
||||
onclick="document.getElementById('delete_pathway_node_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
|
||||
>
|
||||
@ -78,14 +96,16 @@
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#delete_pathway_edge_modal"
|
||||
onclick="document.getElementById('delete_pathway_edge_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Pathway</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,37 +1,51 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_reaction_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_reaction_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Reaction</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#generic_set_external_reference_modal"
|
||||
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||
>
|
||||
</li>
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,28 +1,43 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_rule_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_rule_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Edit Rule</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||
>
|
||||
</li>
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Rule</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -2,8 +2,7 @@
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#add_additional_information_modal"
|
||||
onclick="document.getElementById('add_additional_information_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a
|
||||
>
|
||||
@ -11,14 +10,16 @@
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
data-toggle="modal"
|
||||
data-target="#update_scenario_additional_information_modal"
|
||||
onclick="document.getElementById('update_scenario_additional_information_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Set Additional Information</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Scenario</a
|
||||
>
|
||||
</li>
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
{% if meta.can_edit %}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_user_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_user_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-edit"></i> Update</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#edit_password_modal">
|
||||
<a
|
||||
role="button"
|
||||
onclick="document.getElementById('edit_password_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-lock"></i> Update Password</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
data-toggle="modal"
|
||||
data-target="#new_prediction_setting_modal"
|
||||
onclick="document.getElementById('new_prediction_setting_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-plus"></i> New Prediction Setting</a
|
||||
>
|
||||
@ -23,7 +28,10 @@
|
||||
{# <i class="glyphicon glyphicon-console"></i> Manage API Token</a>#}
|
||||
{# </li>#}
|
||||
<li>
|
||||
<a role="button" data-toggle="modal" data-target="#generic_delete_modal">
|
||||
<a
|
||||
class="button"
|
||||
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||
>
|
||||
<i class="glyphicon glyphicon-trash"></i> Delete Account</a
|
||||
>
|
||||
</li>
|
||||
|
||||
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 %}
|
||||
@ -1,56 +1,59 @@
|
||||
{% extends "framework.html" %}
|
||||
{% extends "framework_modern.html" %}
|
||||
{% load static %}
|
||||
{% load envipytags %}
|
||||
{% block content %}
|
||||
<div class="panel-group" id="reviewListAccordion">
|
||||
<div class="panel panel-default">
|
||||
<div
|
||||
class="panel-heading"
|
||||
id="headingPanel"
|
||||
style="font-size:2rem;height: 46px"
|
||||
>
|
||||
Jobs
|
||||
<div class="space-y-2 p-4">
|
||||
<!-- Header Section -->
|
||||
<div class="card bg-base-100">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">User Prediction Jobs</h2>
|
||||
<p class="mt-2">Job Logs Desc</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>Job Logs Desc</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="panel panel-default panel-heading list-group-item"
|
||||
style="background-color:silver"
|
||||
>
|
||||
<h4 class="panel-title">
|
||||
<a
|
||||
id="job-accordion-link"
|
||||
data-toggle="collapse"
|
||||
data-parent="#job-accordion"
|
||||
href="#jobs"
|
||||
>
|
||||
Jobs
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="jobs" class="panel-collapse in collapse">
|
||||
<div class="panel-body list-group-item" id="job-content">
|
||||
<table class="table-bordered table-hover table">
|
||||
<tr style="background-color: rgba(0, 0, 0, 0.08);">
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Queued</th>
|
||||
<th scope="col">Done</th>
|
||||
<th scope="col">Result</th>
|
||||
<!-- Jobs -->
|
||||
<div class="collapse-arrow bg-base-200 collapse">
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">Recent Jobs</div>
|
||||
<div class="collapse-content" id="job-content">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if meta.user.is_superuser %}
|
||||
<th>User</th>
|
||||
{% endif %}
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Queued</th>
|
||||
<th>Done</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>{{ job.task_id }}</td>
|
||||
{% if meta.user.is_superuser %}
|
||||
<td>
|
||||
<a href="{{ job.user.url }}">{{ job.user.username }}</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<a href="{% url 'job detail' job.task_id %}"
|
||||
>{{ job.task_id }}</a
|
||||
>
|
||||
</td>
|
||||
<td>{{ job.job_name }}</td>
|
||||
<td>{{ job.status }}</td>
|
||||
<td>{{ job.created }}</td>
|
||||
<td>{{ job.done_at }}</td>
|
||||
{% if job.task_result and job.task_result|is_url == True %}
|
||||
<td><a href="{{ job.task_result }}">Result</a></td>
|
||||
<td>
|
||||
<a href="{{ job.task_result }}" class="link link-primary"
|
||||
>Result</a
|
||||
>
|
||||
</td>
|
||||
{% elif job.task_result %}
|
||||
<td>{{ job.task_result|slice:"40" }}...</td>
|
||||
{% else %}
|
||||
@ -62,19 +65,31 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if objects %}
|
||||
<!-- Unreviewable objects such as User / Group / Setting -->
|
||||
<ul class="list-group">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<ul class="menu bg-base-200 rounded-box">
|
||||
{% for obj in objects %}
|
||||
{% if object_type == 'user' %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
<li>
|
||||
<a href="{{ obj.url }}" class="hover:bg-base-300"
|
||||
>{{ obj.username }}</a
|
||||
>
|
||||
</li>
|
||||
{% else %}
|
||||
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name }}</a>
|
||||
<li>
|
||||
<a href="{{ obj.url }}" class="hover:bg-base-300"
|
||||
>{{ obj.name }}</a
|
||||
>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
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 %}
|
||||
@ -1,478 +1,323 @@
|
||||
{% extends "framework.html" %}
|
||||
{% extends "framework_modern.html" %}
|
||||
{% load static %}
|
||||
{% block content %}
|
||||
|
||||
{% if object_type != 'package' %}
|
||||
<div>
|
||||
<div id="load-all-error" style="display: none;">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<span
|
||||
class="glyphicon glyphicon-exclamation-sign"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="sr-only">Error:</span>
|
||||
Getting objects failed!
|
||||
</div>
|
||||
</div>
|
||||
{# Serialize objects data for Alpine pagination #}
|
||||
{# prettier-ignore-start #}
|
||||
{# FIXME: This is a hack to get the objects data into the JavaScript code. #}
|
||||
{% if object_type != 'scenario' %}
|
||||
<script>
|
||||
window.reviewedObjects = [
|
||||
{% for obj in reviewed_objects %}
|
||||
{ "name": "{{ obj.name|escapejs }}", "url": "{{ obj.url }}" }{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
window.unreviewedObjects = [
|
||||
{% for obj in unreviewed_objects %}
|
||||
{ "name": "{{ obj.name|escapejs }}", "url": "{{ obj.url }}" }{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
</script>
|
||||
{% endif %}
|
||||
{# prettier-ignore-end #}
|
||||
|
||||
<div class="px-8 py-4">
|
||||
<input
|
||||
type="text"
|
||||
id="object-search"
|
||||
class="form-control"
|
||||
class="input input-bordered hidden w-full max-w-xs"
|
||||
placeholder="Search by name"
|
||||
style="display: none;"
|
||||
/>
|
||||
<p></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block action_modals %}
|
||||
{% if object_type == 'package' %}
|
||||
{% 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' %}
|
||||
{% if object_type == 'node' %}
|
||||
{% include "modals/collections/new_node_modal.html" %}
|
||||
{% elif object_type == 'edge' %}
|
||||
{% 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 %}
|
||||
{% endblock action_modals %}
|
||||
|
||||
<div class="panel-group" id="reviewListAccordion">
|
||||
<div class="panel panel-default">
|
||||
<div
|
||||
class="panel-heading"
|
||||
id="headingPanel"
|
||||
style="font-size:2rem;height: 46px"
|
||||
>
|
||||
{% if object_type == 'package' %}
|
||||
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' %}
|
||||
<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">
|
||||
{% if object_type == 'node' %}
|
||||
Nodes
|
||||
{% elif object_type == 'edge' %}
|
||||
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 %}
|
||||
<div
|
||||
id="actionsButton"
|
||||
style="float: right;font-weight: normal;font-size: medium;position: relative; top: 50%; transform: translateY(-50%);z-index:100;display: none;"
|
||||
class="dropdown"
|
||||
</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"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
><span class="glyphicon glyphicon-wrench"></span> Actions
|
||||
<span class="caret"></span><span style="padding-right:1em"></span
|
||||
></a>
|
||||
<ul id="actionsList" class="dropdown-menu">
|
||||
{% block actions %}
|
||||
{% if object_type == 'package' %}
|
||||
{% 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' %}
|
||||
{% if object_type == 'node' %}
|
||||
{% include "actions/collections/node.html" %}
|
||||
{% elif object_type == 'edge' %}
|
||||
{% include "actions/collections/edge.html" %}
|
||||
{% elif object_type == 'group' %}
|
||||
{% include "actions/collections/group.html" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- Set Text above links -->
|
||||
{% 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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>Learn more >></a
|
||||
>
|
||||
</p>
|
||||
{% elif object_type == 'node' %}
|
||||
<div class="mt-2">
|
||||
{% if object_type == 'node' %}
|
||||
<p>
|
||||
Nodes represent the (predicted) compounds in a graph.
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://wiki.envipath.org/index.php/nodes"
|
||||
role="button"
|
||||
class="link link-primary"
|
||||
>Learn more >></a
|
||||
>
|
||||
</p>
|
||||
{% elif object_type == 'edge' %}
|
||||
<p>
|
||||
Edges represent the links between Nodes in a graph
|
||||
Edges represent the links between nodes in a graph.
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://wiki.envipath.org/index.php/edges"
|
||||
role="button"
|
||||
>Learn more >></a
|
||||
>
|
||||
</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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
>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"
|
||||
role="button"
|
||||
class="link link-primary"
|
||||
>Learn more >></a
|
||||
>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- If theres nothing to show extend the text above -->
|
||||
{% if reviewed_objects and unreviewed_objects %}
|
||||
{% if reviewed_objects|length == 0 and unreviewed_objects|length == 0 %}
|
||||
<p>
|
||||
Nothing found. There are two possible reasons: <br /><br />1.
|
||||
There is no content yet.<br />2. You have no reading
|
||||
permissions.<br /><br />Please be sure you have at least reading
|
||||
permissions.
|
||||
<p class="mt-4">
|
||||
Nothing found. There are two possible reasons:<br /><br />
|
||||
1. There is no content yet.<br />
|
||||
2. You have no reading permissions.<br /><br />
|
||||
Please ensure you have at least reading permissions.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lists Container -->
|
||||
<div class="w-full">
|
||||
{% if reviewed_objects %}
|
||||
{% if reviewed_objects|length > 0 %}
|
||||
<!-- Reviewed -->
|
||||
<div
|
||||
class="panel panel-default panel-heading list-group-item"
|
||||
style="background-color:silver"
|
||||
class="collapse-arrow bg-base-200 collapse order-2 w-full"
|
||||
x-data="paginatedList(window.reviewedObjects || [], { isReviewed: true, instanceId: 'reviewed' })"
|
||||
>
|
||||
<h4 class="panel-title">
|
||||
<a
|
||||
id="ReviewedLink"
|
||||
data-toggle="collapse"
|
||||
data-parent="#reviewListAccordion"
|
||||
href="#Reviewed"
|
||||
>Reviewed</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
<div id="Reviewed" class="panel-collapse in collapse">
|
||||
<div class="panel-body list-group-item" id="ReviewedContent">
|
||||
{% if object_type == 'package' %}
|
||||
{% for obj in reviewed_objects %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
>{{ obj.name|safe }}
|
||||
<input type="checkbox" checked />
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
Reviewed
|
||||
<span
|
||||
class="glyphicon glyphicon-star"
|
||||
aria-hidden="true"
|
||||
style="float:right"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title=""
|
||||
data-original-title="Reviewed"
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for obj in reviewed_objects|slice:":50" %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
>{{ obj.name|safe }}{# <i>({{ obj.package.name }})</i> #}
|
||||
class="badge badge-sm badge-neutral ml-2"
|
||||
x-text="totalItems"
|
||||
></span>
|
||||
</div>
|
||||
<div class="collapse-content w-full">
|
||||
<ul class="menu bg-base-100 rounded-box w-full">
|
||||
<template x-for="obj in paginatedItems" :key="obj.url">
|
||||
<li>
|
||||
<a :href="obj.url" class="hover:bg-base-200">
|
||||
<span x-text="obj.name"></span>
|
||||
<span
|
||||
class="glyphicon glyphicon-star"
|
||||
aria-hidden="true"
|
||||
style="float:right"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title=""
|
||||
data-original-title="Reviewed"
|
||||
class="tooltip tooltip-left ml-auto"
|
||||
data-tip="Reviewed"
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if unreviewed_objects %}
|
||||
<div
|
||||
class="panel panel-default panel-heading list-group-item"
|
||||
style="background-color:silver"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-star"
|
||||
>
|
||||
<h4 class="panel-title">
|
||||
<a
|
||||
id="UnreviewedLink"
|
||||
data-toggle="collapse"
|
||||
data-parent="#unReviewListAccordion"
|
||||
href="#Unreviewed"
|
||||
>Unreviewed</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
id="Unreviewed"
|
||||
class="panel-collapse {% if reviewed_objects|length == 0 or object_type == 'package' %}in{% endif %} collapse"
|
||||
>
|
||||
<div class="panel-body list-group-item" id="UnreviewedContent">
|
||||
{% if object_type == 'package' %}
|
||||
{% for obj in unreviewed_objects %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
>{{ obj.name|safe }}</a
|
||||
>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% for obj in unreviewed_objects|slice:":50" %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
>{{ obj.name|safe }}</a
|
||||
>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if objects %}
|
||||
<!-- Unreviewable objects such as User / Group / Setting -->
|
||||
<ul class="list-group">
|
||||
{% for obj in objects %}
|
||||
{% if object_type == 'user' %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
>{{ obj.username|safe }}</a
|
||||
>
|
||||
{% else %}
|
||||
<a class="list-group-item" href="{{ obj.url }}"
|
||||
>{{ obj.name|safe }}</a
|
||||
>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
<style>
|
||||
.spinner-widget {
|
||||
position: fixed; /* stays in place on scroll */
|
||||
bottom: 20px; /* distance from bottom */
|
||||
right: 20px; /* distance from right */
|
||||
z-index: 9999; /* above most elements */
|
||||
width: 60px; /* adjust to gif size */
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.spinner-widget img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="load-all-loading" class="spinner-widget" style="display: none">
|
||||
<img
|
||||
id="loading-gif"
|
||||
src="{% static '/images/wait.gif' %}"
|
||||
alt="Loading..."
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</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>
|
||||
{# prettier-ignore-start #}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if unreviewed_objects %}
|
||||
<!-- Unreviewed -->
|
||||
<div
|
||||
class="collapse-arrow bg-base-200 collapse order-1 w-full"
|
||||
x-data="paginatedList(window.unreviewedObjects || [], { isReviewed: false, instanceId: 'unreviewed' })"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
{% if reviewed_objects|length == 0 %}checked{% endif %}
|
||||
/>
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
Unreviewed
|
||||
<span
|
||||
class="badge badge-sm badge-neutral ml-2"
|
||||
x-text="totalItems"
|
||||
></span>
|
||||
</div>
|
||||
<div class="collapse-content w-full">
|
||||
<ul class="menu bg-base-100 rounded-box w-full">
|
||||
<template x-for="obj in paginatedItems" :key="obj.url">
|
||||
<li>
|
||||
<a
|
||||
:href="obj.url"
|
||||
class="hover:bg-base-200"
|
||||
x-text="obj.name"
|
||||
></a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<!-- Pagination Controls -->
|
||||
<div
|
||||
x-show="totalPages > 1"
|
||||
class="mt-4 flex items-center justify-between px-2"
|
||||
>
|
||||
<span class="text-base-content/70 text-sm">
|
||||
Showing <span x-text="showingStart"></span>-<span
|
||||
x-text="showingEnd"
|
||||
></span>
|
||||
of <span x-text="totalItems"></span>
|
||||
</span>
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:disabled="currentPage === 1"
|
||||
@click="prevPage()"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<template x-for="item in pageNumbers" :key="item.key">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:class="{ 'btn-active': item.page === currentPage }"
|
||||
:disabled="item.isEllipsis"
|
||||
@click="!item.isEllipsis && goToPage(item.page)"
|
||||
x-text="item.page"
|
||||
></button>
|
||||
</template>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="nextPage()"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
|
||||
$('#object-search').show();
|
||||
|
||||
{% if object_type != 'package' and object_type != 'user' and object_type != 'group' %}
|
||||
{% if reviewed_objects|length > 50 or unreviewed_objects|length > 50 %}
|
||||
$('#load-all-loading').show()
|
||||
|
||||
setTimeout(function () {
|
||||
$('#load-all-error').hide();
|
||||
|
||||
$.getJSON('?all=true', function (resp) {
|
||||
$('#ReviewedContent').empty();
|
||||
$('#UnreviewedContent').empty();
|
||||
|
||||
for (o in resp.objects) {
|
||||
obj = resp.objects[o];
|
||||
if (obj.reviewed) {
|
||||
$('#ReviewedContent').append('<a class="list-group-item" href="' + obj.url + '">' + obj.name + ' <span class="glyphicon glyphicon-star" aria-hidden="true" style="float:right" data-toggle="tooltip" data-placement="top" title="" data-original-title="Reviewed"></span></a>');
|
||||
} else {
|
||||
$('#UnreviewedContent').append('<a class="list-group-item" href="' + obj.url + '">' + obj.name + '</a>');
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Show actions button if there are actions
|
||||
const actionsButton = document.getElementById("actionsButton");
|
||||
const actionsList = actionsButton?.querySelector("ul");
|
||||
if (actionsList && actionsList.children.length > 0) {
|
||||
actionsButton?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
$('#load-all-loading').hide();
|
||||
$('#load-remaining').hide();
|
||||
}).fail(function (resp) {
|
||||
$('#load-all-loading').hide();
|
||||
$('#load-all-error').show();
|
||||
// Show search input and connect to Alpine pagination
|
||||
const objectSearch = document.getElementById("object-search");
|
||||
if (objectSearch) {
|
||||
objectSearch.classList.remove("hidden");
|
||||
objectSearch.addEventListener("input", function () {
|
||||
const query = this.value;
|
||||
// Dispatch search to all paginatedList components
|
||||
document
|
||||
.querySelectorAll('[x-data*="paginatedList"]')
|
||||
.forEach((el) => {
|
||||
if (el._x_dataStack && el._x_dataStack[0]) {
|
||||
el._x_dataStack[0].search(query);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}, 2500);
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
$('#modal-form-delete-submit').on('click', function (e) {
|
||||
// Delete form submit handler
|
||||
const deleteSubmit = document.getElementById("modal-form-delete-submit");
|
||||
const deleteForm = document.getElementById("modal-form-delete");
|
||||
if (deleteSubmit && deleteForm) {
|
||||
deleteSubmit.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
$('#modal-form-delete').submit();
|
||||
deleteForm.submit();
|
||||
});
|
||||
|
||||
$('#object-search').on('keyup', function () {
|
||||
let query = $(this).val().toLowerCase();
|
||||
$('a.list-group-item').each(function () {
|
||||
let text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.indexOf(query) !== -1);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{# prettier-ignore-end #}
|
||||
{% endblock content %}
|
||||
|
||||
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>
|
||||
<a class="link link-hover" href="/predict">Predict</a>
|
||||
<a class="link link-hover" href="/package">Packages</a>
|
||||
{% if user.is_authenticated %}
|
||||
<a class="link link-hover" href="/model">Your Collections</a>
|
||||
{% endif %}
|
||||
{# {% if user.is_authenticated %}#}
|
||||
{# <a class="link link-hover" href="/model">Your Collections</a>#}
|
||||
{# {% endif %}#}
|
||||
<a
|
||||
href="https://wiki.envipath.org/"
|
||||
target="_blank"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user