forked from enviPath/enviPy
Compare commits
34 Commits
fix/missin
...
6499a0c659
| 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 | |||
| 39faab3d11 | |||
| 4e80cd63cd | |||
| 6592f0a68e | |||
| 21d30a923f | |||
| 12a20756d6 |
@ -20,3 +20,16 @@ LOG_LEVEL='INFO'
|
|||||||
SERVER_URL='http://localhost:8000'
|
SERVER_URL='http://localhost:8000'
|
||||||
PLUGINS_ENABLED=True
|
PLUGINS_ENABLED=True
|
||||||
EP_DATA_DIR='data'
|
EP_DATA_DIR='data'
|
||||||
|
EMAIL_HOST_USER='admin@envipath.com'
|
||||||
|
EMAIL_HOST_PASSWORD='dummy-password'
|
||||||
|
|
||||||
|
DEFAULT_FROM_EMAIL="test@test.com"
|
||||||
|
SERVER_EMAIL='test@test.com'
|
||||||
|
|
||||||
|
# Testing settings VScode
|
||||||
|
DJANGO_SETTINGS_MODULE='envipath.settings'
|
||||||
|
MANAGE_PY_PATH='./manage.py'
|
||||||
|
|
||||||
|
APPLICABILITY_DOMAIN_ENABLED=True
|
||||||
|
ENVIFORMER_PRESENT=True
|
||||||
|
MODEL_BUILDING_ENABLED=True
|
||||||
|
|||||||
72
.gitea/actions/setup-envipy/action.yaml
Normal file
72
.gitea/actions/setup-envipy/action.yaml
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
name: 'Setup enviPy Environment'
|
||||||
|
description: 'Shared setup for enviPy CI - installs dependencies and prepares environment'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
skip-frontend:
|
||||||
|
description: 'Skip frontend build steps (pnpm, tailwind)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
skip-playwright:
|
||||||
|
description: 'Skip Playwright installation'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
ssh-private-key:
|
||||||
|
description: 'SSH private key for git access'
|
||||||
|
required: true
|
||||||
|
run-migrations:
|
||||||
|
description: 'Run Django migrations after setup'
|
||||||
|
required: false
|
||||||
|
default: 'true'
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Setup ssh
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ inputs.ssh-private-key }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts
|
||||||
|
eval $(ssh-agent -s)
|
||||||
|
ssh-add ~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
- name: Setup Python venv
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
uv sync --locked --all-extras --dev
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
if: inputs.skip-playwright == 'false'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
source .venv/bin/activate
|
||||||
|
playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
if: inputs.skip-frontend == 'false'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
uv run python scripts/pnpm_wrapper.py install
|
||||||
|
cat << 'EOF' > pnpm-workspace.yaml
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- '@tailwindcss/oxide'
|
||||||
|
EOF
|
||||||
|
uv run python scripts/pnpm_wrapper.py run build
|
||||||
|
|
||||||
|
- name: Wait for Postgres
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
until pg_isready -h postgres -U ${{ env.POSTGRES_USER }}; do
|
||||||
|
echo "Waiting for postgres..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Postgres is ready!"
|
||||||
|
|
||||||
|
- name: Run Django Migrations
|
||||||
|
if: inputs.run-migrations == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
source .venv/bin/activate
|
||||||
|
python manage.py migrate --noinput
|
||||||
53
.gitea/docker/Dockerfile.ci
Normal file
53
.gitea/docker/Dockerfile.ci
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Custom CI Docker image for Gitea runners
|
||||||
|
# Pre-installs Node.js 24, pnpm 10, uv, and system dependencies
|
||||||
|
# to eliminate setup time in CI workflows
|
||||||
|
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
# Prevent interactive prompts during package installation
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
postgresql-client \
|
||||||
|
redis-tools \
|
||||||
|
openjdk-11-jre-headless \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
gnupg \
|
||||||
|
lsb-release \
|
||||||
|
git \
|
||||||
|
ssh \
|
||||||
|
libxrender1 \
|
||||||
|
libxext6 \
|
||||||
|
libfontconfig1 \
|
||||||
|
libfreetype6 \
|
||||||
|
libcairo2 \
|
||||||
|
libglib2.0-0t64 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Node.js 24 via NodeSource
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Enable corepack and install pnpm 10
|
||||||
|
RUN corepack enable && \
|
||||||
|
corepack prepare pnpm@10 --activate
|
||||||
|
|
||||||
|
# Install uv https://docs.astral.sh/uv/guides/integration/docker/#available-images
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
# Verify installations
|
||||||
|
RUN node --version && \
|
||||||
|
npm --version && \
|
||||||
|
pnpm --version && \
|
||||||
|
uv --version && \
|
||||||
|
pg_isready --version && \
|
||||||
|
redis-cli --version && \
|
||||||
|
java -version
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /workspace
|
||||||
86
.gitea/workflows/api-ci.yaml
Normal file
86
.gitea/workflows/api-ci.yaml
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
name: API CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
paths:
|
||||||
|
- 'epapi/**'
|
||||||
|
- 'epdb/models.py' # API depends on models
|
||||||
|
- 'epdb/logic.py' # API depends on business logic
|
||||||
|
- 'tests/fixtures/**' # API tests use fixtures
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
api-tests:
|
||||||
|
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.envipath.com/envipath/envipy-ci:latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ vars.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ vars.POSTGRES_DB }}
|
||||||
|
ports:
|
||||||
|
- ${{ vars.POSTGRES_PORT}}:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd="pg_isready -U postgres"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=5
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
EP_DATA_DIR: /opt/enviPy/
|
||||||
|
ALLOWED_HOSTS: 127.0.0.1,localhost
|
||||||
|
DEBUG: True
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
MODEL_BUILDING_ENABLED: True
|
||||||
|
APPLICABILITY_DOMAIN_ENABLED: True
|
||||||
|
ENVIFORMER_PRESENT: True
|
||||||
|
ENVIFORMER_DEVICE: cpu
|
||||||
|
FLAG_CELERY_PRESENT: False
|
||||||
|
PLUGINS_ENABLED: True
|
||||||
|
SERVER_URL: http://localhost:8000
|
||||||
|
ADMIN_APPROVAL_REQUIRED: True
|
||||||
|
REGISTRATION_MANDATORY: True
|
||||||
|
LOG_DIR: ''
|
||||||
|
# DB
|
||||||
|
POSTGRES_SERVICE_NAME: postgres
|
||||||
|
POSTGRES_DB: ${{ vars.POSTGRES_DB }}
|
||||||
|
POSTGRES_USER: ${{ vars.POSTGRES_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
# SENTRY
|
||||||
|
SENTRY_ENABLED: False
|
||||||
|
# MS ENTRA
|
||||||
|
MS_ENTRA_ENABLED: False
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Use shared setup action - skips frontend builds for API-only tests
|
||||||
|
- name: Setup enviPy Environment
|
||||||
|
uses: ./.gitea/actions/setup-envipy
|
||||||
|
with:
|
||||||
|
skip-frontend: 'true'
|
||||||
|
skip-playwright: 'false'
|
||||||
|
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
|
||||||
|
run-migrations: 'true'
|
||||||
|
|
||||||
|
- name: Run API tests
|
||||||
|
run: |
|
||||||
|
.venv/bin/python manage.py test epapi -v 2
|
||||||
|
|
||||||
|
- name: Test API endpoints availability
|
||||||
|
run: |
|
||||||
|
.venv/bin/python manage.py runserver 0.0.0.0:8000 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
sleep 5
|
||||||
|
curl -f http://localhost:8000/api/v1/docs || echo "API docs not available"
|
||||||
|
kill $SERVER_PID
|
||||||
48
.gitea/workflows/build-ci-image.yaml
Normal file
48
.gitea/workflows/build-ci-image.yaml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
name: Build CI Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- '.gitea/docker/Dockerfile.ci'
|
||||||
|
- '.gitea/workflows/build-ci-image.yaml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.envipath.com
|
||||||
|
username: ${{ secrets.CI_REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.envipath.com/envipath/envipy-ci
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest
|
||||||
|
type=sha,prefix={{branch}}-
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: .gitea/docker/Dockerfile.ci
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=registry,ref=git.envipath.com/envipath/envipy-ci:latest
|
||||||
|
cache-to: type=inline
|
||||||
@ -8,7 +8,10 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.envipath.com/envipath/envipy-ci:latest
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@ -40,7 +43,7 @@ jobs:
|
|||||||
EP_DATA_DIR: /opt/enviPy/
|
EP_DATA_DIR: /opt/enviPy/
|
||||||
ALLOWED_HOSTS: 127.0.0.1,localhost
|
ALLOWED_HOSTS: 127.0.0.1,localhost
|
||||||
DEBUG: True
|
DEBUG: True
|
||||||
LOG_LEVEL: DEBUG
|
LOG_LEVEL: INFO
|
||||||
MODEL_BUILDING_ENABLED: True
|
MODEL_BUILDING_ENABLED: True
|
||||||
APPLICABILITY_DOMAIN_ENABLED: True
|
APPLICABILITY_DOMAIN_ENABLED: True
|
||||||
ENVIFORMER_PRESENT: True
|
ENVIFORMER_PRESENT: True
|
||||||
@ -63,54 +66,22 @@ jobs:
|
|||||||
MS_ENTRA_ENABLED: False
|
MS_ENTRA_ENABLED: False
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install system tools via apt
|
# Use shared setup action - includes all dependencies and migrations
|
||||||
run: |
|
- name: Setup enviPy Environment
|
||||||
sudo apt-get update
|
uses: ./.gitea/actions/setup-envipy
|
||||||
sudo apt-get install -y postgresql-client redis-tools openjdk-11-jre-headless
|
|
||||||
|
|
||||||
- name: Setup ssh
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.ENVIPY_CI_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts
|
|
||||||
eval $(ssh-agent -s)
|
|
||||||
ssh-add ~/.ssh/id_ed25519
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
with:
|
||||||
version: 10
|
skip-frontend: 'false'
|
||||||
|
skip-playwright: 'false'
|
||||||
|
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
|
||||||
|
run-migrations: 'true'
|
||||||
|
|
||||||
- name: Use Node.js
|
- name: Run frontend tests
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: "pnpm"
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v6
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
|
|
||||||
- name: Setup venv
|
|
||||||
run: |
|
run: |
|
||||||
uv sync --locked --all-extras --dev
|
.venv/bin/python manage.py test --tag frontend
|
||||||
|
|
||||||
- name: Wait for services
|
|
||||||
run: |
|
|
||||||
until pg_isready -h postgres -U postgres; do sleep 2; done
|
|
||||||
# until redis-cli -h redis ping; do sleep 2; done
|
|
||||||
|
|
||||||
- name: Run Django Migrations
|
|
||||||
run: |
|
|
||||||
source .venv/bin/activate
|
|
||||||
python manage.py migrate --noinput
|
|
||||||
|
|
||||||
- name: Run Django tests
|
- name: Run Django tests
|
||||||
run: |
|
run: |
|
||||||
source .venv/bin/activate
|
.venv/bin/python manage.py test tests --exclude-tag slow --exclude-tag frontend
|
||||||
python manage.py test tests --exclude-tag slow
|
|
||||||
|
|||||||
372
.gitignore
vendored
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
|
db.sqlite3
|
||||||
.idea/
|
db.sqlite3-journal
|
||||||
static/admin/
|
static/admin/
|
||||||
static/django_extensions/
|
static/django_extensions/
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||||
|
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||||
|
# pdm.lock
|
||||||
|
# pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# pixi
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||||
|
# pixi.lock
|
||||||
|
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||||
|
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||||
|
.pixi
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
*.rdb
|
||||||
|
*.aof
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# RabbitMQ
|
||||||
|
mnesia/
|
||||||
|
rabbitmq/
|
||||||
|
rabbitmq-data/
|
||||||
|
|
||||||
|
# ActiveMQ
|
||||||
|
activemq-data/
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
.envrc
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Abstra
|
||||||
|
# Abstra is an AI-powered process automation framework.
|
||||||
|
# Ignore directories containing user credentials, local state, and settings.
|
||||||
|
# Learn more at https://abstra.io/docs
|
||||||
|
.abstra/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||||
|
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||||
|
# you could uncomment the following to ignore the entire vscode folder
|
||||||
|
.vscode/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# UV cache
|
||||||
|
.uv-cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# Marimo
|
||||||
|
marimo/_static/
|
||||||
|
marimo/_lsp/
|
||||||
|
__marimo__/
|
||||||
|
|
||||||
|
# Streamlit
|
||||||
|
.streamlit/secrets.toml
|
||||||
|
|
||||||
|
### Agents ###
|
||||||
|
.claude/
|
||||||
|
.codex/
|
||||||
|
.cursor/
|
||||||
|
.github/prompts/
|
||||||
|
.junie/
|
||||||
|
.windsurf/
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
GEMINI.md
|
||||||
|
.aider.*
|
||||||
|
|
||||||
|
### Node.js ###
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.output
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Sveltekit cache directory
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# Firebase cache directory
|
||||||
|
.firebase/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v3
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Vite files
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
### Custom ###
|
||||||
|
|
||||||
debug.log
|
debug.log
|
||||||
scratches/
|
scratches/
|
||||||
|
test-results/
|
||||||
data/
|
data/
|
||||||
|
*.arff
|
||||||
|
|
||||||
.DS_Store
|
# Auto generated
|
||||||
|
|
||||||
node_modules/
|
|
||||||
static/css/output.css
|
static/css/output.css
|
||||||
|
|
||||||
*.code-workspace
|
# macOS system files
|
||||||
|
.DS_Store
|
||||||
|
.Trashes
|
||||||
|
._*
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from epdb.api import router as epdb_app_router
|
from epapi.v1.router import router as v1_router # Refactored API from epdb.api_v2
|
||||||
from epdb.legacy_api import router as epdb_legacy_app_router
|
from epdb.legacy_api import router as epdb_legacy_app_router
|
||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
@ -8,5 +8,5 @@ api_v1 = NinjaAPI(title="API V1 Docs", urls_namespace="api-v1")
|
|||||||
api_legacy = NinjaAPI(title="Legacy API Docs", urls_namespace="api-legacy")
|
api_legacy = NinjaAPI(title="Legacy API Docs", urls_namespace="api-legacy")
|
||||||
|
|
||||||
# Add routers
|
# Add routers
|
||||||
api_v1.add_router("/", epdb_app_router)
|
api_v1.add_router("/", v1_router)
|
||||||
api_legacy.add_router("/", epdb_legacy_app_router)
|
api_legacy.add_router("/", epdb_legacy_app_router)
|
||||||
|
|||||||
@ -48,10 +48,25 @@ INSTALLED_APPS = [
|
|||||||
"django_extensions",
|
"django_extensions",
|
||||||
"oauth2_provider",
|
"oauth2_provider",
|
||||||
# Custom
|
# Custom
|
||||||
|
"epapi", # API endpoints (v1, etc.)
|
||||||
"epdb",
|
"epdb",
|
||||||
"migration",
|
# "migration",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TENANT = os.environ.get("TENANT", "public")
|
||||||
|
|
||||||
|
if TENANT != "public":
|
||||||
|
INSTALLED_APPS.append(TENANT)
|
||||||
|
|
||||||
|
EPDB_PACKAGE_MODEL = os.environ.get("EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||||
|
|
||||||
|
|
||||||
|
def GET_PACKAGE_MODEL():
|
||||||
|
from django.apps import apps
|
||||||
|
|
||||||
|
return apps.get_model(EPDB_PACKAGE_MODEL)
|
||||||
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
]
|
]
|
||||||
@ -184,6 +199,12 @@ if not os.path.exists(LOG_DIR):
|
|||||||
os.mkdir(LOG_DIR)
|
os.mkdir(LOG_DIR)
|
||||||
|
|
||||||
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
|
PLUGIN_DIR = os.path.join(EP_DATA_DIR, "plugins")
|
||||||
|
|
||||||
|
API_PAGINATION_DEFAULT_PAGE_SIZE = int(os.environ.get("API_PAGINATION_DEFAULT_PAGE_SIZE", 50))
|
||||||
|
PAGINATION_MAX_PER_PAGE_SIZE = int(
|
||||||
|
os.environ.get("API_PAGINATION_MAX_PAGE_SIZE", 100)
|
||||||
|
) # Ninja override
|
||||||
|
|
||||||
if not os.path.exists(PLUGIN_DIR):
|
if not os.path.exists(PLUGIN_DIR):
|
||||||
os.mkdir(PLUGIN_DIR)
|
os.mkdir(PLUGIN_DIR)
|
||||||
|
|
||||||
@ -341,6 +362,7 @@ FLAGS = {
|
|||||||
# -> /password_reset/done is covered as well
|
# -> /password_reset/done is covered as well
|
||||||
LOGIN_EXEMPT_URLS = [
|
LOGIN_EXEMPT_URLS = [
|
||||||
"/register",
|
"/register",
|
||||||
|
"/api/v1/", # Let API handle its own authentication
|
||||||
"/api/legacy/",
|
"/api/legacy/",
|
||||||
"/o/token/",
|
"/o/token/",
|
||||||
"/o/userinfo/",
|
"/o/userinfo/",
|
||||||
@ -352,7 +374,7 @@ LOGIN_EXEMPT_URLS = [
|
|||||||
"/cookie-policy",
|
"/cookie-policy",
|
||||||
"/about",
|
"/about",
|
||||||
"/contact",
|
"/contact",
|
||||||
"/jobs",
|
"/careers",
|
||||||
"/cite",
|
"/cite",
|
||||||
"/legal",
|
"/legal",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -23,12 +23,20 @@ from .api import api_v1, api_legacy
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include("epdb.urls")),
|
path("", include("epdb.urls")),
|
||||||
path("", include("migration.urls")),
|
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/v1/", api_v1.urls),
|
path("api/v1/", api_v1.urls),
|
||||||
path("api/legacy/", api_legacy.urls),
|
path("api/legacy/", api_legacy.urls),
|
||||||
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if "migration" in s.INSTALLED_APPS:
|
||||||
|
urlpatterns.append(path("", include("migration.urls")))
|
||||||
|
|
||||||
if s.MS_ENTRA_ENABLED:
|
if s.MS_ENTRA_ENABLED:
|
||||||
urlpatterns.append(path("", include("epauth.urls")))
|
urlpatterns.append(path("", include("epauth.urls")))
|
||||||
|
|
||||||
|
# Custom error handlers
|
||||||
|
handler400 = "epdb.views.handler400"
|
||||||
|
handler403 = "epdb.views.handler403"
|
||||||
|
handler404 = "epdb.views.handler404"
|
||||||
|
handler500 = "epdb.views.handler500"
|
||||||
|
|||||||
0
epapi/__init__.py
Normal file
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 django.contrib import admin
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
User,
|
|
||||||
UserPackagePermission,
|
|
||||||
Group,
|
|
||||||
GroupPackagePermission,
|
|
||||||
Package,
|
|
||||||
MLRelativeReasoning,
|
|
||||||
EnviFormer,
|
|
||||||
Compound,
|
Compound,
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
SimpleAmbitRule,
|
|
||||||
ParallelRule,
|
|
||||||
Reaction,
|
|
||||||
Pathway,
|
|
||||||
Node,
|
|
||||||
Edge,
|
Edge,
|
||||||
Scenario,
|
EnviFormer,
|
||||||
Setting,
|
|
||||||
ExternalDatabase,
|
ExternalDatabase,
|
||||||
ExternalIdentifier,
|
ExternalIdentifier,
|
||||||
|
Group,
|
||||||
|
GroupPackagePermission,
|
||||||
JobLog,
|
JobLog,
|
||||||
License,
|
License,
|
||||||
|
MLRelativeReasoning,
|
||||||
|
Node,
|
||||||
|
ParallelRule,
|
||||||
|
Pathway,
|
||||||
|
Reaction,
|
||||||
|
Scenario,
|
||||||
|
Setting,
|
||||||
|
SimpleAmbitRule,
|
||||||
|
User,
|
||||||
|
UserPackagePermission,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
class UserAdmin(admin.ModelAdmin):
|
class UserAdmin(admin.ModelAdmin):
|
||||||
list_display = ["username", "email", "is_active"]
|
list_display = ["username", "email", "is_active"]
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class EPDBConfig(AppConfig):
|
class EPDBConfig(AppConfig):
|
||||||
@ -7,3 +12,6 @@ class EPDBConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import epdb.signals # noqa: F401
|
import epdb.signals # noqa: F401
|
||||||
|
|
||||||
|
model_name = getattr(settings, "EPDB_PACKAGE_MODEL", "epdb.Package")
|
||||||
|
logger.info(f"Using Package model: {model_name}")
|
||||||
|
|||||||
@ -5,7 +5,7 @@ Context processors automatically make variables available to all templates.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .logic import PackageManager
|
from .logic import PackageManager
|
||||||
from .models import Package
|
from django.conf import settings as s
|
||||||
|
|
||||||
|
|
||||||
def package_context(request):
|
def package_context(request):
|
||||||
@ -20,7 +20,7 @@ def package_context(request):
|
|||||||
|
|
||||||
reviewed_package_qs = PackageManager.get_reviewed_packages()
|
reviewed_package_qs = PackageManager.get_reviewed_packages()
|
||||||
|
|
||||||
unreviewed_package_qs = Package.objects.none()
|
unreviewed_package_qs = s.GET_PACKAGE_MODEL().objects.none()
|
||||||
|
|
||||||
# Only get user-specific packages if user is authenticated
|
# Only get user-specific packages if user is authenticated
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
|
|||||||
@ -1,27 +1,35 @@
|
|||||||
from typing import List, Dict, Optional, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import nh3
|
||||||
|
from django.conf import settings as s
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from ninja import Router, Schema, Field, Form
|
from ninja import Field, Form, Router, Schema, Query
|
||||||
|
from ninja.security import SessionAuth
|
||||||
|
|
||||||
from utilities.chem import FormatConverter
|
from utilities.chem import FormatConverter
|
||||||
from .logic import PackageManager, UserManager, SettingManager
|
from utilities.misc import PackageExporter
|
||||||
|
|
||||||
|
from .logic import GroupManager, PackageManager, SettingManager, UserManager, SearchManager
|
||||||
from .models import (
|
from .models import (
|
||||||
Compound,
|
Compound,
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
Package,
|
Edge,
|
||||||
|
EPModel,
|
||||||
|
Node,
|
||||||
|
Pathway,
|
||||||
|
Reaction,
|
||||||
|
Rule,
|
||||||
|
Scenario,
|
||||||
|
SimpleAmbitRule,
|
||||||
User,
|
User,
|
||||||
UserPackagePermission,
|
UserPackagePermission,
|
||||||
Rule,
|
ParallelRule,
|
||||||
Reaction,
|
|
||||||
Scenario,
|
|
||||||
Pathway,
|
|
||||||
Node,
|
|
||||||
Edge,
|
|
||||||
SimpleAmbitRule,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
def _anonymous_or_real(request):
|
def _anonymous_or_real(request):
|
||||||
if request.user.is_authenticated and not request.user.is_anonymous:
|
if request.user.is_authenticated and not request.user.is_anonymous:
|
||||||
@ -29,8 +37,7 @@ def _anonymous_or_real(request):
|
|||||||
return get_user_model().objects.get(username="anonymous")
|
return get_user_model().objects.get(username="anonymous")
|
||||||
|
|
||||||
|
|
||||||
# router = Router(auth=SessionAuth())
|
router = Router(auth=SessionAuth(csrf=False))
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
class Error(Schema):
|
class Error(Schema):
|
||||||
@ -118,13 +125,16 @@ class SimpleEdge(SimpleObject):
|
|||||||
identifier: str = "edge"
|
identifier: str = "edge"
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleModel(SimpleObject):
|
||||||
|
identifier: str = "relative-reasoning"
|
||||||
|
|
||||||
|
|
||||||
################
|
################
|
||||||
# Login/Logout #
|
# Login/Logout #
|
||||||
################
|
################
|
||||||
@router.post("/", response={200: SimpleUser, 403: Error})
|
@router.post("/", response={200: SimpleUser, 403: Error}, auth=None)
|
||||||
def login(request, loginusername: Form[str], loginpassword: Form[str]):
|
def login(request, loginusername: Form[str], loginpassword: Form[str]):
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate, login
|
||||||
from django.contrib.auth import login
|
|
||||||
|
|
||||||
email = User.objects.get(username=loginusername).email
|
email = User.objects.get(username=loginusername).email
|
||||||
user = authenticate(username=email, password=loginpassword)
|
user = authenticate(username=email, password=loginpassword)
|
||||||
@ -167,9 +177,13 @@ class UserSchema(Schema):
|
|||||||
return SettingManager.get_all_settings(obj)
|
return SettingManager.get_all_settings(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class Me(Schema):
|
||||||
|
whoami: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user", response={200: UserWrapper, 403: Error})
|
@router.get("/user", response={200: UserWrapper, 403: Error})
|
||||||
def get_users(request, whoami: str = None):
|
def get_users(request, me: Query[Me]):
|
||||||
if whoami:
|
if me.whoami:
|
||||||
return {"user": [request.user]}
|
return {"user": [request.user]}
|
||||||
else:
|
else:
|
||||||
return {"user": User.objects.all()}
|
return {"user": User.objects.all()}
|
||||||
@ -186,6 +200,61 @@ def get_user(request, user_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Search(Schema):
|
||||||
|
packages: List[str] = Field(alias="packages[]")
|
||||||
|
search: str
|
||||||
|
method: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response={200: Any, 403: Error})
|
||||||
|
def search(request, search: Query[Search]):
|
||||||
|
try:
|
||||||
|
packs = []
|
||||||
|
for package in search.packages:
|
||||||
|
packs.append(PackageManager.get_package_by_url(request.user, package))
|
||||||
|
|
||||||
|
method = None
|
||||||
|
|
||||||
|
if search.method == "text":
|
||||||
|
method = "text"
|
||||||
|
elif search.method == "inchikey":
|
||||||
|
method = "inchikey"
|
||||||
|
elif search.method == "defaultSmiles":
|
||||||
|
method = "default"
|
||||||
|
elif search.method == "canonicalSmiles":
|
||||||
|
method = "canonical"
|
||||||
|
elif search.method == "exactSmiles":
|
||||||
|
method = "exact"
|
||||||
|
|
||||||
|
if method is None:
|
||||||
|
raise ValueError(f"Search method {search.method} is not supported!")
|
||||||
|
|
||||||
|
search_res = SearchManager.search(packs, search.search, method)
|
||||||
|
res = {}
|
||||||
|
if "Compounds" in search_res:
|
||||||
|
res["compound"] = search_res["Compounds"]
|
||||||
|
|
||||||
|
if "Compound Structures" in search_res:
|
||||||
|
res["structure"] = search_res["Compound Structures"]
|
||||||
|
|
||||||
|
if "Reaction" in search_res:
|
||||||
|
res["reaction"] = search_res["Reaction"]
|
||||||
|
|
||||||
|
if "Pathway" in search_res:
|
||||||
|
res["pathway"] = search_res["Pathway"]
|
||||||
|
|
||||||
|
if "Rules" in search_res:
|
||||||
|
res["rule"] = search_res["Rules"]
|
||||||
|
|
||||||
|
for key in res:
|
||||||
|
for v in res[key]:
|
||||||
|
v["id"] = v["url"].replace("simple-ambit-rule", "simple-rule")
|
||||||
|
|
||||||
|
return res
|
||||||
|
except ValueError as e:
|
||||||
|
return 403, {"message": f"Search failed due to {e}"}
|
||||||
|
|
||||||
|
|
||||||
###########
|
###########
|
||||||
# Package #
|
# Package #
|
||||||
###########
|
###########
|
||||||
@ -251,67 +320,110 @@ def get_packages(request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/package/{uuid:package_uuid}", response={200: PackageSchema, 403: Error})
|
class GetPackage(Schema):
|
||||||
def get_package(request, package_uuid):
|
exportAsJson: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 403: Error})
|
||||||
|
def get_package(request, package_uuid, gp: Query[GetPackage]):
|
||||||
try:
|
try:
|
||||||
return PackageManager.get_package_by_id(request.user, package_uuid)
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if gp.exportAsJson and gp.exportAsJson.strip() == "true":
|
||||||
|
return PackageExporter(p).do_export()
|
||||||
|
|
||||||
|
return p
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 403, {
|
return 403, {
|
||||||
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
|
"message": f"Getting Package with id {package_uuid} failed due to insufficient rights!"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePackage(Schema):
|
||||||
|
packageName: str
|
||||||
|
packageDescription: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/package")
|
@router.post("/package")
|
||||||
def create_packages(
|
def create_packages(
|
||||||
request, packageName: Form[str], packageDescription: Optional[str] = Form(None)
|
request,
|
||||||
|
p: Form[CreatePackage],
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
if packageName.strip() == "":
|
if p.packageName.strip() == "":
|
||||||
raise ValueError("Package name cannot be empty!")
|
raise ValueError("Package name cannot be empty!")
|
||||||
|
|
||||||
new_pacakge = PackageManager.create_package(request.user, packageName, packageDescription)
|
new_pacakge = PackageManager.create_package(
|
||||||
|
request.user, p.packageName, p.packageDescription
|
||||||
|
)
|
||||||
return redirect(new_pacakge.url)
|
return redirect(new_pacakge.url)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return 400, {"message": str(e)}
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePackage(Schema):
|
||||||
|
packageDescription: str | None = None
|
||||||
|
hiddenMethod: str | None = None
|
||||||
|
permissions: str | None = None
|
||||||
|
ppsURI: str | None = None
|
||||||
|
read: str | None = None
|
||||||
|
write: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
|
@router.post("/package/{uuid:package_uuid}", response={200: PackageSchema | Any, 400: Error})
|
||||||
def update_package(
|
def update_package(request, package_uuid, pack: Form[UpdatePackage]):
|
||||||
request,
|
|
||||||
package_uuid,
|
|
||||||
packageDescription: Optional[str] = Form(None),
|
|
||||||
hiddenMethod: Optional[str] = Form(None),
|
|
||||||
exportAsJson: Optional[str] = Form(None),
|
|
||||||
permissions: Optional[str] = Form(None),
|
|
||||||
ppsURI: Optional[str] = Form(None),
|
|
||||||
read: Optional[str] = Form(None),
|
|
||||||
write: Optional[str] = Form(None),
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
if hiddenMethod:
|
if pack.hiddenMethod:
|
||||||
if hiddenMethod == "DELETE":
|
if pack.hiddenMethod == "DELETE":
|
||||||
p.delete()
|
p.delete()
|
||||||
|
|
||||||
elif packageDescription and packageDescription.strip() != "":
|
elif pack.packageDescription is not None:
|
||||||
p.description = packageDescription
|
description = nh3.clean(pack.packageDescription, tags=s.ALLOWED_HTML_TAGS).strip()
|
||||||
p.save()
|
|
||||||
return
|
|
||||||
elif exportAsJson == "true":
|
|
||||||
pack_json = PackageManager.export_package(
|
|
||||||
p, include_models=False, include_external_identifiers=False
|
|
||||||
)
|
|
||||||
return pack_json
|
|
||||||
elif all([permissions, ppsURI, read]):
|
|
||||||
PackageManager.update_permissions
|
|
||||||
elif all([permissions, ppsURI, write]):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
if description:
|
||||||
|
p.description = description
|
||||||
|
p.save()
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
else:
|
||||||
|
raise ValueError("Package description cannot be empty!")
|
||||||
|
elif all([pack.permissions, pack.ppsURI, pack.read]):
|
||||||
|
if "group" in pack.ppsURI:
|
||||||
|
grantee = GroupManager.get_group_lp(pack.ppsURI)
|
||||||
|
else:
|
||||||
|
grantee = UserManager.get_user_lp(pack.ppsURI)
|
||||||
|
|
||||||
|
PackageManager.grant_read(request.user, p, grantee)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
elif all([pack.permissions, pack.ppsURI, pack.write]):
|
||||||
|
if "group" in pack.ppsURI:
|
||||||
|
grantee = GroupManager.get_group_lp(pack.ppsURI)
|
||||||
|
else:
|
||||||
|
grantee = UserManager.get_user_lp(pack.ppsURI)
|
||||||
|
|
||||||
|
PackageManager.grant_write(request.user, p, grantee)
|
||||||
|
return HttpResponse(status=200)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return 400, {"message": str(e)}
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}")
|
||||||
|
def delete_package(request, package_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.administrable(request.user, p):
|
||||||
|
p.delete()
|
||||||
|
return redirect(f"{s.SERVER_URL}/package")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Package!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Package with id {package_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
################################
|
################################
|
||||||
# Compound / CompoundStructure #
|
# Compound / CompoundStructure #
|
||||||
################################
|
################################
|
||||||
@ -509,6 +621,83 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCompound(Schema):
|
||||||
|
compoundSmiles: str
|
||||||
|
compoundName: str | None = None
|
||||||
|
compoundDescription: str | None = None
|
||||||
|
inchi: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/package/{uuid:package_uuid}/compound")
|
||||||
|
def create_package_compound(
|
||||||
|
request,
|
||||||
|
package_uuid,
|
||||||
|
c: Form[CreateCompound],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
# inchi is not used atm
|
||||||
|
c = Compound.create(
|
||||||
|
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
|
||||||
|
)
|
||||||
|
return redirect(c.url)
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}")
|
||||||
|
def delete_compound(request, package_uuid, compound_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||||
|
c.delete()
|
||||||
|
return redirect(f"{p.url}/compound")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Compound!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Compound with id {compound_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/package/{uuid:package_uuid}/compound/{uuid:compound_uuid}/structure/{uuid:structure_uuid}"
|
||||||
|
)
|
||||||
|
def delete_compound_structure(request, package_uuid, compound_uuid, structure_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
c = Compound.objects.get(package=p, uuid=compound_uuid)
|
||||||
|
cs = CompoundStructure.objects.get(compound=c, uuid=structure_uuid)
|
||||||
|
|
||||||
|
# Check if we have to delete the compound as no structure is left
|
||||||
|
if len(cs.compound.structures.all()) == 1:
|
||||||
|
# This will delete the structure as well
|
||||||
|
c.delete()
|
||||||
|
return redirect(p.url + "/compound")
|
||||||
|
else:
|
||||||
|
if cs.normalized_structure:
|
||||||
|
c.delete()
|
||||||
|
return redirect(p.url + "/compound")
|
||||||
|
else:
|
||||||
|
if c.default_structure == cs:
|
||||||
|
cs.delete()
|
||||||
|
c.default_structure = c.structures.all().first()
|
||||||
|
return redirect(c.url + "/structure")
|
||||||
|
else:
|
||||||
|
cs.delete()
|
||||||
|
return redirect(c.url + "/structure")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this CompoundStructure!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting CompoundStructure with id {compound_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#########
|
#########
|
||||||
# Rules #
|
# Rules #
|
||||||
#########
|
#########
|
||||||
@ -672,6 +861,73 @@ def _get_package_rule(request, package_uuid, rule_uuid):
|
|||||||
|
|
||||||
|
|
||||||
# POST
|
# POST
|
||||||
|
class CreateSimpleRule(Schema):
|
||||||
|
smirks: str
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
reactantFilterSmarts: str | None = None
|
||||||
|
productFilterSmarts: str | None = None
|
||||||
|
immediate: str | None = None
|
||||||
|
rdkitrule: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/package/{uuid:package_uuid}/simple-rule")
|
||||||
|
def create_package_simple_rule(
|
||||||
|
request,
|
||||||
|
package_uuid,
|
||||||
|
r: Form[CreateSimpleRule],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if r.rdkitrule and r.rdkitrule.strip() == "true":
|
||||||
|
raise ValueError("Not yet implemented!")
|
||||||
|
else:
|
||||||
|
sr = SimpleAmbitRule.create(
|
||||||
|
p, r.name, r.description, r.smirks, r.reactantFilterSmarts, r.productFilterSmarts
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(sr.url)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateParallelRule(Schema):
|
||||||
|
simpleRules: str
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
reactantFilterSmarts: str | None = None
|
||||||
|
productFilterSmarts: str | None = None
|
||||||
|
immediate: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/package/{uuid:package_uuid}/parallel-rule")
|
||||||
|
def create_package_parallel_rule(
|
||||||
|
request,
|
||||||
|
package_uuid,
|
||||||
|
r: Form[CreateParallelRule],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
srs = SimpleRule.objects.filter(package=p, url__in=r.simpleRules)
|
||||||
|
|
||||||
|
if srs.count() != len(r.simpleRules):
|
||||||
|
raise ValueError(
|
||||||
|
f"Not all SimpleRules could be found in Package with id {package_uuid}!"
|
||||||
|
)
|
||||||
|
|
||||||
|
sr = ParallelRule.create(
|
||||||
|
p, list(srs), r.name, r.description, r.reactantFilterSmarts, r.productFilterSmarts
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(sr.url)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}", response={200: str | Any, 403: Error}
|
"/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}", response={200: str | Any, 403: Error}
|
||||||
)
|
)
|
||||||
@ -721,6 +977,41 @@ def _post_package_rule(request, package_uuid, rule_uuid, compound: Form[str]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/rule/{uuid:rule_uuid}")
|
||||||
|
def delete_rule(request, package_uuid, rule_uuid):
|
||||||
|
return _delete_rule(request, package_uuid, rule_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/package/{uuid:package_uuid}/simple-rule/{uuid:rule_uuid}",
|
||||||
|
)
|
||||||
|
def delete_simple_rule(request, package_uuid, rule_uuid):
|
||||||
|
return _delete_rule(request, package_uuid, rule_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/package/{uuid:package_uuid}/parallel-rule/{uuid:rule_uuid}",
|
||||||
|
)
|
||||||
|
def delete_parallel_rule(request, package_uuid, rule_uuid):
|
||||||
|
return _delete_rule(request, package_uuid, rule_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_rule(request, package_uuid, rule_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
r = Rule.objects.get(package=p, uuid=rule_uuid)
|
||||||
|
r.delete()
|
||||||
|
return redirect(f"{p.url}/rule")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Rule!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Rule with id {rule_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
############
|
############
|
||||||
# Reaction #
|
# Reaction #
|
||||||
############
|
############
|
||||||
@ -809,6 +1100,82 @@ def get_package_reaction(request, package_uuid, reaction_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateReaction(Schema):
|
||||||
|
reactionName: str | None = None
|
||||||
|
reactionDescription: str | None = None
|
||||||
|
smirks: str | None = None
|
||||||
|
educt: str | None = None
|
||||||
|
product: str | None = None
|
||||||
|
rule: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/package/{uuid:package_uuid}/reaction")
|
||||||
|
def create_package_reaction(
|
||||||
|
request,
|
||||||
|
package_uuid,
|
||||||
|
r: Form[CreateReaction],
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if r.smirks is None and (r.educt is None or r.product is None):
|
||||||
|
raise ValueError("Either SMIRKS or educt/product must be provided")
|
||||||
|
|
||||||
|
if r.smirks is not None and (r.educt is not None and r.product is not None):
|
||||||
|
raise ValueError("SMIRKS and educt/product provided!")
|
||||||
|
|
||||||
|
rule = None
|
||||||
|
if r.rule:
|
||||||
|
try:
|
||||||
|
rule = Rule.objects.get(package=p, url=r.rule)
|
||||||
|
except Rule.DoesNotExist:
|
||||||
|
raise ValueError(f"Rule with id {r.rule} does not exist!")
|
||||||
|
|
||||||
|
if r.educt is not None:
|
||||||
|
try:
|
||||||
|
educt_cs = CompoundStructure.objects.get(compound__package=p, url=r.educt)
|
||||||
|
except CompoundStructure.DoesNotExist:
|
||||||
|
raise ValueError(f"Compound with id {r.educt} does not exist!")
|
||||||
|
|
||||||
|
try:
|
||||||
|
product_cs = CompoundStructure.objects.get(compound__package=p, url=r.product)
|
||||||
|
except CompoundStructure.DoesNotExist:
|
||||||
|
raise ValueError(f"Compound with id {r.product} does not exist!")
|
||||||
|
|
||||||
|
new_r = Reaction.create(
|
||||||
|
p, r.reactionName, r.reactionDescription, [educt_cs], [product_cs], rule
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
educts = r.smirks.split(">>")[0].split("\\.")
|
||||||
|
products = r.smirks.split(">>")[1].split("\\.")
|
||||||
|
|
||||||
|
new_r = Reaction.create(
|
||||||
|
p, r.reactionName, r.reactionDescription, educts, products, rule
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(new_r.url)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/reaction/{uuid:reaction_uuid}")
|
||||||
|
def delete_reaction(request, package_uuid, reaction_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
r = Reaction.objects.get(package=p, uuid=reaction_uuid)
|
||||||
|
r.delete()
|
||||||
|
return redirect(f"{p.url}/reaction")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Reaction!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Reaction with id {reaction_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
############
|
############
|
||||||
# Scenario #
|
# Scenario #
|
||||||
############
|
############
|
||||||
@ -823,7 +1190,7 @@ class ScenarioSchema(Schema):
|
|||||||
description: str = Field(None, alias="description")
|
description: str = Field(None, alias="description")
|
||||||
id: str = Field(None, alias="url")
|
id: str = Field(None, alias="url")
|
||||||
identifier: str = "scenario"
|
identifier: str = "scenario"
|
||||||
linkedTo: List[Dict[str, str]] = Field({}, alias="linked_to")
|
linkedTo: List[Dict[str, str]] = Field([], alias="linked_to")
|
||||||
name: str = Field(None, alias="name")
|
name: str = Field(None, alias="name")
|
||||||
pathways: List["SimplePathway"] = Field([], alias="related_pathways")
|
pathways: List["SimplePathway"] = Field([], alias="related_pathways")
|
||||||
relatedScenarios: List[Dict[str, str]] = Field([], alias="related_scenarios")
|
relatedScenarios: List[Dict[str, str]] = Field([], alias="related_scenarios")
|
||||||
@ -874,6 +1241,38 @@ def get_package_scenario(request, package_uuid, scenario_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/scenario")
|
||||||
|
def delete_scenarios(request, package_uuid, scenario_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
scens = Scenario.objects.filter(package=p)
|
||||||
|
scens.delete()
|
||||||
|
return redirect(f"{p.url}/scenario")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete Scenarios!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {"message": "Deleting Scenarios failed due to insufficient rights!"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/scenario/{uuid:scenario_uuid}")
|
||||||
|
def delete_scenario(request, package_uuid, scenario_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
scen = Scenario.objects.get(package=p, uuid=scenario_uuid)
|
||||||
|
scen.delete()
|
||||||
|
return redirect(f"{p.url}/scenario")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Scenario!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Scenario with id {scenario_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
###########
|
###########
|
||||||
# Pathway #
|
# Pathway #
|
||||||
###########
|
###########
|
||||||
@ -1013,46 +1412,67 @@ def get_package_pathway(request, package_uuid, pathway_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePathway(Schema):
|
||||||
|
smilesinput: str
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
rootOnly: str | None = None
|
||||||
|
selectedSetting: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/package/{uuid:package_uuid}/pathway")
|
@router.post("/package/{uuid:package_uuid}/pathway")
|
||||||
def create_pathway(
|
def create_pathway(
|
||||||
request,
|
request,
|
||||||
package_uuid,
|
package_uuid,
|
||||||
smilesinput: Form[str],
|
pw: Form[CreatePathway],
|
||||||
name: Optional[str] = Form(None),
|
|
||||||
description: Optional[str] = Form(None),
|
|
||||||
rootOnly: Optional[str] = Form(None),
|
|
||||||
selectedSetting: Optional[str] = Form(None),
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
stand_smiles = FormatConverter.standardize(smilesinput.strip())
|
stand_smiles = FormatConverter.standardize(pw.smilesinput.strip())
|
||||||
|
|
||||||
pw = Pathway.create(p, stand_smiles, name=name, description=description)
|
new_pw = Pathway.create(p, stand_smiles, name=pw.name, description=pw.description)
|
||||||
|
|
||||||
pw_mode = "predict"
|
pw_mode = "predict"
|
||||||
if rootOnly and rootOnly == "true":
|
if pw.rootOnly and pw.rootOnly.strip() == "true":
|
||||||
pw_mode = "build"
|
pw_mode = "build"
|
||||||
|
|
||||||
pw.kv.update({"mode": pw_mode})
|
new_pw.kv.update({"mode": pw_mode})
|
||||||
pw.save()
|
new_pw.save()
|
||||||
|
|
||||||
if pw_mode == "predict":
|
if pw_mode == "predict":
|
||||||
setting = request.user.prediction_settings()
|
setting = request.user.prediction_settings()
|
||||||
|
|
||||||
if selectedSetting:
|
if pw.selectedSetting:
|
||||||
setting = SettingManager.get_setting_by_url(request.user, selectedSetting)
|
setting = SettingManager.get_setting_by_url(request.user, pw.selectedSetting)
|
||||||
|
|
||||||
pw.setting = setting
|
new_pw.setting = setting
|
||||||
pw.save()
|
new_pw.save()
|
||||||
|
|
||||||
from .tasks import predict
|
from .tasks import dispatch, predict
|
||||||
|
|
||||||
predict.delay(pw.pk, setting.pk, limit=-1)
|
dispatch(request.user, predict, new_pw.pk, setting.pk, limit=None)
|
||||||
|
|
||||||
return redirect(pw.url)
|
return redirect(new_pw.url)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(e)
|
return 400, {"message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}")
|
||||||
|
def delete_pathway(request, package_uuid, pathway_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||||
|
pw.delete()
|
||||||
|
return redirect(f"{p.url}/pathway")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this pathway!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Pathway with id {pathway_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
########
|
########
|
||||||
@ -1143,6 +1563,52 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateNode(Schema):
|
||||||
|
nodeAsSmiles: str
|
||||||
|
nodeName: str | None = None
|
||||||
|
nodeReason: str | None = None
|
||||||
|
nodeDepth: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node",
|
||||||
|
response={200: str | Any, 403: Error},
|
||||||
|
)
|
||||||
|
def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||||
|
|
||||||
|
if n.nodeDepth is not None and n.nodeDepth.strip() != "":
|
||||||
|
node_depth = int(n.nodeDepth)
|
||||||
|
else:
|
||||||
|
node_depth = -1
|
||||||
|
|
||||||
|
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
|
||||||
|
|
||||||
|
return redirect(n.url)
|
||||||
|
except ValueError:
|
||||||
|
return 403, {"message": "Adding node failed!"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/node/{uuid:node_uuid}")
|
||||||
|
def delete_node(request, package_uuid, pathway_uuid, node_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||||
|
n = Node.objects.get(pathway=pw, uuid=node_uuid)
|
||||||
|
n.delete()
|
||||||
|
return redirect(f"{pw.url}/node")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Node!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Node with id {node_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
########
|
########
|
||||||
# Edge #
|
# Edge #
|
||||||
########
|
########
|
||||||
@ -1206,6 +1672,200 @@ def get_package_pathway_edge(request, package_uuid, pathway_uuid, edge_uuid):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateEdge(Schema):
|
||||||
|
edgeAsSmirks: str | None = None
|
||||||
|
educts: str | None = None # Node URIs comma sep
|
||||||
|
products: str | None = None # Node URIs comma sep
|
||||||
|
multistep: str | None = None
|
||||||
|
edgeReason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/package/{uuid:package_uuid}/üathway/{uuid:pathway_uuid}/edge",
|
||||||
|
response={200: str | Any, 403: Error},
|
||||||
|
)
|
||||||
|
def add_pathway_edge(request, package_uuid, pathway_uuid, e: Form[CreateEdge]):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||||
|
|
||||||
|
if e.edgeAsSmirks is None and (e.educts is None or e.products is None):
|
||||||
|
raise ValueError("Either SMIRKS or educt/product must be provided")
|
||||||
|
|
||||||
|
if e.edgeAsSmirks is not None and (e.educts is not None and e.products is not None):
|
||||||
|
raise ValueError("SMIRKS and educt/product provided!")
|
||||||
|
|
||||||
|
educts = []
|
||||||
|
products = []
|
||||||
|
|
||||||
|
if e.edgeAsSmirks:
|
||||||
|
for ed in e.edgeAsSmirks.split(">>")[0].split("\\."):
|
||||||
|
educts.append(Node.objects.get(pathway=pw, default_node_label__smiles=ed))
|
||||||
|
|
||||||
|
for pr in e.edgeAsSmirks.split(">>")[1].split("\\."):
|
||||||
|
products.append(Node.objects.get(pathway=pw, default_node_label__smiles=pr))
|
||||||
|
else:
|
||||||
|
for ed in e.educts.split(","):
|
||||||
|
educts.append(Node.objects.get(pathway=pw, url=ed.strip()))
|
||||||
|
|
||||||
|
for pr in e.products.split(","):
|
||||||
|
products.append(Node.objects.get(pathway=pw, url=pr.strip()))
|
||||||
|
|
||||||
|
new_e = Edge.create(
|
||||||
|
pathway=pw,
|
||||||
|
start_nodes=educts,
|
||||||
|
end_nodes=products,
|
||||||
|
rule=None,
|
||||||
|
name=e.name,
|
||||||
|
description=e.edgeReason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(new_e.url)
|
||||||
|
except ValueError:
|
||||||
|
return 403, {"message": "Adding node failed!"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/pathway/{uuid:pathway_uuid}/edge/{uuid:edge_uuid}")
|
||||||
|
def delete_edge(request, package_uuid, pathway_uuid, edge_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
pw = Pathway.objects.get(package=p, uuid=pathway_uuid)
|
||||||
|
e = Edge.objects.get(pathway=pw, uuid=edge_uuid)
|
||||||
|
e.delete()
|
||||||
|
return redirect(f"{pw.url}/edge")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Edge!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Edge with id {edge_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#########
|
||||||
|
# Model #
|
||||||
|
#########
|
||||||
|
class ModelWrapper(Schema):
|
||||||
|
relative_reasoning: List["SimpleModel"] = Field(..., alias="relative-reasoning")
|
||||||
|
|
||||||
|
|
||||||
|
class ModelSchema(Schema):
|
||||||
|
aliases: List[str] = Field([], alias="aliases")
|
||||||
|
description: str = Field(None, alias="description")
|
||||||
|
evalPackages: List["SimplePackage"] = Field([])
|
||||||
|
id: str = Field(None, alias="url")
|
||||||
|
identifier: str = "relative-reasoning"
|
||||||
|
# "info" : {
|
||||||
|
# "Accuracy (Single-Gen)" : "0.5932962678936605" ,
|
||||||
|
# "Area under PR-Curve (Single-Gen)" : "0.5654653182134282" ,
|
||||||
|
# "Area under ROC-Curve (Single-Gen)" : "0.8178302405034772" ,
|
||||||
|
# "Precision (Single-Gen)" : "0.6978730822873083" ,
|
||||||
|
# "Probability Threshold" : "0.5" ,
|
||||||
|
# "Recall/Sensitivity (Single-Gen)" : "0.4484149210261006"
|
||||||
|
# } ,
|
||||||
|
name: str = Field(None, alias="name")
|
||||||
|
pathwayPackages: List["SimplePackage"] = Field([])
|
||||||
|
reviewStatus: str = Field(None, alias="review_status")
|
||||||
|
rulePackages: List["SimplePackage"] = Field([])
|
||||||
|
scenarios: List["SimpleScenario"] = Field([], alias="scenarios")
|
||||||
|
status: str
|
||||||
|
statusMessage: str
|
||||||
|
threshold: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/model", response={200: ModelWrapper, 403: Error})
|
||||||
|
def get_models(request):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/package/{uuid:package_uuid}/model", response={200: ModelWrapper, 403: Error})
|
||||||
|
def get_package_models(request, package_uuid, model_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
return EPModel.objects.filter(package=p)
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Getting Reaction with id {model_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Classify(Schema):
|
||||||
|
smiles: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/package/{uuid:package_uuid}/model/{uuid:model_uuid}",
|
||||||
|
response={200: ModelSchema | Any, 403: Error, 400: Error},
|
||||||
|
)
|
||||||
|
def get_model(request, package_uuid, model_uuid, c: Query[Classify]):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
mod = EPModel.objects.get(package=p, uuid=model_uuid)
|
||||||
|
|
||||||
|
if c.smiles:
|
||||||
|
if c.smiles == "":
|
||||||
|
return 400, {"message": "Received empty SMILES"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
stand_smiles = FormatConverter.standardize(c.smiles)
|
||||||
|
except ValueError:
|
||||||
|
return 400, {"message": f'"{c.smiles}" is not a valid SMILES'}
|
||||||
|
|
||||||
|
from epdb.tasks import dispatch_eager, predict_simple
|
||||||
|
|
||||||
|
_, pred_res = dispatch_eager(request.user, predict_simple, mod.pk, stand_smiles)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for pr in pred_res:
|
||||||
|
if len(pr) > 0:
|
||||||
|
products = []
|
||||||
|
for prod_set in pr.product_sets:
|
||||||
|
products.append(tuple([x for x in prod_set]))
|
||||||
|
|
||||||
|
res = {
|
||||||
|
"probability": pr.probability,
|
||||||
|
"products": list(set(products)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if pr.rule:
|
||||||
|
res["id"] = pr.rule.url
|
||||||
|
res["identifier"] = pr.rule.get_rule_identifier()
|
||||||
|
res["name"] = pr.rule.name
|
||||||
|
res["reviewStatus"] = (
|
||||||
|
"reviewed" if pr.rule.package.reviewed else "unreviewed"
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(res)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
return mod
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Getting Reaction with id {model_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/package/{uuid:package_uuid}/model/{uuid:model_uuid}")
|
||||||
|
def delete_model(request, package_uuid, model_uuid):
|
||||||
|
try:
|
||||||
|
p = PackageManager.get_package_by_id(request.user, package_uuid)
|
||||||
|
|
||||||
|
if PackageManager.writable(request.user, p):
|
||||||
|
m = EPModel.objects.get(package=p, uuid=model_uuid)
|
||||||
|
m.delete()
|
||||||
|
return redirect(f"{p.url}/model")
|
||||||
|
else:
|
||||||
|
raise ValueError("You do not have the rights to delete this Model!")
|
||||||
|
except ValueError:
|
||||||
|
return 403, {
|
||||||
|
"message": f"Deleting Model with id {model_uuid} failed due to insufficient rights!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
###########
|
###########
|
||||||
# Setting #
|
# Setting #
|
||||||
###########
|
###########
|
||||||
|
|||||||
351
epdb/logic.py
351
epdb/logic.py
@ -1,39 +1,41 @@
|
|||||||
import re
|
|
||||||
import logging
|
|
||||||
import json
|
import json
|
||||||
from typing import Union, List, Optional, Set, Dict, Any
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Set, Union, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import nh3
|
import nh3
|
||||||
|
from django.conf import settings as s
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.conf import settings as s
|
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from epdb.models import (
|
from epdb.models import (
|
||||||
User,
|
|
||||||
Package,
|
|
||||||
UserPackagePermission,
|
|
||||||
GroupPackagePermission,
|
|
||||||
Permission,
|
|
||||||
Group,
|
|
||||||
Setting,
|
|
||||||
EPModel,
|
|
||||||
UserSettingPermission,
|
|
||||||
Rule,
|
|
||||||
Pathway,
|
|
||||||
Node,
|
|
||||||
Edge,
|
|
||||||
Compound,
|
Compound,
|
||||||
Reaction,
|
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
|
Edge,
|
||||||
EnzymeLink,
|
EnzymeLink,
|
||||||
|
EPModel,
|
||||||
|
ExpansionSchemeChoice,
|
||||||
|
Group,
|
||||||
|
GroupPackagePermission,
|
||||||
|
Node,
|
||||||
|
Pathway,
|
||||||
|
Permission,
|
||||||
|
Reaction,
|
||||||
|
Rule,
|
||||||
|
Setting,
|
||||||
|
User,
|
||||||
|
UserPackagePermission,
|
||||||
|
UserSettingPermission,
|
||||||
)
|
)
|
||||||
from utilities.chem import FormatConverter
|
from utilities.chem import FormatConverter
|
||||||
from utilities.misc import PackageImporter, PackageExporter
|
from utilities.misc import PackageExporter, PackageImporter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
class EPDBURLParser:
|
class EPDBURLParser:
|
||||||
UUID_PATTERN = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
|
UUID_PATTERN = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
|
||||||
@ -442,6 +444,7 @@ class PackageManager(object):
|
|||||||
if PackageManager.readable(user, p):
|
if PackageManager.readable(user, p):
|
||||||
return p
|
return p
|
||||||
else:
|
else:
|
||||||
|
# FIXME: use custom exception to be translatable to 403 in API
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Insufficient permissions to access Package with ID {}".format(package_id)
|
"Insufficient permissions to access Package with ID {}".format(package_id)
|
||||||
)
|
)
|
||||||
@ -578,30 +581,39 @@ class PackageManager(object):
|
|||||||
else:
|
else:
|
||||||
_ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data)
|
_ = perm_cls.objects.update_or_create(defaults={"permission": new_perm}, **data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def grant_read(caller: User, package: Package, grantee: Union[User, Group]):
|
||||||
|
PackageManager.update_permissions(caller, package, grantee, Permission.READ[0])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def grant_write(caller: User, package: Package, grantee: Union[User, Group]):
|
||||||
|
PackageManager.update_permissions(caller, package, grantee, Permission.WRITE[0])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def import_legacy_package(
|
def import_legacy_package(
|
||||||
data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False
|
data: dict, owner: User, keep_ids=False, add_import_timestamp=True, trust_reviewed=False
|
||||||
):
|
):
|
||||||
from uuid import UUID, uuid4
|
|
||||||
from datetime import datetime
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from envipy_additional_information import AdditionalInformationConverter
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
Package,
|
|
||||||
Compound,
|
Compound,
|
||||||
CompoundStructure,
|
CompoundStructure,
|
||||||
SimpleRule,
|
Edge,
|
||||||
SimpleAmbitRule,
|
Node,
|
||||||
ParallelRule,
|
ParallelRule,
|
||||||
|
Pathway,
|
||||||
|
Reaction,
|
||||||
|
Scenario,
|
||||||
SequentialRule,
|
SequentialRule,
|
||||||
SequentialRuleOrdering,
|
SequentialRuleOrdering,
|
||||||
Reaction,
|
SimpleAmbitRule,
|
||||||
Pathway,
|
SimpleRule,
|
||||||
Node,
|
|
||||||
Edge,
|
|
||||||
Scenario,
|
|
||||||
)
|
)
|
||||||
from envipy_additional_information import AdditionalInformationConverter
|
|
||||||
|
|
||||||
pack = Package()
|
pack = Package()
|
||||||
pack.uuid = UUID(data["id"].split("/")[-1]) if keep_ids else uuid4()
|
pack.uuid = UUID(data["id"].split("/")[-1]) if keep_ids else uuid4()
|
||||||
@ -1106,6 +1118,7 @@ class SettingManager(object):
|
|||||||
rule_packages: List[Package] = None,
|
rule_packages: List[Package] = None,
|
||||||
model: EPModel = None,
|
model: EPModel = None,
|
||||||
model_threshold: float = None,
|
model_threshold: float = None,
|
||||||
|
expansion_scheme: ExpansionSchemeChoice = ExpansionSchemeChoice.BFS,
|
||||||
):
|
):
|
||||||
new_s = Setting()
|
new_s = Setting()
|
||||||
# Clean for potential XSS
|
# Clean for potential XSS
|
||||||
@ -1388,6 +1401,9 @@ class SEdge(object):
|
|||||||
self.rule = rule
|
self.rule = rule
|
||||||
self.probability = probability
|
self.probability = probability
|
||||||
|
|
||||||
|
def product_smiles(self):
|
||||||
|
return [p.smiles for p in self.products]
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
full_hash = 0
|
full_hash = 0
|
||||||
|
|
||||||
@ -1473,6 +1489,7 @@ class SPathway(object):
|
|||||||
self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes})
|
self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes})
|
||||||
self.edges: Set["SEdge"] = set()
|
self.edges: Set["SEdge"] = set()
|
||||||
self.done = False
|
self.done = False
|
||||||
|
self.empty_due_to_threshold = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_pathway(pw: "Pathway", persist: bool = True):
|
def from_pathway(pw: "Pathway", persist: bool = True):
|
||||||
@ -1537,6 +1554,207 @@ class SPathway(object):
|
|||||||
|
|
||||||
return sorted(res, key=lambda x: hash(x))
|
return sorted(res, key=lambda x: hash(x))
|
||||||
|
|
||||||
|
def _expand(self, substrates: List[SNode]) -> Tuple[List[SNode], List[SEdge]]:
|
||||||
|
"""
|
||||||
|
Expands the given substrates by generating new nodes and edges based on prediction settings.
|
||||||
|
|
||||||
|
This method processes a list of substrates and expands them into new nodes and edges using defined
|
||||||
|
rules and settings. It evaluates each substrate to determine its applicability domain, persists
|
||||||
|
domain assessments, and generates candidates for further processing. Newly created nodes and edges
|
||||||
|
are returned, and any applicable information is stored or updated internally during the process.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
substrates (List[SNode]): A list of substrate nodes to be expanded.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[List[SNode], List[SEdge]]:
|
||||||
|
A tuple containing:
|
||||||
|
- A list of new nodes generated during the expansion.
|
||||||
|
- A list of new edges representing connections between nodes based on candidate reactions.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a node does not have an ID when it should have been saved already.
|
||||||
|
"""
|
||||||
|
new_nodes: List[SNode] = []
|
||||||
|
new_edges: List[SEdge] = []
|
||||||
|
|
||||||
|
for sub in substrates:
|
||||||
|
# For App Domain we have to ensure that each Node is evaluated
|
||||||
|
if sub.app_domain_assessment is None:
|
||||||
|
if self.prediction_setting.model:
|
||||||
|
if self.prediction_setting.model.app_domain:
|
||||||
|
app_domain_assessment = self.prediction_setting.model.app_domain.assess(
|
||||||
|
sub.smiles
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.persist is not None:
|
||||||
|
n = self.snode_persist_lookup[sub]
|
||||||
|
|
||||||
|
if n.id is None:
|
||||||
|
raise ValueError(f"Node {n} has no ID... aborting!")
|
||||||
|
|
||||||
|
node_data = n.simple_json()
|
||||||
|
node_data["image"] = f"{n.url}?image=svg"
|
||||||
|
app_domain_assessment["assessment"]["node"] = node_data
|
||||||
|
|
||||||
|
n.kv["app_domain_assessment"] = app_domain_assessment
|
||||||
|
n.save()
|
||||||
|
|
||||||
|
sub.app_domain_assessment = app_domain_assessment
|
||||||
|
|
||||||
|
expansion_result = self.prediction_setting.expand(self, sub)
|
||||||
|
|
||||||
|
# We don't have any substrate, but technically we have at least one rule that triggered.
|
||||||
|
# If our substrate is a root node a.k.a. depth == 0 store that info in SPathway
|
||||||
|
if (
|
||||||
|
len(expansion_result["transformations"]) == 0
|
||||||
|
and expansion_result["rule_triggered"]
|
||||||
|
and sub.depth == 0
|
||||||
|
):
|
||||||
|
self.empty_due_to_threshold = True
|
||||||
|
|
||||||
|
# Emit directly
|
||||||
|
if self.persist is not None:
|
||||||
|
self.persist.kv["empty_due_to_threshold"] = True
|
||||||
|
self.persist.save()
|
||||||
|
|
||||||
|
# candidates is a List of PredictionResult. The length of the List is equal to the number of rules
|
||||||
|
for cand_set in expansion_result["transformations"]:
|
||||||
|
if cand_set:
|
||||||
|
# cand_set is a PredictionResult object that can consist of multiple candidate reactions
|
||||||
|
for cand in cand_set:
|
||||||
|
cand_nodes = []
|
||||||
|
# candidate reactions can have multiple fragments
|
||||||
|
for c in cand:
|
||||||
|
if c not in self.smiles_to_node:
|
||||||
|
# For new nodes do an AppDomain Assessment if an AppDomain is attached
|
||||||
|
app_domain_assessment = None
|
||||||
|
if self.prediction_setting.model:
|
||||||
|
if self.prediction_setting.model.app_domain:
|
||||||
|
app_domain_assessment = (
|
||||||
|
self.prediction_setting.model.app_domain.assess(c)
|
||||||
|
)
|
||||||
|
snode = SNode(c, sub.depth + 1, app_domain_assessment)
|
||||||
|
self.smiles_to_node[c] = snode
|
||||||
|
new_nodes.append(snode)
|
||||||
|
|
||||||
|
node = self.smiles_to_node[c]
|
||||||
|
cand_nodes.append(node)
|
||||||
|
|
||||||
|
edge = SEdge(
|
||||||
|
sub,
|
||||||
|
cand_nodes,
|
||||||
|
rule=cand_set.rule,
|
||||||
|
probability=cand_set.probability,
|
||||||
|
)
|
||||||
|
self.edges.add(edge)
|
||||||
|
new_edges.append(edge)
|
||||||
|
|
||||||
|
return new_nodes, new_edges
|
||||||
|
|
||||||
|
def predict(self):
|
||||||
|
"""
|
||||||
|
Predicts outcomes based on a graph traversal algorithm using the specified expansion schema.
|
||||||
|
|
||||||
|
This method iteratively explores the nodes of a graph starting from the root nodes, propagating
|
||||||
|
probabilities through edges, and updating the probabilities of the connected nodes. The traversal
|
||||||
|
can follow one of three predefined expansion schemas: Depth-First Search (DFS), Breadth-First Search
|
||||||
|
(BFS), or a Greedy approach based on node probabilities. The methodology ensures that all reachable
|
||||||
|
nodes are processed systematically according to the specified schema.
|
||||||
|
|
||||||
|
Errors will be raised if the expansion schema is undefined or invalid. Additionally, this method
|
||||||
|
supports persisting changes by writing back data to the database when configured to do so.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
done : bool
|
||||||
|
A flag indicating whether the prediction process is completed.
|
||||||
|
persist : Any
|
||||||
|
An optional object that manages persistence operations for saving modifications.
|
||||||
|
root_nodes : List[SNode]
|
||||||
|
A collection of initial nodes in the graph from which traversal begins.
|
||||||
|
prediction_setting : Any
|
||||||
|
Configuration object specifying settings for graph traversal, such as the choice of
|
||||||
|
expansion schema.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If an invalid or unknown expansion schema is provided in `prediction_setting`.
|
||||||
|
"""
|
||||||
|
# populate initial queue
|
||||||
|
queue = list(self.root_nodes)
|
||||||
|
processed = set()
|
||||||
|
|
||||||
|
# initial nodes have prob 1.0
|
||||||
|
node_probs: Dict[SNode, float] = {}
|
||||||
|
node_probs.update({n: 1.0 for n in queue})
|
||||||
|
|
||||||
|
while queue:
|
||||||
|
current = queue.pop(0)
|
||||||
|
|
||||||
|
if current in processed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
processed.add(current)
|
||||||
|
|
||||||
|
new_nodes, new_edges = self._expand([current])
|
||||||
|
|
||||||
|
if new_nodes or new_edges:
|
||||||
|
# Check if we need to write back data to the database
|
||||||
|
if self.persist:
|
||||||
|
self._sync_to_pathway()
|
||||||
|
# call save to update the internal modified field
|
||||||
|
self.persist.save()
|
||||||
|
|
||||||
|
if new_nodes:
|
||||||
|
for edge in new_edges:
|
||||||
|
# All edge have `current` as educt
|
||||||
|
# Use `current` and adjust probs
|
||||||
|
current_prob = node_probs[current]
|
||||||
|
|
||||||
|
for prod in edge.products:
|
||||||
|
# Either is a new product or a product and we found a path with a higher prob
|
||||||
|
if (
|
||||||
|
prod not in node_probs
|
||||||
|
or current_prob * edge.probability > node_probs[prod]
|
||||||
|
):
|
||||||
|
node_probs[prod] = current_prob * edge.probability
|
||||||
|
|
||||||
|
# Update Queue to proceed
|
||||||
|
if self.prediction_setting.expansion_scheme == "DFS":
|
||||||
|
for n in new_nodes:
|
||||||
|
if n not in processed:
|
||||||
|
# We want to follow this path -> prepend queue
|
||||||
|
queue.insert(0, n)
|
||||||
|
elif self.prediction_setting.expansion_scheme == "BFS":
|
||||||
|
for n in new_nodes:
|
||||||
|
if n not in processed:
|
||||||
|
# Add at the end, everything queued before will be processed
|
||||||
|
# before new_nodese
|
||||||
|
queue.append(n)
|
||||||
|
elif self.prediction_setting.expansion_scheme == "GREEDY":
|
||||||
|
# Simply add them, as we will re-order the queue later
|
||||||
|
for n in new_nodes:
|
||||||
|
if n not in processed:
|
||||||
|
queue.append(n)
|
||||||
|
|
||||||
|
node_and_probs = []
|
||||||
|
for queued_val in queue:
|
||||||
|
node_and_probs.append((queued_val, node_probs[queued_val]))
|
||||||
|
|
||||||
|
# re-order the queue and only pick smiles
|
||||||
|
queue = [
|
||||||
|
n[0] for n in sorted(node_and_probs, key=lambda x: x[1], reverse=True)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown expansion schema: {self.prediction_setting.expansion_scheme}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue exhausted, we're done
|
||||||
|
self.done = True
|
||||||
|
|
||||||
def predict_step(self, from_depth: int = None, from_node: "Node" = None):
|
def predict_step(self, from_depth: int = None, from_node: "Node" = None):
|
||||||
substrates: List[SNode] = []
|
substrates: List[SNode] = []
|
||||||
|
|
||||||
@ -1547,67 +1765,15 @@ class SPathway(object):
|
|||||||
if from_node == v:
|
if from_node == v:
|
||||||
substrates = [k]
|
substrates = [k]
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Node {from_node} not found in SPathway!")
|
||||||
else:
|
else:
|
||||||
raise ValueError("Neither from_depth nor from_node_url specified")
|
raise ValueError("Neither from_depth nor from_node_url specified")
|
||||||
|
|
||||||
new_tp = False
|
new_tp = False
|
||||||
if substrates:
|
if substrates:
|
||||||
for sub in substrates:
|
new_nodes, _ = self._expand(substrates)
|
||||||
if sub.app_domain_assessment is None:
|
new_tp = len(new_nodes) > 0
|
||||||
if self.prediction_setting.model:
|
|
||||||
if self.prediction_setting.model.app_domain:
|
|
||||||
app_domain_assessment = self.prediction_setting.model.app_domain.assess(
|
|
||||||
sub.smiles
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.persist is not None:
|
|
||||||
n = self.snode_persist_lookup[sub]
|
|
||||||
|
|
||||||
assert n.id is not None, (
|
|
||||||
"Node has no id! Should have been saved already... aborting!"
|
|
||||||
)
|
|
||||||
node_data = n.simple_json()
|
|
||||||
node_data["image"] = f"{n.url}?image=svg"
|
|
||||||
app_domain_assessment["assessment"]["node"] = node_data
|
|
||||||
|
|
||||||
n.kv["app_domain_assessment"] = app_domain_assessment
|
|
||||||
n.save()
|
|
||||||
|
|
||||||
sub.app_domain_assessment = app_domain_assessment
|
|
||||||
|
|
||||||
candidates = self.prediction_setting.expand(self, sub)
|
|
||||||
# candidates is a List of PredictionResult. The length of the List is equal to the number of rules
|
|
||||||
for cand_set in candidates:
|
|
||||||
if cand_set:
|
|
||||||
new_tp = True
|
|
||||||
# cand_set is a PredictionResult object that can consist of multiple candidate reactions
|
|
||||||
for cand in cand_set:
|
|
||||||
cand_nodes = []
|
|
||||||
# candidate reactions can have multiple fragments
|
|
||||||
for c in cand:
|
|
||||||
if c not in self.smiles_to_node:
|
|
||||||
# For new nodes do an AppDomain Assessment if an AppDomain is attached
|
|
||||||
app_domain_assessment = None
|
|
||||||
if self.prediction_setting.model:
|
|
||||||
if self.prediction_setting.model.app_domain:
|
|
||||||
app_domain_assessment = (
|
|
||||||
self.prediction_setting.model.app_domain.assess(c)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.smiles_to_node[c] = SNode(
|
|
||||||
c, sub.depth + 1, app_domain_assessment
|
|
||||||
)
|
|
||||||
|
|
||||||
node = self.smiles_to_node[c]
|
|
||||||
cand_nodes.append(node)
|
|
||||||
|
|
||||||
edge = SEdge(
|
|
||||||
sub,
|
|
||||||
cand_nodes,
|
|
||||||
rule=cand_set.rule,
|
|
||||||
probability=cand_set.probability,
|
|
||||||
)
|
|
||||||
self.edges.add(edge)
|
|
||||||
|
|
||||||
# In case no substrates are found, we're done.
|
# In case no substrates are found, we're done.
|
||||||
# For "predict from node" we're always done
|
# For "predict from node" we're always done
|
||||||
@ -1620,6 +1786,14 @@ class SPathway(object):
|
|||||||
# call save to update the internal modified field
|
# call save to update the internal modified field
|
||||||
self.persist.save()
|
self.persist.save()
|
||||||
|
|
||||||
|
def get_edge_for_educt_smiles(self, smiles: str) -> List[SEdge]:
|
||||||
|
res = []
|
||||||
|
for e in self.edges:
|
||||||
|
for n in e.educts:
|
||||||
|
if n.smiles == smiles:
|
||||||
|
res.append(e)
|
||||||
|
return res
|
||||||
|
|
||||||
def _sync_to_pathway(self) -> None:
|
def _sync_to_pathway(self) -> None:
|
||||||
logger.info("Updating Pathway with SPathway")
|
logger.info("Updating Pathway with SPathway")
|
||||||
|
|
||||||
@ -1683,11 +1857,6 @@ class SPathway(object):
|
|||||||
"to": to_indices,
|
"to": to_indices,
|
||||||
}
|
}
|
||||||
|
|
||||||
# if edge.rule:
|
|
||||||
# e['rule'] = {
|
|
||||||
# 'name': edge.rule.name,
|
|
||||||
# 'id': edge.rule.url,
|
|
||||||
# }
|
|
||||||
edges.append(e)
|
edges.append(e)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -2,7 +2,9 @@ from django.conf import settings as s
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from epdb.models import MLRelativeReasoning, EnviFormer, Package
|
from epdb.models import EnviFormer, MLRelativeReasoning
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@ -75,11 +77,13 @@ class Command(BaseCommand):
|
|||||||
return packages
|
return packages
|
||||||
|
|
||||||
# Iteratively create models in options["model_names"]
|
# Iteratively create models in options["model_names"]
|
||||||
print(f"Creating models: {options['model_names']}\n"
|
print(
|
||||||
f"Data packages: {options['data_packages']}\n"
|
f"Creating models: {options['model_names']}\n"
|
||||||
f"Rule Packages (only for MLRR): {options['rule_packages']}\n"
|
f"Data packages: {options['data_packages']}\n"
|
||||||
f"Eval Packages: {options['eval_packages']}\n"
|
f"Rule Packages (only for MLRR): {options['rule_packages']}\n"
|
||||||
f"Threshold: {options['threshold']:.2f}")
|
f"Eval Packages: {options['eval_packages']}\n"
|
||||||
|
f"Threshold: {options['threshold']:.2f}"
|
||||||
|
)
|
||||||
data_packages = decode_packages(options["data_packages"])
|
data_packages = decode_packages(options["data_packages"])
|
||||||
eval_packages = decode_packages(options["eval_packages"])
|
eval_packages = decode_packages(options["eval_packages"])
|
||||||
rule_packages = decode_packages(options["rule_packages"])
|
rule_packages = decode_packages(options["rule_packages"])
|
||||||
@ -89,22 +93,20 @@ class Command(BaseCommand):
|
|||||||
model = EnviFormer.create(
|
model = EnviFormer.create(
|
||||||
pack,
|
pack,
|
||||||
data_packages=data_packages,
|
data_packages=data_packages,
|
||||||
eval_packages=eval_packages,
|
threshold=options["threshold"],
|
||||||
threshold=options['threshold'],
|
|
||||||
name=f"EnviFormer - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}",
|
name=f"EnviFormer - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}",
|
||||||
description=f"EnviFormer transformer trained on {options['data_packages']} "
|
description=f"EnviFormer transformer trained on {options['data_packages']} "
|
||||||
f"evaluated on {options['eval_packages']}.",
|
f"evaluated on {options['eval_packages']}.",
|
||||||
)
|
)
|
||||||
elif model_name == "mlrr":
|
elif model_name == "mlrr":
|
||||||
model = MLRelativeReasoning.create(
|
model = MLRelativeReasoning.create(
|
||||||
package=pack,
|
package=pack,
|
||||||
rule_packages=rule_packages,
|
rule_packages=rule_packages,
|
||||||
data_packages=data_packages,
|
data_packages=data_packages,
|
||||||
eval_packages=eval_packages,
|
threshold=options["threshold"],
|
||||||
threshold=options['threshold'],
|
|
||||||
name=f"ECC - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}",
|
name=f"ECC - {', '.join(options['data_packages'])} - T{options['threshold']:.2f}",
|
||||||
description=f"ML Relative Reasoning trained on {options['data_packages']} with rules from "
|
description=f"ML Relative Reasoning trained on {options['data_packages']} with rules from "
|
||||||
f"{options['rule_packages']} and evaluated on {options['eval_packages']}.",
|
f"{options['rule_packages']} and evaluated on {options['eval_packages']}.",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Cannot create model of type {model_name}, unknown model type")
|
raise ValueError(f"Cannot create model of type {model_name}, unknown model type")
|
||||||
|
|||||||
@ -8,7 +8,9 @@ from django.conf import settings as s
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from epdb.models import EnviFormer, Package
|
from epdb.models import EnviFormer
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.conf import settings as s
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import F, JSONField, TextField, Value
|
||||||
from django.db.models import F, Value, TextField, JSONField
|
from django.db.models.functions import Cast, Replace
|
||||||
from django.db.models.functions import Replace, Cast
|
|
||||||
|
|
||||||
from epdb.models import EnviPathModel
|
from epdb.models import EnviPathModel
|
||||||
|
|
||||||
@ -23,10 +23,12 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
Package.objects.update(url=Replace(F("url"), Value(options["old"]), Value(options["new"])))
|
||||||
|
|
||||||
MODELS = [
|
MODELS = [
|
||||||
"User",
|
"User",
|
||||||
"Group",
|
"Group",
|
||||||
"Package",
|
|
||||||
"Compound",
|
"Compound",
|
||||||
"CompoundStructure",
|
"CompoundStructure",
|
||||||
"Pathway",
|
"Pathway",
|
||||||
@ -47,7 +49,6 @@ class Command(BaseCommand):
|
|||||||
]
|
]
|
||||||
for model in MODELS:
|
for model in MODELS:
|
||||||
obj_cls = apps.get_model("epdb", model)
|
obj_cls = apps.get_model("epdb", model)
|
||||||
print(f"Localizing urls for {model}")
|
|
||||||
obj_cls.objects.update(
|
obj_cls.objects.update(
|
||||||
url=Replace(F("url"), Value(options["old"]), Value(options["new"]))
|
url=Replace(F("url"), Value(options["old"]), Value(options["new"]))
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
632
epdb/models.py
632
epdb/models.py
File diff suppressed because it is too large
Load Diff
168
epdb/tasks.py
168
epdb/tasks.py
@ -6,14 +6,18 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from celery.utils.functional import LRUCache
|
from celery.utils.functional import LRUCache
|
||||||
|
from django.conf import settings as s
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from epdb.logic import SPathway
|
from epdb.logic import SPathway
|
||||||
from epdb.models import Edge, EPModel, JobLog, Node, Package, Pathway, Rule, Setting, User
|
from epdb.models import Edge, EPModel, JobLog, Node, Pathway, Rule, Setting, User
|
||||||
|
from utilities.chem import FormatConverter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
ML_CACHE = LRUCache(3) # Cache the three most recent ML models to reduce load times.
|
ML_CACHE = LRUCache(3) # Cache the three most recent ML models to reduce load times.
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
def get_ml_model(model_pk: int):
|
def get_ml_model(model_pk: int):
|
||||||
if model_pk not in ML_CACHE:
|
if model_pk not in ML_CACHE:
|
||||||
@ -33,7 +37,7 @@ def dispatch_eager(user: "User", job: Callable, *args, **kwargs):
|
|||||||
log.task_result = str(x) if x else None
|
log.task_result = str(x) if x else None
|
||||||
log.save()
|
log.save()
|
||||||
|
|
||||||
return x
|
return log, x
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise e
|
raise e
|
||||||
@ -49,7 +53,7 @@ def dispatch(user: "User", job: Callable, *args, **kwargs):
|
|||||||
log.status = "INITIAL"
|
log.status = "INITIAL"
|
||||||
log.save()
|
log.save()
|
||||||
|
|
||||||
return x.result
|
return log
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
raise e
|
raise e
|
||||||
@ -136,14 +140,25 @@ def predict(
|
|||||||
pred_setting_pk: int,
|
pred_setting_pk: int,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
node_pk: Optional[int] = None,
|
node_pk: Optional[int] = None,
|
||||||
|
setting_overrides: Optional[dict] = None,
|
||||||
) -> Pathway:
|
) -> Pathway:
|
||||||
pw = Pathway.objects.get(id=pw_pk)
|
pw = Pathway.objects.get(id=pw_pk)
|
||||||
setting = Setting.objects.get(id=pred_setting_pk)
|
setting = Setting.objects.get(id=pred_setting_pk)
|
||||||
|
|
||||||
|
if setting_overrides:
|
||||||
|
for k, v in setting_overrides.items():
|
||||||
|
setattr(setting, k, v)
|
||||||
|
|
||||||
# If the setting has a model add/restore it from the cache
|
# If the setting has a model add/restore it from the cache
|
||||||
if setting.model is not None:
|
if setting.model is not None:
|
||||||
setting.model = get_ml_model(setting.model.pk)
|
setting.model = get_ml_model(setting.model.pk)
|
||||||
|
|
||||||
pw.kv.update(**{"status": "running"})
|
kv = {"status": "running"}
|
||||||
|
|
||||||
|
if setting_overrides:
|
||||||
|
kv["setting_overrides"] = setting_overrides
|
||||||
|
|
||||||
|
pw.kv.update(**kv)
|
||||||
pw.save()
|
pw.save()
|
||||||
|
|
||||||
if JobLog.objects.filter(task_id=self.request.id).exists():
|
if JobLog.objects.filter(task_id=self.request.id).exists():
|
||||||
@ -168,10 +183,12 @@ def predict(
|
|||||||
spw = SPathway.from_pathway(pw)
|
spw = SPathway.from_pathway(pw)
|
||||||
spw.predict_step(from_node=n)
|
spw.predict_step(from_node=n)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Neither limit nor node_pk given!")
|
spw = SPathway(prediction_setting=setting, persist=pw)
|
||||||
|
spw.predict()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pw.kv.update({"status": "failed"})
|
pw.kv.update({"status": "failed"})
|
||||||
|
pw.kv.update(**{"error": str(e)})
|
||||||
pw.save()
|
pw.save()
|
||||||
|
|
||||||
if JobLog.objects.filter(task_id=self.request.id).exists():
|
if JobLog.objects.filter(task_id=self.request.id).exists():
|
||||||
@ -281,3 +298,144 @@ def identify_missing_rules(
|
|||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
|
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, queue="background")
|
||||||
|
def engineer_pathways(self, pw_pks: List[int], setting_pk: int, target_package_pk: int):
|
||||||
|
from utilities.misc import PathwayUtils
|
||||||
|
|
||||||
|
setting = Setting.objects.get(pk=setting_pk)
|
||||||
|
# Temporarily set model_threshold to 0.0 to keep all tps
|
||||||
|
setting.model_threshold = 0.0
|
||||||
|
|
||||||
|
target = Package.objects.get(pk=target_package_pk)
|
||||||
|
|
||||||
|
intermediate_pathways = []
|
||||||
|
predicted_pathways = []
|
||||||
|
|
||||||
|
for pw in Pathway.objects.filter(pk__in=pw_pks):
|
||||||
|
pu = PathwayUtils(pw)
|
||||||
|
|
||||||
|
eng_pw, node_to_snode_mapping, intermediates = pu.engineer(setting)
|
||||||
|
|
||||||
|
# If we've found intermediates, do the following
|
||||||
|
# - Get a copy of the original pathway and add intermediates
|
||||||
|
# - Store the predicted pathway for further investigation
|
||||||
|
if len(intermediates):
|
||||||
|
copy_mapping = {}
|
||||||
|
copied_pw = pw.copy(target, copy_mapping)
|
||||||
|
copied_pw.name = f"{copied_pw.name} (Engineered)"
|
||||||
|
copied_pw.description = f"The original Pathway can be found here: {pw.url}"
|
||||||
|
copied_pw.save()
|
||||||
|
|
||||||
|
for inter in intermediates:
|
||||||
|
start = copy_mapping[inter[0]]
|
||||||
|
end = copy_mapping[inter[1]]
|
||||||
|
start_snode = inter[2]
|
||||||
|
end_snode = inter[3]
|
||||||
|
for idx, intermediate_edge in enumerate(inter[4]):
|
||||||
|
smiles_to_node = {}
|
||||||
|
|
||||||
|
snodes_to_create = list(
|
||||||
|
set(intermediate_edge.educts + intermediate_edge.products)
|
||||||
|
)
|
||||||
|
|
||||||
|
for snode in snodes_to_create:
|
||||||
|
if snode == start_snode or snode == end_snode:
|
||||||
|
smiles_to_node[snode.smiles] = start if snode == start_snode else end
|
||||||
|
continue
|
||||||
|
|
||||||
|
if snode.smiles not in smiles_to_node:
|
||||||
|
n = Node.create(copied_pw, smiles=snode.smiles, depth=snode.depth)
|
||||||
|
# Used in viz to highlight intermediates
|
||||||
|
n.kv.update({"is_engineered_intermediate": True})
|
||||||
|
n.save()
|
||||||
|
smiles_to_node[snode.smiles] = n
|
||||||
|
|
||||||
|
Edge.create(
|
||||||
|
copied_pw,
|
||||||
|
[smiles_to_node[educt.smiles] for educt in intermediate_edge.educts],
|
||||||
|
[smiles_to_node[product.smiles] for product in intermediate_edge.products],
|
||||||
|
rule=intermediate_edge.rule,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist the predicted pathway
|
||||||
|
pred_pw = pu.spathway_to_pathway(target, eng_pw, name=f"{pw.name} (Predicted)")
|
||||||
|
|
||||||
|
intermediate_pathways.append(copied_pw.url)
|
||||||
|
predicted_pathways.append(pred_pw.url)
|
||||||
|
|
||||||
|
return intermediate_pathways, predicted_pathways
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, queue="background")
|
||||||
|
def batch_predict(
|
||||||
|
self,
|
||||||
|
substrates: List[str] | List[List[str]],
|
||||||
|
prediction_setting_pk: int,
|
||||||
|
target_package_pk: int,
|
||||||
|
num_tps: int = 50,
|
||||||
|
):
|
||||||
|
target_package = Package.objects.get(pk=target_package_pk)
|
||||||
|
prediction_setting = Setting.objects.get(pk=prediction_setting_pk)
|
||||||
|
|
||||||
|
if len(substrates) == 0:
|
||||||
|
raise ValueError("No substrates given!")
|
||||||
|
|
||||||
|
is_pair = isinstance(substrates[0], list)
|
||||||
|
|
||||||
|
substrate_and_names = []
|
||||||
|
if not is_pair:
|
||||||
|
for sub in substrates:
|
||||||
|
substrate_and_names.append([sub, None])
|
||||||
|
else:
|
||||||
|
substrate_and_names = substrates
|
||||||
|
|
||||||
|
# Check prerequisite that we can standardize all substrates
|
||||||
|
standardized_substrates_and_smiles = []
|
||||||
|
for substrate in substrate_and_names:
|
||||||
|
try:
|
||||||
|
stand_smiles = FormatConverter.standardize(substrate[0])
|
||||||
|
standardized_substrates_and_smiles.append([stand_smiles, substrate[1]])
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f'Pathway prediction failed as standardization of SMILES "{substrate}" failed!'
|
||||||
|
)
|
||||||
|
|
||||||
|
pathways = []
|
||||||
|
|
||||||
|
for pair in standardized_substrates_and_smiles:
|
||||||
|
pw = Pathway.create(
|
||||||
|
target_package,
|
||||||
|
pair[0],
|
||||||
|
name=pair[1],
|
||||||
|
predicted=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# set mode and setting
|
||||||
|
pw.setting = prediction_setting
|
||||||
|
pw.kv.update({"mode": "predict"})
|
||||||
|
pw.save()
|
||||||
|
|
||||||
|
predict(
|
||||||
|
pw.pk,
|
||||||
|
prediction_setting.pk,
|
||||||
|
limit=None,
|
||||||
|
setting_overrides={
|
||||||
|
"max_nodes": num_tps,
|
||||||
|
"max_depth": num_tps,
|
||||||
|
"model_threshold": 0.001,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
pathways.append(pw)
|
||||||
|
|
||||||
|
buffer = io.StringIO()
|
||||||
|
|
||||||
|
for idx, pw in enumerate(pathways):
|
||||||
|
# Carry out header only for the first pathway
|
||||||
|
buffer.write(pw.to_csv(include_header=idx == 0, include_pathway_url=True))
|
||||||
|
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
return buffer.getvalue()
|
||||||
|
|||||||
@ -49,6 +49,7 @@ urlpatterns = [
|
|||||||
re_path(r"^group$", v.groups, name="groups"),
|
re_path(r"^group$", v.groups, name="groups"),
|
||||||
re_path(r"^search$", v.search, name="search"),
|
re_path(r"^search$", v.search, name="search"),
|
||||||
re_path(r"^predict$", v.predict_pathway, name="predict_pathway"),
|
re_path(r"^predict$", v.predict_pathway, name="predict_pathway"),
|
||||||
|
re_path(r"^batch-predict$", v.batch_predict_pathway, name="batch_predict_pathway"),
|
||||||
# User Detail
|
# User Detail
|
||||||
re_path(rf"^user/(?P<user_uuid>{UUID})", v.user, name="user"),
|
re_path(rf"^user/(?P<user_uuid>{UUID})", v.user, name="user"),
|
||||||
# Group Detail
|
# Group Detail
|
||||||
@ -196,7 +197,8 @@ urlpatterns = [
|
|||||||
re_path(r"^indigo/dearomatize$", v.dearomatize, name="indigo_dearomatize"),
|
re_path(r"^indigo/dearomatize$", v.dearomatize, name="indigo_dearomatize"),
|
||||||
re_path(r"^indigo/layout$", v.layout, name="indigo_layout"),
|
re_path(r"^indigo/layout$", v.layout, name="indigo_layout"),
|
||||||
re_path(r"^depict$", v.depict, name="depict"),
|
re_path(r"^depict$", v.depict, name="depict"),
|
||||||
re_path(r"^jobs", v.jobs, name="jobs"),
|
path("jobs", v.jobs, name="jobs"),
|
||||||
|
path("jobs/<uuid:job_uuid>", v.job, name="job detail"),
|
||||||
# OAuth Stuff
|
# OAuth Stuff
|
||||||
path("o/userinfo/", v.userinfo, name="oauth_userinfo"),
|
path("o/userinfo/", v.userinfo, name="oauth_userinfo"),
|
||||||
# Static Pages
|
# Static Pages
|
||||||
|
|||||||
602
epdb/views.py
602
epdb/views.py
@ -1,58 +1,63 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import nh3
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.http import JsonResponse, HttpResponse, HttpResponseNotAllowed, HttpResponseBadRequest
|
from django.core.exceptions import BadRequest, PermissionDenied
|
||||||
from django.shortcuts import render, redirect
|
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from envipy_additional_information import NAME_MAPPING
|
from envipy_additional_information import NAME_MAPPING
|
||||||
from oauth2_provider.decorators import protected_resource
|
from oauth2_provider.decorators import protected_resource
|
||||||
import nh3
|
|
||||||
|
|
||||||
from utilities.chem import FormatConverter, IndigoUtils
|
from utilities.chem import FormatConverter, IndigoUtils
|
||||||
from utilities.decorators import package_permission_required
|
from utilities.decorators import package_permission_required
|
||||||
from utilities.misc import HTMLGenerator
|
from utilities.misc import HTMLGenerator
|
||||||
|
|
||||||
from .logic import (
|
from .logic import (
|
||||||
|
EPDBURLParser,
|
||||||
GroupManager,
|
GroupManager,
|
||||||
PackageManager,
|
PackageManager,
|
||||||
UserManager,
|
|
||||||
SettingManager,
|
|
||||||
SearchManager,
|
SearchManager,
|
||||||
EPDBURLParser,
|
SettingManager,
|
||||||
|
UserManager,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
Package,
|
APIToken,
|
||||||
GroupPackagePermission,
|
|
||||||
Group,
|
|
||||||
CompoundStructure,
|
|
||||||
Compound,
|
Compound,
|
||||||
|
CompoundStructure,
|
||||||
|
Edge,
|
||||||
|
EnviFormer,
|
||||||
|
EnzymeLink,
|
||||||
|
EPModel,
|
||||||
|
ExternalDatabase,
|
||||||
|
ExternalIdentifier,
|
||||||
|
Group,
|
||||||
|
GroupPackagePermission,
|
||||||
|
JobLog,
|
||||||
|
License,
|
||||||
|
MLRelativeReasoning,
|
||||||
|
Node,
|
||||||
|
Pathway,
|
||||||
|
Permission,
|
||||||
Reaction,
|
Reaction,
|
||||||
Rule,
|
Rule,
|
||||||
Pathway,
|
|
||||||
Node,
|
|
||||||
EPModel,
|
|
||||||
EnviFormer,
|
|
||||||
MLRelativeReasoning,
|
|
||||||
RuleBasedRelativeReasoning,
|
RuleBasedRelativeReasoning,
|
||||||
Scenario,
|
Scenario,
|
||||||
SimpleAmbitRule,
|
SimpleAmbitRule,
|
||||||
APIToken,
|
|
||||||
UserPackagePermission,
|
|
||||||
Permission,
|
|
||||||
License,
|
|
||||||
User,
|
User,
|
||||||
Edge,
|
UserPackagePermission,
|
||||||
ExternalDatabase,
|
ExpansionSchemeChoice,
|
||||||
ExternalIdentifier,
|
|
||||||
EnzymeLink,
|
|
||||||
JobLog,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
def log_post_params(request):
|
def log_post_params(request):
|
||||||
if s.DEBUG:
|
if s.DEBUG:
|
||||||
@ -60,6 +65,26 @@ def log_post_params(request):
|
|||||||
logger.debug(f"{k}\t{v}")
|
logger.debug(f"{k}\t{v}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_error_handler_context(request, for_user=None) -> Dict[str, Any]:
|
||||||
|
current_user = _anonymous_or_real(request)
|
||||||
|
|
||||||
|
if for_user:
|
||||||
|
current_user = for_user
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"title": "enviPath",
|
||||||
|
"meta": {
|
||||||
|
"site_id": s.MATOMO_SITE_ID,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"server_url": s.SERVER_URL,
|
||||||
|
"user": current_user,
|
||||||
|
"enabled_features": s.FLAGS,
|
||||||
|
"debug": s.DEBUG,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def error(request, message: str, detail: str, code: int = 400):
|
def error(request, message: str, detail: str, code: int = 400):
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
error_context = {
|
error_context = {
|
||||||
@ -74,6 +99,48 @@ def error(request, message: str, detail: str, code: int = 400):
|
|||||||
return render(request, "errors/error.html", context, status=code)
|
return render(request, "errors/error.html", context, status=code)
|
||||||
|
|
||||||
|
|
||||||
|
def handler400(request, exception):
|
||||||
|
"""Custom 400 Bad Request error handler"""
|
||||||
|
context = get_error_handler_context(request)
|
||||||
|
context["public_mode"] = True
|
||||||
|
return render(request, "errors/400_bad_request.html", context, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
def handler403(request, exception):
|
||||||
|
"""Custom 403 Forbidden error handler"""
|
||||||
|
context = get_error_handler_context(request)
|
||||||
|
context["public_mode"] = True
|
||||||
|
return render(request, "errors/403_access_denied.html", context, status=403)
|
||||||
|
|
||||||
|
|
||||||
|
def handler404(request, exception):
|
||||||
|
"""Custom 404 Not Found error handler"""
|
||||||
|
context = get_error_handler_context(request)
|
||||||
|
context["public_mode"] = True
|
||||||
|
return render(request, "errors/404_not_found.html", context, status=404)
|
||||||
|
|
||||||
|
|
||||||
|
def handler500(request):
|
||||||
|
"""Custom 500 Internal Server Error handler"""
|
||||||
|
context = get_error_handler_context(request)
|
||||||
|
|
||||||
|
error_context = {}
|
||||||
|
error_context["error_message"] = "Internal Server Error"
|
||||||
|
error_context["error_detail"] = "An unexpected error occurred. Please try again later."
|
||||||
|
|
||||||
|
if request.headers.get("Accept") == "application/json":
|
||||||
|
return JsonResponse(error_context, status=500)
|
||||||
|
|
||||||
|
context["public_mode"] = True
|
||||||
|
context["error_code"] = 500
|
||||||
|
context["error_description"] = (
|
||||||
|
"We encountered an unexpected error while processing your request. Our team has been notified and is working to resolve the issue."
|
||||||
|
)
|
||||||
|
context.update(**error_context)
|
||||||
|
|
||||||
|
return render(request, "errors/error.html", context, status=500)
|
||||||
|
|
||||||
|
|
||||||
def login(request):
|
def login(request):
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
|
|
||||||
@ -83,8 +150,7 @@ def login(request):
|
|||||||
return render(request, "static/login.html", context)
|
return render(request, "static/login.html", context)
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate, login
|
||||||
from django.contrib.auth import login
|
|
||||||
|
|
||||||
username = request.POST.get("username").strip()
|
username = request.POST.get("username").strip()
|
||||||
if username != request.POST.get("username"):
|
if username != request.POST.get("username"):
|
||||||
@ -191,8 +257,8 @@ def register(request):
|
|||||||
|
|
||||||
|
|
||||||
def editable(request, user):
|
def editable(request, user):
|
||||||
if user.is_superuser:
|
# if user.is_superuser:
|
||||||
return True
|
# return True
|
||||||
|
|
||||||
url = request.build_absolute_uri(request.path)
|
url = request.build_absolute_uri(request.path)
|
||||||
if PackageManager.is_package_url(url):
|
if PackageManager.is_package_url(url):
|
||||||
@ -256,7 +322,7 @@ def get_base_context(request, for_user=None) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _anonymous_or_real(request):
|
def _anonymous_or_real(request):
|
||||||
if request.user.is_authenticated and not request.user.is_anonymous:
|
if request.user and (request.user.is_authenticated and not request.user.is_anonymous):
|
||||||
return request.user
|
return request.user
|
||||||
return get_user_model().objects.get(username="anonymous")
|
return get_user_model().objects.get(username="anonymous")
|
||||||
|
|
||||||
@ -374,6 +440,18 @@ def predict_pathway(request):
|
|||||||
return render(request, "predict_pathway.html", context)
|
return render(request, "predict_pathway.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_predict_pathway(request):
|
||||||
|
"""Top-level predict pathway view using user's default package."""
|
||||||
|
if request.method != "GET":
|
||||||
|
return HttpResponseNotAllowed(["GET"])
|
||||||
|
|
||||||
|
context = get_base_context(request)
|
||||||
|
context["title"] = "enviPath - Batch Predict Pathway"
|
||||||
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
|
|
||||||
|
return render(request, "batch_predict_pathway.html", context)
|
||||||
|
|
||||||
|
|
||||||
@package_permission_required()
|
@package_permission_required()
|
||||||
def package_predict_pathway(request, package_uuid):
|
def package_predict_pathway(request, package_uuid):
|
||||||
"""Package-specific predict pathway view."""
|
"""Package-specific predict pathway view."""
|
||||||
@ -396,20 +474,15 @@ def packages(request):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = "enviPath - Packages"
|
context["title"] = "enviPath - Packages"
|
||||||
|
|
||||||
context["object_type"] = "package"
|
|
||||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
context["meta"]["can_edit"] = True
|
|
||||||
|
|
||||||
reviewed_package_qs = Package.objects.filter(reviewed=True).order_by("created")
|
# Context for paginated template
|
||||||
unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user).order_by(
|
context["entity_type"] = "package"
|
||||||
"name"
|
context["api_endpoint"] = "/api/v1/packages/"
|
||||||
)
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "packages"
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_package_qs
|
return render(request, "collections/packages_paginated.html", context)
|
||||||
context["unreviewed_objects"] = unreviewed_package_qs
|
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
if hidden := request.POST.get("hidden", None):
|
if hidden := request.POST.get("hidden", None):
|
||||||
@ -455,29 +528,16 @@ def compounds(request):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = "enviPath - Compounds"
|
context["title"] = "enviPath - Compounds"
|
||||||
|
|
||||||
context["object_type"] = "compound"
|
|
||||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
|
|
||||||
reviewed_compound_qs = Compound.objects.none()
|
# Context for paginated template
|
||||||
|
context["entity_type"] = "compound"
|
||||||
|
context["api_endpoint"] = "/api/v1/compounds/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_mode"] = "tabbed"
|
||||||
|
context["list_title"] = "compounds"
|
||||||
|
|
||||||
for p in PackageManager.get_reviewed_packages():
|
return render(request, "collections/compounds_paginated.html", context)
|
||||||
reviewed_compound_qs |= Compound.objects.filter(package=p)
|
|
||||||
|
|
||||||
reviewed_compound_qs = reviewed_compound_qs.order_by("name")
|
|
||||||
|
|
||||||
if request.GET.get("all"):
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"objects": [
|
|
||||||
{"name": pw.name, "url": pw.url, "reviewed": True}
|
|
||||||
for pw in reviewed_compound_qs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_compound_qs
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
# delegate to default package
|
# delegate to default package
|
||||||
@ -493,32 +553,19 @@ def rules(request):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = "enviPath - Rules"
|
context["title"] = "enviPath - Rules"
|
||||||
|
|
||||||
context["object_type"] = "rule"
|
|
||||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
context["breadcrumbs"] = [
|
context["breadcrumbs"] = [
|
||||||
{"Home": s.SERVER_URL},
|
{"Home": s.SERVER_URL},
|
||||||
{"Rule": s.SERVER_URL + "/rule"},
|
{"Rule": s.SERVER_URL + "/rule"},
|
||||||
]
|
]
|
||||||
reviewed_rule_qs = Rule.objects.none()
|
|
||||||
|
|
||||||
for p in PackageManager.get_reviewed_packages():
|
# Context for paginated template
|
||||||
reviewed_rule_qs |= Rule.objects.filter(package=p)
|
context["entity_type"] = "rule"
|
||||||
|
context["api_endpoint"] = "/api/v1/rules/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "rules"
|
||||||
|
|
||||||
reviewed_rule_qs = reviewed_rule_qs.order_by("name")
|
return render(request, "collections/rules_paginated.html", context)
|
||||||
|
|
||||||
if request.GET.get("all"):
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"objects": [
|
|
||||||
{"name": pw.name, "url": pw.url, "reviewed": True}
|
|
||||||
for pw in reviewed_rule_qs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_rule_qs
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
# delegate to default package
|
# delegate to default package
|
||||||
@ -534,32 +581,19 @@ def reactions(request):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = "enviPath - Reactions"
|
context["title"] = "enviPath - Reactions"
|
||||||
|
|
||||||
context["object_type"] = "reaction"
|
|
||||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
context["breadcrumbs"] = [
|
context["breadcrumbs"] = [
|
||||||
{"Home": s.SERVER_URL},
|
{"Home": s.SERVER_URL},
|
||||||
{"Reaction": s.SERVER_URL + "/reaction"},
|
{"Reaction": s.SERVER_URL + "/reaction"},
|
||||||
]
|
]
|
||||||
reviewed_reaction_qs = Reaction.objects.none()
|
|
||||||
|
|
||||||
for p in PackageManager.get_reviewed_packages():
|
# Context for paginated template
|
||||||
reviewed_reaction_qs |= Reaction.objects.filter(package=p).order_by("name")
|
context["entity_type"] = "reaction"
|
||||||
|
context["api_endpoint"] = "/api/v1/reactions/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "reactions"
|
||||||
|
|
||||||
reviewed_reaction_qs = reviewed_reaction_qs.order_by("name")
|
return render(request, "collections/reactions_paginated.html", context)
|
||||||
|
|
||||||
if request.GET.get("all"):
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"objects": [
|
|
||||||
{"name": pw.name, "url": pw.url, "reviewed": True}
|
|
||||||
for pw in reviewed_reaction_qs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_reaction_qs
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
# delegate to default package
|
# delegate to default package
|
||||||
@ -575,33 +609,19 @@ def pathways(request):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = "enviPath - Pathways"
|
context["title"] = "enviPath - Pathways"
|
||||||
|
|
||||||
context["object_type"] = "pathway"
|
|
||||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
context["breadcrumbs"] = [
|
context["breadcrumbs"] = [
|
||||||
{"Home": s.SERVER_URL},
|
{"Home": s.SERVER_URL},
|
||||||
{"Pathway": s.SERVER_URL + "/pathway"},
|
{"Pathway": s.SERVER_URL + "/pathway"},
|
||||||
]
|
]
|
||||||
|
|
||||||
reviewed_pathway_qs = Pathway.objects.none()
|
# Context for paginated template
|
||||||
|
context["entity_type"] = "pathway"
|
||||||
|
context["api_endpoint"] = "/api/v1/pathways/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "pathways"
|
||||||
|
|
||||||
for p in PackageManager.get_reviewed_packages():
|
return render(request, "collections/pathways_paginated.html", context)
|
||||||
reviewed_pathway_qs |= Pathway.objects.filter(package=p).order_by("name")
|
|
||||||
|
|
||||||
reviewed_pathway_qs = reviewed_pathway_qs.order_by("name")
|
|
||||||
|
|
||||||
if request.GET.get("all"):
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"objects": [
|
|
||||||
{"name": pw.name, "url": pw.url, "reviewed": True}
|
|
||||||
for pw in reviewed_pathway_qs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_pathway_qs
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
# delegate to default package
|
# delegate to default package
|
||||||
@ -625,25 +645,13 @@ def scenarios(request):
|
|||||||
{"Scenario": s.SERVER_URL + "/scenario"},
|
{"Scenario": s.SERVER_URL + "/scenario"},
|
||||||
]
|
]
|
||||||
|
|
||||||
reviewed_scenario_qs = Scenario.objects.none()
|
# Context for paginated template
|
||||||
|
context["entity_type"] = "scenario"
|
||||||
|
context["api_endpoint"] = "/api/v1/scenarios/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "scenarios"
|
||||||
|
|
||||||
for p in PackageManager.get_reviewed_packages():
|
return render(request, "collections/scenarios_paginated.html", context)
|
||||||
reviewed_scenario_qs |= Scenario.objects.filter(package=p).order_by("name")
|
|
||||||
|
|
||||||
reviewed_scenario_qs = reviewed_scenario_qs.order_by("name")
|
|
||||||
|
|
||||||
if request.GET.get("all"):
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"objects": [
|
|
||||||
{"name": s.name, "url": s.url, "reviewed": True}
|
|
||||||
for s in reviewed_scenario_qs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_scenario_qs
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
# delegate to default package
|
# delegate to default package
|
||||||
@ -658,42 +666,28 @@ def models(request):
|
|||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = "enviPath - Models"
|
context["title"] = "enviPath - Models"
|
||||||
|
|
||||||
context["object_type"] = "model"
|
|
||||||
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
context["meta"]["current_package"] = context["meta"]["user"].default_package
|
||||||
context["breadcrumbs"] = [
|
context["breadcrumbs"] = [
|
||||||
{"Home": s.SERVER_URL},
|
{"Home": s.SERVER_URL},
|
||||||
{"Model": s.SERVER_URL + "/model"},
|
{"Model": s.SERVER_URL + "/model"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Keep model_types for potential modal/action use
|
||||||
context["model_types"] = {
|
context["model_types"] = {
|
||||||
"ML Relative Reasoning": "ml-relative-reasoning",
|
"ML Relative Reasoning": "ml-relative-reasoning",
|
||||||
"Rule Based Relative Reasoning": "rule-based-relative-reasoning",
|
"Rule Based Relative Reasoning": "rule-based-relative-reasoning",
|
||||||
"EnviFormer": "enviformer",
|
"EnviFormer": "enviformer",
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||||
context["model_types"][v.display()] = k
|
context["model_types"][v.display()] = k
|
||||||
|
|
||||||
reviewed_model_qs = EPModel.objects.none()
|
# Context for paginated template
|
||||||
|
context["entity_type"] = "model"
|
||||||
|
context["api_endpoint"] = "/api/v1/models/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "models"
|
||||||
|
|
||||||
for p in PackageManager.get_reviewed_packages():
|
return render(request, "collections/models_paginated.html", context)
|
||||||
reviewed_model_qs |= EPModel.objects.filter(package=p).order_by("name")
|
|
||||||
|
|
||||||
reviewed_model_qs = reviewed_model_qs.order_by("name")
|
|
||||||
|
|
||||||
if request.GET.get("all"):
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"objects": [
|
|
||||||
{"name": pw.name, "url": pw.url, "reviewed": True}
|
|
||||||
for pw in reviewed_model_qs
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_model_qs
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
current_user = _anonymous_or_real(request)
|
current_user = _anonymous_or_real(request)
|
||||||
@ -770,6 +764,10 @@ def package_models(request, package_uuid):
|
|||||||
context["meta"]["current_package"] = current_package
|
context["meta"]["current_package"] = current_package
|
||||||
context["object_type"] = "model"
|
context["object_type"] = "model"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "model")
|
context["breadcrumbs"] = breadcrumbs(current_package, "model")
|
||||||
|
context["entity_type"] = "model"
|
||||||
|
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/model/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "models"
|
||||||
|
|
||||||
reviewed_model_qs = EPModel.objects.none()
|
reviewed_model_qs = EPModel.objects.none()
|
||||||
unreviewed_model_qs = EPModel.objects.none()
|
unreviewed_model_qs = EPModel.objects.none()
|
||||||
@ -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"] = {
|
context["model_types"] = {
|
||||||
"ML Relative Reasoning": "mlrr",
|
"ML Relative Reasoning": "mlrr",
|
||||||
"Rule Based Relative Reasoning": "rbrr",
|
"Rule Based Relative Reasoning": "rbrr",
|
||||||
@ -806,7 +801,7 @@ def package_models(request, package_uuid):
|
|||||||
for k, v in s.CLASSIFIER_PLUGINS.items():
|
for k, v in s.CLASSIFIER_PLUGINS.items():
|
||||||
context["model_types"][v.display()] = k
|
context["model_types"][v.display()] = k
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
return render(request, "collections/models_paginated.html", context)
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
log_post_params(request)
|
log_post_params(request)
|
||||||
@ -872,7 +867,7 @@ def package_models(request, package_uuid):
|
|||||||
request, "Invalid model type.", f'Model type "{model_type}" is not supported."'
|
request, "Invalid model type.", f'Model type "{model_type}" is not supported."'
|
||||||
)
|
)
|
||||||
|
|
||||||
from .tasks import dispatch, build_model
|
from .tasks import build_model, dispatch
|
||||||
|
|
||||||
dispatch(current_user, build_model, mod.pk)
|
dispatch(current_user, build_model, mod.pk)
|
||||||
|
|
||||||
@ -897,19 +892,20 @@ def package_model(request, package_uuid, model_uuid):
|
|||||||
# Check if smiles is non empty and valid
|
# Check if smiles is non empty and valid
|
||||||
if smiles == "":
|
if smiles == "":
|
||||||
return JsonResponse({"error": "Received empty SMILES"}, status=400)
|
return JsonResponse({"error": "Received empty SMILES"}, status=400)
|
||||||
|
stereo = FormatConverter.has_stereo(smiles)
|
||||||
try:
|
try:
|
||||||
stand_smiles = FormatConverter.standardize(smiles)
|
stand_smiles = FormatConverter.standardize(smiles, remove_stereo=True)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return JsonResponse({"error": f'"{smiles}" is not a valid SMILES'}, status=400)
|
return JsonResponse({"error": f'"{smiles}" is not a valid SMILES'}, status=400)
|
||||||
|
|
||||||
if classify:
|
if classify:
|
||||||
from epdb.tasks import dispatch_eager, predict_simple
|
from epdb.tasks import dispatch_eager, predict_simple
|
||||||
|
|
||||||
res = dispatch_eager(current_user, predict_simple, current_model.pk, stand_smiles)
|
_, pred_res = dispatch_eager(
|
||||||
|
current_user, predict_simple, current_model.pk, stand_smiles
|
||||||
|
)
|
||||||
|
|
||||||
pred_res = current_model.predict(stand_smiles)
|
res = {"pred": [], "stereo": stereo}
|
||||||
res = []
|
|
||||||
|
|
||||||
for pr in pred_res:
|
for pr in pred_res:
|
||||||
if len(pr) > 0:
|
if len(pr) > 0:
|
||||||
@ -918,7 +914,7 @@ def package_model(request, package_uuid, model_uuid):
|
|||||||
logger.debug(f"Checking {prod_set}")
|
logger.debug(f"Checking {prod_set}")
|
||||||
products.append(tuple([x for x in prod_set]))
|
products.append(tuple([x for x in prod_set]))
|
||||||
|
|
||||||
res.append(
|
res["pred"].append(
|
||||||
{
|
{
|
||||||
"products": list(set(products)),
|
"products": list(set(products)),
|
||||||
"probability": pr.probability,
|
"probability": pr.probability,
|
||||||
@ -1068,9 +1064,7 @@ def package(request, package_uuid):
|
|||||||
return redirect(s.SERVER_URL + "/package")
|
return redirect(s.SERVER_URL + "/package")
|
||||||
elif hidden == "publish-package":
|
elif hidden == "publish-package":
|
||||||
for g in Group.objects.filter(public=True):
|
for g in Group.objects.filter(public=True):
|
||||||
PackageManager.update_permissions(
|
PackageManager.grant_read(current_user, current_package, g)
|
||||||
current_user, current_package, g, Permission.READ[0]
|
|
||||||
)
|
|
||||||
return redirect(current_package.url)
|
return redirect(current_package.url)
|
||||||
elif hidden == "copy":
|
elif hidden == "copy":
|
||||||
object_to_copy = request.POST.get("object_to_copy")
|
object_to_copy = request.POST.get("object_to_copy")
|
||||||
@ -1165,6 +1159,11 @@ def package_compounds(request, package_uuid):
|
|||||||
context["meta"]["current_package"] = current_package
|
context["meta"]["current_package"] = current_package
|
||||||
context["object_type"] = "compound"
|
context["object_type"] = "compound"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "compound")
|
context["breadcrumbs"] = breadcrumbs(current_package, "compound")
|
||||||
|
context["entity_type"] = "compound"
|
||||||
|
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/compound/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_mode"] = "tabbed"
|
||||||
|
context["list_title"] = "compounds"
|
||||||
|
|
||||||
reviewed_compound_qs = Compound.objects.none()
|
reviewed_compound_qs = Compound.objects.none()
|
||||||
unreviewed_compound_qs = Compound.objects.none()
|
unreviewed_compound_qs = Compound.objects.none()
|
||||||
@ -1190,17 +1189,18 @@ def package_compounds(request, package_uuid):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_compound_qs
|
return render(request, "collections/compounds_paginated.html", context)
|
||||||
context["unreviewed_objects"] = unreviewed_compound_qs
|
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
compound_name = request.POST.get("compound-name")
|
compound_name = request.POST.get("compound-name")
|
||||||
compound_smiles = request.POST.get("compound-smiles")
|
compound_smiles = request.POST.get("compound-smiles")
|
||||||
compound_description = request.POST.get("compound-description")
|
compound_description = request.POST.get("compound-description")
|
||||||
|
try:
|
||||||
c = Compound.create(current_package, compound_smiles, compound_name, compound_description)
|
c = Compound.create(
|
||||||
|
current_package, compound_smiles, compound_name, compound_description
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(str(e))
|
||||||
|
|
||||||
return redirect(c.url)
|
return redirect(c.url)
|
||||||
|
|
||||||
@ -1308,19 +1308,17 @@ def package_compound_structures(request, package_uuid, compound_uuid):
|
|||||||
context["breadcrumbs"] = breadcrumbs(
|
context["breadcrumbs"] = breadcrumbs(
|
||||||
current_package, "compound", current_compound, "structure"
|
current_package, "compound", current_compound, "structure"
|
||||||
)
|
)
|
||||||
|
context["entity_type"] = "structure"
|
||||||
|
context["page_title"] = f"{current_compound.name} - Structures"
|
||||||
|
context["api_endpoint"] = (
|
||||||
|
f"/api/v1/package/{current_package.uuid}/compound/{current_compound.uuid}/structure/"
|
||||||
|
)
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["compound"] = current_compound
|
||||||
|
context["list_mode"] = "combined"
|
||||||
|
context["list_title"] = "structures"
|
||||||
|
|
||||||
reviewed_compound_structure_qs = CompoundStructure.objects.none()
|
return render(request, "collections/structures_paginated.html", context)
|
||||||
unreviewed_compound_structure_qs = CompoundStructure.objects.none()
|
|
||||||
|
|
||||||
if current_package.reviewed:
|
|
||||||
reviewed_compound_structure_qs = current_compound.structures.order_by("name")
|
|
||||||
else:
|
|
||||||
unreviewed_compound_structure_qs = current_compound.structures.order_by("name")
|
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_compound_structure_qs
|
|
||||||
context["unreviewed_objects"] = unreviewed_compound_structure_qs
|
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
structure_name = request.POST.get("structure-name")
|
structure_name = request.POST.get("structure-name")
|
||||||
@ -1467,6 +1465,10 @@ def package_rules(request, package_uuid):
|
|||||||
context["meta"]["current_package"] = current_package
|
context["meta"]["current_package"] = current_package
|
||||||
context["object_type"] = "rule"
|
context["object_type"] = "rule"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "rule")
|
context["breadcrumbs"] = breadcrumbs(current_package, "rule")
|
||||||
|
context["entity_type"] = "rule"
|
||||||
|
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/rule/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "rules"
|
||||||
|
|
||||||
reviewed_rule_qs = Rule.objects.none()
|
reviewed_rule_qs = Rule.objects.none()
|
||||||
unreviewed_rule_qs = Rule.objects.none()
|
unreviewed_rule_qs = Rule.objects.none()
|
||||||
@ -1488,10 +1490,7 @@ def package_rules(request, package_uuid):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_rule_qs
|
return render(request, "collections/rules_paginated.html", context)
|
||||||
context["unreviewed_objects"] = unreviewed_rule_qs
|
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
log_post_params(request)
|
log_post_params(request)
|
||||||
@ -1669,11 +1668,15 @@ def package_reactions(request, package_uuid):
|
|||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
context["title"] = f"enviPath - {current_package.name} - {current_package.name} - Reactions"
|
context["title"] = f"enviPath - {current_package.name} - Reactions"
|
||||||
|
|
||||||
context["meta"]["current_package"] = current_package
|
context["meta"]["current_package"] = current_package
|
||||||
context["object_type"] = "reaction"
|
context["object_type"] = "reaction"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
|
context["breadcrumbs"] = breadcrumbs(current_package, "reaction")
|
||||||
|
context["entity_type"] = "reaction"
|
||||||
|
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/reaction/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "reactions"
|
||||||
|
|
||||||
reviewed_reaction_qs = Reaction.objects.none()
|
reviewed_reaction_qs = Reaction.objects.none()
|
||||||
unreviewed_reaction_qs = Reaction.objects.none()
|
unreviewed_reaction_qs = Reaction.objects.none()
|
||||||
@ -1699,10 +1702,7 @@ def package_reactions(request, package_uuid):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_reaction_qs
|
return render(request, "collections/reactions_paginated.html", context)
|
||||||
context["unreviewed_objects"] = unreviewed_reaction_qs
|
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
reaction_name = request.POST.get("reaction-name")
|
reaction_name = request.POST.get("reaction-name")
|
||||||
@ -1821,6 +1821,10 @@ def package_pathways(request, package_uuid):
|
|||||||
context["meta"]["current_package"] = current_package
|
context["meta"]["current_package"] = current_package
|
||||||
context["object_type"] = "pathway"
|
context["object_type"] = "pathway"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
|
context["breadcrumbs"] = breadcrumbs(current_package, "pathway")
|
||||||
|
context["entity_type"] = "pathway"
|
||||||
|
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/pathway/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "pathways"
|
||||||
|
|
||||||
reviewed_pathway_qs = Pathway.objects.none()
|
reviewed_pathway_qs = Pathway.objects.none()
|
||||||
unreviewed_pathway_qs = Pathway.objects.none()
|
unreviewed_pathway_qs = Pathway.objects.none()
|
||||||
@ -1844,10 +1848,7 @@ def package_pathways(request, package_uuid):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
context["reviewed_objects"] = reviewed_pathway_qs
|
return render(request, "collections/pathways_paginated.html", context)
|
||||||
context["unreviewed_objects"] = unreviewed_pathway_qs
|
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
log_post_params(request)
|
log_post_params(request)
|
||||||
@ -1864,7 +1865,6 @@ def package_pathways(request, package_uuid):
|
|||||||
"Pathway prediction failed!",
|
"Pathway prediction failed!",
|
||||||
"Pathway prediction failed due to missing or empty SMILES",
|
"Pathway prediction failed due to missing or empty SMILES",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stand_smiles = FormatConverter.standardize(smiles)
|
stand_smiles = FormatConverter.standardize(smiles)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -1887,8 +1887,13 @@ def package_pathways(request, package_uuid):
|
|||||||
prediction_setting = SettingManager.get_setting_by_url(current_user, prediction_setting)
|
prediction_setting = SettingManager.get_setting_by_url(current_user, prediction_setting)
|
||||||
else:
|
else:
|
||||||
prediction_setting = current_user.prediction_settings()
|
prediction_setting = current_user.prediction_settings()
|
||||||
|
pw = Pathway.create(
|
||||||
pw = Pathway.create(current_package, stand_smiles, name=name, description=description)
|
current_package,
|
||||||
|
stand_smiles,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
predicted=pw_mode in {"predict", "incremental"},
|
||||||
|
)
|
||||||
|
|
||||||
# set mode
|
# set mode
|
||||||
pw.kv.update({"mode": pw_mode})
|
pw.kv.update({"mode": pw_mode})
|
||||||
@ -1896,7 +1901,7 @@ def package_pathways(request, package_uuid):
|
|||||||
|
|
||||||
if pw_mode == "predict" or pw_mode == "incremental":
|
if pw_mode == "predict" or pw_mode == "incremental":
|
||||||
# unlimited pred (will be handled by setting)
|
# unlimited pred (will be handled by setting)
|
||||||
limit = -1
|
limit = None
|
||||||
|
|
||||||
# For incremental predict first level and return
|
# For incremental predict first level and return
|
||||||
if pw_mode == "incremental":
|
if pw_mode == "incremental":
|
||||||
@ -1932,6 +1937,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
|
|||||||
{
|
{
|
||||||
"status": current_pathway.status(),
|
"status": current_pathway.status(),
|
||||||
"modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"),
|
"modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"emptyDueToThreshold": current_pathway.empty_due_to_threshold(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1952,7 +1958,7 @@ def package_pathway(request, package_uuid, pathway_uuid):
|
|||||||
rule_package = PackageManager.get_package_by_url(
|
rule_package = PackageManager.get_package_by_url(
|
||||||
current_user, request.GET.get("rule-package")
|
current_user, request.GET.get("rule-package")
|
||||||
)
|
)
|
||||||
res = dispatch_eager(
|
_, res = dispatch_eager(
|
||||||
current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk
|
current_user, identify_missing_rules, [current_pathway.pk], rule_package.pk
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2380,6 +2386,10 @@ def package_scenarios(request, package_uuid):
|
|||||||
context["meta"]["current_package"] = current_package
|
context["meta"]["current_package"] = current_package
|
||||||
context["object_type"] = "scenario"
|
context["object_type"] = "scenario"
|
||||||
context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
|
context["breadcrumbs"] = breadcrumbs(current_package, "scenario")
|
||||||
|
context["entity_type"] = "scenario"
|
||||||
|
context["api_endpoint"] = f"/api/v1/package/{current_package.uuid}/scenario/"
|
||||||
|
context["per_page"] = s.API_PAGINATION_DEFAULT_PAGE_SIZE
|
||||||
|
context["list_title"] = "scenarios"
|
||||||
|
|
||||||
reviewed_scenario_qs = Scenario.objects.none()
|
reviewed_scenario_qs = Scenario.objects.none()
|
||||||
unreviewed_scenario_qs = Scenario.objects.none()
|
unreviewed_scenario_qs = Scenario.objects.none()
|
||||||
@ -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 (
|
from envipy_additional_information import (
|
||||||
|
SEDIMENT_ADDITIONAL_INFORMATION,
|
||||||
SLUDGE_ADDITIONAL_INFORMATION,
|
SLUDGE_ADDITIONAL_INFORMATION,
|
||||||
SOIL_ADDITIONAL_INFORMATION,
|
SOIL_ADDITIONAL_INFORMATION,
|
||||||
SEDIMENT_ADDITIONAL_INFORMATION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
context["scenario_types"] = {
|
context["scenario_types"] = {
|
||||||
@ -2442,7 +2449,7 @@ def package_scenarios(request, package_uuid):
|
|||||||
context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION
|
context["soil_additional_information"] = SOIL_ADDITIONAL_INFORMATION
|
||||||
context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION
|
context["sediment_additional_information"] = SEDIMENT_ADDITIONAL_INFORMATION
|
||||||
|
|
||||||
return render(request, "collections/objects_list.html", context)
|
return render(request, "collections/scenarios_paginated.html", context)
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
log_post_params(request)
|
log_post_params(request)
|
||||||
|
|
||||||
@ -2657,6 +2664,14 @@ def user(request, user_uuid):
|
|||||||
|
|
||||||
return redirect(current_user.url)
|
return redirect(current_user.url)
|
||||||
|
|
||||||
|
if "change_default" in request.POST:
|
||||||
|
new_default_uuid = request.POST["change_default"]
|
||||||
|
current_user.default_setting = SettingManager.get_setting_by_id(
|
||||||
|
current_user, new_default_uuid
|
||||||
|
)
|
||||||
|
current_user.save()
|
||||||
|
return redirect(current_user.url)
|
||||||
|
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -2757,14 +2772,18 @@ def settings(request):
|
|||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
|
context = get_base_context(request)
|
||||||
|
context["title"] = "enviPath - Settings"
|
||||||
|
|
||||||
context["object_type"] = "setting"
|
context["object_type"] = "setting"
|
||||||
# Even if settings are aready in "meta", for consistency add it on root level
|
|
||||||
context["settings"] = SettingManager.get_all_settings(current_user)
|
|
||||||
context["breadcrumbs"] = [
|
context["breadcrumbs"] = [
|
||||||
{"Home": s.SERVER_URL},
|
{"Home": s.SERVER_URL},
|
||||||
{"Group": s.SERVER_URL + "/setting"},
|
{"Group": s.SERVER_URL + "/setting"},
|
||||||
]
|
]
|
||||||
return
|
|
||||||
|
context["objects"] = SettingManager.get_all_settings(current_user)
|
||||||
|
|
||||||
|
return render(request, "collections/objects_list.html", context)
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
if s.DEBUG:
|
if s.DEBUG:
|
||||||
for k, v in request.POST.items():
|
for k, v in request.POST.items():
|
||||||
@ -2802,15 +2821,25 @@ def settings(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not PackageManager.readable(current_user, params["model"].package):
|
if not PackageManager.readable(current_user, params["model"].package):
|
||||||
raise ValueError("")
|
raise PermissionDenied("You're not allowed to access this model!")
|
||||||
|
|
||||||
|
expansion_scheme = request.POST.get(
|
||||||
|
"model-based-prediction-setting-expansion-scheme", "BFS"
|
||||||
|
)
|
||||||
|
|
||||||
|
if expansion_scheme not in ExpansionSchemeChoice.values:
|
||||||
|
raise BadRequest(f"Unknown expansion scheme: {expansion_scheme}")
|
||||||
|
|
||||||
|
params["expansion_scheme"] = ExpansionSchemeChoice(expansion_scheme)
|
||||||
|
|
||||||
elif tp_gen_method == "rule-based-prediction-setting":
|
elif tp_gen_method == "rule-based-prediction-setting":
|
||||||
rule_packages = request.POST.getlist("rule-based-prediction-setting-packages")
|
rule_packages = request.POST.getlist("rule-based-prediction-setting-packages")
|
||||||
params["rule_packages"] = [
|
params["rule_packages"] = [
|
||||||
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
PackageManager.get_package_by_url(current_user, p) for p in rule_packages
|
||||||
]
|
]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError("")
|
raise BadRequest("Neither Model-Based nor Rule-Based as Method selected!")
|
||||||
|
|
||||||
created_setting = SettingManager.create_setting(
|
created_setting = SettingManager.create_setting(
|
||||||
current_user,
|
current_user,
|
||||||
@ -2852,6 +2881,143 @@ def jobs(request):
|
|||||||
|
|
||||||
return render(request, "collections/joblog.html", context)
|
return render(request, "collections/joblog.html", context)
|
||||||
|
|
||||||
|
elif request.method == "POST":
|
||||||
|
job_name = request.POST.get("job-name")
|
||||||
|
|
||||||
|
if job_name == "engineer-pathway":
|
||||||
|
pathway_to_engineer = request.POST.get("pathway-to-engineer")
|
||||||
|
engineer_setting = request.POST.get("engineer-setting")
|
||||||
|
|
||||||
|
if not all([pathway_to_engineer, engineer_setting]):
|
||||||
|
raise BadRequest(
|
||||||
|
f"Unable to run {job_name} as it requires 'pathway-to-engineer' and 'engineer-setting' parameters."
|
||||||
|
)
|
||||||
|
|
||||||
|
pathway_package = PackageManager.get_package_by_url(current_user, pathway_to_engineer)
|
||||||
|
pathway_to_engineer = Pathway.objects.get(
|
||||||
|
url=pathway_to_engineer, package=pathway_package
|
||||||
|
)
|
||||||
|
|
||||||
|
engineer_setting = SettingManager.get_setting_by_url(current_user, engineer_setting)
|
||||||
|
|
||||||
|
target_package = PackageManager.create_package(
|
||||||
|
current_user,
|
||||||
|
f"Autogenerated Package for Pathway Engineering of {pathway_to_engineer.name}",
|
||||||
|
f"This Package was generated automatically for the engineering Task of {pathway_to_engineer.name}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
from .tasks import dispatch, engineer_pathways
|
||||||
|
|
||||||
|
res = dispatch(
|
||||||
|
current_user,
|
||||||
|
engineer_pathways,
|
||||||
|
[pathway_to_engineer.pk],
|
||||||
|
engineer_setting.pk,
|
||||||
|
target_package.pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(f"{s.SERVER_URL}/jobs/{res.task_id}")
|
||||||
|
|
||||||
|
elif job_name == "batch-predict":
|
||||||
|
substrates = request.POST.get("substrates")
|
||||||
|
prediction_setting_url = request.POST.get("prediction-setting")
|
||||||
|
num_tps = request.POST.get("num-tps")
|
||||||
|
|
||||||
|
if substrates is None or substrates.strip() == "":
|
||||||
|
raise BadRequest("No substrates provided.")
|
||||||
|
|
||||||
|
pred_data = []
|
||||||
|
for pair in substrates.split("\n"):
|
||||||
|
parts = pair.split(",")
|
||||||
|
|
||||||
|
try:
|
||||||
|
smiles = FormatConverter.standardize(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
raise BadRequest(f"Couldn't standardize SMILES {parts[0]}!")
|
||||||
|
|
||||||
|
# name is optional
|
||||||
|
name = parts[1] if len(parts) > 1 else None
|
||||||
|
pred_data.append([smiles, name])
|
||||||
|
|
||||||
|
max_tps = 50
|
||||||
|
if num_tps is not None and num_tps.strip() != "":
|
||||||
|
try:
|
||||||
|
num_tps = int(num_tps)
|
||||||
|
max_tps = max(min(num_tps, 50), 1)
|
||||||
|
except ValueError:
|
||||||
|
raise BadRequest(f"Parameter for num-tps {num_tps} is not a valid integer.")
|
||||||
|
|
||||||
|
batch_predict_setting = SettingManager.get_setting_by_url(
|
||||||
|
current_user, prediction_setting_url
|
||||||
|
)
|
||||||
|
|
||||||
|
target_package = PackageManager.create_package(
|
||||||
|
current_user,
|
||||||
|
f"Autogenerated Package for Batch Prediction {datetime.now()}",
|
||||||
|
"This Package was generated automatically for the batch prediction task.",
|
||||||
|
)
|
||||||
|
|
||||||
|
from .tasks import dispatch, batch_predict
|
||||||
|
|
||||||
|
res = dispatch(
|
||||||
|
current_user,
|
||||||
|
batch_predict,
|
||||||
|
pred_data,
|
||||||
|
batch_predict_setting.pk,
|
||||||
|
target_package.pk,
|
||||||
|
num_tps=max_tps,
|
||||||
|
)
|
||||||
|
|
||||||
|
return redirect(f"{s.SERVER_URL}/jobs/{res.task_id}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise BadRequest(f"Job {job_name} is not supported!")
|
||||||
|
else:
|
||||||
|
return HttpResponseNotAllowed(["GET", "POST"])
|
||||||
|
|
||||||
|
|
||||||
|
def job(request, job_uuid):
|
||||||
|
current_user = _anonymous_or_real(request)
|
||||||
|
context = get_base_context(request)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
if current_user.is_superuser:
|
||||||
|
job = JobLog.objects.get(task_id=job_uuid)
|
||||||
|
else:
|
||||||
|
job = JobLog.objects.get(task_id=job_uuid, user=current_user)
|
||||||
|
|
||||||
|
# No op if status is already in a terminal state
|
||||||
|
job.check_for_update()
|
||||||
|
|
||||||
|
if request.GET.get("download", False) == "true":
|
||||||
|
if not job.is_result_downloadable():
|
||||||
|
raise BadRequest("Result is not downloadable!")
|
||||||
|
|
||||||
|
if job.job_name == "batch_predict":
|
||||||
|
filename = f"{job.job_name.replace(' ', '_')}_{job.task_id}.csv"
|
||||||
|
else:
|
||||||
|
raise BadRequest("Result is not downloadable!")
|
||||||
|
|
||||||
|
res_str = job.task_result
|
||||||
|
response = HttpResponse(res_str, content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
context["object_type"] = "joblog"
|
||||||
|
context["breadcrumbs"] = [
|
||||||
|
{"Home": s.SERVER_URL},
|
||||||
|
{"Jobs": s.SERVER_URL + "/jobs"},
|
||||||
|
{job.job_name: f"{s.SERVER_URL}/jobs/{job.task_id}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
context["job"] = job
|
||||||
|
|
||||||
|
return render(request, "objects/joblog.html", context)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return HttpResponseNotAllowed(["GET"])
|
||||||
|
|
||||||
|
|
||||||
###########
|
###########
|
||||||
# KETCHER #
|
# KETCHER #
|
||||||
|
|||||||
Binary file not shown.
@ -1,24 +1,21 @@
|
|||||||
import gzip
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os.path
|
import os.path
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from django.conf import settings as s
|
from django.conf import settings as s
|
||||||
from django.http import HttpResponseNotAllowed
|
from django.http import HttpResponseNotAllowed
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
from epdb.logic import PackageManager
|
|
||||||
from epdb.models import Rule, SimpleAmbitRule, Package, CompoundStructure
|
|
||||||
from epdb.views import get_base_context, _anonymous_or_real
|
|
||||||
from utilities.chem import FormatConverter
|
|
||||||
|
|
||||||
|
|
||||||
from rdkit import Chem
|
from rdkit import Chem
|
||||||
from rdkit.Chem.MolStandardize import rdMolStandardize
|
from rdkit.Chem.MolStandardize import rdMolStandardize
|
||||||
|
|
||||||
|
from epdb.models import CompoundStructure, Rule, SimpleAmbitRule
|
||||||
|
from epdb.views import get_base_context
|
||||||
|
from utilities.chem import FormatConverter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
Package = s.GET_PACKAGE_MODEL()
|
||||||
|
|
||||||
|
|
||||||
def normalize_smiles(smiles):
|
def normalize_smiles(smiles):
|
||||||
m1 = Chem.MolFromSmiles(smiles)
|
m1 = Chem.MolFromSmiles(smiles)
|
||||||
@ -59,9 +56,7 @@ def run_both_engines(SMILES, SMIRKS):
|
|||||||
set(
|
set(
|
||||||
[
|
[
|
||||||
normalize_smiles(str(x))
|
normalize_smiles(str(x))
|
||||||
for x in FormatConverter.sanitize_smiles(
|
for x in FormatConverter.sanitize_smiles([str(s) for s in all_rdkit_prods])[0]
|
||||||
[str(s) for s in all_rdkit_prods]
|
|
||||||
)[0]
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -85,8 +80,7 @@ def migration(request):
|
|||||||
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
|
url="http://localhost:8000/package/32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"
|
||||||
)
|
)
|
||||||
ALL_SMILES = [
|
ALL_SMILES = [
|
||||||
cs.smiles
|
cs.smiles for cs in CompoundStructure.objects.filter(compound__package=BBD)
|
||||||
for cs in CompoundStructure.objects.filter(compound__package=BBD)
|
|
||||||
]
|
]
|
||||||
RULES = SimpleAmbitRule.objects.filter(package=BBD)
|
RULES = SimpleAmbitRule.objects.filter(package=BBD)
|
||||||
|
|
||||||
@ -142,9 +136,7 @@ def migration(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for r in migration_status["results"]:
|
for r in migration_status["results"]:
|
||||||
r["detail_url"] = r["detail_url"].replace(
|
r["detail_url"] = r["detail_url"].replace("http://localhost:8000", s.SERVER_URL)
|
||||||
"http://localhost:8000", s.SERVER_URL
|
|
||||||
)
|
|
||||||
|
|
||||||
context.update(**migration_status)
|
context.update(**migration_status)
|
||||||
|
|
||||||
@ -152,8 +144,6 @@ def migration(request):
|
|||||||
|
|
||||||
|
|
||||||
def migration_detail(request, package_uuid, rule_uuid):
|
def migration_detail(request, package_uuid, rule_uuid):
|
||||||
current_user = _anonymous_or_real(request)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
context = get_base_context(request)
|
context = get_base_context(request)
|
||||||
|
|
||||||
@ -235,9 +225,7 @@ def compare(request):
|
|||||||
context["smirks"] = (
|
context["smirks"] = (
|
||||||
"[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O"
|
"[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O"
|
||||||
)
|
)
|
||||||
context["smiles"] = (
|
context["smiles"] = "C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
|
||||||
"C(CC(=O)N[C@@H](CS[Se-])C(=O)NCC(=O)[O-])[C@@H](C(=O)[O-])N"
|
|
||||||
)
|
|
||||||
return render(request, "compare.html", context)
|
return render(request, "compare.html", context)
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
|
|||||||
@ -9,7 +9,8 @@ dependencies = [
|
|||||||
"django>=5.2.1",
|
"django>=5.2.1",
|
||||||
"django-extensions>=4.1",
|
"django-extensions>=4.1",
|
||||||
"django-model-utils>=5.0.0",
|
"django-model-utils>=5.0.0",
|
||||||
"django-ninja>=1.4.1",
|
"django-ninja>=1.4.5",
|
||||||
|
"django-ninja-extra>=0.30.6",
|
||||||
"django-oauth-toolkit>=3.0.1",
|
"django-oauth-toolkit>=3.0.1",
|
||||||
"django-polymorphic>=4.1.0",
|
"django-polymorphic>=4.1.0",
|
||||||
"enviformer",
|
"enviformer",
|
||||||
@ -34,7 +35,7 @@ dependencies = [
|
|||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" }
|
enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" }
|
||||||
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
|
envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" }
|
||||||
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.7"}
|
envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.7" }
|
||||||
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
|
envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" }
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@ -45,6 +46,9 @@ dev = [
|
|||||||
"poethepoet>=0.37.0",
|
"poethepoet>=0.37.0",
|
||||||
"pre-commit>=4.3.0",
|
"pre-commit>=4.3.0",
|
||||||
"ruff>=0.13.3",
|
"ruff>=0.13.3",
|
||||||
|
"pytest-playwright>=0.7.1",
|
||||||
|
"pytest-django>=4.11.1",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
@ -66,47 +70,31 @@ docstring-code-format = true
|
|||||||
|
|
||||||
[tool.poe.tasks]
|
[tool.poe.tasks]
|
||||||
# Main tasks
|
# Main tasks
|
||||||
setup = { sequence = ["db-up", "migrate", "bootstrap"], help = "Complete setup: start database, run migrations, and bootstrap data" }
|
setup = { sequence = [
|
||||||
dev = { shell = """
|
"db-up",
|
||||||
# Start pnpm CSS watcher in background
|
"migrate",
|
||||||
pnpm run dev &
|
"bootstrap",
|
||||||
PNPM_PID=$!
|
], help = "Complete setup: start database, run migrations, and bootstrap data" }
|
||||||
echo "Started CSS watcher (PID: $PNPM_PID)"
|
dev = { cmd = "uv run python scripts/dev_server.py", help = "Start the development server with CSS watcher", deps = [
|
||||||
|
"db-up",
|
||||||
# Cleanup function
|
"js-deps",
|
||||||
cleanup() {
|
] }
|
||||||
echo "\nShutting down..."
|
build = { sequence = [
|
||||||
if kill -0 $PNPM_PID 2>/dev/null; then
|
"build-frontend",
|
||||||
kill $PNPM_PID
|
"collectstatic",
|
||||||
echo "✓ CSS watcher stopped"
|
], help = "Build frontend assets and collect static files" }
|
||||||
fi
|
|
||||||
if [ ! -z "${DJ_PID:-}" ] && kill -0 $DJ_PID 2>/dev/null; then
|
|
||||||
kill $DJ_PID
|
|
||||||
echo "✓ Django server stopped"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set trap for cleanup
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
# Start Django dev server in background
|
|
||||||
uv run python manage.py runserver &
|
|
||||||
DJ_PID=$!
|
|
||||||
|
|
||||||
# Wait for Django to finish
|
|
||||||
wait $DJ_PID
|
|
||||||
""", help = "Start the development server with CSS watcher", deps = ["db-up", "js-deps"] }
|
|
||||||
build = { sequence = ["build-frontend", "collectstatic"], help = "Build frontend assets and collect static files" }
|
|
||||||
|
|
||||||
# Database tasks
|
# Database tasks
|
||||||
db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
|
db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" }
|
||||||
db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
|
db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" }
|
||||||
|
|
||||||
# Frontend tasks
|
# Frontend tasks
|
||||||
js-deps = { cmd = "pnpm install", help = "Install frontend dependencies" }
|
js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" }
|
||||||
|
|
||||||
# Full cleanup tasks
|
# Full cleanup tasks
|
||||||
clean = { sequence = ["clean-db"], help = "Remove model files and database volumes (WARNING: destroys all data!)" }
|
clean = { sequence = [
|
||||||
|
"clean-db",
|
||||||
|
], help = "Remove model files and database volumes (WARNING: destroys all data!)" }
|
||||||
clean-db = { cmd = "docker compose -f docker-compose.dev.yml down -v", help = "Removes the database container and volume." }
|
clean-db = { cmd = "docker compose -f docker-compose.dev.yml down -v", help = "Removes the database container and volume." }
|
||||||
|
|
||||||
# Django tasks
|
# Django tasks
|
||||||
@ -121,9 +109,36 @@ echo "Default admin credentials:"
|
|||||||
echo " Username: admin"
|
echo " Username: admin"
|
||||||
echo " Email: admin@envipath.com"
|
echo " Email: admin@envipath.com"
|
||||||
echo " Password: SuperSafe"
|
echo " Password: SuperSafe"
|
||||||
""", help = "Bootstrap initial data (anonymous user, packages, models)" }
|
""", help = "Bootstrap initial data (anonymous user, packages, models)" }
|
||||||
shell = { cmd = "uv run python manage.py shell", help = "Open Django shell" }
|
shell = { cmd = "uv run python manage.py shell", help = "Open Django shell" }
|
||||||
|
|
||||||
# Build tasks
|
|
||||||
build-frontend = { cmd = "pnpm run build", help = "Build frontend assets using pnpm", deps = ["js-deps"] }
|
build-frontend = { cmd = "uv run python scripts/pnpm_wrapper.py run build", help = "Build frontend assets using pnpm", deps = [
|
||||||
collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = ["build-frontend"] }
|
"js-deps",
|
||||||
|
] } # Build tasks
|
||||||
|
|
||||||
|
|
||||||
|
collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = [
|
||||||
|
"build-frontend",
|
||||||
|
] }
|
||||||
|
|
||||||
|
frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" }
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "--verbose --capture=no --durations=10"
|
||||||
|
testpaths = ["tests", "*/tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
norecursedirs = [
|
||||||
|
"env",
|
||||||
|
"venv",
|
||||||
|
"envipy-plugins",
|
||||||
|
"envipy-additional-information",
|
||||||
|
"envipy-ambit",
|
||||||
|
"enviformer",
|
||||||
|
]
|
||||||
|
markers = [
|
||||||
|
"api: API tests",
|
||||||
|
"frontend: Frontend tests",
|
||||||
|
"end2end: End-to-end tests",
|
||||||
|
"slow: Slow tests",
|
||||||
|
]
|
||||||
|
|||||||
201
scripts/dev_server.py
Executable file
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
|
* @returns {string} Cleaned excerpt
|
||||||
*/
|
*/
|
||||||
extractExcerpt(excerpt) {
|
extractExcerpt(excerpt) {
|
||||||
if (!excerpt) return 'Click to read more';
|
if (!excerpt) return 'No preview available yet';
|
||||||
|
|
||||||
// Remove HTML tags and clean up; collapse whitespace; do not add manual ellipsis
|
// Remove HTML tags and clean up; collapse whitespace; do not add manual ellipsis
|
||||||
return excerpt
|
const cleaned = excerpt
|
||||||
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||||||
.replace(/ /g, ' ') // Replace with spaces
|
.replace(/ /g, ' ') // Replace with spaces
|
||||||
.replace(/&/g, '&') // Replace & with &
|
.replace(/&/g, '&') // Replace & with &
|
||||||
.replace(/</g, '<') // Replace < with <
|
.replace(/</g, '<') // Replace < with <
|
||||||
.replace(/>/g, '>') // Replace > with >
|
.replace(/>/g, '>') // Replace > with >
|
||||||
.replace(/\s+/g, ' ') // Collapse all whitespace/newlines
|
.replace(/\s+/g, ' ') // Collapse all whitespace/newlines
|
||||||
.trim()
|
.trim();
|
||||||
|
|
||||||
|
// Check if excerpt is empty after cleaning
|
||||||
|
return cleaned || 'No preview available yet';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
1297
static/js/pps.js
1297
static/js/pps.js
File diff suppressed because it is too large
Load Diff
247
static/js/pw.js
247
static/js/pw.js
@ -1,15 +1,22 @@
|
|||||||
console.log("loaded pw.js")
|
console.log("loaded pw.js")
|
||||||
|
|
||||||
function predictFromNode(url) {
|
function predictFromNode(url) {
|
||||||
$.post("", {node: url})
|
fetch("", {
|
||||||
.done(function (data) {
|
method: "POST",
|
||||||
console.log("Success:", data);
|
headers: {
|
||||||
window.location.href = data.success;
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
})
|
"X-CSRFToken": document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
|
||||||
.fail(function (xhr, status, error) {
|
},
|
||||||
console.error("Error:", xhr.status, xhr.responseText);
|
body: new URLSearchParams({node: url})
|
||||||
// show user-friendly message or log error
|
})
|
||||||
});
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log("Success:", data);
|
||||||
|
window.location.href = data.success;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Error:", error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// data = {{ pathway.d3_json | safe }};
|
// data = {{ pathway.d3_json | safe }};
|
||||||
@ -103,6 +110,9 @@ function draw(pathway, elem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dragstarted(event, d) {
|
function dragstarted(event, d) {
|
||||||
|
// Prevent zoom pan when dragging nodes
|
||||||
|
event.sourceEvent.stopPropagation();
|
||||||
|
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
d.fx = d.x;
|
d.fx = d.x;
|
||||||
d.fy = d.y;
|
d.fy = d.y;
|
||||||
@ -117,6 +127,9 @@ function draw(pathway, elem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dragged(event, d) {
|
function dragged(event, d) {
|
||||||
|
// Prevent zoom pan when dragging nodes
|
||||||
|
event.sourceEvent.stopPropagation();
|
||||||
|
|
||||||
d.fx = event.x;
|
d.fx = event.x;
|
||||||
d.fy = event.y;
|
d.fy = event.y;
|
||||||
|
|
||||||
@ -127,6 +140,9 @@ function draw(pathway, elem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dragended(event, d) {
|
function dragended(event, d) {
|
||||||
|
// Prevent zoom pan when dragging nodes
|
||||||
|
event.sourceEvent.stopPropagation();
|
||||||
|
|
||||||
if (!event.active) simulation.alphaTarget(0);
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
|
|
||||||
// Mark that dragging has ended
|
// Mark that dragging has ended
|
||||||
@ -192,58 +208,163 @@ function draw(pathway, elem) {
|
|||||||
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
|
d3.select(t).select("circle").classed("highlighted", !d3.select(t).select("circle").classed("highlighted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait one second before showing popup
|
// Wait before showing popup (ms)
|
||||||
var popupWaitBeforeShow = 1000;
|
var popupWaitBeforeShow = 1000;
|
||||||
// Keep Popup at least for one second
|
|
||||||
var popushowAtLeast = 1000;
|
|
||||||
|
|
||||||
function pop_show_e(element) {
|
// Custom popover element
|
||||||
var e = element;
|
let popoverTimeout = null;
|
||||||
setTimeout(function () {
|
|
||||||
if ($(e).is(':hover')) { // if element is still hovered
|
|
||||||
$(e).popover("show");
|
|
||||||
|
|
||||||
// workaround to set fixed positions
|
function createPopover() {
|
||||||
pop = $(e).attr("aria-describedby")
|
const popover = document.createElement('div');
|
||||||
h = $('#' + pop).height();
|
popover.id = 'custom-popover';
|
||||||
$('#' + pop).attr("style", `position: fixed; top: ${clientY - (h / 2.0)}px; left: ${clientX + 10}px; margin: 0px; max-width: 1000px; display: block;`)
|
popover.className = 'fixed z-50';
|
||||||
setTimeout(function () {
|
popover.style.cssText = `
|
||||||
var close = setInterval(function () {
|
background: #ffffff;
|
||||||
if (!$(".popover:hover").length // mouse outside popover
|
border: 1px solid #d1d5db;
|
||||||
&& !$(e).is(':hover')) { // mouse outside element
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
$(e).popover('hide');
|
max-width: 320px;
|
||||||
clearInterval(close);
|
padding: 0.75rem;
|
||||||
}
|
border-radius: 0.5rem;
|
||||||
}, 100);
|
opacity: 0;
|
||||||
}, popushowAtLeast);
|
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;
|
||||||
}
|
}
|
||||||
}, popupWaitBeforeShow);
|
#custom-popover a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#custom-popover a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
if (!document.getElementById('popover-styles')) {
|
||||||
|
style.id = 'popover-styles';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep popover open when hovering over it
|
||||||
|
popover.addEventListener('mouseenter', () => {
|
||||||
|
if (popoverTimeout) {
|
||||||
|
clearTimeout(popoverTimeout);
|
||||||
|
popoverTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
popover.addEventListener('mouseleave', () => {
|
||||||
|
hidePopover();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPopover() {
|
||||||
|
return document.getElementById('custom-popover') || createPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPopover(element, title, content) {
|
||||||
|
const popover = getPopover();
|
||||||
|
popover.querySelector('.popover-title').textContent = title;
|
||||||
|
popover.querySelector('.popover-content').innerHTML = content;
|
||||||
|
|
||||||
|
// Make visible to measure
|
||||||
|
popover.style.visibility = 'hidden';
|
||||||
|
popover.style.opacity = '0';
|
||||||
|
popover.style.display = 'block';
|
||||||
|
|
||||||
|
// Smart positioning - avoid viewport overflow
|
||||||
|
const padding = 10;
|
||||||
|
const popoverRect = popover.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = clientX + 15;
|
||||||
|
let top = clientY - (popoverRect.height / 2);
|
||||||
|
|
||||||
|
// Prevent right overflow
|
||||||
|
if (left + popoverRect.width > viewportWidth - padding) {
|
||||||
|
left = clientX - popoverRect.width - 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent bottom overflow
|
||||||
|
if (top + popoverRect.height > viewportHeight - padding) {
|
||||||
|
top = viewportHeight - popoverRect.height - padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent top overflow
|
||||||
|
if (top < padding) {
|
||||||
|
top = padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
popover.style.top = `${top}px`;
|
||||||
|
popover.style.left = `${left}px`;
|
||||||
|
popover.style.visibility = 'visible';
|
||||||
|
popover.style.opacity = '1';
|
||||||
|
|
||||||
|
currentElement = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePopover() {
|
||||||
|
const popover = getPopover();
|
||||||
|
popover.style.opacity = '0';
|
||||||
|
popover.style.visibility = 'hidden';
|
||||||
|
currentElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pop_add(objects, title, contentFunction) {
|
function pop_add(objects, title, contentFunction) {
|
||||||
objects.attr("id", "pop")
|
objects.each(function (d) {
|
||||||
.attr("data-container", "body")
|
const element = this;
|
||||||
.attr("data-toggle", "popover")
|
|
||||||
.attr("data-placement", "right")
|
|
||||||
.attr("title", title);
|
|
||||||
|
|
||||||
objects.each(function (d, i) {
|
element.addEventListener('mouseenter', () => {
|
||||||
options = {trigger: "manual", html: true, animation: false};
|
if (popoverTimeout) clearTimeout(popoverTimeout);
|
||||||
this_ = this;
|
|
||||||
var p = $(this).popover(options).on("mouseenter", function () {
|
popoverTimeout = setTimeout(() => {
|
||||||
pop_show_e(this);
|
if (element.matches(':hover')) {
|
||||||
|
const content = contentFunction(d);
|
||||||
|
showPopover(element, title, content);
|
||||||
|
}
|
||||||
|
}, popupWaitBeforeShow);
|
||||||
});
|
});
|
||||||
p.on("show.bs.popover", function (e) {
|
|
||||||
// this is to dynamically ajdust the content and bounds of the popup
|
element.addEventListener('mouseleave', () => {
|
||||||
p.attr('data-content', contentFunction(d));
|
if (popoverTimeout) {
|
||||||
p.data("bs.popover").setContent();
|
clearTimeout(popoverTimeout);
|
||||||
p.data("bs.popover").tip().css({"max-width": "1000px"});
|
popoverTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay hide to allow moving to popover
|
||||||
|
setTimeout(() => {
|
||||||
|
const popover = getPopover();
|
||||||
|
if (!popover.matches(':hover') && !element.matches(':hover')) {
|
||||||
|
hidePopover();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function node_popup(n) {
|
function node_popup(n) {
|
||||||
popupContent = "<a href='" + n.url + "'>" + n.name + "</a><br>";
|
popupContent = "";
|
||||||
|
if (n.stereo_removed) {
|
||||||
|
popupContent += "<span class='alert alert-warning alert-soft'>Removed stereochemistry for prediction</span>";
|
||||||
|
}
|
||||||
|
popupContent += "<a href='" + n.url + "'>" + n.name + "</a><br>";
|
||||||
popupContent += "Depth " + n.depth + "<br>"
|
popupContent += "Depth " + n.depth + "<br>"
|
||||||
|
|
||||||
if (appDomainViewEnabled) {
|
if (appDomainViewEnabled) {
|
||||||
@ -255,7 +376,7 @@ function draw(pathway, elem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
popupContent += "<img src='" + n.image + "' width='" + 20 * nodeRadius + "'><br>"
|
popupContent += "<img src='" + n.image + "'><br>"
|
||||||
if (n.scenarios.length > 0) {
|
if (n.scenarios.length > 0) {
|
||||||
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
popupContent += '<b>Half-lives and related scenarios:</b><br>'
|
||||||
for (var s of n.scenarios) {
|
for (var s of n.scenarios) {
|
||||||
@ -265,7 +386,7 @@ function draw(pathway, elem) {
|
|||||||
|
|
||||||
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
|
var isLeaf = pathway.links.filter(obj => obj.source.id === n.id).length === 0;
|
||||||
if (pathway.isIncremental && isLeaf) {
|
if (pathway.isIncremental && isLeaf) {
|
||||||
popupContent += '<br><a class="btn btn-primary" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
|
popupContent += '<br><a class="btn btn-primary btn-sm mt-2" onclick="predictFromNode(\'' + n.url + '\')" href="#">Predict from here</a><br>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return popupContent;
|
return popupContent;
|
||||||
@ -285,7 +406,7 @@ function draw(pathway, elem) {
|
|||||||
popupContent += adcontent;
|
popupContent += adcontent;
|
||||||
}
|
}
|
||||||
|
|
||||||
popupContent += "<img src='" + e.image + "' width='" + 20 * nodeRadius + "'><br>"
|
popupContent += "<img src='" + e.image + "'><br>"
|
||||||
if (e.reaction_probability) {
|
if (e.reaction_probability) {
|
||||||
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
|
popupContent += '<b>Probability:</b><br>' + e.reaction_probability.toFixed(3) + '<br>';
|
||||||
}
|
}
|
||||||
@ -308,6 +429,23 @@ function draw(pathway, elem) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const zoomable = d3.select("#zoomable");
|
const zoomable = d3.select("#zoomable");
|
||||||
|
const svg = d3.select("#pwsvg");
|
||||||
|
const container = d3.select("#vizdiv");
|
||||||
|
|
||||||
|
// Set explicit SVG dimensions for proper zoom behavior
|
||||||
|
svg.attr("width", width)
|
||||||
|
.attr("height", height);
|
||||||
|
|
||||||
|
// Add background rectangle FIRST to enable pan/zoom on empty space
|
||||||
|
// This must be inserted before zoomable group so it's behind everything
|
||||||
|
svg.insert("rect", "#zoomable")
|
||||||
|
.attr("x", 0)
|
||||||
|
.attr("y", 0)
|
||||||
|
.attr("width", width)
|
||||||
|
.attr("height", height)
|
||||||
|
.attr("fill", "transparent")
|
||||||
|
.attr("pointer-events", "all")
|
||||||
|
.style("cursor", "grab");
|
||||||
|
|
||||||
// Zoom Funktion aktivieren
|
// Zoom Funktion aktivieren
|
||||||
const zoom = d3.zoom()
|
const zoom = d3.zoom()
|
||||||
@ -316,7 +454,12 @@ function draw(pathway, elem) {
|
|||||||
zoomable.attr("transform", event.transform);
|
zoomable.attr("transform", event.transform);
|
||||||
});
|
});
|
||||||
|
|
||||||
d3.select("svg").call(zoom);
|
// Apply zoom to the SVG element - this enables wheel zoom
|
||||||
|
svg.call(zoom);
|
||||||
|
|
||||||
|
// Also apply zoom to container to catch events that might not reach SVG
|
||||||
|
// This ensures drag-to-pan works even when clicking on empty space
|
||||||
|
container.call(zoom);
|
||||||
|
|
||||||
nodes = pathway['nodes'];
|
nodes = pathway['nodes'];
|
||||||
links = pathway['links'];
|
links = pathway['links'];
|
||||||
@ -381,7 +524,7 @@ function draw(pathway, elem) {
|
|||||||
node.append("circle")
|
node.append("circle")
|
||||||
// make radius "invisible" for pseudo nodes
|
// make radius "invisible" for pseudo nodes
|
||||||
.attr("r", d => d.pseudo ? 0.01 : nodeRadius)
|
.attr("r", d => d.pseudo ? 0.01 : nodeRadius)
|
||||||
.style("fill", "#e8e8e8");
|
.style("fill", d => d.is_engineered_intermediate ? "#42eff5" : "#e8e8e8");
|
||||||
|
|
||||||
// Add image only for non pseudo nodes
|
// Add image only for non pseudo nodes
|
||||||
node.filter(d => !d.pseudo).each(function (d, i) {
|
node.filter(d => !d.pseudo).each(function (d, i) {
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
{% if meta.can_edit %}
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_compound_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Compound</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{% if meta.can_edit %}
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_compound_structure_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Compound Structure</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
@ -1,6 +1,10 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#new_edge_modal">
|
<a
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Edge</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('new_edge_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<span class="glyphicon glyphicon-plus"></span> New Edge</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#new_group_modal">
|
<a
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Group</a>
|
role="button"
|
||||||
|
onclick="document.getElementById('new_group_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<span class="glyphicon glyphicon-plus"></span> New Group</a
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
{% if meta.can_edit and meta.enabled_features.MODEL_BUILDING %}
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_model_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Model</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
@ -1,6 +1,10 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#new_node_modal">
|
<a
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Node</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('new_node_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<span class="glyphicon glyphicon-plus"></span> New Node</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_package_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Package</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#import_package_modal">
|
|
||||||
<span class="glyphicon glyphicon-import"></span> Import Package from JSON</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#import_legacy_package_modal">
|
|
||||||
<span class="glyphicon glyphicon-import"></span> Import Package from legacy JSON</a>
|
|
||||||
</li>
|
|
||||||
@ -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,6 +0,0 @@
|
|||||||
{% if meta.can_edit %}
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_reaction_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Reaction</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{% if meta.can_edit %}
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_rule_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Rule</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{% if meta.can_edit %}
|
|
||||||
<li>
|
|
||||||
<a role="button" data-toggle="modal" data-target="#new_scenario_modal">
|
|
||||||
<span class="glyphicon glyphicon-plus"></span> New Scenario</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
@ -1,6 +1,10 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#new_setting_modal">
|
<a
|
||||||
<span class="glyphicon glyphicon-plus"></span>New Setting</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('new_setting_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<span class="glyphicon glyphicon-plus"></span>New Setting</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,32 +1,60 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_compound_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Compound</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_compound_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
<i class="glyphicon glyphicon-edit"></i> Edit Compound</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#add_structure_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Add Structure</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_set_external_reference_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('add_structure_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Add Structure</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<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"
|
||||||
|
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,22 +1,42 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_compound_structure_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Compound Structure</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_compound_structure_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
<i class="glyphicon glyphicon-edit"></i> Edit Compound Structure</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_set_external_reference_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound Structure</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Compound Structure</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,14 +1,26 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Edge</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Edge</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_group_member_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Add/Remove Member</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_group_member_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<i class="glyphicon glyphicon-trash"></i> Add/Remove Member</a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Group</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Group</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
10
templates/actions/objects/joblog.html
Normal file
10
templates/actions/objects/joblog.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{% if job.is_result_downloadable %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('download_job_result_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-floppy-save"></i> Download Result</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
@ -1,18 +1,38 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
|
<li>
|
||||||
|
<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>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_model_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Model</a>
|
role="button"
|
||||||
|
onclick="document.getElementById('evaluate_model_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if model.model_status == 'BUILT_NOT_EVALUATED' or model.model_status == 'FINISHED' %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#evaluate_model_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-ok"></i> Evaluate Model</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('retrain_model_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#retrain_model_modal">
|
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a
|
||||||
<i class="glyphicon glyphicon-repeat"></i> Retrain Model</a>
|
>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Model</a>
|
|
||||||
</li>
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Model</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,18 +1,34 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_node_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Node</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_node_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
<i class="glyphicon glyphicon-edit"></i> Edit Node</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Node</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<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"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Node</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,26 +1,50 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_package_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Package</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_package_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_package_permissions_modal">
|
<i class="glyphicon glyphicon-edit"></i> Edit Package</a
|
||||||
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#publish_package_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_package_permissions_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#export_package_modal">
|
<i class="glyphicon glyphicon-user"></i> Edit Permissions</a
|
||||||
<i class="glyphicon glyphicon-bullhorn"></i> Export Package as JSON</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_license_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-duplicate"></i> License</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('publish_package_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<i class="glyphicon glyphicon-bullhorn"></i> Publish Package</a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Package</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<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"
|
||||||
|
onclick="document.getElementById('set_license_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-duplicate"></i> License</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Package</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,59 +1,112 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#add_pathway_node_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Add Compound</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('add_pathway_node_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#add_pathway_edge_modal">
|
<i class="glyphicon glyphicon-plus"></i> Add Compound</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Add Reaction</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li role="separator" class="divider"></li>
|
<li>
|
||||||
|
<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 %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#download_pathway_csv_modal">
|
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||||
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as CSV</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#download_pathway_image_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('download_pathway_csv_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as CSV</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('download_pathway_image_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-floppy-save"></i> Download Pathway as Image</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
onclick="document.getElementById('engineer_pathway_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-cog"></i> Engineer Pathway</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#identify_missing_rules_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-question-sign"></i> Identify Missing Rules</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('identify_missing_rules_modal').showModal(); return false;"
|
||||||
<li role="separator" class="divider"></li>
|
>
|
||||||
<li>
|
<i class="glyphicon glyphicon-question-sign"></i> Identify Missing
|
||||||
<a class="button" data-toggle="modal" data-target="#edit_pathway_modal">
|
Rules</a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li role="separator" class="divider"></li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<li>
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
<a
|
||||||
</li>
|
class="button"
|
||||||
<li>
|
onclick="document.getElementById('edit_pathway_modal').showModal(); return false;"
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
>
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
<i class="glyphicon glyphicon-edit"></i> Edit Pathway</a
|
||||||
</li>
|
>
|
||||||
{# <li>#}
|
</li>
|
||||||
{# <a class="button" data-toggle="modal" data-target="#add_pathway_edge_modal">#}
|
<li>
|
||||||
{# <i class="glyphicon glyphicon-plus"></i> Calculate Compound Properties</a>#}
|
<a
|
||||||
{# </li>#}
|
role="button"
|
||||||
<li role="separator" class="divider"></li>
|
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#delete_pathway_node_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#delete_pathway_edge_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Pathway</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li role="separator" class="divider"></li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('delete_pathway_node_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Compound</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('delete_pathway_edge_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Pathway</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,28 +1,52 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_reaction_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Reaction</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_reaction_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
<i class="glyphicon glyphicon-edit"></i> Edit Reaction</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_set_external_reference_modal">
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<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"
|
||||||
|
onclick="document.getElementById('generic_set_external_reference_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set External Reference</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Reaction</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,24 +1,44 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_rule_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Edit Rule</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_rule_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_aliases_modal">
|
<i class="glyphicon glyphicon-edit"></i> Edit Rule</a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#set_scenario_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('set_aliases_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set Aliases</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
role="button"
|
||||||
|
onclick="document.getElementById('set_scenario_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> Set Scenarios</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_copy_object_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-duplicate"></i> Copy</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_copy_object_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-duplicate"></i> Copy</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Rule</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Rule</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,14 +1,26 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#add_additional_information_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a>
|
class="button"
|
||||||
</li>
|
onclick="document.getElementById('add_additional_information_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a class="button" data-toggle="modal" data-target="#update_scenario_additional_information_modal">
|
<i class="glyphicon glyphicon-trash"></i> Add Additional Information</a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Set Additional Information</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Scenario</a>
|
class="button"
|
||||||
</li>
|
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"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Scenario</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,22 +1,38 @@
|
|||||||
{% if meta.can_edit %}
|
{% if meta.can_edit %}
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_user_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-edit"></i> Update</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_user_modal').showModal(); return false;"
|
||||||
<li>
|
>
|
||||||
<a role="button" data-toggle="modal" data-target="#edit_password_modal">
|
<i class="glyphicon glyphicon-edit"></i> Update</a
|
||||||
<i class="glyphicon glyphicon-lock"></i> Update Password</a>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#new_prediction_setting_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-plus"></i> New Prediction Setting</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('edit_password_modal').showModal(); return false;"
|
||||||
{# <li>#}
|
>
|
||||||
{# <a role="button" data-toggle="modal" data-target="#manage_api_token_modal">#}
|
<i class="glyphicon glyphicon-lock"></i> Update Password</a
|
||||||
{# <i class="glyphicon glyphicon-console"></i> Manage API Token</a>#}
|
>
|
||||||
{# </li>#}
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a role="button" data-toggle="modal" data-target="#generic_delete_modal">
|
<a
|
||||||
<i class="glyphicon glyphicon-trash"></i> Delete Account</a>
|
role="button"
|
||||||
</li>
|
onclick="document.getElementById('new_prediction_setting_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-plus"></i> New Prediction Setting</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{# <li>#}
|
||||||
|
{# <a role="button" data-toggle="modal" data-target="#manage_api_token_modal">#}
|
||||||
|
{# <i class="glyphicon glyphicon-console"></i> Manage API Token</a>#}
|
||||||
|
{# </li>#}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="button"
|
||||||
|
onclick="document.getElementById('generic_delete_modal').showModal(); return false;"
|
||||||
|
>
|
||||||
|
<i class="glyphicon glyphicon-trash"></i> Delete Account</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -1,12 +1,17 @@
|
|||||||
{% extends "framework.html" %}
|
{% extends "framework.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div id="searchContent">
|
||||||
<div id=searchContent>
|
|
||||||
<form id="admin-form" action="{{ SERVER_BASE }}/admin" method="post">
|
<form id="admin-form" action="{{ SERVER_BASE }}/admin" method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="textarea">Query</label>
|
<label for="textarea">Query</label>
|
||||||
<textarea id="textarea" class="form-control" rows="10" placeholder="Paste query here" required>
|
<textarea
|
||||||
|
id="textarea"
|
||||||
|
class="form-control"
|
||||||
|
rows="10"
|
||||||
|
placeholder="Paste query here"
|
||||||
|
required
|
||||||
|
>
|
||||||
PREFIX pps: <http://localhost:8080/vocabulary#>
|
PREFIX pps: <http://localhost:8080/vocabulary#>
|
||||||
SELECT ?name (count(?objId) as ?xcnt)
|
SELECT ?name (count(?objId) as ?xcnt)
|
||||||
WHERE {
|
WHERE {
|
||||||
@ -15,32 +20,29 @@ WHERE {
|
|||||||
?packageId pps:reviewStatus 'reviewed' .
|
?packageId pps:reviewStatus 'reviewed' .
|
||||||
?packageId pps:pathway ?objId .
|
?packageId pps:pathway ?objId .
|
||||||
} GROUP BY ?name
|
} GROUP BY ?name
|
||||||
</textarea>
|
</textarea
|
||||||
</div>
|
>
|
||||||
<button id="submit" type="button" class="btn btn-primary">Submit</button>
|
</div>
|
||||||
|
<button id="submit" type="button" class="btn btn-primary">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
<p></p>
|
<p></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="results">
|
<div id="results"></div>
|
||||||
</div>
|
<div id="loading"></div>
|
||||||
<div id="loading"></div>
|
<script>
|
||||||
</div>
|
$(function () {
|
||||||
<script>
|
$("#submit").on("click", function () {
|
||||||
$(function() {
|
|
||||||
$('#submit').on('click', function() {
|
|
||||||
|
|
||||||
makeLoadingGif("#loading", "{% static '/images/wait.gif' %}");
|
makeLoadingGif("#loading", "{% static '/images/wait.gif' %}");
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"query": $("#textarea").val()
|
query: $("#textarea").val(),
|
||||||
}
|
};
|
||||||
|
|
||||||
$.post("{{ SERVER_BASE }}/expire", data, function(result) {
|
$.post("{{ SERVER_BASE }}/expire", data, function (result) {
|
||||||
$("#loading").empty();
|
$("#loading").empty();
|
||||||
queryResultToTable("results", result);
|
queryResultToTable("results", result);
|
||||||
})
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
</script>
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
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,70 +1,95 @@
|
|||||||
{% extends "framework.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load envipytags %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="space-y-2 p-4">
|
||||||
<div class="panel-group" id="reviewListAccordion">
|
<!-- Header Section -->
|
||||||
<div class="panel panel-default">
|
<div class="card bg-base-100">
|
||||||
<div class="panel-heading" id="headingPanel" style="font-size:2rem;height: 46px">
|
<div class="card-body">
|
||||||
Jobs
|
<h2 class="card-title text-2xl">User Prediction Jobs</h2>
|
||||||
</div>
|
<p class="mt-2">Job Logs Desc</p>
|
||||||
<div class="panel-body">
|
</div>
|
||||||
<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 collapse in">
|
|
||||||
<div class="panel-body list-group-item" id="job-content">
|
|
||||||
<table class="table table-bordered table-hover">
|
|
||||||
<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>
|
|
||||||
</tr>
|
|
||||||
<tbody>
|
|
||||||
{% for job in jobs %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ job.task_id }}</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>
|
|
||||||
{% elif job.task_result %}
|
|
||||||
<td>{{ job.task_result|slice:"40" }}...</td>
|
|
||||||
{% else %}
|
|
||||||
<td>Empty</td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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 }}</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name }}</a>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
{% 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 }}" class="link link-primary"
|
||||||
|
>Result</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
{% elif job.task_result %}
|
||||||
|
<td>{{ job.task_result|slice:"40" }}...</td>
|
||||||
|
{% else %}
|
||||||
|
<td>Empty</td>
|
||||||
|
{% endif %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if objects %}
|
||||||
|
<!-- Unreviewable objects such as User / Group / Setting -->
|
||||||
|
<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' %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ obj.url }}" class="hover:bg-base-300"
|
||||||
|
>{{ obj.username }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ obj.url }}" class="hover:bg-base-300"
|
||||||
|
>{{ obj.name }}</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% 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,319 +1,323 @@
|
|||||||
{% extends "framework.html" %}
|
{% extends "framework_modern.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if object_type != 'package' %}
|
{# Serialize objects data for Alpine pagination #}
|
||||||
<div>
|
{# prettier-ignore-start #}
|
||||||
<div id="load-all-error" style="display: none;">
|
{# FIXME: This is a hack to get the objects data into the JavaScript code. #}
|
||||||
<div class="alert alert-danger" role="alert">
|
{% if object_type != 'scenario' %}
|
||||||
<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
|
<script>
|
||||||
<span class="sr-only">Error:</span>
|
window.reviewedObjects = [
|
||||||
Getting objects failed!
|
{% for obj in reviewed_objects %}
|
||||||
</div>
|
{ "name": "{{ obj.name|escapejs }}", "url": "{{ obj.url }}" }{% if not forloop.last %},{% endif %}
|
||||||
</div>
|
{% endfor %}
|
||||||
|
];
|
||||||
|
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 #}
|
||||||
|
|
||||||
<input type="text" id="object-search" class="form-control" placeholder="Search by name"
|
<div class="px-8 py-4">
|
||||||
style="display: none;">
|
<input
|
||||||
<p></p>
|
type="text"
|
||||||
</div>
|
id="object-search"
|
||||||
|
class="input input-bordered hidden w-full max-w-xs"
|
||||||
|
placeholder="Search by name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block action_modals %}
|
||||||
|
{% if object_type == 'node' %}
|
||||||
|
{% include "modals/collections/new_node_modal.html" %}
|
||||||
|
{% elif object_type == 'edge' %}
|
||||||
|
{% include "modals/collections/new_edge_modal.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endblock action_modals %}
|
||||||
|
|
||||||
{% block action_modals %}
|
<div class="px-8 py-4">
|
||||||
{% if object_type == 'package' %}
|
<!-- Header Section -->
|
||||||
{% include "modals/collections/new_package_modal.html" %}
|
<div class="card bg-base-100">
|
||||||
{% include "modals/collections/import_package_modal.html" %}
|
<div class="card-body px-0 py-4">
|
||||||
{% include "modals/collections/import_legacy_package_modal.html" %}
|
<div class="flex items-center justify-between">
|
||||||
{% elif object_type == 'compound' %}
|
<h2 class="card-title text-2xl">
|
||||||
{% include "modals/collections/new_compound_modal.html" %}
|
{% if object_type == 'node' %}
|
||||||
{% elif object_type == 'rule' %}
|
Nodes
|
||||||
{% include "modals/collections/new_rule_modal.html" %}
|
|
||||||
{% elif object_type == 'reaction' %}
|
|
||||||
{% include "modals/collections/new_reaction_modal.html" %}
|
|
||||||
{% elif object_type == 'pathway' %}
|
|
||||||
{# {% include "modals/collections/new_pathway_modal.html" %} #}
|
|
||||||
{% elif object_type == 'node' %}
|
|
||||||
{% include "modals/collections/new_node_modal.html" %}
|
|
||||||
{% 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' %}
|
|
||||||
Nodes
|
|
||||||
{% elif object_type == 'edge' %}
|
{% elif object_type == 'edge' %}
|
||||||
Edges
|
Edges
|
||||||
{% elif object_type == 'scenario' %}
|
|
||||||
Scenarios
|
|
||||||
{% elif object_type == 'model' %}
|
|
||||||
Model
|
|
||||||
{% elif object_type == 'setting' %}
|
|
||||||
Settings
|
|
||||||
{% elif object_type == 'user' %}
|
|
||||||
Users
|
|
||||||
{% elif object_type == 'group' %}
|
|
||||||
Groups
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div id="actionsButton"
|
</h2>
|
||||||
style="float: right;font-weight: normal;font-size: medium;position: relative; top: 50%; transform: translateY(-50%);z-index:100;display: none;"
|
<div id="actionsButton" class="dropdown dropdown-end hidden">
|
||||||
class="dropdown"><a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button"
|
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
|
||||||
aria-haspopup="true" aria-expanded="false"><span
|
<svg
|
||||||
class="glyphicon glyphicon-wrench"></span> Actions <span class="caret"></span><span
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
style="padding-right:1em"></span></a>
|
width="16"
|
||||||
<ul id="actionsList" class="dropdown-menu">
|
height="16"
|
||||||
{% block actions %}
|
viewBox="0 0 24 24"
|
||||||
{% if object_type == 'package' %}
|
fill="none"
|
||||||
{% include "actions/collections/package.html" %}
|
stroke="currentColor"
|
||||||
{% elif object_type == 'compound' %}
|
stroke-width="2"
|
||||||
{% include "actions/collections/compound.html" %}
|
stroke-linecap="round"
|
||||||
{% elif object_type == 'structure' %}
|
stroke-linejoin="round"
|
||||||
{% include "actions/collections/compound_structure.html" %}
|
class="lucide lucide-wrench"
|
||||||
{% elif object_type == 'rule' %}
|
>
|
||||||
{% include "actions/collections/rule.html" %}
|
<path
|
||||||
{% elif object_type == 'reaction' %}
|
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"
|
||||||
{% include "actions/collections/reaction.html" %}
|
/>
|
||||||
{% elif object_type == 'setting' %}
|
</svg>
|
||||||
{% include "actions/collections/setting.html" %}
|
Actions
|
||||||
{% elif object_type == 'scenario' %}
|
|
||||||
{% include "actions/collections/scenario.html" %}
|
|
||||||
{% elif object_type == 'model' %}
|
|
||||||
{% include "actions/collections/model.html" %}
|
|
||||||
{% elif object_type == 'pathway' %}
|
|
||||||
{% include "actions/collections/pathway.html" %}
|
|
||||||
{% elif object_type == 'node' %}
|
|
||||||
{% include "actions/collections/node.html" %}
|
|
||||||
{% elif object_type == 'edge' %}
|
|
||||||
{% include "actions/collections/edge.html" %}
|
|
||||||
{% elif object_type == 'group' %}
|
|
||||||
{% include "actions/collections/group.html" %}
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul
|
||||||
|
tabindex="-1"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2"
|
||||||
|
>
|
||||||
|
{% block actions %}
|
||||||
|
{% if object_type == 'node' %}
|
||||||
|
{% include "actions/collections/node.html" %}
|
||||||
|
{% elif object_type == 'edge' %}
|
||||||
|
{% include "actions/collections/edge.html" %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="mt-2">
|
||||||
<!-- Set Text above links -->
|
{% if object_type == 'node' %}
|
||||||
{% if object_type == 'package' %}
|
<p>
|
||||||
<p>A package contains pathways, rules, etc. and can reflect specific experimental
|
Nodes represent the (predicted) compounds in a graph.
|
||||||
conditions. <a target="_blank" href="https://wiki.envipath.org/index.php/packages" role="button">Learn
|
<a
|
||||||
more >></a></p>
|
target="_blank"
|
||||||
{% elif object_type == 'compound' %}
|
href="https://wiki.envipath.org/index.php/nodes"
|
||||||
<p>A compound stores the structure of a molecule and can include meta-information.
|
class="link link-primary"
|
||||||
<a target="_blank" href="https://wiki.envipath.org/index.php/compounds" role="button">Learn more
|
>Learn more >></a
|
||||||
>></a></p>
|
>
|
||||||
{% elif object_type == 'structure' %}
|
</p>
|
||||||
<p>The structures stored in this compound
|
{% elif object_type == 'edge' %}
|
||||||
<a target="_blank" href="https://wiki.envipath.org/index.php/compounds" role="button">Learn more
|
<p>
|
||||||
>></a></p>
|
Edges represent the links between nodes in a graph.
|
||||||
{% elif object_type == 'rule' %}
|
<a
|
||||||
<p>A rule describes a biotransformation reaction template that is defined as SMIRKS.
|
target="_blank"
|
||||||
<a target="_blank" href="https://wiki.envipath.org/index.php/Rules" role="button">Learn more
|
href="https://wiki.envipath.org/index.php/edges"
|
||||||
>></a></p>
|
class="link link-primary"
|
||||||
{% elif object_type == 'reaction' %}
|
>Learn more >></a
|
||||||
<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
|
</p>
|
||||||
>></a></p>
|
{% endif %}
|
||||||
{% 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' %}
|
|
||||||
<p>Nodes represent the (predicted) compounds in a graph.
|
|
||||||
<a target="_blank" href="https://wiki.envipath.org/index.php/nodes" role="button">Learn more
|
|
||||||
>></a></p>
|
|
||||||
{% elif object_type == 'edge' %}
|
|
||||||
<p>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">Learn more
|
|
||||||
>></a></p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- If theres nothing to show extend the text above -->
|
{% if reviewed_objects and unreviewed_objects %}
|
||||||
{% if reviewed_objects and unreviewed_objects %}
|
|
||||||
{% if reviewed_objects|length == 0 and unreviewed_objects|length == 0 %}
|
{% if reviewed_objects|length == 0 and unreviewed_objects|length == 0 %}
|
||||||
<p>Nothing found. There are two possible reasons: <br><br>1. There is no content yet.<br>2. You have no
|
<p class="mt-4">
|
||||||
reading permissions.<br><br>Please be sure you have at least reading permissions.</p>
|
Nothing found. There are two possible reasons:<br /><br />
|
||||||
{% endif %}
|
1. There is no content yet.<br />
|
||||||
|
2. You have no reading permissions.<br /><br />
|
||||||
|
Please ensure you have at least reading permissions.
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if reviewed_objects %}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lists Container -->
|
||||||
|
<div class="w-full">
|
||||||
|
{% if reviewed_objects %}
|
||||||
{% if reviewed_objects|length > 0 %}
|
{% if reviewed_objects|length > 0 %}
|
||||||
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver">
|
<!-- Reviewed -->
|
||||||
<h4 class="panel-title">
|
<div
|
||||||
<a id="ReviewedLink" data-toggle="collapse" data-parent="#reviewListAccordion"
|
class="collapse-arrow bg-base-200 collapse order-2 w-full"
|
||||||
href="#Reviewed">Reviewed</a>
|
x-data="paginatedList(window.reviewedObjects || [], { isReviewed: true, instanceId: 'reviewed' })"
|
||||||
</h4>
|
>
|
||||||
</div>
|
<input type="checkbox" checked />
|
||||||
<div id="Reviewed" class="panel-collapse collapse in">
|
<div class="collapse-title text-xl font-medium">
|
||||||
<div class="panel-body list-group-item" id="ReviewedContent">
|
Reviewed
|
||||||
{% if object_type == 'package' %}
|
<span
|
||||||
{% for obj in reviewed_objects %}
|
class="badge badge-sm badge-neutral ml-2"
|
||||||
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}
|
x-text="totalItems"
|
||||||
<span class="glyphicon glyphicon-star" aria-hidden="true"
|
></span>
|
||||||
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> #}
|
|
||||||
<span class="glyphicon glyphicon-star" aria-hidden="true"
|
|
||||||
style="float:right" data-toggle="tooltip"
|
|
||||||
data-placement="top" title="" data-original-title="Reviewed">
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="collapse-content w-full">
|
||||||
{% endif %}
|
<ul class="menu bg-base-100 rounded-box w-full">
|
||||||
{% endif %}
|
<template x-for="obj in paginatedItems" :key="obj.url">
|
||||||
{% if unreviewed_objects %}
|
<li>
|
||||||
<div class="panel panel-default panel-heading list-group-item" style="background-color:silver"><h4
|
<a :href="obj.url" class="hover:bg-base-200">
|
||||||
class="panel-title"><a id="UnreviewedLink" data-toggle="collapse" data-parent="#unReviewListAccordion"
|
<span x-text="obj.name"></span>
|
||||||
href="#Unreviewed">Unreviewed</a></h4></div>
|
<span
|
||||||
<div id="Unreviewed" class="panel-collapse collapse {% if reviewed_objects|length == 0 or object_type == 'package' %}in{% endif %}">
|
class="tooltip tooltip-left ml-auto"
|
||||||
<div class="panel-body list-group-item" id="UnreviewedContent">
|
data-tip="Reviewed"
|
||||||
{% if object_type == 'package' %}
|
>
|
||||||
{% for obj in unreviewed_objects %}
|
<svg
|
||||||
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}</a>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{% endfor %}
|
width="16"
|
||||||
{% else %}
|
height="16"
|
||||||
{% for obj in unreviewed_objects|slice:":50" %}
|
viewBox="0 0 24 24"
|
||||||
<a class="list-group-item" href="{{ obj.url }}">{{ obj.name|safe }}</a>
|
fill="none"
|
||||||
{% endfor %}
|
stroke="currentColor"
|
||||||
{% endif %}
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-star"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
</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>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
<style>
|
</div>
|
||||||
.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 {
|
<script>
|
||||||
width: 100%;
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
height: auto;
|
// Show actions button if there are actions
|
||||||
}
|
const actionsButton = document.getElementById("actionsButton");
|
||||||
</style>
|
const actionsList = actionsButton?.querySelector("ul");
|
||||||
|
if (actionsList && actionsList.children.length > 0) {
|
||||||
|
actionsButton?.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
<div id="load-all-loading" class="spinner-widget" style="display: none">
|
// Show search input and connect to Alpine pagination
|
||||||
<img id="loading-gif" src="{% static '/images/wait.gif' %}" alt="Loading...">
|
const objectSearch = document.getElementById("object-search");
|
||||||
</div>
|
if (objectSearch) {
|
||||||
|
objectSearch.classList.remove("hidden");
|
||||||
</div>
|
objectSearch.addEventListener("input", function () {
|
||||||
<script>
|
const query = this.value;
|
||||||
$(function () {
|
// Dispatch search to all paginatedList components
|
||||||
|
document
|
||||||
$('#object-search').show();
|
.querySelectorAll('[x-data*="paginatedList"]')
|
||||||
|
.forEach((el) => {
|
||||||
{% if object_type != 'package' and object_type != 'user' and object_type != 'group' %}
|
if (el._x_dataStack && el._x_dataStack[0]) {
|
||||||
{% if reviewed_objects|length > 50 or unreviewed_objects|length > 50 %}
|
el._x_dataStack[0].search(query);
|
||||||
$('#load-all-loading').show()
|
}
|
||||||
|
|
||||||
setTimeout(function () {
|
|
||||||
$('#load-all-error').hide();
|
|
||||||
|
|
||||||
$.getJSON('?all=true', function (resp) {
|
|
||||||
$('#ReviewedContent').empty();
|
|
||||||
$('#UnreviewedContent').empty();
|
|
||||||
|
|
||||||
for (o in resp.objects) {
|
|
||||||
obj = resp.objects[o];
|
|
||||||
if (obj.reviewed) {
|
|
||||||
$('#ReviewedContent').append('<a class="list-group-item" href="' + obj.url + '">' + obj.name + ' <span class="glyphicon glyphicon-star" aria-hidden="true" style="float:right" data-toggle="tooltip" data-placement="top" title="" data-original-title="Reviewed"></span></a>');
|
|
||||||
} else {
|
|
||||||
$('#UnreviewedContent').append('<a class="list-group-item" href="' + obj.url + '">' + obj.name + '</a>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#load-all-loading').hide();
|
|
||||||
$('#load-remaining').hide();
|
|
||||||
}).fail(function (resp) {
|
|
||||||
$('#load-all-loading').hide();
|
|
||||||
$('#load-all-error').show();
|
|
||||||
});
|
|
||||||
|
|
||||||
}, 2500);
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
$('#modal-form-delete-submit').on('click', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
$('#modal-form-delete').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>
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
deleteForm.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock content %}
|
{% 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 %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user